Skip to content

jit(wasm): the last two bridge declines — pointer-chase induction, and the trampoline scratch inside the JitFrame - #1058

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

jit(wasm): the last two bridge declines — pointer-chase induction, and the trampoline scratch inside the JitFrame#1058
youknowone merged 5 commits into
mainfrom
perf-bridge

Conversation

@youknowone

@youknowone youknowone commented Aug 5, 2026

Copy link
Copy Markdown
Owner

Two wasm-only bridge declines, found by a full 383-fixture MC_DIAG census after #1038 closed the exception-edge class. Together they were everything the census had left.

1. A heap load is loop-state advancement

compile_bridge carries a livelock shield: a loop-closing bridge whose terminal JUMP carries no advancing value would re-present byte-identical state, its guard would re-fail, and the loop would spin against the bridge. The advance predicate accepted only integer and float arithmetic.

A pointer chase does not qualify under that rule. tb = tb.tb_next lowers to GetfieldGcR, which is neither arithmetic nor a heap write, so the bridge that re-reads the link was refused — even though each pass reads a different node and walks the loop toward its exit. Seven fixtures carry that traceback walk, and it is the only linked-list walk in the suite, which is why the whole class looked exception-shaped.

There is no upstream counterpart to this shield: send_bridge_to_backend hands the bridge to do_compile_bridge with no terminal-JUMP shape analysis, and x86's closing_jump is JMP _ll_loop_code. dynasm and cranelift have no such predicate.

The predicate widens to accept heap loads. The shield itself stays — the hazard it names is real and the resume-at-LABEL dispatch does not address it, so a JUMP built entirely of verbatim input reloads, fresh allocations and baked constants is still refused.

fixture guard_failures dynasm
exception_inline_callee_tb_frames 975 → 403 403
exception_traceback_lineno_chain 802 → 402 402
exception_catching_frame_tb_node 601 → 401 401
exception_reentry_guard_finally_residual 2459 → 2261 2261
gc_bug_bridge_flavor_traceback_names 2036 → 1837 1655
exception_traceback_frame_lineno 814 → 813 811

1570 permanent blackhole deopts removed; four land on dynasm exactly. bridges_compiled drops alongside on five of them, also onto dynasm's value — the extra bridges were retraces a declined guard had forced.

2. The residual-call trampoline scratch leaves the JitFrame

The trampoline's ABI was frame-relative on both sides of the host hop: the guest stored func_ptr/nargs/args off wasm local 0 and read the result back off local 0 before reloading it, and the host computed call_area = frame_ptr + call_area_ofs before re-entering the guest and wrote the result there afterwards. That is sound only for the host-entry frame, which is allocated old-gen and therefore non-moving — an invariant the backend states in so many words.

CALL_ASSEMBLER breaks it twice. wasm_jit_ca_alloc_frame allocates the callee frame in the nursery, so it can move across the hop; and it sizes it ca_frame_bytes, which deliberately excludes the trailing call area, so a trampoline call on such a frame would store past the object's end. Rather than fix that, the backend refused to compile any CALL_ASSEMBLER-bearing bridge sharing a trace or token with a trampoline residual call.

The scratch is now a module-static array whose address is baked into emitted code, the way the pending-exception cells already are. Frame geometry is untouched — the call area stays reserved in the frame, unused — so frame_bytes, ca_frame_bytes, the module cache and the geometry pinning test all stay put. A single shared area is sound because trampoline use is strictly LIFO: the host materialises every argument before invoking the callee, and the guest loads its result immediately on return. That assumption is written down where the static is defined.

The trampoline reads its scratch at base + offset, and the base-only import has the offset baked host-side, so every emitting module now takes the two-argument import.

With nothing left to protect, the gate and its census go: the has_trampoline_calls checks on the pending self target, the registered target, the live loop and the redirect, the chained-bridge census, and ca_reentry_safe. The BRIDGE_DIAG slots stay; slot 15 stops firing.

fixture guard_failures bridges dynasm
ca_bridge_multiframe_resume_double_call 3062 → 2581 14 → 16 2592 / 16
recursion_memo_branch 3083 → 2602 14 → 16 2613 / 16
foriter_call_resume_drops_iteration 5182 → 5165 23 → 26 5150 / 27

On all three, decl_shortcircuit 543/543/89 → 0/0/0, decl_callasm and decl_ca_trampoline → 0, and entered == BRIDGE_OK exactly (16, 16, 26): every bridge that enters is accepted. No other fixture in the suite moved.

