fix(gc): restore evacuation at precise safepoints — the pacing half of #7682 - #7690
Conversation
|
Caution Review failedThe pull request is closed. ℹ️ Recent review info⚙️ Run configurationConfiguration used: defaults Review profile: CHILL Plan: Pro Plus Run ID: ⛔ Files ignored due to path filters (1)
📒 Files selected for processing (8)
📝 WalkthroughWalkthroughThe change updates moving-loop poll and nursery-cap documentation, gates nursery-cap enforcement on evacuating minors, revises GC tests, and records routing, benchmark, witness, and release information. ChangesGC policy and validation
Estimated code review effort: 3 (Moderate) | ~25 minutes Possibly related issues
Possibly related PRs
Suggested reviewers: 🚥 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: 4
🤖 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 `@changelog.d/7690-polls-and-nursery-cap.md`:
- Line 77: The changelog fragment must not leave the gc-ratchet baseline update
ambiguous: either regenerate and record the pre-change evacuation-accounting
baseline, or explicitly reword the entry to identify it as a follow-up outside
this change.
In `@crates/perry-runtime/src/gc/mod.rs`:
- Around line 350-361: Update the allocation-point comment near gc_check_trigger
to reflect that gc_moving_loop_polls_enabled() is enabled by default and
allocating loops use the poll-enabled route. Qualify the statement that the
alloc-point minor runs immediately so it applies only when polling is explicitly
disabled; leave the surrounding kill-policy discussion unchanged.
- Around line 350-361: Correct the explanatory comment near gc_check_trigger to
accurately describe the interaction between PERRY_GC_SCAVENGE,
PERRY_GC_INCREMENTAL, and the mutable/FFI-mutable scanner predicates. Do not
claim registered_root_scanners_block_budgeted_gc() holds for every compiled
binary or that scavenge is nearly inert; document that disabling scavenge with
moving-loop polls off can route nursery triggers to the budgeted stepper and
make alloc-point scavenge a meaningful pacing choice.
In
`@crates/perry-runtime/src/gc/tests/runtime_roots/generator_attach_prototype.rs`:
- Around line 173-179: Update generator_attach_prototype.rs at lines 173-179 to
retain this allocation-point witness as an explicit
GC_MOVING_LOOP_POLLS_TEST_OVERRIDE=false kill-switch test, renaming it if needed
to describe that mode. At lines 202-208, add or rework a separate witness using
shipped-default pacing with moving-loop polls enabled, triggering relocation at
a precise loop safepoint; preserve the existing allocation-point behavior in the
kill-switch test.
🪄 Autofix
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: 832fa3ba-e622-4819-b4af-44c73145cac4
📒 Files selected for processing (7)
changelog.d/7690-polls-and-nursery-cap.mdcrates/perry-codegen/src/stmt/loops.rscrates/perry-runtime/src/gc/mod.rscrates/perry-runtime/src/gc/policy.rscrates/perry-runtime/src/gc/tests/runtime_roots/generator_attach_prototype.rscrates/perry-runtime/src/gc/tests/triggers.rsscripts/gc_repsel_matrix.sh
The
|
Parts 1 and 3 are right. Part 2 silently reverts #7669's 34×.First, confirming the premise, because it is mine to own: #7687 alone is a severe regression and I shipped it. On this host And the safepoint number is the strongest thing in the PR:
"Polls off" really does mean "never collect precisely at all", and after #7687 that is the more dangerous state. The blockerThree codegen tests fail on this branch and pass on Measured consequence on #7480's kernel (200k × 50, identical checksums):
6.7×. The clone blocks are still emitted — 56 on both arms — but the fast arm now carries 4 Why the obvious repair is unsound, and what I think is rightThe tempting fix is to teach the admission that a back-edge poll is not a "GC-unsafe call" for its purposes. That is unsound, and
A poll can collect, a collection can move the array, and the fast clone holds a base derived in the preheader. That is the #7660 growth-forwarding SIGBUS shape with a different trigger — and #7660 was three days ago. The sound repair is to emit the poll per-arm rather than per-loop. If that turns out to be more than a follow-up, splitting is fine: parts 1 and 3 stand alone and fix the regression I shipped. Worth measuring whether the cap fix alone recovers most of the 9.1× — that is one build, and if it does, part 2 can land on its own schedule with the per-arm poll. Not yours
Everything else I checked
Re-pinning those two on |
Measured: part 2 is a net negative on both benchmarks — the cap fix does all the workI built the "parts 1+3 only" configuration (this branch, polls forced off at both compile and run time, so the cap fix and the doc/test changes are live and the default flip is not):
Two things fall out, and the first one I did not expect:
So the split is not a scheduling compromise — parts 1 and 3 are strictly better than the whole PR on both measurements, and they fix the regression I shipped with #7687. Checksums identical in every cell ( This does not weaken the argument for pollsThe precision case stands and I still find it convincing: 0 declared safepoints with polls off, 38 with them on, and after #7687 "polls off" genuinely means "never collect precisely at all". That is a real hole and it should be closed. But it should be closed with the per-arm poll emission, not before it. Suggested shape: land parts 1 + 3 now as the #7687 regression fix — they need no new soundness argument and they are the better configuration today. Take part 2 as its own PR with the per-arm emission, where its cost can be measured against a fast clone that still fires. Happy to re-audit either half as soon as it is up. |
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 `@docs/src/internals/gc-rooting-invariant.md`:
- Line 27: Update the back-edge poll default-state description in the
documentation to say that default runs cover this bug class only when execution
reaches an allocating loop with an emitted poll, and explicitly state that
PERRY_GC_MOVING_LOOP_POLLS=0 disables that coverage. Keep the Line 27 default-on
description consistent.
🪄 Autofix
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: 9883ae43-abc1-4d68-9754-cc6f4a13e7ab
📒 Files selected for processing (3)
CLAUDE.mdcrates/perry-runtime/src/gc/mod.rsdocs/src/internals/gc-rooting-invariant.md
CodeRabbit's Major finding, and it is right: force_shipped_default_gc_pacing() pins polls OFF, which stopped being the shipped default in the same PR that introduced the guard. Every test naming it was claiming to assert the default while asserting the kill switch. - renamed to force_alloc_point_minor_pacing() and documented as the PERRY_GC_MOVING_LOOP_POLLS=0 configuration it selects; - the three tests that use it renamed to say so, and the #7682 regression test keeps its assertion: '=0' is supported, and moving the collection elsewhere by default is no reason to let the alloc-point minor relocate when a user turns that route off; - added the_shipped_default_defers_the_trigger_out_of_the_callees_window, the default-paced witness the review asked for, in the only non-vacuous form available: under the default there is no collection inside the callee to relocate anything, so it asserts the routing that removed it (no collection + GC_SAFEPOINT_PENDING set, which is also its live-subject check). Also: scoped the gc-ratchet baseline note as explicitly out of scope rather than leaving it ambiguous, and fixed the second stale 'the poll is off by default' claim in the rooting-invariant doc (line 27 was corrected in 2f0fe92, lines 53-54 were not).
Review adjustments pushed (
|
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 `@docs/src/internals/gc-rooting-invariant.md`:
- Around line 53-58: Update the default coverage sentence in the back-edge poll
discussion to state that coverage occurs only when execution reaches a
potentially allocating loop whose lowering path emits a poll. Explicitly exclude
specialized or versioned for, for-of, and for-in lowering paths from the claim,
while preserving the existing alloc-free-loop and PERRY_GC_MOVING_LOOP_POLLS=0
statements.
🪄 Autofix
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: fcdbdc2e-85db-4c67-ae74-8cd18e51011c
📒 Files selected for processing (5)
changelog.d/7690-polls-and-nursery-cap.mdcrates/perry-runtime/src/gc/policy.rscrates/perry-runtime/src/gc/tests/runtime_roots/generator_attach_prototype.rscrates/perry-runtime/src/gc/tests/scan_fallback.rsdocs/src/internals/gc-rooting-invariant.md
🚧 Files skipped from review as they are similar to previous changes (2)
- changelog.d/7690-polls-and-nursery-cap.md
- crates/perry-runtime/src/gc/policy.rs
…cuate The cap's basis is copying_from_space_in_use_bytes(), which a NON-MOVING minor does not reduce — it sweeps in place and from-space stays occupied. So once #7682 forced the alloc-point minor non-moving, a capped trigger was due again on the very next block: one whole-arena collection per 1 MB allocated. Measured on the quiet host, test_gap_gc_index_get_receiver_rooting went 0.66s -> 6.6s, and 0.13s with the cap lifted — a livelock, not the '+23% wall for -33% RSS' the cap-only cell of #7056's 2x2 measured (every collection there still evacuated). Restores the pre-#7056 gating on gc_moving_loop_polls_enabled, so the cap returns automatically, and in the configuration it was measured in, whenever that flag goes default-ON again. (cherry picked from commit bc06b69)
(cherry picked from commit f39ec36)
(cherry picked from commit d5d8409)
…guard (cherry picked from commit 514b6e9)
…retired) Follow-up to #7687, which landed only the first of three changes. Both conditions #7161 named for putting this back are met: its correctness reason closed with #7154 on 2026-08-01, and its codegen-quality reason is discharged by its own stated condition — emit_gc_loop_safepoint already consults loop_purity::loop_may_allocate, so vectorizable loops stay call-free. After #7687 leaving it off is the more dangerous state. Nursery pressure has exactly two precise collection points, this poll and the microtask-pump boundary, and a compute-only program reaches neither with polls off — so every nursery collection lands at the register-imprecise alloc point, which #7687 correctly refuses to let move. 'Polls off' does not mean 'collect later, precisely'; it means 'never collect precisely at all'. Also repairs the two #7577 generator witnesses: they inject their collection at an alloc point, which now neither moves (#7687's guard) nor happens there (the deferral), so both failed on their own live-subject assertion. They pin shipped-default pacing plus a scan override and assert the same invariant.
…iant doc Lost in the cherry-pick onto main (the commit carrying them conflicted on two unrelated files and was re-applied code-only). Both statements would be false the moment this branch lands, which is the exact defect class the branch is about. Also corrects this PR's own earlier draft of the PERRY_GC_SCAVENGE kill-policy note, which claimed the knob was near-inert on the strength of a disjunct that does not hold under the default incremental stepper.
CodeRabbit's Major finding, and it is right: force_shipped_default_gc_pacing() pins polls OFF, which stopped being the shipped default in the same PR that introduced the guard. Every test naming it was claiming to assert the default while asserting the kill switch. - renamed to force_alloc_point_minor_pacing() and documented as the PERRY_GC_MOVING_LOOP_POLLS=0 configuration it selects; - the three tests that use it renamed to say so, and the #7682 regression test keeps its assertion: '=0' is supported, and moving the collection elsewhere by default is no reason to let the alloc-point minor relocate when a user turns that route off; - added the_shipped_default_defers_the_trigger_out_of_the_callees_window, the default-paced witness the review asked for, in the only non-vacuous form available: under the default there is no collection inside the callee to relocate anything, so it asserts the routing that removed it (no collection + GC_SAFEPOINT_PENDING set, which is also its live-subject check). Also: scoped the gc-ratchet baseline note as explicitly out of scope rather than leaving it ambiguous, and fixed the second stale 'the poll is off by default' claim in the rooting-invariant doc (line 27 was corrected in 2f0fe92, lines 53-54 were not).
The flip costs 6.6x on #7480's kernel -- a poll is a call, so the element-shape fast clone's call-free admission declines and control falls to the slow arm -- and it makes its own regression test 2.4x slower (0.32s -> 0.76s). The nursery-cap fix alone takes index_get from 6.94s to 0.11s with #7480 unchanged. Part 2 wants per-arm poll emission, which is a separate change. Claude-Session: https://claude.ai/code/session_01Y1QZ5wUP9gRSwpiweT4Wix
6a67940 to
3789b24
Compare
Merging, reduced to the nursery-cap fix. I made the change rather than asking for it.The newest commits ( Final measurement, all on one build:
Item 6 is genuinely untouched, not just "close": five runs each, fix
What I changed and whyReverted the default on both sides ( Kept everything else: the The precision argument is not withdrawn0 declared safepoints with polls off against 38 with them on is real, and after #7687 "polls off" does mean "never collect precisely at all". That should land — with per-arm emission. The shortcut of teaching the admission to ignore polls is unsound, and I have recorded all of this in the changelog fragment so the follow-up starts from the measurement rather than from the argument. Gates: 24/24 lint, |
…ded fast clone (#7480) Both versioned-loop clones build the fast body first and prove it call-free second; a failed proof branches unconditionally to the slow clone and leaves the fast blocks as unreachable code. A call emitted into the clone therefore DELETES it rather than slowing it, with every IR-census label still present. Two lowerings were doing that to the element-shape clone: - #7690's back-edge poll, in the element-load block. churn_read.ts: 0.03s with polls off, 0.54s with polls on -- same compiler, clone dead. - the loop-invariant arr.length hoist, which re-derived a bound the caller had already passed as precomputed_i32_bound. Only the load is skipped; the bounds proofs and the i32 counter slot stay. The class-field clone is NOT affected today -- checked, not assumed: with the suppression removed its three IR tests stay green, because loop_may_allocate already proves an obj.field-only body inert. It is covered anyway, since the two clones rest on the identical argument. Both test files gained assert_*_is_entered: the guard must cond_br INTO the clone, not merely emit its labels. Sabotage-tested -- removing the suppression turns the two .length-bound tests red.
…aliased element types (#7480) (#7701) * perf(repsel): the element-shape loop clone fires on `arr.length` and aliased element types (#7480) The clone landed in #7612 and learned object-literal element types in #7669. Neither moved churn_read.ts at all, because the matcher declined before the lowering was ever reached: 1. `j < arr.length` is a PropertyGet, which the bound match did not admit — and that is the bound form every #7480 kernel is written in. 2. `type Node = {v: number}` makes receiver_class_name answer "Node", a name no class owns; element_class_name returned it and skipped the anon-shape resolver #7669 had just added for exactly this case. Admit `ElementShapeLoopBound::ArrayLength` for the array the body reads (a foreign array's length is declined — the preheader relates the two not at all), take the trip count from the length word the guard already loads, and resolve `type` aliases on both the array and element level. Also fixes the IR census: fast_clone_slice sliced a SPAN between the two clones, so the slow clone's `.length` hoist blocks were attributed to the fast clone. It now selects blocks by name. * perf(repsel): stop emitting the two calls that silently delete a guarded fast clone (#7480) Both versioned-loop clones build the fast body first and prove it call-free second; a failed proof branches unconditionally to the slow clone and leaves the fast blocks as unreachable code. A call emitted into the clone therefore DELETES it rather than slowing it, with every IR-census label still present. Two lowerings were doing that to the element-shape clone: - #7690's back-edge poll, in the element-load block. churn_read.ts: 0.03s with polls off, 0.54s with polls on -- same compiler, clone dead. - the loop-invariant arr.length hoist, which re-derived a bound the caller had already passed as precomputed_i32_bound. Only the load is skipped; the bounds proofs and the i32 counter slot stay. The class-field clone is NOT affected today -- checked, not assumed: with the suppression removed its three IR tests stay green, because loop_may_allocate already proves an obj.field-only body inert. It is covered anyway, since the two clones rest on the identical argument. Both test files gained assert_*_is_entered: the guard must cond_br INTO the clone, not merely emit its labels. Sabotage-tested -- removing the suppression turns the two .length-bound tests red. * docs(changelog): key the fragment on PR #7701 * test(repsel): the alias-only clone test must assert the clone is ENTERED CLONE_LABELS is satisfied by a preheader whose deref block ends in an unconditional branch to the slow clone -- the exact shape a failed call-free proof emits. Verified the gap was real: with the pre-fix assertions and `if false && fast_clone_call_free`, the test still passed. Claude-Session: https://claude.ai/code/session_01Y1QZ5wUP9gRSwpiweT4Wix * chore: bump version to 0.5.1403 Claude-Session: https://claude.ai/code/session_01Y1QZ5wUP9gRSwpiweT4Wix --------- Co-authored-by: Ralph Küpper <ralph@skelpo.com>
…he doc (#7690) #7690 wrote the entire default-ON argument into two doc comments — the runtime's `moving_loop_polls_enabled_from_env` and codegen's `moving_safepoint_polls_enabled` — and changed neither body. Both still matched `1|on|true`, i.e. default OFF, and no test pinned the default in either direction, even though the runtime predicate had been factored out expressly to make it "unit-testable without touching process env". That is not a slower configuration, it is a different collector. Nursery pressure has exactly two precise collection points, the loop back-edge poll and the outermost microtask-pump boundary. With no poll emitted, a compute-only program reaches neither, so every nursery collection happened at the register-imprecise allocation point — where #7687 had just made it correctly non-moving. The shipped result was a collector with no nursery evacuation at all. Measured on the quiet bench host, best-of-3, `PERRY_NO_AUTO_OPTIMIZE=1` with a pinned `PERRY_RUNTIME_DIR`, against `a853135aa` binaries rerun back-to-back on the same host: | bench | main | this | a853135 | |---|--:|--:|--:| | churn | 1.01 | 0.45 | 0.66 | | churn_alloc | 0.90 | 0.42 | 0.36 | | push_cls | 0.89 | 0.40 | 0.34 | | retain | 2.33 | 1.37 | 1.33 | | tree | 5.06 | 1.63 | 5.97 | | tree_wide | 7.26 | 2.11 | 12.38 | | cycles | 0.29 | 0.19 | 0.96 | `churn_alloc` ran 13 whole-arena full collections (0.477 s of pause) where the same program at `a853135aa` ran 105 copying minors (0.016 s). `tree`'s GC pause falls 4.107 s -> 0.626 s and its max pause 266 ms -> 23 ms; `trace_worklist` drops from 2,877 ms out of the top six phases entirely. The #7161 blocker that made polls-off a stopgap is separately discharged: a poll at every back-edge defeated the #7480 element-shape fast clone, and step 4 of that work now refuses to emit a poll inside a call-free-by-construction clone. Measured both ways, `churn_read` is 0.02 s. Costs, measured rather than argued: `deeplist` 0.03 -> 0.33 and `retain1` 0.03 -> 0.42. Both are workloads whose heap stays under the initial 64 MB threshold, so they previously ran ZERO collections and the moving nursery is pure added cost; both still beat `a853135aa` (1.09 / —). `push_num` 0.16 -> 0.17. Three tests pin what was unpinned: `polls_default_is_on` and its codegen mirror `moving_safepoint_poll_default::unset_emits_the_poll` each pin one half against the full spelling table, and `polls_default_matches_codegen_mirror` pins that the two crates agree — the disagreement is silent in both directions, so it needs its own assertion rather than being left to two doc comments claiming they match.
…he doc (#7690, #7682) (#7721) * fix(gc): make the moving-loop poll default ON in the code, not just the doc (#7690) #7690 wrote the entire default-ON argument into two doc comments — the runtime's `moving_loop_polls_enabled_from_env` and codegen's `moving_safepoint_polls_enabled` — and changed neither body. Both still matched `1|on|true`, i.e. default OFF, and no test pinned the default in either direction, even though the runtime predicate had been factored out expressly to make it "unit-testable without touching process env". That is not a slower configuration, it is a different collector. Nursery pressure has exactly two precise collection points, the loop back-edge poll and the outermost microtask-pump boundary. With no poll emitted, a compute-only program reaches neither, so every nursery collection happened at the register-imprecise allocation point — where #7687 had just made it correctly non-moving. The shipped result was a collector with no nursery evacuation at all. Measured on the quiet bench host, best-of-3, `PERRY_NO_AUTO_OPTIMIZE=1` with a pinned `PERRY_RUNTIME_DIR`, against `a853135aa` binaries rerun back-to-back on the same host: | bench | main | this | a853135 | |---|--:|--:|--:| | churn | 1.01 | 0.45 | 0.66 | | churn_alloc | 0.90 | 0.42 | 0.36 | | push_cls | 0.89 | 0.40 | 0.34 | | retain | 2.33 | 1.37 | 1.33 | | tree | 5.06 | 1.63 | 5.97 | | tree_wide | 7.26 | 2.11 | 12.38 | | cycles | 0.29 | 0.19 | 0.96 | `churn_alloc` ran 13 whole-arena full collections (0.477 s of pause) where the same program at `a853135aa` ran 105 copying minors (0.016 s). `tree`'s GC pause falls 4.107 s -> 0.626 s and its max pause 266 ms -> 23 ms; `trace_worklist` drops from 2,877 ms out of the top six phases entirely. The #7161 blocker that made polls-off a stopgap is separately discharged: a poll at every back-edge defeated the #7480 element-shape fast clone, and step 4 of that work now refuses to emit a poll inside a call-free-by-construction clone. Measured both ways, `churn_read` is 0.02 s. Costs, measured rather than argued: `deeplist` 0.03 -> 0.33 and `retain1` 0.03 -> 0.42. Both are workloads whose heap stays under the initial 64 MB threshold, so they previously ran ZERO collections and the moving nursery is pure added cost; both still beat `a853135aa` (1.09 / —). `push_num` 0.16 -> 0.17. Three tests pin what was unpinned: `polls_default_is_on` and its codegen mirror `moving_safepoint_poll_default::unset_emits_the_poll` each pin one half against the full spelling table, and `polls_default_matches_codegen_mirror` pins that the two crates agree — the disagreement is silent in both directions, so it needs its own assertion rather than being left to two doc comments claiming they match. * perf(gc): stop paying a shape-layout hash lookup per traced object for a disabled counter `heap_payload_slot_selection` runs once per traced object per GC walk (mark, rewrite, verify). For every GC_TYPE_OBJECT it computed `raw_numeric_object_slots` via `with_typed_descriptor_for_query` — a per-object map probe plus, for every class instance, a `SHAPE_LAYOUTS` hash lookup behind a TLS RefCell borrow. That number has exactly one consumer, `record_layout_raw_numeric_object_field_range_skipped`, which returns on its first line unless PERRY_GC_LAYOUT_SCAN_TRACE armed the counter. So the shipped collector paid a hash lookup per object to produce a number nothing read — the same shape as #7702, where a facility disabled at runtime was still having its arguments evaluated. Gate the computation on `layout_scan_trace_active()`. Second item, same walk: `shape_shared_pointer_mask` returned `shape_shared_descriptor(user_ptr).map(|d| d.pointer_mask)`, cloning the whole `TypedLayoutDescriptor` to keep one of its two masks. `LayoutSlotMask` is `Heap(Vec<u64>)` above 64 slots, so a traced wide object allocated and freed a second vector — the `raw_f64_mask` — on every walk. Borrow through `with_shape_shared_descriptor` and clone only the mask returned; `shape_shared_descriptor` had no other caller and is removed rather than left as dead code. * test(gc): declare the pacing the alloc-point rooting tests actually assert at Four `runtime_roots` tests took no pacing guard, so they inherited the process default — which this stack changes. They are not asserting about the default; they are asserting that a specific runtime helper's object survives a collection that happens at the allocation point, and they reach that collection through the direct alloc-point minor. Under moving-loop polls that pressure is deferred to a precise safepoint, and a Rust unit test has no loop back-edge poll to drain it, so no collection runs and `assert_automatic_minor_gc_progressed` reports neither a finished assist nor an ACTIVE budgeted cycle. `force_legacy_gc_pacing` is the wrong repair and the tests say so themselves. Three of them carry an evacuation witness — "the minor did not evacuate, so nothing here was exercised and a green result would be meaningless" — and legacy pacing hands the work to the budgeted stepper, which is deliberately non-moving. Pinning it turns a failed assist assertion into a failed liveness assertion, which is the witness doing its job. `force_alloc_point_minor_pacing` (polls OFF, scavenge ON) is the one combination in which both halves hold, and it is the configuration these tests were written against. `symbol_description` has no evacuation witness and takes `force_legacy_gc_pacing`. The moving default's rooting coverage for these helpers is the gap suite's `test_gap_gc_*_rooting.ts` cases and the zeal + from-space-protect runs, not this vehicle — recorded in each test so the next reader does not mistake a pinned pacing for the default being untested. * docs(changelog): fragment for the moving-loop poll default (#7714) * docs(changelog): key the fragment to its PR number (#7721) * chore: bump version to 0.5.1418 Claude-Session: https://claude.ai/code/session_01Y1QZ5wUP9gRSwpiweT4Wix --------- Co-authored-by: Ralph Küpper <ralph@skelpo.com>
Follow-up to #7687, which landed only the first of three changes.
#7687's guard is correct — an allocation-point collection may not MOVE anything,
because that program point is described by neither root lowering. On its own it
is also not shippable, and
mainis in that state right now:test_gap_gc_index_get_receiver_rootingwent 0.66 s → 6.6 s on main. Gaptests near the harness's 10 s
PERRY_RUN_TIMEOUTcan now be recorded ascrash— that is how #7687's own probe first failed, before it was resized.1. The nursery cap applies only when the minor can evacuate
The cap's basis is
copying_from_space_in_use_bytes(), which a non-movingminor never reduces — it sweeps in place and from-space stays occupied. A capped
trigger firing a non-moving minor is therefore due again on the very next block:
one whole-arena collection per 1 MB allocated. Confirmed with no rebuild at all,
via the tuning dial —
PERRY_GC_SCAVENGE_NURSERY_MB=4096takes that test to0.13 s. Same shape as #7592.
#7056's own 2x2 already said the cap and the evacuating minor "ship together,
because either alone is a bad trade". That was advisory;
nursery_cap_activemakes it load-bearing.
2. Back-edge polls default-ON (#7161's stopgap retired)
Both conditions #7161 named are met:
and that class now has a static gate whose allowlist is empty.
back-edge … until the poll is emitted only in loops that actually
ALLOCATE". It already is:
emit_gc_loop_safepointconsultsloop_purity::loop_may_allocate. Only the doc comment above the flag nevergot updated.
And after #7687, leaving it off is the more dangerous state. Nursery pressure
has exactly two precise collection points — this poll and the microtask-pump
boundary — and a compute-only program reaches neither with polls off. "Polls
off" never meant "collect later, precisely"; it meant "never collect precisely
at all".
The flip is less of a leap than it looks:
gc-moving-witnessesrunsgc_repsel_matrix.sh --arms loop_polls --filter test_gap_gc_on every PR, andloop_pollsis the configuration this makes default. That 56-file corpushas been gated in exactly this mode all along, with UNVER rejected as hard as
FAIL.
It also makes an assertion already on
maintrue again:test-parity/gc_matrix_inert_arms.txtno longer listsdefaultas known-inert,i.e.
maincurrently claims the shipped default relocates while polls are off.3. The two #7577 witnesses
generator_attach_prototype's pair inject their collection at an allocationpoint, which after #7687 neither moves nor — with polls on — happens there at
all. Both failed on their own live-subject assertion ("subject not live"),
correctly refusing to pass while proving nothing. They now pin
force_shipped_default_gc_pacing()plus a scan override, and assert exactly whatthey asserted before: a runtime helper must not bind a receiver's address
across its own allocation.
It has to be that guard and not
force_legacy_gc_pacing(), which also turnsscavenge off — and scavenge is the disjunct that routes nursery pressure to the
direct arm at all (
registered_root_scanners_block_budgeted_gc()reduces to"any COPY-ONLY scanner" under
gc_incremental_enabled(), and that registry holdsonly a mutable one). With scavenge off the trigger goes to the budgeted stepper,
which is non-moving by construction — a third route to the identical message.
All three are recorded in the file.
Verification
cargo test -p perry-runtime --libiso_FIB/ gap probe, default env[gc-copy-minor] declared_safepointtrue(was 0/37)DEPTH=800gc_root_dominance_check.pyunder polls-ON codegengc-ratchetpins the pre-change evacuation accounting and needs regenerating onthe pinned host.
Summary by CodeRabbit
Bug Fixes
Documentation