Skip to content

fix(gc): explicit gc() runs on precise roots — remove the forced conservative stack scan (#7558) - #7657

Merged
proggeramlug merged 7 commits into
mainfrom
fix/7558-gc-precise-roots
Aug 8, 2026
Merged

fix(gc): explicit gc() runs on precise roots — remove the forced conservative stack scan (#7558)#7657
proggeramlug merged 7 commits into
mainfrom
fix/7558-gc-precise-roots

Conversation

@proggeramlug

@proggeramlug proggeramlug commented Aug 8, 2026

Copy link
Copy Markdown
Contributor

Closes #7558.

What the conservative scan at gc() actually was

A workaround for a precise-rooting hole, not a property gc() needs.

#4977 (2026-06-11) reported const keep = {…}; gc(); keep.nested.deep reading dangling-pointer garbage: a module-init/top-level local was held only as a native-stack alloca that neither the shadow stack nor the module-var scanners covered. The fix (#4998) forced the conservative native-stack scan at the one collection site that could hide it. Nothing about gc() was ever argued to require a different root set from every other collection — and every automatic collection in a production binary already runs precise (conservative_stack_scan_mode()AutoSkipDisabled), including a full mark-sweep with precise roots at the microtask-pump safepoint (#7148's SafepointDrainKind::OldReclaim).

The hole itself was closed from the other end by the 2026-06→08 rooting campaign: pointer-typed locals get a persistent shadow slot bound in function-entry setup (#6968, #6951/#6972), module-level bindings are @perry_global_* cells registered with js_gc_register_global_root, and scripts/gc_root_dominance_check.py gates "a root store must dominate every collection point" with an empty allowlist. js_gc_collect is a collection point by that invariant like any other.

So the scan at gc() was grandfathered. This removes it.

What it cost

gc_ratchet.py classify on main at 961777904, all twelve probes — the residue was not the "16% on one probe" the issue reported, it was on nine of twelve and up to 31%:

probe conservative precise excess
01_nursery_churn 7,325,584 5,228,512 28.63%
02_survivor_promotion 9,678,792 9,416,632 2.71%
03_cross_gen_writes 1,427,664 1,394,880 2.30%
04_dead_after_deep_stack 4,897,320 4,891,968 0.11%
05_closure_capture 7,426,960 5,329,880 28.24%
06_string_retention 7,058,896 4,961,800 29.71%
07_array_grow_evacuate 15,649,104 15,649,104 0.00%
08_map_set_sidetables 1,512,456 1,512,456 0.00%
09_try_catch_roots 6,020,664 5,825,256 3.25%
10_store_receiver_across_alloc 4,664,632 4,664,632 0.00%
11_collect_at_depth 7,390,832 5,097,776 31.03%
12_large_live_set 59,942,456 51,668,568 13.80%

On this build, classify reports excess 0.00% and spread 0 on all twelve, and the [gc-scan-fallback] site=manual_collect census line is gone from every probe.

The part that is a real collector-behaviour change, not just a reading

gc/tenuring.rs deliberately refuses to seed the adaptive tenuring threshold from a cycle that ran the conservative scan ("a liveness measurement taken under it is not sound"). Every probe drives collection with gc(), so on those workloads the seed had never fired and tenuring_survivals sat at its power-on 4. It fires now.

On 09_try_catch_roots and 11_collect_at_depth the threshold falls 4 → 1 and every survivor is promoted on first copy:

probe copied_objects promoted_objects heap_used_bytes heap_total_bytes
09_try_catch_roots 5,823 → 0 0 → 6,077 −3.26% unchanged
11_collect_at_depth 5,830 → 0 0 → 6,150 −31.03% unchanged

PERRY_GC_DIAG=1 on both arms confirms the copying minor still ran (eligible=true fallback=none, [gc-copy-minor] ran) and moved more objects — to old-gen instead of survivor space. freed_bytes is within 0.08%, heap_total_bytes is byte-identical, probe stdout is byte-identical, and RSS falls.

That exposed a hole in the ratchet's own liveness rule, fixed here: it asserted copied_objects > 0, but copied_objects and promoted_objects come from the same [gc-copy-minor] ran line and each names a destination. Only their sum answers "did the copying minor move anything". Left alone, the re-pin would have pinned copied_objects = 0 on those two probes and made the rule's base > 0 guard permanently false exactly where it had most recently fired — CLAUDE.md's fourth failure mode, inside the gate meant to close it. copied_objects keeps its own two-sided 5% band, so the same shift is still a -100% REGRESSION row that must be re-pinned deliberately.

Verification

Sabotage-verified detector, not just a green test. explicit_gc_collects_precisely_and_a_native_stack_plant_dies plants a pointer-shaped word (both NaN-boxed and raw-I64 encodings) in a live native-stack frame as the only reference to a real malloc-GC object, calls js_gc_collect() from that frame, and asserts the object is swept — with the per-thread scan override cleared, because the test-isolation guard's pinned Auto would otherwise make a reintroduced force_full_scan a silent no-op. Its companion the_native_stack_plant_survives_when_the_scan_is_pinned_on runs the identical plant with the scan pinned Full and asserts the object survives, so a green detector means "the plant was findable and was not found", not "the plant never landed".

Re-adding force_full_scan to manual_gc_collect_now and re-running: 0 error[, Running unittests present, control arm still ok, detector arm FAILED on exactly the intended assertion.

  • cargo test -p perry-runtime --lib --no-fail-fast1,916 passed, 0 failed.
  • python3 -m unittest tests.test_gc_ratchet — 72 tests, green.
  • Gap suite, 506 tests, fix arm: 23 failures. Every one A/B'd individually against main built identically — all 23 produce byte-identical output on both arms once pids/tids/ports are normalised. Zero regressions attributable to this change. (A regression necessarily appears as a fix-arm failure, so this covers the failure class completely.)
  • test-files/test_issue_4977_gc_toplevel_locals.ts — correct output on the fix arm and with PERRY_CONSERVATIVE_STACK_SCAN=off on main.
  • Ratchet control: main at 961777904, same pinned quiet host, same toolchain, built identically, reproduced the previous artifact and exited gc-ratchet: OK before the new pin was taken. Every delta above is this change, not accumulated drift.
  • All 22 lint-job commands, cargo fmt --all -- --check, cargo check --all-targets.

Re-pin (deliberate, and required by this change — flagged rather than done silently)

benchmarks/gc_ratchet/baseline/gc-ratchet-v1.json is re-pinned on the pinned quiet host with the reasoning in --notes. This is not optional dressing: deleting the probe_overrides entry requires a baseline whose 12_large_live_set.heap_used_bytes has zero spread, and that cell is only bit-identical because of this change. Per #7652 the artifact also regains single provenance (143 cells were from 26b9c9d59, one from c8394bfdb).

tolerances.json probe_overrides is now empty — the #7554 entry was deleted because its cause is gone, which is the rule that file carries.

Scope, and what is deliberately not in it

  • perry/gc minor() keeps its forced scan. Dropping it there makes the copying minor eligible, so the collection starts relocating survivors rather than merely retaining less — a different risk with a different proof obligation. Bundling it would have made one A/B answer two questions.
  • The ManualCollect census variant is deleted, not left unconstructible, matching the rule the HostPressure note in the same enum already states: an arm nothing can produce is a claim no test can check, and its count=0 would read as "the site is quiet" when the truth is "the site is gone".
  • gc-native-roots is unaffected, and that was checked rather than assumed. Its arms already set PERRY_CONSERVATIVE_STACK_SCAN=off, which beats the pinned Full this PR removes, so main was already precise there. Simulating that arm locally on both probes whose counters move (PERRY_GC_FORCE_EVACUATE=1 PERRY_GC_VERIFY_EVACUATION=1 PERRY_GC_HEAP_LIMIT=8 PERRY_GC_INCREMENTAL=0 PERRY_CONSERVATIVE_STACK_SCAN=off): base and fix are identical — 09_try_catch_roots 6 copying minors / 388 copied on both, 11_collect_at_depth 5 / 259 on both, gc_evacuation_liveness_assert.py exit 0 on both, stdout byte-identical.
  • The tenuring flip is deterministic and reproduces cross-host. Spread 0 on copied_objects/promoted_objects/minor_cycles across the pinned run's traced repeats, and the same values (0 / 6,077) on a second, different machine.

Residual, stated rather than hidden

A value unrooted across gc() with no other collection point in its window is now freed rather than conservatively retained. gc-root-dominance.yml runs --moving-only and js_gc_collect is not in POLL_CAPABLE_RUNTIME, so that specific window shape is invisible to the gate's filtered arm (the unfiltered arm does see it — js_gc_collect is not in NONCOLLECTING).

I tried to measure the residual and the measurement was vacuous, which is worth more than the number would have been. Running the shipped corpus (129 sources → 149 modules, 2,452 functions, 9,799 root stores) with js_gc_collect added to the mover set reports violations: 0 — but @js_gc_collect appears in those 149 .ll files exactly 149 times, once each, every one of them a declare and not a single call. No corpus source calls gc(), so the arm proved nothing. (For the record the unmodified gate is clean on this build: violations: 0 (moving-minor reachable: 0).)

So the honest statement is: this window shape is unmeasured, and the reason is that the gate corpus cannot currently express it. Adding js_gc_collect to POLL_CAPABLE_RUNTIME is also semantically wrong — that set means "can re-enter JS", which gc() cannot — so the right follow-up is a corpus source that calls gc() with a live local across it, plus a collector-point classification separate from the mover set. Left for a separate change rather than bolted on here.

@coderabbitai

coderabbitai Bot commented Aug 8, 2026

Copy link
Copy Markdown

Warning

Review limit reached

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

Next review available in: 25 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: 3bb6169d-bb63-416f-971d-3e706ede881a

📥 Commits

Reviewing files that changed from the base of the PR and between 99b6ecb and 6c88ab3.

📒 Files selected for processing (14)
  • benchmarks/gc_ratchet/README.md
  • benchmarks/gc_ratchet/baseline/gc-ratchet-v1.json
  • benchmarks/gc_ratchet/gc_ratchet.py
  • benchmarks/gc_ratchet/tolerances.json
  • changelog.d/7657-gc-explicit-collect-precise-roots.md
  • crates/perry-runtime/src/gc/heap_snapshot.rs
  • crates/perry-runtime/src/gc/policy.rs
  • crates/perry-runtime/src/gc/roots/scan_mode.rs
  • crates/perry-runtime/src/gc/scan_fallback.rs
  • crates/perry-runtime/src/gc/tests/roots.rs
  • crates/perry-runtime/src/gc/tests/scan_fallback.rs
  • docs/src/internals/gc-rooting-invariant.md
  • scripts/gc_evacuation_liveness_assert.py
  • tests/test_gc_ratchet.py

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.

@proggeramlug

Copy link
Copy Markdown
Contributor Author

Audit — merging as v0.5.1374

The finding, and why I believe it

The forced conservative scan was never something gc() needs. It was a workaround for a precise-rooting hole (#4977#4998), applied at the one site that could hide it, and the hole was closed from the other end by the 2026-06→08 rooting campaign. Grandfathered, then quoted for two months as though it were a requirement.

I verified the load-bearing claim rather than the conclusion: test_issue_4977_gc_toplevel_locals.ts, built on this branch with the scan removed, prints 16 / leaf-string-4916 / widget-name-4977 / 1 — correct — and does so again under PERRY_GC_ZEAL=1 PERRY_GC_PROTECT_FROMSPACE=1. The original defect does not come back.

Sabotage verified independently: re-adding a force_full_scan to manual_gc_collect_now reddens exactly explicit_gc_collects_precisely_and_a_native_stack_plant_dies, with error[ 0 and Running unittests present; the other ten stay green. (Two earlier attempts of mine failed to compile and I discarded them rather than reading the result — the protocol earning its keep for the fifth time today.)

The detector itself is the right shape: it clears the pinned override so it sees what a production binary sees — otherwise an already-pinned mode makes force_full_scan a no-op and the test cannot distinguish a removed force from a suppressed one — and it asserts a collection actually ran before concluding the plant died.

Excess → 0.00% on all twelve, and probe_overrides is empty

Nine of twelve probes carried non-zero residue, up to 31.03%. All twelve now read 0.00% with spread 0, and the site=manual_collect census line is gone from every one (it fired on all 12 before — liveness, not absence).

The consequence I most wanted: probe_overrides is now {}. 12_large_live_set.heap_used_bytes had a one-way gating: false spent on it precisely because of this residue. Reclaiming a one-way exemption is rare and worth naming — I argued this morning that spending gating is permanent, and this got it back.

It is a collector change, not only a reading — and you were right to insist on that framing

gc/tenuring.rs refuses to seed the adaptive threshold from a conservatively-scanned cycle, so on every gc()-driven workload the seed had never fired. On 09/11 it now does: tenuring_survivals 4→1, copied_objects 5,823→0 with promoted_objects 0→6,077. The copying minor still ran and moved more.

And catching that this broke the ratchet's own liveness rule — copied_objects > 0, now copied_objects + promoted_objects > 0 — is the best catch in the PR. Without it the re-pin would have pinned copied_objects = 0 and made the guard permanently false exactly where it last fired: a liveness assertion that can never fire again, installed by the change that made it necessary. That is the failure mode this repo has shipped four times, caught before landing for once.

Your five corrections

All accepted; three are mine. The "16% on a 50 MB live set" framing in my brief understated it by half and misattributed 05_closure_capture's figure (that 16.44% is drift between two commits; its residue is 28.24%). PERRY_CONSERVATIVE_STACK_SCAN=off is not a clean proxy because it also disables old_reclaim_alloc_point — the numbers coinciding was luck, and measuring on the real arm instead of inferring from the knob is the whole discipline.

And flagging your own vacuous measurement rather than quoting it is the one I value most: adding js_gc_collect to the checker's mover set reports 0 violations over 149 modules, but all 149 occurrences are declare, zero calls — the corpus has no gc() callsite, so that arm proved nothing. Stating it as unmeasured in the PR is exactly right.

Gates

22/22 lint, cargo fmt --check clean, perry-runtime --lib 1917 passed / 0 failed, test_gc_ratchet green, gc_ratchet.py validate --scope all structurally valid. The 23 gap-suite failures A/B'd individually as byte-identical on both arms is the check that makes a root-set change believable.

#7652 closes with this — the surgical re-pin's mixed provenance is gone; the artifact has single provenance again.

@proggeramlug
proggeramlug force-pushed the fix/7558-gc-precise-roots branch from 6c88ab3 to 173a20b Compare August 8, 2026 18:14
@proggeramlug
proggeramlug merged commit ec675f2 into main Aug 8, 2026
@proggeramlug
proggeramlug deleted the fix/7558-gc-precise-roots branch August 8, 2026 18:14
proggeramlug pushed a commit that referenced this pull request Aug 8, 2026
… pinned quiet host

Thirteen probes at a8f7312, `perry-macos`, driver-gated (clean tree, AC
power, CPU-active <= 25% for 60 s before each phase), node oracle v26.5.1.

CONTROL, taken with the same binary before the pin: `--check` against the
previous artifact (59d5220, #7657) reported every one of its 144 cells `ok`
and failed on exactly one line — "probes present now but absent from the
baseline: 13_large_eden_survivors". So nothing here is drift; the only new rows
are the new probe's, and `wt-scavtenure`'s re-pin is subsumed.

Every deterministic cell in the new artifact has spread 0, including all twelve
of the new probe's; peak RSS spread 0.169%, wall 4.69% against a 10% band (the
suite's widest, and its median reproduces to +0.5% on an independent session).

Also records the measurement-context trap this found: a probe compiled with a
`package.json` in scope retains one more 1 MiB arena block at `gc()`, which
makes an ad-hoc `measure --probes-dir <copy outside the repo>` report
09_try_catch_roots at -17.98% against the pin. Not a collector change; the
driver and CI always compile from the repo.

Claude-Session: https://claude.ai/code/session_01Y1QZ5wUP9gRSwpiweT4Wix
proggeramlug pushed a commit that referenced this pull request Aug 8, 2026
… pinned quiet host

Thirteen probes at a8f7312, `perry-macos`, driver-gated (clean tree, AC
power, CPU-active <= 25% for 60 s before each phase), node oracle v26.5.1.

CONTROL, taken with the same binary before the pin: `--check` against the
previous artifact (59d5220, #7657) reported every one of its 144 cells `ok`
and failed on exactly one line — "probes present now but absent from the
baseline: 13_large_eden_survivors". So nothing here is drift; the only new rows
are the new probe's, and `wt-scavtenure`'s re-pin is subsumed.

Every deterministic cell in the new artifact has spread 0, including all twelve
of the new probe's; peak RSS spread 0.169%, wall 4.69% against a 10% band (the
suite's widest, and its median reproduces to +0.5% on an independent session).

Also records the measurement-context trap this found: a probe compiled with a
`package.json` in scope retains one more 1 MiB arena block at `gc()`, which
makes an ad-hoc `measure --probes-dir <copy outside the repo>` report
09_try_catch_roots at -17.98% against the pin. Not a collector change; the
driver and CI always compile from the repo.

Claude-Session: https://claude.ai/code/session_01Y1QZ5wUP9gRSwpiweT4Wix
proggeramlug added a commit that referenced this pull request Aug 8, 2026
…d re-derive the #7056 RSS numbers under statepoints (#7666)

* test(gc-ratchet): pin the large-Eden copying-minor cadence (#7481) and re-derive the #7056 RSS numbers under statepoints

Two halves of the same job: they share the pinned quiet host and the same
harness.

#7481 named coverage this matrix did not have — "a live copying-minor
correctness signal at exactly the cadence the ratchet probes never exercise".
All twelve probes ran the shipped 16 MB nursery cap, so every copying minor
they had ever exercised was small and frequent. `13_large_eden_survivors`
closes that, via a per-probe `// gc-ratchet-env:` declaration that `check`
compares like a metric: delete the directive and every band is still
satisfied, so the arm itself has to be gated or it is not an arm.

The finding that shaped the probe: a large Eden on a *small* retained set runs
ZERO copying minors, because `arena_growth_full_escalation_due` escalates every
minor to a full mark-sweep once arena in-use clears 32 MB and exceeds twice the
post-full baseline. The first draft did exactly that and would have been pinned
on a collector it never reached.

#7056's RSS numbers were taken under the shadow stack, which stopped being the
default in #7370. Re-derived as a 2x2 (root lowering x nursery cap) over 12
probes, 7 repeats, 3 interleaved rotations: the root lowering is not an RSS
lever (peak RSS 1.002x, retention 1.000x, 104 of 108 deterministic cells
bit-identical), and the nursery cap still is (1.911x peak RSS at 128 MB).

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

* test(gc-ratchet): re-pin the baseline with the large-Eden arm, on the pinned quiet host

Thirteen probes at a8f7312, `perry-macos`, driver-gated (clean tree, AC
power, CPU-active <= 25% for 60 s before each phase), node oracle v26.5.1.

CONTROL, taken with the same binary before the pin: `--check` against the
previous artifact (59d5220, #7657) reported every one of its 144 cells `ok`
and failed on exactly one line — "probes present now but absent from the
baseline: 13_large_eden_survivors". So nothing here is drift; the only new rows
are the new probe's, and `wt-scavtenure`'s re-pin is subsumed.

Every deterministic cell in the new artifact has spread 0, including all twelve
of the new probe's; peak RSS spread 0.169%, wall 4.69% against a 10% band (the
suite's widest, and its median reproduces to +0.5% on an independent session).

Also records the measurement-context trap this found: a probe compiled with a
`package.json` in scope retains one more 1 MiB arena block at `gc()`, which
makes an ad-hoc `measure --probes-dir <copy outside the repo>` report
09_try_catch_roots at -17.98% against the pin. Not a collector change; the
driver and CI always compile from the repo.

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

* chore(gc-ratchet): keep the diag regexes together, and say how a declared arm layers over the shell

Cosmetic move of PROBE_ENV_RE/RESERVED_PROBE_ENV below the diag-parsing
regexes they were splitting, plus a docstring line stating the layering an
ad-hoc knob sweep depends on: os.environ applies to every probe that does not
declare the knob, and the declaring probe wins for itself.

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

* docs(gc-ratchet): state the per-minor comparison exactly rather than approximately

"~16 MB per minor for every default-cap probe" understated 12_large_live_set,
which runs 21.8 MB because its tenured-proportional cap term already raises its
Eden without any knob. The real spread is 14.6-16.6 MB on eleven of twelve and
21.8 on the twelfth, against 49.7 MB per minor for the new probe -- and the
12_large_live_set row is worth naming, because it is the shipped path to a
larger Eden and shows where that path tops out.

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

* docs(gc-ratchet): correct the probe's own comment to the shipped numbers

Its inline sizing note carried figures from a pre-final tuning run (3 minors,
36/36/68 MB) and said the survivor reads fold into the checksum when they fold
into five separately-diffed lines. A stale number in the comment beside the
constant it justifies is exactly the shape this campaign keeps paying for.

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

* chore: bump version to 0.5.1379

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

---------

Co-authored-by: Ralph Küpper <ralph@skelpo.com>
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.

gc(): the forced conservative stack scan makes every retained-heap reading nondeterministic, and 16% too large on a 50 MB live set

1 participant