Skip to content

jit: pointer-width exception-class reads, the wasm exception triple, and the exception-edge bridge on every backend - #1038

Merged
youknowone merged 2 commits into
mainfrom
perf-bridge
Aug 5, 2026
Merged

jit: pointer-width exception-class reads, the wasm exception triple, and the exception-edge bridge on every backend#1038
youknowone merged 2 commits into
mainfrom
perf-bridge

Conversation

@youknowone

@youknowone youknowone commented Aug 4, 2026

Copy link
Copy Markdown
Owner

The wasm exception-edge bridge was disabled because the bridge it produced deopted again on its own entry guard. The guard could never pass: the expected class was read out of a one-word typeptr through *const i64.

The over-wide read

PyObject is { ob_type: *const PyType, w_class: *mut PyObject }. On wasm32 those are 4 bytes each, so an i64 read of ob_type returns (w_class << 32) | ob_type. Measured, by baking a BRIDGE_DIAG slot address into the emitted guard the way jit_exc_type_addr() is already baked and storing both compare operands:

runtime JIT_EXC_TYPE  = 0x0000_0000_0083_4658   (jit_exc_raise, pointer-width)
baked GUARD_EXCEPTION = 0x00E0_EA40_0083_4658   (i64 read: w_class in the high half)

The guard executed 218889 times and passed 0 times.

jit_exc_raise had already been narrowed to pointer width, and its comment says why ("…so the high bits stay clear and GuardException's type comparison matches the baked class pointer") — the producer side was never matched to it. 64-bit hosts are immune, which is why dynasm and cranelift never saw it.

Four sites are fixed. The one that reaches generated code is bridge_subwalk::dispatch_via_miframe's exc_edge_class, which becomes the bridge-entry GUARD_EXCEPTION constant. The three in pyjitpl.rs are the same read (the third fires once per raising iteration). On 64-bit targets all four are byte-identical before and after.

Second blocker, visible only after the first was fixed

OpCode::SaveException | OpCode::SaveExcClass | OpCode::RestoreException => {
    // No-op in wasm MVP — exception state is managed by the host.
}

SAVE_EXCEPTION produces a value — the caught exception the resumed handler reads. The explicit arm bypassed the value-producing decline in the _ fallback, so the local stayed 0 and the fixture died with TypeError: comparison on null operand inside the except block. All three are lowered against x86/assembler.py genop_save_exc_class, genop_save_exception and _restore_exception.

Result

exc_edge_bridge_enabled() is now true for every backend instead of cfg!(not(target_arch = "wasm32")), and wasm converges onto the native counts:

fixture guard_failures wasm before → after dynasm loops_aborted
type_name_surrogate_reject 9464 → 202 201 1 → 0
exc_caught_in_callee_return_loop 23748 → 613 611 1 → 0
inline_subwalk_property_mutates 23748 → 613 611 1 → 0
inline_subwalk_mutating_residual 23950 → 815 813 1 → 0
named_reraise_sibling_hot 2649 → 1712 1712 3 → 0
sre_pattern_methods 2536 → 1671 1670 1 → 0
handler_reraise_second_exc 1285 → 804 804 1 → 0

type_name_surrogate_reject also drops jit_calls 103624 → 2248 and compile_ms 76 → 7.8.

Before the fix the cost was unbounded in the iteration count, not a fixed factor: every raising iteration after the guard was blacklisted took a full deopt → blackhole → resume round trip, so guard_failures scaled with N while dynasm stayed flat (611 at every N from 15k to 240k).

BRIDGE_DIAG grows to 30 slots — cell_set / cell_missing / cell_rebridge — which separate "the backend accepted a bridge" from "the source guard's dispatch cell can reach it". BRIDGE_OK alone cannot tell those apart from outside the guest, and that distinction is what started this diagnosis.

Verification

check.py wasm 373/373, dynasm 377/377, cranelift 377/377; cargo test --release -p majit-backend-wasm -p majit-metainterp --features dynasm green; cargo fmt --check clean. Seven .wasm.jitstats baselines re-recorded.

Left in place

exc_edge_bridge_enabled() now returns a constant true, so the if !exc_edge_bridge_enabled() legacy prologue branch in call_jit.rs is unreachable. Removing the flag and that branch is not folded in here — it sits inside a cranelift feature gate that this change cannot verify from the wasm side.

authored by Claude

Summary by CodeRabbit

  • Bug Fixes

    • Improved exception handling and re-raising behavior across supported platforms.
    • Corrected exception-class handling on 32-bit targets.
    • Enabled exception-edge bridging for WebAssembly.
    • Reduced aborted loops and guard failures in several benchmark scenarios.
  • Diagnostics

    • Added bridge statistics identifying missing, initialized, and re-used dispatch cells.

…and the exception-edge bridge on every backend

