Skip to content

jit(cranelift): three ref-root/demoted-home GC fixes, and the def-site store removal - #1345

Merged
youknowone merged 6 commits into
mainfrom
jitcode
Aug 19, 2026
Merged

jit(cranelift): three ref-root/demoted-home GC fixes, and the def-site store removal#1345
youknowone merged 6 commits into
mainfrom
jitcode

Conversation

@youknowone

@youknowone youknowone commented Aug 19, 2026

Copy link
Copy Markdown
Owner

Four cranelift changes on the ref-root / demoted-home machinery: one measured
loop-header saving, and three correctness fixes for words the collector reads.

jit(cranelift): drop the ref-root home store at every Ref-defining op

Twelve op-emission sites wrote a Ref's ref-root home the moment the op defined
it. No reader needs that store: spill_ref_roots over the live slot list
immediately precedes every emit_push_gcmap, under the same predicate
get_gcmap applies, and a guard's map names dense fail-arg slots only. A
demoted raw's definition always precedes the LABEL that demotes it, and that
LABEL's fall-through seeding writes the same word with the same value.

251 static stores removed. No measurable wall-clock effect — fannkuch
−0.7% over 25 interleaved rounds (12/25 paired), nbody −1.6% (12/20), combined
34/60 at p≈0.30. The stores are off the hot path; the win from the earlier
LABEL-header change was hot-path only.

jit(cranelift): borrow the jitframe's gcmap in force() instead of releasing it

force_token_to_dead_frame built its deadframe through
deadframe_from_jitframe, whose Drop writes a null over jf_gcmap. The force
token is the jitframe of a compiled run that is still on the JF shadow stack,
inside the residual call that armed jf_force_descr — that call site pushed the
map and clears it with pop_gcmap on return, so the release left the frame's
spilled Ref slots untraced for the rest of the call. The dynasm backend
already separates the two cases as FrameData::owning / FrameData::borrowing.

Adds owns_gcmap to JitFrameDeadFrame with a borrowing constructor.

Measured with an instrumented Drop reporting the (owns_gcmap, still on the JF
shadow stack) pair per drop: across the 44 getframe / vable / traceback
synth fixtures, getframe_caller_locals_nested_compiled_callee is the one that
produces owns=false on_stack=true. Restoring the release and forcing a
full moving collection at that point changed no fixture's output, so the defect
is real but latent and no wrong-answer fixture accompanies it.

jit(cranelift): write a demoted non-ref's frame home at a later LABEL header

The LABEL-header reconcile called sync_ref_root_var for every kept block param
whose raw is in demoted_failarg_slots. That helper emits nothing when the raw
is absent from ref_root_slots, and a demoted non-ref never appears there — so
a raw demoted at an earlier LABEL and kept at a later one had its home left
alone, while resolve_failarg_opref reads that home in preference to use_var
and a loader re-entry refreshes only the dense carried slot.

Emits nothing on the corpus as it stands: across 110 synth fixtures the CLIF
store count is unchanged, so no fixture currently carries a non-ref demoted at
one LABEL and kept at another.

jit(cranelift): keep an already-demoted ref's forwarded home at a later LABEL

The LABEL fall-through seeding stored use_var(raw) into the ref-root home. For
a raw demoted at an earlier LABEL that is the same word — both LABELs derive
the offset from the same ref_root_slots entry — and its SSA value
stops being refreshed the moment it is demoted — spill_ref_roots and
reload_ref_roots both skip a demoted raw — so a collecting call between the
two LABELs leaves the register holding a pre-collection pointer that the store
then puts back over the word the collector just forwarded. Skip the store when
the home is already established; the re-materialize immediately below
re-defines the SSA variable from the word.

Two reported defects that did not survive checking

  • A later LABEL's loader leaving an earlier LABEL's demoted home unwritten,
    while get_gcmap marks it with no liveness test.
    Harmless: a jitframe is
    zeroed at allocation (write_bytes(.., 0, payload_bytes)), and the
    custom-trace visitor skips a zero word at is_nursery_object_start and at the
    poison assert. The loader re-seeds its own LABEL's demoted ref and non-ref
    homes from the dense carried slots, and an earlier-demoted ref that is a kept
    param is already synced.
  • A CALL_ASSEMBLER shadow-stack imbalance. Refuted by measurement: with a
    probe comparing jf_depth() across every run_compiled_code entry, all 431
    synth fixtures return at the depth they entered at — including the ~20 that
    actually emit CALL_ASSEMBLER (ca_bridge_multiframe_resume_double_call alone
    emits 182).

Gates

pyre/check.py --backend cranelift 440/441, jitstats unchanged (no
re-record). The one red, synth/generator_tree_recursion, is discriminated as
load: it read 8.6x against a 7.6x gate in the full run at load average 45, and
6.0x re-measured in isolation on the same binary. cargo test -p majit-backend-cranelift 138 + 35 + 1 green, cargo test -p majit-backend
green.

Test coverage of the three fixes

  • gcmap ownershiponly_an_owning_deadframe_releases_the_frames_gcmap
    drops both deadframe kinds over a frame carrying a sentinel map. Verified to
    fail with the condition forced true.
  • The later-LABEL fall-through's demoted-ref home
    a_later_labels_fallthrough_keeps_an_already_demoted_refs_forwarded_home
    gives two LABELs one descr so the single back-edge demotes the carried
    position at both, collects between them, and reads the guard's published
    reference. Verified to fail with the store made unconditional again: it
    publishes GcRef(4358165544), the address the object had before the
    collection.
  • The demoted non-ref's home at a later LABEL header — no unit test. Its
    observable path is a loader re-entry, which is the only edge that refreshes
    the dense carried slot the block param arrives in without touching the home;
    reaching it from a unit test means attaching a bridge, not compiling one
    trace. The shape emits nothing on the 110-fixture corpus (CLIF store count
    unchanged), so there is no fixture to reach it from either.

Summary by CodeRabbit

  • Bug Fixes
    • Improved JIT execution frame handling during garbage collection and failure recovery.
    • Prevented unnecessary reference synchronization, reducing redundant runtime work.
    • Fixed frame cleanup behavior so active frame metadata remains intact while owned resources are released correctly.
  • Reliability
    • Added safeguards and coverage for borrowed and owned frame state, improving runtime stability in edge cases.

Twelve backend arms stored a freshly defined Ref result into that raw's
ref-root home right after `def_var`: SameAsR, the Call/CallPure/CallLoopinvariant
Ref arm, the CallAssembler merge, GcLoadR, GcLoadIndexedR, VirtualRefR,
New/NewWithVtable in both its GC and no-GC forms, NewArray/NewArrayClear,
GetarrayitemRawR, ThreadlocalrefGet, and Newstr/Newunicode.

No reader misses them. A demoted raw's definition always precedes the LABEL
that demotes it, because `compute_loop_phi_keep` refuses to demote a raw
redefined after the LABEL; that LABEL's fall-through seeding writes the same
word with the same value immediately before recording the demotion, so the
eager store is subsumed. Ahead of the seeding the raw is not demoted, so it
follows the ordinary discipline: `spill_ref_roots` over the live slot list
precedes every `emit_push_gcmap` under the predicate `get_gcmap` applies, and
`get_gcmap`'s unconditional demoted mark cannot fire before the seeding store
because the map is filled during emission in op order. Guard maps name dense
fail-arg slots only. The remaining escape, `stale_ref_vars`, has no insertion
anywhere in the workspace.

`LoadFromGcTable`, `CallMallocNursery`, `GetfieldGcR`, `GetarrayitemGcR` and
`CondCallValueR` are Ref-result definitions that already did not sync a home,
so this makes the arms consistent rather than introducing a new rule.

This is not a measurable speedup. fannkuch and nbody A/Bs sit inside the noise
band — 34 of 60 paired rounds — even though the emitted store count drops
(fannkuch 5609 to 5358, nbody 2877 to 2733, spectral_norm 892 to 842). The
stores were unobservable work, not hot work.

`cargo test -p majit-backend-cranelift` and `pyre/check.py --backend cranelift`
(440/440) pass.

Assisted-by: Claude
…easing it

