Skip to content

fix(gc): route RS4GC through the in-process LLVM backend - #7339

Merged
proggeramlug merged 5 commits into
mainfrom
feat/7327-rs4gc-in-process
Aug 4, 2026
Merged

fix(gc): route RS4GC through the in-process LLVM backend#7339
proggeramlug merged 5 commits into
mainfrom
feat/7327-rs4gc-in-process

Conversation

@proggeramlug

@proggeramlug proggeramlug commented Aug 3, 2026

Copy link
Copy Markdown
Contributor

Closes #7327.

The problem

PERRY_RS4GC=1 failed on any stock toolchain with:

error: unterminated attribute group

RS4GC ran as an external opt subprocess and handed its output to clang. On a
Mac that pairing is Homebrew's LLVM 22 opt feeding Apple's clang 21, and the
newer opt emits attributes the older clang cannot parse. So RS4GC was
reachable only with PERRY_LLVM_CLANG pointed at a version-matched LLVM 22 —
which the existing CI arm does, and which no user does.

That mattered more than a knob normally would, because RS4GC is the only
backend that can root an invoke
, and since #7302 every call inside a try is
an invoke. The explicit bridge refuses them outright (#7330). 128 of 479 gap
tests (26%) contain try {}.
So there was no working statepoint path for a
quarter of the suite on a default toolchain.

The fix

Run the pass in-process, where #7301 already pins LLVM 22 and no IR crosses a
toolchain boundary. The pass itself was already known to schedule there —
rs4gc_schedules_in_process has been asserting it. Two gaps had to be closed to
get from "schedules" to "produces a linkable object":

  1. inprocess.rs ignored -S. It fell into the catch-all that discards
    -c, so the statepoint backends asked for assembly and were handed an
    object. Native-frame GC roots via LLVM statepoints, opt-in (#7173, #7174) #7314's compact-map rewriter rewrites .llvm_stackmaps in assembly
    text — that is where LLVM prints function addresses as symbol names — so it
    needs the real thing.

  2. Nothing assembled the result. The returned assembly went straight into a
    .o and the link died with ld: unknown file type. This now mirrors the
    external path: write to plan.asm_path, run compact_and_assemble, return
    the object. The assembler is resolved via find_clang() because plan.clang
    is the literal (in-process) placeholder on this path — and using the system
    clang for it is sound, since the skew was an IR parse failure and by this
    point the IR is gone.

Result

All 9 gc-ratchet probes compile under PERRY_RS4GC=1 PERRY_LLVM_INPROCESS=1
with no PERRY_LLVM_* pinning, including probe 09, which the bridge cannot
compile at all
:

probe checksum vs shadow stack objects copied __perry_gcmap
01_nursery_churn identical 17,060 605 B
02_survivor_promotion identical 90,275 669 B
03_cross_gen_writes identical 41,129 650 B
04_dead_after_deep_stack identical 16,510 853 B
05_closure_capture identical 11,169 730 B
06_string_retention identical 16,229 595 B
07_array_grow_evacuate identical 16,134 640 B
08_map_set_sidetables identical 5,946 622 B
09_try_catch_roots identical 10,892 1,995 B

9/9 byte-identical to the shadow-stack control, every one under
PERRY_CONSERVATIVE_STACK_SCAN=off so the native map is doing the rooting.
--statepoint-report reports backend rs4gc: 9 function(s) on probe 09 — no
per-function bail to the bridge.

Gating

Existing RS4GC steps all pin PERRY_LLVM_OPT + PERRY_LLVM_CLANG, so none of
them can observe that the pinning is no longer needed. The new step is the only
arm that unsets both. It asserts four things, each of which is a way a GC gate
in this repo has previously gone green while measuring nothing:

Job timeout goes 90 → 120 because the step builds a second time with the
llvm-inprocess feature, which cargo cannot share with the build above it.

No silent-fallback hole

The concern worth stating, since it is the #7332 shape: if the in-process
compile failed and fell back to external clang after maybe_rs4gc_preprocess
had already skipped the external opt, the result would be a binary with no
statepoints at all — correct-looking until a collection freed something live.
Checked: both the failure branch and the missing-feature stub bail!, so there
is no path from "asked for in-process" to "served the text path".

Tests

  • dash_s_requests_assembly_and_dash_c_does_not — the parse gap directly.
    Verified it fails when "-S" => emit_asm = true is reverted to "-S" => {}.
  • assembly_emission_is_text_not_an_object — asserts the two FileTypes do not
    return identical bytes, so a future regression cannot silently re-swallow
    -S.

Scope

This does not change any default. llvm-inprocess remains a non-default cargo
feature, so PERRY_RS4GC=1 on a stock release build still takes the external
path and still fails loudly there. Making RS4GC a default is a separate decision
that depends on #7301's feature becoming default, and on #7333 (the x86-64
walker) for non-aarch64 hosts.

Summary by CodeRabbit

  • New Features

    • RS4GC can now run fully in-process with the pinned LLVM toolchain.
    • Assembly output is supported when using the in-process compiler.
    • invoke roots and compact GC-map generation work without external LLVM tool incompatibilities.
    • Apple AArch64 builds now receive a default CPU target when none is specified.
  • Bug Fixes

    • Prevented duplicate garbage-collection rewriting during compilation.
    • Improved compact stack-map and assembly/object output handling.
  • Tests

    • Added validation for evacuation, copying, GC-map rewriting, and RS4GC lowering.

Ralph Küpper added 4 commits August 3, 2026 22:46
PERRY_RS4GC=1 died with `unterminated attribute group` on any stock
toolchain: the pass ran under an external LLVM 22 `opt`, and Apple
clang 21 then could not parse the IR it produced. That made RS4GC
reachable only with PERRY_LLVM_CLANG pointed at a version-matched
LLVM 22 -- and RS4GC is the only backend that can root an `invoke`,
which since #7302 is every call inside a `try`.

Run the pass in-process instead, where LLVM 22 is already pinned and
no IR ever crosses a toolchain boundary. Two gaps had to be closed:

- inprocess.rs ignored `-S`, so the statepoint backends' request for
  assembly silently produced an object. #7314's compact-map rewriter
  works on assembly text, so it needs the real thing.
- the returned assembly was written straight to a `.o` with nothing
  assembling it (`ld: unknown file type`). Mirror the external path:
  write it to plan.asm_path, run compact_and_assemble, return the
  object. The assembler is resolved via find_clang() because
  plan.clang is the literal `(in-process)` placeholder here.
Every existing RS4GC step pins PERRY_LLVM_OPT and PERRY_LLVM_CLANG to one
brew install, which is the requirement the in-process route removes. An
arm that keeps the pinning cannot observe that.

This step is the only one that unsets both, and it runs probe 09 --
try-carrying, so every call in it is an invoke, which the explicit bridge
refuses (#7330). It asserts four things, each of which has been a way a
GC gate went green while measuring nothing: the map section exists, the
compact rewrite ran, a copying minor actually copied, and RS4GC (not a
per-function bail to the bridge) did the lowering.
@coderabbitai

coderabbitai Bot commented Aug 3, 2026

Copy link
Copy Markdown

Review Change Stack

No actionable comments were generated in the recent review. 🎉

ℹ️ Recent review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro Plus

Run ID: 7d459a8a-cf44-49bf-aa7b-c688a15e3088

📥 Commits

Reviewing files that changed from the base of the PR and between f398e23 and f66fe44.

📒 Files selected for processing (1)
  • crates/perry-codegen/src/inprocess.rs

📝 Walkthrough

Walkthrough

The in-process LLVM backend now performs RS4GC rewriting, supports assembly emission, and integrates compact GC-map assembly. CI validates try/catch roots, evacuation, and RS4GC lowering on ARM64.

Changes

In-process LLVM RS4GC pipeline

Layer / File(s) Summary
Compiler output and RS4GC passes
crates/perry-codegen/src/inprocess.rs
The compiler tracks -S, applies the Apple AArch64 apple-m1 default, runs RS4GC passes in-process, selects assembly or object output, and tests these behaviors.
GC-map assembly integration
crates/perry-codegen/src/linker.rs
The linker avoids duplicate external RS4GC rewriting and assembles compacted output through a system clang when assembly is emitted.
In-process RS4GC validation
.github/workflows/gc-native-roots.yml, changelog.d/7339-rs4gc-in-process.md
The ARM64 workflow validates try/catch roots, compact-map replacement, evacuation, object movement, and the rs4gc backend. The changelog records the new behavior and coverage.

Estimated code review effort: 4 (Complex) | ~45 minutes

Sequence Diagram(s)

sequenceDiagram
  participant gc_native_roots as gc-native-roots workflow
  participant inprocess_rs as inprocess.rs
  participant linker_rs as linker.rs
  participant clang
  gc_native_roots->>inprocess_rs: build with llvm-inprocess
  inprocess_rs->>inprocess_rs: run RS4GC statepoint rewriting
  inprocess_rs-->>linker_rs: emit assembly
  linker_rs->>linker_rs: compact GC maps
  linker_rs->>clang: assemble compacted assembly
  clang-->>linker_rs: return object
  linker_rs-->>gc_native_roots: run evacuation and backend checks
Loading

Possibly related issues

  • PerryTS/perry issue 7241: Extends the in-process LLVM backend with RS4GC rewriting and assembly/object emission.

Possibly related PRs

  • PerryTS/perry#7301: Extends the in-process LLVM backend in the same compilation paths.
  • PerryTS/perry#7322: Introduces the native-roots workflow that this change expands with in-process RS4GC validation.
  • PerryTS/perry#7330: Covers the RS4GC invoke try/catch root path that this change enables in-process.
🚥 Pre-merge checks | ✅ 5
✅ Passed checks (5 passed)
Check name Status Explanation
Title check ✅ Passed The title clearly states that RS4GC now uses the in-process LLVM backend, which is the primary change.
Description check ✅ Passed The description is detailed and covers the problem, implementation, linked issue, tests, results, scope, and unchanged defaults, but it omits the template headings and checklist.
Linked Issues check ✅ Passed The changes address [#7327] by enabling RS4GC to handle try/catch code with invoke instructions through the in-process LLVM backend.
Out of Scope Changes check ✅ Passed The code, tests, changelog, and CI updates support the RS4GC in-process backend objective without unrelated changes.
Docstring Coverage ✅ Passed Docstring coverage is 100.00% which is sufficient. The required threshold is 80.00%.
✨ Finishing Touches
📝 Generate docstrings
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch feat/7327-rs4gc-in-process

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.

@proggeramlug

Copy link
Copy Markdown
Contributor Author

Full gap suite, two arms, 479/479 — zero new regressions, zero refusals

Ran every test_gap_*.ts twice: once with the default shadow-stack build as a
control, once under PERRY_RS4GC=1 PERRY_LLVM_INPROCESS=1, both diffed against
the pinned Node 26.5.1 oracle.

pass -> pass              447
diff -> diff               19    pre-existing, unchanged by the backend
node_fail -> node_fail     13    oracle cannot run the test
────────────────────────────
NEW REGRESSIONS             0
RS4GC refusals              0
RS4GC compile failures      0

Zero refusals is the number that matters here, more than zero regressions.
128 of the 479 tests contain try {}, so every call in them is an invoke,
which the explicit bridge refuses outright (#7330). RS4GC compiled every test in
the suite.

For context: the earlier soak that returned "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. That verdict should not be
carried forward, and docs/engine-plan.md now says so.

Scope, stated plainly

This makes RS4GC-in-process viable as a default on aarch64. It does not make
it one, and two things still gate that — neither of which is correctness:

  1. llvm-inprocess is a non-default cargo feature (In-process LLVM backend: native construction via the C API, opt-in (#7241, engine-plan layer 0) #7301's scope).
  2. x86-64 is blocked on gc: the platform-unwinder stack-map walker segfaults on Linux/ELF — and on x86-64 it is the only walker #7333. I measured the mechanism while this was running:
    _Unwind_GetGR(ctx, 7) does not return an unreliable value, it segfaults
    — RBX/RBP/RIP return fine, RAX and RSP both SIGSEGV, because libgcc tracks
    only the columns CFI restores and RSP is derived from the CFA. Every x86-64
    root is Indirect [RSP + off], so that call faults 100% of the time on the
    only register those roots use. Comment with a reproducer is on gc: the platform-unwinder stack-map walker segfaults on Linux/ELF — and on x86-64 it is the only walker #7333.

The plain in-process path (PERRY_LLVM_INPROCESS=1 with no statepoints, i.e.
#7301's normal use) was regression-checked separately and is unchanged.

…VM's generic

CI hit `LLVM ERROR: Cannot select: intrinsic %llvm.aarch64.fjcvtzs` and
aborted the compile.

Codegen decides whether to emit `fjcvtzs` (FEAT_JSCVT, ARMv8.3+, the
single-instruction ECMAScript ToInt32) from the TRIPLE ALONE --
`set_jscvt_for_target` opts in for every Apple arm64 triple -- and that
is sound for clang, whose default CPU for arm64-apple-* is apple-m1
(ARMv8.5). But `create_target_machine` with an empty CPU string selects
LLVM's `generic`, which on aarch64 is ARMv8.0 and has no FEAT_JSCVT.

So the two halves disagreed: one decided what to EMIT from the triple,
the other what the target could EXECUTE, and only the clang path had
them aligned. The in-process backend now derives the same default.

Local build never hit it because a host build gets `-mcpu=native`, which
takes the host-features branch. Reproduced exactly with
`PERRY_TARGET_CPU=generic` (the CI path): fails before, compiles after,
9/9 probes compile, native-tuning path unchanged.

Also fixes the doc-comment placement that had left
`#[allow(clippy::type_complexity)]` attached to the wrong function.
@proggeramlug
proggeramlug merged commit 1f2e8c2 into main Aug 4, 2026
12 of 17 checks passed
@proggeramlug
proggeramlug deleted the feat/7327-rs4gc-in-process branch August 4, 2026 05:53
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
…ly linked (#7353)

Perry now links LLVM 22 statically and ships self-contained. We own the
assumption rather than pushing it onto the user, and there is no "install
a compatible clang" step left to get wrong.

It is load-bearing, not a preference. The explicit statepoint bridge is
gone (#7348), so RS4GC is the only native-root backend, and RS4GC cannot
round-trip its IR through an external `opt` plus a different clang
(#7339). Keeping this opt-in meant the only working statepoint path was
behind a flag nobody sets.

Two defaults flip together, because either alone is half a feature:

  * `llvm-inprocess` becomes a default cargo feature.
  * `inprocess_requested()` defaults to ON -- but only iff the backend is
    actually compiled in. Defaulting to `true` unconditionally would route
    every compile in a `--no-default-features` build into the
    not-built-in stub and fail it outright. Verified both ways.

`PERRY_LLVM_INPROCESS=0` reverts to the clang subprocess for bisection,
and `--no-default-features` still builds the text path.

CI: a new `.github/actions/setup-llvm22` composite action, referenced from
all 44 toolchain steps across 18 workflows. One definition rather than 44
inline recipes, because the three platforms need three different sources
and only one is obvious -- Ubuntu 24.04's own llvm-dev is 18, and
chocolatey's `llvm` is the clang toolchain with no llvm-config.exe and
none of the static libs. Every arm asserts the major version.

Size: 98.9 MB, not the 185.9 MB this would have cost before #7350 --
`initialize_all()` was linking ~18 backends nothing can reach.

Also fixed, surfaced by the flip: PERRY_LLVM_KEEP_IR promises the whole
scratch dir including the .o. The clang path got that free because the
object is a file; in-process returns bytes and silently dropped it,
degrading a debugging aid exactly when someone is debugging.

Verified on the 81-module zod corpus with no env set: compiles, output
byte-identical to the clang path, and PERRY_RS4GC=1 now compiles a
try-carrying probe with no further flags. 605 codegen tests pass.

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.

Statepoint bridge emits no statepoint on invoke — every call inside a try is unrooted

1 participant