`PyObject` is `{ ob_type, w_class }`, two machine words. Four sites read the
one-word `typeptr` at offset 0 through `*const i64`, so on a 32-bit target the
value carried the adjacent `w_class` in its high half and could never compare
equal to the pending-exception cell, which `jit_exc_raise` publishes at pointer
width. All four now read at pointer width; the reads are byte-identical on
64-bit targets.

The site that reaches generated code is `bridge_subwalk::dispatch_via_miframe`,
whose `exc_edge_class` becomes the bridge-entry GUARD_EXCEPTION constant. On
wasm that guard executed 218889 times and passed 0 times, measured by baking a
`BRIDGE_DIAG` slot address into the emitted guard and storing both compare
operands: runtime `JIT_EXC_TYPE` 0x0000_0000_0083_4658 against a baked
0x00E0_EA40_0083_4658. The three `pyjitpl.rs` sites are the same read; the
third fires once per raising iteration.

The wasm backend no-opped SAVE_EXCEPTION / SAVE_EXC_CLASS / RESTORE_EXCEPTION.
SAVE_EXCEPTION produces a value, and the explicit arm bypassed the
value-producing decline in the `_` fallback, so the local stayed null and the
resumed handler compared against it. Lowered all three against
`x86/assembler.py` genop_save_exc_class, genop_save_exception and
_restore_exception.

With both fixed, `exc_edge_bridge_enabled()` returns `true` for every backend
instead of `cfg!(not(target_arch = "wasm32"))`, and the wasm guard-failure
counts land on the native ones:

  fixture                           wasm before -> after   dynasm
  type_name_surrogate_reject        9464 -> 202            201
  exc_caught_in_callee_return_loop  23748 -> 613           611
  inline_subwalk_property_mutates   23748 -> 613           611
  inline_subwalk_mutating_residual  23950 -> 815           813
  named_reraise_sibling_hot         2649 -> 1712           1712
  sre_pattern_methods               2536 -> 1671           1670
  handler_reraise_second_exc        1285 -> 804            804

`loops_aborted` goes to 0 on all seven; `type_name_surrogate_reject` also drops
`jit_calls` 103624 -> 2248 and `compile_ms` 76 -> 7.8. The seven `.wasm.jitstats`
baselines are re-recorded.

`BRIDGE_DIAG` grows to 30 slots: `cell_set` / `cell_missing` / `cell_rebridge`
separate "the backend accepted a bridge" from "the source guard's dispatch cell
can reach it", which `BRIDGE_OK` alone does not.

The `if !exc_edge_bridge_enabled()` legacy prologue branch in `call_jit.rs` is
now unreachable and is left in place.

Assisted-by: Claude
@coderabbitai

coderabbitai Bot commented Aug 4, 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: 34 minutes

You've used all free OSS reviews for now. Wait for the free limit to reset to keep reviewing this public repository.

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 Plus

Run ID: ecf95d5c-c2a7-4727-b9db-a0f99307b0e0

📥 Commits

Reviewing files that changed from the base of the PR and between 37cebf6 and 654a5e0.

📒 Files selected for processing (1)
  • pyre/pyre-jit-trace/src/jitcode_dispatch/mod.rs

Walkthrough

The change enables wasm exception-edge bridges, implements wasm exception save and restore operations, fixes pointer-width exception-class reads, adds bridge dispatch-cell diagnostics, and updates wasm benchmark statistics.

Changes

Wasm exception bridge support

