Skip to content

fix(gc): split the precise-root analysis from its lowering - #7340

Merged
proggeramlug merged 4 commits into
mainfrom
fix/7326-decouple-root-analysis
Aug 4, 2026
Merged

fix(gc): split the precise-root analysis from its lowering#7340
proggeramlug merged 4 commits into
mainfrom
fix/7326-decouple-root-analysis

Conversation

@proggeramlug

@proggeramlug proggeramlug commented Aug 3, 2026

Copy link
Copy Markdown
Contributor

Closes #7326. Supersedes the #7332 stopgap.

The conflation

Two separable questions, one knob:

  1. Which locals hold GC pointers, and where must each stay live? That is the
    analysis. It is backend-independent.
  2. How is that answer represented in emitted code — Perry's heap-backed
    shadow frame, or a native stack map? That is the lowering.

LlFunction already chose the lowering independently: enable_shadow_frame_inner
and reserve_shadow_slot both take the native path first under
native_stack_roots_enabled(). But all eight sites that build the slot map
gated on shadow_stack_enabled().

So PERRY_SHADOW_STACK=0 + PERRY_STATEPOINTS=1 switched the analysis off and
left the statepoint lowering with nothing to lower. The output was a binary with
no precise frame roots at all — no __perry_gcmap section, same size as a plain
shadow-off build, correct answers. Nothing about the run distinguished it from a
good build until a collection freed a live object. #7332 made the combination a
hard error as a stopgap, which was right at the time but leaves the underlying
coupling in place.

The change

Introduce precise_root_analysis_enabled() and route those eight sites through
it. Nothing else moves — the lowering selection was already where it belonged.

                        analysis   lowering        __perry_gcmap
default                 on         shadow          —
PERRY_SHADOW_STACK=0    off        none            —
PERRY_STATEPOINTS=1     on         native          yes
  + SHADOW_STACK=0      on         native          yes, identical

Why this matters beyond the one broken combination

A mode nobody can select is a mode nobody can measure. Deleting the
shadow-stack lowering is the direction this project has committed to, and the
first step is being able to express "roots on, shadow lowering off" so it can
be run, gated, and compared. Until now that spelling produced a rootless binary,
so nobody could have run it meaningfully — which is a large part of why the
question stayed open.

This PR does not delete anything or change any default. It makes the
configuration selectable and proves the split is real.

Measurement

On 01_nursery_churn, PERRY_STATEPOINTS=1 with and without
PERRY_SHADOW_STACK=0:

  • __perry_gcmap: 885 bytes, identical
  • __text: 2,234,533 lines, identical

And the knob keeps its own meaning: alone it still emits no root map, and it is
still observable against the default build (2,234,817 vs 2,234,450 lines of
__text — the lowering is the inline #7088 path, which is why no
js_shadow_frame_push call symbol appears in either).

Gating

New step in native-roots-aarch64, asserting all four rows above.

It compares the root map and __text, not the binary. Two runs of the same
configuration already differ byte-for-byte, because the build embeds a
PID-and-nonce scratch path — an end-to-end hash here would be a test that can
only fail. I hit exactly that while writing this and it is worth stating rather
than leaving for the next person.

The step also carries a vacuity check: if the default build and
PERRY_SHADOW_STACK=0 ever emit identical code, the probe roots nothing and the
comparison above proves nothing, so it fails rather than passing quietly. That is
CLAUDE.md's fourth hazard applied to this gate's own subject.

cargo test -p perry-codegen --lib: 601 passed, 0 failed.

Summary by CodeRabbit

  • New Features

    • Native stack-root builds now retain precise root analysis independently of shadow-stack lowering.
    • Statepoint builds produce consistent root maps and generated code regardless of the shadow-stack setting.
  • Bug Fixes

    • Disabling the shadow stack no longer prevents root discovery when native stack-root support is enabled.
    • Improved reliability of stack-root handling on x86-64 systems.
  • Documentation

    • Added release documentation describing updated root-analysis behavior and platform support.

