From ad7c9bd1f3a1a8a42dd7fe70278011a7e0fd9365 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ralph=20K=C3=BCpper?= Date: Tue, 4 Aug 2026 08:28:07 +0200 Subject: [PATCH] =?UTF-8?q?gc:=20delete=20the=20explicit=20statepoint=20br?= =?UTF-8?q?idge=20=E2=80=94=20one=20native-root=20backend?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Perry carried two statepoint backends. The explicit bridge rewrote Perry's own IR text into gc.statepoint calls with hand-emitted relocations; RS4GC retypes root allocas and lets LLVM's RewriteStatepointsForGC insert every statepoint and relocation itself. They were never peers. RS4GC does strictly more: the bridge cannot root an invoke, so since #7330 it refused try-carrying functions and CI skipped 09_try_catch_roots on that arm. A mode that cannot compile what its sibling compiles, kept beside it with its own emitter, parser and knob, is the permanent hybrid this project keeps paying for. The bridge was also RS4GC's fallback — a bail in the recognizer silently downgraded the function to it. Measured first: 1,574 functions across test-drizzle-pg (1,543) and the probes (31) all lowered as rs4gc, none fell back. A fallback nothing takes is an untested configuration, so a bail is now a hard failure naming the function rather than a silent downgrade. Deleted with it, because only the bridge used them: the CFG root-liveness analysis (RS4GC gets liveness from LLVM's SSA form), the direct-call parser and statepoint emitter, PreciseRootBackend, and the PERRY_STATEPOINTS knob. PERRY_RS4GC=1 is the single switch; native_stack_roots_enabled() is now just rs4gc_enabled(). One fewer GC knob is one less kill-policy debt. Net -1,216 lines. The default shadow-stack path is untouched. Verified: 10/10 probes byte-match the oracle on the sole backend under forced evacuation with the verifying walker, 10/10 on the default arm, drizzle still builds, 593 codegen unit tests pass. --- .github/workflows/gc-native-roots.yml | 362 +------ changelog.d/7345-delete-statepoint-bridge.md | 32 + crates/perry-codegen/src/codegen/helpers.rs | 22 +- crates/perry-codegen/src/function.rs | 30 +- .../src/function/precise_roots.rs | 883 +----------------- crates/perry-codegen/src/linker.rs | 3 +- crates/perry-codegen/src/module.rs | 4 +- .../perry/src/commands/compile/build_cache.rs | 1 - .../src/commands/compile/object_cache.rs | 5 - crates/perry/src/commands/compile/types.rs | 2 +- docs/src/cli/flags.md | 2 +- 11 files changed, 81 insertions(+), 1265 deletions(-) create mode 100644 changelog.d/7345-delete-statepoint-bridge.md diff --git a/.github/workflows/gc-native-roots.yml b/.github/workflows/gc-native-roots.yml index 3a2f9af405..087016c919 100644 --- a/.github/workflows/gc-native-roots.yml +++ b/.github/workflows/gc-native-roots.yml @@ -75,7 +75,6 @@ # CLAUDE.md's GC knob kill-policy: an arm exercising the non-default state, or # delete the mode. # -# PERRY_STATEPOINTS -> native-roots-aarch64, "statepoint mode" step # PERRY_GC_SAFEPOINT_ONLY -> native-roots-aarch64, "safepoint-only" steps # PERRY_STACKMAP_WALKER -> native-roots-aarch64, "both non-default walkers" # PERRY_RS4GC -> native-roots-rs4gc-aarch64 @@ -96,361 +95,6 @@ on: workflow_dispatch: jobs: - native-roots-aarch64: - runs-on: macos-14 - timeout-minutes: 90 - steps: - - uses: actions/checkout@v4 - - uses: actions/setup-node@v4 - with: - node-version-file: .node-version - - uses: dtolnay/rust-toolchain@stable - - uses: Swatinem/rust-cache@v2 - with: - shared-key: gc-native-roots - - name: Build compiler and static runtime (perry-dev profile) - run: | - export RUSTFLAGS="-C force-frame-pointers=yes -C force-unwind-tables=yes" - cargo build --profile perry-dev -p perry -p perry-runtime-static -p perry-stdlib-static - - # Every arm below carries `if: ${{ !cancelled() }}`. A job is a SEQUENCE - # of independent gates and a failed step takes every later step to - # `skipped`, so without this one red arm silently stops the other three - # from ever speaking — the same hazard `lint` documents at length. - - name: Probe matrix, statepoint mode, forced evacuation - if: ${{ !cancelled() }} - run: | - set -euo pipefail - export PERRY_RUNTIME_DIR="$PWD/target/perry-dev" - export PERRY_NO_AUTO_OPTIMIZE=1 - pass=0 - total=0 - errs="" - for probe in benchmarks/gc_ratchet/probes/*.ts; do - # #7335: the explicit bridge cannot root an `invoke`, so since #7330 it - # REFUSES a try-carrying probe rather than emitting a frame with no - # roots (#7327). Skip 09 here; the RS4GC job covers it, and the step - # below asserts the refusal actually happens. - [ "$(basename "$probe")" = "09_try_catch_roots.ts" ] && continue - total=$((total+1)) - name=$(basename "$probe" .ts) - node --expose-gc --experimental-strip-types "$probe" > "/tmp/$name.oracle" - PERRY_STATEPOINTS=1 ./target/perry-dev/perry "$probe" -o "/tmp/$name" - # Liveness assert 1: the subject must exist. The compact map - # replaced LLVM's section, so assert BOTH facts — the new section - # is present AND the old one is gone. Checking only the former - # would still pass if compaction silently stopped running. - otool -l "/tmp/$name" | grep -q "sectname __perry_gcmap" \ - || { echo "::error::$name has no __perry_gcmap section — statepoint mode was not live"; exit 1; } - otool -l "/tmp/$name" | grep -q "sectname __llvm_stackmaps" \ - && { echo "::error::$name still carries __llvm_stackmaps — the compact rewrite did not run"; exit 1; } - PERRY_STATEPOINTS=1 PERRY_GC_FORCE_EVACUATE=1 PERRY_GC_VERIFY_EVACUATION=1 \ - PERRY_GC_HEAP_LIMIT=8 PERRY_GC_INCREMENTAL=0 PERRY_CONSERVATIVE_STACK_SCAN=off \ - "/tmp/$name" > "/tmp/$name.out" 2> "/tmp/$name.err" - diff "/tmp/$name.oracle" "/tmp/$name.out" \ - || { echo "::error::$name output diverged from the pinned oracle"; exit 1; } - errs="$errs /tmp/$name.err" - pass=$((pass+1)) - done - # Derived from the glob, not hardcoded: a literal goes stale the - # moment a probe is added (it did — 09_try_catch_roots), and if it is - # ever lowered to match it silently stops asserting full coverage. - echo "statepoint forced-evacuation matrix: $pass/$total" - [ "$total" -gt 0 ] \ - || { echo "::error::no probes matched — the matrix ran on nothing"; exit 1; } - [ "$pass" -eq "$total" ] - # Liveness assert 2: at least one probe actually collected (gcmetric - # lines go to stderr). Collected during the loop rather than globbed - # as /tmp/0*.err, which silently depends on every probe name starting - # with a zero. - 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: | - set -euo pipefail - export PERRY_RUNTIME_DIR="$PWD/target/perry-dev" - export PERRY_NO_AUTO_OPTIMIZE=1 - # The arm for the report itself. `PERRY_STATEPOINT_REPORT` was deleted - # as a user-facing knob (a second spelling of this flag, with no arm), - # so the flag is the only entry point and it needs one. - # - # It also asserts #7314's headline claim — that every root path fails - # CLOSED. `plain_stack_maps` and `statepoint_fallbacks` must both be - # zero: LLVM's plain stackmap can record a root as `Register R#N` - # (caller-saved, unrecoverable at collection time), so a nonzero count - # here is silently lost roots, not a degraded-but-safe mode. - # #7335: was 09_try_catch_roots, which the bridge now refuses (#7330). - # Any non-try probe exercises the same report assertions. - probe=benchmarks/gc_ratchet/probes/01_nursery_churn.ts - PERRY_STATEPOINTS=1 ./target/perry-dev/perry "$probe" \ - -o /tmp/report-probe --statepoint-report=json 2> /tmp/statepoint-report.json - python3 scripts/statepoint_report_assert.py /tmp/statepoint-report.json \ - --only-backend statepoint \ - --require-positive statepoints \ - --require-positive relocations \ - --require-zero plain_stack_maps \ - --require-zero statepoint_fallbacks - - # PERRY_GC_SAFEPOINT_ONLY: the explicit-safepoint collection contract. - # Under it, audited allocate-but-never-reenter helpers need no statepoint - # so codegen emits fewer, and the runtime enforces that a precise-root - # collection only ever begins at a declared safepoint. `strict` is the - # mode that panics rather than heals — by the contract's own docs, the - # mode that proves enforcement is live — so that is the mode run here. - - name: Safepoint-only contract changes codegen (differential) - if: ${{ !cancelled() }} - run: | - set -euo pipefail - export PERRY_RUNTIME_DIR="$PWD/target/perry-dev" - export PERRY_NO_AUTO_OPTIMIZE=1 - # A `strict` run that never trips the panic proves the enforcement was - # armed but NOT that the contract did anything, because a build where - # it changed no code would be equally quiet. The differential is the - # assert with teeth: with the contract on, codegen must skip strictly - # MORE calls and emit strictly FEWER statepoints. - # - # Aggregated over the whole glob on purpose. Some individual probes - # contain no AllocNoReentry callee at all and show a zero delta - # (09_try_catch_roots is one, measured), so a per-probe assert would - # be a coin flip on which probe the author happened to pick. - off_sp=0; off_sk=0; on_sp=0; on_sk=0 - for probe in benchmarks/gc_ratchet/probes/*.ts; do - # #7335: the explicit bridge cannot root an `invoke`, so since #7330 it - # REFUSES a try-carrying probe rather than emitting a frame with no - # roots (#7327). Skip 09 here; the RS4GC job covers it, and the step - # below asserts the refusal actually happens. - [ "$(basename "$probe")" = "09_try_catch_roots.ts" ] && continue - name=$(basename "$probe" .ts) - PERRY_STATEPOINTS=1 ./target/perry-dev/perry "$probe" \ - -o /dev/null --statepoint-report=json 2> "/tmp/off-$name.json" - PERRY_STATEPOINTS=1 PERRY_GC_SAFEPOINT_ONLY=strict ./target/perry-dev/perry "$probe" \ - -o /dev/null --statepoint-report=json 2> "/tmp/on-$name.json" - off_sp=$((off_sp + $(python3 scripts/statepoint_report_assert.py "/tmp/off-$name.json" --print statepoints))) - off_sk=$((off_sk + $(python3 scripts/statepoint_report_assert.py "/tmp/off-$name.json" --print skipped_non_safepoints))) - on_sp=$((on_sp + $(python3 scripts/statepoint_report_assert.py "/tmp/on-$name.json" --print statepoints))) - on_sk=$((on_sk + $(python3 scripts/statepoint_report_assert.py "/tmp/on-$name.json" --print skipped_non_safepoints))) - done - echo "contract off: statepoints=$off_sp skipped_non_safepoints=$off_sk" - echo "contract on : statepoints=$on_sp skipped_non_safepoints=$on_sk" - if [ "$on_sk" -le "$off_sk" ]; then - echo "::error::PERRY_GC_SAFEPOINT_ONLY skipped no additional calls ($on_sk <= $off_sk) — the contract never reached codegen and this arm asserted nothing" - exit 1 - fi - if [ "$on_sp" -ge "$off_sp" ]; then - echo "::error::PERRY_GC_SAFEPOINT_ONLY removed no statepoints ($on_sp >= $off_sp) — the contract never reached codegen" - exit 1 - fi - - - name: Probe matrix, safepoint-only contract in strict mode - if: ${{ !cancelled() }} - run: | - set -euo pipefail - export PERRY_RUNTIME_DIR="$PWD/target/perry-dev" - export PERRY_NO_AUTO_OPTIMIZE=1 - pass=0 - total=0 - errs="" - for probe in benchmarks/gc_ratchet/probes/*.ts; do - # #7335: the explicit bridge cannot root an `invoke`, so since #7330 it - # REFUSES a try-carrying probe rather than emitting a frame with no - # roots (#7327). Skip 09 here; the RS4GC job covers it, and the step - # below asserts the refusal actually happens. - [ "$(basename "$probe")" = "09_try_catch_roots.ts" ] && continue - total=$((total+1)) - name=$(basename "$probe" .ts) - node --expose-gc --experimental-strip-types "$probe" > "/tmp/so-$name.oracle" - PERRY_STATEPOINTS=1 PERRY_GC_SAFEPOINT_ONLY=strict \ - ./target/perry-dev/perry "$probe" -o "/tmp/so-$name" - otool -l "/tmp/so-$name" | grep -q "sectname __perry_gcmap" \ - || { echo "::error::$name has no __perry_gcmap section — statepoint mode was not live"; exit 1; } - # strict PANICS if a precise-root collection ever begins outside a - # declared safepoint, so a clean exit here is the enforcement - # holding, not the enforcement being absent. - PERRY_STATEPOINTS=1 PERRY_GC_SAFEPOINT_ONLY=strict \ - PERRY_GC_FORCE_EVACUATE=1 PERRY_GC_VERIFY_EVACUATION=1 \ - PERRY_GC_HEAP_LIMIT=8 PERRY_GC_INCREMENTAL=0 PERRY_CONSERVATIVE_STACK_SCAN=off \ - "/tmp/so-$name" > "/tmp/so-$name.out" 2> "/tmp/so-$name.err" - diff "/tmp/so-$name.oracle" "/tmp/so-$name.out" \ - || { echo "::error::$name diverged from the oracle under the safepoint-only contract"; exit 1; } - errs="$errs /tmp/so-$name.err" - pass=$((pass+1)) - done - echo "safepoint-only strict matrix: $pass/$total" - [ "$total" -gt 0 ] \ - || { echo "::error::no probes matched — the matrix ran on nothing"; exit 1; } - [ "$pass" -eq "$total" ] - grep -l "#gcmetric" $errs >/dev/null \ - || { echo "::error::no probe emitted gc metrics — the collector never ran"; exit 1; } - - # PERRY_STACKMAP_WALKER. This arm is aarch64-only for a second, unrelated - # reason: the x29 chain walk is compiled in for `any(macos, linux) + - # aarch64` and stubbed to `None` everywhere else, so on x86-64 `fast` - # silently degrades to the unwinder and `verify` panics outright on "fast - # walk unavailable". An arm for this knob on an x86-64 runner would assert - # the opposite of what it appears to. - - name: Probe matrix under both non-default walkers - if: ${{ !cancelled() }} - run: | - set -euo pipefail - export PERRY_RUNTIME_DIR="$PWD/target/perry-dev" - export PERRY_NO_AUTO_OPTIMIZE=1 - pass=0 - total=0 - for probe in benchmarks/gc_ratchet/probes/*.ts; do - # #7335: the explicit bridge cannot root an `invoke`, so since #7330 it - # REFUSES a try-carrying probe rather than emitting a frame with no - # roots (#7327). Skip 09 here; the RS4GC job covers it, and the step - # below asserts the refusal actually happens. - [ "$(basename "$probe")" = "09_try_catch_roots.ts" ] && continue - total=$((total+1)) - name=$(basename "$probe" .ts) - node --expose-gc --experimental-strip-types "$probe" > "/tmp/w-$name.oracle" - # The walker is a RUNTIME knob — one binary, two walks over it. - PERRY_STATEPOINTS=1 ./target/perry-dev/perry "$probe" -o "/tmp/w-$name" - - # verify: runs the chain walk AND the unwinder and panics unless - # they visit the identical slot set. It is the only check that can - # catch a fast walk silently skipping frames — forced-evacuation - # verification enumerates roots through the same walker, so it - # cannot see a slot the walker never reached. - PERRY_STACKMAP_WALKER=verify PERRY_GC_TRACE=1 \ - PERRY_GC_FORCE_EVACUATE=1 PERRY_GC_VERIFY_EVACUATION=1 \ - PERRY_GC_HEAP_LIMIT=8 PERRY_GC_INCREMENTAL=0 PERRY_CONSERVATIVE_STACK_SCAN=off \ - "/tmp/w-$name" > "/tmp/w-$name.verify.out" 2> "/tmp/w-$name.verify.err" - diff "/tmp/w-$name.oracle" "/tmp/w-$name.verify.out" \ - || { echo "::error::$name diverged from the oracle under PERRY_STACKMAP_WALKER=verify"; exit 1; } - python3 scripts/gc_walker_trace_assert.py "/tmp/w-$name.verify.err" --require-fp-walks - - # unwind: the bisection control. Same roots, platform unwinder only. - PERRY_STACKMAP_WALKER=unwind PERRY_GC_TRACE=1 \ - PERRY_GC_FORCE_EVACUATE=1 PERRY_GC_VERIFY_EVACUATION=1 \ - PERRY_GC_HEAP_LIMIT=8 PERRY_GC_INCREMENTAL=0 PERRY_CONSERVATIVE_STACK_SCAN=off \ - "/tmp/w-$name" > "/tmp/w-$name.unwind.out" 2> "/tmp/w-$name.unwind.err" - diff "/tmp/w-$name.oracle" "/tmp/w-$name.unwind.out" \ - || { echo "::error::$name diverged from the oracle under PERRY_STACKMAP_WALKER=unwind"; exit 1; } - python3 scripts/gc_walker_trace_assert.py "/tmp/w-$name.unwind.err" --forbid-fp-walks - - pass=$((pass+1)) - done - echo "stackmap-walker verify+unwind matrix: $pass/$total" - [ "$total" -gt 0 ] \ - || { echo "::error::no probes matched — the matrix ran on nothing"; exit 1; } - [ "$pass" -eq "$total" ] - - # PERRY_RS4GC=1: LLVM's own RewriteStatepointsForGC inserts the statepoints - # instead of Perry's explicit bridge. Split from the job above because it is - # the one arm with an external-tool dependency (`opt`), so a Homebrew hiccup - # cannot take the core arms down with it. - # #7335 / #7327: the bridge cannot express a statepoint on an `invoke`, so - # since #7330 it refuses a try-carrying module rather than emitting a frame - # whose roots the collector cannot see. Assert the refusal HAPPENS — a skip - # that is not also checked is just missing coverage, and this is the one - # construct where the bridge is known to be unable to root anything. - - name: The bridge must refuse a try-carrying probe, not silently skip it - if: ${{ !cancelled() }} - run: | - set -uo pipefail - probe=benchmarks/gc_ratchet/probes/09_try_catch_roots.ts - if PERRY_STATEPOINTS=1 ./target/perry-dev/perry "$probe" \ - -o /tmp/should-not-exist > /tmp/refuse.log 2>&1; then - echo "::error::the bridge COMPILED a try-carrying probe. Either it learned" - echo "::error::invokes (delete this step and re-enable 09 above) or it is" - echo "::error::emitting unrooted frames again (#7327)." - exit 1 - fi - if ! grep -q "7327" /tmp/refuse.log; then - echo "::error::the bridge failed on $probe, but not with the #7327 refusal:" - tail -20 /tmp/refuse.log - exit 1 - fi - echo "bridge refused the try-carrying probe, as expected (#7327)" - - # #7336: the evacuation arm was VACUOUS. The probes drive collection with - # `gc()`, which takes `manual_collect` — a full mark-sweep behind a forced - # conservative scan — and `PERRY_GC_FORCE_EVACUATE` is read only on the - # MINOR path. Measured: `copied_objects` and `moved_objects` were 0 on - # every probe, while `--require-fp-walks` passed because it asserts a walk - # HAPPENED, not that it FOUND anything. That is #6942/#6946 repeating, the - # one CLAUDE.md records as costing months of meaningless green. - # - # The arms above now drive the minor path. This asserts they actually - # moved something, so the gate fails if it ever goes inert again. - - name: The evacuation arm must actually evacuate - if: ${{ !cancelled() }} - run: | - set -uo pipefail - export PERRY_RUNTIME_DIR="$PWD/target/perry-dev" - export PERRY_NO_AUTO_OPTIMIZE=1 - fail=0 - for probe in benchmarks/gc_ratchet/probes/*.ts; do - name=$(basename "$probe" .ts) - [ "$name" = "09_try_catch_roots" ] && continue - PERRY_STATEPOINTS=1 ./target/perry-dev/perry "$probe" -o "/tmp/ev-$name" >/dev/null 2>&1 || continue - PERRY_STATEPOINTS=1 PERRY_GC_FORCE_EVACUATE=1 \ - PERRY_GC_HEAP_LIMIT=8 PERRY_GC_INCREMENTAL=0 PERRY_CONSERVATIVE_STACK_SCAN=off \ - PERRY_GC_DIAG=1 "/tmp/ev-$name" > /dev/null 2> "/tmp/ev-$name.err" || true - python3 scripts/gc_evacuation_liveness_assert.py "/tmp/ev-$name.err" --probe "$name" || fail=1 - done - exit $fail - native-roots-rs4gc-aarch64: runs-on: macos-14 # 120, not 90: the in-process step below builds a second time with the @@ -615,13 +259,13 @@ jobs: export PERRY_NO_AUTO_OPTIMIZE=1 probe=benchmarks/gc_ratchet/probes/01_nursery_churn.ts set +e - PERRY_STATEPOINTS=1 ./target/perry-dev/perry "$probe" -o /tmp/x86-probe \ + PERRY_RS4GC=1 ./target/perry-dev/perry "$probe" -o /tmp/x86-probe \ > /tmp/x86.out 2> /tmp/x86.err rc=$? set -e tail -40 /tmp/x86.out /tmp/x86.err || true if [ "$rc" -eq 0 ]; then - echo "::error::PERRY_STATEPOINTS now compiles on x86-64. That is good news and this job is the wrong shape for it: move the x86-64 host into native-roots-aarch64's matrix (rename it) and delete this job." + echo "::error::native GC roots now compile on x86-64. That is good news and this job is the wrong shape for it: add the x86-64 host to native-roots-rs4gc-aarch64 (rename it) and delete this job." exit 1 fi # Non-zero for the RIGHT reason. Any old failure (missing clang, a @@ -637,7 +281,7 @@ jobs: # branch protection to require, so adding an arm never needs a protection edit # and a red arm cannot hide behind a green sibling. gc-native-roots-complete: - needs: [native-roots-aarch64, native-roots-rs4gc-aarch64, statepoints-refuse-x86] + needs: [native-roots-rs4gc-aarch64, statepoints-refuse-x86] if: always() runs-on: ubuntu-latest timeout-minutes: 5 diff --git a/changelog.d/7345-delete-statepoint-bridge.md b/changelog.d/7345-delete-statepoint-bridge.md new file mode 100644 index 0000000000..3b695d19fb --- /dev/null +++ b/changelog.d/7345-delete-statepoint-bridge.md @@ -0,0 +1,32 @@ +### Delete the explicit statepoint bridge — one native-root backend, not two + +Perry carried two statepoint backends. The **explicit bridge** rewrote Perry's +own IR text into `gc.statepoint` calls with hand-emitted relocations; **RS4GC** +retypes root allocas and lets LLVM's `RewriteStatepointsForGC` insert every +statepoint and relocation itself. The bridge is gone. + +They were never peers. RS4GC does strictly more: the bridge **cannot root an +`invoke`**, so since #7330 it refused try-carrying functions outright, and CI +had to skip `09_try_catch_roots` on that arm. Keeping a mode that cannot +compile what its sibling compiles — plus its textual emitter, its call parser, +and its knob — is the permanent hybrid this project keeps paying for. + +The bridge was also RS4GC's fallback: a bail in the RS4GC recognizer silently +downgraded the whole function to it. **Measured before removing it: 1,574 +functions across `test-drizzle-pg` (1,543) and the gc-ratchet probes (31) all +lowered as `rs4gc`, none fell back.** A fallback nothing takes is an untested +configuration, which is what the GC knob kill-policy exists to prevent — so a +bail is now a hard failure naming the function, not a silent downgrade. + +Deleted with it, because only the bridge used them: the CFG-based root-liveness +analysis (RS4GC gets liveness from LLVM's SSA form), the direct-call parser and +statepoint emitter, the `PreciseRootBackend` enum, and the `PERRY_STATEPOINTS` +knob — `PERRY_RS4GC=1` is now the single switch, and one fewer GC knob is one +less kill-policy debt. `native_stack_roots_enabled()` is just `rs4gc_enabled()`. + +Net **−1,216 lines**. The default shadow-stack path is untouched. + +Verified: all ten gc-ratchet probes byte-match the pinned Node oracle on the +sole backend under `PERRY_GC_FORCE_EVACUATE=1 PERRY_GC_VERIFY_EVACUATION=1 +PERRY_STACKMAP_WALKER=verify`, the default arm is 10/10, `test-drizzle-pg` +still builds, and 593 codegen unit tests pass. diff --git a/crates/perry-codegen/src/codegen/helpers.rs b/crates/perry-codegen/src/codegen/helpers.rs index 025204acb4..a4b93f0c69 100644 --- a/crates/perry-codegen/src/codegen/helpers.rs +++ b/crates/perry-codegen/src/codegen/helpers.rs @@ -105,26 +105,6 @@ 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`). -/// -/// The standalone plain-stack-map mode (`PERRY_STACK_MAPS`) was deleted per -/// the GC knob kill-policy after the quiet-host matrix: statepoints matched -/// it within timer quantization, and it is structurally unsound — LLVM's -/// stackmap intrinsic can record a root slot's address as `Register R#N` -/// (caller-saved, unrecoverable at collection time), making those roots -/// invisible to the collector by construction. The plain-map LOWERING -/// survives only as this mode's internal fallback for `try`/setjmp -/// functions and unsupported call forms. The Register hazard exists there -/// too, which is why shrinking the fallback set is the remaining -/// correctness work for this backend, tracked in the experiment doc. -pub(crate) fn statepoints_enabled() -> bool { - matches!( - std::env::var("PERRY_STATEPOINTS").as_deref(), - Ok("1") | Ok("on") | Ok("true") - ) -} - /// `PERRY_RS4GC=1` — research pipeline for #7174: root allocas become /// `ptr addrspace(1)`, functions are tagged `gc "statepoint-example"`, and /// each module is piped through `opt -passes='function(mem2reg), @@ -146,7 +126,7 @@ pub(crate) fn rs4gc_enabled() -> bool { /// Whether precise roots should use a native-stack metadata backend rather /// than Perry's heap-backed shadow frame. pub(crate) fn native_stack_roots_enabled() -> bool { - statepoints_enabled() || rs4gc_enabled() + rs4gc_enabled() } /// `PERRY_GC_SAFEPOINT_ONLY=1` — the explicit-safepoint collection contract diff --git a/crates/perry-codegen/src/function.rs b/crates/perry-codegen/src/function.rs index 1284027820..b21c145757 100644 --- a/crates/perry-codegen/src/function.rs +++ b/crates/perry-codegen/src/function.rs @@ -13,9 +13,7 @@ use crate::types::LlvmType; /// #7173 / #7174). A sibling file only because of the 2,000-line cap. mod precise_roots; -use precise_roots::{ - lower_precise_roots_to_native_stack, retype_landing_pads_for_statepoints, PreciseRootBackend, -}; +use precise_roots::{lower_precise_roots_to_native_stack, retype_landing_pads_for_statepoints}; pub struct LlFunction { pub name: String, @@ -685,14 +683,12 @@ impl LlFunction { // #7174: the `!has_try` exclusion is gone with the field. Try/catch no // longer lowers to setjmp/longjmp (#7302), so nothing can jump past a // `gc.relocate` any more and statepoints cover every function. - let gc_strategy = if self.stack_map_requested - && (crate::codegen::helpers::statepoints_enabled() - || crate::codegen::helpers::rs4gc_enabled()) - { - " gc \"statepoint-example\"" - } else { - "" - }; + let gc_strategy = + if self.stack_map_requested && crate::codegen::helpers::native_stack_roots_enabled() { + " gc \"statepoint-example\"" + } else { + "" + }; // Invoke-EH (#7302): functions containing landing/funclet pads name // their personality on the define line. LLVM's grammar orders these // `[fn attrs] [gc] [personality]`, so the strategy precedes it. @@ -730,17 +726,7 @@ impl LlFunction { // lazily-reserved scalar root and every call site is visible. // let ir = if self.stack_map_requested { - let backend = if crate::codegen::helpers::rs4gc_enabled() { - PreciseRootBackend::Rs4gc - } else { - // Not `StackMap`: that variant is gone. Both sites that set - // `stack_map_requested` are guarded by - // `native_stack_roots_enabled()`, which is exactly - // `statepoints_enabled() || rs4gc_enabled()`, so this branch - // is only reachable with statepoints on. - PreciseRootBackend::Statepoint - }; - lower_precise_roots_to_native_stack(&ir, &self.name, self.stack_map_slot_count, backend) + lower_precise_roots_to_native_stack(&ir, &self.name, self.stack_map_slot_count) } else { ir }; diff --git a/crates/perry-codegen/src/function/precise_roots.rs b/crates/perry-codegen/src/function/precise_roots.rs index 401155b1cf..bbd748f8b8 100644 --- a/crates/perry-codegen/src/function/precise_roots.rs +++ b/crates/perry-codegen/src/function/precise_roots.rs @@ -27,178 +27,6 @@ fn parse_shadow_set(line: &str) -> Option<(usize, String)> { /// Compute a conservative set of active logical shadow slots before each IR /// line. Joins use union ("active on any incoming path"), so a stale local can /// be retained but a live root cannot be omitted. -fn stack_map_active_slots( - lines: &[&str], - slot_count: u32, -) -> Vec>> { - use std::collections::{HashMap, HashSet, VecDeque}; - - #[derive(Debug)] - struct Block { - first_line: usize, - end_line: usize, - successors: Vec, - } - - fn label_name(line: &str) -> Option<&str> { - if line.starts_with(char::is_whitespace) { - return None; - } - line.strip_suffix(':') - .filter(|name| !name.is_empty() && !name.starts_with(';')) - } - - fn referenced_labels(line: &str) -> Vec<&str> { - let mut labels = Vec::new(); - let mut rest = line; - while let Some(pos) = rest.find("label %") { - let after = &rest[pos + "label %".len()..]; - let len = after - .bytes() - .take_while(|byte| { - byte.is_ascii_alphanumeric() || matches!(byte, b'.' | b'_' | b'-' | b'$') - }) - .count(); - if len == 0 { - break; - } - labels.push(&after[..len]); - rest = &after[len..]; - } - labels - } - - let labels: Vec<(usize, &str)> = lines - .iter() - .enumerate() - .filter_map(|(idx, line)| label_name(line).map(|name| (idx, name))) - .collect(); - let mut states = vec![None; lines.len()]; - if labels.is_empty() { - return states; - } - - let label_to_block: HashMap<&str, usize> = labels - .iter() - .enumerate() - .map(|(block, (_, name))| (*name, block)) - .collect(); - let mut blocks: Vec = labels - .iter() - .enumerate() - .map(|(block, (label_line, _))| Block { - first_line: label_line + 1, - end_line: labels - .get(block + 1) - .map_or(lines.len(), |(next_line, _)| *next_line), - successors: Vec::new(), - }) - .collect(); - for block in &mut blocks { - let mut seen = HashSet::new(); - for line in &lines[block.first_line..block.end_line] { - for label in referenced_labels(line) { - if let Some(&successor) = label_to_block.get(label) { - if seen.insert(successor) { - block.successors.push(successor); - } - } - } - } - } - - fn apply_root_op(state: &mut HashSet, line: &str, slot_count: u32) { - if let Some((idx, _)) = parse_shadow_bind(line) { - if idx < slot_count as usize { - state.insert(idx); - } - } else if let Some((idx, value)) = parse_shadow_set(line) { - if idx < slot_count as usize { - if value == "0" { - state.remove(&idx); - } else { - state.insert(idx); - } - } - } - } - - let mut entries: Vec>> = vec![None; blocks.len()]; - entries[0] = Some(HashSet::new()); - let mut work = VecDeque::from([0usize]); - while let Some(block_idx) = work.pop_front() { - let Some(mut state) = entries[block_idx].clone() else { - continue; - }; - let block = &blocks[block_idx]; - for line in &lines[block.first_line..block.end_line] { - apply_root_op(&mut state, line, slot_count); - } - for &successor in &block.successors { - let changed = match &mut entries[successor] { - Some(existing) => { - let old_len = existing.len(); - existing.extend(state.iter().copied()); - existing.len() != old_len - } - entry @ None => { - *entry = Some(state.clone()); - true - } - }; - if changed { - work.push_back(successor); - } - } - } - - for (block_idx, block) in blocks.iter().enumerate() { - let Some(mut state) = entries[block_idx].clone() else { - continue; - }; - for (line_idx, line) in lines - .iter() - .enumerate() - .take(block.end_line) - .skip(block.first_line) - { - states[line_idx] = Some(state.clone()); - apply_root_op(&mut state, line, slot_count); - } - } - states -} - -#[derive(Clone, Copy, Debug, Eq, PartialEq)] -pub(super) enum PreciseRootBackend { - Statepoint, - /// `PERRY_RS4GC=1` (#7174): retype every root alloca to - /// `ptr addrspace(1)` with cast surgery at its load/store sites, tag the - /// function `gc "statepoint-example"`, mark audited non-collecting - /// callees `"gc-leaf-function"` at the call site, and emit NO per-call - /// safepoint machinery — `opt -passes='function(mem2reg), - /// rewrite-statepoints-for-gc'` promotes the allocas to SSA and inserts - /// every statepoint, relocation, and downstream-use rewrite itself. - /// After mem2reg, each former load site is a cast site, which is exactly - /// the placement RS4GC needs to rewrite uses with relocated values. - /// Fail-closed: any use of a root alloca outside the recognized - /// load/store shapes bails the whole function to the Statepoint backend. - Rs4gc, -} - -impl PreciseRootBackend { - fn as_str(self) -> &'static str { - match self { - Self::Statepoint => "statepoint", - Self::Rs4gc => "rs4gc", - } - } -} - -/// RS4GC surgery (#7174): retype root allocas to `ptr addrspace(1)` and cast -/// at every recognized load/store site. Returns `None` when any root alloca -/// appears in an unrecognized shape (the caller falls back to the explicit -/// statepoint backend for the whole function). fn lower_roots_for_rs4gc(lines: &[&str], root_ptrs: &[String]) -> Option { let roots: std::collections::HashSet<&str> = root_ptrs.iter().map(String::as_str).collect(); let mut out = String::with_capacity(lines.len() * 48 + root_ptrs.len() * 96); @@ -336,118 +164,6 @@ fn lower_roots_for_rs4gc(lines: &[&str], root_ptrs: &[String]) -> Option Some(out) } -#[derive(Debug, Eq, PartialEq)] -struct DirectCall<'a> { - result: Option<&'a str>, - return_type: &'a str, - callee: &'a str, - args: Vec<&'a str>, - arg_types: Vec<&'a str>, -} - -fn split_call_args(args: &str) -> Option> { - if args.trim().is_empty() { - return Some(Vec::new()); - } - let mut out = Vec::new(); - let mut depth = 0i32; - let mut start = 0usize; - for (idx, ch) in args.char_indices() { - match ch { - '(' | '[' | '{' | '<' => depth += 1, - ')' | ']' | '}' | '>' => { - depth -= 1; - if depth < 0 { - return None; - } - } - ',' if depth == 0 => { - out.push(args[start..idx].trim()); - start = idx + 1; - } - _ => {} - } - } - if depth != 0 { - return None; - } - out.push(args[start..].trim()); - Some(out) -} - -fn statepoint_scalar_type(arg: &str) -> Option<&str> { - let ty = arg.split_ascii_whitespace().next()?; - matches!( - ty, - "i1" | "i8" | "i16" | "i32" | "i64" | "i128" | "float" | "double" | "ptr" - ) - .then_some(ty) -} - -/// Parse the deliberately small direct-call subset emitted by `LlBlock`. -/// -/// Calls with tail markers, operand attributes, aggregate types, inline asm, -/// indirect targets, or call-site suffixes stay on the plain stack-map -/// fallback. That keeps the research mode correct while making its explicit -/// statepoint coverage measurable and easy to expand. -fn parse_direct_statepoint_call(line: &str) -> Option> { - let trimmed = line.trim(); - let (result, call) = if let Some(call) = trimmed.strip_prefix("call ") { - (None, call) - } else { - let (result, call) = trimmed.split_once(" = call ")?; - (Some(result.trim()), call) - }; - let (return_type, target_and_args) = call.split_once(' ')?; - if !matches!( - return_type, - "void" | "i1" | "i8" | "i16" | "i32" | "i64" | "i128" | "float" | "double" | "ptr" - ) { - return None; - } - if return_type != "void" && result.is_none() { - return None; - } - let open = target_and_args.find('(')?; - let close = target_and_args.rfind(')')?; - if close + 1 != target_and_args.len() { - return None; - } - let callee = target_and_args[..open].trim(); - // Indirect targets are statepoint-able: `gc.statepoint` takes the callee as - // a `ptr` operand, and `emit_statepoint` interpolates it verbatim, so - // `ptr elementtype(T) %fnptr` is as valid as `... @callee`. Rejecting them - // was a limitation of this textual parser, not of statepoints — and the - // fallback it forced is the unsound plain stack map. An unknown callee - // simply cannot be audited as non-collecting, which is the conservative - // (correct) answer anyway. - let direct = callee.starts_with('@'); - let indirect = callee.starts_with('%'); - if !(direct || indirect) - || callee.starts_with("@llvm.") - || matches!(callee, "@setjmp" | "@_setjmp" | "@longjmp" | "@_longjmp") - { - return None; - } - let args = split_call_args(&target_and_args[open + 1..close])?; - let arg_types = args - .iter() - .map(|arg| statepoint_scalar_type(arg)) - .collect::>>()?; - Some(DirectCall { - result, - return_type, - callee, - args, - arg_types, - }) -} - -/// Return a direct callee name without the leading `@`. -/// -/// This accepts more call syntax than the statepoint parser because the -/// GC-effect audit only needs to recognize a direct target. Unsupported and -/// indirect forms return `None` and therefore stay conservative. fn direct_callee_name(line: &str) -> Option<&str> { let call = line.trim().split_once("call ")?.1; let args_open = call.find('(')?; @@ -460,111 +176,26 @@ fn direct_callee_name(line: &str) -> Option<&str> { .then_some(name) } -fn gc_result_suffix(ty: &str) -> Option<&'static str> { - match ty { - "i1" => Some("i1"), - "i8" => Some("i8"), - "i16" => Some("i16"), - "i32" => Some("i32"), - "i64" => Some("i64"), - "i128" => Some("i128"), - "float" => Some("f32"), - "double" => Some("f64"), - "ptr" => Some("p0"), - _ => None, - } -} - -/// Emit one explicit statepoint relocation sequence. -/// -/// Perry roots remain ordinary NaN-boxed `i64` values everywhere else. At -/// this boundary we load each live word, carry its exact bits through a -/// temporary addrspace(1) pointer, and convert the `gc.relocate` result back -/// into the existing slot. LLVM therefore owns the spill/reload and the -/// post-safepoint SSA transition without requiring a whole-program -/// representation change for this prototype. -fn emit_statepoint(out: &mut String, call: &DirectCall<'_>, live: &[&String], statepoint_id: u64) { - for (root_idx, ptr) in live.iter().enumerate() { - out.push_str(&format!( - " %perry_sp_bits_{statepoint_id}_{root_idx} = load i64, ptr {ptr}\n" - )); - out.push_str(&format!( - " %perry_sp_root_{statepoint_id}_{root_idx} = inttoptr i64 \ - %perry_sp_bits_{statepoint_id}_{root_idx} to ptr addrspace(1)\n" - )); - } - - let function_type = format!("{} ({})", call.return_type, call.arg_types.join(", ")); - let call_args = call - .args - .iter() - .map(|arg| format!(", {arg}")) - .collect::(); - let gc_live = live - .iter() - .enumerate() - .map(|(root_idx, _)| format!("ptr addrspace(1) %perry_sp_root_{statepoint_id}_{root_idx}")) - .collect::>() - .join(", "); - out.push_str(&format!( - " %perry_sp_token_{statepoint_id} = call token (i64, i32, ptr, i32, i32, ...) \ - @llvm.experimental.gc.statepoint.p0(i64 {statepoint_id}, i32 0, \ - ptr elementtype({function_type}) {}, i32 {}, i32 0{call_args}, i32 0, i32 0) \ - [\"gc-live\"({gc_live})]\n", - call.callee, - call.args.len() - )); - - if let Some(result) = call.result { - let suffix = gc_result_suffix(call.return_type) - .expect("non-void statepoint return type was validated by the parser"); - out.push_str(&format!( - " {result} = call {} @llvm.experimental.gc.result.{suffix}(token \ - %perry_sp_token_{statepoint_id})\n", - call.return_type - )); - } - - for (root_idx, ptr) in live.iter().enumerate() { - out.push_str(&format!( - " %perry_sp_relocated_{statepoint_id}_{root_idx} = call ptr addrspace(1) \ - @llvm.experimental.gc.relocate.p1(token %perry_sp_token_{statepoint_id}, \ - i32 {root_idx}, i32 {root_idx})\n" - )); - out.push_str(&format!( - " %perry_sp_relocated_bits_{statepoint_id}_{root_idx} = ptrtoint \ - ptr addrspace(1) %perry_sp_relocated_{statepoint_id}_{root_idx} to i64\n" - )); - out.push_str(&format!( - " store i64 %perry_sp_relocated_bits_{statepoint_id}_{root_idx}, ptr {ptr}\n" - )); - } -} - -/// Lower Perry's existing precise-root operations to native-stack metadata. -/// -/// The old binding calls already name exactly the mutable native alloca that a -/// moving collection must rewrite. We use them as compile-time markers: +/// Lower this function's shadow-slot binding IR into RS4GC's input form. /// -/// * collect `logical slot -> native alloca`; -/// * remove the runtime bind calls and shadow-frame traffic; -/// * compute conservative per-call liveness from bind/clear markers without -/// mutating the native slot; -/// * either place a plain stack map before a call, or replace a supported call -/// with a statepoint/result/relocate sequence. +/// There is one native-root backend. The explicit statepoint bridge — Perry +/// rewriting its own IR text into `gc.statepoint` calls with hand-emitted +/// relocations — is gone. It was strictly weaker than RS4GC (it could not root +/// an `invoke`, so it refused try-carrying functions outright) and it survived +/// only as this path's fallback. Measured before removing it: **1,574 functions +/// across `test-drizzle-pg` and the gc-ratchet probes all lowered as `rs4gc`, +/// none fell back.** A fallback nothing takes is an untested configuration, +/// which is exactly what the GC knob kill-policy exists to prevent. /// -/// Statepoint mode deliberately retains a plain-stack-map fallback for call -/// forms outside the narrow parser above. The fallback preserves correctness -/// while the report records how much of real Perry code reaches the explicit -/// relocation path. +/// A bail is therefore a hard failure, not a silent downgrade: if the +/// recognizer meets a root-alloca use it does not understand, anything else +/// would emit a frame whose roots the collector cannot find. pub(super) fn lower_precise_roots_to_native_stack( ir: &str, function_name: &str, slot_count: u32, - backend: PreciseRootBackend, ) -> String { let lines: Vec<&str> = ir.lines().collect(); - let active_slots = stack_map_active_slots(&lines, slot_count); let mut roots: Vec> = vec![None; slot_count as usize]; for line in &lines { if let Some((idx, ptr)) = parse_shadow_bind(line) { @@ -582,262 +213,40 @@ pub(super) fn lower_precise_roots_to_native_stack( } } - let slot_roots = roots; - let root_ptrs: Vec = slot_roots.iter().flatten().cloned().collect(); - let mut report = crate::statepoint_report::enabled().then(|| { + let root_ptrs: Vec = roots.iter().flatten().cloned().collect(); + let report = crate::statepoint_report::enabled().then(|| { crate::statepoint_report::FunctionRecord::new( function_name, - backend.as_str(), + "rs4gc", slot_count, root_ptrs.len(), ) }); - // RS4GC runs BEFORE the empty-roots early return on purpose: a function - // can reserve slots (so it carries `gc "statepoint-example"`) yet bind - // none, and it still contains inline asm that RS4GC would rewrite into an - // invalid statepoint. Found on the Claude Code bundle, where the early - // return skipped leaf-marking and the verifier aborted with "Cannot take - // the address of an inline asm!". - if backend == PreciseRootBackend::Rs4gc { - if let Some(out) = lower_roots_for_rs4gc(&lines, &root_ptrs) { + + // Runs BEFORE any empty-roots early return on purpose: a function can + // reserve slots (so it carries `gc "statepoint-example"`) yet bind none, + // and still contain inline asm that RS4GC would rewrite into an invalid + // statepoint. Found on the Claude Code bundle, where an early return + // skipped leaf-marking and the verifier aborted with "Cannot take the + // address of an inline asm!". + match lower_roots_for_rs4gc(&lines, &root_ptrs) { + Some(out) => { if let Some(mut report) = report { report.note_call(root_ptrs.len()); crate::statepoint_report::record(report); } - return out; - } - return lower_precise_roots_to_native_stack( - ir, - function_name, - slot_count, - PreciseRootBackend::Statepoint, - ); - } - - if root_ptrs.is_empty() { - let out = ir - .lines() - .filter(|line| parse_shadow_bind(line).is_none() && parse_shadow_set(line).is_none()) - .map(|line| format!("{line}\n")) - .collect(); - if let Some(report) = report { - crate::statepoint_report::record(report); - } - return out; - } - - let mut out = String::with_capacity(ir.len() + root_ptrs.len() * 128); - let mut available = std::collections::HashSet::::new(); - let mut initialized = std::collections::HashSet::::new(); - let mut map_id = 0u64; - - for (line_idx, line) in lines.iter().enumerate() { - if parse_shadow_bind(line).is_some() { - // Compile-time marker only. The real slot is already populated by - // the local store immediately preceding this old bind. - continue; - } - if parse_shadow_set(line).is_some() { - // This marker changes stack-map liveness, not the program local. - // Shadow-stack clears only flipped SLOT_ACTIVE for the same - // reason: a value can be semantically read after its final - // GC-capable call. - continue; - } - - // A stack-map operand must dominate the intrinsic. Root allocas are - // normally entry-hoisted, but tracking definitions here also handles - // the few block-local scalar-replacement slots without emitting - // invalid SSA. - for ptr in &root_ptrs { - if line.trim_start().starts_with(&format!("{ptr} = ")) { - available.insert(ptr.clone()); - } - } - - out.push_str(line); - out.push('\n'); - - // Slots can be named by a stack map before their source-level `let` - // executes. Zero them directly after the alloca so an earlier - // safepoint never exposes uninitialized stack bytes as roots. - for ptr in &root_ptrs { - if available.contains(ptr) - && !initialized.contains(ptr) - && line.trim_start().starts_with(&format!("{ptr} = alloca ")) - { - out.push_str(&format!(" store i64 0, ptr {ptr}\n")); - initialized.insert(ptr.clone()); - } - } - - // Insert before calls, not after. Rebuild the tail when the line just - // appended is a call so the intrinsic's instruction offset is the - // actual call-site offset in the final machine function. - let trimmed = line.trim_start(); - let is_call = trimmed.starts_with("call ") - || trimmed.contains(" = call ") - || trimmed.starts_with("tail call ") - || trimmed.contains(" = tail call "); - // #7327: an `invoke` is a call with two successors. Since #7302 moved - // exception lowering to `invoke`/`landingpad`, EVERY call inside a - // `try` is one — and none matched `is_call`, so they skipped both the - // statepoint conversion and the fail-closed panic below, passing - // through as ordinary lines. Measured on one program: 58 invokes, 0 - // carrying `gc.statepoint`, with allocating callees among them - // (`js_object_alloc_class_inline_keys`, `js_array_push_f64`, - // `js_native_call_method_by_id`). Those frames had no roots at all, - // and `--statepoint-report` was silent because it only counts lines it - // recognises — "0 parser fallbacks" said nothing about any call inside - // a `try`. - // - // Forming a statepoint FROM an invoke is real work: the statepoint must - // itself become an invoke, with `gc.result` and the relocates in the - // normal successor. RS4GC already does it correctly. Until the bridge - // does, refuse — same fail-closed rule the plain-stackmap fallback was - // deleted for (#7314). - let is_invoke = trimmed.starts_with("invoke ") || trimmed.contains(" = invoke "); - if is_invoke && backend == PreciseRootBackend::Statepoint { - let active = active_slots.get(line_idx).and_then(Option::as_ref); - let live_here: Vec<&String> = slot_roots - .iter() - .enumerate() - .filter(|(idx, _)| active.is_some_and(|slots| slots.contains(idx))) - .filter_map(|(_, ptr)| ptr.as_ref()) - .filter(|ptr| available.contains(*ptr) && initialized.contains(*ptr)) - .collect(); - let callee = direct_callee_name(line); - let compiler_only = callee.is_some_and(|c| c.starts_with("llvm.")); - let cannot_collect = callee.is_some_and(|c| { - matches!( - crate::gc_call_effects::classify_direct_callee(c), - crate::gc_call_effects::GcCallEffect::CannotCollect - | crate::gc_call_effects::GcCallEffect::NeverReturns - ) - }); - if let Some(report) = report.as_mut() { - report.note_call(live_here.len()); - } - if !live_here.is_empty() && !compiler_only && !cannot_collect { - panic!( - "perry: native-root lowering cannot yet express a safepoint on an \ - `invoke` — `{}` in @{} has {} live root(s) across it. Since #7302 \ - every call inside a `try` is an invoke, so emitting it unchanged \ - would leave those roots invisible to the collector (#7327). \ - PERRY_RS4GC=1 handles invokes, but needs PERRY_LLVM_CLANG pointing \ - at a version-matched LLVM 22 (Apple clang rejects the IR it emits). \ - Otherwise compile this module without PERRY_STATEPOINTS.", - callee.unwrap_or(""), - function_name, - live_here.len(), - ); - } - } - if !is_call || trimmed.contains("@llvm.experimental.stackmap") { - continue; - } - let active = active_slots.get(line_idx).and_then(Option::as_ref); - let live: Vec<&String> = slot_roots - .iter() - .enumerate() - .filter(|(idx, _)| active.is_some_and(|slots| slots.contains(idx))) - .filter_map(|(_, ptr)| ptr.as_ref()) - .filter(|ptr| available.contains(*ptr) && initialized.contains(*ptr)) - .collect(); - if let Some(report) = report.as_mut() { - report.note_call(live.len()); + out } - if live.is_empty() { - continue; - } - - let direct_callee = direct_callee_name(line); - let is_compiler_only = direct_callee.is_some_and(|callee| callee.starts_with("llvm.")) - || trimmed.contains("call void asm "); - let cannot_collect = direct_callee.is_some_and(|callee| { - match crate::gc_call_effects::classify_direct_callee(callee) { - crate::gc_call_effects::GcCallEffect::CannotCollect => true, - // Control never returns here: no relocation is consumed and - // the frame's roots are dead past the call. Deeper frames - // carry their own records. - crate::gc_call_effects::GcCallEffect::NeverReturns => true, - // Under the explicit-safepoint contract the runtime - // guarantees these helpers' triggers never consume this - // frame's precise roots (they defer to a declared safepoint - // or collect behind a forced conservative scan), so the - // call site needs no metadata. Without the contract they - // stay safepoints. - crate::gc_call_effects::GcCallEffect::AllocNoReentry => { - crate::codegen::helpers::gc_safepoint_only_contract_enabled() - } - crate::gc_call_effects::GcCallEffect::Unknown => false, - } - }); - if is_compiler_only || cannot_collect { - // LLVM intrinsics, zero-instruction compiler barriers, and - // runtime helpers in the audited GC-effect table cannot enter - // Perry's allocator. Neither native-stack backend needs metadata - // around them. - if let Some(report) = report.as_mut() { - report.note_skipped(direct_callee.unwrap_or("")); - } - continue; - } - - // Move the call line behind the intrinsic. - let call_len = line.len() + 1; - out.truncate(out.len() - call_len); - if backend == PreciseRootBackend::Statepoint { - if let Some(call) = parse_direct_statepoint_call(line) { - emit_statepoint(&mut out, &call, &live, map_id); - if let Some(report) = report.as_mut() { - report.note_statepoint(call.callee.trim_start_matches('@'), live.len()); - } - map_id += 1; - continue; - } - } - // No statepoint could be formed for a call that has live roots. The - // old behaviour was to fall back to a plain `llvm.experimental.stackmap`, - // which is UNSOUND: LLVM may record a root slot's address as - // `Register R#N`, caller-saved and unrecoverable at collection time, - // so the collector silently misses that root. - // - // Measured on test-drizzle-pg (133 modules): 23,301 safepoints, ALL - // statepoints, 0 plain stack maps, 0 parser fallbacks. The path is not - // taken by real code, so failing closed costs nothing and removes the - // last way this backend can lose a root. A loud compile failure beats - // silent heap corruption. - panic!( - "perry: native-root lowering could not express a safepoint for \ - `{}` in @{} ({} live roots). Falling back to a plain stack map \ - here would record roots in caller-saved registers that the \ - collector cannot recover, so the compile stops instead. Report \ - this call shape on #7174.", - direct_callee.unwrap_or(""), + None => panic!( + "perry: native-root lowering could not recognise a root-alloca use in @{} \ + ({} root slots). This used to fall back to the explicit statepoint bridge, \ + which is gone; emitting anything else would produce a frame whose roots the \ + collector cannot find. Report the function shape on #7174.", function_name, - live.len(), - ); - } - if let Some(report) = report { - crate::statepoint_report::record(report); + root_ptrs.len(), + ), } - out } - -/// Retype Itanium landing pads to `token` for `statepoint-example`. -/// -/// RS4GC uses the unwind destination's landing pad **as the token** for the -/// relocates it inserts on the exceptional edge, so the pad must already be -/// `landingpad token`. Given `{ ptr, i32 }` it emits -/// `gc.relocate({ ptr, i32 } %lpad, ...)` and the verifier rejects the module, -/// which is why a try-carrying function failed to compile under RS4GC at all. -/// -/// This is only sound because the pad's value is **dead**: `try_stmt` emits it -/// to anchor the edge and branches straight on, taking the exception from the -/// runtime rather than the pad payload. So a pad whose register IS referenced -/// is left alone — retyping a value someone reads would swap a silent -/// miscompile for the loud one this fixes. pub(super) fn retype_landing_pads_for_statepoints(ir: &str) -> String { const ITANIUM: &str = "landingpad { ptr, i32 } catch ptr null"; if !ir.contains(ITANIUM) { @@ -922,232 +331,4 @@ mod stack_map_tests { assert!(super::mentions_register(" br label %r2", "%r2")); assert!(!super::mentions_register(" %x = add i64 %r21, 1", "%r2")); } - use super::{ - direct_callee_name, lower_precise_roots_to_native_stack, parse_direct_statepoint_call, - PreciseRootBackend, - }; - - fn lower_statepoints(input: &str, slots: u32) -> String { - lower_precise_roots_to_native_stack(input, "probe", slots, PreciseRootBackend::Statepoint) - } - - #[test] - fn lowers_bind_and_liveness_clear_to_native_frame_maps() { - let input = r#"define i64 @probe(i64 %arg) { -entry.0: - %r0 = alloca i64 - store i64 %arg, ptr %r0 - call void @js_shadow_slot_bind(i32 0, ptr %r0) - %r1 = call i64 @may_collect() - call void @js_shadow_slot_set(i32 0, i64 0) - call void @may_collect_again() - ret i64 %r1 -} -"#; - let output = lower_statepoints(input, 1); - assert!(!output.contains("@js_shadow_slot_bind")); - assert!(!output.contains("@js_shadow_slot_set")); - assert!(output.contains("%r0 = alloca i64\n store i64 0, ptr %r0")); - assert!( - output.contains("@llvm.experimental.gc.statepoint.p0"), - "the collecting call must become a statepoint:\n{output}" - ); - assert!( - output.contains("%r0"), - "the root slot must appear in the statepoint's live list:\n{output}" - ); - assert_eq!(output.matches("store i64 0, ptr %r0").count(), 1); - assert_eq!( - output - .matches("@llvm.experimental.gc.statepoint.p0") - .count(), - 1 - ); - assert!(output.contains("call void @may_collect_again()")); - } - - #[test] - fn does_not_reference_a_root_before_its_alloca_dominates() { - let input = r#"define void @probe() { -entry.0: - call void @early_call() - %r0 = alloca i64 - call void @js_shadow_slot_bind(i32 0, ptr %r0) - call void @late_call() - ret void -} -"#; - let output = lower_statepoints(input, 1); - let early = output.find("call void @early_call()").unwrap(); - let first_map = output.find("@llvm.experimental.gc.statepoint.p0").unwrap(); - assert!( - early < first_map, - "no safepoint may reference a root before its alloca dominates:\n{output}" - ); - assert!( - output.contains("@late_call"), - "the dominated call must still be mapped:\n{output}" - ); - } - - #[test] - fn unions_root_liveness_at_control_flow_joins() { - let input = r#"define void @probe(i1 %cond) { -entry.0: - %r0 = alloca i64 - call void @js_shadow_slot_bind(i32 0, ptr %r0) - br i1 %cond, label %live.1, label %dead.2 -live.1: - call void @live_call() - br label %merge.3 -dead.2: - call void @js_shadow_slot_set(i32 0, i64 0) - call void @dead_call() - br label %merge.3 -merge.3: - call void @merge_call() - ret void -} -"#; - let output = lower_statepoints(input, 1); - assert!(output.contains("@llvm.experimental.gc.statepoint.p0")); - assert!(!output.contains("@dead_call, ptr %r0")); - assert!(output.contains("@merge_call")); - } - - #[test] - fn parses_the_scalar_direct_call_subset() { - assert_eq!( - direct_callee_name(" %r7 = call double @foo(i64 %r1, ptr %r2)"), - Some("foo") - ); - assert_eq!( - direct_callee_name(" %r7 = call i64 ()* %fn()"), - None, - "an indirect target must not be inferred from its arguments" - ); - assert_eq!( - parse_direct_statepoint_call(" %r7 = call double @foo(i64 %r1, ptr %r2)"), - Some(super::DirectCall { - result: Some("%r7"), - return_type: "double", - callee: "@foo", - args: vec!["i64 %r1", "ptr %r2"], - arg_types: vec!["i64", "ptr"], - }) - ); - assert!(parse_direct_statepoint_call( - " %r7 = call double (i64, ptr)* %fn(i64 %r1, ptr %r2)" - ) - .is_none()); - assert!(parse_direct_statepoint_call(" call void @llvm.assume(i1 %ok)").is_none()); - assert!(parse_direct_statepoint_call(" %r7 = tail call i64 @foo()").is_none()); - } - - #[test] - fn lowers_direct_calls_to_explicit_statepoint_relocations() { - let input = r#"define i64 @probe(i64 %arg) { -entry.0: - %r0 = alloca i64 - store i64 %arg, ptr %r0 - call void @js_shadow_slot_bind(i32 0, ptr %r0) - %r1 = call i64 @may_collect(i64 %arg) - ret i64 %r1 -} -"#; - let output = lower_statepoints(input, 1); - assert!(!output.contains("call i64 @may_collect")); - assert!(!output.contains("asm sideeffect")); - assert!(output - .contains("%perry_sp_root_0_0 = inttoptr i64 %perry_sp_bits_0_0 to ptr addrspace(1)")); - assert!(output.contains( - "ptr elementtype(i64 (i64)) @may_collect, i32 1, i32 0, i64 %arg, i32 0, i32 0" - )); - assert!(output - .contains("%r1 = call i64 @llvm.experimental.gc.result.i64(token %perry_sp_token_0)")); - assert!(output - .contains("@llvm.experimental.gc.relocate.p1(token %perry_sp_token_0, i32 0, i32 0)")); - assert!(output.contains("store i64 %perry_sp_relocated_bits_0_0, ptr %r0")); - } - - #[test] - fn statepoint_mode_maps_indirect_calls() { - // An indirect call used to fall back to a plain stack map, which is the - // unsound lowering: LLVM may record the root's address in a - // caller-saved register. `gc.statepoint` takes its callee as a `ptr` - // operand, so an indirect target is expressible — the restriction was - // in this textual parser, not in statepoints. - let input = r#"define i64 @probe(i64 %arg, ptr %fn) { -entry.0: - %r0 = alloca i64 - store i64 %arg, ptr %r0 - call void @js_shadow_slot_bind(i32 0, ptr %r0) - %r1 = call i64 %fn() - ret i64 %r1 -} -"#; - let output = lower_statepoints(input, 1); - assert!( - output.contains("@llvm.experimental.gc.statepoint.p0"), - "an indirect call with live roots must become a statepoint:\n{output}" - ); - assert!( - output.contains("%fn"), - "the indirect target must survive as the statepoint callee:\n{output}" - ); - assert!( - !output.contains("@llvm.experimental.stackmap"), - "no plain (unsound) stack map may remain:\n{output}" - ); - } - - #[test] - fn statepoint_mode_does_not_map_non_allocating_llvm_intrinsics() { - let input = r#"define void @probe(i64 %arg, i1 %condition) { -entry.0: - %r0 = alloca i64 - store i64 %arg, ptr %r0 - call void @js_shadow_slot_bind(i32 0, ptr %r0) - call void @llvm.assume(i1 %condition) - call void @may_collect() - ret void -} -"#; - let output = lower_statepoints(input, 1); - assert!(output.contains("call void @llvm.assume(i1 %condition)")); - assert_eq!( - output - .matches("@llvm.experimental.gc.statepoint.p0") - .count(), - 1 - ); - assert!(!output.contains("@llvm.experimental.stackmap")); - } - - #[test] - fn audited_non_collecting_helpers_are_not_safepoints_in_either_backend() { - let input = r#"define void @probe(i64 %arg) { -entry.0: - %r0 = alloca i64 - store i64 %arg, ptr %r0 - call void @js_shadow_slot_bind(i32 0, ptr %r0) - call void @js_gc_temp_root_push(i64 %arg) - call void @js_write_barrier_root_nanbox(i64 %arg) - call void @js_gc_loop_safepoint() - ret void -} -"#; - for output in [lower_statepoints(input, 1)] { - assert!(output.contains("call void @js_gc_temp_root_push(i64 %arg)")); - assert!(output.contains("call void @js_write_barrier_root_nanbox(i64 %arg)")); - assert_eq!( - output.matches("@llvm.experimental.stackmap").count() - + output - .matches("@llvm.experimental.gc.statepoint.p0") - .count(), - 1, - "only the explicit collection boundary should be a safepoint:\n{output}" - ); - } - } } diff --git a/crates/perry-codegen/src/linker.rs b/crates/perry-codegen/src/linker.rs index 847bccc422..467deb74a5 100644 --- a/crates/perry-codegen/src/linker.rs +++ b/crates/perry-codegen/src/linker.rs @@ -405,8 +405,7 @@ fn build_clang_compile_plan( // the statepoint backends emit a stack map, so only they pay for it, and // the cost is small: `-S` takes the same time as `-c` (codegen is the // cost, printing text is free) and assembling is ~0.02s per module. - let compact_gc_map = - crate::codegen::helpers::statepoints_enabled() || crate::codegen::helpers::rs4gc_enabled(); + let compact_gc_map = crate::codegen::helpers::native_stack_roots_enabled(); let asm_path = compact_gc_map.then(|| PathBuf::from(format!("{}.s", obj_path.display()))); let mut clang_args = vec![ diff --git a/crates/perry-codegen/src/module.rs b/crates/perry-codegen/src/module.rs index 419ae18bfe..faba2ba28f 100644 --- a/crates/perry-codegen/src/module.rs +++ b/crates/perry-codegen/src/module.rs @@ -648,7 +648,7 @@ impl LlModule { if crate::codegen::helpers::native_stack_roots_enabled() { ir.push_str("declare void @llvm.experimental.stackmap(i64, i32, ...)\n"); } - if crate::codegen::helpers::statepoints_enabled() { + if crate::codegen::helpers::native_stack_roots_enabled() { push_statepoint_declarations(&mut ir); } ir.push('\n'); @@ -920,7 +920,7 @@ impl LlModule { if crate::codegen::helpers::native_stack_roots_enabled() { pre.push_str("declare void @llvm.experimental.stackmap(i64, i32, ...)\n"); } - if crate::codegen::helpers::statepoints_enabled() { + if crate::codegen::helpers::native_stack_roots_enabled() { push_statepoint_declarations(&mut pre); } pre.push('\n'); diff --git a/crates/perry/src/commands/compile/build_cache.rs b/crates/perry/src/commands/compile/build_cache.rs index d917284cc5..9e534f12b3 100644 --- a/crates/perry/src/commands/compile/build_cache.rs +++ b/crates/perry/src/commands/compile/build_cache.rs @@ -38,7 +38,6 @@ const BUILD_CACHE_ENV_VARS: &[&str] = &[ "PERRY_LLVM_INPROCESS", "PERRY_WRITE_BARRIERS", "PERRY_SHADOW_STACK", - "PERRY_STATEPOINTS", "PERRY_RS4GC", "PERRY_GC_SAFEPOINT_ONLY", "PERRY_INLINE_SHADOW_SLOT", diff --git a/crates/perry/src/commands/compile/object_cache.rs b/crates/perry/src/commands/compile/object_cache.rs index 10e5c8615e..9428ccaa67 100644 --- a/crates/perry/src/commands/compile/object_cache.rs +++ b/crates/perry/src/commands/compile/object_cache.rs @@ -230,7 +230,6 @@ fn stable_type_key(ty: &perry_hir::types::Type) -> String { /// at compile time but that aren't part of `CompileOptions`: /// `PERRY_DEBUG_INIT`, `PERRY_DEBUG_SYMBOLS`, `PERRY_LLVM_CLANG`, /// `PERRY_WRITE_BARRIERS`, `PERRY_SHADOW_STACK`, -/// `PERRY_STATEPOINTS`, /// `PERRY_DISABLE_BUFFER_FAST_PATH`, `PERRY_VERIFY_NATIVE_REGIONS`, /// `PERRY_UNBOXED_OBJECT_FIELDS`, and `PERRY_TARGET_CPU`. See the env-var /// block at the bottom of this function for the rationale. @@ -806,10 +805,6 @@ fn compute_object_cache_key_with_env( "env_shadow_stack", env_var("PERRY_SHADOW_STACK").as_deref().unwrap_or(""), ); - h.field( - "env_statepoints", - env_var("PERRY_STATEPOINTS").as_deref().unwrap_or(""), - ); h.field("env_rs4gc", env_var("PERRY_RS4GC").as_deref().unwrap_or("")); // Explicit-safepoint contract: flips audited AllocNoReentry helpers // between statepoint and plain call. Two arms sharing a cached object diff --git a/crates/perry/src/commands/compile/types.rs b/crates/perry/src/commands/compile/types.rs index 0f266205a2..a89ecdfa6e 100644 --- a/crates/perry/src/commands/compile/types.rs +++ b/crates/perry/src/commands/compile/types.rs @@ -500,7 +500,7 @@ pub struct CompileArgs { /// cannot collect, statepoint relocation counts, plain stack-map /// fallbacks, and the live-root-width distribution. /// - /// Useful with `PERRY_STATEPOINTS=1`. + /// Useful with `PERRY_RS4GC=1`. /// `--statepoint-report=json` emits a stable machine-readable schema. /// Observational only; cache reuse is disabled for the reporting run so /// codegen executes and produces records. diff --git a/docs/src/cli/flags.md b/docs/src/cli/flags.md index 60e31ae749..5d9f615f0e 100644 --- a/docs/src/cli/flags.md +++ b/docs/src/cli/flags.md @@ -103,7 +103,7 @@ accept either the `$perryfs/` virtual path or the embed-relative key. | `--no-codegen` | Skip the `package.json` `perry.codegen` build-time steps (also `PERRY_SKIP_CODEGEN=1`). See [Project Configuration](../getting-started/project-config.md) | | `--keep-intermediates` | Keep `.o` and `.asm` intermediate files | | `--opt-report[=json]` | Report which values Perry could **not** statically type, why, and whether you can fix it. Text by default; `--opt-report=json` emits a stable schema for tooling. Also settable via `PERRY_OPT_REPORT=1` | -| `--statepoint-report[=json]` | Report native-stack GC root pressure: calls with live roots, audited non-collecting calls omitted, relocations, plain-map fallbacks, and live-root widths. Research-only; requires `PERRY_STATEPOINTS=1` or `PERRY_RS4GC=1` (the plain stack-map mode it also named is gone) | +| `--statepoint-report[=json]` | Report native-stack GC root pressure: calls with live roots, audited non-collecting calls omitted, relocations, plain-map fallbacks, and live-root widths. Research-only; requires `PERRY_RS4GC=1`, the one native-root backend (the plain stack-map and explicit-bridge modes it also named are gone) | The `--trace`/`--focus` pair localizes "compiled to the wrong thing" bugs: `perry compile foo.ts --trace hir,llvm --focus parseRow` dumps just the