Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
37 changes: 37 additions & 0 deletions changelog.d/10098-json-lazy-array-movable-and-brand.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
### Performance

- **Lazy JSON arrays are collectable by a minor, and indexed reads no longer re-classify
the receiver (#10098, #10118).**

Two independent costs on the same object. A lazy cluster was born into old-gen and
pinned there, so a **dead** one held its whole element graph live through the
remembered set until a full collection — which on `records_array_16k:scan` never
arrived: the arena rebaselined its own trigger 134M->268M->536M->1073M while
`old_in_use` climbed past 48 MB, and every minor reported `survival_permille=996`,
`copied_objects=0`, `freed_bytes=0`.

`GC_TYPE_LAZY_ARRAY` was pinned for two concrete reasons, both removed the way
`GC_TYPE_REGEXP` removed its own: `json_tape_store` keyed a tape by its owner's
address (now rekeyed through `json_tape_store::owner_moved` +
`GcMoveHookKind::LazyArrayTape`), and the copying minor's flip ran no per-object
finalize hook, so a header dying young leaked its tape (now
`finalize_dead_copied_minor_from_space_lazy_tapes`, wired in beside
map/set/errors/regex). The cluster's generation is still decided ONCE, by cache size
against the pointer-bearing threshold, so #7546's rule that header, cache and bitmap
share a generation holds; large clusters stay old exactly as before.

Separately, the indexed inline cache's brand check rejected `GC_TYPE_LAZY_ARRAY`
outright, forcing every read through four layers of re-classification. The brand test
now decides on the **tag alone**, with the kind and index guards moved into their own
block, and the shared pointer proof is computed once in the entry block instead of per
tier.

| row | before | after | vs Node | vs Bun |
|---|---:|---:|---:|---:|
| `records_array_16k:scan` peak RSS | 205 MiB | **51 MiB** | below | below |
| `records_array_1m:scan` peak RSS | 159 MiB | **75 MiB** | below | below |
| `records_array_16k:scan` CPU | — | **-10.1%** | | |
| `records_array_1m:scan` CPU | — | **-5.5%** | | |
| `records_array_20m:repeat` | — | — | | **0.93x** |

The access window showed zero separated regressions.
26 changes: 21 additions & 5 deletions crates/perry-codegen/src/expr/index_get/inline_dyn_typed_array.rs
Original file line number Diff line number Diff line change
Expand Up @@ -106,15 +106,33 @@ pub(super) fn lower_inline_dyn_typed_array_get(
// ABA-proof for a value held by live code: the arena rewrites `obj_type`
// before it hands the address out again, and a live reference keeps the
// typed array alive.
// The brand test is the FIRST thing every indexed read on an unknown
// receiver executes, and until #10118 it computed the whole guard set --
// element kind, both index range checks, three ANDs -- before finding out
// the receiver was not a typed array at all. A `JSON.parse` array, and any
// ordinary Array behind an erased receiver, paid that on every element
// read forever. Decide on the tag alone and leave; the rest of the guard
// set is only meaningful once the tag says typed array.
let kind_guard_idx = ctx.new_block("tav.get.kind_guard");
let kind_guard_label = ctx.block_label(kind_guard_idx);
ctx.current_block = brand_idx;
let entry_guard = {
let is_typed_array = {
let blk = ctx.block();
let obj_bits = blk.bitcast_double_to_i64(obj_box);
let raw = blk.and(I64, &obj_bits, pointer_mask);
let gc_type_addr = blk.sub(I64, &raw, "8");
let gc_type_ptr = blk.inttoptr(I64, &gc_type_addr);
let gc_type = blk.load(I8, &gc_type_ptr);
let is_typed_array = blk.icmp_eq(I8, &gc_type, "11"); // GC_TYPE_TYPED_ARRAY
blk.icmp_eq(I8, &gc_type, "11") // GC_TYPE_TYPED_ARRAY
};
ctx.block()
.cond_br(&is_typed_array, &kind_guard_label, &slow_label);

ctx.current_block = kind_guard_idx;
let entry_guard = {
let blk = ctx.block();
let obj_bits = blk.bitcast_double_to_i64(obj_box);
let raw = blk.and(I64, &obj_bits, pointer_mask);
let kind_addr = blk.add(I64, &raw, "8");
let kind_ptr = blk.inttoptr(I64, &kind_addr);
let kind_i8 = blk.load(I8, &kind_ptr);
Expand All @@ -127,9 +145,7 @@ pub(super) fn lower_inline_dyn_typed_array_get(
// result is never poison there.
let idx_ge0 = blk.fcmp("oge", idx_d, "0.0");
let idx_lt = blk.fcmp("olt", idx_d, "4294967296.0");
// AND-reduce all guards.
let g = blk.and(I1, &is_typed_array, &kind_ok);
let g = blk.and(I1, &g, &idx_ge0);
let g = blk.and(I1, &kind_ok, &idx_ge0);
blk.and(I1, &g, &idx_lt)
};
ctx.block().cond_br(&entry_guard, &fast_label, &slow_label);
Expand Down
10 changes: 9 additions & 1 deletion crates/perry-runtime/src/gc/copying_phase.rs
Original file line number Diff line number Diff line change
Expand Up @@ -99,7 +99,7 @@ impl CopyingMinorPhaseDiag {
let mut out = String::new();
write!(
out,
"root_scan={}/{} copy_evacuation={}/{}/{} remembered_set_young_logs={}/{}/{} promotion={}/{}/{} dead_owner_side_table_pruning={}{} from_space_finalization={}/map:{}/{}+set:{}/{}+errors:{}/{}+regex:{}/{} forwarding_fixups={} block_reset_flip={} other={} phase_sum_us={}",
"root_scan={}/{} copy_evacuation={}/{}/{} remembered_set_young_logs={}/{}/{} promotion={}/{}/{} dead_owner_side_table_pruning={}{} from_space_finalization={}/map:{}/{}+set:{}/{}+errors:{}/{}+regex:{}/{}+lazytape:{}/{} forwarding_fixups={} block_reset_flip={} other={} phase_sum_us={}",
self.root_scan_ns / 1000,
scan_us,
self.copy_evacuation_ns / 1000,
Expand All @@ -122,6 +122,8 @@ impl CopyingMinorPhaseDiag {
finalization.errors,
finalization.regex_ns / 1000,
finalization.regexps,
finalization.lazy_tape_ns / 1000,
finalization.lazy_tapes,
self.forwarding_fixups_ns / 1000,
self.block_reset_flip_ns / 1000,
other_ns / 1000,
Expand All @@ -145,6 +147,8 @@ pub(super) struct CopiedMinorFinalizationDiag {
pub(super) regexps: usize,
pub(super) dead_owner_ns: u64,
pub(super) dead_owner_detail: String,
pub(super) lazy_tapes: usize,
pub(super) lazy_tape_ns: u64,
}

/// Finalize the side allocations whose from-space owners just died. The
Expand Down Expand Up @@ -175,6 +179,10 @@ pub(super) fn finalize_dead_copied_minor_from_space_side_allocations() -> Copied
out.regexps = crate::regex::finalize_dead_copied_minor_from_space_regexps();
out.regex_ns = start.map_or(0, |start| start.elapsed().as_nanos() as u64);

let start = diag.then(Instant::now);
out.lazy_tapes = crate::json_tape_store::finalize_dead_copied_minor_from_space_lazy_tapes();
out.lazy_tape_ns = start.map_or(0, |start| start.elapsed().as_nanos() as u64);

let start = diag.then(Instant::now);
out.dead_owner_detail = super::dead_owner::prune_dead_owner_side_tables_copied_minor();
out.dead_owner_ns = start.map_or(0, |start| start.elapsed().as_nanos() as u64);
Expand Down
14 changes: 9 additions & 5 deletions crates/perry-runtime/src/gc/tests/alloc.rs
Original file line number Diff line number Diff line change
Expand Up @@ -481,10 +481,14 @@ fn test_gc_type_metadata_covers_all_declared_types() {
arena_walkable: true,
rewrite_descriptor_kind: GcRewriteDescriptorKind::LazyArray,
layout_slot_kind: GcLayoutSlotKind::None,
// #7539: NOT movable. The tape registry is keyed by the header
// address, and callers outside `json_tape` hold raw header
// pointers across allocations.
movable: false,
// Movable since the tape registration follows its owner
// (`GcMoveHookKind::LazyArrayTape`) and a header dying in a
// copying minor's from-space gives its tape back
// (`finalize_dead_copied_minor_from_space_lazy_tapes`). Those two
// were the whole reason for `false`; pinning cost a dead cluster's
// entire element graph, held live through the remembered set until
// a full collection.
movable: true,
// #7539: the tape is a `json_tape_store` side allocation, not
// inline payload. Inline, it made the header as large as the tape
// (~2.4 MB on a 10k-record blob), which `arena_alloc_gc` routed
Expand All @@ -493,7 +497,7 @@ fn test_gc_type_metadata_covers_all_declared_types() {
external_byte_policy: GcExternalBytePolicy::SideAllocation,
large_object_policy: GcLargeObjectPolicy::OldArenaWhenOverThreshold,
pointer_free: false,
move_hook_kind: GcMoveHookKind::None,
move_hook_kind: GcMoveHookKind::LazyArrayTape,
rewrite_hook_kind: GcRewriteHookKind::None,
finalize_hook_kind: GcFinalizeHookKind::LazyArrayTape,
},
Expand Down
40 changes: 22 additions & 18 deletions crates/perry-runtime/src/gc/tests/lazy_tape_side_alloc.rs
Original file line number Diff line number Diff line change
Expand Up @@ -133,22 +133,25 @@ fn test_old_generation_growth_does_not_scale_with_tape_size() {
);
}

/// The header allocation no longer scales with the tape — but it stays in the
/// OLD generation and born tenured, exactly where a multi-megabyte inline-tape
/// header always landed.
/// The header allocation no longer scales with the tape, and a LARGE cluster
/// still lands in the old generation born tenured.
///
/// That is the load-bearing half of this test, not a leftover. `json_tape_store`
/// keys a tape by its owner's address, and every caller outside `json_tape`
/// holds raw `*mut LazyArrayHeader` across allocations —
/// `json::stringify_api::try_stringify_lazy_array` reads `blob_bytes` off a raw
/// header and then allocates the result string. Letting the shrunken header
/// fall into the nursery made it movable for the first time and the copying
/// minor relocated it out from under those callers: `field_access` went
/// non-deterministic, emitting a JSON string of NUL bytes for
/// `JSON.stringify(parsed)` on 3 of 60 iterations. If a future change routes
/// the header allocation back through `arena_alloc_gc`, this fails.
/// It stays there on size now, not on principle. The old-gen request used to be
/// unconditional because `json_tape_store` keys a tape by its owner's address
/// and the copying minor's flip runs no finalize hook, so a nursery header
/// would orphan or leak its tape — `JSON.stringify(parsed)` emitted a string of
/// NUL bytes on 3 of 60 `field_access` iterations when that was tried.
/// `GcMoveHookKind::LazyArrayTape` and
/// `json_tape_store::finalize_dead_copied_minor_from_space_lazy_tapes` remove
/// both reasons, so the generation is decided by cache size — and #7546's rule
/// that header, cache and bitmap share one generation still decides it once.
///
/// This fixture is `big_blob()`, whose cache is far over the large-object line,
/// so it must still be old: if a future change made even a large cluster
/// nursery-resident, the promotion behaviour this test pins would stop being
/// exercised.
#[test]
fn test_lazy_header_is_small_but_stays_old_gen_and_immovable() {
fn test_large_lazy_cluster_is_still_born_old_and_tenured() {
let _guard = GcTestIsolationGuard::new();
let blob = big_blob();
let tape_bytes = tape_bytes_of(&blob);
Expand All @@ -158,12 +161,13 @@ fn test_lazy_header_is_small_but_stays_old_gen_and_immovable() {

assert!(
crate::arena::pointer_in_old_gen(lazy as usize),
"the header must stay old-gen: callers outside json_tape hold raw \
header pointers across allocations"
"a cluster this large must still be born old — otherwise the \
large-object promotion path here stops being exercised"
);
assert!(
!crate::gc::gc_type_is_movable(crate::gc::GC_TYPE_LAZY_ARRAY),
"a lazy array must not be movable — its tape is keyed by its address"
crate::gc::gc_type_is_movable(crate::gc::GC_TYPE_LAZY_ARRAY),
"the type is movable now: the tape registration follows its owner and \
a from-space death gives the tape back"
);
unsafe {
let header = (lazy as *const u8).sub(GC_HEADER_SIZE) as *const GcHeader;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -270,19 +270,25 @@ fn test_json_tape_lazy_get_header_handle_survives_copied_minor_gc() {
JsonTapeSafepointHookGuard::new(crate::json_tape::JsonTapeSafepoint::LazyArrayRooted);
let hdr = unsafe { test_alloc_lazy_json_array(input) };
let original_hdr = hook.fired_ptr();
// #7539: the header is old-gen and immovable by construction, so a
// copied minor at the safepoint CANNOT relocate it — that is the
// property `try_stringify_lazy_array` and the array accessors rely on
// when they hold a raw header across an allocation. What must still be
// true is that `alloc_lazy_array` hands back the address the collector
// sees, i.e. the one its own rooted handle resolves to.
assert_eq!(
// A small lazy cluster is nursery-resident and movable now: pinning it
// meant a minor could never reclaim a dead one, and it held its whole
// element graph live through the remembered set. So the header DOES
// relocate here — the hook observed the address before the collection
// it triggered — and what `alloc_lazy_array` owes its caller is the
// REFRESHED address, read back through its own rooted handle.
assert!(
!crate::arena::pointer_in_old_gen(hdr as usize),
"a two-element cluster is small, so it must be nursery-resident"
);
assert_ne!(
hdr as usize, original_hdr,
"the lazy header must not move across a copied-minor GC"
"a nursery header must relocate across the safepoint, or this test \
proves nothing about the refresh"
);
assert!(
crate::arena::pointer_in_old_gen(hdr as usize),
"…because it is old-gen, which is what makes that guaranteed"
assert_eq!(
unsafe { (*hdr).magic },
crate::json_tape::LAZY_ARRAY_MAGIC,
"the returned address must be the live header, not the stale one"
);
hdr
};
Expand All @@ -293,11 +299,18 @@ fn test_json_tape_lazy_get_header_handle_survives_copied_minor_gc() {
let value = unsafe { crate::json_tape::lazy_get(hdr_handle.get_raw_mut_ptr(), 0) };
let original_hdr = hook.fired_ptr();
let hdr_after = hdr_handle.get_raw_mut_ptr::<crate::json_tape::LazyArrayHeader>();
assert_eq!(
assert_ne!(
hdr_after as usize, original_hdr,
"the lazy header must not move across a copied-minor GC (#7539)"
"a nursery header must relocate across lazy_get's safepoint too"
);
unsafe {
assert_eq!(
(*hdr_after).magic,
crate::json_tape::LAZY_ARRAY_MAGIC,
"the handle must resolve to the live header after the move"
);
// The cache the relocated header points at must be the one lazy_get
// wrote through: a stale cache edge would read back as an empty bitmap.
let bitmap = (*hdr_after).materialized_bitmap;
assert!(!bitmap.is_null());
assert_ne!(*bitmap & 1, 0, "cold lazy_get should cache element 0");
Expand Down Expand Up @@ -375,10 +388,16 @@ fn test_json_tape_lazy_get_records_its_cache_store_as_an_external_edge() {
// Born-old header. That is the shape the #7538 workload had and the only
// one where the in-object/external distinction bites — a nursery header is
// traced directly and its descriptor reaches the cache without any
// remembered-set entry at all. #7539 moved the tape into a side allocation
// but deliberately kept the header in the old generation, so this premise
// still holds by construction rather than by the header being large.
let elements = 4096;
// remembered-set entry at all.
//
// The cluster's generation is decided by its cache size now, against the
// POINTER-BEARING threshold (128 KB), so the premise has to be bought with
// element count rather than assumed: 20 000 JSValues is ~156 KB, safely
// over the line. 4096 elements used to suffice only because every lazy
// header was born old unconditionally, and at 32 KB it would now be a
// NURSERY cluster — this test would still pass its later assertions while
// exercising none of the containment branch it exists for.
let elements = 20_000;
let mut input = String::with_capacity(elements * 8 + 2);
input.push('[');
for i in 0..elements {
Expand Down Expand Up @@ -492,13 +511,19 @@ fn test_json_tape_force_materialize_sparse_cache_handles_survive_copied_minor_gc
);
let original_arr = hook.fired_ptr();
let hdr_after = hdr_handle.get_raw_mut_ptr::<crate::json_tape::LazyArrayHeader>();
assert_eq!(
// A four-element cluster is nursery-resident, so the header relocates here
// as well: the rooted handle, not the address, is what keeps a caller right.
assert_ne!(
hdr_after as usize, before_force_hdr,
"the lazy header must not move across a copied-minor GC (#7539)"
"a small lazy header is young, so force materialization must relocate it"
);
assert_eq!(
unsafe { (*hdr_after).magic },
crate::json_tape::LAZY_ARRAY_MAGIC,
"…and the handle must still resolve to the live header"
);
// The MATERIALIZED ARRAY is young and does move — which is the handle
// refresh this test is really about, and the reason the header being
// stable does not make it vacuous.
// The MATERIALIZED ARRAY moves too, and its handle must refresh for the
// same reason.
assert_ne!(
arr as usize, original_arr,
"force materialization should refresh the rooted array handle"
Expand Down
Loading
Loading