From 362c30d88313eb62649acd6e44436d446b316d96 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ralph=20K=C3=BCpper?= Date: Sat, 8 Aug 2026 01:27:48 +0200 Subject: [PATCH 1/4] perf(gc): seed promote-on-first-copy from a completed mark-sweep (#7598) The survival-rate lock in gc/tenuring.rs is one cycle late by construction: it keys on prev_copied, so a previous copying minor must already have filled the survivor space. On a one-burst workload the first copying minor therefore always pays the wasted Eden->survivor copy (json_pipeline 500k: 268 MB copied on cycle 3, the same 268 MB promoted on cycle 4). Every collection that reaches the mark-sweep path -- a full, or a non-copying minor fallback, the two blind spots of retune_after_scavenge -- already walks every Eden header and classifies it live or dead. That census answers the same question one collection earlier. When the surviving cohort alone exceeds the desired survivor occupancy AND >=90% of the classified Eden bytes were live, the existing PROMOTE_LOCK is engaged so the NEXT copying minor enters at S=1. Exit stays the existing influx signal, so no new oscillation path. Budgeted cycles (allocate-black marks every mid-cycle birth) and conservative-scan cycles (unsound, run-varying liveness) are excluded at the callsite. Claude-Session: https://claude.ai/code/session_01Y1QZ5wUP9gRSwpiweT4Wix --- crates/perry-runtime/src/gc/copying.rs | 5 + crates/perry-runtime/src/gc/cycle.rs | 18 ++ crates/perry-runtime/src/gc/oldgen.rs | 22 ++ crates/perry-runtime/src/gc/tenuring.rs | 258 ++++++++++++++++++++++++ 4 files changed, 303 insertions(+) diff --git a/crates/perry-runtime/src/gc/copying.rs b/crates/perry-runtime/src/gc/copying.rs index b308009f72..7653d42760 100644 --- a/crates/perry-runtime/src/gc/copying.rs +++ b/crates/perry-runtime/src/gc/copying.rs @@ -1272,6 +1272,11 @@ pub(super) fn gc_collect_minor_copying_fast_path_with_eligibility( deallocated_bytes: reset.deallocated_bytes, retained_forwarded_stub_objects: 0, retained_forwarded_stub_bytes: 0, + // The copying minor's Eden census is `stats.eden_live_bytes`, fed + // to `retune_after_scavenge` directly; the #7598 sweep seed covers + // the collections that run NO copying minor. + eden_live_bytes: 0, + eden_dead_bytes: 0, }; trace.pause_us = start.elapsed().as_micros() as u64; trace.capture_layout_scans(); diff --git a/crates/perry-runtime/src/gc/cycle.rs b/crates/perry-runtime/src/gc/cycle.rs index 890b3633cd..456459af96 100644 --- a/crates/perry-runtime/src/gc/cycle.rs +++ b/crates/perry-runtime/src/gc/cycle.rs @@ -1738,6 +1738,24 @@ impl GcCycleState { trace.old_pages = crate::arena::old_page_summary(); } self.sweep = Some(sweep); + // #7598: seed promote-on-first-copy from THIS completed collection. + // Every cycle reaching here is a full or a non-copying minor — the two + // blind spots of `retune_after_scavenge`, which only copying minors + // feed. Policy, the self-reference argument and the two exclusions + // below all live in `tenuring.rs`: allocate-black makes a budgeted + // cycle's Eden read as ~100% live, and a conservative-scan cycle's + // mark set is not a sound liveness measurement. + if !self.progress_kind.is_budgeted() + && matches!( + super::roots::conservative_stack_scan_decision(), + super::roots::ConservativeStackScanDecision::SkipDisabled + ) + { + super::tenuring::seed_promote_lock_from_sweep( + sweep.eden_live_bytes as usize, + sweep.eden_dead_bytes as usize, + ); + } self.phase = GcCyclePhase::Reclaim; } diff --git a/crates/perry-runtime/src/gc/oldgen.rs b/crates/perry-runtime/src/gc/oldgen.rs index 731f2385a4..dd74a480bf 100644 --- a/crates/perry-runtime/src/gc/oldgen.rs +++ b/crates/perry-runtime/src/gc/oldgen.rs @@ -119,6 +119,11 @@ pub(super) struct SweepTraceStats { pub(super) deallocated_bytes: usize, pub(super) retained_forwarded_stub_objects: usize, pub(super) retained_forwarded_stub_bytes: usize, + /// #7598: bytes this walk classified live / dead in the general (Eden) + /// blocks. Consumed by `tenuring::seed_promote_lock_from_sweep`, which + /// documents the policy and why the signal is not self-referential. + pub(super) eden_live_bytes: u64, + pub(super) eden_dead_bytes: u64, } pub(super) fn evacuation_policy_initial_decision( @@ -1056,6 +1061,10 @@ fn legacy_sweep_with_age_bump_and_old_reclaim_targets( deallocated_bytes: reset.deallocated_bytes, retained_forwarded_stub_objects, retained_forwarded_stub_bytes, + // Legacy unbudgeted path, not reached in production and not wired to + // the #7598 seed. + eden_live_bytes: 0, + eden_dead_bytes: 0, } } @@ -1225,6 +1234,8 @@ impl IncrementalSweepState { deallocated_bytes: reset.deallocated_bytes, retained_forwarded_stub_objects: self.arena.retained_forwarded_stub_objects, retained_forwarded_stub_bytes: self.arena.retained_forwarded_stub_bytes, + eden_live_bytes: self.arena.eden_live_bytes, + eden_dead_bytes: self.arena.eden_dead_bytes, }; self.subphase = SweepCycleSubphase::Done; return true; @@ -1286,6 +1297,9 @@ struct ArenaSweepObjectsState { freed_bytes: u64, retained_forwarded_stub_objects: usize, retained_forwarded_stub_bytes: usize, + /// #7598 Eden census: see `SweepTraceStats`. + eden_live_bytes: u64, + eden_dead_bytes: u64, } impl ArenaSweepObjectsState { @@ -1315,6 +1329,8 @@ impl ArenaSweepObjectsState { freed_bytes: 0, retained_forwarded_stub_objects: 0, retained_forwarded_stub_bytes: 0, + eden_live_bytes: 0, + eden_dead_bytes: 0, } } @@ -1444,6 +1460,9 @@ impl ArenaSweepObjectsState { if block_idx < self.block_has_live.len() { self.block_has_live[block_idx] = true; } + if block_idx < self.resettable_general_n { + self.eden_live_bytes = self.eden_live_bytes.saturating_add((*header).size as u64); + } if age_bump_this && flags & GC_FLAG_TENURED == 0 { if flags & GC_FLAG_HAS_SURVIVED != 0 { (*header).gc_flags = @@ -1507,6 +1526,9 @@ impl ArenaSweepObjectsState { } let user_ptr = (header as *mut u8).add(GC_HEADER_SIZE); self.freed_bytes = self.freed_bytes.saturating_add(total_size as u64); + if block_idx < self.resettable_general_n { + self.eden_dead_bytes = self.eden_dead_bytes.saturating_add(total_size as u64); + } finalize_dead_arena_payload(header, user_ptr, self.overflow_active); if self.reclaim_dead_old_blocks && dead_old { invalidate_dead_old_arena_header(header, total_size); diff --git a/crates/perry-runtime/src/gc/tenuring.rs b/crates/perry-runtime/src/gc/tenuring.rs index 4082e7e653..7c1a63387e 100644 --- a/crates/perry-runtime/src/gc/tenuring.rs +++ b/crates/perry-runtime/src/gc/tenuring.rs @@ -60,6 +60,81 @@ //! loop is always on, and its neutral state — influx below `desired`, //! survivor cohorts that die in place — computes S=4, which is //! bit-for-bit the previous fixed behaviour. +//! +//! ## Seeding the lock from a non-copying collection (#7598) +//! +//! The survival-rate lock above is correct but **one cycle late by +//! construction**: it keys on `prev_copied`, so a *previous copying minor* +//! must already have filled the survivor space. On a workload with one +//! long-lived burst (`json_pipeline`: `out.push({…})` 500k times) the first +//! copying minor therefore always pays the wasted copy — measured 268 MB +//! Eden→survivor on cycle 3 and the same 268 MB survivor→old on cycle 4, +//! ~3.9 s of a 5.1 s phase spent copying one cohort twice. +//! +//! Both blind spots named on #7592 are the same shape: `retune_after_scavenge` +//! is fed **only** by copying minors, so full mark-sweeps and non-copying +//! minor fallbacks feed the loop nothing, and once a workload escalates to +//! those the loop goes blind exactly when it is needed. +//! +//! [`seed_promote_lock_from_sweep`] closes that. Every cycle that reaches the +//! mark-sweep path — a full, or a non-copying minor fallback — already walks +//! every Eden header and classifies it live or dead. That walk yields the two +//! numbers the lock wants, one collection *earlier* than the survivor +//! round-trip can produce them. +//! +//! ### Why this signal is not a fixed point of the policy reading it +//! +//! This issue has already produced three self-referential signals, so the +//! argument is spelled out rather than assumed: +//! +//! * The Eden-side probe reached for `promoted_bytes`, which is **zero by +//! construction at S=4** — "was the promotion rate high?" can never answer +//! yes while S=4 holds, so it could never leave S=4. +//! * #7596's first nursery cap gated from-space occupancy on a total that +//! *included* from-space, so the cap was a bound from-space could never +//! cross and scavenging stopped entirely. +//! * #7594's handoff scheduled a *non-moving* full to relieve pressure only a +//! *moving* cycle can relieve, so the predicate was true again next cycle. +//! +//! The Eden live/dead split has none of that structure. It is produced by the +//! mark-sweep's own arena walk: marks come from reachability from roots, and +//! neither the mark phase nor the walk reads `tenuring_survivals()` — the +//! threshold is consulted only by `copying.rs`'s per-object move, which this +//! path does not run. Concretely, at both endpoints the signal stays +//! measurable and keeps its meaning: +//! +//! | state | what Eden holds at the sweep | signal | +//! |---|---|---| +//! | S=4 (state to leave) | everything that has not aged out | high live fraction on a retaining workload ⇒ says "lock" | +//! | S=1 (state to stay in) | only Eden allocated since the last promotion | still the mutator's retention of recent Eden ⇒ still measurable | +//! +//! Exit is deliberately **not** this signal. Entering hands over to the +//! existing `PROMOTE_LOCK`, whose unlock condition (Eden influx below +//! `desired/4` for two consecutive copying minors) is already +//! threshold-invariant and already tested — so the seed cannot introduce an +//! enter/exit oscillation of its own. +//! +//! ### Determinism (#7432) +//! +//! #7432 forbids re-deciding S *while objects are being moved*: the +//! copied/promoted split would then depend on root traversal order. The seed +//! is written at the END of a completed sweep and read at the ENTRY of a later +//! copying minor (`CopyingNurseryCollector::new` snapshots it once), so every +//! object in a cycle still sees exactly one threshold and the counters stay a +//! pure function of (heap state at entry, threshold at entry). +//! +//! Two exclusions at the callsite keep the *input* deterministic too, and both +//! are refusals rather than tuning: +//! +//! * **Budgeted cycles.** Whole-cycle allocate-black marks every mid-cycle +//! birth, and this walk reads MARKED as live — a churn workload's births +//! would read as a ~100% live Eden. Same reason the age-bump is suppressed +//! there (`cycle.rs`). +//! * **Cycles that ran the conservative native-stack scan.** That scan retains +//! whatever the stack happens to look like a pointer to, by an amount that +//! varies run to run (`benchmarks/gc_ratchet/README.md`). A liveness +//! *measurement* taken under it is not sound, and feeding it into a policy +//! would make the gated copy/promote counters non-deterministic. use super::*; @@ -289,6 +364,75 @@ fn retune_nursery_cap_scale(eden_live_bytes: usize) { } } +/// Minimum Eden survival rate, in tenths, for a mark-sweep to seed the +/// promote-on-first-copy lock: ≥90% of the Eden bytes the sweep classified +/// must have been live. That is the "the aging round would filter nothing" +/// proof, measured directly instead of inferred from a survivor round-trip. +const FULL_SEED_LIVE_TENTHS: usize = 9; + +/// Would a completed mark-sweep's Eden census justify promote-on-first-copy? +/// +/// Pure function of the census and the survivor target so the policy is +/// testable without arranging a heap (the #7024 shape: a green test whose +/// subject never ran — see the sibling `scavenge_nursery_cap_from`). +/// +/// Two independent conditions, both required, each answering a different +/// question: +/// +/// 1. **Occupancy** — `compute_target_survivals(...) == 1`, i.e. the surviving +/// cohort alone already exceeds the desired survivor occupancy, so at any +/// S ≥ 2 it cannot fit and would be re-copied. This is deliberately the +/// module's *existing* rule; the only new thing is where the number is read +/// from. +/// 2. **Survival rate** — nearly nothing in Eden died, so a survivor round +/// would filter nothing. Without this an Eden that merely happens to be +/// large would promote its garbage too. +/// +/// The stated failure mode (design note on #7592): a normally churn-heavy +/// program whose nursery is atypically mostly-live at one sweep promotes one +/// Eden's worth of short-lived objects and pays an old-gen reclaim to get them +/// back. Exposure is bounded by one nursery cap and by the existing unlock +/// path; requiring BOTH conditions is what keeps it narrow. +pub(super) fn full_seed_promotes_on_first_copy( + eden_live_bytes: usize, + eden_dead_bytes: usize, + desired_bytes: usize, +) -> bool { + if compute_target_survivals(eden_live_bytes, desired_bytes) != 1 { + return false; + } + let classified = eden_live_bytes.saturating_add(eden_dead_bytes); + classified > 0 + && eden_live_bytes.saturating_mul(10) >= classified.saturating_mul(FULL_SEED_LIVE_TENTHS) +} + +/// Feed one finished mark-sweep's Eden census into the loop. `eden_live_bytes` +/// and `eden_dead_bytes` are the bytes the sweep walk classified live and dead +/// in the general (Eden) blocks. +/// +/// Callers must exclude budgeted cycles and cycles that ran the conservative +/// native-stack scan — see the module header for why those two inputs are not +/// sound liveness measurements. +pub(super) fn seed_promote_lock_from_sweep(eden_live_bytes: usize, eden_dead_bytes: usize) { + if PROMOTE_LOCK.with(Cell::get) { + return; + } + if !full_seed_promotes_on_first_copy( + eden_live_bytes, + eden_dead_bytes, + desired_survivor_bytes(), + ) { + return; + } + let current = TENURING_SURVIVALS.with(Cell::get); + PROMOTE_LOCK.with(|l| l.set(true)); + UNLOCK_STREAK.with(|s| s.set(0)); + RAISE_STREAK.with(|s| s.set(0)); + // PREV_COPIED_BYTES is deliberately untouched: it is the survival-rate + // lock's denominator, owned by the copying path. + set_survivals(current, 1, eden_live_bytes, "sweep-seed"); +} + fn diag_cap_scale(from: u8, to: u8, eden_live_bytes: usize) { if std::env::var_os("PERRY_GC_DIAG").is_some() { eprintln!( @@ -546,6 +690,120 @@ mod tests { ); } + // ── #7598's mark-sweep seed ───────────────────────────────────────── + // + // The lock above cannot engage before the SECOND copying minor. These + // exercise the seed that reads the same proof off a completed mark-sweep, + // one collection earlier. + + #[test] + fn sweep_seed_decides_before_the_first_copying_minor_snapshots_the_threshold() { + // `copying.rs` snapshots `tenuring_survivals()` in + // `CopyingNurseryCollector::new`, so the only value that can change + // what the first big minor does is the one standing BEFORE any + // `retune_after_scavenge` for that cycle has run. That is precisely + // what the survival-rate lock cannot reach and this seed can. + reset_for_test(); + let d = desired_survivor_bytes(); + let eden_live = d * 4; + assert_eq!( + tenuring_survivals(), + 4, + "with no input the loop is at the ceiling: the wasted copy state" + ); + + seed_promote_lock_from_sweep(eden_live, eden_live / 50); + assert_eq!( + tenuring_survivals(), + 1, + "the copying minor must ENTER at S=1, not be retuned to it afterwards" + ); + reset_for_test(); + } + + #[test] + fn sweep_seed_refuses_a_churn_eden() { + // The stated failure mode, guarded: a big Eden is not a survival + // signal. #7592 recorded exactly this trap ("Eden in-use at cycle + // start is not a survival signal — churn workloads also overshoot + // Eden, with ~0% survival"). Occupancy alone would say S=1 here. + reset_for_test(); + let d = desired_survivor_bytes(); + let eden_live = d * 4; + let eden_dead = eden_live * 9; + assert_eq!( + compute_target_survivals(eden_live, d), + 1, + "precondition: occupancy alone says lock here, so this test is \ + exercising the survival-rate half and not passing vacuously" + ); + seed_promote_lock_from_sweep(eden_live, eden_dead); + assert_eq!( + tenuring_survivals(), + 4, + "10% Eden survival must not seed promote-on-first-copy" + ); + reset_for_test(); + } + + #[test] + fn sweep_seed_refuses_a_small_fully_live_eden() { + // The other half: a nursery that is 100% live but far under the + // survivor target fits the survivor space, so aging still filters and + // the ladder must decide. Right after a scavenge this is the NORMAL + // reading, and seeding off it would lock every program at S=1. + reset_for_test(); + let d = desired_survivor_bytes(); + seed_promote_lock_from_sweep(d / 8, 0); + assert_eq!(tenuring_survivals(), 4); + reset_for_test(); + } + + #[test] + fn sweep_seed_rule_is_a_pure_function_of_the_census() { + // Table over the policy itself, with no heap state involved — the + // sibling `scavenge_nursery_cap_from` exists for the same reason + // (#7024: a test whose subject never ran). + let d = 1024 * 1024; + // Both conditions met. + assert!(full_seed_promotes_on_first_copy(4 * d, d / 10, d)); + // Exactly at the 90% survival boundary: 9 live, 1 dead. + assert!(full_seed_promotes_on_first_copy(9 * d, d, d)); + // One byte under it. + assert!(!full_seed_promotes_on_first_copy(9 * d - 1, d + 1, d)); + // Occupancy says the cohort fits the survivor space. + assert!(!full_seed_promotes_on_first_copy(d / 2, 0, d)); + // An empty census decides nothing (and must not divide by zero). + assert!(!full_seed_promotes_on_first_copy(0, 0, d)); + assert!(!full_seed_promotes_on_first_copy(0, 4 * d, d)); + } + + #[test] + fn sweep_seed_hands_over_to_the_existing_unlock_path() { + // The seed sets the lock and nothing else: exit stays the influx + // signal, which is measurable at S=1 (survivor occupancy is not). + // Without this the seed would need an exit condition of its own, and + // the obvious one — "Eden stopped being mostly live" — reads + // differently at S=1 than at S=4 and would oscillate. + reset_for_test(); + let d = desired_survivor_bytes(); + seed_promote_lock_from_sweep(d * 4, 0); + assert_eq!(tenuring_survivals(), 1); + + // Substantial influx holds the lock, exactly as if it had been set by + // the survivor round-trip. + for _ in 0..4 { + retune_after_scavenge(d * 4, 0, 0); + assert_eq!(tenuring_survivals(), 1); + } + // Quiet influx exits after the same debounce, to the same S=2. + retune_after_scavenge(0, 0, 0); + assert_eq!(tenuring_survivals(), 1); + retune_after_scavenge(0, 0, 0); + assert_eq!(tenuring_survivals(), 2); + reset_for_test(); + } + #[test] fn effective_nursery_cap_is_the_two_term_policy() { // Wiring pin: the effective accessor must be the composition, so a From 3aa3b091c2609211523fcb67b42e93fa930fdb23 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ralph=20K=C3=BCpper?= Date: Sat, 8 Aug 2026 01:44:39 +0200 Subject: [PATCH 2/4] test(gc): pin the sweep Eden census, and print the seed's verdict on refusals The promote-on-first-copy seed is a policy decision made from the sweep's Eden census, so a plausible-but-wrong census would fire the policy on the wrong workloads without ever crashing. One assertion per way it can be wrong: live counted, dead counted separately, and old-gen live counted in NEITHER -- the last is the sabotage target for the block_idx < resettable_general_n gate. The PERRY_GC_DIAG line prints the census AND the verdict on every mark-sweep, including refusals: a policy that silently declines is indistinguishable from one that never ran (#7024/#7025), and it is how the ratchet probes were shown to evaluate the rule and decline it rather than never reaching it. Claude-Session: https://claude.ai/code/session_01Y1QZ5wUP9gRSwpiweT4Wix --- crates/perry-runtime/src/gc/tenuring.rs | 25 ++++-- .../src/gc/tests/incremental_sweep_reclaim.rs | 86 +++++++++++++++++++ 2 files changed, 104 insertions(+), 7 deletions(-) diff --git a/crates/perry-runtime/src/gc/tenuring.rs b/crates/perry-runtime/src/gc/tenuring.rs index 7c1a63387e..185bc6c81f 100644 --- a/crates/perry-runtime/src/gc/tenuring.rs +++ b/crates/perry-runtime/src/gc/tenuring.rs @@ -414,14 +414,25 @@ pub(super) fn full_seed_promotes_on_first_copy( /// native-stack scan — see the module header for why those two inputs are not /// sound liveness measurements. pub(super) fn seed_promote_lock_from_sweep(eden_live_bytes: usize, eden_dead_bytes: usize) { - if PROMOTE_LOCK.with(Cell::get) { - return; + let already_locked = PROMOTE_LOCK.with(Cell::get); + let desired = desired_survivor_bytes(); + let seeds = full_seed_promotes_on_first_copy(eden_live_bytes, eden_dead_bytes, desired); + // Diagnostic, not a knob: print the census AND the verdict on every + // mark-sweep, including refusals. A policy that silently declines is + // indistinguishable from one that never ran (#7024/#7025), and the + // refusal reason is the number a future tuning decision needs. + if std::env::var_os("PERRY_GC_DIAG").is_some() { + let classified = eden_live_bytes.saturating_add(eden_dead_bytes); + let pct = if classified == 0 { + 0 + } else { + eden_live_bytes * 100 / classified + }; + eprintln!( + "[gc-tenuring] sweep-seed eden_live_bytes={eden_live_bytes} eden_dead_bytes={eden_dead_bytes} live_pct={pct} desired={desired} seeds={seeds} already_locked={already_locked}" + ); } - if !full_seed_promotes_on_first_copy( - eden_live_bytes, - eden_dead_bytes, - desired_survivor_bytes(), - ) { + if already_locked || !seeds { return; } let current = TENURING_SURVIVALS.with(Cell::get); diff --git a/crates/perry-runtime/src/gc/tests/incremental_sweep_reclaim.rs b/crates/perry-runtime/src/gc/tests/incremental_sweep_reclaim.rs index 093adfd461..a057d57349 100644 --- a/crates/perry-runtime/src/gc/tests/incremental_sweep_reclaim.rs +++ b/crates/perry-runtime/src/gc/tests/incremental_sweep_reclaim.rs @@ -655,3 +655,89 @@ fn budgeted_reclaim_runs_process_malloc_trim() { Some("executed") | Some("unsupported") )); } + +#[test] +fn full_sweep_eden_census_counts_only_nursery_blocks() { + // #7598: `tenuring::seed_promote_lock_from_sweep` decides promote-on- + // first-copy from this census, so a census with a plausible-but-wrong + // answer would fire the policy on the wrong workloads and never show up + // as a crash. Three assertions, one per way it can be wrong: + // + // 1. live Eden bytes are counted at all; + // 2. dead Eden bytes are counted SEPARATELY, not folded into live + // (folding them makes the survival-rate test read 100% always); + // 3. old-gen live bytes are counted in NEITHER. Dropping the + // `block_idx < resettable_general_n` gate in `keep_live_object` + // would add this 4 MB old-gen object to `eden_live_bytes`, and any + // program with a large tenured set would then seed S=1 forever. + // + // The old-gen object is deliberately an order of magnitude larger than + // everything in the nursery so assertion 3 cannot pass by accident. + let _legacy_pacing = crate::gc::policy::force_legacy_gc_pacing(); + let _guard = CopyingNurseryTestGuard::new(1); + let _trigger_guard = GcTriggerThresholdTestGuard::suppress_automatic_triggers(); + clear_marks(); + clear_mark_seeds(); + crate::arena::old_pages_begin_gc_cycle(); + + const CHUNK: usize = 8 * 1024; + const LIVE_CHUNKS: usize = 32; + const DEAD_CHUNKS: usize = 16; + const OLD_BYTES: usize = 4 * 1024 * 1024; + + let mut live_nursery_bytes = 0usize; + for _ in 0..LIVE_CHUNKS { + let ptr = crate::arena::arena_alloc_gc(CHUNK, 8, GC_TYPE_STRING); + unsafe { + let header = header_from_user_ptr(ptr as *const u8); + (*header).gc_flags |= GC_FLAG_MARKED; + live_nursery_bytes += (*header).size as usize; + } + } + let mut dead_nursery_bytes = 0usize; + for _ in 0..DEAD_CHUNKS { + let ptr = crate::arena::arena_alloc_gc(CHUNK, 8, GC_TYPE_STRING); + unsafe { + dead_nursery_bytes += (*header_from_user_ptr(ptr as *const u8)).size as usize; + } + } + + // A LIVE old-gen resident: marked, so the sweep keeps it. This is the + // bulk of the heap and must not reach the Eden census. + let old = crate::arena::arena_alloc_gc_old(OLD_BYTES, 8, GC_TYPE_STRING); + let old_bytes = unsafe { + let header = header_from_user_ptr(old as *const u8); + (*header).gc_flags |= GC_FLAG_MARKED; + (*header).size as usize + }; + assert!( + old_bytes > live_nursery_bytes * 4, + "the old-gen resident must dominate the nursery for assertion 3 to bite" + ); + + // A FULL sweep: minor_sweep = false, so unmarked really means dead. + let mut sweep = IncrementalSweepState::new(false, true, None, true, false); + let stats = complete_incremental_sweep(&mut sweep); + + assert!( + stats.eden_live_bytes as usize >= live_nursery_bytes, + "marked nursery bytes must be counted live (got {}, expected >= {live_nursery_bytes})", + stats.eden_live_bytes + ); + assert!( + stats.eden_dead_bytes as usize >= dead_nursery_bytes, + "unmarked nursery bytes must be counted dead (got {}, expected >= {dead_nursery_bytes})", + stats.eden_dead_bytes + ); + assert!( + (stats.eden_live_bytes as usize) < old_bytes, + "old-gen live bytes must not reach the Eden census: eden_live_bytes={} \ + but the single old-gen resident alone is {old_bytes}", + stats.eden_live_bytes + ); + assert!( + (stats.eden_dead_bytes as usize) < old_bytes, + "old-gen bytes must not reach the Eden dead census either (got {})", + stats.eden_dead_bytes + ); +} From 1bbf4a3f7cb111099db31ebecdb115ad6af0acc4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ralph=20K=C3=BCpper?= Date: Sat, 8 Aug 2026 01:50:23 +0200 Subject: [PATCH 3/4] docs(changelog): #7613 promote-on-first-copy seed Claude-Session: https://claude.ai/code/session_01Y1QZ5wUP9gRSwpiweT4Wix --- changelog.d/7613-promote-on-first-copy.md | 77 +++++++++++++++++++++++ 1 file changed, 77 insertions(+) create mode 100644 changelog.d/7613-promote-on-first-copy.md diff --git a/changelog.d/7613-promote-on-first-copy.md b/changelog.d/7613-promote-on-first-copy.md new file mode 100644 index 0000000000..0176263dcb --- /dev/null +++ b/changelog.d/7613-promote-on-first-copy.md @@ -0,0 +1,77 @@ +### GC: long-lived cohorts are copied once, not twice — the promote-on-first-copy seed (#7598) + +#7592's remainder. On promote-heavy workloads every long-lived object was copied +**twice** — Eden → survivor by one copying minor, survivor → old by the next. +`json_pipeline` at 500k: cycle 3 copied 280,997,080 bytes into the survivor +space, cycle 4 promoted the same bytes out of it. + +`gc/tenuring.rs` already computed the right condition. The survival-rate lock +keys on `prev_copied`, so it needs a **previous copying minor** to have filled +the survivor space — it engaged for cycle 4, which is precisely why the waste +was confined to cycle 3. The obstacle was latency, not blindness. + +**Every collection that reaches the mark-sweep path already walks every Eden +header and classifies it live or dead.** Those are exactly the two blind spots +of `retune_after_scavenge`, which only copying minors feed — a full mark-sweep, +or a non-copying minor fallback. So the census is read there, and when the +surviving Eden cohort exceeds the desired survivor occupancy (the module's +*existing* occupancy rule, read from a different place) **and** ≥90% of the +classified Eden bytes were live, the existing `PROMOTE_LOCK` engages and the +next copying minor *enters* at S=1. Exit stays the influx-based unlock, so the +seed adds no new oscillation path. + +**Why the signal is not a fixed point of the policy reading it.** This issue +produced three self-referential signals before this one: `promoted_bytes` is +zero by construction at S=4; #7596's first nursery cap gated from-space +occupancy on a total that included from-space; #7594's handoff scheduled a +non-moving full to relieve pressure only a moving cycle can relieve. The Eden +live/dead split cannot have that structure — it is produced by the mark-sweep's +own arena walk, whose marks come from reachability, and neither the mark phase +nor the sweep reads `tenuring_survivals()`. The threshold is consulted in +exactly one place, `copying.rs`'s per-object move, which this path does not run. +Measured while S was still 4: `eden_live_bytes=279,964,968 eden_dead_bytes=896 +live_pct=99 seeds=true`. + +**Determinism (#7432)** is preserved by construction: written at the end of a +completed sweep, read at the entry of a later copying minor, which snapshots it +once. Two callsite exclusions keep the *input* sound as well — budgeted cycles +(allocate-black marks every mid-cycle birth, so a churn Eden would read ~100% +live) and cycles that ran the conservative native-stack scan (its retention +varies run to run, so a policy fed from it would make the gated copy/promote +counters non-deterministic). + +**Measured**, `json_pipeline`, output hash identical on every row: + +| records | arm | collections | copied_bytes | promoted_bytes | bytes moved | +|--:|---|--:|--:|--:|--:| +| 200k | before | 4 | 113,227,216 | 114,275,776 | 227,502,992 | +| 200k | after | 3 | **0** | 113,227,216 | **113,227,216** | +| 500k | before | 4 | 280,997,080 | 282,045,656 | 563,042,736 | +| 500k | after | 3 | **0** | 280,997,080 | **280,997,080** | + +Bytes moved **0.498× / 0.499×** — halved, which is the signature that separates +this from a cadence change. The per-cycle trace makes that explicit: cycles 1–3 +are unchanged in kind, trigger, `old_before` and `eden_live` — cycle 3 receives +the *same 280,997,080 bytes to the byte* and merely sends them to old-gen +instead of the survivor space. The cycle that disappears is the old cycle 4, +whose own Eden influx was 1.0 MB and whose entire content was the second copy. + +On the pinned quiet host (`perry-macos`, 5 interleaved reps, `cmp`-identical +output): 200k **1.85 s → 1.44 s (−22.2%)**, peak RSS 608.7 MB → 485.7 MB +(**−20.2%**); 500k **5.12 s → 3.86 s (−24.6%)**, peak RSS 1,404.5 MB → +1,109.8 MB (**−21.0%**). + +**RSS goes down, not up**, which is the opposite of the design note's +expectation. Promoting earlier does raise the old-gen high-water mark, but it +removes a larger term: at S=4 the 268 MB cohort exists twice at once at the +peak, as Eden from-space plus survivor to-space. This is the first change in +this campaign whose wall-time win is not traded against RSS. + +**gc-ratchet (`--check`, `pinned_host`, on the #7609 baseline):** OK. Every +semantic counter on all 12 probes is **bit-identical** to a `main`-arm run +measured in the same session on the same host — the only differing cells are +`rss_bytes`/`peak_rss_bytes`/`wall_ms`, all inside band and all moving in both +arms. The seed does not fire on any probe, and the diagnostic proves that is a +*refusal*, not an absence: `12_large_live_set` prints `live_pct=36 seeds=false`. +`12_large_live_set.wall_ms` — #7610's flagged cell — reads −13.11% on the +`main` arm and −13.14% on this one, so this change does not move it. From a6a0eb1197f1d0d599db9d553a52f43e7c57ec39 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ralph=20K=C3=BCpper?= Date: Sat, 8 Aug 2026 02:02:46 +0200 Subject: [PATCH 4/4] chore(version): bump to 0.5.1349 --- CLAUDE.md | 2 +- Cargo.lock | 152 ++++++++++++++++++++++++++--------------------------- Cargo.toml | 2 +- 3 files changed, 78 insertions(+), 78 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index 1b20620c71..bc77440c37 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -8,7 +8,7 @@ This file provides guidance to Claude Code (claude.ai/code) when working with co Perry is a native TypeScript compiler written in Rust that compiles TypeScript source code directly to native executables. It uses SWC for TypeScript parsing and LLVM for code generation. -**Current Version:** 0.5.1348 +**Current Version:** 0.5.1349 ## TypeScript Parity Status diff --git a/Cargo.lock b/Cargo.lock index a0f62a037d..eb5855e594 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -5547,7 +5547,7 @@ checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220" [[package]] name = "perry" -version = "0.5.1348" +version = "0.5.1349" dependencies = [ "anyhow", "base64", @@ -5607,14 +5607,14 @@ dependencies = [ [[package]] name = "perry-api-manifest" -version = "0.5.1348" +version = "0.5.1349" dependencies = [ "serde", ] [[package]] name = "perry-audio-miniaudio" -version = "0.5.1348" +version = "0.5.1349" dependencies = [ "cc", "libc", @@ -5622,7 +5622,7 @@ dependencies = [ [[package]] name = "perry-codegen" -version = "0.5.1348" +version = "0.5.1349" dependencies = [ "anyhow", "inkwell", @@ -5639,7 +5639,7 @@ dependencies = [ [[package]] name = "perry-codegen-arkts" -version = "0.5.1348" +version = "0.5.1349" dependencies = [ "anyhow", "perry-hir", @@ -5647,7 +5647,7 @@ dependencies = [ [[package]] name = "perry-codegen-glance" -version = "0.5.1348" +version = "0.5.1349" dependencies = [ "anyhow", "perry-hir", @@ -5655,7 +5655,7 @@ dependencies = [ [[package]] name = "perry-codegen-js" -version = "0.5.1348" +version = "0.5.1349" dependencies = [ "anyhow", "perry-dispatch", @@ -5664,7 +5664,7 @@ dependencies = [ [[package]] name = "perry-codegen-swiftui" -version = "0.5.1348" +version = "0.5.1349" dependencies = [ "anyhow", "perry-hir", @@ -5672,7 +5672,7 @@ dependencies = [ [[package]] name = "perry-codegen-wasm" -version = "0.5.1348" +version = "0.5.1349" dependencies = [ "anyhow", "base64", @@ -5684,7 +5684,7 @@ dependencies = [ [[package]] name = "perry-codegen-wear-tiles" -version = "0.5.1348" +version = "0.5.1349" dependencies = [ "anyhow", "perry-hir", @@ -5692,7 +5692,7 @@ dependencies = [ [[package]] name = "perry-container-compose" -version = "0.5.1348" +version = "0.5.1349" dependencies = [ "anyhow", "async-trait", @@ -5721,14 +5721,14 @@ dependencies = [ [[package]] name = "perry-container-e2e" -version = "0.5.1348" +version = "0.5.1349" dependencies = [ "anyhow", ] [[package]] name = "perry-diagnostics" -version = "0.5.1348" +version = "0.5.1349" dependencies = [ "serde", "serde_json", @@ -5736,7 +5736,7 @@ dependencies = [ [[package]] name = "perry-dispatch" -version = "0.5.1348" +version = "0.5.1349" [[package]] name = "perry-doc-fixture-my-bindings" @@ -5747,7 +5747,7 @@ dependencies = [ [[package]] name = "perry-doc-tests" -version = "0.5.1348" +version = "0.5.1349" dependencies = [ "anyhow", "clap", @@ -5762,7 +5762,7 @@ dependencies = [ [[package]] name = "perry-ext-ads" -version = "0.5.1348" +version = "0.5.1349" dependencies = [ "block2", "objc2", @@ -5772,7 +5772,7 @@ dependencies = [ [[package]] name = "perry-ext-argon2" -version = "0.5.1348" +version = "0.5.1349" dependencies = [ "argon2", "perry-ffi", @@ -5780,7 +5780,7 @@ dependencies = [ [[package]] name = "perry-ext-axios" -version = "0.5.1348" +version = "0.5.1349" dependencies = [ "perry-ffi", "reqwest", @@ -5789,7 +5789,7 @@ dependencies = [ [[package]] name = "perry-ext-bcrypt" -version = "0.5.1348" +version = "0.5.1349" dependencies = [ "bcrypt", "perry-ffi", @@ -5797,7 +5797,7 @@ dependencies = [ [[package]] name = "perry-ext-better-sqlite3" -version = "0.5.1348" +version = "0.5.1349" dependencies = [ "perry-ffi", "rusqlite", @@ -5805,7 +5805,7 @@ dependencies = [ [[package]] name = "perry-ext-cheerio" -version = "0.5.1348" +version = "0.5.1349" dependencies = [ "perry-ffi", "scraper", @@ -5813,7 +5813,7 @@ dependencies = [ [[package]] name = "perry-ext-commander" -version = "0.5.1348" +version = "0.5.1349" dependencies = [ "perry-ffi", "perry-runtime", @@ -5821,7 +5821,7 @@ dependencies = [ [[package]] name = "perry-ext-cron" -version = "0.5.1348" +version = "0.5.1349" dependencies = [ "chrono", "cron", @@ -5831,7 +5831,7 @@ dependencies = [ [[package]] name = "perry-ext-dayjs" -version = "0.5.1348" +version = "0.5.1349" dependencies = [ "chrono", "perry-ffi", @@ -5839,7 +5839,7 @@ dependencies = [ [[package]] name = "perry-ext-decimal" -version = "0.5.1348" +version = "0.5.1349" dependencies = [ "perry-ffi", "rust_decimal", @@ -5847,7 +5847,7 @@ dependencies = [ [[package]] name = "perry-ext-dotenv" -version = "0.5.1348" +version = "0.5.1349" dependencies = [ "perry-ffi", "serde_json", @@ -5855,7 +5855,7 @@ dependencies = [ [[package]] name = "perry-ext-ethers" -version = "0.5.1348" +version = "0.5.1349" dependencies = [ "perry-ffi", "rand 0.10.1", @@ -5863,7 +5863,7 @@ dependencies = [ [[package]] name = "perry-ext-events" -version = "0.5.1348" +version = "0.5.1349" dependencies = [ "perry-ffi", "perry-runtime", @@ -5871,14 +5871,14 @@ dependencies = [ [[package]] name = "perry-ext-exponential-backoff" -version = "0.5.1348" +version = "0.5.1349" dependencies = [ "perry-ffi", ] [[package]] name = "perry-ext-fastify" -version = "0.5.1348" +version = "0.5.1349" dependencies = [ "bytes", "http-body-util", @@ -5896,7 +5896,7 @@ dependencies = [ [[package]] name = "perry-ext-fetch" -version = "0.5.1348" +version = "0.5.1349" dependencies = [ "bytes", "lazy_static", @@ -5909,7 +5909,7 @@ dependencies = [ [[package]] name = "perry-ext-http" -version = "0.5.1348" +version = "0.5.1349" dependencies = [ "bytes", "h2", @@ -5933,7 +5933,7 @@ dependencies = [ [[package]] name = "perry-ext-ioredis" -version = "0.5.1348" +version = "0.5.1349" dependencies = [ "lazy_static", "perry-ffi", @@ -5943,7 +5943,7 @@ dependencies = [ [[package]] name = "perry-ext-jsonwebtoken" -version = "0.5.1348" +version = "0.5.1349" dependencies = [ "base64", "jsonwebtoken", @@ -5954,7 +5954,7 @@ dependencies = [ [[package]] name = "perry-ext-lru-cache" -version = "0.5.1348" +version = "0.5.1349" dependencies = [ "lru", "perry-ffi", @@ -5963,7 +5963,7 @@ dependencies = [ [[package]] name = "perry-ext-moment" -version = "0.5.1348" +version = "0.5.1349" dependencies = [ "chrono", "perry-ffi", @@ -5971,7 +5971,7 @@ dependencies = [ [[package]] name = "perry-ext-mongodb" -version = "0.5.1348" +version = "0.5.1349" dependencies = [ "bson", "futures-util", @@ -5983,7 +5983,7 @@ dependencies = [ [[package]] name = "perry-ext-mysql2" -version = "0.5.1348" +version = "0.5.1349" dependencies = [ "chrono", "perry-ffi", @@ -5993,7 +5993,7 @@ dependencies = [ [[package]] name = "perry-ext-nanoid" -version = "0.5.1348" +version = "0.5.1349" dependencies = [ "nanoid", "perry-ffi", @@ -6002,7 +6002,7 @@ dependencies = [ [[package]] name = "perry-ext-net" -version = "0.5.1348" +version = "0.5.1349" dependencies = [ "bytes", "perry-ffi", @@ -6015,7 +6015,7 @@ dependencies = [ [[package]] name = "perry-ext-node-forge" -version = "0.5.1348" +version = "0.5.1349" dependencies = [ "const-oid 0.9.6", "der 0.7.10", @@ -6034,7 +6034,7 @@ dependencies = [ [[package]] name = "perry-ext-nodemailer" -version = "0.5.1348" +version = "0.5.1349" dependencies = [ "lettre", "perry-ffi", @@ -6044,7 +6044,7 @@ dependencies = [ [[package]] name = "perry-ext-pdf" -version = "0.5.1348" +version = "0.5.1349" dependencies = [ "perry-ffi", "printpdf", @@ -6052,7 +6052,7 @@ dependencies = [ [[package]] name = "perry-ext-pg" -version = "0.5.1348" +version = "0.5.1349" dependencies = [ "perry-ffi", "sqlx", @@ -6061,7 +6061,7 @@ dependencies = [ [[package]] name = "perry-ext-ratelimit" -version = "0.5.1348" +version = "0.5.1349" dependencies = [ "governor", "perry-ffi", @@ -6069,7 +6069,7 @@ dependencies = [ [[package]] name = "perry-ext-sharp" -version = "0.5.1348" +version = "0.5.1349" dependencies = [ "fast_image_resize", "image", @@ -6079,14 +6079,14 @@ dependencies = [ [[package]] name = "perry-ext-slugify" -version = "0.5.1348" +version = "0.5.1349" dependencies = [ "perry-ffi", ] [[package]] name = "perry-ext-streams" -version = "0.5.1348" +version = "0.5.1349" dependencies = [ "lazy_static", "perry-ffi", @@ -6095,7 +6095,7 @@ dependencies = [ [[package]] name = "perry-ext-undici" -version = "0.5.1348" +version = "0.5.1349" dependencies = [ "perry-ffi", "perry-runtime", @@ -6104,7 +6104,7 @@ dependencies = [ [[package]] name = "perry-ext-uuid" -version = "0.5.1348" +version = "0.5.1349" dependencies = [ "perry-ffi", "uuid", @@ -6112,7 +6112,7 @@ dependencies = [ [[package]] name = "perry-ext-validator" -version = "0.5.1348" +version = "0.5.1349" dependencies = [ "perry-ffi", "regex", @@ -6122,7 +6122,7 @@ dependencies = [ [[package]] name = "perry-ext-ws" -version = "0.5.1348" +version = "0.5.1349" dependencies = [ "futures-util", "lazy_static", @@ -6135,7 +6135,7 @@ dependencies = [ [[package]] name = "perry-ext-zlib" -version = "0.5.1348" +version = "0.5.1349" dependencies = [ "brotli", "flate2", @@ -6145,7 +6145,7 @@ dependencies = [ [[package]] name = "perry-ffi" -version = "0.5.1348" +version = "0.5.1349" dependencies = [ "dashmap", "once_cell", @@ -6154,7 +6154,7 @@ dependencies = [ [[package]] name = "perry-hir" -version = "0.5.1348" +version = "0.5.1349" dependencies = [ "anyhow", "perry-api-manifest", @@ -6172,7 +6172,7 @@ dependencies = [ [[package]] name = "perry-parser" -version = "0.5.1348" +version = "0.5.1349" dependencies = [ "anyhow", "perry-diagnostics", @@ -6184,7 +6184,7 @@ dependencies = [ [[package]] name = "perry-runtime" -version = "0.5.1348" +version = "0.5.1349" dependencies = [ "anyhow", "base64", @@ -6226,14 +6226,14 @@ dependencies = [ [[package]] name = "perry-runtime-static" -version = "0.5.1348" +version = "0.5.1349" dependencies = [ "perry-runtime", ] [[package]] name = "perry-stdlib" -version = "0.5.1348" +version = "0.5.1349" dependencies = [ "aes 0.8.4", "aes 0.9.1", @@ -6328,14 +6328,14 @@ dependencies = [ [[package]] name = "perry-stdlib-static" -version = "0.5.1348" +version = "0.5.1349" dependencies = [ "perry-stdlib", ] [[package]] name = "perry-transform" -version = "0.5.1348" +version = "0.5.1349" dependencies = [ "anyhow", "perry-hir", @@ -6344,14 +6344,14 @@ dependencies = [ [[package]] name = "perry-ui" -version = "0.5.1348" +version = "0.5.1349" dependencies = [ "perry-ui-model", ] [[package]] name = "perry-ui-android" -version = "0.5.1348" +version = "0.5.1349" dependencies = [ "base64", "itoa", @@ -6368,7 +6368,7 @@ dependencies = [ [[package]] name = "perry-ui-geisterhand" -version = "0.5.1348" +version = "0.5.1349" dependencies = [ "rand 0.10.1", "serde", @@ -6378,7 +6378,7 @@ dependencies = [ [[package]] name = "perry-ui-gtk4" -version = "0.5.1348" +version = "0.5.1349" dependencies = [ "base64", "cairo-rs 0.22.0", @@ -6401,7 +6401,7 @@ dependencies = [ [[package]] name = "perry-ui-ios" -version = "0.5.1348" +version = "0.5.1349" dependencies = [ "base64", "block2", @@ -6417,7 +6417,7 @@ dependencies = [ [[package]] name = "perry-ui-macos" -version = "0.5.1348" +version = "0.5.1349" dependencies = [ "base64", "block2", @@ -6432,7 +6432,7 @@ dependencies = [ [[package]] name = "perry-ui-model" -version = "0.5.1348" +version = "0.5.1349" [[package]] name = "perry-ui-test" @@ -6443,11 +6443,11 @@ dependencies = [ [[package]] name = "perry-ui-testkit" -version = "0.5.1348" +version = "0.5.1349" [[package]] name = "perry-ui-tvos" -version = "0.5.1348" +version = "0.5.1349" dependencies = [ "base64", "block2", @@ -6463,7 +6463,7 @@ dependencies = [ [[package]] name = "perry-ui-visionos" -version = "0.5.1348" +version = "0.5.1349" dependencies = [ "base64", "block2", @@ -6479,7 +6479,7 @@ dependencies = [ [[package]] name = "perry-ui-watchos" -version = "0.5.1348" +version = "0.5.1349" dependencies = [ "block2", "libc", @@ -6492,7 +6492,7 @@ dependencies = [ [[package]] name = "perry-ui-windows" -version = "0.5.1348" +version = "0.5.1349" dependencies = [ "base64", "libc", @@ -6509,14 +6509,14 @@ dependencies = [ [[package]] name = "perry-ui-windows-winui" -version = "0.5.1348" +version = "0.5.1349" dependencies = [ "perry-ui-windows", ] [[package]] name = "perry-updater" -version = "0.5.1348" +version = "0.5.1349" dependencies = [ "anyhow", "base64", @@ -6532,7 +6532,7 @@ dependencies = [ [[package]] name = "perry-wasm-host" -version = "0.5.1348" +version = "0.5.1349" dependencies = [ "wasmi", ] diff --git a/Cargo.toml b/Cargo.toml index 555c483a4a..a50487d252 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -315,7 +315,7 @@ codegen-units = 16 codegen-units = 16 [workspace.package] -version = "0.5.1348" +version = "0.5.1349" edition = "2021" license = "MIT" repository = "https://github.com/PerryTS/perry"