Ralph Küpper added 2 commits August 3, 2026 23:01
One knob answered two questions. "Which locals hold GC pointers, and
where must each stay live" is the analysis and is backend-independent.
"Is that answer represented as a heap-backed shadow frame or a native
stack map" is the lowering, and LlFunction already chose it
independently -- enable_shadow_frame_inner and reserve_shadow_slot both
take the native path first.

But the eight sites that build the slot map all gated on
shadow_stack_enabled(), so PERRY_SHADOW_STACK=0 switched the ANALYSIS
off and left the statepoint lowering with nothing to lower. The result
was a binary with no precise frame roots at all: no __perry_gcmap
section, same size as a plain shadow-off build, correct output. Nothing
distinguished it from a good build until a collection freed a live
object. #7332 made the pair a hard error as a stopgap.

Route those eight sites through precise_root_analysis_enabled() instead
and the pair becomes expressible, which is what the stopgap was standing
in for. Measured on 01_nursery_churn: PERRY_STATEPOINTS=1 with and
without PERRY_SHADOW_STACK=0 now emit an identical 885-byte root map and
an identical __text. The knob keeps its own meaning on its own -- no
gcmap, and still observable against the default build.

A mode nobody can select is a mode nobody can measure, so this is the
prerequisite for the shadow-stack lowering ever being removed rather
than merely being switched off in one configuration nobody tests.
@coderabbitai

coderabbitai Bot commented Aug 3, 2026

Copy link
Copy Markdown

Review Change Stack

📝 Walkthrough

Walkthrough

This change decouples precise root analysis from shadow-stack lowering. Native stack-root builds keep root analysis enabled when PERRY_SHADOW_STACK=0. Code generation uses the new predicate, and CI compares root maps and generated code across configurations.

Changes

Native root analysis

