diff --git a/.github/workflows/gc-native-roots.yml b/.github/workflows/gc-native-roots.yml index 94c59c4277..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. # @@ -154,6 +166,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/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. 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)], diff --git a/docs/engine-plan.md b/docs/engine-plan.md index 5ab4b9766c..a2b3da6a2f 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,101 @@ 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.* + +### ★ 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