Skip to content

jit: fix two exception-path compile defects (last-exc lookahead, except-tuple pin) - #706

Merged
youknowone merged 7 commits into
mainfrom
perf-exc
Jul 22, 2026
Merged

youknowone merged 7 commits into
mainfrom
perf-exc

Conversation

@youknowone

@youknowone youknowone commented Jul 21, 2026 •

Copy link
Copy Markdown
Collaborator

Two defects made ordinary try/except shapes fall off the compiled path, plus two follow-up fixes from review.

1. The last-exc lookahead was control-flow-blind

reads_last_exc_before_next_catch decides whether a residual call may clear the walker's recorded exception. It advanced by op.next_pc only, but jitcode is one flat byte stream whose blocks are stitched by goto/L — the physically next op is frequently not the successor.

Instrumented on a raise/except E as e:/raise e frame: the scan from the residual call at jitcode pc=2040 ran past pc=2077 goto/L → 1017 into the unrelated block at 2080, found that block's catch_exception/L, and answered "no read". The exception was cleared; the real successor 1017 → 1020 is last_exc_value/>r, so every trace died with LastExcValueWithoutActiveException. permanent=false, so the frame retraced and re-aborted for the whole run — loops_compiled=0.

Following labels: 3.3x @80k, 8.8x @200k, 18.0x @400k.

2. except (A, B): pinned a rebuilt tuple by identity

