Skip to content

jit: mapdict instance-attr folds + two resume-journal correctness fixes - #592

Merged
youknowone merged 9 commits into
mainfrom
charon
Jul 16, 2026
Merged

youknowone merged 9 commits into
mainfrom
charon

Conversation

@youknowone

@youknowone youknowone commented Jul 16, 2026 •

Copy link
Copy Markdown
Collaborator

Nine commits: the mapdict instance-attribute folds, the two parity gaps the Codex
review found inside them, a check.py gate fix, and a correctness fix in the
"committed effect re-applied on resume" family found while validating the folds.

Dropped since the first push (force-pushed): jit: abort unfoldable method-form list.append folds. It turned the fold's decline into a trace abort,
and a freshly built list always declines — w_list_new allocates capacity ==
length, so w_list_can_append_without_realloc is false for the first append and
ListStrategy::Empty refuses outright. A list built and appended to in one traced
region therefore stopped compiling (lst = []; lst.append(i) in a loop:
loops_compiled 1 → 0). Its own "measured cost zero" was an artifact: no bench
in the corpus builds a list and appends to it in the same traced region. The hazard
it addressed is real but latent, and the fix it needs is different — see the end.

1. mapdict instance-attribute folds

LOAD_ATTR / STORE_ATTR on a fixed-layout instance whose guarded shape pins an
existing storage slot lower to a direct slot access instead of the opaque
getattr_fn / setattr_fn MRO walk. Unboxed int, unboxed float, and boxed
slots. PYRE_FBW_LOADATTR_FOLD=0 / PYRE_FBW_STOREATTR_FOLD=0 decline.

All four arms take the receiver type's live version_tag guard (main added it
mid-development; it is threaded through the shared
walker_guard_mapdict_instance_shape helper so no arm can miss it): the instance
map pins the storage layout, but class mutation can change lookup precedence
without changing that map.

attr_boxed at N=60M: 3.77s → 0.5s. Dropping the residual's may-force effect
on top measures 0.47s, so the win is the removed MRO walk rather than the force
protocol — the boxed arm therefore keeps the residual's original may-force
effect (force token, virtualizable spill, trailing force/exception guards) and
replaces only the helper and its arguments. The unboxed arms write no GC
reference and do drop it.

Two synthetic fixtures cover the map guard (polymorphic map, unboxed type
change).

2. mapdict: fold parity for ever_mutated and allow_unboxing

Both reported by the Codex parity review on this PR; both verified against the
upstream source before fixing.

The folds resolve attributes through the *_fast_path ports of
LOAD_ATTR_caching / STORE_ATTR_caching but skipped two steps those paths take.

ever_mutated — STORE_ATTR_caching sets it before _direct_write
(mapdict.py:1635-1637); the two store fast paths did not, so a folded write left
the attribute marked unmutated. Severity is honest: latent. Nothing in pyre
reads ever_mutated — every .get() is the "already set?" check before setting,
and node_read documents that both read variants share one body — so no
observable behavior changes.

allow_unboxing — LOAD_ATTR_caching resolves to attr._direct_read
(mapdict.py:1539), which for an unboxed attribute migrates the instance off
unboxed storage once its class froze unboxing (mapdict.py:594-596). The folded
read performs _prim_direct_read alone. load_attr_unboxed_fast_path now
declines a frozen class and leaves the read to the residual, whose direct_read
runs the migration.

This is not a wrong answer either way — _direct_read reads before it
converts — but the gate does fire and changes trace shape. Probe: freeze
unboxing, then hot-loop the first read of 40000 pre-created instances. Both print
799980000:

bridges_compiled guard_failures
before 1 202
after 0 1

Skipping the migration left unboxed and boxed instances mixed under one
promoted-map guard. The residual migrates each on its first read, after which the
boxed fold applies; post-freeze new instances are already boxed
(mapdict.py:197), so the window is pre-freeze instances not yet read.

mapdict_frozen_unboxing_fold.py covers that shape, which no bench reached. It
passes without the fix — no output distinguishes the two, and check.py gates
output, not counters — so it is coverage, not a regression guard.

3. check: exempt wasm from the synthetic pypy ratio gate

run_synthetic_bench applied max-pypy-ratio to every enabled backend. run_bench
takes no wasm_vs_* parameter and hardcodes ("wasm", None, None), and
check.py:81 states "The wasm backend has no perf-ratio gate" — the synthetic
suite was the only caller gating wasm on a ratio, i.e. it broke the invariant the
file documents. Pass None for wasm; its per-bench timeout stays the hang guard.

