Skip to content

jit: the rest of the walk mirror's unmodelled opcodes, and a CI gate for the roll-back-with-effects class - #1056

Merged
youknowone merged 2 commits into
mainfrom
nbody
Aug 5, 2026
Merged

jit: the rest of the walk mirror's unmodelled opcodes, and a CI gate for the roll-back-with-effects class#1056
youknowone merged 2 commits into
mainfrom
nbody

Conversation

@youknowone

Copy link
Copy Markdown
Owner

Follow-ups to #1051. Two changes: the rest of the walk mirror's unmodelled-opcode set,
and a CI gate for the whole double-execution bug class that #1051's three defects belonged
to.

1. LOAD_BUILD_CLASS and the MATCH_* opcodes in the walk operand-stack mirror

#1051's own follow-up list named END_SEND, YIELD_VALUE, AEnter and AExit. That
list was wrong.
All four lower to emit_abort_permanent! (codewriter.rs:11641, :12920,
:12096), so the walk ends inside the opcode's own jitcode region and never crosses the
boundary that applies a VstackOpClass. Their classification is unreachable — and the
earlier A/B that modelled the four await opcodes and moved no counter in either arm was
structurally guaranteed, not workload-specific.

Diffing the 227-variant Instruction enum against the 97 arms classify_vstack_opcode
names leaves 130 fallthroughs, of which only 21 are ever emitted and 16 of those are that
permanent-abort family. The real remaining set is five opcodes, all ResultToTos:

opcode stack effect why
LOAD_BUILD_CLASS 0 → 1 shares one codewriter arm with LOAD_LOCALS (codewriter.rs:12385); the abort_permanent arm there is gated is_locals && !is_true_portal, so it always lowers to the residual plus the push. A plain omission.
MATCH_SEQUENCE / MATCH_MAPPING / MATCH_KEYS peek, push 1 same liveness arm as GET_LEN / IMPORT_FROM, same push_and_bump! lowering
MATCH_CLASS pop 3 → push 1 emit_popvalue_ref! ×3 then push_and_bump!

All five push through push_and_bump!emit_pushvalue_ref!setarrayitem_vable_r,
which stamps vstack_last_ref — exactly what ResultToTos consumes.

MultiResultFromShadow is wrong for MATCH_CLASS. Its pop point is hard-coded
vstack_depth - 1, so for a net-negative opcode the clear loop pop_point..new_depth is
an empty range; nothing is NONEd, both hole-fill helpers skip non-NONE slots, and the
popped subject survives as a stale box in the result slot. That class is only sound for
pop-1/push-N.

Measurement

PYRE_VSTACK_DIAG=1 over the 361 synth fixtures, run sequentially: 34462 reconcile
events, of which class=Unmodeled occurs in exactly two fixtures —
match_sequence_of_class_patterns (5× MatchSequence) and slots_class_var_conflict
(2× LoadBuildClass). Nothing else, on any fixture. Both go to 0.

match_sequence_of_class_patterns also moves, identically on all three backends:

improved:  loops_aborted 5 -> 0,  loops_compiled 1 -> 2
regressed: bridges_compiled 0 -> 2, guard_failures 1 -> 401 (402 on wasm)

The mirror surviving MATCH_SEQUENCE is what lets that loop compile at all; the guard and
bridge rise is the documented consequence of a loop that now has compiled guards.

2. Gate the walks that roll back after an irreversible residual

fbw_diag slot 1 (ROLLED_BACK_WITH_EFFECTS, trace.rs) counts walks that ended
uncommitted with effects > 0 — a residual had already written live heap or entered a
Python frame, neither of which the store journal undoes, so the legacy replay the caller
falls back to applies those effects twice. All three defects in #1051 were found by
reading this counter's population by hand. The counter was computed on every walk and read
by no gate.

It is now printed as fbw_rolled_back_with_effects and is a member of
JITSTATS_BADNESS_FIELDS.

The wasm runner already read the same slot — but printed it under a different key, inside
the PYRE_WASM_JIT_STATS block, which check.py does not set. _jit_stats_change compares
by field name and reads a field absent from a run as 0, the healthy value, so that key
gated nothing. Moving it into the MAJIT_STATS block (which already refuses to report a
missing export as 0) is what surfaced half the population below.

The population it names — 6 latent instances, recorded, unfixed

fixture dynasm cranelift wasm abort variant
raise_reg_unbound_jitstress 1 1 1 ResidualCallArgUnbound
recursive_forced_frame_kept_stack 1 1 1 CompileTracePending
set_hash_protocol 1 1 1 CompileTracePending, bridge
ca_bridge_multiframe_resume_double_call 0 0 1
global_store_plain_dict_globals 0 0 1
pickle_terminal_raise_resume 0 0 5

Each count repeats identically across five runs, which is what makes them gateable. A
baseline field that no run emits reads as 0, so only these needed recording; the remaining
fixtures took the field at 0 with no other counter moving (audited: the only numeric
movement in the 42 touched .jitstats files is match_sequence_of_class_patterns).

Two things worth calling out:

Verification

pyre/check.py: dynasm 379/379, cranelift 379/379, wasm 375/375, 3/3 backends, on this
base.

test.test_asyncio, which #1017 recorded as TIMEOUT ("stalls in about one arm in four"),
now runs to completion: 6 arms with --full, 5 PASS / 1 FAIL / 0 TIMEOUT. The stall is
gone; the baseline entry is left alone in this PR because one arm still fails and recording
PASS would make the gate flaky.

Still open

  • !ctx.trace_ctx.is_bridge_trace at residual_call.rs:3081 still skips the vable-escape
    blackhole latch entirely for a bridge walk. Measured population on the asyncio harness:
    of 18 forced escapes, 11 are bridge=true bh=true fs=0 subwalk=false — the largest
    group, larger than the 5 non-bridge shapes that do take the latch. The conjunct entered
    as one of seven in a default-off rollout gate and is the last of that set never
    individually argued; the adopt machinery contains no bridge term. The reason not to lift
    it blind: a bridge shape that passes the pre-drive gates and then loses frame identity
    hits .expect(...) at trace.rs:2572/2616, a process abort rather than a free decline.
    set_hash_protocol (bridge, in the table above) is the deterministic test — its counter
    should go 1 → 0.
  • The multi-frame arm at residual_call.rs:3142 still hardcodes publish_root_stack: false
    and gates on writes_live_heap && odometer_unchanged, the two gates the comment directly
    above argues against for its sibling. An adversarial review could not establish that this
    arm actually reaches a root-frame getarrayitem_vable_r, so it needs a reachability tally
    before anything is changed.

…nd-stack mirror

`classify_vstack_opcode` fell through to `_ => Unmodeled` for LOAD_BUILD_CLASS,
MATCH_SEQUENCE, MATCH_MAPPING, MATCH_KEYS and MATCH_CLASS, which latches
`vstack_valid = false` for the rest of the walk. All five push exactly one value
through `emit_pushvalue_ref!` -> `setarrayitem_vable_r`, so they take
`ResultToTos`.

LOAD_BUILD_CLASS shares one codewriter arm with LOAD_LOCALS, whose
`abort_permanent` arm is gated on `is_locals && !is_true_portal`, so it always
lowers to the residual plus the push. MATCH_CLASS is net -2 and cannot use
`MultiResultFromShadow`: that class clears `vstack_depth - 1 .. new_depth`, an
empty range for a net-negative opcode, and both hole-fill helpers skip non-NONE
slots, so the popped subject would survive in the result slot.

A `PYRE_VSTACK_DIAG=1` sweep of the 361 synth fixtures records 34462 reconcile
events, of which the Unmodeled ones are 5 MatchSequence in
match_sequence_of_class_patterns and 2 LoadBuildClass in
slots_class_var_conflict; both go to 0. The remaining opcodes that reach the
fallthrough all lower to `emit_abort_permanent!`, so the walk ends inside their
own jitcode region and never applies the class.

match_sequence_of_class_patterns moves loops_aborted 5 -> 0, loops_compiled
1 -> 2, bridges_compiled 0 -> 2 and guard_failures 1 -> 401 (402 on wasm),
identically on all three backends.

Assisted-by: Claude
…sidual

`fbw_diag` slot 1 (ROLLED_BACK_WITH_EFFECTS, trace.rs) counts walks that ended
uncommitted with `effects > 0` — a residual had already written live heap or
entered a Python frame, neither of which the store journal undoes, so the legacy
replay the caller falls back to applies those effects twice. The counter was
computed on every walk and read by no gate.

Print it as `fbw_rolled_back_with_effects` from pyrex and from the wasm runner's
MAJIT_STATS block, and add it to JITSTATS_BADNESS_FIELDS. The wasm runner
already read the same slot, but printed it under a different key and inside the
PYRE_WASM_JIT_STATS block, which check.py does not set; `_jit_stats_change`
reads a field absent from a run as 0, so that key gated nothing.

A baseline field that no run emits also reads as 0, so only the fixtures
reporting a nonzero count needed recording: raise_reg_unbound_jitstress (1,
end=ResidualCallArgUnbound), recursive_forced_frame_kept_stack (1,
end=CompileTracePending) and set_hash_protocol (1, end=CompileTracePending,
bridge) on all three backends, plus ca_bridge_multiframe_resume_double_call (1),
global_store_plain_dict_globals (1) and pickle_terminal_raise_resume (5) on wasm
only. Each count repeats identically across five runs.

Assisted-by: Claude
@coderabbitai

coderabbitai Bot commented Aug 5, 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: 35 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: a2d2db01-de4e-4922-b213-a32b73d1dde9

📥 Commits

Reviewing files that changed from the base of the PR and between 6b843fa and 68cbada.

📒 Files selected for processing (49)
  • pyre/bench/fannkuch.cranelift.jitstats
  • pyre/bench/fannkuch.dynasm.jitstats
  • pyre/bench/fannkuch.wasm.jitstats
  • pyre/bench/fib_loop.cranelift.jitstats
  • pyre/bench/fib_loop.dynasm.jitstats
  • pyre/bench/fib_loop.wasm.jitstats
  • pyre/bench/fib_recursive.cranelift.jitstats
  • pyre/bench/fib_recursive.dynasm.jitstats
  • pyre/bench/fib_recursive.wasm.jitstats
  • pyre/bench/float_loop.cranelift.jitstats
  • pyre/bench/float_loop.dynasm.jitstats
  • pyre/bench/float_loop.wasm.jitstats
  • pyre/bench/inline_helper.cranelift.jitstats
  • pyre/bench/inline_helper.dynasm.jitstats
  • pyre/bench/inline_helper.wasm.jitstats
  • pyre/bench/int_loop.cranelift.jitstats
  • pyre/bench/int_loop.dynasm.jitstats
  • pyre/bench/int_loop.wasm.jitstats
  • pyre/bench/nbody.cranelift.jitstats
  • pyre/bench/nbody.dynasm.jitstats
  • pyre/bench/nbody.wasm.jitstats
  • pyre/bench/nested_loop.cranelift.jitstats
  • pyre/bench/nested_loop.dynasm.jitstats
  • pyre/bench/nested_loop.wasm.jitstats
  • pyre/bench/raise_catch_loop.cranelift.jitstats
  • pyre/bench/raise_catch_loop.dynasm.jitstats
  • pyre/bench/raise_catch_loop.wasm.jitstats
  • pyre/bench/spectral_norm.cranelift.jitstats
  • pyre/bench/spectral_norm.dynasm.jitstats
  • pyre/bench/spectral_norm.wasm.jitstats
  • pyre/bench/synth/ca_bridge_multiframe_resume_double_call.wasm.jitstats
  • pyre/bench/synth/global_store_plain_dict_globals.wasm.jitstats
  • pyre/bench/synth/match_sequence_of_class_patterns.cranelift.jitstats
  • pyre/bench/synth/match_sequence_of_class_patterns.dynasm.jitstats
  • pyre/bench/synth/match_sequence_of_class_patterns.wasm.jitstats
  • pyre/bench/synth/pickle_terminal_raise_resume.wasm.jitstats
  • pyre/bench/synth/raise_reg_unbound_jitstress.cranelift.jitstats
  • pyre/bench/synth/raise_reg_unbound_jitstress.dynasm.jitstats
  • pyre/bench/synth/raise_reg_unbound_jitstress.wasm.jitstats
  • pyre/bench/synth/recursive_forced_frame_kept_stack.cranelift.jitstats
  • pyre/bench/synth/recursive_forced_frame_kept_stack.dynasm.jitstats
  • pyre/bench/synth/recursive_forced_frame_kept_stack.wasm.jitstats
  • pyre/bench/synth/set_hash_protocol.cranelift.jitstats
  • pyre/bench/synth/set_hash_protocol.dynasm.jitstats
  • pyre/bench/synth/set_hash_protocol.wasm.jitstats
  • pyre/check.py
  • pyre/pyre-jit-trace/src/jitcode_dispatch/vstack_mirror.rs
  • pyre/pyre-wasm-runner/src/main.rs
  • pyre/pyrex/src/lib.rs

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 68cbada).
Updated: 2026-08-05T10:27:49.828Z

Files in the reviewed diff
pyre/check.py
pyre/pyre-jit-trace/src/jitcode_dispatch/vstack_mirror.rs
pyre/pyre-wasm-runner/src/main.rs
pyre/pyrex/src/lib.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

  • pyre/pyre-jit-trace/src/jitcode_dispatch/vstack_mirror.rs:137-144 ↔ pypy/interpreter/pyopcode.py:1787-1800: Pyre models MATCH_CLASS as “pop three, push one” (net −2); local PyPy pushes an attribute value/None plus a boolean (net −1). This is a CPython compiler/opcode-version adaptation: Pyre’s interpreter implements the newer one-result shape at pyre/pyre-interpreter/src/eval.rs:4108-4117, so the new mirror classification is internally consistent.

  • pyre/check.py:638-658, pyre/pyrex/src/lib.rs:826-835, and pyre/pyre-wasm-runner/src/main.rs:836-871 ↔ rpython/jit/metainterp/blackhole.py:1711-1730: the new fbw_rolled_back_with_effects diagnostic has no RPython counterpart. PyPy unconditionally reconstructs a blackhole frame from complete MIFrame register banks; Pyre’s partial value-stack mirror can fail to produce an adoptable resume image. The native and Wasm reporting/gating are therefore necessary Pyre-specific observability, not a semantic port divergence.

@youknowone
youknowone merged commit 64880ab into main Aug 5, 2026
15 of 17 checks passed
@youknowone
youknowone deleted the nbody branch August 5, 2026 13:08
youknowone added a commit that referenced this pull request Aug 5, 2026
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
youknowone added a commit that referenced this pull request Aug 5, 2026
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
youknowone added a commit that referenced this pull request Aug 5, 2026
…d the trampoline scratch inside the JitFrame (#1058)

* jit(wasm): count a heap load as loop-state advancement in the loop-closing 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

* jit(wasm): move the residual-call trampoline scratch out of the JitFrame, 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

* bench: re-record two wasm jitstats baselines on the current base

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

* mapdict: say what the four `?` fields actually rely on in pyre

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