Skip to content

jit: size vable scalar stores by the target word - #858

Merged
youknowone merged 5 commits into
mainfrom
perf-exc
Jul 28, 2026
Merged

jit: size vable scalar stores by the target word#858
youknowone merged 5 commits into
mainfrom
perf-exc

Conversation

@youknowone

Copy link
Copy Markdown
Owner

Follow-up to #823, which merged earlier today. It closes the wasm gap that PR
had to skip around, and works through the review findings that were still open
when it landed.

The wasm store-width overrun

#823 registered frame_lineno_mid_replay_regression.py with
skip_backends=("wasm",), because a plain hot loop read through
sys._getframe(1) reported offset 0 there against 4 on pypy3, CPython,
dynasm and cranelift, from the first COMPILED call onward. The review asked for
the backend to be fixed rather than exempted. It is fixed here, and the skip is
gone.

It was not a writer clearing the field. It was a store whose width overran.

PyFrame is repr(C) with valuestackdepth: usize immediately followed by
last_instr: isize. On wasm32 those sit at offsets 20 and 24, spaced four
bytes
. read_descr_vable_field synthesized every vable static field with
field_size: 8, and the PYFRAME descr group declared the same literal for
both fields. The size-dispatching Backend::bh_setfield_gc_i therefore issued
an 8-byte store at offset 20, covering bytes 20..28 and depositing the zero high
half of a small non-negative depth onto last_instrexactly 0, not the
-1 initialization sentinel, deterministically, on every compiled call.

That is why the earlier instrumentation was consistent and still misleading: the
marker hook's own publish stores through *mut isize, a correct four-byte
store, so it read back intact. The clobber arrived afterwards, from the
blackhole replaying a setfield_vable_i at the neighbouring offset.

The widths now come from size_of::<usize>() / size_of::<isize>(). On a
64-bit target that is literally the same number, so any behaviour change is
provably wasm-only.

The extent of the class

Exactly these two fields. descr.rs has 78 hardcoded 8s, but only 14 are
non-pointer, and every other one — W_IntObject.intval, W_BoolObject.intval,
W_IntRangeIterator.{current,remaining,step}, W_SpecialisedTupleObject_ii.*,
PyTraceback.{lasti,lineno}, the f64 entries — is a genuine fixed 64-bit
field where 8 is right. Pointer fields are unaffected: bh_setfield_gc_r /
bh_getfield_gc_r take as_offset() and store at pointer width without
consulting the size, and the wasm codegen already word-corrects
is_pointer_field(). The array-field sibling in read_descr_vable_array is
derived here too, for the declared width rather than for a live defect.

A refuted premise worth recording

The earlier hunt was pushed away from the blackhole by "bh.cpu is None on
wasm, so that handler would panic rather than store". That is false. The
majit-backend-dynasm crate is target-gated out of wasm builds, but the
dynasm feature is not: pyre-jit-trace's default = ["dynasm"] forwards it
through pyre-interpreter, so builder.cpu is Some and BackendImpl
resolves to WasmBackend, which overrides neither bh_setfield_gc_i nor
force and inherits the size-honoring default.

Review findings from #823

  • optimize_FINISH finalized the guard before the FINISH's own emit
    section 1 of the CI parity review, and CodeRabbit Major. emit_extra sent the
    stashed GUARD_NOT_FORCED_2 back through the passes after virtualize, so it
    was numbered before emit(op) force_box'd the FINISH args: a return box that
    was virtual is encoded virtual where postprocess_FINISH encodes it
    materialized. have_postprocess_op / propagate_postprocess are restored on
    OptVirtualize, and the Optimizer half they hand to — which never existed,
    which is why the original field had no reader — is built:
    drain_pending_finish_guard_postprocess finalizes with
    store_final_boxes_in_guard + collect_optimizer_knowledge_for_resume,
    inserts at new_operations.len() - 1, and rebuilds new_operations_index,
    whose last-occurrence-wins mapping a mid-vector insert cannot maintain
    incrementally. The blocker the in-code comment named ("propagate_postprocess
    is a method on a pass, and the finalization needs the Optimizer") is answered
    by the pending_guard_class_postprocess precedent in the same file.

  • A declined adoption restored the locals and not the scalars — REAL, and
    reachable by default (PYRE_FBW_BLACKHOLE_RESUME defaults on). The decline
    arm's own comment already said the replay's vable stores "have to come off".
    The reviewer's stated mechanism was wrong — no EscapeFlushUndo is or can be
    armed on that arm, since the latch is gated on
    committed_frame_escape_pc().is_none() — but the defect is not: the drive
    reaches last_instr and valuestackdepth through setfield_vable_i against
    the frame register, which is the live frame, and no undo image covered them.
    The interpreter resumed at the coordinate the blackhole reached while reading
    the operand stack at a depth the restored locals do not match. Adds
    capture_frame_scalars / restore_frame_scalars beside the locals pair, on
    both the single- and multi-frame post-drive declines.

  • Two comments the recent work falsified. The codewriter paragraph still
    claimed a frame observed mid-replay reports the last coordinate published at a
    frame exit; the marker hook publishes at every marker it passes, so what is
    actually left unpublished is the inlined non-portal callee level. And the
    portal-return force named only the jitframe-chain lifetime as its blocker.

  • The portal-return force itself — section 2 of the parity review — is
    DEFERRED, with both blockers now named in the comment: the jitframe is
    libc-freed inside execute_token, and no backend arms jf_force_descr for a
    standalone trailing GUARD_NOT_FORCED_2 (upstream does, from
    consider_guard_not_forced_2), so the armed-token test would answer false for
    a portal exit even once the chain is retained. Filed with the ordered
    convergence path. Note the coupling: that force suppresses the only
    GUARD_NOT_FORCED_2 producer, which is why the FINISH divergence above is
    dormant today and why it had to be fixed first.

  • The synthetic-pycode sentinel guard landed in jit: publish the frame's exit coordinate into last_instr #823 and is re-verified here
    (null / usize::MAX before is_code before w_code_get_ptr, matching the
    precedent it cites).

Verification

Rebased onto origin/main; LLBC re-extracted; every number below is from that base.

check.py --backend dynasm 331/331
check.py --backend cranelift 331/331
check.py --backend wasm 328/328
cargo test pyre-interpreter / pyre-jit / pyre-jit-trace / majit-metainterp 401 + 322 + 299 + 1407 pass
cargo fmt --check clean

The wasm run now includes frame_lineno_mid_replay, which was skipped there before.

🤖 Generated with Claude Code

`read_descr_vable_field` synthesized every vable static field with
`field_size: 8`, and the `PYFRAME_DESCR_GROUP` entries for
`valuestackdepth` and `last_instr` declared the same literal.  Both fields
are machine words, not fixed 64-bit integers, and they are adjacent in
`PyFrame`, so on a 32-bit target the size-dispatching
`Backend::bh_setfield_gc_i` stored eight bytes over a four-byte field and
the overrun cleared the `last_instr` behind it.

Derive all three widths from `size_of::<usize>()` / `size_of::<isize>()`.
The other `Type::Int` entries in the group table are `i64` and keep 8.

`frame_lineno_mid_replay_regression.py` reported `plain` as `[0, 4]` on
wasm against `[4]` on dynasm, cranelift, pypy3 and CPython; it now passes
there, so the guard drops its `skip_backends`.

Assisted-by: Claude
The blackhole drive reaches `last_instr` and `valuestackdepth` through
`setfield_vable_i` against the frame register, which is the live frame.
Both decline arms put the locals back and left those two where the drive
had moved them, so the interpreter resumed at the coordinate the blackhole
reached, reading the operand stack at a depth the restored locals do not
match.

No other mechanism covers them: the store journal carries heap effects,
and `fbw_exit_last_instr_rollback` arms only on a return or void-return
exit, which an unadoptable terminal is not.

Adds `capture_frame_scalars` / `restore_frame_scalars` beside the locals
pair and calls them from the post-drive decline in both
`try_adopt_single_frame_blackhole` and `try_adopt_multi_frame_blackhole`.

Assisted-by: Claude
`optimize_FINISH` queued the stashed `GUARD_NOT_FORCED_2` through
`emit_extra`, so it went back through the passes after virtualize and was
finalized before the FINISH's own emit force_box'd the FINISH args. A
return box that was virtual is numbered as virtual there, where
`postprocess_FINISH` numbers it materialized.

Restores `have_postprocess_op` / `propagate_postprocess` on OptVirtualize
and adds the Optimizer half they hand to, which never existed: the pass
holds no Optimizer, and both `store_final_boxes_in_guard` and
`collect_optimizer_knowledge_for_resume` are Optimizer-side.
`drain_pending_finish_guard_postprocess` runs after each postprocess
dispatch, finalizes the guard and inserts it at `new_operations.len() - 1`,
then rebuilds `new_operations_index`, whose last-occurrence-wins mapping a
mid-vector insert cannot maintain incrementally.

Assisted-by: Claude
The codewriter paragraph still said a frame observed mid-replay reports
the last coordinate published at a frame exit. The `-live-` marker hook
publishes at every marker it passes, so what is left unpublished is the
inlined non-portal callee level, whose `frame_var` aliases the outermost
frame.

The portal-return force names only the jitframe-chain lifetime as what
blocks narrowing it back to the token protocol. Arming `jf_force_descr`
for a standalone trailing `GUARD_NOT_FORCED_2` is a second, independent
gap: no backend does it, so the armed-token test answers false for a
portal exit even once the chain is retained.

Assisted-by: Claude
`read_descr_vable_array` synthesized the array field pointer with the same
literal 8 its scalar sibling used.  Latent rather than live — the `_gc_r`
accessors this descr reaches take `as_offset()` and store at pointer width
without consulting the size — but the declared width should still be the
target word.

Assisted-by: Claude
@coderabbitai

coderabbitai Bot commented Jul 28, 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: 2 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 Plus

Run ID: 33ec3d63-b08c-41d8-a2e0-30b730be8dc7

📥 Commits

Reviewing files that changed from the base of the PR and between 22321e1 and 694496b.

📒 Files selected for processing (11)
  • majit/majit-metainterp/src/blackhole.rs
  • majit/majit-metainterp/src/optimizeopt/mod.rs
  • majit/majit-metainterp/src/optimizeopt/optimizer.rs
  • majit/majit-metainterp/src/optimizeopt/virtualize.rs
  • pyre/bench/frame_lineno_mid_replay_regression.py
  • pyre/check.py
  • pyre/pyre-jit-trace/src/descr.rs
  • pyre/pyre-jit-trace/src/jitcode_dispatch/fbw_state.rs
  • pyre/pyre-jit-trace/src/state.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.

@github-actions

Copy link
Copy Markdown

🤖 Codex parity review

Static analysis of this diff vs the local RPython/PyPy sources (commit 694496b).
Updated: 2026-07-28T13:40:00.086Z

Files in the reviewed diff
majit/majit-metainterp/src/blackhole.rs
majit/majit-metainterp/src/optimizeopt/mod.rs
majit/majit-metainterp/src/optimizeopt/optimizer.rs
majit/majit-metainterp/src/optimizeopt/virtualize.rs
pyre/bench/frame_lineno_mid_replay_regression.py
pyre/check.py
pyre/pyre-jit-trace/src/descr.rs
pyre/pyre-jit-trace/src/jitcode_dispatch/fbw_state.rs
pyre/pyre-jit-trace/src/state.rs
pyre/pyre-jit-trace/src/trace.rs
pyre/pyre-jit/src/jit/codewriter.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)

  • pyre/pyre-jit/src/jit/codewriter.rs:7849 ↔ pypy/interpreter/pyopcode.py:200 — PyPy writes self.last_instr = intmask(next_instr) for every dispatched opcode; pyre still emits this store only at exits/raises, so mid-replay observations can retain a stale line coordinate. This behavior was already present in upstream/main; the patch only updates its explanation.

  • pyre/pyre-jit-trace/src/trace.rs:2343 ↔ rpython/jit/metainterp/resume.py:1042 — pyre’s multi-frame blackhole path publishes/restores only frame 0 and explicitly leaves inlined callee locals without a per-level slot/value image. PyPy rebuilds one MIFrame per resume-data jitcode header, preserving each inlined frame’s state. This frame-identity collapse predates the patch; its new scalar rollback covers only the root frame.

4. Structural adaptations

  • pyre/pyre-jit-trace/src/descr.rs:1361 ↔ pypy/interpreter/pyframe.py:86 — using size_of::<usize>() / size_of::<isize>() for valuestackdepth and last_instr is the necessary Rust target-layout equivalent of PyPy’s machine-word Signed fields. It correctly avoids an eight-byte store overwriting the adjacent field on wasm32.

  • pyre/pyre-jit-trace/src/state.rs:4870 ↔ rpython/jit/metainterp/resume.py:1401 — pyre snapshots and restores live-frame scalar words around an abandoned blackhole drive; PyPy’s resume reader writes through per-call frame objects and does not need an undo image. This is a Rust/live-frame ownership adaptation, not a parity regression.

  • pyre/pyre-jit-trace/src/jitcode_dispatch/fbw_state.rs:1360 ↔ pypy/module/pypyjit/interp_jit.py:94 — pyre forces the virtualizable on all portal returns, whereas PyPy does so only on Yield. The broader force is an existing backend-lifetime adaptation: pyre cannot retain/arm the token/deadframe protocol for ordinary portal exits.

@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: 694496b369

ℹ️ 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 6105 to 6109
// Vable scalar word-size invariant — see fn doc-block.
field_size: 8,
field_size: std::mem::size_of::<usize>(),
field_type: majit_ir::value::Type::Ref,
field_flag: majit_ir::descr::ArrayFlag::Pointer,
is_field_signed: false,

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 Preserve signedness when sizing vable integer fields

On wasm32, when blackhole replay executes getfield_vable_i for a negative word-sized field—most notably PyFrame.last_instr == -1—this new 4-byte descriptor still sets is_field_signed: false, so Backend::bh_getfield_gc_i takes its (4, false) branch and returns 4294967295 instead of -1. The canonical PYFRAME_DESCR_GROUP explicitly marks last_instr signed (descr.rs:1375-1383), and RPython passes the original field descriptor through unchanged; derive the size, type, and signedness from vinfo.static_field_descr(field_index) rather than synthesizing only its offset.

AGENTS.md reference: AGENTS.md:L194-L196

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