Layer / File(s) Summary
Exception state and target support
majit/majit-backend-wasm/src/codegen.rs, majit/majit-metainterp/src/pyjitpl.rs, pyre/pyre-jit-trace/src/jitcode_dispatch/bridge_subwalk.rs, pyre/pyre-jit-trace/src/jitcode_dispatch/mod.rs
Wasm exception operations now access shared exception state. Exception class reads use pointer-width loads. Exception-edge bridges are enabled on wasm.
Bridge cell diagnostics
majit/majit-backend-wasm/src/lib.rs, pyre/pyre-wasm-runner/src/main.rs
Bridge compilation records missing, populated, and rebridged dispatch cells. The diagnostic counter array and output labels include these counters.
Wasm benchmark validation
pyre/bench/synth/*.wasm.jitstats
Benchmark statistics record updated compiled-bridge, guard-failure, and aborted-loop counts.

Estimated code review effort: 3 (Moderate) | ~25 minutes

Possibly related PRs

  • youknowone/pyre#312: Shares the wasm bridge and exception infrastructure modified here.
  • youknowone/pyre#563: Shares exception handling and wasm exception-edge bridge behavior.
  • youknowone/pyre#757: Directly relates to stateful SaveException, SaveExcClass, and RestoreException operations.

Poem

A rabbit hops through wasm code,
Saving exceptions on the road.
Pointer words fit, bridges bloom,
Dispatch cells now show their room.
Benchmarks thump a steadier tune.

🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 0.00% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (4 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 main exception-handling, wasm lowering, and exception-edge bridge changes.
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 💡 1
📝 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.

@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/pyjitpl.rs`:
- Around line 9471-9476: Extract a shared helper in the relevant module that
null-checks a GcRef and reads its typeptr header using pointer width before
widening to i64. Replace the duplicated unsafe reads at the sites around the
exception handling logic, including the code using result.exception_value, with
this helper, preserving each site’s existing null behavior. Ensure the helper is
reusable by the corresponding bridge_subwalk.rs call site.
🪄 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: e38d8738-0baf-4543-acb6-4b7350f961e0

📥 Commits

Reviewing files that changed from the base of the PR and between 4fecf86 and 37cebf6.

📒 Files selected for processing (13)
  • majit/majit-backend-wasm/src/codegen.rs
  • majit/majit-backend-wasm/src/lib.rs
  • majit/majit-metainterp/src/pyjitpl.rs
  • pyre/bench/synth/exc_caught_in_callee_return_loop.wasm.jitstats
  • pyre/bench/synth/handler_reraise_second_exc.wasm.jitstats
  • pyre/bench/synth/inline_subwalk_mutating_residual.wasm.jitstats
  • pyre/bench/synth/inline_subwalk_property_mutates.wasm.jitstats
  • pyre/bench/synth/named_reraise_sibling_hot.wasm.jitstats
  • pyre/bench/synth/sre_pattern_methods.wasm.jitstats
  • pyre/bench/synth/type_name_surrogate_reject.wasm.jitstats
  • pyre/pyre-jit-trace/src/jitcode_dispatch/bridge_subwalk.rs
  • pyre/pyre-jit-trace/src/jitcode_dispatch/mod.rs
  • pyre/pyre-wasm-runner/src/main.rs

Comment on lines +9471 to +9476
// `typeptr` is one machine word at offset 0, so read it at
// pointer width: an i64 read on a 32-bit target would pull the
// adjacent header word into the high half and the class value
// would never compare equal to a pointer-width one
// (`Cpu::cls_of_gcref`, `jit_exc_raise`).
unsafe { *(result.exception_value.0 as *const usize) as i64 }

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 | 🟠 Major | ⚡ Quick win

Extract a shared helper for the pointer-width exception-class read.

The same pattern repeats at three sites in this file: null-check a GcRef, then read its typeptr header word at pointer width and widen to i64. This is exactly the read that was wrong in four places before this fix (three here, one in bridge_subwalk.rs). A shared helper removes the duplication and prevents a future call site from reintroducing the fixed-width bug by copy-paste.

♻️ Proposed helper extraction
+/// Read a GC object's `typeptr` header word at pointer width (matching
+/// `Cpu::cls_of_gcref` / `jit_exc_raise`). Returns 0 for a null ref.
+fn read_exc_class(gcref: majit_ir::GcRef) -> i64 {
+    if gcref.is_null() {
+        0
+    } else {
+        unsafe { *(gcref.0 as *const usize) as i64 }
+    }
+}

Then each site becomes, e.g.:

-        let exc_class = if result.exception_value.is_null() {
-            0
-        } else {
-            unsafe { *(result.exception_value.0 as *const usize) as i64 }
-        };
+        let exc_class = read_exc_class(result.exception_value);

Also applies to: 9646-9647, 9825-9826

🤖 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/pyjitpl.rs` around lines 9471 - 9476, Extract a
shared helper in the relevant module that null-checks a GcRef and reads its
typeptr header using pointer width before widening to i64. Replace the
duplicated unsafe reads at the sites around the exception handling logic,
including the code using result.exception_value, with this helper, preserving
each site’s existing null behavior. Ensure the helper is reusable by the
corresponding bridge_subwalk.rs call site.

@github-actions

github-actions Bot commented Aug 4, 2026

Copy link
Copy Markdown

🤖 Codex parity review

Static analysis of this diff vs the local RPython/PyPy sources (commit 654a5e0).
Updated: 2026-08-04T23:47:53.591Z

Files in the reviewed diff
majit/majit-backend-wasm/src/codegen.rs
majit/majit-backend-wasm/src/lib.rs
majit/majit-metainterp/src/pyjitpl.rs
pyre/pyre-jit-trace/src/jitcode_dispatch/bridge_subwalk.rs
pyre/pyre-jit-trace/src/jitcode_dispatch/mod.rs
pyre/pyre-wasm-runner/src/main.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)

4. Structural adaptations

`try_catch_exception_at` is a lookahead predicate called from the inlining
and resume-snapshot walkers; the frame-popping loop that decodes
`rvmprof_code` and calls `cintf::jit_rvmprof_code` is
`MetaInterp::finishframe_exception`. The comment claimed the dropped call
matched a non-trace-recorded upstream `cintf` call, which reads as a
divergence from `pyjitpl.py:2547`.

Assisted-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