Where the corpus stands

Suite-wide wasm decl_shortcircuit was 82324 before #1038, 1175 after it, and is 0 now. Every structural decline slot in the 383-fixture census is zero on all three backends, except bridge_no_targets_close (12 events, a bridge close that finds no compiled target and falls through to compile_loop — benign, and identical on dynasm).

Verification

check.py wasm 374/374, dynasm 378/378, cranelift 378/378, and cargo test -p majit-backend-wasm (integration tests included) — all run on the immediately preceding base, a8677f4a0a0.

This branch was then rebased onto 6b843fa4b76 with no conflicts and no code change; re-running the suites at that base is pending because the build box is currently at load average 89 with several sibling worktrees compiling, where check.py's ratio gates produce false failures. CI runs on a clean machine and is the authority here. Nine .wasm.jitstats baselines are re-recorded.

authored by Claude

Summary by CodeRabbit

  • Performance

    • Improved runtime handling for compiled execution paths, including calls, memory operations, allocations, and loop progression.
    • Simplified trampoline invocation behavior with more consistent call handling.
  • Reliability

    • Updated bridge and fallback handling to preserve existing safety checks while supporting broader execution scenarios.
  • Tests

    • Updated generated-module validation and refreshed benchmark statistics to reflect the latest compilation and guard outcomes.
  • Documentation

    • Clarified attribute mutation and guard behavior documentation.

@coderabbitai

coderabbitai Bot commented Aug 5, 2026

Copy link
Copy Markdown

Review Change Stack

Caution

Review failed

The pull request is closed.

ℹ️ Recent review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: ASSERTIVE

Plan: Pro Plus

Run ID: a32ee3c0-df28-487c-be48-618670d1b8e6

📥 Commits

Reviewing files that changed from the base of the PR and between b9e7c3f and d58a27a.

📒 Files selected for processing (12)
  • majit/majit-backend-wasm/src/codegen.rs
  • majit/majit-backend-wasm/src/failguard.rs
  • majit/majit-backend-wasm/src/lib.rs
  • majit/majit-backend-wasm/tests/codegen_test.rs
  • pyre/bench/synth/ca_bridge_multiframe_resume_double_call.wasm.jitstats
  • pyre/bench/synth/exception_catching_frame_tb_node.wasm.jitstats
  • pyre/bench/synth/exception_inline_callee_tb_frames.wasm.jitstats
  • pyre/bench/synth/exception_reentry_guard_finally_residual.wasm.jitstats
  • pyre/bench/synth/exception_traceback_lineno_chain.wasm.jitstats
  • pyre/bench/synth/gc_bug_bridge_flavor_traceback_names.wasm.jitstats
  • pyre/bench/synth/recursion_memo_branch.wasm.jitstats
  • pyre/pyre-interpreter/src/objspace/std/mapdict.rs

Walkthrough

The wasm backend now uses shared static scratch storage and a fixed compact trampoline ABI. CALL_ASSEMBLER metadata and admission checks are simplified. Heap loads count as loop-state advancement. Interpreter documentation describes guard-based mapdict protection.

Changes

Wasm trampoline and CALL_ASSEMBLER changes

