Track A2 of the repsel/GC plan calls for deleting the conservative native-stack
scanner, on the premise that it is "not merely unused in production" — that
production resolves Auto -> SkipDisabled and only two force_full_scan()
sites remain, both on the explicit gc() path.
That premise does not hold. The test-build half is real and is fixed in
#7147. The production half is a different change than the plan describes, and
this issue records the inventory and the soundness argument so the decision is
made on the facts.
There are six force_full_scan() sites, not two
| # |
site |
path |
automatic? |
| 1 |
gc/policy.rs:1300 |
OldReclaim direct full mark-sweep at an allocation point |
yes |
| 2 |
gc/policy.rs:1404 |
nursery-churn direct minor at an allocation point |
yes (fallback, see below) |
| 3 |
gc/mod.rs:336 |
gc_try_emergency_reclaim() |
yes (OOM path) |
| 4 |
gc/pressure.rs:54 |
perry/gc pressure API (host callback) |
host-driven |
| 5 |
gc/policy.rs:2164 |
manual_gc_collect_now() — explicit gc() |
explicit |
| 6 |
gc/policy.rs:2199 |
js_gc_module_minor() — perry/gc minor() |
explicit |
Only 5 and 6 are the gc() paths the plan names. Four of the six are reached
without any user calling gc().
They are not test scaffolding — the code says why
Each site documents a non-safepoint rationale:
policy.rs:1404 — "at an arbitrary alloc point a value mid-construction may
live only in registers, which the conservative scan retains."
policy.rs:1300 — "anything still referenced from the stack/registers at
this allocation point (e.g. the temporary currently being built) is
retained."
mod.rs:336 — "this runs at an arbitrary allocation site where locals of the
current call chain may not be spilled to shadow slots."
pressure.rs:54 — "a host may deliver the callback with unspilled locals on
the native frames above us."
And policy.rs draws the distinction explicitly, in its own words:
Unlike gc_scavenge_enabled() (which skips the conservative scan HERE —
sound only if the alloc point is precise), the loop-polls path never
reaches the skip: it always defers to a real safepoint, so it is sound by
construction.
So skipping the scan at these points is already recognised in-tree as
conditional on a precision property the alloc point does not have in general.
PERRY_GC_SCAVENGE is the knob that does exactly this at site 2, and it is
labelled "Phase-1 de-risking, OFF by default" — the project has deliberately
not flipped it. Removing site 2's force_full_scan() unconditionally is that
flip, for every path at once, including three that have no safepoint
alternative.
What does soften the risk
gc_moving_loop_polls_enabled() is default ON
(policy.rs:369 — unset resolves true), so the shipped default defers the
alloc-point collection to js_gc_loop_safepoint -> gc_safepoint_moving_minor,
a precise-root safepoint. Site 2's force_full_scan() is therefore the
safety-valve fallback taken when the deferral slack is exhausted — a
mega-expression that reached no poll — not the common path. That is a narrower
exposure than it first appears, but it is also the sharpest case: the fallback
fires precisely when no precise safepoint was reachable.
(Note: fable-audit-perry-gc.md lists PERRY_GC_MOVING_LOOP_POLLS as "default
off". The code says otherwise. Stale doc, worth correcting separately.)
Evidence that already exists
The matrix's cons_scan_off / cons_scan_off_force arms set
PERRY_CONSERVATIVE_STACK_SCAN=off, and the env value beats the per-thread
override — so those arms already disable all six sites and are green. Any
re-run of the matrix against a code-removal branch reproduces that result by
construction; it is not independent confirmation. The real question is whether
a 22-program corpus is sufficient basis to make it the default for everyone,
against six documented soundness comments.
A separate, unambiguous kill-policy violation
PERRY_CONSERVATIVE_STACK_SCAN=full fails 134 of 1574 perry-runtime tests on
main (verified against pristine main roots.rs at a3b31c0d8; measured on
darwin-arm64, release, --test-threads=1 — 1440 pass / 134 fail). The env value
beats the per-thread override, so the isolation guards cannot opt back out and
tests that require collection of native-stack-held objects break.
This is a shipped escape hatch in a state nobody has verified. Worth resolving
independently of the Track A2 decision.
(Correction to an earlier revision of this issue: the gc_ratchet baseline is
not measured under =full. benchmarks/gc_ratchet/README.md uses =full as
the deliberate sensitivity arm that proves the gate can fail — the control
arm reproduced the baseline exactly and the =full arm produced 60 regression
rows.)
The scan is also expensive — this is the RSS argument, quantified
That same validation table is the strongest quantitative case for removing the
scan, and it is already in-tree
(benchmarks/gc_ratchet/README.md). Turning the conservative scan on costs,
per probe, heap_used_bytes +364% to +5371%, and takes minor_cycles to
zero on all eight probes — it does not merely slow the copying minor down,
it stops it running at all:
| Probe |
baseline |
with conservative scan |
Δ |
minor_cycles |
01_nursery_churn |
807,000 |
3,742,800 |
+364% |
14 → 0 |
02_survivor_promotion |
5,022,864 |
25,242,760 |
+403% |
10 → 0 |
03_cross_gen_writes |
705,320 |
6,494,536 |
+821% |
22 → 0 |
04_dead_after_deep_stack |
1,000,728 |
9,187,088 |
+818% |
104 → 0 |
05_closure_capture |
1,040,208 |
7,336,208 |
+605% |
26 → 0 |
06_string_retention |
746,056 |
4,746,240 |
+536% |
64 → 0 |
07_array_grow_evacuate |
1,816,232 |
99,362,360 |
+5371% |
80 → 0 |
08_map_set_sidetables |
457,872 |
6,754,560 |
+1375% |
84 → 0 |
So on the four automatic sites, every collection that takes the
force_full_scan() path is a collection that runs no copying minor. That
raises the stakes on both sides: the upside of removal is large and measured,
and it is also why the fallback must not simply be made imprecise — the right
end state is probably that the fallback stops being reachable (precise-safepoint
deferral always available) rather than that it collects without the scan.
Recommendation
Split the decision:
- Retire the
full value of PERRY_CONSERVATIVE_STACK_SCAN (134 failing
tests, no CI arm — a clean kill-policy case), and re-check what the ratchet
baseline is actually measured under.
- Treat "remove the six
force_full_scan() sites" as its own change, with
the evidence it deserves: either land the precise-safepoint deferral as the
only path (so the fallback stops existing rather than becoming imprecise), or
flip PERRY_GC_SCAVENGE on and soak it first. It is not a mode-deletion
cleanup, and CLAUDE.md's kill-policy does not by itself settle it — the
losing mode here is load-bearing at four automatic sites.
Filed from the Track A2 investigation; #7147 delivers the part that is
unambiguously correct (the test build now runs production's scan mode).
Track A2 of the repsel/GC plan calls for deleting the conservative native-stack
scanner, on the premise that it is "not merely unused in production" — that
production resolves
Auto -> SkipDisabledand only twoforce_full_scan()sites remain, both on the explicit
gc()path.That premise does not hold. The test-build half is real and is fixed in
#7147. The production half is a different change than the plan describes, and
this issue records the inventory and the soundness argument so the decision is
made on the facts.
There are six
force_full_scan()sites, not twogc/policy.rs:1300gc/policy.rs:1404gc/mod.rs:336gc_try_emergency_reclaim()gc/pressure.rs:54perry/gcpressure API (host callback)gc/policy.rs:2164manual_gc_collect_now()— explicitgc()gc/policy.rs:2199js_gc_module_minor()—perry/gcminor()Only 5 and 6 are the
gc()paths the plan names. Four of the six are reachedwithout any user calling
gc().They are not test scaffolding — the code says why
Each site documents a non-safepoint rationale:
policy.rs:1404— "at an arbitrary alloc point a value mid-construction maylive only in registers, which the conservative scan retains."
policy.rs:1300— "anything still referenced from the stack/registers atthis allocation point (e.g. the temporary currently being built) is
retained."
mod.rs:336— "this runs at an arbitrary allocation site where locals of thecurrent call chain may not be spilled to shadow slots."
pressure.rs:54— "a host may deliver the callback with unspilled locals onthe native frames above us."
And
policy.rsdraws the distinction explicitly, in its own words:So skipping the scan at these points is already recognised in-tree as
conditional on a precision property the alloc point does not have in general.
PERRY_GC_SCAVENGEis the knob that does exactly this at site 2, and it islabelled "Phase-1 de-risking, OFF by default" — the project has deliberately
not flipped it. Removing site 2's
force_full_scan()unconditionally is thatflip, for every path at once, including three that have no safepoint
alternative.
What does soften the risk
gc_moving_loop_polls_enabled()is default ON(
policy.rs:369— unset resolves true), so the shipped default defers thealloc-point collection to
js_gc_loop_safepoint -> gc_safepoint_moving_minor,a precise-root safepoint. Site 2's
force_full_scan()is therefore thesafety-valve fallback taken when the deferral slack is exhausted — a
mega-expression that reached no poll — not the common path. That is a narrower
exposure than it first appears, but it is also the sharpest case: the fallback
fires precisely when no precise safepoint was reachable.
(Note:
fable-audit-perry-gc.mdlistsPERRY_GC_MOVING_LOOP_POLLSas "defaultoff". The code says otherwise. Stale doc, worth correcting separately.)
Evidence that already exists
The matrix's
cons_scan_off/cons_scan_off_forcearms setPERRY_CONSERVATIVE_STACK_SCAN=off, and the env value beats the per-threadoverride — so those arms already disable all six sites and are green. Any
re-run of the matrix against a code-removal branch reproduces that result by
construction; it is not independent confirmation. The real question is whether
a 22-program corpus is sufficient basis to make it the default for everyone,
against six documented soundness comments.
A separate, unambiguous kill-policy violation
PERRY_CONSERVATIVE_STACK_SCAN=fullfails 134 of 1574 perry-runtime tests onmain(verified against pristinemainroots.rsata3b31c0d8; measured ondarwin-arm64, release,
--test-threads=1— 1440 pass / 134 fail). The env valuebeats the per-thread override, so the isolation guards cannot opt back out and
tests that require collection of native-stack-held objects break.
This is a shipped escape hatch in a state nobody has verified. Worth resolving
independently of the Track A2 decision.
(Correction to an earlier revision of this issue: the
gc_ratchetbaseline isnot measured under
=full.benchmarks/gc_ratchet/README.mduses=fullasthe deliberate sensitivity arm that proves the gate can fail — the control
arm reproduced the baseline exactly and the
=fullarm produced 60 regressionrows.)
The scan is also expensive — this is the RSS argument, quantified
That same validation table is the strongest quantitative case for removing the
scan, and it is already in-tree
(
benchmarks/gc_ratchet/README.md). Turning the conservative scan on costs,per probe,
heap_used_bytes+364% to +5371%, and takesminor_cyclestozero on all eight probes — it does not merely slow the copying minor down,
it stops it running at all:
minor_cycles01_nursery_churn02_survivor_promotion03_cross_gen_writes04_dead_after_deep_stack05_closure_capture06_string_retention07_array_grow_evacuate08_map_set_sidetablesSo on the four automatic sites, every collection that takes the
force_full_scan()path is a collection that runs no copying minor. Thatraises the stakes on both sides: the upside of removal is large and measured,
and it is also why the fallback must not simply be made imprecise — the right
end state is probably that the fallback stops being reachable (precise-safepoint
deferral always available) rather than that it collects without the scan.
Recommendation
Split the decision:
fullvalue ofPERRY_CONSERVATIVE_STACK_SCAN(134 failingtests, no CI arm — a clean kill-policy case), and re-check what the ratchet
baseline is actually measured under.
force_full_scan()sites" as its own change, withthe evidence it deserves: either land the precise-safepoint deferral as the
only path (so the fallback stops existing rather than becoming imprecise), or
flip
PERRY_GC_SCAVENGEon and soak it first. It is not a mode-deletioncleanup, and CLAUDE.md's kill-policy does not by itself settle it — the
losing mode here is load-bearing at four automatic sites.
Filed from the Track A2 investigation; #7147 delivers the part that is
unambiguously correct (the test build now runs production's scan mode).