Layer / File(s) Summary
Precise root analysis predicate
crates/perry-codegen/src/codegen/helpers.rs
Adds precise_root_analysis_enabled(). It enables analysis for shadow-stack or native stack-root backends and removes the incompatible configuration error.
Codegen root tracking integration
crates/perry-codegen/src/codegen/{closure,function,method}.rs``, crates/perry-codegen/src/codegen/helpers.rs`
Closure, function, module-init, instance-method, and static-method shadow-frame setup now uses precise root analysis.
Native-roots validation and adoption records
.github/workflows/gc-native-roots.yml, changelog.d/7340-decouple-root-analysis.md, docs/engine-plan.md
CI compares root maps and generated text across shadow-stack configurations. The changelog and engine plan record the validation results and x86-64 statepoint constraints.

Estimated code review effort: 3 (Moderate) | ~20 minutes

Sequence Diagram(s)

sequenceDiagram
  participant CI
  participant Codegen
  participant RootMapEmitter
  CI->>Codegen: Build with statepoints and shadow stack enabled
  CI->>Codegen: Build with statepoints and PERRY_SHADOW_STACK=0
  Codegen->>RootMapEmitter: Analyze precise roots and emit root maps
  RootMapEmitter-->>CI: Return root maps and generated text
  CI->>CI: Compare outputs and verify non-vacuity
Loading

Possibly related PRs

  • PerryTS/perry#7314: Advances the statepoint and native-stack-root implementation by decoupling root analysis from the lowering gate.
  • PerryTS/perry#7332: Changes the PERRY_SHADOW_STACK restriction for statepoint root analysis.
  • PerryTS/perry#7065: Modifies closure shadow-frame and root-tracking logic.
🚥 Pre-merge checks | ✅ 5
✅ Passed checks (5 passed)
Check name Status Explanation
Title check ✅ Passed The title clearly describes separating precise-root analysis from its lowering, which is the primary change.
Description check ✅ Passed The description explains the problem, implementation, measurements, gating, related issues, and test results, although it omits the template checklist.
Linked Issues check ✅ Passed The PR fixes the silent rootless statepoint configuration, preserves native lowering, and updates the plan with validation for the affected modes.
Out of Scope Changes check ✅ Passed The workflow, changelog, code, and plan updates directly support separating precise-root analysis from lowering and validating the corrected configurations.
Docstring Coverage ✅ Passed Docstring coverage is 100.00% which is sufficient. The required threshold is 80.00%.
✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch fix/7326-decouple-root-analysis

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🧹 Nitpick comments (1)
.github/workflows/gc-native-roots.yml (1)

157-212: 🩺 Stability & Availability | 🔵 Trivial | ⚡ Quick win

Add shadow-stack-off coverage to the RS4GC job.

native_stack_roots_enabled() = statepoints_enabled() || rs4gc_enabled(), and the changelog notes this silent-root-loss combination affects PERRY_RS4GC=1 as well. The current RS4GC matrix only runs PERRY_RS4GC=1 with forced evacuation; add the PERRY_RS4GC=1 PERRY_SHADOW_STACK=0 differential path that mirrors the statepoint step.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In @.github/workflows/gc-native-roots.yml around lines 157 - 212, Extend the
RS4GC job matrix with a differential run using PERRY_RS4GC=1 and
PERRY_SHADOW_STACK=0, mirroring the existing statepoint shadow-stack-off
coverage. Compare its generated root-map and emitted-text artifacts against the
corresponding RS4GC-enabled run, while preserving the existing forced-evacuation
coverage and assertions.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Nitpick comments:
In @.github/workflows/gc-native-roots.yml:
- Around line 157-212: Extend the RS4GC job matrix with a differential run using
PERRY_RS4GC=1 and PERRY_SHADOW_STACK=0, mirroring the existing statepoint
shadow-stack-off coverage. Compare its generated root-map and emitted-text
artifacts against the corresponding RS4GC-enabled run, while preserving the
existing forced-evacuation coverage and assertions.

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro Plus

Run ID: 863159e0-ca25-4d89-80ad-1bff09a7a0b7

📥 Commits

Reviewing files that changed from the base of the PR and between 480aab9 and d3e4661.

📒 Files selected for processing (6)
  • .github/workflows/gc-native-roots.yml
  • changelog.d/7340-decouple-root-analysis.md
  • crates/perry-codegen/src/codegen/closure.rs
  • crates/perry-codegen/src/codegen/function.rs
  • crates/perry-codegen/src/codegen/helpers.rs
  • crates/perry-codegen/src/codegen/method.rs

…echanism

Two corrections and one measurement.

The gap suite re-run against RS4GC in-process, two arms per test
(shadow-stack control + RS4GC), 479/479: 447 pass->pass, 19 pre-existing
diffs unchanged, 13 node_fail, ZERO new regressions, ZERO refusals, ZERO
compile failures. Zero refusals is the load-bearing number -- 128 of the
479 tests contain `try {}` and the bridge cannot compile any of them.

The earlier soak's "13 regressions, do not flip" was measured against
the bridge, before #7329/#7330, on a backend that structurally cannot
compile a quarter of the suite. It should not be carried forward.

And the x86-64 mechanism was wrong. The workflow comment claimed
_Unwind_GetGR(ctx, 7) "does not reliably return the stack pointer".
Measured on x86-64 Linux (glibc 2.39, gcc 13.3.0): it SEGFAULTS. RBX,
RBP and RIP return correctly; RAX and RSP both SIGSEGV, because libgcc
tracks only the columns CFI restores and RSP is derived from the CFA
rather than tracked. The fault is in the call itself, so no address
validation after it can help -- the previous wording pointed at the
wrong fix. Details and a reproducer in #7333.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 2

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@docs/engine-plan.md`:
- Around line 206-213: Update the “On aarch64” section in the RS4GC
default-readiness discussion to classify the x86-64 `#7333` blocker as a
correctness and stability issue, explicitly distinguishing the absence of new
aarch64 regressions from the unresolved x86-64 SIGSEGV and native-root recovery
gap. Preserve the existing llvm-inprocess feature prerequisite separately.
- Around line 160-170: Update the probe-results paragraph describing the
in-process RS4GC path to state that building llvm-inprocess requires LLVM 22,
available through LLVM_SYS_221_PREFIX or llvm-config on PATH, while
PERRY_LLVM_INPROCESS=1 remains the runtime gate. Keep the existing claim about
no PERRY_LLVM_* runtime pinning, but clarify that it does not remove this build
prerequisite.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro Plus

