perf(gc): minor collections don't walk/rebuild over old-gen (#6181)#6213
Conversation
…6181) A minor collection ran the from-scratch OldToYoungRememberedRebuildState walk in AtomicFinalize->RememberedSetRebuild — a full ArenaObjectCursor over every arena object plus the entire malloc registry, on every minor (with require_marked=false, so it even resurrected dead-but-unswept old parents). That makes minor pauses grow with total heap / process lifetime. A minor's old→young remembered set is already maintained without that walk: - the codegen + runtime write barriers record every old→young edge at mutation time; - this cycle's evacuation-created edges are re-remembered by evacuation_sticky (rebuild_evacuated_old_to_young_remembered_set over the cycle's moved headers, built in atomic_finalize_minor_prelude); - reclaim's restore_surviving_dirty_coverage (#5029) rescans the pre-clear dirty snapshot with the same slot predicate the evacuation verifier uses and re-remembers any slot still pointing into the nursery. So for minors, skip the whole-heap rebuild and leave live_old_to_young_sticky None; reclaim then restores only evacuation_sticky + the dirty snapshot. Full cycles are unchanged: they still run the authoritative require_marked whole-heap rebuild (they are inherently O(heap)). Safety (no dropped old→young edge / UAF): every old→young edge present at reclaim is either (a) mutator-created — recorded by the barrier, hence in the pre-clear dirty snapshot and re-remembered by restore_surviving_dirty_coverage when the child is still young, or (b) evacuation-created — covered by evacuation_sticky. The state-machine minor never promotes into OLD_ARENA except via tracked evacuation (age-bump only flags TENURED in place, staying nursery-classified), so no untracked promotion introduces an edge. The copying fast path is a separate code path and already handles its promoted headers via its own rebuild_evacuated_old_to_young_remembered_set(moved). Observability: GcCycleTrace gains remembered_set.rebuild_objects_scanned (the rebuild walk's object-visit count) so the skip is provable — 0 for a minor, O(heap) for a full cycle. Tests: - test_minor_skips_whole_heap_old_to_young_rebuild: a minor over a large pinned old-gen reports rebuild_objects_scanned == 0; a full cycle over the same heap reports >= the old-object count. - test_minor_preserves_old_to_young_edge_across_minors: an old→young edge recorded before a minor survives 4 successive minors (RS root marking keeps reaching the young child), i.e. the under-remembering guard. Fix 1 from #6181 (restrict the minor SWEEP's old-gen object walk) is NOT in this change — see the PR description for the specific mark-invariant blocker. Reference: #6181; 2026-07-09 GC audit.
|
No actionable comments were generated in the recent review. 🎉 ℹ️ Recent review info⚙️ Run configurationConfiguration used: defaults Review profile: CHILL Plan: Pro Plus Run ID: 📒 Files selected for processing (4)
📝 WalkthroughWalkthroughMinor GC cycles now skip the whole-heap old→young remembered-set rebuild, transitioning directly to Sweep, while full cycles record a new objects-scanned count. ChangesMinor cycle rebuild skip and scanned-object tracking
Estimated code review effort: 3 (Moderate) | ~25 minutes Sequence Diagram(s)sequenceDiagram
participant Cycle as GcCycle
participant Rebuild as OldToYoungRememberedRebuildState
participant Trace as GcCycleTrace
Cycle->>Cycle: RememberedSetRebuild subphase
alt minor cycle
Cycle->>Cycle: skip rebuild, set phase Sweep
else full cycle
Cycle->>Rebuild: step(budget)
Rebuild-->>Cycle: finish() -> live_old_to_young_sticky
Cycle->>Trace: old_to_young_rebuild_objects_scanned
Cycle->>Cycle: set subphase WeakProcessing
end
Possibly related issues
Possibly related PRs
Suggested labels: 🚥 Pre-merge checks | ✅ 5✅ Passed checks (5 passed)
✨ Finishing Touches📝 Generate docstrings
🧪 Generate unit tests (beta)
Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out. Comment |
Summary
Minor GCs did O(total-heap) work every cycle by rebuilding the old→young
remembered set from a whole-heap walk, so minor pauses grew with heap size /
process lifetime (#6181; 2026-07-09 GC audit §7). This PR ships Fix 2 of
that item and defers Fix 1 with a specific, documented blocker (below).
Fix 2 (shipped): minor collections no longer run the from-scratch
OldToYoungRememberedRebuildStatewalk (a fullArenaObjectCursorover everyarena object + the entire malloc registry) in
AtomicFinalize → RememberedSetRebuild. Full cycles are unchanged.The invariant each removed old-gen touch maintained, and how it's preserved
The whole-heap rebuild produced
live_old_to_young_sticky, restored inReclaimafterremembered_set_clear, to re-establish every survivingold→young edge so the next minor's remembered-set root scan marks the young
children. For a minor that job is already done by three mechanisms that run
regardless:
maintained incrementally during the mutator phase).
evacuation_sticky(rebuild_evacuated_old_to_young_remembered_setover this cycle's moved headers, built in
atomic_finalize_minor_preludeand restored in
Reclaim) re-adds the old→young edges that this cycle'sevacuation created — evac copies land in OLD_ARENA after the RS scan.
restore_surviving_dirty_coverage(CI: gc_write_barrier_stress red on main (missing old→young remembered-set edges → segfault) #5029) rescans the pre-clear dirtysnapshot with the same slot predicate the evacuation verifier uses and
re-remembers any slot still pointing into the nursery.
So for minors we skip the rebuild and leave
live_old_to_young_sticky= None;Reclaimrestores onlyevacuation_sticky+ the dirty-snapshot repair. Therequire_marked=false full walk was not only redundant here but slightly worse:
it re-remembered dead-but-unswept old parents (over-retention).
RS-edge safety argument (no under-remembering / UAF)
Under-remembering is a use-after-free, so this is the load-bearing claim.
Every old→young edge present at reclaim time is one of:
store happened, so the page is in the pre-clear dirty snapshot;
restore_surviving_dirty_coveragere-remembers the slot iff the child stillneeds tracking (
remembered_child_needs_tracking: nursery — includingTENURED-in-nursery — or a live malloc child). If the child was promoted to
OLD_ARENA this cycle, the slot was rewritten to the old copy and correctly
dropped. This is self-maintaining across cycles by induction: each cycle's
restore keeps the parent's page dirty for the next.
evacuation_sticky.There is no third source in the state-machine minor: it never moves an object
into OLD_ARENA except via tracked evacuation. The age-bump only flips
GC_FLAG_TENUREDin place (the object stays physically nursery, henceclassify_heap_generation == Nursery), so a "silent promotion" that wouldcreate an untracked old→young edge cannot happen. The copying fast path is a
separate code path (
gc/copying.rs) that this change does not touch and thatalready re-remembers its promoted headers via its own
rebuild_evacuated_old_to_young_remembered_set(collector.moved_headers).restore_surviving_dirty_coveragederives its kept set from the same walk thePERRY_GC_VERIFY_EVACUATIONverifier performs, which per #5029 iscomplete-by-construction relative to that verifier — it is the authoritative
mechanism, and historically more complete than the from-scratch rebuild
(#5029 measured the rebuild covering ~10 of ~130 needed pages before the
snapshot repair was added).
Trace-based proof of bounded old work
GcCycleTracegainsremembered_set.rebuild_objects_scanned— the object-visitcount of the whole-heap rebuild walk.
test_minor_skips_whole_heap_old_to_young_rebuild: a minor over a largepinned old-gen (64 old objects) reports
rebuild_objects_scanned == 0(also asserted in the trace JSON); a full cycle over the same heap reports
>= 64, proving the counter is wired and the minor's 0 is a genuine skip.test_minor_preserves_old_to_young_edge_across_minors: an old→young edgerecorded (via the write barrier) before a minor survives 4 successive
minors — after each, RS root marking still reaches and marks the young
child that is reachable only through the old parent. The child's mark is
cleared between cycles so each minor must re-derive coverage from the
remembered set alone. This is the under-remembering guard.
Test results
PERRY_NO_AUTO_OPTIMIZE=1 cargo test -p perry-runtime --lib -- --test-threads=1→ 1205 passed; 0 failed.
PERRY_GC_VERIFY_EVACUATION=1 … gc::→ no evacuation-verify panics(no
missing_edges/ stale-forwarded /old-young-edge-verifier failed).Two
gc::tests::copying::*_rewrites_exact_*_pointer_*_onlytests fail underthat env var because they hard-assert exact
layout_scans.masked_pointer_slots_readcounts that the VERIFY-gated
verify_old_to_young_edges_covered()pass ingc/copying.rsperturbs — verified to fail identically on the origin/mainbase (c78903c) without this change (0 passed / 2 failed), i.e.
pre-existing env-var sensitivity in the copying fast path, which this PR does
not touch.
cargo fmt --all -- --checkclean. No file > 2000 lines.Fix 1 (deferred): restrict the minor SWEEP's old-gen object walk — blocker
Fix 1 (make the minor sweep cursor skip the old-gen region) has a concrete
mark-invariant blocker I could not cleanly preserve within a correctness-first
scope, so per the task guidance I did not guess:
young→old-reachable old objects (
mark_mutable_root_slots_stepusestry_mark_value, which marks any heap object), and the minor sweepfinalizes unmarked old objects (freed/dead-accounted). The sweep's old-gen
walk is therefore load-bearing: it clears the MARKED bit those minor
marks set on old objects.
clear_marksis#[cfg(test)]only) — it assumes marks are clean at entry, which today holds only because
the minor sweep clears the old marks it set. If a minor skipped the old-gen
walk, root/young-reachable old objects would retain stale MARKED across to
the next full GC, which would then treat them as already-marked, skip tracing
their fields, and free their exclusive old children — a UAF.
A safe Fix 1 needs one of: (i) suppress old-object marking in minors (a
minor-specific gate in the hot, shared
try_mark_valuepath), or (ii) afull-GC mark-clear pass plus track the (bounded) set of old objects marked
this minor for cheap clearing plus delta-maintain the
old_page_*live/dead/pinned/dirty accounting and preserve old-page evacuation's reliance
on accurate per-cycle marks. That is a larger, riskier change than fits here;
Fix 2 alone removes the bigger cost anyway (an O(all objects) walk, a
superset of the O(old-gen) sweep walk).
Reference: #6181; 2026-07-09 GC audit.
Summary by CodeRabbit
Bug Fixes
Tests