Skip to content

jit: admit a retrace's close onto its attach token, align the retrace path with unroll.py, and fix the wasm livelock it exposed - #1134

Merged
youknowone merged 4 commits into
mainfrom
perf-bridge
Aug 10, 2026
Merged

jit: admit a retrace's close onto its attach token, align the retrace path with unroll.py, and fix the wasm livelock it exposed#1134
youknowone merged 4 commits into
mainfrom
perf-bridge

Conversation

@youknowone

@youknowone youknowone commented Aug 10, 2026

Copy link
Copy Markdown
Owner

Four commits. The first two are the measured change; the third is parity alignment with no
measured movement; the fourth is a baseline that belongs to main.

1. _jump_to_existing_trace was offered candidates it could never take

_jump_to_existing_trace walks jitcelltoken.target_tokens (unroll.py:171), so every
candidate upstream offers belongs to one JitCellToken. pyre seeds the candidates from
compiled_loops[green_key].front_target_tokens, a green-key side table that survives a
recompile, so a candidate can name a token of an earlier, retired compilation. The unroll
pass defended against that by discarding every close whose JUMP did not name the body
token this compilation had just pushed — including the legitimate ones.

This carries the token the artifact will be installed under and admits a close onto any of
its target tokens. compile_retrace resolves it from the source guard's own loop token,
which is where compile.py:797-811 attaches the result, so such a close stays inside one
code buffer. A compile_loop has no such token yet — compile.py:287-289 expresses the
same thing by resetting jitcell_token.target_tokens to [start_descr], leaving nothing
matchable — so it admits nothing beyond its own body.

synth/retrace_outer_loop_type_flip, the acceptance fixture landed with #1127, on all
three backends:

before after
loops_compiled 1 1
bridges_compiled 0 1
retraces_compiled 0 1
loops_aborted 2 0
guard_failures 590 201

2. The wasm livelock the admission exposed

Admitting the close made that fixture hang forever on wasm — flat 89 MB RSS, so a
constant-stack-depth livelock rather than a leak. PYRE_WASM_DUMP_ALL_TRACES showed the
retrace module tail-calling the loop with dispatch key 0, while the loop's entry
dispatch is br_table 0 (;@6;) 1 (;@5;) 3 (;@3;) 0 (;@6;) — key 0 lands on the function
entry, so the peeled target re-ran its preamble against mid-loop state and the induction
variable never advanced.

has_cross_loop_terminal_jump decided "does this JUMP leave the trace?" by the structural
proxy has_jump && !has_label, while the code generator decides the same question by descr
identity in find_loop_label_indexx86/assembler.py:2463's
target_token in self.target_tokens_currently_compiling. The two disagree on exactly one
shape: a trace that defines LABELs of its own and whose terminal JUMP names none of them,
which is precisely a retrace attached as a bridge. Codegen took the external arm; the
predicate had answered false, so neither caller ever called
resolve_cross_loop_jump_target and the arm ran with external_jump_key = 0 and
external_jump_slot = source_func_handle.

