feat(gc): moving (copying) GC is now the default — safepoint-triggered, precise-root (Phases 1-4) - #6134
Conversation
First step of the "one great GC" project: make Perry's already-built precise/generational/MOVING minor actually run, by triggering it at a precise-root safepoint instead of at arbitrary allocation points. Background: the copying minor (gc_collect_minor_copying_fast_path) is fully built but never runs today. The nursery-churn arm forces a conservative native-stack scan at the alloc point (a mid-construction value may live only in a register there), which makes the copying minor ineligible (CopiedMinorEligibility::evaluate -> ConservativeStack) so the non-moving minor runs. Everything else the moving minor needs is already satisfied: barriers on, zero copy-only scanners in production, a shadow stack that binds each live local to its real alloca so copied-minor GC can rewrite roots. This commit adds gc_safepoint_moving_minor(), called from the outermost microtask-pump boundary (js_promise_run_microtasks, depth==1). At that point the JS stack has fully unwound: no live register temporaries, every live heap value is a named local on the shadow stack or a registered root. So the copying minor is eligible with PRECISE, rewritable roots and no force_full_scan — it MOVES (compacting, O(survivors), no sweep). Trigger detection (ArenaBytes/MallocCount) and re-baseline mirror the nursery-churn arm; OldReclaim stays on its existing full-sweep path. Purely additive and gated behind PERRY_GC_MOVING_SAFEPOINT (default off) while the moving path is validated; the alloc-point fallback is untouched. Programs that yield to the event loop (servers especially — the RSS-sensitive case) get compacting young collection at safepoints.
Adds observability for the moving (copying) young-gen minor: under PERRY_GC_DIAG, each attempt logs whether it was eligible or the fallback reason (barriers_inactive / conservative_stack / copy_only_roots / pinned_young_* etc.), and each successful run logs copied_objects, copied_bytes, promoted_objects, freed_bytes. Without this the copying minor was invisible — PERRY_GC_DIAG only showed the sweep/evac-policy paths, so there was no way to tell whether a nursery collection actually moved survivors or fell back to the non-moving minor. Needed to validate the safepoint-triggered moving minor (it revealed that alloc-point minors fall back with conservative_stack by design while the event-loop-safepoint minor runs eligible and copies survivors).
…f-range size Hardening for the copying minor (not a full fix; see the copying-minor relocation issue). A genuine young/survivor object is always small — large objects are allocated old-gen/malloc, never in the copying nursery — so a "young" object classified with a size below the header size or above a nursery block is a corrupt / mis-classified header (e.g. an off-heap typed-array pointer whose preceding bytes coincidentally pass plausible_gc_header). move_young previously trusted (*header).size unconditionally and could drive a wild out-of-bounds std::ptr::copy_nonoverlapping -> SIGSEGV. This refuses to relocate such an object (leaves it in place, which is correct for a real off-heap object kept live by its own side-table) and surfaces it under PERRY_GC_DIAG as [gc-move-guard]. It turns undefined behavior from a corrupt header into a no-op; it does NOT catch a plausible-but-wrong *small* size, so the root classification fix is still required.
…isting relocation bugs)
|
Note Reviews pausedIt looks like this branch is under active development. To avoid overwhelming you with review comments due to an influx of new commits, CodeRabbit has automatically paused this review. You can configure this behavior by changing the Use the following commands to manage reviews:
Use the checkboxes below for quick actions:
📝 WalkthroughWalkthroughAdds a size guard and diagnostics to the copying minor GC path, introduces a moving-safepoint minor GC entrypoint with deferred nursery triggering, and wires safepoint polling through microtasks and loop back-edges. ChangesMoving safepoint GC
Estimated code review effort: 4 (Complex) | ~45 minutes 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 |
There was a problem hiding this comment.
Actionable comments posted: 1
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
Inline comments:
In `@crates/perry-runtime/src/gc/copying.rs`:
- Around line 1154-1162: The gc-copy-minor diagnostic in copying.rs is missing
promoted byte volume, so update the PERRY_GC_DIAG logging in the minor collector
path to include collector.stats.promoted_bytes alongside copied_bytes,
promoted_objects, and freed_bytes. Keep the change localized to the existing
eprintln! call so the run diagnostic reports promotion volume when validating
the moving minor path.
🪄 Autofix (Beta)
Fix all unresolved CodeRabbit comments on this PR:
- Push a commit to this branch (recommended)
- Create a new PR with the fixes
ℹ️ Review info
⚙️ Run configuration
Configuration used: defaults
Review profile: CHILL
Plan: Pro Plus
Run ID: dc9d395d-2fee-4144-86b6-7e7abe0b4004
📒 Files selected for processing (3)
crates/perry-runtime/src/gc/copying.rscrates/perry-runtime/src/gc/policy.rscrates/perry-runtime/src/promise/microtasks.rs
…oints (moving primary) Extends the moving-GC project so the copying minor can be the PRIMARY collector for tight synchronous loops, not just at the event-loop boundary. All gated behind the experimental PERRY_GC_MOVING_SAFEPOINT opt-in (compile-time for the codegen polls, runtime for the collection); default binaries are unchanged and carry zero loop overhead. Phase 3 (deferral, runtime): when moving mode is on, the alloc-point nursery- churn arm no longer runs the conservative non-moving minor mid-expression — it sets GC_SAFEPOINT_PENDING and returns, deferring the collection to the next precise-root safepoint. A hard cap (256 MB committed) is the safety valve: a pathological single mega-expression that reaches no safepoint falls back to the non-moving minor so growth stays bounded. Phase 2 (codegen polls): emit js_gc_loop_safepoint() at loop back-edges (after clear_loop_body_shadow_slots, where the body expression has completed so roots are precise). The runtime poll drains a pending deferral by running the moving minor. Gated at compile time (moving_safepoint_polls_enabled) so default builds emit nothing. Status: validated end-to-end for the generic while/do-while/for back-edges (poll fires; output byte-identical to the non-moving GC). KNOWN GAP: the specialized/versioned for-loop lowering paths (i32-bound, packed-f64/i32/u32, bulk-fill) and for-of/for-in don't emit the poll yet, so a hot loop on one of those paths defers to the event-loop safepoint instead — the remaining Phase 2 codegen-coverage work, documented at emit_gc_loop_safepoint.
…al old-gen Flips the moving GC on by default — it is now "the" GC, not an experiment. The copying minor runs at the event-loop safepoint and at loop back-edge polls, moving survivors (compacting, O(survivors), no sweep); the alloc-point path defers to those safepoints. `PERRY_GC_MOVING_SAFEPOINT=0` is a single kill switch that reverts to the non-moving path for regression bisection — not a parallel fallback maze. Changes: - gc_moving_safepoint_enabled + moving_safepoint_polls_enabled default to ON (kill switch is an explicit =0/off/false), so both the runtime collection and the codegen loop polls are on by default and stay coherent. - Lower the deferral hard cap 256->128 MB so a synchronous loop on a specialized lowering path that doesn't yet emit the poll can't balloon RSS before the alloc-point valve fires. - Phase 4 (opt-in, PERRY_GC_INCREMENTAL, default off): unblock the incremental old-gen budgeted stepper without converting all 88 mutable root scanners — when on, registered_root_scanners_block_budgeted_gc() stops blocking on unbudgeted mutable scanners and the stepper runs them synchronously in its initial root-scan step (bounded initial-mark pause), then marks/sweeps the old gen incrementally. Validated: default output byte-identical to the kill switch across a spread of programs (classes/closures/Map/Set/WeakMap/async/recursion/JSON, retained graphs, object-keyed maps), moving fires by default (copied 171 / promoted 6722 on the stress test), zero crashes. Known hardening items (test + harden in place): #6132 (codegen Array+TypedArray bug — corrupts the heap, so moving can crash on those programs; highest priority), #6133 (old-page force-evac), and loop poll coverage on the specialized/for-of lowering paths.
…eiver is a member `for (let i = 0; i < n.buf.length; i++) ... n.buf[i]` where `n.buf` is a Uint32Array (or any typed array) accessed as a MEMBER expression returned garbage / nondeterministic junk, corrupting the heap (which then crashed the moving GC). Root cause: a member receiver with a loop-variable integer index and no proven numeric layout was lowered via lower_legacy_array_index_get, which inline-reads the value as a plain ArrayHeader — gc_type at `handle-8`, raw f64 slot at `handle+8+i*8`. A small typed array is allocated OFF the GC heap with no GcHeader, so both reads land on unrelated bytes: the dispatch routes nondeterministically and the "fast" path returns raw garbage. Fix: route that case through lower_guarded_array_index_get instead. Its runtime typed-feedback guard rejects non-plain arrays and takes the boxed fallback (which dispatches typed arrays correctly), while plain arrays keep the inline fast path — so regular-array member loops are unaffected and typed-array member loops are correct. lower_legacy_array_index_get is now unused (retired). Validated: the case matrix (member typed-array loop, first/only, after another loop, in a helper, local-alias, direct) matches Node exactly; the moving-GC stress test that used to crash ~4/5 runs now crashes 0/6; gc_valid / object-keyed Map/WeakMap / high-volume loop / class+closure smoke unchanged. (A separate, smaller non-GC nondeterminism remains in the heaviest mixed repro; filed apart.)
…— keep event-loop moving default compiler-output-regression caught it: emitting js_gc_loop_safepoint() at every loop back-edge (default-on in the prior commit) inserts a CALL into hot numeric loops, which defeats LLVM auto-vectorization and violates the native-region "no runtime calls in hot loop" proofs (image_convolution, packed_f64 versioning, h1_* buffer regions). That's a real, broad perf regression. Split the concern: - Phase 1 (moving minor at the EVENT-LOOP safepoint) stays the DEFAULT — it's pure runtime, no codegen change, no per-loop cost. Moving still fires by default (validated: gc_valid eligible=true, byte-identical). - Phase 2/3 (loop back-edge polls + alloc-point deferral, making moving PRIMARY inside loops) is now opt-in behind PERRY_GC_MOVING_LOOP_POLLS (compile-time poll emission + runtime deferral/poll gate, kept coherent). Off by default until the poll is emitted only for loops that actually ALLOCATE, so numeric/vectorizable loops stay call-free. Verified: default numeric-loop IR has 0 js_gc_loop_safepoint (was the regression); PERRY_GC_MOVING_LOOP_POLLS=1 emits it; #6132 matrix still matches Node; gc_valid unchanged.
Summary
Makes Perry's precise / generational / moving (copying) young-gen GC the default. Perry already contained a full copying minor but it was dormant — nursery collections fire at allocation points where values live in registers, forcing a conservative scan that makes the copying minor ineligible, so the non-moving minor always ran. This PR runs the copying minor with precise roots at a safepoint and turns it on by default, and fixes the codegen bug that made it crash on typed-array programs.
What it does
PERRY_GC_MOVING_SAFEPOINT=0is a kill switch back to the non-moving path.n.buf[i]in a loop, wheren.bufis a typed array reached as a member, was lowered as a plainArrayHeaderread (gc_type athandle-8, raw f64 slot) — but a small typed array is off-heap with no GcHeader, so it read garbage and corrupted the heap (which then crashed the moving collector). Now routed through the typed-feedback-guarded path (guard rejects non-plain arrays → boxed fallback that dispatches typed arrays; regular arrays keep the fast path).PERRY_GC_MOVING_LOOP_POLLS, default off). Emits ajs_gc_loop_safepoint()at loop back-edges + defers the alloc-point minor to it, so moving is primary inside tight synchronous loops too. Off by default because the poll is a call that defeats LLVM auto-vectorization (caught bycompiler-output-regression); it flips on once the poll is emitted only for loops that actually allocate.PERRY_GC_INCREMENTAL, default off). Unblocks the dormant budgeted stepper (runs unbudgeted scanners synchronously in a bounded initial-mark step, then marks/sweeps incrementally) without rewriting all 88 root scanners. Opt-in until validated to activate under old-gen pressure.[gc-copy-minor]PERRY_GC_DIAGline (eligibility/fallback + copied/promoted objects & bytes); amove_youngguard that refuses tomemmovea young object with an out-of-range (corrupt) size.Validation
Default output byte-identical to the kill switch across classes / closures / Map / Set / WeakMap / async / recursion / JSON, retained graphs, object-keyed Map/WeakMap, high-volume loops; moving fires by default (copied 171 / promoted 6722 on the stress test); the #6132 case matrix matches Node; the moving-GC stress that crashed ~4/5 runs is 0/6; default numeric-loop codegen is call-free (no vectorization regression).
Follow-ups (test + harden in place)
PERRY_GC_FORCE_EVACUATE=1SIGBUS in old-page evacuation (debug stress path).PERRY_GC_MOVING_LOOP_POLLSon by default; then remove the non-moving fallback + the [perf] Generational GC unreachable in production → every collection is STW O(total heap) #6083 nursery-scoped minor.Risk
PERRY_GC_MOVING_SAFEPOINT=0reverts to exactly the prior non-moving GC. Default-on is validated on non-typed-array programs and now on the #6132 typed-array case; the loop-primary and incremental layers are opt-in.