This is a live failure on main today (FAIL wasm synth/residual_raise_except_resume … x10.0), not a hypothetical: that bench measured ~11.9x on wasm and passed
before #576 added the header, so only the gate appeared — the number barely moved.

4. jit: settle the drain sub-walk's journals in the root walk

drive_bridge_carrier_walk's PYRE_P2_COMPILE path returns into
full_body_walk_trace after its sub-walk has concrete-executed the reconstructed
callee and journaled its eager stores. That walk's entry
fbw_store_journal_reset() dropped those entries rather than settling them —
its comment assumes a "prior aborted walk", but the drain's sub-walk already ran.
So a root walk that did not commit rolled back only its own entries: the callee's
stores stood while the blackhole replayed the callee from the guard. The drain's
own abort path already rolls back; this second exit did not.

full_body_walk_trace takes a WalkJournals argument (only two callers exist).
The fresh-walk caller keeps the reset; the drain continuation keeps the journals
so the root walk's epilogue settles them — commit keeps the stores, non-commit
rolls them back.

Measured on the in-repo regression guard
pyre/bench/synth/inline_multiframe_drain_journaled_store.py ("hits[0] != N means
the drain doubled"), under PYRE_P2_COMPILE=1, both binaries verified distinct:

hits[0] loops_compiled bridges loops_aborted
before 120299 2 0 299
after 120000 2 0 299

Every counter identical and P2Drain::CompileRoot still fires 598 times, so no
trace decision moved — the 299 aborts are the 299 doubles. 3x deterministic both
ways. PYRE_P2_COMPILE is opt-in/default-OFF, so this was latent, not shipping;
the default drain-abort path is untouched.

Known gap this PR does not close

The list.append journal hazard the dropped commit aimed at is real and stays as
it is on main: the generic dispatcher concrete-executes a declined list.append
without fbw_append_journal_push, so a non-committing walk cannot rewind it. The
abort was the wrong instrument. try_execute_residual_call_via_executor already
carries the right one — inplace_list_journal captures (list, len_before) before
the call and journals it from the success arm — and an append is rewindable by
length under every strategy, so routing the declined append through that path keeps
both the trace and the journal. Left for its own change, with its own repro.

Verification

python3 pyre/check.py on base ccdc1a52be (LLBC re-extracted after the rebase; the
run built all four binaries itself): dynasm 194/194, cranelift 194/194, wasm 193/193,
3/3 backends, 0 FAIL
.

🤖 Generated with Claude Code

@coderabbitai

coderabbitai Bot commented Jul 16, 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: 33 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: e9d8802d-b8e5-41c2-94f5-02acff27b9bf

📥 Commits

Reviewing files that changed from the base of the PR and between ccdc1a5 and 25945ac.

📒 Files selected for processing (13)
  • majit/majit-ir/src/effectinfo.rs
  • majit/majit-metainterp/src/executor.rs
  • majit/majit-metainterp/src/pyjitpl.rs
  • majit/majit-metainterp/src/pyjitpl/dispatch.rs
  • pyre/bench/synth/mapdict_frozen_unboxing_fold.py
  • pyre/bench/synth/mapdict_polymorphic_map_attr.py
  • pyre/bench/synth/mapdict_unboxed_type_change_attr.py
  • pyre/check.py
  • pyre/pyre-interpreter/src/objspace/std/mapdict.rs
  • pyre/pyre-jit-trace/src/helpers.rs
  • pyre/pyre-jit-trace/src/jitcode_dispatch.rs
  • pyre/pyre-jit-trace/src/trace.rs
  • pyre/pyre-jit/src/jit/flatten.rs
✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch charon

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 Jul 16, 2026 •

Copy link
Copy Markdown

🤖 Codex parity review

Static analysis of this diff vs the local RPython/PyPy sources (commit 644971e).
Updated: 2026-07-16T06:26:16.490Z

Files in the reviewed diff
majit/majit-ir/src/effectinfo.rs
majit/majit-metainterp/src/executor.rs
majit/majit-metainterp/src/pyjitpl.rs
majit/majit-metainterp/src/pyjitpl/dispatch.rs
pyre/bench/synth/mapdict_frozen_unboxing_fold.py
pyre/bench/synth/mapdict_polymorphic_map_attr.py
pyre/bench/synth/mapdict_unboxed_type_change_attr.py
pyre/check.py
pyre/pyre-interpreter/src/objspace/std/mapdict.rs
pyre/pyre-jit-trace/src/helpers.rs
pyre/pyre-jit-trace/src/jitcode_dispatch.rs
pyre/pyre-jit-trace/src/trace.rs
pyre/pyre-jit/src/jit/flatten.rs

1. Regressions to PyPy parity introduced by this patch

None.

2. Other mismatches introduced by this patch

  • pyre/pyre-jit-trace/src/jitcode_dispatch.rs:18496 ↔ pypy/objspace/std/mapdict.py:1574 — the new boxed-slot specialization deliberately retains the original MayForce effect, force-token spill, GUARD_NOT_FORCED, and exception handling for a helper that only performs _mapdict_write_storage. PyPy’s cache hit marks ever_mutated then performs attr._direct_write(...) directly (mapdict.py:1581-1584), with no forcing call. This makes the newly added boxed fast path structurally non-equivalent and can unnecessarily force/devirtualize state.

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

  • pyre/pyre-jit/src/jit/flatten.rs:5344 ↔ pypy/objspace/std/mapdict.py:1574 — before this patch, ordinary store_attr lowering classified every store as MayForce; PyPy’s resolved mapdict cache-hit path is a direct write (mapdict.py:1581-1584, 1636-1639). The patch’s StoreAttr tag preserves that pre-existing generic effect classification when specialization declines.

4. Structural adaptations

  • pyre/pyre-jit-trace/src/helpers.rs:329 ↔ pypy/objspace/std/mapdict.py:614 — the unboxed write is represented as a Rust ABI helper call over (object, storageindex, listindex, raw-value), whereas PyPy writes the unboxed list element inline. This is a Rust/JIT-interface adaptation; the same-type and exact-type gates in jitcode_dispatch.rs:18415 preserve mapdict.py:615-619 semantics.

  • majit/majit-metainterp/src/pyjitpl/dispatch.rs:7593 ↔ pypy/objspace/std/mapdict.py:614 — float writes require a typed host-call shim to place the final argument in the ABI floating-point register. RPython’s low-level call machinery carries float storage directly; this Rust ABI distinction is structural.

The full-body-walker LOAD_ATTR fold previously bailed on unboxed int/float
slots (load_attr_fast_path returns None when p.unboxed.is_some()), so every
`obj.x` on an unboxed-int attribute compiled to the forcing getattr_fn
residual: ForceToken + SetfieldGc(vable) + CallMayForceR + GuardNotForced +
GuardNoException plus an MRO walk.

Add an unboxed fold path mirroring the boxed one:
- load_attr_unboxed_fast_path (mapdict.rs): same LOAD_ATTR resolution gates
  as load_attr_fast_path but accepts only an UnboxedPlainAttribute, returning
  the (storageindex, listindex, UnboxType) coordinates.
- read_unboxed_storage_raw (mapdict.rs): _prim_direct_read's raw longlong
  read before boxing (mapdict.py:600-601).
- jit_mapdict_unboxed_read_raw (helpers.rs): non-forcing residual mirroring
  jit_mapdict_read; returns the raw i64.
- try_walker_specialize_load_attr (jitcode_dispatch.rs): after the boxed fast
  path declines, emit guard_class + guard_value(map) + the non-forcing int
  residual, then wrapint. Float slots still decline (no Int->Float bitcast
  yet). The boxed wrapint result carries its heap-ptr concrete stamp via
  box_int_concrete so a downstream eager void residual (e.g. the still-
  residual STORE_ATTR) can resolve its value arg.

The read is non-forcing and the box virtualizes when an integer consumer
immediately unboxes it, so the vable spill and force/exception guards
disappear. synth dynasm 176/176.

Assisted-by: Claude
An `obj.x = obj.x + 1` unboxed-int hot loop still compiled STORE_ATTR to the
forcing setattr residual: ForceToken + SetfieldGc(vable) + NewWithVtable
(re-box the value) + SetfieldGc(box) + CallMayForceN(setattr_fn) +
GuardNotForced + GuardNoException. setattr forces because it may run
__setattr__ / a data descriptor and raise; for a plain instance whose map and
version_tag are guarded, with the attribute already present as an unboxed int
slot and the incoming value the same type, the write is a pure in-place
list[listindex] = raw that cannot force or raise.

Fold that case to a non-forcing raw write:
- PyreHelperKind::StoreAttr (effectinfo.rs) + tag the setattr residual with it
  (flatten.rs lower_setattr).
- store_attr_unboxed_fast_path + write_unboxed_storage_raw (mapdict.rs): the
  STORE_ATTR_caching resolution gates (custom __setattr__ / __getattribute__,
  version_tag, classify, find_map_attr) restricted to an existing unboxed slot,
  and the same-type in-place longlong write (mapdict.py:615-619).
- jit_mapdict_unboxed_write_raw (helpers.rs): non-forcing void residual.
- try_walker_specialize_store_attr (jitcode_dispatch.rs): after resolving an
  unboxed-int slot and proving the value is a plain int (exact type, bool
  rejected), guard_class + guard_value(map), unbox the value to raw i64, and
  return a substitute descr (CannotRaise) + arglist. The caller swaps only the
  helper/effect/args and continues through the SAME generic residual
  recorder/executor, so concrete execution, body-effect tracking, and rollback
  are identical to the generic setattr path. No W_IntObject is re-boxed.
- Hoist the shared guard_class + guard_value(map) block into
  walker_guard_mapdict_instance_shape (used by both the load and store folds).
- PYRE_FBW_STOREATTR_FOLD kill switch (default ON), independent of the read
  fold since the write commits a concrete heap mutation.

The store collapses from ~7 ops to a single non-forcing CallN carrying the raw
value; the value re-box virtualizes away. attr_min (o.x=o.x+1, 2e6 iters)
0.23s -> 0.02s. synth dynasm 176/176, cranelift 176/176, GC-soak clean.

Assisted-by: Claude
run_synthetic_bench passed max-pypy-ratio to every enabled backend. run_bench
takes no wasm_vs_* parameter and hardcodes ("wasm", None, None), so the
synthetic suite was the only caller gating wasm on a ratio.

Pass None for wasm and keep its per-bench timeout as the hang guard.

Assisted-by: Claude
The map guard_value the attr folds rely on was dead code until the fix on main
(walker_guard_mapdict_instance_shape tested box_value, which falls back to the
walker's always-present concrete shadow, so the guard was never emitted). The
synthetic suite stayed green at 176/176 throughout because it had no fixture
that reaches the shapes an unguarded map breaks; polymorphic_slot_retype.py
covers local retyping and the valuestack, not map identity.

mapdict_polymorphic_map_attr: one LOAD_ATTR site over two plain instances whose
attribute insertion order assigns `q` different storage slots. Without the map
guard this read 1201010 instead of 2200000, since GuardClass(&INSTANCE_TYPE)
passes for both maps.

mapdict_unboxed_type_change_attr: a hot loop over an unboxed int slot that takes
a float mid-loop, and an unboxed float slot that takes an int. Either write runs
convert_to_boxed, which allocates a new map through node_copy; without the guard
the trace kept calling jit_mapdict_unboxed_read_raw, which unerase_unboxed'es a
now-real PyObjectRef as *mut Vec<i64> (int slot returned a heap address as an
int; float slot segfaulted).

Both compile their loop, so they pin the guard rather than the interpreter.

Assisted-by: Claude
LOAD_ATTR declined an unboxed float slot and STORE_ATTR declined anything but an
int one, so a float attribute kept the forcing getattr/setattr residuals:
o.f = o.f + 1.0 ran 8.16s against pypy's 0.25s at 60M iterations, while the same
loop without the attribute was already at parity.

The decline comment claimed a Float-bank bitcast was needed first. It is not:
unboxed storage already holds the value's bit pattern (mapdict.py:577,584
float2longlong / longlong2float), so a float-typed helper converts in Rust and
the trace only needs the bank it already has. mapdict is unchanged — both fast
paths already return the UnboxType and the raw accessors are type-agnostic.

Add jit_mapdict_unboxed_read_f / jit_mapdict_unboxed_write_f beside their raw
twins and emit_trace_call_float_typed beside emit_trace_call_int_typed, then
give both folds a Float arm: read through CallF plus wrapfloat, write through
walker_unbox_float into a [Ref, Int, Int, Float] void residual. A value whose
type is not exactly float still declines, since that write converts the slot to
boxed storage.

Route the concrete void call through call_void_function_typed: args reach the
executor packed into machine words, so passing an f64 through the all-i64
transmute would leave the callee reading an untouched floating-point register.
The host-trampoline path reflects the callee's real signature and is unchanged.

attr_float at 60M: 8.16s -> 0.24s against pypy 0.23s.

Assisted-by: Claude
Replace the generic setattr residual's helper and arguments with a direct
mapdict slot write when the receiver's guarded shape pins an existing boxed
slot. The residual's original may-force effect is kept, so the force token,
the virtualizable spill, and the trailing force/exception guards stay as the
generic setattr emitted them; only the opaque setattr_fn MRO walk is
replaced.

Adds store_attr_boxed_fast_path + write_boxed_storage (matching
plain_direct_write's boxed arm) and the jit_mapdict_boxed_write residual.
PYRE_FBW_STOREATTR_FOLD=0 declines the fold.

attr_boxed at N=60M: 3.77s without the fold, 0.5s with it. Dropping the
residual's may-force effect on top measures 0.47s, so the win is the removed
MRO walk rather than the force protocol, and the effect is left alone.

Assisted-by: Claude
`drive_bridge_carrier_walk`'s `PYRE_P2_COMPILE` path returns into
`full_body_walk_trace` after its sub-walk has concrete-executed the
reconstructed callee and journaled the callee's eager stores.  That walk's entry
`fbw_store_journal_reset()` dropped those entries rather than settling them, so
a root walk that did not commit rolled back only its own: the callee's stores
stood while the blackhole replayed the callee from the guard, applying them a
second time.  The drain's own abort path already rolls back.

Take a `WalkJournals` argument.  The fresh-walk caller keeps the reset; the
drain continuation keeps the journals so the root walk's epilogue settles them
-- commit keeps the stores, non-commit rolls them back.  The
unjournaled-effect flag carries over for the same reason: a sub-walk that only
recorded a residual must still block the root's commit.

`pyre/bench/synth/inline_multiframe_drain_journaled_store.py` under
`PYRE_P2_COMPILE=1`: hits[0] 120299 -> 120000 (N=120000), 3x deterministic.
loops_compiled, bridges_compiled and loops_aborted are unchanged at 2 / 0 / 299,
and `P2Drain::CompileRoot` still fires 598 times, so no trace decision moved --
the 299 aborts are the 299 doubles.  The default drain-abort path is untouched.

Assisted-by: Claude
The JIT LOAD_ATTR/STORE_ATTR folds resolve attributes through the
`*_fast_path` ports of LOAD_ATTR_caching/STORE_ATTR_caching but skipped two
steps those paths perform.

STORE_ATTR_caching sets `attr.ever_mutated` before `_direct_write`
(mapdict.py:1635-1637). `store_attr_boxed_fast_path` and
`store_attr_unboxed_fast_path` now mark it too; a folded write previously
left the attribute marked unmutated. Nothing in pyre reads `ever_mutated`
yet, so no observable behavior changes.

LOAD_ATTR_caching resolves to `attr._direct_read` (mapdict.py:1539), which
for an unboxed attribute migrates the instance off unboxed storage once its
class froze unboxing (mapdict.py:594-596). The folded read performs
`_prim_direct_read` alone, so `load_attr_unboxed_fast_path` now declines a
frozen class and leaves the read to the residual, whose `direct_read` runs
the migration. Both forms return the same value — `_direct_read` reads
before it converts — so this changes trace shape, not results. On a probe
that freezes unboxing and then hot-loops the first read of 40000
pre-created instances, both print 799980000:

  before: bridges_compiled=1 guard_failures=202
  after:  bridges_compiled=0 guard_failures=1

Skipping the migration left unboxed and boxed instances mixed under one
promoted-map guard; the residual migrates each on its first read, after
which the boxed fold applies.

mapdict_frozen_unboxing_fold.py covers that shape, which no bench reached.
It passes either way — no output distinguishes the two — so it is coverage,
not a regression guard.

The effectinfo docs for both helper tags described the earlier folds: the
LoadAttr tag still listed an unboxed slot as falling through to the
residual, and StoreAttr claimed integer-only.

Both reported by the Codex parity review on PR #592.

Assisted-by: Claude
The WalkJournals enum was inserted between full_body_walk_trace's rustdoc
and the fn, so rustdoc attached the whole 16-line block ("Issue #73
production full-body tracer") to the enum and the fn lost its docs. Move
the enum above the block.

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