Repository navigation
GC roots: statepoints only — make statepoint lowering linear and remove the shadow-frame size path (#11960) #12023
Description
Decision (owner)
GC roots are lowered with LLVM statepoints, and only statepoints. Shadow-frame homes are the legacy gcroot/shadow-stack design. #11960 (#11926) brought them back as a second rooting regime for root-heavy functions, chosen by a size heuristic. That is a second path and it is being removed now, not deferred. The quadratic code growth it worked around gets fixed inside statepoint lowering.
Problem
Perry promotes managed locals to SSA before RS4GC, so every live GC value is relocated at every statepoint. Statepoint lowering spills all of them before each call and reloads fresh relocated values after it, and the spill slots are evidently not reused across statepoints. Results:
- the
big()witness grows quadratically: 38.7 KB (25 locals), 121 KB (50), 500 KB (100), 1.83 MB (200); - Claude Code's
GW7was 26.6 MB of code with a ~2.3 MB stack frame, 62% of it stack-slot copy runs; - across the cc binary, copy runs were ~15% of instructions.
#11960 routes functions with ≥25 safepoints, >2× more root slots than safepoints and an estimate >2,048 relocations to shadow frames (root_home_size_candidate, DEFAULT_ROOT_HOME_RELOCATIONS in crates/perry-codegen/src/codegen/helpers.rs). The older #8620 32M-relocation ceiling does the same as a compile-time backstop.
Goal
- Linear statepoint lowering. Code size and compile time grow linearly with live values + calls. A GC value that sits in a stack slot across consecutive statepoints stays there, and the stack map names the slot; it is not re-spilled and reloaded around every call. Candidate mechanisms, to be prototyped and measured rather than assumed:
- LLVM statepoint spill-slot reuse across blocks;
- reporting long-lived locals as stack-slot locations (allocas in the gc-live bundle, if StatepointLowering supports them on our LLVM 22);
max-registers-for-gc-values;- fewer GC values: proven numbers are not GC pointers, and recompute instead of relocate where possible.
- Delete the shadow-frame size path (
root_home_size_candidateand its constants) in the same change. Re-evaluate the codegen: #8589 root-spill default threshold (4M) too low — spills moderate-fan-out functions and slows their compile #8620 ceiling, and delete it too if compile time is linear. - Witness sizes at or below perf(codegen): use stable homes for root-heavy functions (#11926) #11960's shadow-frame numbers (q200 ≤ ~249 KB), with no runtime regression.
Acceptance
- q25/q50/q100/q200 (
secret-tests/cc-size-tickets/) and a GW7-sized function: code size ≤ main (with perf(codegen): use stable homes for root-heavy functions (#11926) #11960) and linear. - Compile time is not worse; the post-RS4GC instruction budget (fix(codegen): delete the unsound pre-RS4GC optnone knob; add a post-rewrite instruction budget and per-unit codegen stats (#8583) #8586/codegen: reconcile the root-spill threshold (#8623, 32M) with the RS4GC instruction budget (#8586, 1.57M) — budget-gap functions get refused instead of spilled #8679) stays a backstop or becomes unnecessary.
- tsc, Zod, qs, commander, hello: instructions, CPU, RSS ≤ main; binary sizes ≤ main.
- GC correctness under every knob (forced evacuation, VERIFY_EVACUATION/VERIFY_MARK, moving safepoint, budgeted old reclaim). gc-root-dominance and the rooting gates stay green.
- No shadow-frame code path remains for size reasons.
Activity
proggeramlug commentedon Oct 5, 2026
Scope (owner): shadow frames stay ONLY where a target platform requires them, i.e. targets where LLVM statepoints/stack maps are unavailable or unsupported. Everywhere else they come out completely: the #11960 size heuristic, the #8620 32M-relocation ceiling, and any other non-platform fallback (env-selected, optnone, per-function compile-time escapes). The lane inventories every shadow-frame use, classifies each as platform-required or not with a reason, and removes all of the 'not' ones in this change.