The clause builds its match target with BUILD_TUPLE on every visit, so the tuple is a fresh object each time. try_walker_fold_check_exc_match pinned it with GuardValue(match_op, const_ref(<the traced tuple's address>)) — unsatisfiable. Guard failures came in at roughly N/2 (the nursery bump sometimes hands back the same address, which is what made it look intermittent).

Discriminator: hoisting EXC = (TypeError, ValueError) out of the loop took guard failures 49900 → 1; except (TypeError,) with a single element was equally slow, so it was never about tuple length or multi-class matching.

Fix pins the elements instead. Two object elements land in W_SpecialisedTupleObject_oo, whose inline value0/value1 are immutable → GetfieldGcPureR → folded away entirely; other arities go through wrappeditems behind an arraylen guard. Non-tuple targets keep the identity pin, which does hold for a loaded target.

dynasm 0.627 → 0.067s (9.4x), cranelift 0.484 → 0.072s, guard failures 29901 → 2, bridges 149 → 0 (pypy 0.059s). 46/79 corpus programs improved, none regressed. The inline tuple-except form appears at 510 sites across 200 files of the bundled lib-python.

Bench added: pyre/bench/synth/except_tuple_clause_hot.py.

3–4. Review follow-ups

  • The lookahead followed only goto/L and goto_if_not/iL; the other 23 label-bearing ops fell through to the physically next op, switch/id successors were never considered, and reraise/ was classified as not reading the exception although opimpl_reraise re-raises last_exc_value. The label offset is now derived from the key's operand signature, and an un-decodable successor set answers "read" — over-reporting leaves a recorded exception standing, under-reporting drops one a later read needs.
  • walker_guard_exc_match_tuple_items emitted the layout GuardClass before reading each element, so an element bail-out returned false with the class already pinned; the caller gates its own identity pin on is_class_known, which dropped every value guard from the fold. Elements are now read before anything is recorded.

Not included, deliberately

Removing the for_iter_frame_has_raising_named_handler gate was tried on top of these fixes. It measured 2.5–4.9x faster with zero aborts and passed 1088 differential checks on both backends — and it is still wrong. The gate blocks every abort, not only the one fixed here, and any abort in a FOR_ITER frame drops the in-flight item. A handler body that mutates a container hits InplaceContainerMutationUnsupported and the loop returns 3999 items instead of 4000 on both backends; a second shape aborts the process. The differential corpus missed it because no generated handler body mutated a container. The gate stays.

Verification

check.py 245/245 dynasm + 245/245 cranelift; pyre-jit-trace 321, pyre-jit 241, majit-metainterp 1401 (dynasm) / 1399 (cranelift); 688 differential checks across 348 generated exception shapes on both backends, 0 wrong.

Known, pre-existing, out of scope

A JIT-compiled same-frame raise/except never attaches __traceback__, so e.__traceback__ reads None inside the handler once the loop compiles (4000 → 201 on both backends; e.args and type(e) are unaffected). Confirmed pre-existing by rebuilding with origin/main's jitcode_dispatch/mod.rs — same result. Filed as follow-up work, untouched here.

🤖 Generated with Claude Code

Summary by CodeRabbit

  • Bug Fixes

    • Improved exception handling across branching code paths, preventing incorrect exception state tracking.
    • Improved support for matching exceptions against tuples of exception classes.
  • Performance

    • Refined exception-match optimization to reduce unnecessary runtime checks while preserving correct behavior.
  • Benchmarks

    • Added coverage to compare tuple-based and single-class exception handling performance.

@coderabbitai

coderabbitai Bot commented Jul 21, 2026 •

Copy link
Copy Markdown

Review Change Stack

Warning

Review limit reached

@youknowone, you've reached your PR review limit, so we couldn't start this review.

Next review available in: 44 minutes

Enable usage-based reviews in Billing to review now. Otherwise, wait until the next included review is available.
You're only billed for reviews past your plan's rate limits ($0.25/file).

How can I continue?

After more reviews become available, a review can be triggered using the @coderabbitai review command as a PR comment. Alternatively, push new commits to this PR.

To avoid repeated limits, reduce automatic review volume by pausing incremental auto-reviews earlier, using label-based review opt-in, excluding WIP or generated PR titles, or requesting reviews manually when the PR is ready. If your team needs uninterrupted high-volume reviews, an organization admin can enable usage-based reviews.

How do review limits work?

CodeRabbit enforces per-developer PR review limits for each organization. Most developers receive the normal plan review availability.

For paid Pro and Pro+ PR reviews, CodeRabbit uses adaptive limits for sustained high-volume activity. When a developer's recent PR review activity reaches the 95th percentile or higher among CodeRabbit users, additional reviews become available more gradually as earlier reviews age out of the rolling window.

Please refer docs for additional details.

Review details
⚙️ Run configuration

Configuration used: Organization UI

Review profile: ASSERTIVE

Plan: Pro

Run ID: 8d544aad-4a8c-4467-bac5-f83e5398cafa

📥 Commits

Reviewing files that changed from the base of the PR and between 5a37391 and a53cb16.

📒 Files selected for processing (4)
  • majit/majit-metainterp/src/history.rs
  • pyre/bench/synth/except_tuple_clause_hot.py
  • pyre/pyre-jit-trace/src/jitcode_dispatch/mod.rs
  • pyre/pyre-jit-trace/src/jitcode_dispatch/specialize.rs

Walkthrough

Adds a benchmark for tuple and single-class exception clauses, refines JIT exception-match tuple guarding, and replaces linear exception-state scanning with label-aware control-flow traversal.

Changes

Exception matching

Layer / File(s) Summary
Exception clause benchmark
pyre/bench/synth/except_tuple_clause_hot.py
Compares tuple-based and single-class exception handlers over the same loop workload and prints both totals.
JIT exception matching and control flow
pyre/pyre-jit-trace/src/jitcode_dispatch/mod.rs, pyre/pyre-jit-trace/src/jitcode_dispatch/specialize.rs
Tracks label-based successors when analyzing exception reads and pins tuple exception-match elements with guarded loads and value guards.

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

Possibly related issues

  • youknowone/pyre issue 595: The updated exception control-flow walk addresses misattribution across exception-handler bytecode blocks.

Possibly related PRs

Poem

A rabbit hops where caught errors play,
Tuple and single guards mark the way.
Labels guide the walker through the night,
Element pins keep matches tight.
“Thump!” says Bun—the benchmark’s bright!

🚥 Pre-merge checks | ✅ 5
✅ Passed checks (5 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title clearly summarizes the two main JIT exception-path fixes introduced in the changeset.
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.
✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch perf-exc

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.

@github-actions

github-actions Bot commented Jul 21, 2026 •

Copy link
Copy Markdown

🤖 Codex parity review

Static analysis of this diff vs the local RPython/PyPy sources (commit a53cb16).
Updated: 2026-07-21T21:12:25.662Z

Files in the reviewed diff
majit/majit-metainterp/src/history.rs
pyre/bench/synth/except_tuple_clause_hot.py
pyre/pyre-jit-trace/src/jitcode_dispatch/mod.rs
pyre/pyre-jit-trace/src/jitcode_dispatch/specialize.rs

1. Regressions to PyPy parity introduced by this patch

None.

2. Other mismatches introduced by this patch

  • pyre/pyre-jit-trace/src/jitcode_dispatch/specialize.rs:2152 ↔ pypy/interpreter/baseobjspace.py:1196 — tuple guarding is only one level deep. PyPy recursively evaluates tuple members (exception_match(..., w_t)); for except ((A, B), C):, a freshly built inner (A, B) is guarded by identity at line 2157 and therefore side-exits on every iteration. The new patch fixes a flat tuple clause but not recursively nested tuple clauses.

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

  • pyre/pyre-jit-trace/src/jitcode_dispatch/mod.rs:2288 ↔ rpython/jit/metainterp/pyjitpl.py:2610 — walker resume snapshots conservatively include every non-empty register instead of using the per-PC live-box set that RPython passes through capture_resumedata. This is explicitly documented as over-capture; it differs structurally from RPython’s liveness-precise resume encoding.

4. Structural adaptations

  • pyre/pyre-jit-trace/src/jitcode_dispatch/specialize.rs:2098 ↔ pypy/objspace/std/specialisedtupleobject.py:161 — the patch must discriminate Rust’s physical W_SpecialisedTupleObject_oo versus array-backed tuple layouts before guarding elements. PyPy uses object-space classes and makespecialisedtuple2; the Rust layout guards are a necessary implementation-language adaptation.

  • majit/majit-metainterp/src/history.rs:2406 ↔ rpython/jit/metainterp/opencoder.py:819 — extra_virtual_roots: Vec::new() is a pyre-specific snapshot field with no RPython snapshot argument. It accommodates pyre’s detached virtual backing objects; this call correctly supplies no such roots for the multi-frame path.

@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: 5a3739195c

ℹ️ 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 +2097 to +2102
if !started.contains(&target) {
started.push(target);
pending.push(target);
}
pc = op.next_pc;
true

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2 Badge Make last-exc preservation path-specific

When the folded CHECK_EXC_MATCH is followed by goto_if_not, this now queues the jump target and returns true if either successor reads last_exc_value. That over-preserves the active exception on traces that take the other branch; for example, a matching except TypeError: whose sibling except ValueError as e: is the jump target will keep ctx.last_exc_value even though the interpreter-side residual call would have cleared it on the taken path, so a residual-free handler can hit the later catch_exception/L with a stale active exception and abort as CatchExceptionWithActiveException. The lookahead needs to use the concrete branch outcome already available to the walker rather than OR-ing both successors.

AGENTS.md reference: AGENTS.md:L16-L18

Useful? React with 👍 / 👎.

@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

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (1)
pyre/pyre-jit-trace/src/jitcode_dispatch/specialize.rs (1)

2219-2230: 🎯 Functional Correctness | 🟠 Major | ⚡ Quick win

Do not treat is_class_known as an identity guard.

When tuple pinning declines, this condition skips GuardValue whenever the heapcache knows only match_op’s physical class. Rebinding a handler global from one exception class to another can preserve that physical class, so the precomputed match result may be reused without detecting the identity change. Only tuple-element pinning should suppress the container identity guard; otherwise emit GuardValue for every non-constant fallback target.

Proposed fix
     if !match_op.is_constant()
         && !walker_guard_exc_match_tuple_items(ctx, op_pc, match_op, match_type)?
-        && !ctx.trace_ctx.heap_cache().is_class_known(match_op)
     {
🤖 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 `@pyre/pyre-jit-trace/src/jitcode_dispatch/specialize.rs` around lines 2219 -
2230, Update the guard condition around match_op so is_class_known no longer
suppresses GuardValue. After match_op.is_constant() and
walker_guard_exc_match_tuple_items(...) both decline, always record the identity
guard, capture the snapshot, and replace the boxed value for every non-constant
fallback target.
🤖 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 `@pyre/pyre-jit-trace/src/jitcode_dispatch/mod.rs`:
- Around line 2068-2119: The decode_op_at failure path in
reads_last_exc_before_next_catch currently treats undecodable successors as
safe; change the None branch to return true conservatively, while preserving the
existing traversal behavior for successfully decoded operations.

In `@pyre/pyre-jit-trace/src/jitcode_dispatch/specialize.rs`:
- Around line 2060-2103: Update walker_guard_exc_match_tuple_items so the
SPECIALISED_TUPLE_OO branch validates that match_type’s w_class is the canonical
specialized tuple class before calling walker_guard_exc_match_tuple_class or
reading inline values. Reuse the existing canonical-w_class validation from the
array-backed tuple branch and return false on mismatch, preserving the identity
fallback for unsupported tuple subclasses.

---

Outside diff comments:
In `@pyre/pyre-jit-trace/src/jitcode_dispatch/specialize.rs`:
- Around line 2219-2230: Update the guard condition around match_op so
is_class_known no longer suppresses GuardValue. After match_op.is_constant() and
walker_guard_exc_match_tuple_items(...) both decline, always record the identity
guard, capture the snapshot, and replace the boxed value for every non-constant
fallback target.
🪄 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: Organization UI

Review profile: ASSERTIVE

Plan: Pro

Run ID: 3a9880df-9c3f-4963-a488-3fec1855dd25

📥 Commits

Reviewing files that changed from the base of the PR and between 0b487f7 and 5a37391.

📒 Files selected for processing (3)
  • pyre/bench/synth/except_tuple_clause_hot.py
  • pyre/pyre-jit-trace/src/jitcode_dispatch/mod.rs
  • pyre/pyre-jit-trace/src/jitcode_dispatch/specialize.rs

`reads_last_exc_before_next_catch` advanced by `op.next_pc` only. The
jitcode is one flat byte stream whose blocks are stitched by `goto/L`
labels, so that scan leaves the current block at its terminator and
answers from whatever block the assembler placed next.

A `residual_call` inside an except-handler hit this: its fall-through
ends in a `goto/L` back to the block holding the handler's
`last_exc_value/>r`, but the scan read past the goto into an unrelated
block, found that block's `catch_exception/L`, and reported no pending
read. The caller then cleared `last_exc_value`, and the real successor
aborted the walk with `LastExcValueWithoutActiveException` on every
attempt, so the frame never compiled.

Follow the labels: `goto/L` continues at its target, `goto_if_not/iL`
queues its taken arm and continues on the fall-through, and
`catch_exception/L`, `raise`, `reraise` and the `*_return` family end a
path. Targets are visited once, bounding the walk across back edges.

An `except E as e:` handler that raises, in a `while` frame, now
compiles instead of aborting: 3.3x at N=80k, 8.8x at 200k, 18.0x at
400k against the same binary with the JIT off.

Assisted-by: Claude
The `CHECK_EXC_MATCH` fold pinned its match target with a `GuardValue`
against the object it saw while tracing. `except (A, B):` lowers to
`BUILD_TUPLE`, which allocates a fresh tuple on every visit, so that
guard compares against an address the clause can no longer produce.

The handler then side-exited once per visit. On a loop raising every
iteration the trace collected a guard failure per visit and a bridge per
few hundred, and running the clause compiled came out slower than
interpreting it.

The container is not what the match reads. `check_exc_match_against`
walks the elements, and the elements are what a rebound handler global
would change; the tuple wrapping them is rebuilt either way. Guard those
instead: `GuardClass` on the layout, then a `GuardValue` per element
against the class seen while tracing. `w_tuple_new` gives two object
elements a `W_SpecialisedTupleObject_oo` with inline immutable `value0` /
`value1`, so those reads lower to `GetfieldGcPureR` and a tuple built
from constants leaves nothing behind; other arities read through
`wrappeditems` with an `arraylen` guard. A match target that is neither
layout keeps the identity pin, which holds for a target that is loaded
rather than built.

`except_tuple_clause_hot`, 60000 iterations, best of 3:

  dynasm     0.627s -> 0.067s   guard failures 29901 -> 2, bridges 149 -> 0
  cranelift  0.484s -> 0.072s
  pypy       0.059s

Across 79 exception-shape programs: 46 improved, 33 unchanged, none
regressed.

Assisted-by: Claude
reads_last_exc_before_next_catch followed only goto/L and goto_if_not/iL;
the other 23 label-bearing ops fell through to the physically next op, the
successors of switch/id were never considered, and reraise/ was classified
as ending a path without reading the exception although opimpl_reraise
re-raises last_exc_value.

Derive the label operand offset from the key's operand signature so every
conditional branch queues its taken arm, answer true for reraise/, and
answer true whenever the successor set is not decodable. Over-reporting a
read leaves a recorded exception standing; under-reporting drops one a
later read still needs.

Assisted-by: Claude
walker_guard_exc_match_tuple_items emitted the layout GuardClass and the
array-length GuardValue before reading each element, so the element read's
bail-out returned false with the match target's class already pinned. The
caller gates its own identity pin on is_class_known, so that arm dropped
every value guard from the fold.

Collect the concrete elements first and bail before recording anything.

Assisted-by: Claude
goto_if_exception_mismatch/iL was treated as an ordinary conditional
branch, but opimpl_goto_if_exception_mismatch reads last_exc_value and
asserts it before testing the class, exactly like opimpl_last_exc_value.
The scan could therefore report no later read and let the exception be
cleared before it.

decode_op_at also returns None for an opcode byte outside the instruction
table and for a payload the stream is too short to hold, not only past the
end of the code, so answering false there under-approximated the reads the
rest of the scan is careful to over-approximate.

Assisted-by: Claude
walker_guard_exc_match_tuple_items documents that a tuple subclass whose
w_class diverges returns false so the caller pins the object identity, but
only the array-backed branch enforced it; the specialised arity-2 branch
read value0/value1 through whatever class the target carried. Hoist the
check ahead of the layout split so it covers both.

Assisted-by: Claude
capture_snapshot_for_last_guard_op_multi_frame_with_vable_vref builds a
recorder::Snapshot without the extra_virtual_roots field added to the
struct in the same commit that introduced it, so the crate does not
compile. Pass an empty vector, as the sibling multi-frame path does.

Assisted-by: Claude
@youknowone

Copy link
Copy Markdown
Collaborator Author

Went through every review finding against the source and, where a shape was named, against a running reproducer. Dispositions below.

Fixed

goto_if_exception_mismatch/iL was misclassified (Codex parity §3). Confirmed against rpython/jit/metainterp/pyjitpl.py:1677-1681 — opimpl_goto_if_exception_mismatch does last_exc_value = metainterp.last_exc_value; assert last_exc_value before testing the class, i.e. it reads the exception exactly like opimpl_last_exc_value (:1716-1719, same assert). The scan treated it as an ordinary conditional branch and could report no later read, letting the exception be cleared before it. Now answers "read".

decode_op_at returning None is not only end-of-stream (CodeRabbit). Confirmed at jitcode_runtime.rs:634-637: it also returns None for an opcode byte outside INSNS_BYTE_TO_OPNAME and for a payload the stream is too short to hold. Answering false there under-approximated the reads the rest of the scan deliberately over-approximates. Now answers "read".

w_class check missing on the specialised arity-2 branch (CodeRabbit). The helper's own doc says a tuple subclass whose w_class diverges must return false so the caller pins identity, but only the array-backed branch enforced it. Hoisted the check ahead of the layout split so it covers both. Not reachable today — all three constructors in specialisedtupleobject.rs (:109, :141, :184) hardcode w_class: get_instantiate(&TUPLE_TYPE) — so this closes a contract gap rather than a live bug.

Not reproducible — kept as is

Over-preserving on the untaken sibling branch (Codex inline, P2). The concern is that OR-ing both successors of a conditional keeps last_exc_value on a trace whose taken path would have cleared it, reaching a later catch_exception/L with a stale exception. Built the exact shape described — a matched except TypeError: whose sibling except ValueError as e: is the jump target — plus four variants (reversed clause order, three clauses, a re-raising sibling, a tuple-clause sibling), all at N=60000. Every one is correct against the interpreter with loops_compiled=1, loops_aborted=0 on both backends; the 348-program differential corpus is likewise abort-free.

The suggested fix — use the concrete branch outcome instead of OR-ing — is not available at the decision point: the clear/preserve choice is made at the residual call, before the branch condition (frequently derived from that call's own result) has been evaluated. A design that defers the clear to the next catch_exception/L would be the real path-specific answer; that is a larger change than this PR, and nothing measured so far motivates it.

The preservation is also narrower than it may read: it is gated on saved_last_exc_value.is_some(), so it only applies when an exception is already recorded.

Deferred, with the blocker named

RvmprofCode collapses to None, dropping cintf.jit_rvmprof_code(arg1, arg2) (Codex parity §3, pre-existing). Real divergence from pyjitpl.py:2547-2555. Calling the hook needs the frame's registers_i, and try_catch_exception_at / finishframe_lookahead_at take only &[u8] — wiring a WalkContext through the finishframe lookahead touches call sites unrelated to this PR's subject. majit_translate::rlib::rvmprof::cintf::jit_rvmprof_code is a no-op unless an embedder installs a hook, and pyre installs none, so nothing observable is lost today.

Also in this branch

main at 57b01d0e8bd does not compile: capture_snapshot_for_last_guard_op_multi_frame_with_vable_vref (history.rs:2406) builds a recorder::Snapshot without the extra_virtual_roots field that the same commit (0364c4679f5) added to the struct. Carried a one-line fix here — an empty vector, matching the sibling multi-frame path at :2368 — because nothing builds without it. Happy to drop it if it is being fixed on main.

Verification after these changes

check.py 245/245 dynasm + 245/245 cranelift; pyre-jit-trace 321, pyre-jit 241, majit-metainterp 1401 / 1399; 688 differential checks over 348 generated exception shapes on both backends, 0 wrong; the two iteration-drop / crash reproducers that reverted the gate removal still match the interpreter.

— commented by Claude

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