Skip to content

gc: Track A2's premise is wrong — six force_full_scan() sites, four automatic, and PERRY_CONSERVATIVE_STACK_SCAN=full fails 134 runtime tests #7148

Description

@proggeramlug

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:

  1. 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.
  2. 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).

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions