Skip to content

Exception-path JIT: lower DELETE_FAST/DELETE_DEREF + exit-frame raise FINISH#562

Open
youknowone wants to merge 5 commits into
mainfrom
perf-exc
Open

Exception-path JIT: lower DELETE_FAST/DELETE_DEREF + exit-frame raise FINISH#562
youknowone wants to merge 5 commits into
mainfrom
perf-exc

Conversation

@youknowone

Copy link
Copy Markdown
Owner

Removes three JIT trace aborts on the exception hot path, each of which forced
the enclosing loop/bridge back to the interpreter.

1. DELETE_FAST / DELETE_DEREF no longer abort_permanent

Every except E as e: handler ends with an implicit del e, compiled to
DELETE_FAST (or DELETE_DEREF when a nested function closes over e, turning
the local into a cell). The codewriter previously emitted abort_permanent for
both opcodes, so any loop or bridge containing an exception handler fell out of
the JIT entirely.

Both are now lowered by composing existing graph ops — no new residual:

  • DELETE_FASTload_fast_check (raise-if-unbound) + a ConstRef(0)
    slot clear (reusing LoadFastAndClear) + clear_local_value.
  • DELETE_DEREFload_deref_value (raise-if-unbound) +
    store_deref_value(cell, NULL) (clears the cell contents, keeps the cell in
    the slot) + store_local_value. The live interpreter impl is delete_deref.

Both are byte-identical to the interpreter path by construction. Added a guard
bench exception_as_cell_cleanup.py for the cell (DELETE_DEREF) case.

2. Top-level raise builds the exit-frame FINISH

A top-level frame that exits by raising (no local handler) recorded its FINISH
inline with exit_frame_with_exception_descr, and set a Raise disposition —
but that disposition flag was write-only. trace.rs's two Terminate handlers
only read the normal-return payload channel, so they discarded the trace with
"Terminate without finish payload" and every raising exit deopted (bridges
compiled = 0).

The TraceAction::Finish { exit_with_exception } field and
finish_and_compile's exit-frame-with-exception branch already existed but were
unfed. The fix defers the FINISH to finish_and_compile (symmetric with the
normal-return path): walk() stashes the exception in FBW_FINISH_PAYLOAD plus
the Raise disposition, and both Terminate handlers detect Raise and build
TraceAction::Finish { exit_with_exception: true }. Four dispatch unit tests
updated to the deferred contract.

Results

  • exceptions.py / exception_args_virtual: bridges 0 → 1, aborts 43 → 5,
    guard failures 7758 → 200; output byte-identical.
  • Sweep of 23 exception benches: zero regressions.
  • Wins: swap_except_return_resume 2.79 → 0.06s, finally_bare_raise
    0.86 → 0.05s, exception_oserror_fields 1.12 → 0.51s,
    residual_raise_except_resume → 0.06s.
  • check.py dynasm + cranelift 188/188 each; majit-metainterp lib 263/0.

🤖 Generated with Claude Code

@coderabbitai

coderabbitai Bot commented Jul 15, 2026

Copy link
Copy Markdown

Warning

Review limit reached

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

Next review available in: 48 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: 2f60caa9-052e-4bf1-a303-4a0dbbd7b138

📥 Commits

Reviewing files that changed from the base of the PR and between 6d1c562 and 7ddf972.

📒 Files selected for processing (4)
  • pyre/bench/synth/exception_as_cell_cleanup.py
  • pyre/pyre-jit-trace/src/jitcode_dispatch.rs
  • pyre/pyre-jit-trace/src/trace.rs
  • pyre/pyre-jit/src/jit/codewriter.rs
✨ 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.

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

ℹ️ 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".

// disposition `Raise` so the `Terminate` consumer builds a
// `TraceAction::Finish { exit_with_exception: true }`.
FBW_FINISH_PAYLOAD.with(|c| c.set(Some((exc, Type::Ref))));
fbw_finish_raise_set(exc_concrete);

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 Avoid stashing non-concrete raises