`force_token_to_dead_frame` built its deadframe through
`deadframe_from_jitframe`, whose `Drop` writes a null over `jf_gcmap`.  The
force token is the jitframe of a compiled run that is still on the JF shadow
stack, inside the residual call that armed `jf_force_descr`; that call site
pushed the map before the call and clears it with `pop_gcmap` on return, so the
release left the frame's spilled `Ref` slots untraced for the rest of the call.
The dynasm backend already separates the two cases, as `FrameData::owning` and
`FrameData::borrowing`.

Add `owns_gcmap` to `JitFrameDeadFrame` with a `borrowing` constructor, and take
it in `force_token_to_dead_frame`.

Measured with an instrumented `Drop` reporting the (owns_gcmap, still on the JF
shadow stack) pair per drop: across the 44 `getframe` / `vable` / `traceback`
synth fixtures, `getframe_caller_locals_nested_compiled_callee` is the one that
produces `owns=false on_stack=true`.  Restoring the release and forcing a full
moving collection at that point changed no fixture's output, so no wrong-answer
fixture accompanies this.

check.py --backend cranelift 440/440.

Assisted-by: Claude
… header

The LABEL-header reconcile calls `sync_ref_root_var` for every kept block param
whose raw is in `demoted_failarg_slots`.  That helper looks the raw up in
`ref_root_slots` and emits nothing when it is absent, and a demoted non-ref is
never in `ref_root_slots` by construction — so a raw demoted at an earlier LABEL
and kept at a later one had its home left alone.

`resolve_failarg_opref` tests the demoted offset before it falls back to
`use_var`, and a loader re-entry refreshes only the dense carried slot the block
param arrives in, so every guard below that LABEL published the value the earlier
LABEL's seeding wrote.

Store the block param into `demoted_failarg_offset`'s home for that case.

Emits nothing on the corpus as it stands: across 110 synth fixtures — the first
50 plus every `bridge` / `retrace` / `nested` / `while` / `loop` / `label` /
`preamble` name — the CLIF store count is unchanged, so no fixture currently
carries a non-ref demoted at one LABEL and kept at another.

check.py --backend cranelift 440/440.

Assisted-by: Claude
…er LABEL

The LABEL fall-through seeding stored `resolve_opref(raw)` — a `use_var` — into
the ref-root home of every demoted ref arg.  When the raw was already demoted at
an earlier LABEL that is the same frame word, both LABELs deriving the offset
from the same `ref_root_slots` entry, and the SSA value stops being refreshed
the moment a raw is demoted: `spill_ref_roots` and `reload_ref_roots` both skip
a demoted raw, so a collecting call between the two LABELs leaves the register
holding a pre-collection pointer while `get_gcmap` marks the word and the
collector forwards it in place.  The store then wrote that pointer back over the
forwarded word.

Skip the store when the home is already established.  The re-materialize
immediately below re-defines the SSA variable from the word.

Emits nothing on the corpus as it stands: across the same 110 synth fixtures the
CLIF store count is unchanged.

check.py --backend cranelift 440/441, with `synth/generator_tree_recursion`
discriminated as load — it read 8.6x against a 7.6x gate in the full run at load
average 45, and 6.0x re-measured in isolation.

Assisted-by: Claude
@coderabbitai

coderabbitai Bot commented Aug 19, 2026

Copy link
Copy Markdown

Review Change Stack

No actionable comments were generated in the recent review. 🎉

ℹ️ Recent review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: ASSERTIVE

Plan: Pro Plus

Run ID: 751c591f-59d9-4a51-8475-e1dca8c96776

📥 Commits

Reviewing files that changed from the base of the PR and between eb27867 and a24ba64.

📒 Files selected for processing (2)
  • majit/majit-backend-cranelift/src/compiler.rs
  • majit/majit-backend/src/deadframe.rs

Included review availability: Your plan provides up to 2 included reviews per hour; 0 remain after this review.


Walkthrough

The JIT now supports borrowing deadframes without clearing active GC maps. LABEL handling distinguishes demoted references from non-references, and redundant reference-root synchronization is removed from multiple result-producing operations.

Changes

JIT deadframe and root handling

