Skip to content

fix(gc): make the moving-loop poll default ON in the code, not just the doc (#7690, #7682) - #7721

Merged
proggeramlug merged 6 commits into
mainfrom
gc/7690-polls-default-on
Aug 9, 2026
Merged

fix(gc): make the moving-loop poll default ON in the code, not just the doc (#7690, #7682)#7721
proggeramlug merged 6 commits into
mainfrom
gc/7690-polls-default-on

Conversation

@proggeramlug

@proggeramlug proggeramlug commented Aug 9, 2026

Copy link
Copy Markdown
Contributor

What this is

#7690 wrote the entire default-ON argument for the moving-loop poll 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. 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". The runtime doc even asserted "Codegen's moving_safepoint_polls_enabled mirrors this exactly — they MUST agree"; they did agree, at the value the doc said they no longer held.

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 collector had no nursery evacuation at all, and the trigger fell back to whole-arena full collections.

churn_alloc runs 13 whole-arena full collections (0.477 s of pause) on main where the same program at a853135aa ran 105 copying minors (0.016 s).

Measurements

Pinned quiet M1 mini, best-of-3 interleaved, PERRY_NO_AUTO_OPTIMIZE=1 with a pinned PERRY_RUNTIME_DIR, a853135aa binaries rerun back-to-back on the same host.

bench main 12e48edd6 this a853135aa node scriptc 0.0.22
tree 5.10 1.63 6.00 0.45 4.80
tree_wide 7.26 2.12 12.38 0.89 7.01
retain 2.33 1.32 1.33 0.15 0.11
churn 1.00 0.46 0.66 0.16 0.75
churn_alloc 0.91 0.41 0.36 0.14 0.44
push_cls 0.89 0.40 0.34 0.14 0.45
cycles 0.29 0.19 0.95 0.07 0.31
churn_read 0.02 0.02 0.35 0.08 0.30
deeplist 0.03 0.31 1.14 0.09 0.22

tree total GC pause 4.107 s → 0.550 s, max pause 266 ms → 16 ms; trace_worklist falls from 2,877 ms out of the top six phases entirely. tree_wide 0.549 s of pause, 17 ms max. All 11 benchmark stdouts byte-identical to Node.

Costs, measured rather than argued. deeplist 0.03 → 0.31 and retain1 0.03 → 0.42: both keep their heap under the initial 64 MB threshold, so they previously ran zero collections and a moving nursery is pure added cost there. Both still beat a853135aa (1.14 / —). push_num 0.16 → 0.17.

The earlier objection is discharged

Polls-default-ON was declined once because a poll at every back-edge deleted the #7480 element-shape fast clone — a call inside a clone whose admission rests on being call-free-by-construction does not slow it, it removes it. Step 4 of that work now refuses to emit a poll inside such a clone. churn_read measures 0.02 s with polls either way.

Also in this PR

  • perf(gc): heap_payload_slot_selection runs once per traced object per GC walk and computed raw_numeric_object_slots via a SHAPE_LAYOUTS hash lookup behind a TLS RefCell — for a counter that returns on its first line unless PERRY_GC_LAYOUT_SCAN_TRACE armed it. Same shape as perf(typed-feedback): stop emitting recording calls into default builds (#7480) #7702. Now computed only when the trace is armed. Second item on the same walk: shape_shared_pointer_mask cloned a whole TypedLayoutDescriptor to keep one of its two masks, allocating and freeing a throwaway Vec per traced wide object; it now borrows and clones only what it returns.
  • test(gc): four runtime_roots tests took no pacing guard and so inherited the default. force_legacy_gc_pacing is the wrong repair and the tests say so themselves — three carry an evacuation witness ("the minor did not evacuate, so nothing here was exercised") and legacy pacing routes to the budgeted stepper, which is deliberately non-moving. force_alloc_point_minor_pacing (polls OFF, scavenge ON) is the combination they were written against.

Validation

  • perry-runtime 1947 passed / 0 failed; perry-codegen lib 796 / 0.
  • PERRY_GC_VERIFY_MARK=1 PERRY_GC_VERIFY_EVACUATION=1 clean on six benches.
  • PERRY_GC_ZEAL=1 PERRY_GC_PROTECT_FROMSPACE=1 at quarantine depths 8 and 800: 4,837 objects moved, no fault, byte-identical output. Zeal's verdict line is the liveness proof that the new default is live rather than merely untripped: forced_collections=2000005 copying_minors=2000005 moved_objects=8 loop_polls=2000000.
  • cargo fmt --all -- --check and scripts/check_file_size.sh clean.
  • Gap suite: in progress, not complete. 20/511 at time of writing — 18 pass, 2 parity_fail, and both (test_gap_2159_defineproperty_class_prototype, test_gap_2514_settracesigint) are already in test-parity/known_failures.json. Zero new failures so far. The run is crawling because the dev host is under load from other work; I will post the final verdict on this PR before it should be merged. Please do not merge on the partial result.

Three tests pin what was unpinned

polls_default_is_on and codegen's moving_safepoint_poll_default::unset_emits_the_poll each pin one half against the full spelling table — including the unrecognised-value arm, which is the one that silently changes meaning if the matches! is inverted back. polls_default_matches_codegen_mirror pins that the two crates agree; that disagreement is silent in both directions (polls nothing consumes, or a deferral nothing drains), so it needs its own assertion rather than two doc comments claiming they match.

Unrelated finding, not fixed here

crates/perry-codegen/tests/typed_shape_descriptors.rs::integer_arithmetic_array_push_omits_inbounds_layout_note_and_barrier is red on main and it is not this change — it fails identically under PERRY_GC_MOVING_LOOP_POLLS=0. #7702 turned typed_feedback_emission_enabled() off by default, so keep_guarded_numeric_push is false for a canonical-raw-f64 value and the apush.numeric_fast block is never emitted. The code change is deliberate; the test was not updated. That file is under crates/*/tests/, which does not run per-PR, so it landed red. push_num 0.13 → 0.16 on both main and this branch is the matching wall-clock cost.

https://claude.ai/code/session_01KaVWLc4q8Ng9P7mWmbUZpf

Summary by CodeRabbit

  • New Features

    • Moving-loop garbage-collection polling is now enabled by default.
    • Polling can still be disabled with 0, off, or false.
  • Bug Fixes

    • Reduced unnecessary garbage-collection layout work and descriptor copying.
    • Improved allocation and evacuation test coverage across supported GC pacing modes.
  • Tests

    • Added validation for default, enabled, disabled, and unrecognized polling settings.

@coderabbitai

coderabbitai Bot commented Aug 9, 2026

Copy link
Copy Markdown

Review Change Stack

Warning

Review limit reached

@proggeramlug, you've reached your PR review limit, so we couldn't start this review.

Next review available in: 11 minutes

You've used all free OSS reviews for now. Wait for the free limit to reset to keep reviewing this public repository.

How can I continue?

After more reviews become available, a review can be triggered using the @coderabbitai review command as a PR comment. Alternatively, push new commits to this PR.

To avoid repeated limits, reduce automatic review volume by pausing incremental auto-reviews earlier, using label-based review opt-in, excluding WIP or generated PR titles, or requesting reviews manually when the PR is ready. If your team needs uninterrupted high-volume reviews, an organization admin can enable usage-based reviews.

How do review limits work?

CodeRabbit enforces per-developer PR review limits for each organization. Most developers receive the normal plan review availability.

For paid Pro and Pro+ PR reviews, CodeRabbit uses adaptive limits for sustained high-volume activity. When a developer's recent PR review activity reaches the 95th percentile or higher among CodeRabbit users, additional reviews become available more gradually as earlier reviews age out of the rolling window.

Please refer docs for additional details.

Review details
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro Plus

Run ID: 2e3f9db2-e37d-46a6-b4f4-a2fbaec50282

📥 Commits

Reviewing files that changed from the base of the PR and between 70a3523 and 691c664.

⛔ Files ignored due to path filters (1)
  • Cargo.lock is excluded by !**/*.lock
📒 Files selected for processing (2)
  • CLAUDE.md
  • Cargo.toml
📝 Walkthrough

Walkthrough

The change enables moving-loop GC polls by default in runtime and code generation, optimizes selected GC layout paths, and sets explicit pacing modes in runtime-root tests.

Changes

GC moving-loop poll defaults

Layer / File(s) Summary
Align moving-loop poll configuration
crates/perry-runtime/src/gc/policy.rs, crates/perry-codegen/src/stmt/loops.rs, crates/perry-runtime/src/gc/tests/triggers.rs, changelog.d/7721-gc-moving-loop-poll-default.md
Runtime and codegen enable moving-loop polls unless PERRY_GC_MOVING_LOOP_POLLS is 0, off, or false. Tests cover defaults, explicit values, unknown values, and runtime/codegen parity.
Restrict GC layout work
crates/perry-runtime/src/gc/layout.rs, changelog.d/7721-gc-moving-loop-poll-default.md
Shared pointer-mask lookup clones only pointer_mask. Raw numeric slot counting runs only during active layout-scan tracing.
Pin GC pacing in rooting tests
crates/perry-runtime/src/gc/tests/runtime_roots/*, changelog.d/7721-gc-moving-loop-poll-default.md
Evacuation-sensitive tests force allocation-point minor pacing. The symbol description test forces legacy pacing.

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

Sequence Diagram(s)

sequenceDiagram
  participant Environment
  participant RuntimePolicy
  participant CodegenLoops
  participant TriggerTests
  Environment->>RuntimePolicy: provide PERRY_GC_MOVING_LOOP_POLLS
  Environment->>CodegenLoops: provide PERRY_GC_MOVING_LOOP_POLLS
  RuntimePolicy->>TriggerTests: return runtime setting
  CodegenLoops->>TriggerTests: return codegen setting
  TriggerTests->>TriggerTests: verify parity
Loading

Possibly related PRs

🚥 Pre-merge checks | ✅ 5
✅ Passed checks (5 passed)
Check name Status Explanation
Title check ✅ Passed The title clearly identifies the primary change: enabling moving-loop polls by default in the runtime and code generator.
Description check ✅ Passed The description clearly explains the fix, related issues, concrete changes, measurements, validation, known gaps, and unrelated findings.
Docstring Coverage ✅ Passed Docstring coverage is 100.00% which is sufficient. The required threshold is 80.00%.
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 gc/7690-polls-default-on

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/policy.rs`:
- Around line 477-492: The documentation around the moving-loop poll policy
still describes the process default as off. Update the comment associated with
moving_loop_polls_enabled_from_env to state that polls are enabled when
PERRY_GC_MOVING_LOOP_POLLS is unset, matching the implementation at lines
547-548 and the existing default-on behavior.
🪄 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: 209e6a6e-801c-40f8-8b5a-3ae4837ceb1e

📥 Commits

Reviewing files that changed from the base of the PR and between e732f82 and 70a3523.

📒 Files selected for processing (8)
  • changelog.d/7721-gc-moving-loop-poll-default.md
  • crates/perry-codegen/src/stmt/loops.rs
  • crates/perry-runtime/src/gc/layout.rs
  • crates/perry-runtime/src/gc/policy.rs
  • crates/perry-runtime/src/gc/tests/runtime_roots/fs_options_object.rs
  • crates/perry-runtime/src/gc/tests/runtime_roots/json_shape_template.rs
  • crates/perry-runtime/src/gc/tests/runtime_roots/symbol_description.rs
  • crates/perry-runtime/src/gc/tests/triggers.rs

Comment on lines +477 to +492
/// **DEFAULT ON.** The kill switch is `PERRY_GC_MOVING_LOOP_POLLS=0`/`off`/`false`.
/// See [`moving_loop_polls_enabled_from_env`] for the decision and its evidence;
/// #7161's stopgap default-OFF (pending #7154) is discharged there.
///
/// MUST match codegen `moving_safepoint_polls_enabled` (same env) so the deferral
/// and the polls that drain it stay coherent — a runtime default that disagrees
/// with the codegen default would defer collections that never drain (or drain
/// collections that were never deferred).
/// collections that were never deferred). That disagreement is not hypothetical:
/// it shipped. #7690 wrote the default-ON argument into the doc below and left
/// both bodies matching `1|on|true`, so the runtime deferred nursery pressure to
/// a safepoint codegen never emitted. Combined with #7687 (the alloc-point minor
/// must not move), the shipped collector had NO nursery evacuation at all —
/// `churn_alloc` ran 13 whole-arena full collections where it had run 105 copying
/// minors, and `tree` spent 4.1 s of its 5.1 s wall in GC pause. Both predicates
/// are now pinned by tests, and `polls_default_matches_codegen_mirror` pins that
/// they agree.

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

📐 Maintainability & Code Quality | 🟡 Minor | ⚡ Quick win

Correct the stale default description.

Line 496 states that the process default is off. Lines 547-548 enable polls when PERRY_GC_MOVING_LOOP_POLLS is unset. Update that comment to state that the process default is on.

🤖 Prompt for 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.

In `@crates/perry-runtime/src/gc/policy.rs` around lines 477 - 492, The
documentation around the moving-loop poll policy still describes the process
default as off. Update the comment associated with
moving_loop_polls_enabled_from_env to state that polls are enabled when
PERRY_GC_MOVING_LOOP_POLLS is unset, matching the implementation at lines
547-548 and the existing default-on behavior.

@proggeramlug

Copy link
Copy Markdown
Contributor Author

Audit — the change is right; two corrections, and I am respecting your "do not merge on the partial result"

Not merging. You asked for the gap verdict first, and 20/511 is not a verdict. Everything below is review, not a hold-up.

The flip is real this time, and in both bodies

matches!(v, Some("1")|Some("on")|Some("true"))!matches!(v, Some("0")|Some("off")|Some("false")), in the runtime and in codegen, with the predicate extracted as a pure function so it can actually be tested. That is the thing #7690 claimed and did not do.

The framing is also right: this is not a slower configuration, it is a different collector. With no poll emitted, a compute-only program reaches neither precise collection point, so every nursery collection landed at the register-imprecise allocation point where #7687 had just made it correctly non-moving — leaving no nursery evacuation at all and a trigger falling back to whole-arena full collections. churn_alloc: 13 whole-arena fulls / 0.477 s of pause, against 105 copying minors / 0.016 s at a853135aa.

tree total pause 4.107 s → 0.550 s, max 266 ms → 16 ms, trace_worklist out of the top six phases. And the zeal verdict line is the liveness proof that the new default is live rather than merely untripped.

The costs are stated, not burieddeeplist 0.03 → 0.31 and retain1 0.03 → 0.42 — and your explanation is independently corroborated. I measured this today on #6978: a program freeing 36.7 MB against the 64 MB initial trigger runs zero automatic collections (0 diag lines), while the same program at 10× runs 12. So yes: those benches previously collected never, and a moving nursery is pure added cost there. Worth stating in the changelog as a general property rather than a per-bench excuse — any workload under the initial threshold pays this.

1. polls_default_matches_codegen_mirror pins a copy, not the agreement

// Mirrors `perry_codegen::stmt::loops::moving_safepoint_polls_enabled_from_env`.
fn codegen_mirror(value: Option<&str>) -> bool {
    !matches!(value, Some("0") | Some("off") | Some("false"))
}

That is a re-implementation living in perry-runtime. If codegen's predicate changes, this test still passes — it is asserting that the runtime matches a hardcoded literal, not that the two crates agree. Which is the same failure class as the doc comment this PR exists to fix: a claim of agreement that nothing binds.

Not your fault structurally — perry-codegen/Cargo.toml has no perry-runtime dependency (only perry-hir, perry-dispatch, perry-api-manifest), so neither crate can call the other's function.

The repo already has the right idiom for exactly this, in gc_root_dominance_corpus.sh's rs4gc_pass_string(): single-source the value out of the Rust by parsing it, and treat "not found" as a hard error, precisely so a fourth copy can't drift. A ~15-line scripts/ check that extracts both predicate bodies and asserts they are textually identical would make the agreement binding, and would fail loudly if either is reworded. Your own PR text argues the disagreement is silent in both directions — that is the argument for a checker, not a copy.

2. The integer_arithmetic_array_push_... attribution is wrong

You attribute it to #7702 turning typed_feedback_emission_enabled() off. It predates that.

I captured a full cargo test -p perry-codegen baseline on main at v0.5.1402, and that test is in it — five versions before #7702 merged at v0.5.1407. The audit of #7702 independently bisected the same test to 94ad78435, its own parent, and found it already failing there.

Your conclusion stands — it is not caused by this PR, and it landed red because crates/*/tests/ does not run per-PR — but the cause is something older. It is one of the 6 pre-existing failures I filed as #7708, and worth triaging there rather than as #7702 residue.

Small thing

The four runtime_roots pacing-guard repairs look right, and the reasoning is the good kind: force_legacy_gc_pacing routes to the budgeted stepper, which is deliberately non-moving, so it would have satisfied the guard while defeating the three tests' own evacuation witnesses. force_alloc_point_minor_pacing is the combination they were written against.

Post the gap verdict and I will merge.

@proggeramlug

Copy link
Copy Markdown
Contributor Author

Addendum on the attribution point — there is stronger evidence than my baseline, and it settles it.

#7494 already documents integer_arithmetic_array_push_omits_inbounds_layout_note_and_barrier as failing on main at f05ae3b, alongside three siblings. That is well before #7702 existed. It also establishes two things worth having:

So the typed_feedback_emission_enabled() explanation is a plausible-looking mechanism for a failure that predates the change it is attributed to. Worth flagging because that is a genuinely easy trap here: #7702 does make keep_guarded_numeric_push false for a canonical-raw-f64 value, so the reasoning is locally sound — it just isn't why this test is red.

Your push_num 0.13 → 0.16 observation on both main and this branch is consistent with that: the cost is on main independently of your change.

Nothing here affects the PR. Everything else in my review stands, and I'm still holding for the gap verdict.

@proggeramlug

Copy link
Copy Markdown
Contributor Author

Gap suite: complete — no regression attributable to this PR

Run on the pinned quiet M1 mini (PERRY_SKIP_BUILD=1 against this branch's compiler and archives, node v26.5.1 matching .node-version). The dev host was too contended to finish — 65 s/test, an 8.8 h ETA — so the run was moved to the bench host at ~1.6 s/test.

511 tests: 487 pass, 18 parity_fail, 6 crash.

The harness reports 9 regressions against gap_snapshot.json. All nine reproduce identically under PERRY_GC_MOVING_LOOP_POLLS=0 — same host, same tree, same binary, and since the harness compiles each test with that env set, both the codegen and runtime halves revert to the pre-change default. None is caused by this PR.

regression polls ON polls OFF (pre-change default) cause
test_gap_gc_rest_argument_rooting parity_fail parity_fail #7684 [gc-zeal] line, see below
test_gap_gc_same_module_call_argument_rooting parity_fail parity_fail same
test_gap_zlib_3285_params parity_fail parity_fail known host-local zlib flake
test_gap_http_overloads_3226plus crash crash tokio sandbox panic
test_gap_net_connect_bound_value crash crash tokio sandbox panic
test_gap_fetch_request_from_node_incoming_message crash crash tokio sandbox panic
test_gap_http_client_no_redirect_follow crash crash tokio sandbox panic
test_gap_http_req_async_iterator crash crash tokio sandbox panic
test_gap_http_res_socket_writable_onfinished crash crash tokio sandbox panic

The two GC rooting tests actually PASS, and are evidence for this change

Their assertion is bad 0, and both sides print exactly that. The entire diff is a line on stderr, which the harness merges into stdout via > $tmp 2>&1:

--- node ---            --- perry ---
bad 0                   bad 0
                        [gc-zeal] forced_collections=9653 copying_minors=9653 moved_objects=115728 loop_polls=9648

That is #7684's zeal verdict line breaking parity for any test that enables zeal — a pre-existing defect in the verdict's output channel, not a rooting failure.

Read the numbers, though: these are the tests written specifically to catch rooting bugs, and under the new default they ran 9,653 and 19,253 copying minors, moving 115,728 and 231,348 objects, and still reported bad 0. Under the old default they move nothing. This is the strongest correctness evidence in the PR, and it only exists because the flip turns the moving collector on.

The five crashes are the host's tokio sandbox panic

thread '<unnamed>' panicked at tokio-1.53.1/src/net/tcp/listener.rs:304:22:
there is no reactor running, must be called from the context of a Tokio 1.x runtime

All are http/net/fetch socket tests. Unrelated to GC.

Caveat, stated plainly

gap_snapshot.json was recorded on a different host, so part of the delta above (including 10 node_fail -> parity_fail status changes on npm-dependent tests like dayjs/moment/cron/slugify) is oracle-environment drift, not a Perry change. The load-bearing control is therefore not the snapshot but the same-host kill-switch A/B, which isolates this PR's variable and holds for every one of the nine.

One improvement also recorded: test_gap_iterator_helpers_2874 parity_fail -> pass.

Ralph Küpper added 6 commits August 9, 2026 19:32
…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.
…r 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.
…ssert 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.
@proggeramlug
proggeramlug force-pushed the gc/7690-polls-default-on branch from 70a3523 to 691c664 Compare August 9, 2026 17:32
@proggeramlug

Copy link
Copy Markdown
Contributor Author

Merging as v0.5.1418 — the gap verdict holds, and I checked its mechanism independently

The methodology is the right one. The load-bearing control is the same-host, same-binary kill-switch A/B, not the cross-host snapshot — and you say so explicitly, including that part of the snapshot delta (the 10 node_fail → parity_fail changes on npm-dependent tests) is oracle-environment drift rather than a Perry change. All nine reported regressions reproduce identically under PERRY_GC_MOVING_LOOP_POLLS=0, which isolates exactly the variable this PR changes.

I verified the [gc-zeal] explanation rather than taking it: the harness captures with > "$node_tmp" 2>&1 (run_parity_tests.sh:1261), so any stderr line breaks byte parity, and the verdict is printed at gc/zeal.rs:229. So the two GC rooting tests are a pre-existing defect in that verdict's output channel, not a rooting failure. I'd hit the same thing independently earlier — it's worth its own fix, since it will keep breaking parity for any test that enables zeal.

And the numbers in those two tests are the best evidence in the PR. They are the tests written specifically to catch rooting bugs; under the new default they ran 9,653 and 19,253 copying minors, moving 115,728 and 231,348 objects, and still reported bad 0. Under the old default they move nothing at all. A rooting test that never moves an object is not a passing rooting test — it is an untested one.

Everything from my earlier review stands: the flip is real in both bodies, the framing (a different collector, not a slower one) is correct, and the costs are stated rather than buried.

Two follow-ups, neither blocking

  1. polls_default_matches_codegen_mirror pins a copy. It re-implements codegen's predicate inside perry-runtime, so if codegen's changes the test still passes. Structurally forced — neither crate depends on the other — but rs4gc_pass_string()'s idiom (parse the value out of the Rust, hard-error on "not found") would make the agreement binding. Given this PR exists because a doc comment claimed an agreement that had stopped holding, it's worth closing that loop properly.
  2. The [gc-zeal] line should go to a channel the harness doesn't fold into stdout, or be suppressed when a parity run is detected.

Gates 19/19. Rebased onto v0.5.1417 (this branch was 5 commits behind after today's merges).

@proggeramlug
proggeramlug merged commit ca8c0d6 into main Aug 9, 2026
1 of 16 checks passed
@proggeramlug
proggeramlug deleted the gc/7690-polls-default-on branch August 9, 2026 17:35
proggeramlug pushed a commit that referenced this pull request Aug 9, 2026
… no-op

crates/perry-runtime/src/gc/tests/runtime_roots/generator_attach_prototype.rs's
three tests were red on main: js_generator_attach_prototype and
js_generator_attach_closure_prototype no longer moved their receiver under an
alloc-point copying minor, and the shipped-default witness never saw its
trigger armed.

warm_generator_intrinsics() called js_generator_attach_prototype(TAG_UNDEFINED, 0)
to pre-build the generator intrinsic tower before the timed call under test.
That never worked: js_generator_attach_prototype returns at its very first
line for any non-pointer obj, so the "warm-up" touched nothing. It went
unnoticed because GENERATOR_FUNCTION_INTRINSIC_PTR and its five siblings were
plain process-global AtomicI64s pre-#7723 - some earlier test in the same
binary had almost always already built the tower, so the real call under test
found it cached regardless of what warm_generator_intrinsics() did.

#7723 converted those six statics to per_test_global! specifically so each
test starts from a guaranteed first-touch state (crates/perry-runtime/src/gc/tests/lazy_intrinsic_towers.rs's
whole point). That is a correct, deliberate change - it took away the
accidental cross-test priming these three tests had been relying on. With
nothing pre-built, the real call now pays the dozens-of-allocations tower
build itself, inside build_generator_tower's GcSuppressScope (#7251's no-move
window for that build). That suppression window swallows the arena trigger
the test injected via arm_collection_on_next_block for the rest of the call:
no copying minor ever runs before the tower build's own scope closes, and by
then intermediate's own allocation no longer needs a new arena block, so the
trigger is never serviced. Confirmed with instrumented gc_check_trigger /
GcSuppressScope traces comparing the last-good commit against #7723: on the
last-good commit the real call's first allocation reaches gc_check_trigger
unsuppressed and services the trigger directly; on #7723 the entire ~1800-call
tower build runs suppressed first and nothing ever re-triggers afterward.

Fix warm_generator_intrinsics() to call crate::object::ensure_generator_intrinsics()
directly - the same builder lazy_intrinsic_towers.rs uses - so it does what
its name and doc comment always claimed. This does not touch the
liveness/deferral assertions those tests make; it only repairs the test's own
setup helper.

Bisected via git checkout of each of today's three merges in an isolated
worktree: c907953 (pre-#7721) passes; ca8c0d6 (#7721, moving-loop poll
default flip) passes; cbb682d (#7723, no-move window + per_test_global
towers) is the first commit where all three fail. #7724 is uninvolved.
proggeramlug added a commit that referenced this pull request Aug 9, 2026
… no-op (#7731)

* fix(gc): warm_generator_intrinsics must call the tower builder, not a no-op

crates/perry-runtime/src/gc/tests/runtime_roots/generator_attach_prototype.rs's
three tests were red on main: js_generator_attach_prototype and
js_generator_attach_closure_prototype no longer moved their receiver under an
alloc-point copying minor, and the shipped-default witness never saw its
trigger armed.

warm_generator_intrinsics() called js_generator_attach_prototype(TAG_UNDEFINED, 0)
to pre-build the generator intrinsic tower before the timed call under test.
That never worked: js_generator_attach_prototype returns at its very first
line for any non-pointer obj, so the "warm-up" touched nothing. It went
unnoticed because GENERATOR_FUNCTION_INTRINSIC_PTR and its five siblings were
plain process-global AtomicI64s pre-#7723 - some earlier test in the same
binary had almost always already built the tower, so the real call under test
found it cached regardless of what warm_generator_intrinsics() did.

#7723 converted those six statics to per_test_global! specifically so each
test starts from a guaranteed first-touch state (crates/perry-runtime/src/gc/tests/lazy_intrinsic_towers.rs's
whole point). That is a correct, deliberate change - it took away the
accidental cross-test priming these three tests had been relying on. With
nothing pre-built, the real call now pays the dozens-of-allocations tower
build itself, inside build_generator_tower's GcSuppressScope (#7251's no-move
window for that build). That suppression window swallows the arena trigger
the test injected via arm_collection_on_next_block for the rest of the call:
no copying minor ever runs before the tower build's own scope closes, and by
then intermediate's own allocation no longer needs a new arena block, so the
trigger is never serviced. Confirmed with instrumented gc_check_trigger /
GcSuppressScope traces comparing the last-good commit against #7723: on the
last-good commit the real call's first allocation reaches gc_check_trigger
unsuppressed and services the trigger directly; on #7723 the entire ~1800-call
tower build runs suppressed first and nothing ever re-triggers afterward.

Fix warm_generator_intrinsics() to call crate::object::ensure_generator_intrinsics()
directly - the same builder lazy_intrinsic_towers.rs uses - so it does what
its name and doc comment always claimed. This does not touch the
liveness/deferral assertions those tests make; it only repairs the test's own
setup helper.

Bisected via git checkout of each of today's three merges in an isolated
worktree: c907953 (pre-#7721) passes; ca8c0d6 (#7721, moving-loop poll
default flip) passes; cbb682d (#7723, no-move window + per_test_global
towers) is the first commit where all three fail. #7724 is uninvolved.

* changelog: add fragment for #7731 (generator-attach-pacing)

* chore: bump version to 0.5.1422

Claude-Session: https://claude.ai/code/session_01Y1QZ5wUP9gRSwpiweT4Wix

---------

Co-authored-by: Ralph Küpper <ralph@skelpo.com>
proggeramlug pushed a commit that referenced this pull request Aug 9, 2026
…oad (#7721)

#7721 turned the moving-loop back-edge poll on by default, which was right about
the collector and wrong about its price. The poll is emitted at EVERY allocating
loop back-edge — 20 M of them in `bench/churn_alloc.ts` — so its no-work path is
a per-iteration cost of the language, and that path was an out-of-line call into
two `OnceLock` acquire loads, an unconditional atomic increment, and a
thread-local read that on Darwin is a CALL to `_tlv_get_addr`. ~3 ns per
back-edge: `churn_alloc` 0.367 s -> 0.419, `push_cls` 0.350 -> 0.408,
`push_num` 0.131 -> 0.178.

`gc/poll_arm.rs` adds `PERRY_GC_POLL_ARMED`, a process-global counter of the
reasons the poll must do more than return. Zero is a PROOF the poll is a no-op,
so codegen loads it inline and branches around the call (two aarch64
instructions, address hoisted into the preheader) and the runtime entry point
re-checks it for modules from any other emission path.

`GC_SAFEPOINT_PENDING` now has exactly one writer, `policy::set_safepoint_pending`,
which moves the flag and the global together — the word reading zero while a
deferral is outstanding is the one unsound direction, and it would strand that
collection until an event-loop boundary a compute-only program never reaches.

Measured best-of-7 interleaved on the quiet M1 bench host, outputs verified
against `node --experimental-strip-types`:

| bench | main | this | 0.5.1384 |
|---|--:|--:|--:|
| churn_alloc | 0.419 | 0.376 | 0.367 |
| push_cls | 0.408 | 0.357 | 0.350 |
| push_num | 0.178 | 0.144 | 0.131 |
| churn | 0.45 | 0.41 | — |
| churn_read | 0.02 | 0.02 | — |
| cycles | 0.19 | 0.19 | — |
| deeplist | 0.31 | 0.31 | — |
| tree | 1.64 | 1.64 | — |
| tree_wide | 2.10 | 2.12 | — |

GC behaviour is unchanged: `churn` runs 105 minors in both arms with positive
reclamation every cycle, max pause 3.63 ms -> 1.78 ms. `gc-handoff/apps/iso_miss.ts`
prints `checksum 437840 misses 0`.
proggeramlug pushed a commit that referenced this pull request Aug 9, 2026
…oad (#7721)

#7721 turned the moving-loop back-edge poll on by default, which was right about
the collector and wrong about its price. The poll is emitted at EVERY allocating
loop back-edge — 20 M of them in `bench/churn_alloc.ts` — so its no-work path is
a per-iteration cost of the language, and that path was an out-of-line call into
two `OnceLock` acquire loads, an unconditional atomic increment, and a
thread-local read that on Darwin is a CALL to `_tlv_get_addr`. ~3 ns per
back-edge: `churn_alloc` 0.367 s -> 0.419, `push_cls` 0.350 -> 0.408,
`push_num` 0.131 -> 0.178.

`gc/poll_arm.rs` adds `PERRY_GC_POLL_ARMED`, a process-global counter of the
reasons the poll must do more than return. Zero is a PROOF the poll is a no-op,
so codegen loads it inline and branches around the call (two aarch64
instructions, address hoisted into the preheader) and the runtime entry point
re-checks it for modules from any other emission path.

`GC_SAFEPOINT_PENDING` now has exactly one writer, `policy::set_safepoint_pending`,
which moves the flag and the global together — the word reading zero while a
deferral is outstanding is the one unsound direction, and it would strand that
collection until an event-loop boundary a compute-only program never reaches.

Measured best-of-7 interleaved on the quiet M1 bench host, outputs verified
against `node --experimental-strip-types`:

| bench | main | this | 0.5.1384 |
|---|--:|--:|--:|
| churn_alloc | 0.419 | 0.376 | 0.367 |
| push_cls | 0.408 | 0.357 | 0.350 |
| push_num | 0.178 | 0.144 | 0.131 |
| churn | 0.45 | 0.41 | — |
| churn_read | 0.02 | 0.02 | — |
| cycles | 0.19 | 0.19 | — |
| deeplist | 0.31 | 0.31 | — |
| tree | 1.64 | 1.64 | — |
| tree_wide | 2.10 | 2.12 | — |

GC behaviour is unchanged: `churn` runs 105 minors in both arms with positive
reclamation every cycle, max pause 3.63 ms -> 1.78 ms. `gc-handoff/apps/iso_miss.ts`
prints `checksum 437840 misses 0`.
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