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 7aa9ac1492..c74284b70c 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); @@ -335,118 +163,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('(')?; @@ -459,111 +175,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) { @@ -581,257 +212,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; + out } - return lower_precise_roots_to_native_stack( - ir, + 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, - 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 - ) - }); - 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()); - } - 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, - // 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(""), - 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) { @@ -916,232 +330,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