From 2b4751bb35bb45e9744c971b754b8d49732dc2f8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ralph=20K=C3=BCpper?= Date: Sun, 13 Sep 2026 09:24:07 +0200 Subject: [PATCH 1/2] perf(gc): birth a large JSON leaf in the arena when the young generation already holds it JSON results at or above 512 KB are malloc-tracked so that the next minor can reclaim a discarded result without a whole-old-heap trace. That trade inverts when the young generation already holds at least as many bytes as the leaf, which is the shape of a freshly parsed document the caller is about to stringify: a non-empty malloc registry forbids the untraced in-place promotion, so the next minor traces the whole tree (55 ms for a 20 MB document, 52% of the roundtrip's wall time) to reclaim one leaf. Such a leaf is now born old in the arena instead. The tree promotes untraced, and the leaf is reclaimed by the old-reclaim full that has to mark the tree anyway. Results allocated while the young generation is small keep the malloc path, and only tracked leaves charge malloc-output debt. --- crates/perry-runtime/src/gc/mod.rs | 9 +++ crates/perry-runtime/src/gc/policy.rs | 44 +++++++++++- .../perry-runtime/src/gc/promote_in_place.rs | 25 ++++++- crates/perry-runtime/src/gc/tests/mod.rs | 1 + .../src/gc/tests/young_leaf_route.rs | 67 +++++++++++++++++++ crates/perry-runtime/src/json/mod.rs | 6 +- .../perry-runtime/src/json/stringify_flat.rs | 4 +- .../src/json/stringify_record_output.rs | 12 ++-- .../src/json/stringify_string_tests.rs | 60 +++++++++++++++++ .../src/string/json_construction.rs | 3 +- crates/perry-runtime/src/string/mod.rs | 47 +++++++++++-- scripts/gc_runtime_root_holders.json | 12 +++- 12 files changed, 268 insertions(+), 22 deletions(-) create mode 100644 crates/perry-runtime/src/gc/tests/young_leaf_route.rs diff --git a/crates/perry-runtime/src/gc/mod.rs b/crates/perry-runtime/src/gc/mod.rs index 9223e1c058..2098869ce1 100644 --- a/crates/perry-runtime/src/gc/mod.rs +++ b/crates/perry-runtime/src/gc/mod.rs @@ -39,9 +39,11 @@ mod json_defer; mod policy; pub(crate) use json_defer::JsonParseAllocation; pub(crate) use policy::gc_runtime_safepoint; +pub(crate) use policy::note_young_leaf_born_old; /// The one writer of `GC_SAFEPOINT_PENDING` — it also keeps the poll's global /// arming shadow in step. See `gc/poll_arm.rs`. pub(crate) use policy::set_safepoint_pending; +pub(crate) use policy::young_generation_holds_a_nursery; pub use policy::*; mod progress; pub use progress::*; @@ -231,10 +233,17 @@ mod native_stack_scan; /// mechanism is `arena/promote.rs`; this decides when to use it. mod promote_in_place; use promote_in_place::*; +#[cfg(test)] +pub(crate) use promote_in_place::{ + clear_young_survival_for_tests, last_young_survival_permille, seed_young_survival_for_tests, +}; pub use promote_in_place::{ first_cycle_promotion_attempts, first_cycle_promotion_rollbacks, in_place_promoted_objects, in_place_promotion_cycles, untraced_promoted_objects, untraced_promotion_cycles, }; +pub(crate) use promote_in_place::{ + young_generation_measured_dying, young_generation_measured_retained, +}; /// Instrument-liveness counters (#7604): copying minors completed, objects /// relocated, loop back-edge polls reached. Mode-independent — they count what /// the COLLECTOR did, not what forced it, so they outlive any one stress knob. diff --git a/crates/perry-runtime/src/gc/policy.rs b/crates/perry-runtime/src/gc/policy.rs index b2a25b101c..6a772c0b14 100644 --- a/crates/perry-runtime/src/gc/policy.rs +++ b/crates/perry-runtime/src/gc/policy.rs @@ -120,6 +120,19 @@ pub(super) fn young_scavenge_cap_due() -> bool { from_space_in_use >= scavenge_nursery_cap_dueness_bytes() } +/// #10169: does the young generation hold at least one BASE nursery cap of +/// bytes — a minor's worth of data, whatever the adaptive cap currently says? +/// Read by `string::json_leaf_prefers_arena` from inside a suppressed +/// construction window. The base cap rather than [`young_scavenge_cap_due`]'s +/// adaptive one on purpose: after a fully-live minor the adaptive cap scales +/// past the very tree that made it scale, and a gate keyed on it would flip +/// the next leaf back to the malloc registry, where one tracked leaf is enough +/// to veto the untraced promotion the route exists to enable. +pub(crate) fn young_generation_holds_a_nursery() -> bool { + nursery_cap_active() + && crate::arena::copying_from_space_in_use_bytes() >= gc_scavenge_nursery_cap_bytes() +} + /// The cap value [`young_scavenge_cap_due`] compares against. /// /// Split out only so a test can make the cap due without allocating the real @@ -1085,6 +1098,9 @@ crate::perry_thread_local! { pub(super) static GC_DEFERRED_REQUEST: Cell = const { Cell::new(DeferredGcRequest::None) }; pub(super) static GC_OLD_RECLAIM_PENDING: Cell = const { Cell::new(false) }; + /// #10169: a document-sized JSON leaf was born old under young pressure + /// since the last trigger decision (`note_young_leaf_born_old`). + pub(super) static GC_YOUNG_LEAF_BORN_OLD: Cell = const { Cell::new(false) }; pub(super) static GC_LAST_OLD_RECLAIM_IN_USE_BYTES: Cell = const { Cell::new(0) }; /// Live allocated arena bytes measured right after the last FULL /// mark-sweep — the baseline for major-GC pacing @@ -3117,7 +3133,7 @@ struct BudgetedGcCycle { } #[derive(Clone, Copy, Debug, PartialEq, Eq)] -enum BudgetedGcTrigger { +pub(super) enum BudgetedGcTrigger { OldReclaim, ArenaBytes, /// The young-generation scavenge cap ([`young_scavenge_cap_due`]). @@ -3230,7 +3246,31 @@ pub(crate) fn trigger_path_hot_slot_indices() -> Vec<(&'static str, u32)> { ] } -fn gc_budgeted_due_trigger() -> Option { +/// #10169: record that a document-sized JSON leaf was just born old in the +/// arena because the young generation is at least that large and has not been +/// measured as dying (`string::json_leaf_prefers_arena`). Read once by the +/// next trigger decision. +pub(crate) fn note_young_leaf_born_old() { + GC_YOUNG_LEAF_BORN_OLD.with(|flag| flag.set(true)); +} + +pub(super) fn gc_budgeted_due_trigger() -> Option { + // #10169: a leaf born old under young pressure gives the nursery minor + // ONE-TIME priority over old-reclaim, and only while the young generation + // is still unmeasured. A young generation that a minor has already + // measured as retained wholesale is either promoted (so the next leaf + // finds it small) or, when it dies at every loop edge, best left to the + // old-reclaim full that sweeps it in Eden together with the leaf. The one + // case that must not fall through is an unmeasured young generation that + // stays live: old-reclaim would re-mark it in place at every full, so it + // is promoted by a minor first. The flag is consumed here whatever the + // decision, so it can never starve old-reclaim. + if GC_YOUNG_LEAF_BORN_OLD.with(Cell::get) { + GC_YOUNG_LEAF_BORN_OLD.with(|flag| flag.set(false)); + if !super::young_generation_measured_retained() && young_scavenge_cap_due() { + return Some(BudgetedGcTrigger::YoungScavengeCap); + } + } let old_pending = GC_OLD_RECLAIM_PENDING.with(Cell::get); // #6010: external Map/Set side-buffer bytes escalate to OldReclaim too. let old_in_use = diff --git a/crates/perry-runtime/src/gc/promote_in_place.rs b/crates/perry-runtime/src/gc/promote_in_place.rs index 9e36e15ad0..5f2fe7d5b0 100644 --- a/crates/perry-runtime/src/gc/promote_in_place.rs +++ b/crates/perry-runtime/src/gc/promote_in_place.rs @@ -577,6 +577,27 @@ pub(crate) fn last_young_survival_permille() -> Option { LAST_YOUNG_SURVIVAL_PERMILLE.with(Cell::get) } +/// #10169: did the previous copying minor measure the young generation as +/// retained wholesale? This is the in-place promotion signal read directly, +/// without the test-build opt-in that gates the promotion itself, so a +/// scheduling decision elsewhere can key on the measurement alone. `None` — +/// no copying minor has run on this thread — is deliberately `false`. +pub(crate) fn young_generation_measured_retained() -> bool { + LAST_YOUNG_SURVIVAL_PERMILLE + .with(Cell::get) + .is_some_and(|permille| permille >= PROMOTE_SURVIVAL_THRESHOLD_PERMILLE) +} + +/// #10169: the complement that is NOT `!measured_retained`: the previous +/// copying minor measured the young generation as mostly garbage. `None` — no +/// measurement yet — is `false` here too, so an unmeasured young generation +/// is neither retained nor dying. +pub(crate) fn young_generation_measured_dying() -> bool { + LAST_YOUNG_SURVIVAL_PERMILLE + .with(Cell::get) + .is_some_and(|permille| permille < PROMOTE_SURVIVAL_THRESHOLD_PERMILLE) +} + #[cfg(test)] pub(crate) fn promoted_dead_bytes_since_full() -> usize { PROMOTED_DEAD_BYTES.with(Cell::get) @@ -644,12 +665,12 @@ impl Drop for InPlacePromotionTestGuard { /// MEASUREMENT of "almost nothing survived", and only this exercises the /// `None` arm of the decision. #[cfg(test)] -pub(super) fn clear_young_survival_for_tests() { +pub(crate) fn clear_young_survival_for_tests() { LAST_YOUNG_SURVIVAL_PERMILLE.with(|c| c.set(None)); } #[cfg(test)] -pub(super) fn seed_young_survival_for_tests(permille: u64) { +pub(crate) fn seed_young_survival_for_tests(permille: u64) { LAST_YOUNG_SURVIVAL_PERMILLE.with(|c| c.set(Some(permille))); } diff --git a/crates/perry-runtime/src/gc/tests/mod.rs b/crates/perry-runtime/src/gc/tests/mod.rs index 85f22246c4..635f3af432 100644 --- a/crates/perry-runtime/src/gc/tests/mod.rs +++ b/crates/perry-runtime/src/gc/tests/mod.rs @@ -74,4 +74,5 @@ mod triggers; mod typed_layout_intact_residual; mod u8_inline_cache; mod weak_read_barrier; +mod young_leaf_route; mod young_log_tests; diff --git a/crates/perry-runtime/src/gc/tests/young_leaf_route.rs b/crates/perry-runtime/src/gc/tests/young_leaf_route.rs new file mode 100644 index 0000000000..2865f520b5 --- /dev/null +++ b/crates/perry-runtime/src/gc/tests/young_leaf_route.rs @@ -0,0 +1,67 @@ +//! #10169: a document-sized JSON leaf born old under young pressure gives the +//! nursery minor one-time priority over old-reclaim, and only while the young +//! generation is unmeasured. Both halves are asserted: the priority fires +//! exactly once per leaf, and a measured young generation buys none. + +use super::super::policy::{ + gc_budgeted_due_trigger, note_young_leaf_born_old, BudgetedGcTrigger, + ScavengeNurseryCapTestGuard, GC_OLD_RECLAIM_PENDING, +}; +use super::super::*; +use super::support::*; + +#[test] +fn young_leaf_born_old_prioritises_the_nursery_minor_until_measured() { + let _isolation = GcTestIsolationGuard::new(); + let _pacing = crate::gc::policy::force_moving_gc_pacing(); + let _triggers = GcTriggerThresholdTestGuard::suppress_automatic_triggers(); + let _cap_due = ScavengeNurseryCapTestGuard::due_at_bytes(1); + // The isolated arena starts empty; one young allocation makes "due at one + // byte" actually due. + let filler = [b'y'; 64]; + crate::string::js_string_from_bytes(filler.as_ptr(), filler.len() as u32); + assert!( + crate::arena::copying_from_space_in_use_bytes() >= 1, + "fixture: the young generation must hold something for the cap to be due" + ); + let previous_survival = last_young_survival_permille(); + GC_OLD_RECLAIM_PENDING.with(|pending| pending.set(true)); + assert_eq!( + gc_budgeted_due_trigger(), + Some(BudgetedGcTrigger::OldReclaim), + "fixture: old-reclaim must be due before the leaf can outrank it" + ); + + // Unmeasured young generation: the leaf buys the nursery minor exactly one + // decision, then old-reclaim is back. + clear_young_survival_for_tests(); + note_young_leaf_born_old(); + assert_eq!( + gc_budgeted_due_trigger(), + Some(BudgetedGcTrigger::YoungScavengeCap) + ); + assert_eq!( + gc_budgeted_due_trigger(), + Some(BudgetedGcTrigger::OldReclaim) + ); + + // Measured as retained: no priority, and the flag is still consumed. + seed_young_survival_for_tests(999); + note_young_leaf_born_old(); + assert_eq!( + gc_budgeted_due_trigger(), + Some(BudgetedGcTrigger::OldReclaim) + ); + clear_young_survival_for_tests(); + assert_eq!( + gc_budgeted_due_trigger(), + Some(BudgetedGcTrigger::OldReclaim), + "a consumed flag must not be honoured later" + ); + + GC_OLD_RECLAIM_PENDING.with(|pending| pending.set(false)); + match previous_survival { + Some(permille) => seed_young_survival_for_tests(permille), + None => clear_young_survival_for_tests(), + } +} diff --git a/crates/perry-runtime/src/json/mod.rs b/crates/perry-runtime/src/json/mod.rs index 0951812113..3b31868919 100644 --- a/crates/perry-runtime/src/json/mod.rs +++ b/crates/perry-runtime/src/json/mod.rs @@ -600,13 +600,15 @@ pub(crate) fn json_string_from_native_output_bytes(bytes: &[u8]) -> *mut StringH crate::string::compute_utf16_len(bytes.as_ptr(), len) }; stringify_flat::service_json_output_sweep_boundary(); - let (ptr, data) = crate::string::json_output_storage_alloc(len); + let (ptr, data, malloc_tracked) = crate::string::json_output_storage_alloc(len); unsafe { crate::string::init_string_header(ptr, utf16_len, len, len, 0, 0); // GC_STORE_AUDIT(POINTER_FREE): completed JSON payload bytes. std::ptr::copy_nonoverlapping(bytes.as_ptr(), data, len as usize); } - stringify_flat::note_completed_malloc_json_output(len); + if malloc_tracked { + stringify_flat::note_completed_malloc_json_output(len); + } ptr } diff --git a/crates/perry-runtime/src/json/stringify_flat.rs b/crates/perry-runtime/src/json/stringify_flat.rs index 08b688fbee..84dbf69b85 100644 --- a/crates/perry-runtime/src/json/stringify_flat.rs +++ b/crates/perry-runtime/src/json/stringify_flat.rs @@ -416,7 +416,7 @@ unsafe fn emit_two_field_parsed_string_object( let large_output = bytes >= JSON_MALLOC_OUTPUT_THRESHOLD; let construction = large_output.then(crate::gc::GcSuppressScope::new); - let (result, output) = json_output_storage_alloc(bytes); + let (result, output, malloc_tracked) = json_output_storage_alloc(bytes); let value = input.with_const_ptr(|obj: *const crate::ObjectHeader| { let keys = crate::object::object_keys_array(obj); init_string_header(result, units, bytes, bytes, 0, 0); @@ -449,7 +449,7 @@ unsafe fn emit_two_field_parsed_string_object( Some(JSValue::string_ptr(result)) }); drop(construction); - if large_output { + if malloc_tracked { note_completed_malloc_json_output(bytes); } value diff --git a/crates/perry-runtime/src/json/stringify_record_output.rs b/crates/perry-runtime/src/json/stringify_record_output.rs index 582fb35cc2..889b9429f1 100644 --- a/crates/perry-runtime/src/json/stringify_record_output.rs +++ b/crates/perry-runtime/src/json/stringify_record_output.rs @@ -460,7 +460,7 @@ unsafe fn emit_cached_record_uncached( return None; } let construction = large_output.then(crate::gc::GcSuppressScope::new); - let (result, output) = json_output_storage_alloc(bytes); + let (result, output, malloc_tracked) = json_output_storage_alloc(bytes); init_string_header(result, units, bytes, bytes, 0, 0); let value = input.with_const_ptr(|obj: *const crate::ObjectHeader| { let mut at = 0usize; @@ -505,7 +505,7 @@ unsafe fn emit_cached_record_uncached( Some(JSValue::string_ptr(result)) }); drop(construction); - if large_output { + if malloc_tracked { super::stringify_flat::note_completed_malloc_json_output(bytes); } value @@ -562,7 +562,7 @@ unsafe fn emit_cached_record_memo( } let repeated_candidate = bytes as usize <= MAX_REPEATED_OUTPUT_BYTES; let construction = large_output.then(crate::gc::GcSuppressScope::new); - let (result, output) = json_output_storage_alloc(bytes); + let (result, output, malloc_tracked) = json_output_storage_alloc(bytes); init_string_header(result, units, bytes, bytes, 0, 0); let value = input.with_const_ptr(|obj: *const crate::ObjectHeader| { let mut at = 0usize; @@ -665,7 +665,7 @@ unsafe fn emit_cached_record_memo( Some(JSValue::string_ptr(result)) }); drop(construction); - if large_output { + if malloc_tracked { super::stringify_flat::note_completed_malloc_json_output(bytes); } value @@ -745,7 +745,7 @@ unsafe fn emit_record(obj: *const crate::ObjectHeader, fields: usize) -> Option< return None; } let construction = large_output.then(crate::gc::GcSuppressScope::new); - let (result, output) = json_output_storage_alloc(bytes); + let (result, output, malloc_tracked) = json_output_storage_alloc(bytes); init_string_header(result, units, bytes, bytes, 0, 0); let value = input.with_const_ptr(|obj: *const crate::ObjectHeader| { let keys = crate::object::object_keys_array(obj); @@ -797,7 +797,7 @@ unsafe fn emit_record(obj: *const crate::ObjectHeader, fields: usize) -> Option< Some(JSValue::string_ptr(result)) }); drop(construction); - if large_output { + if malloc_tracked { super::stringify_flat::note_completed_malloc_json_output(bytes); } value diff --git a/crates/perry-runtime/src/json/stringify_string_tests.rs b/crates/perry-runtime/src/json/stringify_string_tests.rs index 7806aa07da..bc3a14b0b5 100644 --- a/crates/perry-runtime/src/json/stringify_string_tests.rs +++ b/crates/perry-runtime/src/json/stringify_string_tests.rs @@ -98,3 +98,63 @@ fn direct_quoted_raw_strings_preserve_fallback_length_semantics() { } } } + +/// #10169: a large leaf stays malloc-tracked while the young generation is +/// smaller than the leaf or was measured as mostly garbage; it is born old in +/// the arena when the young generation holds at least the leaf's own bytes +/// and is unmeasured or measured as retained — the shape of a freshly parsed +/// document whose result the caller is about to stringify. +#[test] +fn large_json_leaf_routes_by_young_generation_occupancy() { + if !crate::gc::gen_gc_enabled() { + return; + } + let previous_survival = crate::gc::last_young_survival_permille(); + let _suppress = crate::gc::GcSuppressScope::new(); + let young_before = crate::arena::copying_from_space_in_use_bytes(); + let leaf = (young_before as u32 + (1 << 20)).max(crate::string::JSON_MALLOC_OUTPUT_THRESHOLD); + crate::gc::seed_young_survival_for_tests(999); + let (_, _, tracked_below) = crate::string::json_output_storage_alloc(leaf); + assert!( + tracked_below, + "a young generation smaller than the leaf keeps malloc tracking" + ); + + let filler = vec![b'y'; 1024]; + while crate::arena::copying_from_space_in_use_bytes() < leaf as usize { + crate::string::js_string_from_bytes(filler.as_ptr(), filler.len() as u32); + } + if !crate::gc::young_generation_holds_a_nursery() { + let (_, _, tracked_below_nursery) = crate::string::json_output_storage_alloc(leaf); + assert!( + tracked_below_nursery, + "a young generation below one nursery keeps malloc tracking" + ); + while !crate::gc::young_generation_holds_a_nursery() { + crate::string::js_string_from_bytes(filler.as_ptr(), filler.len() as u32); + } + } + crate::gc::clear_young_survival_for_tests(); + let (_, _, tracked_unmeasured) = crate::string::json_output_storage_alloc(leaf); + assert!( + !tracked_unmeasured, + "an unmeasured young generation at or above the leaf size births the leaf in the arena" + ); + crate::gc::seed_young_survival_for_tests(100); + let (_, _, tracked_dying) = crate::string::json_output_storage_alloc(leaf); + assert!( + tracked_dying, + "a young generation measured as mostly garbage keeps malloc tracking" + ); + crate::gc::seed_young_survival_for_tests(999); + let (_, _, tracked_retained) = crate::string::json_output_storage_alloc(leaf); + assert!( + !tracked_retained, + "a retained young generation at or above the leaf size births the leaf in the arena" + ); + + match previous_survival { + Some(permille) => crate::gc::seed_young_survival_for_tests(permille), + None => crate::gc::clear_young_survival_for_tests(), + } +} diff --git a/crates/perry-runtime/src/string/json_construction.rs b/crates/perry-runtime/src/string/json_construction.rs index f8770e8f8c..d3a1ec861b 100644 --- a/crates/perry-runtime/src/string/json_construction.rs +++ b/crates/perry-runtime/src/string/json_construction.rs @@ -23,7 +23,8 @@ pub(crate) unsafe fn string_from_json_bytes( }; let (header, data) = if raw.is_null() { if large_json_leaf { - json_output_storage_alloc(len) + let (header, data, _malloc_tracked) = json_output_storage_alloc(len); + (header, data) } else { string_storage_alloc(len) } diff --git a/crates/perry-runtime/src/string/mod.rs b/crates/perry-runtime/src/string/mod.rs index 24fb6f64e4..c02b69ed62 100644 --- a/crates/perry-runtime/src/string/mod.rs +++ b/crates/perry-runtime/src/string/mod.rs @@ -669,23 +669,62 @@ pub(crate) fn string_storage_alloc(capacity: u32) -> (*mut StringHeader, *mut u8 /// JSON results at or above this size use individually tracked storage. pub(crate) const JSON_MALLOC_OUTPUT_THRESHOLD: u32 = 512 * 1024; -/// Allocate a large, pointer-free JSON result outside old-generation arenas. +/// Allocate a large, pointer-free JSON result. The flag reports whether it is +/// malloc-tracked (`true`) or born old in the arena (`false`), so callers +/// charge malloc-output debt only for tracked leaves. /// /// Ordinary large strings are born old because copying them through survivor /// space is wasteful. Repeated `JSON.stringify` is different: each result is a /// leaf commonly discarded at the next loop edge. Tracking that leaf as an /// individual malloc object lets the next minor sweep reclaim it without a /// whole-old-heap trace. Smaller results retain the arena fast path. +/// +/// #10169: that trade inverts when the young generation already holds at +/// least as many bytes as the leaf — typically a freshly parsed document the +/// caller is still using — unless the previous minor measured the young +/// generation as mostly garbage. A non-empty malloc registry forbids the +/// untraced in-place promotion (`skip_remembering` needs it empty), so the +/// next minor would trace that whole tree — 55 ms for a 20 MB document — to +/// reclaim one leaf; and a single stale tracked leaf keeps vetoing it until +/// swept. Such a leaf is born old in the arena instead, and the collector is +/// told (`gc::note_young_leaf_born_old`) so that an UNMEASURED young +/// generation gets a minor before old-reclaim can re-mark it in place: a +/// stringify-only loop over one parsed input thus has its input promoted +/// once, after which the young generation is too small for this route, while +/// a parse/stringify loop's transient tree dies in Eden under the old-reclaim +/// full that also reclaims the leaf. #[inline] -pub(crate) fn json_output_storage_alloc(capacity: u32) -> (*mut StringHeader, *mut u8) { +pub(crate) fn json_output_storage_alloc(capacity: u32) -> (*mut StringHeader, *mut u8, bool) { if capacity < JSON_MALLOC_OUTPUT_THRESHOLD { - return string_storage_alloc(capacity); + let (ptr, data) = string_storage_alloc(capacity); + return (ptr, data, false); + } + if json_leaf_prefers_arena(capacity) { + let (ptr, data) = string_storage_alloc(capacity); + crate::gc::note_young_leaf_born_old(); + return (ptr, data, false); } let payload_size = std::mem::size_of::() + capacity as usize; let raw = crate::gc::gc_malloc(payload_size, crate::gc::GC_TYPE_STRING); let ptr = raw as *mut StringHeader; let data = unsafe { raw.add(std::mem::size_of::()) }; - (ptr, data) + (ptr, data, true) +} + +/// The young generation holds at least the leaf's own bytes and at least one +/// nursery's worth of data (so a minor is what comes next, which is the only +/// time tracking the leaf costs a whole-young trace), and was not measured as +/// dying. Below a nursery the malloc path stays: a loop that stringifies a +/// small parsed input into document-sized results never collects at all, and +/// born-old results would only trade malloc's recycled pages for fresh arena +/// pages (measured: +17 % CPU on a 1 MB stringify loop). See +/// [`json_output_storage_alloc`]. +#[inline] +pub(crate) fn json_leaf_prefers_arena(capacity: u32) -> bool { + crate::gc::gen_gc_enabled() + && crate::arena::copying_from_space_in_use_bytes() >= capacity as usize + && crate::gc::young_generation_holds_a_nursery() + && !crate::gc::young_generation_measured_dying() } /// Maximum number of UTF-16 code units in one Perry string. Mirrors V8's diff --git a/scripts/gc_runtime_root_holders.json b/scripts/gc_runtime_root_holders.json index 93387d5fa6..f891006d32 100644 --- a/scripts/gc_runtime_root_holders.json +++ b/scripts/gc_runtime_root_holders.json @@ -307,7 +307,7 @@ "file": "crates/perry-runtime/src/gc/census.rs", "name": "PASS1_MARKED", "verdict": "non_moving_snapshot", - "why": "Real GC header addresses, deliberately untraced so the diagnostic does not keep its observed objects alive. Populated only at the end of mark propagation of a synchronous full cycle; consumed at sweep entry in the same run_to_completion invocation. The intervening full-cycle phases do not relocate or run JS callbacks. The Vec is used for membership comparisons and dropped with the census before sweep. Budgeted and minor cycles skip both boundaries. Pin re-audited 2026-09-05 after #9760 touched `gc/mod.rs`: that change is `mod heap_stats;` plus a `pub(crate) use` re-export and alters no mark/sweep control flow. `heap_stats()` is reached only from `js_bun_jsc_heap_stats` (the JS-facing `bun:jsc.heapStats()`), i.e. from mutator code, never inside a cycle, and its own module contract forbids allocation or collection during its walk. The mark-complete → sweep-entry window is unchanged. Re-audited 2026-09-05 (train125) after #9769 and #9771 touched pinned files. #9769 adds one `reg_scanner!` registration to `gc/mod.rs`; #9771 adds a feature-gated `alloc_census_init()` there and a feature-gated Rust-heap dump inside `take_census`. `alloc-census` is not in the default feature set, and decisively: `census_take_if_armed_at_full_sweep_start` does `PASS1_MARKED.with(|p| p.borrow_mut().take())` BEFORE calling `take_census`, so the snapshot has already left the thread-local by the time #9771's code runs — it cannot affect the window. Neither change alters mark/sweep control flow. Re-audited 2026-09-06 after #9831 touched `gc/policy.rs`. Its hunks are (a) the tiny-parse pressure guard's pricing (`tiny_parse_pressure_headroom_bytes`, `tiny_parse_pressure_due*`, a `Cell` byte-count base) consulted from JSON.parse's mutator-side boundaries (`gc_bump_malloc_trigger`, `gc_collect_pending_suppressed_parse`, `gc_schedule_parse_boundary_collection_if_pressure`), none of which is reachable from inside a cycle, and (b) one extra `Cell` store in `note_collection_finished_arena_occupancy`, which runs from `publish_reclaim_outcome` in the Publish subphase — after `step_sweep` has already consumed the snapshot. Mark/sweep control flow between `census_pass1_if_armed` and `census_take_if_armed_at_full_sweep_start` is untouched. Re-audited 2026-09-05 (train126) after #9755 restructured `gc/cycle.rs`. Its hunks are all root-scan machinery (`RootScanSubphase`, `RootScanCycleState`, the mutable-scanner iteration state), which runs BEFORE mark propagation completes; `gc/mod.rs` gains only a `mod young_log;` declaration. The bracketing is unchanged — `census_pass1_if_armed` is still inside `step_mark_propagation` and `census_take_if_armed_at_full_sweep_start` inside `step_sweep` — and a synchronous full mark-sweep still moves nothing between them. Re-pinned 2026-09-05 for the #9740 hot-TLS conversion of this file: the sole change is `thread_local!` → `crate::perry_thread_local!`, a macro-name swap with identical declaration syntax and `.with()` call sites. No control flow, no phase boundary, and no storage semantics change. Re-audited 2026-09-06 (train128) after #9794's GC diagnostics touched `gc/mod.rs` and `gc/policy.rs`: both gain diagnostic module declarations and counters only — no mark/sweep control flow, and the census bracketing in `step_mark_propagation` / `step_sweep` is unchanged. Re-audited for #9794's GC diagnostics: `gc/mod.rs` gains `mod diag_sites;` / `mod survival_diag;`, a re-export, a `diag_sites::full_started(...)` call at TRIGGER time (before mark propagation begins), and exit-time reporting. Nothing executes between mark-complete and sweep-entry, so the window is unchanged. Re-audited 2026-09-06 for the retained array-growth verifier fix: the cycle.rs change passes the existing non-copying evacuation verifier an explicit all-forwarded policy. That call remains in minor finalization, outside the synchronous full-cycle census window; its root and heap reads do not allocate GC objects, move objects, or invoke JS callbacks. The mark-complete and sweep-entry boundaries are unchanged. Re-audited 2026-09-05 after #9830 touched `gc/policy.rs`. That change is (a) six `thread_local! {` blocks rewritten as `crate::perry_thread_local! {` and (b) one `#[cfg(test)]` accessor listing the trigger path's hot-slot indices. The macro keeps the same storage, the same `.with()` at every read and write, and the same destructor registration (the teardown guard exists exactly when `needs_drop` holds, which is what `std::thread_local!` already decided); no value, predicate or branch in the file changes, so no mark or sweep control flow does. The one new behaviour is on a declaration's FIRST read: `HotKey::resolve_and_cache` takes a mutex and allocates a key through the GLOBAL allocator. Even if a first read landed inside this window it would be sound — the window's contract is that nothing relocates and no JS callback runs, and a mimalloc allocation does neither. `census_pass1_if_armed` is still inside `step_mark_propagation` and `census_take_if_armed_at_full_sweep_start` inside `step_sweep`; the bracketing is untouched. Re-audited 2026-09-06 (train132) after #9860 and #9845 touched `gc/mod.rs`. Both hunks are re-export lists and nothing else: #9860 adds `idle_reclaim_elapsed_starts` / `IDLE_RECLAIM_REARM_MS`, and #9845 adds `owner_is_dead_copied_minor_from_space_of_type`. No mark or sweep control flow changes. #9845's substantive work sits in `gc/oldgen.rs` and `gc/copying.rs`, neither pinned: the copying-minor arm (`finalize_dead_copied_minor_from_space_regexps`) runs on a MINOR, which skips both census boundaries; the full-cycle arm (`collect_dead_registered_regexps_post_trace`, from `with_dead_collection_finalize`) walks the RegExp registry building a Vec of addresses — no GC allocation, no JS callback, so it cannot relocate the snapshot's subjects — and it is reached from the sweep body, i.e. AFTER `census_take_if_armed_at_full_sweep_start` has already `take()`n the snapshot out of the thread-local. The mark-complete -> sweep-entry window is unchanged. Re-audited 2026-09-07 for #9965 after 1ec9e0e8a touched `gc/cycle.rs` and `gc/mod.rs`: `gc/mod.rs:216-217` only declares and imports the failure-attribution module, while `gc/cycle.rs:1414-1417` reads the trigger and diagnostic counters immediately before evacuation verification inside `atomic_finalize_minor_prelude`. Full cycles bypass `MinorPrelude` at `gc/cycle.rs:1192-1196`; evacuation remains guarded by the minor-only context at `gc/cycle.rs:1330-1372`. The snapshot store remains at `gc/cycle.rs:963-964` after synchronous full marking, and its take remains at `gc/cycle.rs:1454-1457` before sweep. No new write, relocation, collection, or JS callback was added to that full-cycle interval, so the PASS1_MARKED window is unaffected. Re-audited 2026-09-07 for the regex census rows: all new work is in `take_census` after `census_take_if_armed_at_full_sweep_start` has taken PASS1_MARKED out of TLS; neither boundary nor the intervening cycle control flow changed. Re-audited 2026-09-08 (train144) after #9976 and #9977 touched pinned files. `gc/mod.rs` gains exactly three lines: `mod copying_phase;` and `mod regex_census;` (declarations) and one `reg_scanner!(regex::site_test::scan_roots_mut)` registration. A scanner registration adds a root SOURCE for the mutable-root walks; it does not move either census boundary and runs nowhere between them. `gc/census.rs` widens `side_tables()` to `pub(super)`, extends it with regex rows and adds a test module — all census REPORTING, which runs from the diagnostic dump, not inside a cycle. Mark/sweep control flow between `census_pass1_if_armed` and `census_take_if_armed_at_full_sweep_start` is untouched. Re-audited 2026-09-08 for #9849 JSON construction deferral. `gc/mod.rs` adds the `json_defer` module/re-export and a trusted-header layout helper used only by already-validated JSON emitters; neither changes or runs in collector phase control flow. `gc/policy.rs` adds JSON completion scheduling, construction-grace checks, and safepoint deferral predicates. These are called from mutator-side JSON allocation/output boundaries and ordinary safepoint entry; they do not alter `step_mark_propagation`, `step_sweep`, or invoke callbacks or relocation between the census boundaries. The mark-complete to sweep-entry window is unchanged. The follow-up adds a cfg(test)-only one-shot boolean for deterministic explicit-pressure fixtures; it is absent from production builds and cannot affect the census window. The first predicate read consumes it, so post-parse accounting exercises normal pricing. Re-audited 2026-09-09 for bounded tiny-JSON completion polling. The policy.rs changes split the mutator-side pending-parse check into an inlined empty fast path plus an outlined debt-service path, and amortize the mutator-side arena-pressure read across 64 bounded parse completions. Neither function is reachable from step_mark_propagation or step_sweep; neither census boundary nor the synchronous full-cycle interval between them changes. Re-audited 2026-09-09 for lazy JSON record batches: policy.rs only widens gc_budgeted_cycle_active visibility from pub(super) to pub(crate). Its body remains a read-only Cell query. The new caller is lazy_get materialization in the mutator; run_to_completion, step_mark_propagation, census snapshot consumption at step_sweep, and the synchronous non-moving window are unchanged. Re-audited 2026-09-09 for completed JSON-output debt: the added gc_service_json_output_sweep function calls the existing trigger check from a rooted mutator boundary and reports whether its malloc-count request remains due. It is not called from any census or collector phase; the synchronous mark-complete to sweep-entry window is unchanged. Re-audited 2026-09-09 for the JSON byte-debt carry: the same mutator-only service helper now distinguishes requests satisfied before its call from those satisfied by its trigger check. The added enum contains no payload, both count reads are scalar, and no census boundary or collector phase changed. Re-audited 2026-09-11 for #10055: gc/mod.rs only registers the weak UTF-16 index scanner during gc_init. It neither marks strings nor allocates GC objects or runs JS; offset vectors use the Rust allocator. The mark-complete to sweep-entry census window and cycle control flow are unchanged. Re-audited 2026-09-11 for #10054: gc/mod.rs adds only the trim-cache mutable-root scanner registration in gc_init. Its scanner visits two existing string slots without allocating or invoking JS. Root scanning still precedes mark completion, and neither census boundary nor the synchronous mark-complete to sweep-entry window changes. Re-audited 2026-09-11 for #10060: the census array classifier now reads the logical element start and bounds its scan by the remaining capacity. The helper only reads the existing GC/header words and performs pointer arithmetic; it cannot allocate, collect, or call JS. This classifier runs in take_census after PASS1_MARKED has been taken out of TLS. Neither census boundary nor the mark-complete to sweep-entry control flow changed. Re-audited for #8512: gc/mod.rs only enables the existing PTY mutable-root scanner on Windows; it changes no mark/sweep phase or census boundary. The scanner visits NaN-boxed slots without running JS callbacks. Re-audited 2026-09-12 for the single regular-expression engine: `gc/mod.rs` changes `mod prefetch;` to `pub(crate) mod prefetch;` so the RegExp owner-table walks can prefetch headers, a visibility change with no new call in collector control flow; `gc/census.rs` changes only its `#[cfg(test)]` `regex_census_tests` module, dropping assertions for the previous engine's cache rows. Neither boundary (`census_pass1_if_armed` in `step_mark_propagation`, `census_take_if_armed_at_full_sweep_start` in `step_sweep`) nor the synchronous mark-complete to sweep-entry interval changes.", + "why": "Real GC header addresses, deliberately untraced so the diagnostic does not keep its observed objects alive. Populated only at the end of mark propagation of a synchronous full cycle; consumed at sweep entry in the same run_to_completion invocation. The intervening full-cycle phases do not relocate or run JS callbacks. The Vec is used for membership comparisons and dropped with the census before sweep. Budgeted and minor cycles skip both boundaries. Pin re-audited 2026-09-05 after #9760 touched `gc/mod.rs`: that change is `mod heap_stats;` plus a `pub(crate) use` re-export and alters no mark/sweep control flow. `heap_stats()` is reached only from `js_bun_jsc_heap_stats` (the JS-facing `bun:jsc.heapStats()`), i.e. from mutator code, never inside a cycle, and its own module contract forbids allocation or collection during its walk. The mark-complete → sweep-entry window is unchanged. Re-audited 2026-09-05 (train125) after #9769 and #9771 touched pinned files. #9769 adds one `reg_scanner!` registration to `gc/mod.rs`; #9771 adds a feature-gated `alloc_census_init()` there and a feature-gated Rust-heap dump inside `take_census`. `alloc-census` is not in the default feature set, and decisively: `census_take_if_armed_at_full_sweep_start` does `PASS1_MARKED.with(|p| p.borrow_mut().take())` BEFORE calling `take_census`, so the snapshot has already left the thread-local by the time #9771's code runs — it cannot affect the window. Neither change alters mark/sweep control flow. Re-audited 2026-09-06 after #9831 touched `gc/policy.rs`. Its hunks are (a) the tiny-parse pressure guard's pricing (`tiny_parse_pressure_headroom_bytes`, `tiny_parse_pressure_due*`, a `Cell` byte-count base) consulted from JSON.parse's mutator-side boundaries (`gc_bump_malloc_trigger`, `gc_collect_pending_suppressed_parse`, `gc_schedule_parse_boundary_collection_if_pressure`), none of which is reachable from inside a cycle, and (b) one extra `Cell` store in `note_collection_finished_arena_occupancy`, which runs from `publish_reclaim_outcome` in the Publish subphase — after `step_sweep` has already consumed the snapshot. Mark/sweep control flow between `census_pass1_if_armed` and `census_take_if_armed_at_full_sweep_start` is untouched. Re-audited 2026-09-05 (train126) after #9755 restructured `gc/cycle.rs`. Its hunks are all root-scan machinery (`RootScanSubphase`, `RootScanCycleState`, the mutable-scanner iteration state), which runs BEFORE mark propagation completes; `gc/mod.rs` gains only a `mod young_log;` declaration. The bracketing is unchanged — `census_pass1_if_armed` is still inside `step_mark_propagation` and `census_take_if_armed_at_full_sweep_start` inside `step_sweep` — and a synchronous full mark-sweep still moves nothing between them. Re-pinned 2026-09-05 for the #9740 hot-TLS conversion of this file: the sole change is `thread_local!` → `crate::perry_thread_local!`, a macro-name swap with identical declaration syntax and `.with()` call sites. No control flow, no phase boundary, and no storage semantics change. Re-audited 2026-09-06 (train128) after #9794's GC diagnostics touched `gc/mod.rs` and `gc/policy.rs`: both gain diagnostic module declarations and counters only — no mark/sweep control flow, and the census bracketing in `step_mark_propagation` / `step_sweep` is unchanged. Re-audited for #9794's GC diagnostics: `gc/mod.rs` gains `mod diag_sites;` / `mod survival_diag;`, a re-export, a `diag_sites::full_started(...)` call at TRIGGER time (before mark propagation begins), and exit-time reporting. Nothing executes between mark-complete and sweep-entry, so the window is unchanged. Re-audited 2026-09-06 for the retained array-growth verifier fix: the cycle.rs change passes the existing non-copying evacuation verifier an explicit all-forwarded policy. That call remains in minor finalization, outside the synchronous full-cycle census window; its root and heap reads do not allocate GC objects, move objects, or invoke JS callbacks. The mark-complete and sweep-entry boundaries are unchanged. Re-audited 2026-09-05 after #9830 touched `gc/policy.rs`. That change is (a) six `thread_local! {` blocks rewritten as `crate::perry_thread_local! {` and (b) one `#[cfg(test)]` accessor listing the trigger path's hot-slot indices. The macro keeps the same storage, the same `.with()` at every read and write, and the same destructor registration (the teardown guard exists exactly when `needs_drop` holds, which is what `std::thread_local!` already decided); no value, predicate or branch in the file changes, so no mark or sweep control flow does. The one new behaviour is on a declaration's FIRST read: `HotKey::resolve_and_cache` takes a mutex and allocates a key through the GLOBAL allocator. Even if a first read landed inside this window it would be sound — the window's contract is that nothing relocates and no JS callback runs, and a mimalloc allocation does neither. `census_pass1_if_armed` is still inside `step_mark_propagation` and `census_take_if_armed_at_full_sweep_start` inside `step_sweep`; the bracketing is untouched. Re-audited 2026-09-06 (train132) after #9860 and #9845 touched `gc/mod.rs`. Both hunks are re-export lists and nothing else: #9860 adds `idle_reclaim_elapsed_starts` / `IDLE_RECLAIM_REARM_MS`, and #9845 adds `owner_is_dead_copied_minor_from_space_of_type`. No mark or sweep control flow changes. #9845's substantive work sits in `gc/oldgen.rs` and `gc/copying.rs`, neither pinned: the copying-minor arm (`finalize_dead_copied_minor_from_space_regexps`) runs on a MINOR, which skips both census boundaries; the full-cycle arm (`collect_dead_registered_regexps_post_trace`, from `with_dead_collection_finalize`) walks the RegExp registry building a Vec of addresses — no GC allocation, no JS callback, so it cannot relocate the snapshot's subjects — and it is reached from the sweep body, i.e. AFTER `census_take_if_armed_at_full_sweep_start` has already `take()`n the snapshot out of the thread-local. The mark-complete -> sweep-entry window is unchanged. Re-audited 2026-09-07 for #9965 after 1ec9e0e8a touched `gc/cycle.rs` and `gc/mod.rs`: `gc/mod.rs:216-217` only declares and imports the failure-attribution module, while `gc/cycle.rs:1414-1417` reads the trigger and diagnostic counters immediately before evacuation verification inside `atomic_finalize_minor_prelude`. Full cycles bypass `MinorPrelude` at `gc/cycle.rs:1192-1196`; evacuation remains guarded by the minor-only context at `gc/cycle.rs:1330-1372`. The snapshot store remains at `gc/cycle.rs:963-964` after synchronous full marking, and its take remains at `gc/cycle.rs:1454-1457` before sweep. No new write, relocation, collection, or JS callback was added to that full-cycle interval, so the PASS1_MARKED window is unaffected. Re-audited 2026-09-07 for the regex census rows: all new work is in `take_census` after `census_take_if_armed_at_full_sweep_start` has taken PASS1_MARKED out of TLS; neither boundary nor the intervening cycle control flow changed. Re-audited 2026-09-08 (train144) after #9976 and #9977 touched pinned files. `gc/mod.rs` gains exactly three lines: `mod copying_phase;` and `mod regex_census;` (declarations) and one `reg_scanner!(regex::site_test::scan_roots_mut)` registration. A scanner registration adds a root SOURCE for the mutable-root walks; it does not move either census boundary and runs nowhere between them. `gc/census.rs` widens `side_tables()` to `pub(super)`, extends it with regex rows and adds a test module — all census REPORTING, which runs from the diagnostic dump, not inside a cycle. Mark/sweep control flow between `census_pass1_if_armed` and `census_take_if_armed_at_full_sweep_start` is untouched. Re-audited 2026-09-08 for #9849 JSON construction deferral. `gc/mod.rs` adds the `json_defer` module/re-export and a trusted-header layout helper used only by already-validated JSON emitters; neither changes or runs in collector phase control flow. `gc/policy.rs` adds JSON completion scheduling, construction-grace checks, and safepoint deferral predicates. These are called from mutator-side JSON allocation/output boundaries and ordinary safepoint entry; they do not alter `step_mark_propagation`, `step_sweep`, or invoke callbacks or relocation between the census boundaries. The mark-complete to sweep-entry window is unchanged. The follow-up adds a cfg(test)-only one-shot boolean for deterministic explicit-pressure fixtures; it is absent from production builds and cannot affect the census window. The first predicate read consumes it, so post-parse accounting exercises normal pricing. Re-audited 2026-09-09 for bounded tiny-JSON completion polling. The policy.rs changes split the mutator-side pending-parse check into an inlined empty fast path plus an outlined debt-service path, and amortize the mutator-side arena-pressure read across 64 bounded parse completions. Neither function is reachable from step_mark_propagation or step_sweep; neither census boundary nor the synchronous full-cycle interval between them changes. Re-audited 2026-09-09 for lazy JSON record batches: policy.rs only widens gc_budgeted_cycle_active visibility from pub(super) to pub(crate). Its body remains a read-only Cell query. The new caller is lazy_get materialization in the mutator; run_to_completion, step_mark_propagation, census snapshot consumption at step_sweep, and the synchronous non-moving window are unchanged. Re-audited 2026-09-09 for completed JSON-output debt: the added gc_service_json_output_sweep function calls the existing trigger check from a rooted mutator boundary and reports whether its malloc-count request remains due. It is not called from any census or collector phase; the synchronous mark-complete to sweep-entry window is unchanged. Re-audited 2026-09-09 for the JSON byte-debt carry: the same mutator-only service helper now distinguishes requests satisfied before its call from those satisfied by its trigger check. The added enum contains no payload, both count reads are scalar, and no census boundary or collector phase changed. Re-audited 2026-09-11 for #10055: gc/mod.rs only registers the weak UTF-16 index scanner during gc_init. It neither marks strings nor allocates GC objects or runs JS; offset vectors use the Rust allocator. The mark-complete to sweep-entry census window and cycle control flow are unchanged. Re-audited 2026-09-11 for #10054: gc/mod.rs adds only the trim-cache mutable-root scanner registration in gc_init. Its scanner visits two existing string slots without allocating or invoking JS. Root scanning still precedes mark completion, and neither census boundary nor the synchronous mark-complete to sweep-entry window changes. Re-audited 2026-09-11 for #10060: the census array classifier now reads the logical element start and bounds its scan by the remaining capacity. The helper only reads the existing GC/header words and performs pointer arithmetic; it cannot allocate, collect, or call JS. This classifier runs in take_census after PASS1_MARKED has been taken out of TLS. Neither census boundary nor the mark-complete to sweep-entry control flow changed. Re-audited for #8512: gc/mod.rs only enables the existing PTY mutable-root scanner on Windows; it changes no mark/sweep phase or census boundary. The scanner visits NaN-boxed slots without running JS callbacks. Re-audited 2026-09-12 for the single regular-expression engine: `gc/mod.rs` changes `mod prefetch;` to `pub(crate) mod prefetch;` so the RegExp owner-table walks can prefetch headers, a visibility change with no new call in collector control flow; `gc/census.rs` changes only its `#[cfg(test)]` `regex_census_tests` module, dropping assertions for the previous engine's cache rows. Neither boundary (`census_pass1_if_armed` in `step_mark_propagation`, `census_take_if_armed_at_full_sweep_start` in `step_sweep`) nor the synchronous mark-complete to sweep-entry interval changes. Re-audited 2026-09-13 after the #10169 fix touched `gc/mod.rs` and `gc/policy.rs`. `gc/mod.rs` gains only `pub(crate) use` re-exports (`policy::note_young_leaf_born_old`, `policy::young_generation_holds_a_nursery`, `promote_in_place::{young_generation_measured_dying, young_generation_measured_retained}`, and cfg(test) survival seeders). `gc/policy.rs` gains a `Cell` thread-local (`GC_YOUNG_LEAF_BORN_OLD`, no pointer), its setter, a pure predicate over `copying_from_space_in_use_bytes` vs the base nursery cap, and a consumed-once branch at the top of `gc_budgeted_due_trigger` that may answer `YoungScavengeCap` ahead of `OldReclaim`. That branch decides WHICH collection a safepoint starts (a minor instead of a full); it runs before any cycle begins and never inside one, so the mark-complete → sweep-entry window of a synchronous full — where PASS1_MARKED is populated and consumed within one `run_to_completion` — is unchanged, and neither hunk adds an allocation, a JS callback, or a relocation to it.", "window": { "start": { "file": "crates/perry-runtime/src/gc/census.rs", @@ -324,8 +324,8 @@ "sources": { "crates/perry-runtime/src/gc/census.rs": "3f9e6be47b4454022b70ff2357bbdf3a80ef6a84c4986e58763acbdcce9142c1", "crates/perry-runtime/src/gc/cycle.rs": "77eadaf7c4157308c3b14be5e1ff11d7198b84b503a50ff2d244851248d3e800", - "crates/perry-runtime/src/gc/mod.rs": "496d42cf8f41e4c71da6d50470ec1e796b065a919549b8239f579a5bc1a5b175", - "crates/perry-runtime/src/gc/policy.rs": "a701257f2e2310adabe16e33c0afcd935c7cd28e1ddc157b4974b48e2688cc4f", + "crates/perry-runtime/src/gc/mod.rs": "5b0c6dcf8ad8b919e86458f69ef4ad24679e6c7c08eeacf6629439b4e9ff0177", + "crates/perry-runtime/src/gc/policy.rs": "aea89274a017156efea3516b4f49124f48a66527a08195b662cfbf61672ac042", "crates/perry-runtime/src/gc/progress.rs": "a5ad3971bbe4047229ca57325234780daa85921dbc778e1c08dff4ad07ccfb96" } } @@ -402,6 +402,12 @@ "verdict": "not_a_gc_pointer", "why": "Tiny-JSON completion countdown in a Cell. It stores only the number of bounded parse completions before the next arena-pressure poll (0..63), never an address or a NaN-boxed value." }, + { + "file": "crates/perry-runtime/src/gc/policy.rs", + "name": "GC_YOUNG_LEAF_BORN_OLD", + "verdict": "not_a_gc_pointer", + "why": "#10169: a `Cell` scheduling flag — set when a document-sized JSON result was born old under young pressure, consumed by the next `gc_budgeted_due_trigger` decision. Holds no address of any kind." + }, { "file": "crates/perry-runtime/src/gc/policy.rs", "name": "GC_TINY_PARSE_PRESSURE_BASE_BYTES", From 9115323f8455197d382aedb1340ba5887a3ef1cf Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ralph=20K=C3=BCpper?= Date: Sun, 13 Sep 2026 11:08:32 +0200 Subject: [PATCH 2/2] docs(changelog): fragment for #10177 --- changelog.d/10177-json-leaf-arena-route.md | 3 +++ 1 file changed, 3 insertions(+) create mode 100644 changelog.d/10177-json-leaf-arena-route.md diff --git a/changelog.d/10177-json-leaf-arena-route.md b/changelog.d/10177-json-leaf-arena-route.md new file mode 100644 index 0000000000..76a031d05a --- /dev/null +++ b/changelog.d/10177-json-leaf-arena-route.md @@ -0,0 +1,3 @@ +### perf(gc): birth a large JSON result in the arena when the young generation already holds a document + +JSON results at or above 512 KB are malloc-tracked so the next minor can reclaim a discarded result cheaply. On a parse-then-stringify loop over a document-sized input that inverted: the non-empty malloc registry forbids the untraced in-place promotion, so every minor traced the whole freshly parsed tree (55 ms for 20 MB, half the roundtrip's wall time) to reclaim one leaf. Such a leaf is now born old in the arena when the young generation holds at least the leaf's bytes and a base nursery's worth of data and was not measured as dying, and the next trigger decision gives the nursery minor one-time priority over old-reclaim while the young generation is unmeasured, so a stringify-only loop still promotes its input once and then keeps the malloc path. Measured on `records_array_20m:roundtrip`: CPU 206.8 → 114.1 ms (0.55×, now ahead of the better of Node 26.5.1 / Bun 1.3.14 at 162.1) and peak RSS 295 → 266 MiB (Node 261); 20 MB stringify rows −40 MB peak RSS at flat CPU; every other JSON row within noise (#10169).