Layer / File(s) Summary
Borrowing deadframe ownership
majit/majit-backend/src/deadframe.rs, majit/majit-backend-cranelift/src/compiler.rs
JitFrameDeadFrame tracks GC-map ownership and adds borrowing. The compiler uses borrowing deadframes for executing frames. Tests cover both ownership modes.
Demoted fail-argument synchronization
majit/majit-backend-cranelift/src/compiler.rs
LABEL processing skips redundant stores for demoted arguments. Demoted references use root synchronization, while other demoted values are coerced and stored at their frame-home offsets.
Reference-result synchronization cleanup
majit/majit-backend-cranelift/src/compiler.rs
Redundant root synchronization is removed from calls, loads, virtual references, allocations, merged results, and other reference-producing operations.

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

Merge Risk: ⚪ Minimal · up to a24ba

The PR updates Cranelift GC frame-home handling and removes redundant eager stores; backend tests are green, and the single red performance result is attributed to machine load rather than a functional regression. No actionable merge-blocking risk remains after normal checks and review.

Possibly related PRs

Poem

A rabbit checks the frame-map door,
Borrowed roots stay safe in store.
Demoted values find their place,
Extra sync hops leave no trace.
The JIT bounds through cleaner ground!

🚥 Pre-merge checks | ✅ 5
✅ Passed checks (5 passed)
Check name Status Explanation
Docstring Coverage ✅ Passed No functions found in the changed files to evaluate docstring coverage. Skipping docstring coverage check.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title clearly summarizes the pull request’s main GC ref-root, demoted-home, and def-site store changes.
✨ 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 jitcode

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.

…f_gcmap

Neither of the two deadframe kinds was covered: the release is invisible to
every existing test, and the fix that introduced `borrowing` changes only which
of them performs it.

Asserts both directions on one off-GC frame — `borrowing` leaves a sentinel
`jf_gcmap` alone, `new` nulls it.  Verified to fail on the pre-fix behaviour:
making the release unconditional again fails the borrowing assertion.

Assisted-by: Claude
Two LABELs sharing one descr both demote the carried position, and a
`CallMallocNursery` between them collects. The trace reaches the second
LABEL by fall-through, so its seeding runs with the SSA value the demoted
raw held before the collection.

Re-storing that value put a pre-collection address back over the word the
collector had forwarded, and the guard below published it.

Assisted-by: Claude
@youknowone
youknowone merged commit 3fbebd7 into main Aug 19, 2026
17 checks passed
@youknowone
youknowone deleted the jitcode branch August 19, 2026 06:24
@github-actions

Copy link
Copy Markdown

🤖 Codex parity review

Static analysis of this diff vs the local RPython/PyPy sources (commit 2024e5c).
Updated: 2026-08-19T07:10:10.382Z

Files in the reviewed diff
majit/majit-backend-cranelift/src/compiler.rs
majit/majit-backend/src/deadframe.rs

1. Regressions to PyPy parity introduced by this patch

None.

2. Other mismatches introduced by this patch

None.

3. Pre-existing mismatches (already present before this patch)

None.

4. Structural adaptations

  • majit/majit-backend/src/deadframe.rs:122 ↔ rpython/jit/backend/llsupport/llmodel.py:270 — Rust must retain a moving JitFrame through OwnerRootGuard and distinguish a borrowed forced frame from an exit-owned frame. PyPy returns the resolved GCREF directly; the new owns_gcmap: false correctly leaves cleanup to the residual-call pop_gcmap, preserving the upstream lifetime semantics.

  • majit/majit-backend-cranelift/src/compiler.rs:10439 ↔ rpython/jit/backend/x86/regalloc.py:1360 — Cranelift SSA/root-slot lowering replaces PyPy’s register/frame-location allocator. Avoiding a second LABEL store for an already-demoted reference preserves the GC-forwarded frame home; PyPy achieves the same result by retaining the spilled frame location rather than re-materializing a stale register value.

  • majit/majit-backend-cranelift/src/compiler.rs:10897 ↔ rpython/jit/backend/llsupport/regalloc.py:710 — removing eager root-home writes for Ref-producing operations is a Rust/Cranelift lowering adaptation. The patch now publishes live references immediately before collecting calls, matching upstream before_call() spilling and get_gcmap() liveness handling rather than maintaining redundant stores after every definition.

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.

1 participant