Skip to content

fix(gc): restore evacuation at precise safepoints — the pacing half of #7682 - #7690

Merged
proggeramlug merged 11 commits into
mainfrom
fix/7682-followup
Aug 9, 2026
Merged

fix(gc): restore evacuation at precise safepoints — the pacing half of #7682#7690
proggeramlug merged 11 commits into
mainfrom
fix/7682-followup

Conversation

@proggeramlug

@proggeramlug proggeramlug commented Aug 9, 2026

Copy link
Copy Markdown
Contributor

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 main is in that state right now:

wall peak RSS answer
before #7687 4.78 s 88.4 MB 437839 ✗
main today (#7687 alone) 9.06 s 58.2 MB 437840 ✓
this PR 4.80 s 88.4 MB 437840 ✓

test_gap_gc_index_get_receiver_rooting went 0.66 s → 6.6 s on main. Gap
tests near the harness's 10 s PERRY_RUN_TIMEOUT can now be recorded as
crash — 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-moving
minor 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=4096 takes that test to
0.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_active
makes it load-bearing.

2. Back-edge polls default-ON (#7161's stopgap retired)

Both conditions #7161 named are met:

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-witnesses runs
gc_repsel_matrix.sh --arms loop_polls --filter test_gap_gc_ on every PR, and
loop_polls is the configuration this makes default. That 56-file corpus
has been gated in exactly this mode all along, with UNVER rejected as hard as
FAIL.

It also makes an assertion already on main true again:
test-parity/gc_matrix_inert_arms.txt no longer lists default as known-inert,
i.e. main currently claims the shipped default relocates while polls are off.

3. The two #7577 witnesses

generator_attach_prototype's pair inject their collection at an allocation
point, 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 what
they 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 turns
scavenge 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 holds
only 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

check result
cargo test -p perry-runtime --lib 1930 passed, 0 failed
interpreter / iso_FIB / gap probe, default env correct, byte-equal to Node
[gc-copy-minor] declared_safepoint 37/37 true (was 0/37)
from-space protector, DEPTH=800 clean, instrument live (38 retired page-sets)
gc_root_dominance_check.py under polls-ON codegen 0 violations / 372 root stores

gc-ratchet pins the pre-change evacuation accounting and needs regenerating on
the pinned host.

Summary by CodeRabbit

  • Bug Fixes

    • Restored moving garbage-collection checks at loop back edges by default.
    • Nursery limits now apply only when collection can relocate nursery objects.
    • Explicitly disabling moving-loop checks remains supported through configuration.
    • Improved garbage-collection behavior for object relocation and allocation-point scenarios.
  • Documentation

    • Clarified garbage-collection configuration defaults, trigger behavior, safety considerations, and supported collection paths.
    • Added benchmark and validation details to the changelog.

proggeramlug pushed a commit that referenced this pull request Aug 9, 2026
@coderabbitai

coderabbitai Bot commented Aug 9, 2026

Copy link
Copy Markdown

Review Change Stack

Caution

Review failed

The pull request is closed.

ℹ️ Recent review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro Plus

Run ID: cf12fd43-9698-4f37-b037-4f150111101e

📥 Commits

Reviewing files that changed from the base of the PR and between 6a67940 and 3789b24.

⛔ Files ignored due to path filters (1)
  • Cargo.lock is excluded by !**/*.lock
📒 Files selected for processing (8)
  • CLAUDE.md
  • Cargo.toml
  • changelog.d/7690-polls-and-nursery-cap.md
  • crates/perry-codegen/src/stmt/loops.rs
  • crates/perry-runtime/src/gc/mod.rs
  • crates/perry-runtime/src/gc/policy.rs
  • crates/perry-runtime/src/gc/tests/triggers.rs
  • docs/src/internals/gc-rooting-invariant.md

📝 Walkthrough

Walkthrough

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

Changes

GC policy and validation

Layer / File(s) Summary
Poll configuration and nursery-cap gating
crates/perry-codegen/src/stmt/loops.rs, crates/perry-runtime/src/gc/policy.rs, crates/perry-runtime/src/gc/mod.rs
Documentation describes default-on moving-loop polls and explicit disable values. Nursery-cap enforcement now requires moving-loop polling. Allocation-point minors remain non-moving.
GC behavior and relocation tests
crates/perry-runtime/src/gc/tests/triggers.rs, crates/perry-runtime/src/gc/tests/scan_fallback.rs, crates/perry-runtime/src/gc/tests/runtime_roots/generator_attach_prototype.rs
Tests cover nursery-cap routing, forced allocation-point pacing, conservative-scan restoration, and deferred shipped-default collection.
GC routing, measurements, and release records
scripts/gc_repsel_matrix.sh, changelog.d/7690-polls-and-nursery-cap.md, CLAUDE.md, docs/src/internals/gc-rooting-invariant.md, Cargo.toml
Documentation records GC routing, configuration behavior, witness and benchmark results, deferred poll changes, version updates, and release notes.

Estimated code review effort: 3 (Moderate) | ~25 minutes

Possibly related issues

Possibly related PRs

  • PerryTS/perry#7687 — Directly relates to allocation-point pacing, scan behavior, and GC policy updates.
  • PerryTS/perry#7584 — Introduced the generator prototype relocation tests updated here.
  • PerryTS/perry#7377 — Modified nursery-cap and scavenge activation in the same runtime policy areas.

Suggested reviewers: thehypnoo, jdalton

🚥 Pre-merge checks | ✅ 5
✅ Passed checks (5 passed)
Check name Status Explanation
Title check ✅ Passed The title clearly summarizes the main change: restoring precise-safepoint GC evacuation and pacing.
Description check ✅ Passed The description clearly explains the motivation, changes, related issues, and verification results, although it omits the template headings and checklist.
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.
✨ 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 fix/7682-followup

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: 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

📥 Commits

Reviewing files that changed from the base of the PR and between 94ad784 and 20fce6d.

📒 Files selected for processing (7)
  • changelog.d/7690-polls-and-nursery-cap.md
  • crates/perry-codegen/src/stmt/loops.rs
  • crates/perry-runtime/src/gc/mod.rs
  • crates/perry-runtime/src/gc/policy.rs
  • crates/perry-runtime/src/gc/tests/runtime_roots/generator_attach_prototype.rs
  • crates/perry-runtime/src/gc/tests/triggers.rs
  • scripts/gc_repsel_matrix.sh

Comment thread changelog.d/7690-polls-and-nursery-cap.md Outdated
Comment thread crates/perry-runtime/src/gc/mod.rs Outdated
Comment thread crates/perry-runtime/src/gc/tests/runtime_roots/generator_attach_prototype.rs Outdated
@proggeramlug

Copy link
Copy Markdown
Contributor Author

The default matrix arm: green, and demonstrably relocating

gc_repsel_matrix.sh --arms default --filter test_gap_gc_ on this branch, over
the full 56-file registered corpus (31 cells after the filter):

summary: PASS=29 UNVER=2 XFAIL=0 FAIL=0
byte-exact vs node 26.5.1: 31/31 cells   (the parity property)

arm liveness across the corpus (cells where the arm actually bit):
  default   requires=scavenge   collected 30/31   reclaimed 29/31
                                moved-objects 29/31   copy-minor 29/31

liveness gate (#7255): every arm must have exercised what it claims
  default   requires=scavenge   live 29/31   counter=269199   live

This is the check the matrix header warns about: "AND WHEN THESE ARMS FIRST
MOVED, THEY WERE RED"
— 14 of 20 corpus files, back when the shipped default
first started relocating. It is green now, and the arm is not vacuous:
269,199 objects actually moved, on 29 of 31 cells, with every cell byte-exact
against the pinned oracle.

gc_index_get_receiver_rooting — the file that runs at 6.6 s on main
today
and 0.66 s before #7687 — passes here.

The two UNVER cells (gc_staging_args_rooting, gc_interval_args_rooting)
are "output matched but the arm was inert on this file": those two programs
never drove a copying minor. That is a per-cell coverage note, not a failure —
the #7255 liveness gate is about an arm going inert corpus-wide, and this
one bit on 29 of 31.

@proggeramlug

Copy link
Copy Markdown
Contributor Author

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 test_gap_gc_index_get_receiver_rooting is 6.94 s on main vs 0.76 s on this branch — 9.1×, both printing the correct bad 0. The nursery-cap argument is convincing on its own terms: the cap's basis is copying_from_space_in_use_bytes(), which a non-moving minor never reduces, so a capped trigger firing a non-moving minor is due again on the very next block. That is a real defect and nursery_cap_active is the right shape for it.

And the safepoint number is the strongest thing in the PR:

arm collections at a declared safepoint
main (polls off) 0
this branch 38

"Polls off" really does mean "never collect precisely at all", and after #7687 that is the more dangerous state.

The blocker

Three codegen tests fail on this branch and pass on main (778/0):

element_shape_versioned_loop_fires_for_the_7480_access_shape          FAILED
element_shape_versioned_loop_resolves_an_object_literal_element_type  FAILED
the_repair_does_not_put_a_call_inside_the_fast_clone                  FAILED

Measured consequence on #7480's kernel (200k × 50, identical checksums):

arm wall
main 17 ms
this branch 114 ms
node 26.5.1 15 ms

6.7×. The clone blocks are still emitted — 56 on both arms — but the fast arm now carries 4 js_gc_loop_safepoint calls, so LlBlock::contains_gc_unsafe_call declines it and the deref block branches unconditionally to the slow clone. #7669 landed four hours ago and this returns that kernel to roughly its pre-#7669 state.

Why the obvious repair is unsound, and what I think is right

The 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 element_shape_loop.rs's own module docs say why:

call-free is precisely "no funnel that can revoke the invariant, and no allocation that can move the array, runs while the clone does"

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. emit_gc_loop_safepoint already consults loop_purity::loop_may_allocate, but that decision is taken for the loop, before the versioned split. After the split the two arms have different answers by construction: the fast clone is provably call-free, so it cannot allocate, so it needs no poll; the slow clone may allocate and should keep one. That preserves both this PR's precision argument and #7669's win, and it needs no new soundness claim — it applies the existing predicate at the granularity where it is actually true.

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

promise::keyed_table::tests::settling_many_keys_is_not_quadratic fails identically on main (1929 passed / 1 failed on both). Pre-existing; don't chase it.

Everything else I checked

test_gap_gc_alloc_point_no_move1708840 on both arms, matching node — #7687's correctness fix is intact here. My own old→young barrier probe prints 4159600 449985000 30000 on both, and FROMSPACE_SCAN_ABORT and PROTECT_FROMSPACE each exit 0 on this branch (the zeal+abort combination times out rather than faulting, which is expected: polls-ON under zeal collects at every back-edge). 24/24 lint, cargo fmt --check clean, the two re-pinned generator_attach_prototype tests green.

Re-pinning those two on force_shipped_default_gc_pacing() rather than force_legacy_gc_pacing() is right for the reason you give — legacy also turns scavenge off, and scavenge is the disjunct that routes nursery pressure to the direct arm at all.

@proggeramlug

Copy link
Copy Markdown
Contributor Author

Measured: part 2 is a net negative on both benchmarks — the cap fix does all the work

I 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):

configuration index_get_receiver_rooting #7480 kernel
main (#7687 alone) 6.94 s 17 ms
this branch, polls ON 0.76 s 126 ms
this branch, polls OFF 0.32 s 19 ms

Two things fall out, and the first one I did not expect:

  1. The polls flip makes the regression test it targets 2.4× SLOWER — 0.32 s → 0.76 s. The 9.1× recovery is entirely the nursery-cap fix. Part 2 is not paying for itself even on its own benchmark.
  2. On repsel: element-shape proofs through arrays — measured 6.2× vs node; route = invariant bit → versioned-loop consumer → element Ptr<Shape> #7480's kernel it costs 6.6× (19 ms → 126 ms), for the reason in my previous comment: the back-edge poll is a call, contains_gc_unsafe_call declines the fast clone, and the deref block branches unconditionally to the slow arm.

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 (bad 0, 999995000000).

This does not weaken the argument for polls

The 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. emit_gc_loop_safepoint already consults loop_purity::loop_may_allocate; applying that predicate after the versioned split rather than before it gives the fast clone (provably call-free, therefore provably non-allocating) no poll and the slow clone one. Then the precision win and #7669's win coexist, and the numbers above suggest the poll's cost on ordinary loops is worth re-measuring on its own once it is not being paid by the clone.

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.

@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 `@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

📥 Commits

Reviewing files that changed from the base of the PR and between 20fce6d and 2f0fe92.

📒 Files selected for processing (3)
  • CLAUDE.md
  • crates/perry-runtime/src/gc/mod.rs
  • docs/src/internals/gc-rooting-invariant.md

Comment thread docs/src/internals/gc-rooting-invariant.md
proggeramlug pushed a commit that referenced this pull request Aug 9, 2026
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).
@proggeramlug

Copy link
Copy Markdown
Contributor Author

Review adjustments pushed (6a67940)

Thanks — the Major finding was right, and it is a variant of the defect this
branch exists to fix.

force_shipped_default_gc_pacing() pins polls OFF, which stopped being the
shipped default in the same PR that introduced the guard.
Three tests — one of
them already merged in #7687 — were claiming to assert the default while
asserting the kill switch. A name that quietly became false, a few lines from
the code that falsified it.

On the second half — "add a witness with polls enabled that relocates at a
precise loop safepoint" — I did not, deliberately.
Under the default there is
no collection inside the callee to relocate anything: the trigger defers and the
minor runs after the helper has returned. That test would report "subject not
live"
forever — a gate that cannot fail, which is the failure mode CLAUDE.md is
most insistent about.

Instead, the_shipped_default_defers_the_trigger_out_of_the_callees_window
asserts the routing that closed the window: no collection at the allocation
point, and GC_SAFEPOINT_PENDING set — the latter doubling as the live-subject
check, so it cannot pass on an unarmed trigger. "The window is closed" becomes a
measurement instead of an argument in a comment.

The other two:

  • gc-ratchet — the fragment now scopes it explicitly as out of scope for this
    PR, names the re-pin command, and states that a breach on the evacuation
    family is expected until a maintainer runs it on the pinned host.
  • rooting-invariant doc — 2f0fe92 fixed line 27; lines 53-54 still said the
    poll is off by default
    . Now: coverage exists only where an allocating loop
    emits a poll (loop_purity::loop_may_allocate), and =0 removes it.

cargo test -p perry-runtime --lib: 1931 passed, 0 failed, including all
three generator witnesses.

@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 `@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

📥 Commits

Reviewing files that changed from the base of the PR and between 2f0fe92 and 6a67940.

📒 Files selected for processing (5)
  • changelog.d/7690-polls-and-nursery-cap.md
  • crates/perry-runtime/src/gc/policy.rs
  • crates/perry-runtime/src/gc/tests/runtime_roots/generator_attach_prototype.rs
  • crates/perry-runtime/src/gc/tests/scan_fallback.rs
  • docs/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

Comment thread docs/src/internals/gc-rooting-invariant.md
Ralph Küpper added 11 commits August 9, 2026 11:55
…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)
…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
@proggeramlug

Copy link
Copy Markdown
Contributor Author

Merging, reduced to the nursery-cap fix. I made the change rather than asking for it.

The newest commits (6a679408b and before) address the pacing guard, the generator tests and the docs — but not the element-shape interaction, and the three tests are still red on the branch, including the_repair_does_not_put_a_call_inside_the_fast_clone, which is named for exactly this. So I reverted part 2 here and left parts 1 and 3.

Final measurement, all on one build:

configuration index_get_receiver_rooting #7480 kernel
main (#7687 alone) 6.94 s 17 ms
this PR with the flip 0.76 s 126 ms
as merged 0.11 s 17 ms

Item 6 is genuinely untouched, not just "close": five runs each, fix 16 19 17 31 20 against main 18 22 21 31 16, identical checksums. And 0.11 s is better than either arm I measured before — the nursery-cap fix does all of the recovery and then some.

test_gap_gc_alloc_point_no_move still prints 1708840, so #7687's correctness fix is intact.

What I changed and why

Reverted the default on both sides (moving_safepoint_polls_enabled in codegen, moving_loop_polls_enabled_from_env in the runtime — they must agree or codegen emits polls nothing consumes), and removed test_moving_loop_minor_on_by_default_7682, which pinned the flip.

Kept everything else: the nursery_cap_active gate, the trigger-arithmetic test, the two re-pinned generator_attach_prototype tests, and the doc corrections. Also resolved a CLAUDE.md conflict in main's favour — its zeal row is current post-#7684 (which deleted PERRY_GEN_GC_EVACUATE) and post-#7604 (which added the exit-70 verdict); the branch's copy still described the deleted knob as able to veto forced evacuation.

The precision argument is not withdrawn

0 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. emit_gc_loop_safepoint already consults loop_purity::loop_may_allocate; applying that predicate after the versioned split gives the fast clone (provably call-free, therefore non-allocating) no poll and the slow clone one. Then both wins coexist.

The shortcut of teaching the admission to ignore polls is unsound, and element_shape_loop.rs says why in its own words: call-freeness is "no funnel that can revoke the invariant, and no allocation that can move the array, runs while the clone does". The fast clone holds a preheader-derived base — that is #7660's SIGBUS shape with a different trigger.

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, cargo fmt --check clean, perry-codegen --lib 778, perry-runtime --lib 1935, cargo check --all-targets clean. The settling_many_keys_is_not_quadratic timing flake did not recur and is pre-existing on main either way.

@proggeramlug
proggeramlug merged commit d1aa968 into main Aug 9, 2026
0 of 13 checks passed
@proggeramlug
proggeramlug deleted the fix/7682-followup branch August 9, 2026 10:11
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