Run ID: f48ff130-c853-4ade-9bae-297de44849e3

📥 Commits

Reviewing files that changed from the base of the PR and between d3e4661 and 742d7d7.

📒 Files selected for processing (2)
  • .github/workflows/gc-native-roots.yml
  • docs/engine-plan.md
🚧 Files skipped from review as they are similar to previous changes (1)
  • .github/workflows/gc-native-roots.yml

Comment thread docs/engine-plan.md
Comment on lines +160 to +170
**1. There was no working statepoint path for `try` on a default toolchain
(#7339).** The explicit bridge cannot root an `invoke`, and since #7305 every
call inside a `try` *is* an invoke — so the bridge refuses those functions
outright (#7330). RS4GC handles them, but it ran as an external `opt` subprocess
whose output an older `clang` could not parse (`error: unterminated attribute
group`), making it reachable only on a hand-pinned LLVM 22. **128 of 479 gap
tests (26%) contain `try {}`**, so a quarter of the suite had no statepoint path
at all. Routing RS4GC through layer 0's in-process pipeline removes the external
boundary entirely: all nine probes now compile with no `PERRY_LLVM_*` pinning,
byte-identical to the shadow-stack control, copying 5,946–90,271 objects, with
`backend rs4gc` on every function record.

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

📐 Maintainability & Code Quality | 🟡 Minor | ⚡ Quick win

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
set -euo pipefail

echo "== docs/engine-plan.md lines 145-180 =="
sed -n '145,180p' docs/engine-plan.md

echo
echo "== locate Cargo.toml and search LLVM inprocess/preconditions =="
fd -a 'Cargo\.toml$' . | sed 's#^\./##' | head -20
echo
rg -n "llvm-inprocess|PERRY_LLVM|LLVM|llvm" -S crates docs --glob '!target/**' | head -200

Repository: PerryTS/perry

Length of output: 3726


🏁 Script executed:

#!/bin/bash
set -euo pipefail

echo "== crates/perry/Cargo.toml relevant sections =="
rg -n '(\[.*\]|name\s*=\s*"perry"|llvm|inprocess|LLVM)' crates/perry/Cargo.toml crates --glob 'Cargo.toml' -S | head -250

echo
echo "== docs or Cargo files mentioning llvm-inprocess PERRY_LLVM LLVM 22 =="
rg -n "llvm-inprocess|llvm-inprocess|PERRY_LLVM|LLVM 22|LLVMVersion|llvm-version|LLVMVersionReq|versions_require" -S Cargo.toml crates docs README.md README** --glob '!target/**' | head -250

echo
echo "== crates/perry Cargo snippets around features/opt dependencies =="
sed -n '1,220p' crates/perry/Cargo.toml

Repository: PerryTS/perry

Length of output: 45213


Add the LLVM 22 build prerequisite to the probe results.

llvm-inprocess is non-default and requires LLVM 22 (LLVM_SYS_221_PREFIX or llvm-config on PATH) even though PERRY_LLVM_INPROCESS=1 is the runtime gate. The “no PERRY_LLVM_* pinning” wording narrows the runtime path but leaves out the build requirement.

Proposed wording
- byte-identical to the shadow-stack control, copying 5,946–90,271 objects,
+ byte-identical to the shadow-stack control when the LLVM 22 build
+ prerequisite is available, copying 5,946–90,271 objects,
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
**1. There was no working statepoint path for `try` on a default toolchain
(#7339).** The explicit bridge cannot root an `invoke`, and since #7305 every
call inside a `try` *is* an invoke — so the bridge refuses those functions
outright (#7330). RS4GC handles them, but it ran as an external `opt` subprocess
whose output an older `clang` could not parse (`error: unterminated attribute
group`), making it reachable only on a hand-pinned LLVM 22. **128 of 479 gap
tests (26%) contain `try {}`**, so a quarter of the suite had no statepoint path
at all. Routing RS4GC through layer 0's in-process pipeline removes the external
boundary entirely: all nine probes now compile with no `PERRY_LLVM_*` pinning,
byte-identical to the shadow-stack control, copying 5,946–90,271 objects, with
`backend rs4gc` on every function record.
**1. There was no working statepoint path for `try` on a default toolchain
(`#7339`).** The explicit bridge cannot root an `invoke`, and since `#7305` every
call inside a `try` *is* an invoke — so the bridge refuses those functions
outright (`#7330`). RS4GC handles them, but it ran as an external `opt` subprocess
whose output an older `clang` could not parse (`error: unterminated attribute
group`), making it reachable only on a hand-pinned LLVM 22. **128 of 479 gap
tests (26%) contain `try {}`**, so a quarter of the suite had no statepoint path
at all. Routing RS4GC through layer 0's in-process pipeline removes the external
boundary entirely: all nine probes now compile with no `PERRY_LLVM_*` pinning,
byte-identical to the shadow-stack control when the LLVM 22 build
prerequisite is available, copying 5,946–90,271 objects, with
`backend rs4gc` on every function record.
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@docs/engine-plan.md` around lines 160 - 170, Update the probe-results
paragraph describing the in-process RS4GC path to state that building
llvm-inprocess requires LLVM 22, available through LLVM_SYS_221_PREFIX or
llvm-config on PATH, while PERRY_LLVM_INPROCESS=1 remains the runtime gate. Keep
the existing claim about no PERRY_LLVM_* runtime pinning, but clarify that it
does not remove this build prerequisite.

Comment thread docs/engine-plan.md
Comment on lines +206 to +213
**⇒ On aarch64, RS4GC-in-process is now a viable default.** Two things still gate
flipping it globally, and neither is correctness:

1. **`llvm-inprocess` is a non-default cargo feature.** RS4GC-as-default requires
layer 0's feature becoming default first (#7301's scope, not this work's).
2. **x86-64 remains blocked on #7333** — see the measured `_Unwind_GetGR(ctx, 7)`
segfault above. A default that only works on one architecture is not a
default.

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🩺 Stability & Availability | 🟡 Minor | ⚡ Quick win

Classify the x86-64 blocker as a correctness and stability blocker.

The next item documents a SIGSEGV in _Unwind_GetGR(ctx, 7) and missing native-root recovery. That is a runtime-safety blocker, not only a scope or process issue. Distinguish the absence of new aarch64 regressions from the unresolved x86-64 correctness gap.

Proposed wording
-Two things still gate flipping it globally, and neither is correctness:
+Two things still gate flipping it globally. Aarch64 has no new correctness
+regressions, but x86-64 still has a runtime-safety blocker:
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
**⇒ On aarch64, RS4GC-in-process is now a viable default.** Two things still gate
flipping it globally, and neither is correctness:
1. **`llvm-inprocess` is a non-default cargo feature.** RS4GC-as-default requires
layer 0's feature becoming default first (#7301's scope, not this work's).
2. **x86-64 remains blocked on #7333** — see the measured `_Unwind_GetGR(ctx, 7)`
segfault above. A default that only works on one architecture is not a
default.
**⇒ On aarch64, RS4GC-in-process is now a viable default.** Two things still gate
flipping it globally. Aarch64 has no new correctness
regressions, but x86-64 still has a runtime-safety blocker:
1. **`llvm-inprocess` is a non-default cargo feature.** RS4GC-as-default requires
layer 0's feature becoming default first (`#7301`'s scope, not this work's).
2. **x86-64 remains blocked on `#7333`** — see the measured `_Unwind_GetGR(ctx, 7)`
segfault above. A default that only works on one architecture is not a
default.
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@docs/engine-plan.md` around lines 206 - 213, Update the “On aarch64” section
in the RS4GC default-readiness discussion to classify the x86-64 `#7333` blocker
as a correctness and stability issue, explicitly distinguishing the absence of
new aarch64 regressions from the unresolved x86-64 SIGSEGV and native-root
recovery gap. Preserve the existing llvm-inprocess feature prerequisite
separately.

…y, not metadata

The plan asserted 'closing that axis needs fewer roots, not a tighter
encoding' on the strength of one app measurement. Measured directly with
two 2000-function programs:

  root-free functions   +0 bytes        (no map emitted, text identical)
  root-dense functions  +4,330,592 B    (97% __text, 21% gcmap)

So statepoints carry NO fixed cost -- a function with nothing live across
a safepoint pays nothing -- and the growth is the per-root relocation
sequence, not the map. #7314's compact map fully answered the metadata
objection, but metadata was never the dominant term at scale.

Runtime on the same probes, quiet host, median of 5: statepoints 1-2%
faster, every probe neutral or faster.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 2

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@docs/engine-plan.md`:
- Line 218: Update the measurement date in the “Binary size, measured
2026-08-04” heading to the actual run date; if the measurement has not occurred,
mark the measurement as planned instead of presenting a future date.
- Around line 228-235: Clarify the root-free benchmark row and surrounding
conclusion by identifying whether the +12 B in __text is shared binary overhead
rather than per-function statepoint cost. Update the table note or explanatory
text so it explicitly reconciles this value with the statement that root-free
functions emit no statepoint-specific text.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro Plus

Run ID: 46ba50ef-d0a0-4fed-8999-3db1c815f129

📥 Commits

Reviewing files that changed from the base of the PR and between 742d7d7 and 7c057c5.

📒 Files selected for processing (1)
  • docs/engine-plan.md

Comment thread docs/engine-plan.md
So the honest state is: *aarch64-viable, globally blocked on two pieces of scope
that are both already identified.*

### ★ Binary size, measured 2026-08-04 — it is a ROOT-DENSITY problem, not a metadata one

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

📐 Maintainability & Code Quality | 🟡 Minor | ⚡ Quick win

Use the actual measurement date.

Line [218] labels the results as measured on August 4, 2026, but the current review date is August 3, 2026. Replace it with the actual run date, or mark the measurement as planned until it exists.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@docs/engine-plan.md` at line 218, Update the measurement date in the “Binary
size, measured 2026-08-04” heading to the actual run date; if the measurement
has not occurred, mark the measurement as planned instead of presenting a future
date.

Comment thread docs/engine-plan.md
Comment on lines +228 to +235
| 2000 **root-free** functions (scalar only) | **+0 B** | +12 B | not emitted |
| 2000 **root-dense** functions (3 heap values live across an alloc) | **+4,330,592 B (+18.95%)** | +4,203,608 B | 902,124 B |

Two things follow, and both matter for planning:

1. **Statepoints have no fixed cost.** A function with nothing live across a
safepoint pays nothing at all — no map entry, no text. So the axis is not
"statepoints are bigger", it is "roots are bigger", and a program's exposure

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
set -euo pipefail
if [ -f docs/engine-plan.md ]; then
  nl -ba docs/engine-plan.md | sed -n '200,255p'
else
  echo "docs/engine-plan.md not found"
  git ls-files | rg '(^|/)engine-plan\.md$|(^|/)docs/' | head -100
fi

Repository: PerryTS/perry

Length of output: 191


🏁 Script executed:

#!/bin/bash
set -euo pipefail
if [ -f docs/engine-plan.md ]; then
  awk '{printf "%6d\t%s\n", NR, $0}' docs/engine-plan.md | sed -n '200,255p'
else
  echo "docs/engine-plan.md not found"
  git ls-files | grep -E '(^|/)engine-plan\.md$|^docs/' | head -100 || true
fi

Repository: PerryTS/perry

Length of output: 3340


Clarify the shared binary overhead in the root-free case.

The root-free row shows +12 B in __text, but the text below says functions with nothing live pay “no text” at all. State whether the 12 B is shared binary overhead, or otherwise reconcile the table and conclusion.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@docs/engine-plan.md` around lines 228 - 235, Clarify the root-free benchmark
row and surrounding conclusion by identifying whether the +12 B in __text is
shared binary overhead rather than per-function statepoint cost. Update the
table note or explanatory text so it explicitly reconciles this value with the
statement that root-free functions emit no statepoint-specific text.

@proggeramlug
proggeramlug merged commit 4ac5790 into main Aug 4, 2026
30 of 48 checks passed
@proggeramlug
proggeramlug deleted the fix/7326-decouple-root-analysis branch August 4, 2026 05:54
proggeramlug added a commit that referenced this pull request Aug 4, 2026
…#7345)

The layer table said layer 3 was 'not started' when RuntimeHandleScope
has 675 uses across 169 files; what is missing is the word 'non-optional'.
It now says so, and carries #7341's 54-item worklist.

Sequencing step 2 ('in-process LLVM -> statepoints') is complete, so the
list said 'next' about work that had already landed. Replaced with what
actually comes next, and with one ordering change that is a real finding
rather than bookkeeping:

  reducing root density is now a PREREQUISITE for statepoint adoption,
  not a follow-up.

Statepoints cost +18.95% binary size on root-dense code and +0% on
root-free code, 97% of it __text. Making them the default today would
regress the stated goal of minimal binary size. The plan already named
this lever but flagged it 'expected, not measured -- layer 2 must prove
it first'; layer 2 has landed, so it is now measurable.

Co-authored-by: Ralph Küpper <ralph@skelpo.com>
proggeramlug added a commit that referenced this pull request Aug 4, 2026
PERRY_RS4GC=1 is no longer needed. PERRY_RS4GC=0 reverts to the shadow
stack for bisection.

TARGET-AWARE, not blanket. gc_map REFUSES to emit a map for a target
whose frame bases the runtime cannot resolve, because a map nothing reads
loses roots silently -- so a global flip would turn every watchOS
arm64_32 and ARM64-Windows compile into a hard error. The default is
therefore native roots where the runtime can walk, shadow stack where it
cannot. That is only expressible because #7340 split the root-set
analysis from its lowering: falling back is not 'no roots', it is the
other lowering of the same analysis. A test pins the support matrix in
both directions, because the one way this breaks a platform is if the
predicate is LOOSER than gc_map's refusals.

An explicit PERRY_RS4GC=1 still reaches that refusal rather than being
silently downgraded, so an A/B arm measures what it asked for.

Evidence, full 479-test gap suite with no env set:

    pass       447    (shadow baseline: 447)
    diff        19    (pre-existing, unchanged)
    node_fail   13
    regressions  0    compile failures 0

All 128 try-carrying tests compiled -- the class the deleted bridge
(#7348) could never handle. All 10 gc_ratchet probes byte-identical to
Node. Runtime -1-2%; binary size +1.86% measured on zod's 81 modules.

Eight codegen tests assert on shadow-stack IR and now pin that lowering
through a thread-local guard, mirroring arena::quarantine's
ProtectionModeGuard. They were right about what they asserted -- they had
just never needed to name a lowering, because there was only one.

Co-authored-by: Ralph Küpper <ralph@skelpo.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

PERRY_SHADOW_STACK=0 + PERRY_STATEPOINTS=1 silently emits a binary with no precise roots

1 participant