Repository navigation
Stack maps vs the codegen shadow stack: viability experiment (Q1 passes; 0.44 MB text saved for 4.5-16.6 MB metadata) #7108
Description
Scoped viability experiment, not a migration. No shadow-stack code was changed. Throwaway prototype on exp/stackmap-viability (worktree wt-stackmap), which is not intended to merge.
Verdict: Q1 passes, but on the numbers I would not migrate for size or speed. The correctness argument is the only one that survives contact with the measurements — and it is a strong one.
The headline trade, measured on a real dependency-heavy build:
Hot __text the shadow stack costs today |
438,848 B (13.3% of generated code) |
Cold .llvm_stackmaps a statepoint build would add |
4.5 – 16.6 MB |
That is 10–38× more cold metadata than hot text saved, and the metadata alone exceeds the app's entire generated code section (3.7 MB). Reported separately and deliberately not netted.
Perry's LLVM version
Perry has no LLVM crate dependency. perry-codegen writes textual .ll and shells out to clang -c (crates/perry-codegen/src/linker.rs:1). So "Perry's LLVM version" is whichever clang is resolved by find_clang(), floored at MINIMUM_CLANG_MAJOR = 15 (linker.rs:660).
- This host: Apple clang 21.0.0 (
clang-2100.1.1.101), targetarm64-apple-darwin25.5.0. - Also present: Homebrew LLVM 22.1.4.
- Stackmap format emitted: v3. Darwin section:
__LLVM_STACKMAPS,__llvm_stackmaps.
This matters for the whole assessment and is revisited under "migration cost" — the text-IR-plus-stock-clang architecture is what rules the cheapest design out.
Q1 — Can statepoint/stackmap machinery describe a NaN-boxed value?
Yes, by exactly one route out of four. All four were compiled, not read about.
| Option | Result |
|---|---|
gc.relocate on a double |
Rejected at verify: gc.relocate must return a pointer or a vector of pointers |
double in the gc-live bundle, no relocate |
Compiles and silently records nothing — 3 header locations, the double dropped |
double in the deopt bundle |
Recorded at an Indirect slot, but it is a dead snapshot — unsound |
llvm.experimental.stackmap with doubles |
Records the wrong program point, in registers — unsound |
Whole NaN box as ptr addrspace(1) |
Works. Recorded Indirect, relocated, zero instruction cost |
The two traps worth naming
gc-live with a double compiles clean and records nothing. No warning, no verifier error. A prototype built this way would appear to work and would lose every root. Compare the location counts — 3 (header only) versus 5 for the pointer control.
The deopt bundle looks right and is not. It does record the double at an Indirect stack slot, which is why it is tempting. But the mutator does not read that slot:
str d0, [sp] ; LLVM's own spill for the live value
str d0, [sp, #0x8] ; the deopt record slot (what the stackmap points at)
bl callee
ldr d0, [sp] ; reloads from [sp] — NOT the recorded slotCollector-side rewrite of the recorded slot is a no-op. Deopt values are a read-only snapshot for a deoptimiser reconstructing an interpreter frame, not for resuming compiled code.
llvm.experimental.stackmap is not the promising lead it looks like. It records at its own location, not at the call's return address, so a stackmap placed after a call describes post-reload registers (Register R#64, R#65) — the wrong program point, and register locations a collector cannot rewrite. The call is the safepoint; only statepoint (or patchpoint) describes the frame during it. So "stackmap + collector-side rewrite" is not a viable simpler design, for a reason independent of NaN boxing.
The design that works
Carry the entire NaN box through the safepoint as ptr addrspace(1) — bitcast double → i64 → inttoptr — with no untagging. The collector inspects the tag and re-tags when it rewrites, which is what it already does for shadow slots today.
%ab = bitcast double %a to i64
%ap = inttoptr i64 %ab to ptr addrspace(1)
%tok = call token (...) @llvm.experimental.gc.statepoint.p0(...) [ "gc-live"(ptr addrspace(1) %ap) ]
%ar = call ptr addrspace(1) @llvm.experimental.gc.relocate.p1(token %tok, i32 0, i32 0)
%ai = ptrtoint ptr addrspace(1) %ar to i64
%ad = bitcast i64 %ai to doubleThree NaN boxes live across a call at -O3 lower to two stores and two loads (stp/ldp pairs) — LLVM sees through the whole bitcast/inttoptr/ptrtoint/bitcast chain and keeps the values in FP registers. The address-space round trip costs zero instructions; there is no fmov GP↔FP shuffling.
Proven end-to-end at runtime, not just compiled. A collector that parses the real __llvm_stackmaps section, applies Perry's own tag discipline, and rewrites the recorded slots:
BEFORE: a=0x7ffd000001234000 (ptr box) b=0x400921f9f01b866e (double 3.14159)
COLLECTOR: loc#4 Indirect[R#31+8] bits=0x400921f9f01b866e (not heap-tagged, left alone)
COLLECTOR: loc#6 Indirect[R#31+0] bits=0x7ffd000001234000 -> REWROTE to 0x7ffd000001235000
AFTER : out[0]=0x7ffd000001235000 out[1]=0x400921f9f01b866e (=3.141590)
RESULT: relocated pointer observed by mutator: YES
RESULT: plain double survived untouched: YES
The pointer-tagged box was relocated and the mutator observed the new value after the safepoint; the plain double 3.14159 — bit pattern indistinguishable from a pointer to anything but a tag check — was correctly left alone. This is precisely the semantics a moving collector needs, and it composes with Perry's existing tag-inspection logic rather than replacing it.
Supporting facts, all compiled
- Inlining survives. An
internalfunction containing a statepoint inlines fully at-O3with itsgc-livebundle intact. Statepoints do not block Perry's reliance on LLVM inlining. - The linker concatenates per-object stackmaps. Three objects → three sub-stackmaps at offsets 0/200/400, no dedup. A 133-module app yields 133 sub-maps; the runtime parser must iterate them (mine does).
- LLVM prunes dead roots automatically. A
gc-livevalue whose relocate is unused is dropped from the record entirely. Liveness precision comes free — and it is also why a naive size model overestimates. - No built-in GC strategy avoids the base/derived doubling.
statepoint-example,coreclrandshadow-stackall emit3 + 2Nlocations;erlang/ocamlhave noGCMetadataPrinteron this target. The 2× on the dominant metadata term is unavoidable with stock clang.
Q2 — What the metadata weighs
Application: test-drizzle-pg — drizzle-orm + @perryts/postgres compiled natively via compilePackages. 133 modules, 4,735 emitted functions, 1,535 (32.4%) with GC roots, 4,808 reserved root slots, 62,731 candidate safepoints. Chosen because benchmarks/app-patterns is 435 lines total across 12 kernels — far too small, exactly the smallness that hid the size axis before.
Host: Apple M1 Max, 10 core, macOS 26.5. All Q2/Q3 figures are static section sizes and are independent of machine load (which ranged 35–137 during the session, from two other agents building).
The size model is exactly validated against a compiled 20-function × 7-safepoint × 3-root module: modelled 19,536 B, measured 19,536 B, 0.00% error.
bytes = 16·objects + 24·functions + 64·safepoints + 24·(safepoint,root) pairs
The 24 B per root per safepoint is the dominant term — 2 locations × 12 B, because every root is recorded as a base/derived pair.
| Assumption | Safepoints | (safepoint,root) pairs | .llvm_stackmaps |
|---|---|---|---|
| Worst case — every reserved root live at every safepoint | 62,731 | 523,095 | 16.6 MB |
| Refined — excluding provably non-allocating registration/barrier helpers | 40,607 | 341,242 | 10.8 MB |
| Lower bound — LLVM liveness prunes to the median 2 roots live | 40,129 | 77,168 | 4.5 MB |
Per the brief: this is the worst case and I am saying so explicitly. Representation selection currently promotes zero locals on real application code (#7034), so essentially every recorded slot is "might be a pointer". If repsel lands, the root count — and therefore the dominant term — falls proportionally. The realistic band today is 4.5–11 MB, and the true figure sits toward the lower end because LLVM's own liveness prunes dead roots (proven above).
For scale: the app's entire generated __text is 3.7 MB. The root metadata is 1.2× to 4.5× the size of all the code it describes.
Q3 — What __text shrinks by
Same application, same compiler binary, differing only by PERRY_SHADOW_STACK — which already exists (crates/perry-codegen/src/codegen/helpers.rs:66). Measured at e2557c1a9 (current main, includes #7088).
Generated code only, by compiling the 133 traced .ll modules directly, so the ~9.6 MB Rust runtime is excluded rather than diluting the ratio:
| Arm | Generated-code __text |
|---|---|
| Shadow stack ON (current default) | 3,726,252 B |
| Shadow stack OFF | 3,287,404 B |
| Delta | 438,848 B — 13.3% |
Whole-binary __text delta for the same pair: 387,376 B (21,806,836 → 21,419,460). The two differ because the linker dead-strips and because I compiled every traced module at -O3 whereas Perry varies opt-level per module size; both land in the same range.
A statepoint build's text would sit at approximately the OFF arm: across 1–16 roots live across a call, the statepoint form costs ≈0 extra instructions versus not rooting at all (at 8 and 16 roots it is actually fewer, because forced stack slots beat callee-saved register preservation). Values live across a call must be spilled regardless; statepoints make the spill the only cost.
Not measured: I did not build a statepoint backend, so the statepoint arm's app __text is inferred from the OFF-arm floor plus the microbenchmark, not measured directly. Stated plainly rather than dressed up.
An unrelated finding that the brief predicted
#7088's inline slot store costs +189,892 B of __text on this app (21,806,836 with PERRY_INLINE_SHADOW_SLOT default vs 21,616,944 with it off). That is 43% of the shadow stack's entire text footprint, spent to make it faster — and it merged with no size measurement of any kind, because binary-size never runs on PRs. The brief called this axis unobserved; it was, and here is the number. Worth a separate look independent of anything in this experiment.
The correctness argument — the strongest one here
Supported, and it is the reason to keep this alive.
The bug class is not hypothetical, and the codebase already documents it against itself. crates/perry-codegen/src/expr/shadow_slot.rs:14, on %this_closure (#7055):
The parameter is a register value the collector cannot see: an evacuating young collection at a loop back-edge poll inside the body relocates the closure, rewrites every root it knows about, and then resets from-space — after which the register points at recycled memory […] and every subsequent boxed-capture read/write silently no-ops.
Every one of #7055 and #7066 is a human forgetting to bind a slot. With statepoints, LLVM enumerates its own live SSA values: a value live across a call is in the gc-live set because it is live, not because someone remembered. %this_closure is an ordinary LLVM parameter and would be covered automatically. Probe G confirms the enumeration is precise in both directions — dead roots are dropped, so the mechanism does not over-root either.
Two honest qualifications:
- Hand-emitted statepoints reintroduce a different human-error class. After a safepoint, every downstream use must reference the relocated value. Miss one and you have a stale pointer — the same shape of bug, relocated to a new place. My own first inlining probe made exactly this mistake within minutes of writing it.
- Only
RewriteStatepointsForGCremoves that class too. RS4GC takes naive IR withaddrspace(1)values and inserts the statepoint, thegc.result, thegc.relocate, and rewrites all downstream uses. Perry would only need to mark GC-capable values and tag functionsgc "statepoint-example".
So the full correctness win requires RS4GC. Which brings the problem:
What a real migration would cost
Toolchain is the binding constraint, not the codegen.
clangexposes no flag that runs RS4GC —-mllvm -passes=…is rejected outright.- Xcode ships no
opt. Only Homebrew LLVM has it. - The
opt -passes=rewrite-statepoints-for-gc | clang -cpipeline does work end-to-end (verified, correct stackmap withIndirectlocations).
Perry today requires only a clang ≥ 15 on PATH. The cheap, correctness-maximal design requires shipping or requiring opt — a new hard dependency on a ~100 MB binary that must version-match. That is a distribution decision, not an engineering one, and it is the single biggest cost.
Roughly, and only enough for a go/no-go:
- Ship/require
opt, mark valuesaddrspace(1), let RS4GC do the rest — smallest codegen change, full correctness win, new toolchain dependency. Weeks. - Hand-emit statepoints in
perry-codegen— no new dependency. Call emission is already centralised (LlBlock::call/call_void,block.rs:830/:844) and liveness already tracked (FnCtx::shadow_slots_bound), so the emission side is tractable. But Perry must do the SSA relocation rewiring itself, which is where the correctness win partly evaporates. Months, and it re-opens the bug class it was meant to close. - Link LLVM as a library — contradicts the text-IR architecture wholesale. Not costed.
Beyond the toolchain, one hard requirement applies to every option: the collector needs a stack walker. Today js_shadow_frame_enter/pop maintain an explicit list the collector reads directly. With statepoints the collector must unwind from wherever GC triggered (deep in a Rust helper) back through native frames to compiled frames, and map each return address to a stackmap record. That needs guaranteed frame pointers through the Rust runtime (-Cforce-frame-pointers=yes; arm64 macOS already has them, x86-64 Linux does not by default), a sorted return-address index built at startup across all sub-stackmaps, and correct SP reconstruction per frame. This is new, load-bearing, platform-sensitive machinery with no current equivalent, and conservative scanning is not available as a fallback (SkipDisabled in production, being removed).
Not measured — stated rather than estimated:
- No statepoint backend was built, so no app-level statepoint
__text, and no runtime performance number of any kind. The +26.7% instruction figure motivating this line was not re-measured or contested here. - Whether Perry's
setjmp-based EH (setjmp_abi.rs,volatile_setjmp.rs) composes with statepoints — untested, and a plausible source of trouble given values live acrosssetjmphave memory constraints. - Stack-walker cost, both the startup index build and per-collection walk.
- Non-Darwin behaviour. Everything here is arm64 macOS; the
__LLVM_STACKMAPSsection spelling and frame-pointer availability both differ elsewhere.
Where I think this lands
Q1 passes cleanly and the design is real — but on the numbers, stack maps are not a size win and not a speed win. They trade 0.44 MB of hot text for 4.5–16.6 MB of cold metadata, and the "avoids the size trade-off" premise that made them attractive does not survive measurement: the metadata is larger than all the code it describes. It is a different size problem, not the absence of one.
What does survive is correctness. Making an entire bug class structurally impossible is worth real cost, and #7055/#7066 are evidence that cost is already being paid in bugs. But the version that actually delivers it (RS4GC) requires a toolchain dependency Perry has deliberately avoided, and the version that avoids the dependency re-opens the bug class it was meant to close.
Suggested disposition: do not migrate now. Revisit if either premise changes — if repsel (#7034) starts promoting locals, the dominant metadata term shrinks proportionally; or if Perry ever links LLVM directly, at which point RS4GC is free and the calculus is entirely different. The PERRY_SHADOW_STACK=0 A/B and the size model are cheap to re-run against either.
Activity
proggeramlug commentedon Jul 31, 2026
Follow-up: the working prototype now exists on this branch — two sessions took it well past the probe stage. Recording the results here so the issue reflects reality.
Commits (both on
exp/stackmap-viability):a472af082implements both backends end-to-end — plain stack maps (PERRY_STACK_MAPS=1) and an explicitgc.statepoint/gc.relocatebridge (PERRY_STATEPOINTS=1), both opt-in, shadow stack untouched as default.cbbbdcee6adds an auditedGcCallEffecttable (helpers that provably cannot enter the collector stop being safepoints; unknown stays conservative),--statepoint-report[=json], and native-root/unwinder telemetry.Correctness — the strongest result, consistent with this issue's original framing: 8/8 GC-ratchet probes pass in all three modes, including
PERRY_GC_FORCE_EVACUATE=1+PERRY_GC_VERIFY_EVACUATION=1, 24/24 mode/probe comparisons byte-identical to Node. The whole-NaN-box-as-ptr addrspace(1)design from this issue's Q1 is what the bridge uses, proven under real evacuation.The safepoint audit halved the metadata term this issue flagged as the killer:
batch.ts442→219 statepoints, 867→403 relocations,__llvm_stackmaps54,968→26,432 B (−51.9%); eight-probe suite 1,080→502 statepoints. The report found 223 calls with live roots that cannot collect (typed-feedback bookkeeping, class-field guards, temp-root push/get/truncate, layout notes, write barriers). This partially validates the "revisit if the root count falls" disposition — the audit alone is worth ~2× before repsel contributes anything.Runtime (heavily loaded host, load 22–29 — directional only): statepoints −0.38% vs plain maps geometric mean (tied within noise); uncached compile +5.3% vs shadow; statepoint metadata still 2.01× plain maps after both halve. Deep-stack remains the decisive negative: ~20% slower than plain maps, and telemetry explains it — 36,458 frames unwound to visit 104 root locations (350:1). A fast walker is justified but blocked on an explicit frame-pointer/unwind ABI: generated AArch64 code saves
x29without establishing a consistent frame chain.Disposition unchanged: keep the shadow stack as default. Next steps in flight on the branch: FP-chain ABI + exact frame walker, then an explicit-safepoint collection contract experiment (unblocked now that #7114 is fixed). Full write-ups:
docs/stack-map-gc-experiment.mdanddocs/statepoint-gc-experiment.mdon the branch.research(gc): compact per-function root metadata (PERRY_COMPACT_ROOTS)
proggeramlug commentedon Aug 1, 2026
Closing measurement for this issue's headline trade, taken directly on this issue's own testbed (
test-drizzle-pg, 133 modules) with every reduction fromexp/stackmap-viabilitylive (branch tipc6990b7bb):__text__llvm_stackmapsTwo corrections to this issue's model, one in each direction:
batch.tsstatepoint count went 442 → 181 over the campaign, −59%, zero repsel dependency.)PERRY_SHADOW_STACK=0(rooting fully off) as the floor; real statepoint codegen retains spill/reload work.Net: the best sound native arm costs +3.53 MB (+12.4%) on a real app — a ~25× imbalance. The disposition here ("do not migrate now; revisit when repsel promotes or if LLVM is linked directly") stands, now on measured rather than modeled numbers.
Also settled since the last comment, all on the branch and in
docs/statepoint-gc-experiment.md:Register R#Nlocations no collector can recover).The correctness argument remains the reason to keep this alive, and the two levers that flip the size axis remain repsel promotion (each proven non-pointer deletes its records) and the
RewriteStatepointsForGCtoolchain decision.research(gc): compact per-function root metadata (PERRY_COMPACT_ROOTS)
proggeramlug commentedon Aug 3, 2026
Now part of one consolidated GC plan — see
docs/src/internals/rfc-rooting-by-construction.md("Where this sits in the one GC correctness plan", landed in #7292). Read that before starting work here, so five documents stop describing parts of the same shape.What changed for this issue: it is layer 2 of three, and it has a dependency. #7108 concluded that "the text-IR-plus-stock-clang architecture is what rules the cheapest design out" — Perry emits textual
.llto a user-suppliedclangand controls neither the pass pipeline nor stackmap emission. #7241 (in-process LLVM) removes exactly that constraint, and independently verified thatgc "statepoint-example"constructs, verifies and emits.⇒ Sequencing is 0 (#7241) → 2 (this). Starting statepoint migration before the in-process backend means fighting the architecture #7108 already measured as the blocker.
The first thing this layer must prove, before any migration commitment: that safepoint density is a usable lever on metadata size. The measured trade is 438,848 B of hot text saved against 4.5–16.6 MB of cold
.llvm_stackmaps. The model is24 B × (safepoint, root) pairsover 62,731 candidate safepoints, so filtering to genuinely-allocating calls should shrink it proportionally — but that is expected, not measured, and minimal binary size is a stated project goal.exp: pin layer-2 readiness — RS4GC schedules in-process
exp: pin layer-2 readiness — RS4GC schedules in-process
In-process LLVM backend: native construction via the C API, opt-in (#…
proggeramlug commentedon Aug 3, 2026
#7305 landed — the unsound
has_tryfallback can now be deleted.exp/stackmap-viabilitycurrently excludeshas_tryfunctions and routes them to a plain-stack-map fallback that is known-unsound, because alongjmpcan jump past agc.statepoint'sgc.relocatewrite-back.That is fixed at the source. #7305 replaced setjmp/longjmp with LLVM
invoke/landingpad(SEH funclets on windows-msvc) and deleted the setjmp path wholesale —volatile_setjmp.rs(376 lines),setjmp_abi.rs(193), thereturns_twice/noinlineattribute groups, the try-region volatile store tracking, and thePERRY_EHdevelopment flag. There is one exception lowering now, not two.With explicit unwind edges, relocations exist on both paths and no jump can skip one. So the exclusion and the fallback are both obsolete — and removing them closes the last soundness hole in the statepoint design rather than merely narrowing it.
Two other things that changed underneath this work:
rs4gc_schedules_in_processprovingrewrite-statepoints-for-gcruns as a module pass at pinned LLVM 22, emits the statepoint, and relocates the liveaddrspace(1)pointer. The pass pipeline is ours now — which is what makes stack-map density a lever rather than a fixed cost.returns_twice+noinline; they now inline and optimize like anything else, so any pre-Exception lowering: setjmp/longjmp → LLVM invoke/landingpad (#7302) #7305 measurement of statepoint overhead on try-heavy code is stale.Sequencing and the layer model are in
docs/engine-plan.md; tracker is #7294.Native-frame GC roots via LLVM statepoints, opt-in (#7173, #7174) (#7314
gc: a stack-map record must belong to the function the ip is in (#7314 …
proggeramlug commentedon Aug 5, 2026
Both load-bearing claims are now false.
#[cfg(not(feature = "llvm-inprocess"))]fallback.The experiment's verdict — "on the numbers I would not migrate" — is the opposite of what shipped in #7370.