When the exception's concrete shadow is unavailable (for example an unseeded ref register on a bridge/resume path, where read_ref_reg_concrete returns ConcreteValue::Null), this now records Some(FinishConcrete::Raise(Null)). The no-replay epilogue only checks that the concrete stash is Some, so it can keep this value and the later eval.rs/call_jit.rs consumers hit unreachable!("FinishConcrete::Raise must hold a concrete Ref") instead of deopting or replaying. Keep the symbolic exception FINISH payload separate from the concrete no-replay result, or only stash a concrete raise when it is a non-null Ref.

Useful? React with 👍 / 👎.

Comment thread pyre/pyre-jit-trace/src/trace.rs Outdated
Comment on lines +2665 to +2668
if matches!(
crate::jitcode_dispatch::fbw_finish_concrete_peek(),
Some(crate::jitcode_dispatch::FinishConcrete::Raise(_))
) {

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 Preserve raise disposition after no-replay is declined

When an uncaught raise follows an unjournaled effect, run_perfn_walk clears FBW_FINISH_CONCRETE before this Terminate mapping but leaves FBW_FINISH_PAYLOAD intact for replay. Because this new block uses the concrete no-replay stash as the only Raise discriminator, that case falls through to the normal-return payload handling below and compiles FINISH(exc, done_with_this_frame_descr_ref) instead of exit_frame_with_exception_descr_ref, making the replayed raising trace return the exception object. The raise/return disposition needs to be stored independently of the concrete no-replay value.

Useful? React with 👍 / 👎.

@github-actions

github-actions Bot commented Jul 15, 2026

Copy link
Copy Markdown

🤖 Codex parity review

Static analysis of this diff vs the local RPython/PyPy sources (commit 303864c).

Files in the reviewed diff
pyre/bench/synth/exception_as_cell_cleanup.py
pyre/pyre-jit-trace/src/jitcode_dispatch.rs
pyre/pyre-jit-trace/src/trace.rs
pyre/pyre-jit/src/jit/codewriter.rs

1. Regressions to PyPy parity introduced by this patch

  • pyre/pyre-jit/src/jit/codewriter.rs:11380 ↔ pypy/interpreter/pyopcode.py:998: DELETE_FAST now calls clear_local_value() at :11391, making the shadow slot absent rather than Constant::none(). Consequently the existing LOAD_FAST_CHECK known-unbound branch at codewriter.rs:11057-11074 no longer recognizes a just-deleted local and can trace a normal continuation after the required UnboundLocalError. upstream/main preserved the Constant::none() shadow at its former codewriter.rs:11445-11446.

2. Other mismatches introduced by this patch

None.

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

  • pyre/pyre-jit-trace/src/jitcode_dispatch.rs:1664 ↔ rpython/jit/metainterp/pyjitpl.py:3262: uncaught top-level raises record/defer FINISH without first emitting store_token_in_vable() and GUARD_NOT_FORCED_2. PyPy’s compile_exit_frame_with_exception() calls store_token_in_vable() before FINISH at pyjitpl.py:3263-3266. This omission also existed on the pre-patch immediate-finish() path.

4. Structural adaptations

  • pyre/pyre-jit/src/jit/codewriter.rs:4933 ↔ rpython/flowspace/flowcontext.py:124: decoding CPython 3.11+ exception tables (depth/push_lasti) into explicit catch landings is a necessary CPython-bytecode adaptation; RPython flowspace receives already-formed exception links instead.

  • pyre/pyre-jit/src/jit/codewriter.rs:11394 ↔ pypy/interpreter/pyopcode.py:597: the new DELETE_DEREF lowering uses pyre-specific residual helpers to dereference/check then clear a cell. This is the Rust/JIT representation of PyPy’s cell.get() followed by cell.set(None), not a semantic mismatch.

  • pyre/bench/synth/exception_as_cell_cleanup.py:1 ↔ pypy/interpreter/pyopcode.py:597: the new benchmark intentionally exercises CPython 3.11’s cell-backed exception-handler cleanup (DELETE_DEREF), rather than PyPy’s compiler bytecode layout.

DELETE_FAST was lumped with the unsupported delete opcodes and emitted
`abort_permanent`, so any loop or bridge reaching it fell back to the
interpreter. Because the implicit `del e` at the end of every
`except E as e:` handler is a DELETE_FAST, exception-bearing hot loops
and their guard-failure bridges could not compile.

Lower DELETE_FAST traceably by composing existing graph ops: read the
local, `load_fast_check` to raise UnboundLocalError when the slot is
unbound (pyopcode.py:998), then clear the slot to NULL via
`setarrayitem_vable_r` (as LOAD_FAST_AND_CLEAR does) and mirror the
clear in the shadow state. No new residual or HLOp; the traced lowering
stays byte-identical to the interpreter. DELETE_DEREF / DELETE_GLOBAL /
DELETE_NAME / SETUP_ANNOTATIONS still abort.

check.py dynasm 187/187, cranelift 187/187; pyre-jit lib 306/0.
swap_except_return_resume 2.79s->0.06s, finally_bare_raise 0.86s->0.05s,
exception_oserror_fields 1.12s->0.51s.

Assisted-by: Claude
DELETE_DEREF still emitted `abort_permanent`, so a hot loop reaching it
fell back to the interpreter. This blocks `except E as e:` handlers whose
exception variable is captured by a nested function: the capture makes `e`
a cell, so the implicit handler cleanup emits DELETE_DEREF (not DELETE_FAST).

Lower it traceably by composing existing graph ops, mirroring the LoadDeref
and StoreDeref arms: `load_deref_value` dereferences the cell and raises the
unbound error when the contents are empty (pyopcode.py:597), then
`store_deref_value(cell, NULL)` clears the cell contents while the slot keeps
holding the same cell. No new residual; byte-identical to the interpreter.
DELETE_GLOBAL / DELETE_NAME / SETUP_ANNOTATIONS still abort.

Add exception_as_cell_cleanup guard bench (no bench previously exercised
DELETE_DEREF). check.py dynasm 187/187, cranelift 187/187; pyre-jit lib 306/0.

Assisted-by: Claude
A top-level frame that exits by raising (walk()'s top-level SubRaise arm
with no local handler) recorded FINISH(exc, exit_frame_with_exception_
descr_ref) inline and set a Raise disposition, but full_body_walk_trace's
Terminate handler only consulted the normal-return payload channel
(fbw_finish_payload) — which was empty — so it aborted with "Terminate
without finish payload", discarding a valid exit-with-exception trace.
The fbw_finish_raise disposition setter was write-only: no consumer read
it, and every TraceAction::Finish hardcoded exit_with_exception: false.
Result: exception-exit bridges never compiled (bridges_compiled=0) and
every raise deopted to the interpreter.

Defer the exit-frame FINISH recording to finish_and_compile, symmetric
with the normal-return path's fbw_terminate_with_finish stash: walk()'s
top-level SubRaise arm stashes the exception OpRef as the finish payload
under a Raise disposition instead of recording the FINISH itself. Both
Terminate handlers (full_body_walk_trace and drive_outer_continuation_
and_map) now detect the Raise disposition and build TraceAction::Finish
{ exit_with_exception: true }, which finish_and_compile records with
exit_frame_with_exception_descr_ref (pyjitpl.py:3238-3245).

exceptions.py / exception_args_virtual: bridges_compiled 0->1,
loops_aborted 43->5, guard_failures 7758->200; output byte-identical to
the interpreter. Updated four dispatch unit tests to the deferred
contract (walk() stashes the payload + Raise disposition; the FINISH is
recorded downstream). check.py dynasm 188/188 + cranelift 188/188,
pyre-jit-trace lib 263/0.

Assisted-by: Claude
A `Copy` handler landing reached by an explicit raise seeded its
top-of-stack exception with a `last_exc_value` read. A residual call
between the raise landing and that read clears the metainterp exception
transient (`execute_varargs` clears before a call that may itself raise),
so the read found no value and the walk aborted with
LastExcValueWithoutActiveException, leaving the loop interpreter-only.

Reload the propagated exception from the frame's durable top stack slot
via `getarrayitem_vable_r` for those landings — catch dispatch has already
stored it there. Reraise-entered landings keep the `last_exc_value` read,
which still carries the live payload supplied by the exception edge. The
per-thread current-exception slot is not used as the source: at this
landing it can still name the older exception whose handler raised this
one, which would mislink `__context__`.

Also apply the `__context__` write to the concrete exception built in
`try_walker_trace_raise_builtin`, mirroring the recorded SETFIELD, since
the full-body walk is also the authoritative execution of the tracing
iteration.

exception_bare_reraise_restore and exception_context_chain_inhandler now
compile (loops_compiled 0 -> 1) with output byte-identical to the
interpreter oracle. check.py dynasm + cranelift 188/188 each; pyre-jit
306, pyre-jit-trace 263, majit-metainterp 1396 lib tests pass.

Assisted-by: Claude
The top-level raise arm marked its terminal disposition by stashing the
exception in FBW_FINISH_CONCRETE, and the two Terminate consumers read
that stash back as their only raise-vs-return discriminator. The two
cells have different lifetimes: run_perfn_walk clears the concrete
no-replay stash when the shortcut is declined, while the symbolic
payload stays live for the compile. A declined walk therefore lost the
raise disposition and built FINISH(exc, done_with_this_frame_descr_ref),
so the trace returned the exception object instead of raising it.

Carry the disposition in FBW_FINISH_PAYLOAD as
(OpRef, Type, FinishDisposition), so every payload writer names its
disposition and the two cannot drift. A Raise payload without a value
becomes unrepresentable, retiring the Terminate::RaiseNoFinishPayload
and P2Framestack::OuterRaiseNoFinishPayload census keys.

fbw_finish_raise_set now stashes only a non-null Ref. An absent concrete
shadow recorded Raise(Null), which the no-replay epilogue passed on to
consumers that require a raisable object. Without a concrete value the
portal degrades to the ContinueRunningNormally replay while the trace
still compiles the exit-frame FINISH. The guard moves into the setter,
so the two callers that hand-rolled it call it bare.

In drive_outer_continuation_and_map, hoist the root green-key retarget
that both return arms performed to the top of the Terminate arm; the
raise arm returned Finish without it, leaving the outer frame's
exit-with-exception loop attached under the bridge's trace-start key.

Assisted-by: Claude
@youknowone

Copy link
Copy Markdown
Owner Author

Rebased onto origin/main (49ec51c3b9c) and resolved both P1 review findings in 7ddf9729537.

Both findings traced to one root: the disposition was conflated with the no-replay value

FBW_FINISH_PAYLOAD (the symbolic OpRef the compile records) and FBW_FINISH_CONCRETE (the concrete value the no-replay portal exit returns) have different lifetimes. 879676a211a used the concrete stash as the only raise-vs-return discriminator, which is what both findings caught:

  1. "Avoid stashing non-concrete raises" — confirmed. fbw_finish_raise_set(exc_concrete) recorded Raise(Null) when the concrete shadow was absent, and the no-replay epilogue only checks .is_some(), so call_jit.rs / eval.rs reached unreachable!("FinishConcrete::Raise must hold a concrete Ref"). Notably the two pre-existing !fbw_raise_enabled() sites already had the ConcreteValue::Ref(p) + !p.is_null() guard — 879676a211a simply dropped it at the new site. The guard now lives inside fbw_finish_raise_set, so the invariant is enforced at the single producer and those two callers collapse to a bare call.

  2. "Preserve raise disposition after no-replay is declined" — confirmed, and it was a real miscompile. run_perfn_walk clears the concrete stash when the shortcut is declined while the payload stays live, so a declined raising walk built FINISH(exc, done_with_this_frame_descr_ref) — the trace returned the exception object.

The fix went further than "a separate disposition channel"

The first attempt added a sibling FBW_FINISH_DISPOSITION cell. That was wrong: only 1 of the 3 payload writers would have written it, making it a one-way latch whose correctness rested solely on the shared reset — and the previous design at least self-corrected (fbw_finish_concrete_set overwrote Raise with Return). It is also the parallel store AGENTS.md rule 2 forbids by name.

So the disposition is carried in the payload: Option<(OpRef, Type, FinishDisposition)>. Every writer names its disposition, drift is unrepresentable, and a Raise payload without a value cannot exist — which retires the Terminate::RaiseNoFinishPayload / P2Framestack::OuterRaiseNoFinishPayload census keys. This also matches how the sibling channel and upstream both work: FinishConcrete fuses disposition and value in one enum, and finishframe / finishframe_exception raise DoneWithThisFrameRef / ExitFrameWithExceptionRef (pyjitpl.py:2513-2561, jitexc.py) — the disposition rides with its value, never beside it.

Auditing that fix surfaced a third defect in the same commit: in drive_outer_continuation_and_map both return arms retarget the root green key before emitting Finish, but the raise arm 879676a211a added did not — so the outer frame's exit-with-exception loop was attached/redirected under the bridge's trace-start key (finish_and_compile reads ctx.green_key for compiled_loops.swap_remove + attach_procedure_with_redirect). The retarget is now hoisted once to the top of the Terminate arm, covering all three outcomes.

⚠️ The DELETE_FAST arm intentionally overrides merged upstream #533

#533 landed its own DeleteFast walker lowering gated on fbw_delete_fast_enabled(), but it still emits abort_permanent (when local_value_at(idx).is_none(), and as the unbound-path block). loop_body_has_abort_permanent is a static scan, so it declines the whole loop — every except X as e (implicit del e → DELETE_FAST) stays comp=0. A/B proven: skipping our commit regressed exception_context_chain_inhandler loops 1→0, abrt 0→5 (FullBodyWalk::LoopBodyAbortPermanent). Ours is the orthodox port (load_fast_check = raise-if-unbound + clear, per pyopcode.py:998-1004; no trace abort, no env flag), so the rebase keeps our arm and deletes the now-orphaned gate. Flagging it explicitly since it replaces already-merged code.

Verification

  • check.py cranelift 189/189; dynasm 188/189 — the single failure is raise_catch at 0.17s > pypy 0.11s ×1.5, a perf-margin gate, not correctness. A/B against this branch's own base shows the change is not the cause (with: median 0.172s, min 0.166; without: median 0.202s, min 0.166 — the gate is 0.165s, so both sides straddle it).
  • Exception benches on both backends, JIT output byte-identical to the PYRE_JIT=0 oracle: exception_bare_reraise_restore (15000 15000), exception_context_chain_inhandler (30000, so the __context__ chain is intact), exception_as_cell_cleanup (90000), exceptions (1125000), exception_args_virtual (1140629 1954). Compile stats hold (exceptions: brdg=1, abrt=5, gf=200).
  • Units: pyre-jit-trace 263, pyre-jit 306, majit-metainterp 1399.

commented by Claude

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

ℹ️ 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 +11420 to +11424
let result_value = emit_graph_op_with_result(
&mut graph,
&current_block.block(),
"store_deref_value",
vec![cell_value.into(), super::flow::Constant::none().into()],

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 Anchor DELETE_DEREF catches at the unbound check

When DELETE_DEREF is inside a try and the cell is empty, the preceding load_deref_value residual raises the NameError, but this added store_deref_value residual is also lowered as a Plain/can-raise residual. The flattening path anchors a block's catch_exception to the last can-raise residual, so the handler is placed after this store call instead of next to the load_deref_value guard; an unbound-cell del x in an enclosing except NameError path will therefore escape instead of being caught. Split before the store or make the store helper cannot-raise so the catch stays attached to the unbound check.

Useful? React with 👍 / 👎.

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