compile_bridge already documents this livelock above bridge_is_loop_closing ("the wasm
chaining hang on nbody / fannkuch") — it was reached through a shape its guard did not
recognise. Both callers now share the one token-based predicate.

3. Three divergences from optimize_peeled_loop, with no measured movement

optimize_peeled_loop (unroll.py:112-180) contains no retraced_count bookkeeping at
all and calls disable_retracing_if_max_retrace_guards exactly once.

  • The force_boxes=true retry (unroll.py:161-168) is unconditional. pyre had copied
    optimize_bridge's accounting (unroll.py:213-226) onto the loop path, so every loop
    compile whose first match missed spent one unit of the per-JitCellToken retrace budget
    upstream reserves for bridges. The budget's own increment lives on the bridge path
    (unroll.py:213-215) and is untouched.
  • disable_retracing_if_max_retrace_guards ran before the close ladder, and a second time
    over the combined preamble+body list. Upstream runs it after both jump_to_preamble
    early returns — only for a loop that closed — over self._newoperations, the peeled
    body. Either write sets retraced_count = u32::MAX, which compile_loop's early check
    reads as a permanent "skipping recompile" for that green key.
  • unroll.py:231-233 threads the bridge's own runtime_boxes into ExportedState, and
    unroll.py:153/:166 pass state.runtime_boxes to jump_to_existing_trace. The only
    assignment in pyre was on the compile_loop branch, so generate_guards saw an empty
    list on every retrace and each runtime-guided arm — GUARD_VALUE, GUARD_NONNULL,
    GUARD_CLASS/GUARD_NONNULL_CLASS, the IntBound::make_guards fallback — declined for want
    of a value to read. virtualstate.py:550-555 calls those runtime values the "educated
    guess" that picks guard-vs-retrace.

This commit moves nothing measurable. All three backends gate clean with no jit-stats
change, and an in-place revert A/B on dynasm with the arms interleaved reads 0.31-0.33s
reverted against 0.32-0.34s applied. retrace_limit defaults to 0
(rpython/rlib/jit.py:595), so the retrace legs stay dormant for code that does not raise
it. It is parity work, not a speedup.

4. synth/unary_negative's wasm baseline

guard_failures 13 -> 2. Not this branch's doing — reverting this branch's three metainterp
files and rebuilding the wasm module reads the same 2, so it belongs to #1131, which this
branch was rebased onto mid-session.

Gate

Full check.py on all three backends, on a freshly re-extracted LLBC, at the tip of this
branch: dynasm 415/415, cranelift 414/414, wasm 409/409 + the one IMPROVED above. No
SNAPDIFF in any log.

An earlier run of the same gate, at load 12-36, reported synth/list_pop_append and
cpython-suite test_pickletools failing; both went away at load 2.4 on identical code, and
main's own run at this branch's original parent fails list_pop_append on ubuntu and
windows.

authored by Claude

…hether the trace carries a LABEL

`has_cross_loop_terminal_jump` answered `has_jump && !has_label`, while the code
generator answers the same question by descr identity in
`find_loop_label_index` — `x86/assembler.py:2463`'s `target_token in
self.target_tokens_currently_compiling`. The two disagree on a trace that
defines LABELs of its own and whose terminal JUMP names none of them: codegen
takes the external arm and emits `return_call_indirect(external_jump_slot)`,
but the predicate answered false, so neither `compile_loop` nor `compile_bridge`
called `resolve_cross_loop_jump_target` and the arm ran with
`external_jump_key = 0` and `external_jump_slot = source_func_handle`.

Key 0 re-enters the target at its function entry, so a peeled target re-runs its
preamble against mid-loop state and the induction variable never advances — the
livelock `compile_bridge` already documents above `bridge_is_loop_closing`,
reached through a shape the predicate did not recognise. A tail call keeps the
stack and heap flat, so it presents as a hang, not a crash.

Both callers keep the one predicate. Widening is safe at each: they either
resolve a real target through `resolve_cross_loop_jump_target` — adopting its
frozen frame geometry, which a tail call requires anyway — or decline with
`BackendError::Unsupported`, dropping the trace back to the interpreter.

Assisted-by: Claude
…en this compilation attaches to

`_jump_to_existing_trace` walks `jitcelltoken.target_tokens` (unroll.py:171), so
every candidate upstream offers belongs to one JitCellToken. pyre seeds the
candidates from `compiled_loops[green_key].front_target_tokens`, a green-key side
table that survives a recompile, so a candidate can name a token of an earlier,
retired compilation. The unroll pass therefore discarded every close whose JUMP
did not name the body token this compilation had just pushed.

Carry the token the artifact will be installed under and admit a close onto any
of its target tokens. `compile_retrace` resolves it from the source guard's own
loop token, which is where `compile.py:797-811` attaches the result, so such a
close stays inside one code buffer. A `compile_loop` has no such token yet —
`compile.py:287-289` expresses the same thing by resetting
`jitcell_token.target_tokens` to `[start_descr]`, leaving nothing matchable — so
it admits nothing beyond its own body.

`synth/retrace_outer_loop_type_flip` goes from `loops_aborted=2
retraces_compiled=0 guard_failures=590` to `loops_aborted=0 retraces_compiled=1
bridges_compiled=1 guard_failures=201` on all three backends.

Assisted-by: Claude
@coderabbitai

coderabbitai Bot commented Aug 10, 2026

Copy link
Copy Markdown

Review Change Stack

Walkthrough

The change fixes cross-loop jump classification and unroll loop-close validation by using local label descriptors and JitCellToken ownership. Benchmark statistics now record successful bridge and retrace compilation.

Changes

Cross-loop target handling

Layer / File(s) Summary
WASM label identity resolution
majit/majit-backend-wasm/src/codegen.rs, majit/majit-backend-wasm/src/lib.rs
The WASM backend resolves cross-loop jumps by matching target descriptors to local labels. A regression test covers foreign and matching descriptors.
Unroll attachment token validation
majit/majit-metainterp/src/optimizeopt/unroll.rs, majit/majit-metainterp/src/pyjitpl.rs
Unroll configuration records the attached JitCellToken. Loop closes are accepted only for the current body or a target owned by that token.
Retrace benchmark statistics
pyre/bench/synth/retrace_outer_loop_type_flip.*.jitstats
All three backends report one bridge, one retrace, 201 guard failures, and zero aborted loops.

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

Possibly related PRs

  • youknowone/pyre#960: Both changes update unroll loop-close handling and compilation-token ownership.
  • youknowone/pyre#893: Both changes update WASM loop-label and jump-target resolution.
  • youknowone/pyre#945: Both changes modify retrace resume handling and cross-loop JIT target behavior.

Poem

A rabbit hops through loops of code,
Labels guide the proper road.
Tokens guard each bridge and trail,
Foreign jumps now safely fail.
Retraces bloom, stats turn bright—
Thump-thump, the paths compile just right!

🚥 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 retrace close admission change and the related wasm livelock fix.
✨ 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 perf-bridge

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.

@chatgpt-codex-connector chatgpt-codex-connector 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.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: b87ad9e8aa

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment on lines +254 to +265
/// `compile.py:355` — the `JitCellToken` this compilation's artifact will be
/// installed under, when there is one. `compile_retrace` resolves it from
/// `get_procedure_token(greenkey)` and the result is attached as a bridge to
/// that same token (`compile.py:797-811`), so a close onto one of its
/// `target_tokens` stays inside one code buffer.
///
/// `None` for a fresh `compile_loop`, which mints its own token afterwards.
/// `compile.py:287-289` expresses the same thing by *resetting*
/// `jitcell_token.target_tokens` to `[start_descr]`, leaving nothing for
/// `_jump_to_existing_trace` to match; pyre seeds the previous
/// compilation's tokens instead, so the distinction has to be carried here.
pub attach_jitcell_token_number: Option<u64>,

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P1 Badge Port target-token ownership instead of adding a side channel

This adds a second, numeric ownership channel solely to compensate for sourcing target_tokens from the green-key front_target_tokens side table, whereas upstream passes the exact JitCellToken into UnrolledLoopData and scans that object's target_tokens directly. The change therefore preserves the structural divergence and makes close admission depend on separately maintained and later restamped token numbers; restore the upstream JCT-owned target-token list rather than layering this special case over the side table.

AGENTS.md reference: AGENTS.md:L231-L233

Useful? React with 👍 / 👎.

@github-actions

github-actions Bot commented Aug 10, 2026

Copy link
Copy Markdown

🤖 Codex parity review

Static analysis of this diff vs the local RPython/PyPy sources (commit 66199e0).
Updated: 2026-08-10T02:59:02.746Z

Files in the reviewed diff
majit/majit-backend-wasm/src/codegen.rs
majit/majit-backend-wasm/src/lib.rs
majit/majit-metainterp/src/optimizeopt/optimizer.rs
majit/majit-metainterp/src/optimizeopt/unroll.rs
majit/majit-metainterp/src/pyjitpl.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-wasm/src/lib.rs:1462 ↔ rpython/jit/backend/x86/assembler.py:2461 — Rust determines a local loop close by matching the JUMP descriptor against LABEL descriptors in the current IR; PyPy queries the assembler’s mutable target_tokens_currently_compiling. This is the required structured-Wasm representation of the same local-versus-external target distinction.

  • majit/majit-metainterp/src/optimizeopt/unroll.rs:254 ↔ rpython/jit/metainterp/compile.py:288 — Rust carries attach_jitcell_token_number because pyre retains prior target tokens in a green-key side table, whereas PyPy resets jitcell_token.target_tokens to the new start token before loop optimization.

  • majit/majit-metainterp/src/optimizeopt/optimizer.rs:4262 ↔ rpython/jit/metainterp/optimizeopt/unroll.py:231 — Rust copies runtime_boxes into an exported state because its flat OpRef IR does not intrinsically carry PyPy Box runtime values. The added copy restores the runtime-box channel used by PyPy’s export_state.

@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: 1

🤖 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 `@majit/majit-metainterp/src/optimizeopt/unroll.rs`:
- Around line 254-265: Correct the documentation for attach_jitcell_token_number
to state that its setter derives the token from retrace_resumekey.source_descr
via majit_backend::descr_owning_jct, representing the guard’s owning loop token.
Remove the claim that compile_retrace resolves this field through
get_procedure_token(greenkey), while distinguishing that live procedure-token
lookup as the separate loop_jitcell_token value.
🪄 Autofix

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: Organization UI

Review profile: ASSERTIVE

Plan: Pro Plus

Run ID: 27410962-789f-4110-912c-cb0b1137da73

📥 Commits

Reviewing files that changed from the base of the PR and between 9d46d95 and b87ad9e.

📒 Files selected for processing (7)
  • majit/majit-backend-wasm/src/codegen.rs
  • majit/majit-backend-wasm/src/lib.rs
  • majit/majit-metainterp/src/optimizeopt/unroll.rs
  • majit/majit-metainterp/src/pyjitpl.rs
  • pyre/bench/synth/retrace_outer_loop_type_flip.cranelift.jitstats
  • pyre/bench/synth/retrace_outer_loop_type_flip.dynasm.jitstats
  • pyre/bench/synth/retrace_outer_loop_type_flip.wasm.jitstats

Comment on lines +254 to +265
/// `compile.py:355` — the `JitCellToken` this compilation's artifact will be
/// installed under, when there is one. `compile_retrace` resolves it from
/// `get_procedure_token(greenkey)` and the result is attached as a bridge to
/// that same token (`compile.py:797-811`), so a close onto one of its
/// `target_tokens` stays inside one code buffer.
///
/// `None` for a fresh `compile_loop`, which mints its own token afterwards.
/// `compile.py:287-289` expresses the same thing by *resetting*
/// `jitcell_token.target_tokens` to `[start_descr]`, leaving nothing for
/// `_jump_to_existing_trace` to match; pyre seeds the previous
/// compilation's tokens instead, so the distinction has to be carried here.
pub attach_jitcell_token_number: Option<u64>,

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 | 🔵 Trivial | ⚡ Quick win

Fix the field doc: attach_jitcell_token_number is not resolved via get_procedure_token(greenkey).

The comment states that compile_retrace resolves this token from get_procedure_token(greenkey). The actual (and only) setter, in majit/majit-metainterp/src/pyjitpl.rs (lines 7679-7690), derives it from retrace_resumekey.source_descr's owning JitCellToken through majit_backend::descr_owning_jct. That is the guard's own loop token, which can differ from the loop's current live procedure token returned by get_procedure_token(greenkey) if the loop was recompiled since the guard was created. compile_retrace already resolves the live procedure token separately, into the unrelated loop_jitcell_token variable.

Update the doc to describe the actual derivation path so future readers auditing RPython parity do not rely on the wrong resolution source.

📝 Proposed doc fix
     /// `compile.py:355` — the `JitCellToken` this compilation's artifact will be
-    /// installed under, when there is one. `compile_retrace` resolves it from
-    /// `get_procedure_token(greenkey)` and the result is attached as a bridge to
-    /// that same token (`compile.py:797-811`), so a close onto one of its
+    /// installed under, when there is one. For a guard-originated retrace,
+    /// `compile_retrace` resolves it from the source guard's own descr-owning
+    /// `JitCellToken` (`descr_owning_jct`), and the result is attached as a
+    /// bridge to that same token (`compile.py:797-811`), so a close onto one of its
     /// `target_tokens` stays inside one code buffer.
📝 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
/// `compile.py:355` — the `JitCellToken` this compilation's artifact will be
/// installed under, when there is one. `compile_retrace` resolves it from
/// `get_procedure_token(greenkey)` and the result is attached as a bridge to
/// that same token (`compile.py:797-811`), so a close onto one of its
/// `target_tokens` stays inside one code buffer.
///
/// `None` for a fresh `compile_loop`, which mints its own token afterwards.
/// `compile.py:287-289` expresses the same thing by *resetting*
/// `jitcell_token.target_tokens` to `[start_descr]`, leaving nothing for
/// `_jump_to_existing_trace` to match; pyre seeds the previous
/// compilation's tokens instead, so the distinction has to be carried here.
pub attach_jitcell_token_number: Option<u64>,
/// `compile.py:355` — the `JitCellToken` this compilation's artifact will be
/// installed under, when there is one. For a guard-originated retrace,
/// `compile_retrace` resolves it from the source guard's own descr-owning
/// `JitCellToken` (`descr_owning_jct`), and the result is attached as a
/// bridge to that same token (`compile.py:797-811`), so a close onto one of its
/// `target_tokens` stays inside one code buffer.
///
/// `None` for a fresh `compile_loop`, which mints its own token afterwards.
/// `compile.py:287-289` expresses the same thing by *resetting*
/// `jitcell_token.target_tokens` to `[start_descr]`, leaving nothing for
/// `_jump_to_existing_trace` to match; pyre seeds the previous
/// compilation's tokens instead, so the distinction has to be carried here.
pub attach_jitcell_token_number: Option<u64>,
🤖 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 `@majit/majit-metainterp/src/optimizeopt/unroll.rs` around lines 254 - 265,
Correct the documentation for attach_jitcell_token_number to state that its
setter derives the token from retrace_resumekey.source_descr via
majit_backend::descr_owning_jct, representing the guard’s owning loop token.
Remove the claim that compile_retrace resolves this field through
get_procedure_token(greenkey), while distinguishing that live procedure-token
lookup as the separate loop_jitcell_token value.

…time boxes, and neither the budget nor the disable sentinel from the loop path

Three divergences from `optimize_peeled_loop` (unroll.py:112-180), which contains
no `retraced_count` bookkeeping at all and calls
`disable_retracing_if_max_retrace_guards` exactly once.

The `force_boxes=true` retry at unroll.py:161-168 is unconditional. pyre had
copied `optimize_bridge`'s accounting (unroll.py:213-226) onto the loop path, so
every loop compile whose first match missed spent one unit of the per-JitCellToken
retrace budget that upstream reserves for bridges. The budget's own increment
lives on the bridge path (`tok.set_retraced_count(tok.get_retraced_count() + 1)`,
unroll.py:213-215) and is untouched. The two arms differed only in that
bookkeeping, so they collapse into one call.

`disable_retracing_if_max_retrace_guards` ran before the close ladder, and a
second time over the combined preamble+body list. Upstream runs it after both
`jump_to_preamble` early returns — only for a loop that closed — over
`self._newoperations`, the peeled body. Either write sets
`retraced_count = u32::MAX`, which the early check in `compile_loop` reads as a
permanent "skipping recompile" for that green key.

`unroll.py:231-233` threads the bridge's own `runtime_boxes` into `ExportedState`,
and unroll.py:153/166 pass `state.runtime_boxes` to `jump_to_existing_trace`. The
only assignment in pyre was on the `compile_loop` branch, so `generate_guards` saw
an empty list on every retrace and each runtime-guided arm — GUARD_VALUE,
GUARD_NONNULL, GUARD_CLASS/GUARD_NONNULL_CLASS, the `IntBound::make_guards`
fallback — declined for want of a value to read. The length-mismatch fallback in
unroll.rs is left alone; this makes its "a trace with no recorded JUMP" comment
true again.

No jit-stats and no wallclock movement across the corpus: all three backends gate
clean, and an in-place revert A/B on dynasm with the arms interleaved reads
0.31-0.33s reverted against 0.32-0.34s applied. `retrace_limit` defaults to 0
(`rpython/rlib/jit.py:595`), so the retrace legs stay dormant for code that does
not raise it.

Assisted-by: Claude
`guard_failures` 13 -> 2 with `loops_compiled` unchanged at 2. Not this branch's
doing: reverting this branch's three metainterp files and rebuilding the wasm
module reads the same 2, so the move belongs to the base this branch was rebased
onto — `jit: residualise the whole int-box tail, and decline a kept-stack branch
holding a NULL ConstPtr` (#1131). `check.py` reports the drop as IMPROVED and
refuses to pass until the baseline is recorded.

Assisted-by: Claude
@youknowone youknowone changed the title jit: admit a retrace's close onto its attach token, and fix the wasm livelock it exposed jit: admit a retrace's close onto its attach token, align the retrace path with unroll.py, and fix the wasm livelock it exposed Aug 10, 2026
@youknowone
youknowone merged commit cccbf24 into main Aug 10, 2026
15 of 17 checks passed
@youknowone
youknowone deleted the perf-bridge branch August 10, 2026 08:31
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