Skip to content

feat(gc): moving (copying) GC is now the default — safepoint-triggered, precise-root (Phases 1-4) - #6134

Merged
proggeramlug merged 9 commits into
mainfrom
feat/gc-one-great-moving
Jul 8, 2026
Merged

feat(gc): moving (copying) GC is now the default — safepoint-triggered, precise-root (Phases 1-4)#6134
proggeramlug merged 9 commits into
mainfrom
feat/gc-one-great-moving

Conversation

@proggeramlug

@proggeramlug proggeramlug commented Jul 8, 2026

Copy link
Copy Markdown
Contributor

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

  • Moving minor at the event-loop safepoint — DEFAULT. At the outermost microtask-pump boundary the JS stack has unwound, so the copying minor runs with precise, rewritable roots and moves survivors (compacting, O(survivors), no sweep). Pure runtime, no codegen change, no per-loop cost. PERRY_GC_MOVING_SAFEPOINT=0 is a kill switch back to the non-moving path.
  • codegen: typed-array element loop reads wrong sum when object has a sibling Array field #6132 fix (codegen) — the crash-causer. n.buf[i] in a loop, where n.buf is a typed array reached as a member, was lowered as a plain ArrayHeader read (gc_type at handle-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).
  • Loop-primary moving (Phase 2/3) — OPT-IN (PERRY_GC_MOVING_LOOP_POLLS, default off). Emits a js_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 by compiler-output-regression); it flips on once the poll is emitted only for loops that actually allocate.
  • Incremental old-gen (Phase 4) — OPT-IN (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.
  • Observability: [gc-copy-minor] PERRY_GC_DIAG line (eligibility/fallback + copied/promoted objects & bytes); a move_young guard that refuses to memmove a 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)

Risk

PERRY_GC_MOVING_SAFEPOINT=0 reverts 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.

Ralph Küpper added 4 commits July 8, 2026 16:11
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.
@coderabbitai

coderabbitai Bot commented Jul 8, 2026

Copy link
Copy Markdown

Review Change Stack

Note

Reviews paused

It 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 reviews.auto_review.auto_pause_after_reviewed_commits setting.

Use the following commands to manage reviews:

  • @coderabbitai resume to resume automatic reviews.
  • @coderabbitai review to trigger a single review.

Use the checkboxes below for quick actions:

  • ▶️ Resume reviews
  • 🔍 Trigger review
📝 Walkthrough

Walkthrough

Adds 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.

Changes

Moving safepoint GC

Layer / File(s) Summary
Relocation guard and diagnostics
crates/perry-runtime/src/gc/copying.rs
move_young rejects relocation when object size is below GC_HEADER_SIZE or exceeds MAX_YOUNG_MOVE_BYTES (1 MiB), returning the original pointer; adds PERRY_GC_DIAG-gated logging of fast-path eligibility/fallback reason and post-run copied/promoted/freed byte counts.
Moving safepoint state and entrypoint
crates/perry-runtime/src/gc/policy.rs
Adds gc_moving_safepoint_enabled() for PERRY_GC_MOVING_SAFEPOINT, gc_incremental_enabled() for PERRY_GC_INCREMENTAL, defers nursery-triggered collection with GC_SAFEPOINT_PENDING under a hard byte cap, and adds gc_safepoint_moving_minor() plus the js_gc_loop_safepoint() gate for precise-safepoint moving minors.
Microtask pump wiring
crates/perry-runtime/src/promise/microtasks.rs
run_microtasks invokes gc_safepoint_moving_minor() when moving safepoints are enabled and pump depth equals 1.
Loop safepoint declaration and emission
crates/perry-codegen/src/runtime_decls/arrays.rs, crates/perry-codegen/src/stmt/loops.rs
Declares js_gc_loop_safepoint and emits conditional loop back-edge polls for for, while, and do..while lowering when moving safepoints are enabled.
Incremental cycle gates
crates/perry-runtime/src/gc/cycle.rs, crates/perry-runtime/src/gc/roots.rs
Budgeted root scanning and root-scanner blocking behavior change when incremental old-gen GC is enabled.
Legacy index get retirement
crates/perry-codegen/src/expr/index_get.rs
Marks lower_legacy_array_index_get as dead code and removes the dispatch branch that routed unknown member receivers to the legacy inline reader, leaving the guarded array path for those reads.

Estimated code review effort: 4 (Complex) | ~45 minutes

Possibly related issues

Possibly related PRs

  • PerryTS/perry#5551: Also changes IndexGet dispatch around the guarded array path and typed-array receiver handling.
  • PerryTS/perry#5880: Also modifies crates/perry-runtime/src/promise/microtasks.rs and the run_microtasks control flow.
  • PerryTS/perry#5977: Also changes crates/perry-runtime/src/gc/copying.rs in the minor copying fast path.

Suggested labels: run-extended-tests

🚥 Pre-merge checks | ✅ 5
✅ Passed checks (5 passed)
Check name Status Explanation
Docstring Coverage ✅ Passed No functions found in the changed files to evaluate docstring coverage. Skipping docstring coverage check.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.
Title check ✅ Passed The title clearly summarizes the main change: making moving GC the default with safepoint-triggered precise roots.
Description check ✅ Passed The description is detailed and covers summary, changes, validation, and risk, but it omits the template's Related issue and explicit Test plan/Checklist sections.
✨ Finishing Touches
📝 Generate docstrings
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch feat/gc-one-great-moving

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.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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

📥 Commits

Reviewing files that changed from the base of the PR and between 71126d7 and 51f71f3.

📒 Files selected for processing (3)
  • crates/perry-runtime/src/gc/copying.rs
  • crates/perry-runtime/src/gc/policy.rs
  • crates/perry-runtime/src/promise/microtasks.rs

Comment thread crates/perry-runtime/src/gc/copying.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.
@proggeramlug proggeramlug changed the title feat(gc): safepoint-triggered moving (copying) minor — foundation for the one great GC [gated, experimental] feat(gc): moving (copying) GC — safepoint trigger + loop-back-edge polls (Phases 1-3, gated/experimental) Jul 8, 2026
…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.
@proggeramlug proggeramlug changed the title feat(gc): moving (copying) GC — safepoint trigger + loop-back-edge polls (Phases 1-3, gated/experimental) feat(gc): moving (copying) GC is now the default — safepoint-triggered, precise-root (Phases 1-4) Jul 8, 2026
…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.)
Ralph Küpper added 2 commits July 8, 2026 18:53
…— 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.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant