From 46612652147b8335e95b8c0f5ed006b7d7c44254 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ralph=20K=C3=BCpper?= Date: Mon, 3 Aug 2026 23:01:09 +0200 Subject: [PATCH 1/4] fix(gc): split the precise-root analysis from its lowering One knob answered two questions. "Which locals hold GC pointers, and where must each stay live" is the analysis and is backend-independent. "Is that answer represented as a heap-backed shadow frame or a native stack map" is the lowering, and LlFunction already chose it independently -- enable_shadow_frame_inner and reserve_shadow_slot both take the native path first. But the eight sites that build the slot map all gated on shadow_stack_enabled(), so PERRY_SHADOW_STACK=0 switched the ANALYSIS off and left the statepoint lowering with nothing to lower. The result was a binary with no precise frame roots at all: no __perry_gcmap section, same size as a plain shadow-off build, correct output. Nothing distinguished it from a good build until a collection freed a live object. #7332 made the pair a hard error as a stopgap. Route those eight sites through precise_root_analysis_enabled() instead and the pair becomes expressible, which is what the stopgap was standing in for. Measured on 01_nursery_churn: PERRY_STATEPOINTS=1 with and without PERRY_SHADOW_STACK=0 now emit an identical 885-byte root map and an identical __text. The knob keeps its own meaning on its own -- no gcmap, and still observable against the default build. A mode nobody can select is a mode nobody can measure, so this is the prerequisite for the shadow-stack lowering ever being removed rather than merely being switched off in one configuration nobody tests. --- .github/workflows/gc-native-roots.yml | 56 +++++++++++++++++++ crates/perry-codegen/src/codegen/closure.rs | 4 +- crates/perry-codegen/src/codegen/function.rs | 4 +- crates/perry-codegen/src/codegen/helpers.rs | 58 +++++++++++--------- crates/perry-codegen/src/codegen/method.rs | 8 +-- 5 files changed, 95 insertions(+), 35 deletions(-) diff --git a/.github/workflows/gc-native-roots.yml b/.github/workflows/gc-native-roots.yml index 94c59c4277..c8ccf42ccc 100644 --- a/.github/workflows/gc-native-roots.yml +++ b/.github/workflows/gc-native-roots.yml @@ -154,6 +154,62 @@ jobs: grep -l "#gcmetric" $errs >/dev/null \ || { echo "::error::no probe emitted gc metrics — the collector never ran"; exit 1; } + # #7326. The shadow stack's root-set analysis and its lowering used to be + # one knob, so PERRY_SHADOW_STACK=0 + PERRY_STATEPOINTS=1 switched the + # analysis off and left the statepoint lowering with nothing to lower: a + # binary with NO precise frame roots, no __perry_gcmap section, correct + # output, indistinguishable from a good build until a collection freed + # something live. #7332 made the pair a hard error as a stopgap. + # + # The predicate is now split, so the pair is expressible -- and the + # property that says the split is real is that the knob makes NO + # difference under statepoints. Assert that on the artifacts rather than + # the binary: the build embeds a PID-and-nonce scratch path, so two runs + # of the SAME configuration already differ byte-for-byte, and an + # end-to-end hash would be a test that can only fail. + # + # A mode nobody can select is a mode nobody can measure, which is why this + # matters for eventually removing the shadow-stack lowering at all. + - name: The shadow-stack knob is inert under statepoints (analysis/lowering split) + if: ${{ !cancelled() }} + run: | + set -euo pipefail + export PERRY_RUNTIME_DIR="$PWD/target/perry-dev" + export PERRY_NO_AUTO_OPTIMIZE=1 + probe=benchmarks/gc_ratchet/probes/01_nursery_churn.ts + + PERRY_STATEPOINTS=1 ./target/perry-dev/perry "$probe" -o /tmp/split-on + PERRY_STATEPOINTS=1 PERRY_SHADOW_STACK=0 \ + ./target/perry-dev/perry "$probe" -o /tmp/split-off + + for v in on off; do + otool -l "/tmp/split-$v" | grep -q "sectname __perry_gcmap" \ + || { echo "::error::split-$v has no __perry_gcmap — the analysis did not run"; exit 1; } + otool -s __PERRY_GCMAP __perry_gcmap "/tmp/split-$v" | tail -n +3 \ + | awk '{$1="";print}' > "/tmp/split-$v.gcmap" + otool -tV "/tmp/split-$v" | grep -v '^/tmp/' > "/tmp/split-$v.text" + done + + cmp /tmp/split-on.gcmap /tmp/split-off.gcmap \ + || { echo "::error::root maps differ — PERRY_SHADOW_STACK still reaches the statepoint analysis"; exit 1; } + cmp /tmp/split-on.text /tmp/split-off.text \ + || { echo "::error::emitted code differs — the analysis and its lowering are not cleanly split"; exit 1; } + echo "analysis/lowering split holds: identical root map and __text across PERRY_SHADOW_STACK" + + # And the knob must keep its OWN meaning: on its own it still means + # "no precise roots", which is the whole point of a bisection knob. + PERRY_SHADOW_STACK=0 ./target/perry-dev/perry "$probe" -o /tmp/split-alone + otool -l /tmp/split-alone | grep -q "sectname __perry_gcmap" \ + && { echo "::error::PERRY_SHADOW_STACK=0 alone emitted a root map — the knob lost its meaning"; exit 1; } + + # ...and it must still be observable, or it is asserting nothing. + ./target/perry-dev/perry "$probe" -o /tmp/split-default + otool -tV /tmp/split-default | grep -v '^/tmp/' > /tmp/split-default.text + otool -tV /tmp/split-alone | grep -v '^/tmp/' > /tmp/split-alone.text + cmp -s /tmp/split-default.text /tmp/split-alone.text \ + && { echo "::error::default and PERRY_SHADOW_STACK=0 emit identical code — this probe roots nothing, so the assertions above are vacuous"; exit 1; } + echo "shadow-stack knob remains observable on its own" + - name: Root-pressure report fails closed (--statepoint-report) if: ${{ !cancelled() }} run: | diff --git a/crates/perry-codegen/src/codegen/closure.rs b/crates/perry-codegen/src/codegen/closure.rs index e1ea96807d..3640f837c6 100644 --- a/crates/perry-codegen/src/codegen/closure.rs +++ b/crates/perry-codegen/src/codegen/closure.rs @@ -582,7 +582,7 @@ pub(super) fn compile_closure( // evacuating GC fired mid-body swept values reachable only from the // closure's own frame — the referrer then read freed-and-reused memory. // Emit the same frame the top-level function path gets (function.rs). - let shadow_slot_map = if super::helpers::shadow_stack_enabled() { + let shadow_slot_map = if super::helpers::precise_root_analysis_enabled() { let flat_const_ids: std::collections::HashSet = cross_module.flat_const_arrays.keys().copied().collect(); let m = crate::collectors::collect_pointer_typed_locals(params, body, &flat_const_ids); @@ -701,7 +701,7 @@ pub(super) fn compile_closure( // bind — an entry-setup hoist would make the slot active while the alloca // still held stack garbage. let capture_root_base = shadow_slot_map.len() as u32; - let bind_capture_slot = super::helpers::shadow_stack_enabled(); + let bind_capture_slot = super::helpers::precise_root_analysis_enabled(); let new_target_stack = if captures_new_target { let new_target_cap_idx = auto_captures.len() as u32; let blk = lf.block_mut(0).unwrap(); diff --git a/crates/perry-codegen/src/codegen/function.rs b/crates/perry-codegen/src/codegen/function.rs index 664bc94177..4acec095d4 100644 --- a/crates/perry-codegen/src/codegen/function.rs +++ b/crates/perry-codegen/src/codegen/function.rs @@ -14,7 +14,7 @@ use crate::stmt; use crate::strings::StringPool; use crate::types::{LlvmType, DOUBLE, I1, I32, I64, I8, PTR}; -use super::helpers::shadow_stack_enabled; +use super::helpers::precise_root_analysis_enabled; use super::helpers::{inline_hot_small_enabled, inline_hot_small_size_cap, INLINE_HOT_SMALL_MIN}; use super::opts::CrossModuleCtx; use super::spec_abi::{ @@ -394,7 +394,7 @@ pub(super) fn compile_function( // populate the frame with live values; today the slots stay // zero (the tracer doesn't consume them yet — Phase A ship // criterion is "shadow stack is built but not yet consumed"). - let shadow_slot_map = if shadow_stack_enabled() { + let shadow_slot_map = if precise_root_analysis_enabled() { let flat_const_ids: std::collections::HashSet = cross_module.flat_const_arrays.keys().copied().collect(); let m = diff --git a/crates/perry-codegen/src/codegen/helpers.rs b/crates/perry-codegen/src/codegen/helpers.rs index 8f29f43ece..025204acb4 100644 --- a/crates/perry-codegen/src/codegen/helpers.rs +++ b/crates/perry-codegen/src/codegen/helpers.rs @@ -71,36 +71,40 @@ pub(super) fn shadow_stack_enabled() -> bool { std::env::var("PERRY_SHADOW_STACK").as_deref(), Ok("0") | Ok("off") | Ok("false") ); - // #7326: the statepoint backends are an alternative *lowering* of this - // analysis, not an independent mechanism. `reserve_shadow_slot()` is - // the single entry point that, under `native_stack_roots_enabled()`, - // allocates a stack-map slot instead of a shadow-stack slot — and the - // caller of that analysis returns empty maps outright when this is off. - // - // So switching the shadow stack off switches the statepoint roots off - // with it, and the result is a binary with NO precise frame roots that - // still runs and prints the right answer: measured, no `__perry_gcmap` - // section at all, same size as a plain shadow-off build. Nothing about - // the run distinguishes it from a correct one until a collection frees - // a live object. - // - // Refuse, rather than emit it. The bisection knob keeps its meaning on - // its own; it simply cannot be combined with a backend that depends on - // the analysis it disables. - if !on && native_stack_roots_enabled() { - panic!( - "perry: PERRY_SHADOW_STACK=0 cannot be combined with \ - PERRY_STATEPOINTS/PERRY_RS4GC. The statepoint backends reuse the \ - shadow stack's root-set analysis to decide what to root, so \ - disabling it produces a binary with no precise frame roots at all \ - — silently, since such a binary still runs correctly until a \ - collection moves something live (#7326). Drop one of the two." - ); - } on }) } +/// Whether the precise-root **analysis** runs — i.e. whether +/// `collect_pointer_typed_locals` assigns slot indices at all. +/// +/// #7326 is the distinction this function exists to draw. There are two +/// separable questions and one knob used to answer both: +/// +/// 1. *Which locals hold GC pointers, and where must each stay live?* +/// That is the analysis. It is backend-independent. +/// 2. *How is the answer represented in the emitted code?* — Perry's +/// heap-backed shadow frame, or a native stack map. That is the lowering, +/// and it is chosen inside `LlFunction` (`enable_shadow_frame_inner` and +/// `reserve_shadow_slot` both return the native path first). +/// +/// Conflating them made `PERRY_SHADOW_STACK=0 + PERRY_STATEPOINTS=1` produce a +/// binary with **no precise frame roots at all** — the analysis was switched +/// off, so the statepoint lowering had nothing to lower. No `__perry_gcmap` +/// section, same size as a plain shadow-off build, correct output. Nothing +/// distinguished it from a good build until a collection freed a live object. +/// #7332 made that combination a hard error as a stopgap; splitting the +/// predicate makes it *expressible* instead, which is the prerequisite for the +/// shadow stack's lowering ever being removed — a mode nobody can select is a +/// mode nobody can measure. +/// +/// Acceptance property, asserted by test: with statepoints on, this returns +/// true regardless of `PERRY_SHADOW_STACK`, so both spellings must emit +/// byte-identical code. +pub(crate) fn precise_root_analysis_enabled() -> bool { + shadow_stack_enabled() || native_stack_roots_enabled() +} + /// Research-only moving-GC backend using LLVM's explicit statepoint /// relocation sequence (`PERRY_STATEPOINTS=1`). /// @@ -264,7 +268,7 @@ pub(super) fn enable_module_init_shadow_frame( stmts: &[perry_hir::Stmt], flat_const_ids: &std::collections::HashSet, ) -> (HashMap, HashMap>) { - if !shadow_stack_enabled() { + if !precise_root_analysis_enabled() { return (HashMap::new(), HashMap::new()); } diff --git a/crates/perry-codegen/src/codegen/method.rs b/crates/perry-codegen/src/codegen/method.rs index 61bd43b43e..2bfe4f8bf5 100644 --- a/crates/perry-codegen/src/codegen/method.rs +++ b/crates/perry-codegen/src/codegen/method.rs @@ -305,7 +305,7 @@ pub(super) fn compile_method( // exact-roots liveness hole as closures (see compile_closure). One extra // slot roots the receiver (`this` is a pointer value reachable from // nothing else when the caller holds it only in a register temp). - let shadow_slot_map = if super::helpers::shadow_stack_enabled() { + let shadow_slot_map = if super::helpers::precise_root_analysis_enabled() { let flat_const_ids: std::collections::HashSet = cross_module.flat_const_arrays.keys().copied().collect(); let m = crate::collectors::collect_pointer_typed_locals( @@ -333,7 +333,7 @@ pub(super) fn compile_method( let blk = lf.block_mut(0).unwrap(); let this_slot = blk.alloca(DOUBLE); blk.store(DOUBLE, "%this_arg", &this_slot); - if super::helpers::shadow_stack_enabled() { + if super::helpers::precise_root_analysis_enabled() { blk.call_void( "js_shadow_slot_bind", &[(I32, &this_shadow_slot_idx.to_string()), (PTR, &this_slot)], @@ -1337,7 +1337,7 @@ pub(super) fn compile_static_method( // the non-pointer INT32 class-ref, but `js_static_this_resolve` returns a // REAL heap receiver for `C.m.call(x)` / `.apply(x)` / inherited `D.m()` // dynamic dispatch, and that object may be reachable only from this slot. - let shadow_slot_map = if super::helpers::shadow_stack_enabled() { + let shadow_slot_map = if super::helpers::precise_root_analysis_enabled() { let flat_const_ids: std::collections::HashSet = cross_module.flat_const_arrays.keys().copied().collect(); let m = @@ -1383,7 +1383,7 @@ pub(super) fn compile_static_method( &[(DOUBLE, &class_ref_lit)], ); blk.store(DOUBLE, &resolved_this, &this_slot); - if super::helpers::shadow_stack_enabled() { + if super::helpers::precise_root_analysis_enabled() { blk.call_void( "js_shadow_slot_bind", &[(I32, &this_shadow_slot_idx.to_string()), (PTR, &this_slot)], From d3e46618fa79649c02323263e5643607ddaec8d6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ralph=20K=C3=BCpper?= Date: Mon, 3 Aug 2026 23:01:50 +0200 Subject: [PATCH 2/4] docs: changelog fragment for #7340 --- changelog.d/7340-decouple-root-analysis.md | 21 +++++++++++++++++++++ 1 file changed, 21 insertions(+) create mode 100644 changelog.d/7340-decouple-root-analysis.md diff --git a/changelog.d/7340-decouple-root-analysis.md b/changelog.d/7340-decouple-root-analysis.md new file mode 100644 index 0000000000..ef92375cf9 --- /dev/null +++ b/changelog.d/7340-decouple-root-analysis.md @@ -0,0 +1,21 @@ +### Fixed + +- **`PERRY_SHADOW_STACK=0` no longer silently disables statepoint roots.** The + shadow stack's root-set *analysis* and its *lowering* were one knob, so + combining the bisection knob with `PERRY_STATEPOINTS`/`PERRY_RS4GC` switched + the analysis off and left the statepoint lowering with nothing to lower — a + binary with no precise frame roots at all, no `__perry_gcmap` section, and + correct output right up until a collection freed a live object. #7332 made the + combination a hard error; this splits the predicate so it is *expressible* + instead. + + The eight sites that build the slot map now gate on + `precise_root_analysis_enabled()`. The lowering choice was already independent + inside `LlFunction`, so nothing else moves. Under statepoints the knob is now + provably inert: identical 885-byte root map and identical `__text` with and + without it, asserted by a new `gc-native-roots` step that also fails if the + probe roots nothing (which would make the comparison vacuous). + + This changes no default and deletes nothing. It is the prerequisite for the + shadow-stack lowering ever being *removed* rather than merely switched off in + a configuration nobody could run. From 742d7d76b88cd3ce7c4434e3e6192db96ad672c8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ralph=20K=C3=BCpper?= Date: Mon, 3 Aug 2026 23:16:27 +0200 Subject: [PATCH 3/4] docs(gc): record the full-suite RS4GC result and correct the x86-64 mechanism Two corrections and one measurement. The gap suite re-run against RS4GC in-process, two arms per test (shadow-stack control + RS4GC), 479/479: 447 pass->pass, 19 pre-existing diffs unchanged, 13 node_fail, ZERO new regressions, ZERO refusals, ZERO compile failures. Zero refusals is the load-bearing number -- 128 of the 479 tests contain `try {}` and the bridge cannot compile any of them. The earlier soak's "13 regressions, do not flip" was measured against the bridge, before #7329/#7330, on a backend that structurally cannot compile a quarter of the suite. It should not be carried forward. And the x86-64 mechanism was wrong. The workflow comment claimed _Unwind_GetGR(ctx, 7) "does not reliably return the stack pointer". Measured on x86-64 Linux (glibc 2.39, gcc 13.3.0): it SEGFAULTS. RBX, RBP and RIP return correctly; RAX and RSP both SIGSEGV, because libgcc tracks only the columns CFI restores and RSP is derived from the CFA rather than tracked. The fault is in the call itself, so no address validation after it can help -- the previous wording pointed at the wrong fix. Details and a reproducer in #7333. --- .github/workflows/gc-native-roots.yml | 20 +++++-- docs/engine-plan.md | 82 +++++++++++++++++++++++++++ 2 files changed, 98 insertions(+), 4 deletions(-) diff --git a/.github/workflows/gc-native-roots.yml b/.github/workflows/gc-native-roots.yml index c8ccf42ccc..709d402aa2 100644 --- a/.github/workflows/gc-native-roots.yml +++ b/.github/workflows/gc-native-roots.yml @@ -35,10 +35,22 @@ # # The defect is one layer down, at collection time. `chain_walkable` admits only # aarch64's DWARF 29/31, so on x86-64 every frame falls back to the platform -# unwinder, which resolves the base with `_Unwind_GetGR(ctx, 7)` — and that does -# not reliably return the stack pointer (`_Unwind_GetCFA` is the supported way). -# Wild addresses, then a segfault when the collector writes through them. The -# compiler now refuses that target outright (#7324) rather than emitting a +# unwinder, which resolves the base with `_Unwind_GetGR(ctx, 7)`. +# +# MEASURED 2026-08-03 (#7333), and it is worse than the "unreliable value" this +# comment used to claim: that call SEGFAULTS. Probed on x86-64 Linux (glibc 2.39, +# gcc 13.3.0), one register per run from an `_Unwind_Backtrace` callback — RBX +# (3), RBP (6) and RIP (16) return correctly; RAX (0) and RSP (7) both SIGSEGV. +# The split is callee-saved versus not: libgcc tracks only the columns CFI +# restores, and RSP is not one of them (it is *derived* from the CFA), so reg 7 +# is the single lookup guaranteed to fault — and it is the only register x86-64 +# roots use. +# +# So the fault is IN the `_Unwind_GetGR` call, not in a later write through a +# wild address, and no address validation after it can help. The wording here +# before was a guess, and it pointed at the wrong fix. +# +# The compiler now refuses that target outright (#7324) rather than emitting a # binary that crashes under collection, so an x86-64 run of this matrix would # test nothing but the refusal — which is what `statepoints-refuse-x86` is for. # diff --git a/docs/engine-plan.md b/docs/engine-plan.md index 5ab4b9766c..b3e95886dc 100644 --- a/docs/engine-plan.md +++ b/docs/engine-plan.md @@ -125,6 +125,25 @@ be the cause. The matrix therefore runs on `macos-14`, and `statepoints-refuse-x86` pins the refusal *as a refusal* and goes red the day x86-64 starts working. +**The blocker behind it is now measured (#7333).** Even once the map parses, the +walker cannot run: x86-64 roots are all `Indirect [RSP + off]` (DWARF 7), and +`_Unwind_GetGR(ctx, 7)` **segfaults** — not "returns something unreliable", which +is what this was previously assumed to be. On x86-64 Linux (glibc 2.39, gcc +13.3.0), RBX/RBP/RIP return correctly while RAX and RSP both SIGSEGV: libgcc +tracks only the columns CFI restores, and RSP is derived from the CFA rather than +tracked. So reg 7 is the one lookup guaranteed to fault, and it is the only one +x86-64 roots use. + +The recoverable bases do exist — `_Unwind_GetCFA` works, RBP works, and every +generated function already carries `"frame-pointer"="non-leaf"` under native +roots. What is missing is the per-function delta to the body RSP the map's +offsets are relative to. The cheapest place to close it is the compact-map +rewriter (#7314), which already parses the emitted **assembly**, where the +prologue is visible — the same technique #7329 just corrected for the aarch64 +fast walker, whose missing trailing `sub sp, sp, #imm` is the exact analogue of +x86-64's `sub rsp, N`. Doing it there keeps a second architecture-specific +prologue decoder out of the runtime. + A second latent defect, now fixed: the workflow set `RUSTFLAGS="-Cforce-frame-pointers=yes"`, which **replaces** `.cargo/config.toml`'s `[build] rustflags` wholesale and so dropped `-C force-unwind-tables=yes`. A/B'd @@ -133,6 +152,69 @@ unwinder visits **zero** frames — so on any host where the x29 chain walk is unavailable the native-root walker finds no roots, and forced evacuation stays quiet because it enumerates through that same walker. +### ★ Update, later on 2026-08-03 — the two structural blockers are gone + +Both were structural rather than numeric, and both are now closed. What remains +blocking adoption is scope (x86-64) and process (a required check), not design. + +**1. There was no working statepoint path for `try` on a default toolchain +(#7339).** The explicit bridge cannot root an `invoke`, and since #7305 every +call inside a `try` *is* an invoke — so the bridge refuses those functions +outright (#7330). RS4GC handles them, but it ran as an external `opt` subprocess +whose output an older `clang` could not parse (`error: unterminated attribute +group`), making it reachable only on a hand-pinned LLVM 22. **128 of 479 gap +tests (26%) contain `try {}`**, so a quarter of the suite had no statepoint path +at all. Routing RS4GC through layer 0's in-process pipeline removes the external +boundary entirely: all nine probes now compile with no `PERRY_LLVM_*` pinning, +byte-identical to the shadow-stack control, copying 5,946–90,271 objects, with +`backend rs4gc` on every function record. + +**2. "Delete the shadow stack, keep statepoints" was not expressible (#7340).** +The root-set *analysis* and its *lowering* were one knob, so +`PERRY_SHADOW_STACK=0 + PERRY_STATEPOINTS=1` disabled the analysis and left the +statepoint lowering with nothing to lower — a rootless binary that ran correctly +until a collection freed something live. #7332 made the pair a hard error; #7340 +splits the predicate so the pair is *selectable*, with the knob proven inert +under statepoints (identical root map, identical `__text`). + +That second one matters more than its diff suggests. **A mode nobody can select +is a mode nobody can measure**, and the reason the adoption question kept +stalling is that its central configuration could not be run. + +**What this changes about the decision below:** the earlier soak's verdict — +13 gap regressions, "do not flip" — was measured against the *bridge*, before +#7329/#7330, and against a backend that structurally cannot compile a quarter of +the suite. **It should not be carried forward.** + +Re-measured 2026-08-03 against RS4GC in-process, full gap suite, two arms per +test (shadow-stack control + RS4GC), 479/479: + +``` +pass -> pass 447 +diff -> diff 19 pre-existing, unchanged by the backend +node_fail -> node_fail 13 oracle cannot run the test +──────────────────────────── +NEW REGRESSIONS 0 +RS4GC refusals 0 +RS4GC compile failures 0 +``` + +Zero refusals is the load-bearing number, not zero regressions: **128 of the 479 +tests contain `try {}`**, and the bridge cannot compile any of them. RS4GC +compiled every test in the suite. + +**⇒ On aarch64, RS4GC-in-process is now a viable default.** Two things still gate +flipping it globally, and neither is correctness: + +1. **`llvm-inprocess` is a non-default cargo feature.** RS4GC-as-default requires + layer 0's feature becoming default first (#7301's scope, not this work's). +2. **x86-64 remains blocked on #7333** — see the measured `_Unwind_GetGR(ctx, 7)` + segfault above. A default that only works on one architecture is not a + default. + +So the honest state is: *aarch64-viable, globally blocked on two pieces of scope +that are both already identified.* + ### The adoption decision itself Two precise-root mechanisms now exist. The kill-policy says that state is From 7c057c5553c625e220a9c458172ee5bfc0c42b20 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ralph=20K=C3=BCpper?= Date: Tue, 4 Aug 2026 02:07:49 +0200 Subject: [PATCH 4/4] =?UTF-8?q?docs(gc):=20measure=20the=20statepoint=20bi?= =?UTF-8?q?nary-size=20axis=20=E2=80=94=20it=20is=20root=20density,=20not?= =?UTF-8?q?=20metadata?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The plan asserted 'closing that axis needs fewer roots, not a tighter encoding' on the strength of one app measurement. Measured directly with two 2000-function programs: root-free functions +0 bytes (no map emitted, text identical) root-dense functions +4,330,592 B (97% __text, 21% gcmap) So statepoints carry NO fixed cost -- a function with nothing live across a safepoint pays nothing -- and the growth is the per-root relocation sequence, not the map. #7314's compact map fully answered the metadata objection, but metadata was never the dominant term at scale. Runtime on the same probes, quiet host, median of 5: statepoints 1-2% faster, every probe neutral or faster. --- docs/engine-plan.md | 32 ++++++++++++++++++++++++++++++++ 1 file changed, 32 insertions(+) diff --git a/docs/engine-plan.md b/docs/engine-plan.md index b3e95886dc..a2b3da6a2f 100644 --- a/docs/engine-plan.md +++ b/docs/engine-plan.md @@ -215,6 +215,38 @@ flipping it globally, and neither is correctness: So the honest state is: *aarch64-viable, globally blocked on two pieces of scope that are both already identified.* +### ★ Binary size, measured 2026-08-04 — it is a ROOT-DENSITY problem, not a metadata one + +The note above says *"closing that axis needs **fewer roots**, not a tighter +encoding."* That is now measured, and the shape is sharper than "a wash". + +Two synthetic programs, 2000 functions each, aarch64, `PERRY_STATEPOINTS=1` vs +the shadow-stack default: + +| workload | total delta | `__text` | `__perry_gcmap` | +|---|---:|---:|---:| +| 2000 **root-free** functions (scalar only) | **+0 B** | +12 B | not emitted | +| 2000 **root-dense** functions (3 heap values live across an alloc) | **+4,330,592 B (+18.95%)** | +4,203,608 B | 902,124 B | + +Two things follow, and both matter for planning: + +1. **Statepoints have no fixed cost.** A function with nothing live across a + safepoint pays nothing at all — no map entry, no text. So the axis is not + "statepoints are bigger", it is "roots are bigger", and a program's exposure + is exactly its root density. +2. **97% of the growth is `__text`, not metadata.** #7314's compact map answered + the metadata objection completely (it is 21% of the cost at this scale), but + metadata was never the dominant term for root-dense code. The cost is the + relocation sequence emitted per live root per safepoint. + +⇒ **Do not spend further effort on the encoding.** The lever is safepoint density +and root-set size — which is the same lever #7287/#7296 are already pulling for +speed, so the two axes are aligned rather than in tension. + +Runtime, same probes, quiet host, median of 5: statepoints are **1–2% faster** +across the board (2054 ms → 2013 ms total; every probe neutral or faster, none +slower), consistent with the −0.93% recorded above. + ### The adoption decision itself Two precise-root mechanisms now exist. The kill-policy says that state is