Layer / File(s) Summary
Static scratch and compact trampoline ABI
majit/majit-backend-wasm/src/codegen.rs, majit/majit-backend-wasm/src/lib.rs
Residual calls, write barriers, frame operations, and allocations use shared static scratch storage with the jit_call_compact ABI.
CALL_ASSEMBLER admission and metadata
majit/majit-backend-wasm/src/failguard.rs, majit/majit-backend-wasm/src/lib.rs, majit/majit-backend-wasm/src/codegen.rs
Trampoline census and CA re-entry metadata are removed. Admission and redirect validation retain frame, metadata, and terminal-decline checks.
Loop-state advancement detection
majit/majit-backend-wasm/src/lib.rs
Guard analysis and bridge livelock detection classify heap-load results as loop-state advancement.
Generated-code and benchmark validation
majit/majit-backend-wasm/tests/codegen_test.rs, pyre/bench/synth/*.wasm.jitstats
The code-generation test expects jit_call_compact. Synthetic benchmark statistics are updated.

Interpreter attribute documentation

Layer / File(s) Summary
Mapdict guard documentation
pyre/pyre-interpreter/src/objspace/std/mapdict.rs
Documentation describes mutable attributes protected by per-read instance-map guards instead of quasi-immutable watchers.

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

Sequence Diagram(s)

sequenceDiagram
  participant GeneratedWasm
  participant jit_call_area_addr
  participant jit_call_compact
  participant JIT_CALL_AREA
  GeneratedWasm->>jit_call_area_addr: obtain static scratch address
  GeneratedWasm->>JIT_CALL_AREA: write trampoline arguments
  GeneratedWasm->>jit_call_compact: invoke compact trampoline
  jit_call_compact->>JIT_CALL_AREA: write result
  GeneratedWasm->>JIT_CALL_AREA: read result
Loading

Possibly related issues

Possibly related PRs

  • youknowone/pyre#312 — Introduced related CALL_ASSEMBLER and trampoline code in codegen.rs and failguard.rs.
  • youknowone/pyre#501 — Shares the WASM residual-call ABI and call-area handling changes.
  • youknowone/pyre#522 — Shares FrameGeometry, trampoline tracking, and CALL_ASSEMBLER eligibility logic.

Poem

A rabbit hops through scratch-space bright,
Compact calls take flight tonight.
Frames stay still, bridges grow wise,
Heap loads loop beneath clear skies.
“Hop!” says the bunny, “the imports align!”

✨ 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.

@github-actions

github-actions Bot commented Aug 5, 2026

Copy link
Copy Markdown

🤖 Codex parity review

Static analysis of this diff vs the local RPython/PyPy sources (commit d58a27a).
Updated: 2026-08-05T17:59:51.498Z

Files in the reviewed diff
majit/majit-backend-wasm/src/codegen.rs
majit/majit-backend-wasm/src/failguard.rs
majit/majit-backend-wasm/src/lib.rs
majit/majit-backend-wasm/tests/codegen_test.rs
pyre/pyre-interpreter/src/objspace/std/mapdict.rs

1. Regressions to PyPy parity introduced by this patch

  • majit/majit-backend-wasm/src/codegen.rs:3640 ↔ rpython/jit/backend/aarch64/assembler.py:967 — the newly admitted trampoline CA-allocation path does not reload local 0 after its collecting allocation. wasm_jit_ca_alloc_frame can move the caller frame, but the else path ends without the reload present for direct calls; subsequent home/input reads use the stale frame pointer. majit/majit-backend-wasm/src/lib.rs:2499 removed main’s admission guard, so this changes a safe decline into potential memory corruption. Upstream reloads the live frame after collection.

2. Other mismatches introduced by this patch

  • majit/majit-backend-wasm/src/lib.rs:153 ↔ rpython/jit/metainterp/compile.py:1081 — the patch treats every heap load as proof that a loop-closing bridge advances state. A GETFIELD_*, GETARRAYITEM_*, or GC_LOAD_* can repeatedly return an unchanged value, so accepting the bridge can still create the exact loop↔bridge livelock the check claims to exclude. Upstream attaches an optimized bridge without this unsound “load implies progress” classification.

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

  • pyre/pyre-interpreter/src/objspace/std/mapdict.rs:176 ↔ pypy/objspace/std/mapdict.py:305allow_unboxing is an ordinary Cell<bool> with no quasi-immutable invalidation. The newly added comment’s claim that an instance-map GuardValue substitutes for invalidation is false: changing allow_unboxing does not change an instance’s map identity.

  • pyre/pyre-interpreter/src/objspace/std/mapdict.rs:240 ↔ pypy/objspace/std/mapdict.py:421ever_mutated is likewise an ordinary mutable cell rather than PyPy’s ever_mutated? quasi-immutable field. A same-map write flips this flag without invalidating a trace guarded only on map identity.

  • pyre/pyre-interpreter/src/objspace/std/mapdict.rs:3139 ↔ pypy/objspace/std/mapdict.py:669CachedAttributeHolder.attr and typ remain mutable Cell fields without PyPy’s attr? / typ? quasi-immutable invalidation. The patch only documents this pre-existing divergence; its map-shape guard rationale does not protect holder mutation.

4. Structural adaptations

  • majit/majit-backend-wasm/src/lib.rs:235 ↔ rpython/jit/backend/llsupport/assembler.py:295 — replacing frame-resident residual-call storage with one module-global wasm scratch area and a two-argument jit_call_compact import is a Rust/wasm calling-convention adaptation; PyPy passes a per-invocation frame/argument location. It is valid only while wasm trace execution is serialized: atomics prevent a Rust data race but do not prevent two concurrent traces from interleaving and overwriting the shared call protocol.

@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: 593f4d1b14

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

| IntAddOvf
| IntSubOvf
| IntMulOvf
| GetfieldGcR

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 Do not count invariant heap loads as loop progress

When a loop-closing bridge jumps a value produced by an immutable or otherwise unchanged field/array load and the bridge has no store/call, this new load classification makes compile_bridge treat the bridge as advancing even though the loaded value can be byte-identical on every pass. That bypasses the existing !advances && !mutates_heap decline for guard side-traces, so the loop↔bridge chain can be installed and spin forever at constant heap/loop state; loads should only count when the loaded address/content is proven to change, not merely because a load opcode produced the jump arg.

Useful? React with 👍 / 👎.

@youknowone
youknowone force-pushed the perf-bridge branch 2 times, most recently from b1f974d to a5d8881 Compare August 5, 2026 13:14

@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

🤖 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-backend-wasm/src/codegen.rs`:
- Around line 3608-3622: In the compact trampoline path around
emit_call_area_addr and emit_jit_call, reload local 0 after the
wasm_jit_ca_alloc_frame call when WASM_DIRECT_RESIDUAL_CALL is disabled. Add the
same caller-frame reload fallback used by ca_reload_caller_fn_ptr before
emit_reload_ca_input_refs_from_homes, so Ref inputs use the moved JitFrame’s
current caller reference.

In `@majit/majit-backend-wasm/src/lib.rs`:
- Around line 153-173: Update the advances_loop_state predicate to include
GetarrayitemGcPureI, GetarrayitemGcPureR, and GetarrayitemGcPureF alongside the
existing GC array-load opcodes, so build_function’s pure GC array loads are
recognized as loop-state advancement.
🪄 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: a3040c41-adf9-42e0-98e4-3a3df8feed75

📥 Commits

Reviewing files that changed from the base of the PR and between 7b87e22 and 048e3a0.

📒 Files selected for processing (12)
  • majit/majit-backend-wasm/src/codegen.rs
  • majit/majit-backend-wasm/src/failguard.rs
  • majit/majit-backend-wasm/src/lib.rs
  • majit/majit-backend-wasm/tests/codegen_test.rs
  • pyre/bench/synth/ca_bridge_multiframe_resume_double_call.wasm.jitstats
  • pyre/bench/synth/exception_catching_frame_tb_node.wasm.jitstats
  • pyre/bench/synth/exception_inline_callee_tb_frames.wasm.jitstats
  • pyre/bench/synth/exception_reentry_guard_finally_residual.wasm.jitstats
  • pyre/bench/synth/exception_traceback_lineno_chain.wasm.jitstats
  • pyre/bench/synth/foriter_call_resume_drops_iteration.wasm.jitstats
  • pyre/bench/synth/gc_bug_bridge_flavor_traceback_names.wasm.jitstats
  • pyre/bench/synth/recursion_memo_branch.wasm.jitstats

Comment thread majit/majit-backend-wasm/src/codegen.rs
Comment thread majit/majit-backend-wasm/src/lib.rs
…osing bridge check

`compile_bridge`'s livelock shield refuses a loop-closing bridge whose
terminal JUMP carries no advancing value. Its advance predicate accepted
only integer and float arithmetic, so a pointer chase did not qualify:
`tb = tb.tb_next` lowers to GetfieldGcR, which is neither arithmetic nor a
heap write, and the bridge that re-reads the link was declined even though
each pass reads a different node and walks the loop toward its exit.

Seven fixtures carry that traceback walk. Six of them lose their declines,
and four land on dynasm exactly (guard_failures wasm -> dynasm):

  exception_inline_callee_tb_frames         975 -> 403   dynasm 403
  exception_traceback_lineno_chain          802 -> 402   dynasm 402
  exception_catching_frame_tb_node          601 -> 401   dynasm 401
  exception_reentry_guard_finally_residual 2459 -> 2261  dynasm 2261
  gc_bug_bridge_flavor_traceback_names     2036 -> 1837  dynasm 1655
  exception_traceback_frame_lineno          814 -> 813   dynasm 811

`bridges_compiled` drops alongside on five of them, also onto dynasm's
value: the extra bridges were the retraces that a declined guard forced.

The shield itself stays. It refuses a bridge that resumes at the loop
header with byte-identical state, whose guard then re-fails and spins the
loop against the bridge; the resume-at-LABEL dispatch does not address
that, and the two were introduced together. Only the predicate widens, so
a JUMP built entirely of verbatim input reloads, fresh allocations and
baked constants is still refused.

check.py wasm 374/374.

Assisted-by: Claude
…ame, and drop the CALL_ASSEMBLER gate it required

The trampoline's ABI was frame-relative on both sides of the host hop: the
guest stored func_ptr/nargs/args off wasm local 0 and read the result back
off local 0 before reloading it, and the host computed
`call_area = frame_ptr + call_area_ofs` before re-entering the guest and
wrote the result there afterwards. That is sound only for the host-entry
frame, which is allocated old-gen and therefore non-moving.

CALL_ASSEMBLER breaks it twice. `wasm_jit_ca_alloc_frame` allocates the
callee frame in the nursery, so it can move across the hop; and it sizes it
`ca_frame_bytes`, which excludes the trailing call area, so a trampoline
call on such a frame would store past the object's end. The backend
therefore refused to compile any CALL_ASSEMBLER-bearing bridge that shared
a trace or token with a trampoline residual call.

The scratch is now a module-static array whose address is baked into
emitted code, the way the pending-exception cells already are. Frame
geometry is untouched: the call area stays reserved in the frame, unused,
so frame_bytes, ca_frame_bytes and the module cache do not move. A single
shared area is sound because trampoline use is strictly LIFO — the host
materialises every argument before invoking the callee and the guest loads
its result immediately on return.

The trampoline reads its scratch at `base + offset`, and the base-only
import has the offset baked host-side, so every emitting module now takes
the two-argument import.

With nothing left to protect, the gate and its census go: the
`has_trampoline_calls` checks on the pending self target, the registered
target, the live loop and the redirect, the chained-bridge census, and
`ca_reentry_safe`. The BRIDGE_DIAG slots stay; slot 15 stops firing.

This was the last terminal bridge decline on wasm. On the only three
fixtures that carried it, `decl_shortcircuit` 543/543/89 -> 0/0/0,
`decl_callasm` and `decl_ca_trampoline` -> 0, and `entered == BRIDGE_OK`
exactly (16, 16, 26):

  ca_bridge_multiframe_resume_double_call  gf 3062 -> 2581  bridges 14 -> 16
  recursion_memo_branch                    gf 3083 -> 2602  bridges 14 -> 16
  foriter_call_resume_drops_iteration      gf 5182 -> 5165  bridges 23 -> 26

dynasm reports 2592/16, 2613/16 and 5150/27. No other fixture moved.

check.py wasm 374/374; cargo test -p majit-backend-wasm green.

Assisted-by: Claude
Both files were recorded against a base that predates #1050, #1056 and
#1057. Re-measured after re-extracting LLBC, whose three crate
fingerprints had all drifted:

  exception_inline_callee_tb_frames   bridges_compiled 2 -> 3,
                                      guard_failures 403 -> 604
  gc_bug_bridge_flavor_traceback_names guard_failures 1837 -> 1838

Both also gain the `fbw_rolled_back_with_effects` field the new base
adds. The rest of the wasm suite is unchanged at 376 passing.

Assisted-by: Claude
`allow_unboxing`, `ever_mutated`, `attr` and `typ` are declared
quasi-immutable upstream, and the comments asserted that flatly. pyre
installs no quasi-immutable watcher for any of them — they are plain
Cells, and mapdict.rs contains no quasi-immutable code — so the comments
read as a property the code does not have.

State the substitute instead: every read is paired with a GuardValue on
the instance map, emitted by walker_guard_mapdict_instance_shape. The
full rationale sits on `allow_unboxing`, the only one of the four that
gates a fold decision; the other three refer to it. Same formulation
already used for the `Function` `?` fields in descr.rs.

Comments only.

Assisted-by: Claude
@youknowone
youknowone merged commit 160aad3 into main Aug 5, 2026
@youknowone
youknowone deleted the perf-bridge branch August 5, 2026 17:55
@coderabbitai

coderabbitai Bot commented Aug 5, 2026

Copy link
Copy Markdown

Note

GitHub couldn't provide a complete incremental comparison for this pull request, so CodeRabbit is performing a full review instead. This review may take a little longer.

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