Skip to content

jit: four bridge/retrace parity gaps, the x86 GUARD_VALUE counter stamp, and the f32 decay multiply - #1222

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

jit: four bridge/retrace parity gaps, the x86 GUARD_VALUE counter stamp, and the f32 decay multiply#1222
youknowone merged 6 commits into
mainfrom
perf-bridge

Conversation

@youknowone

Copy link
Copy Markdown
Owner

Four unported pieces of the bridge/retrace path, plus two counter fixes carried
over from #1207's review.

Bridge / retrace

jit: represent an unwritten virtual-array slot as absent fieldstate
VArray::items and VArrayStruct::element_fields held a bare
Rc<VirtualStateInfoNode>, so there was no way to say what upstream says with
None. An unwritten slot's OpRef::NONE reached export_state's
unreachable-panic — except under cfg!(test), where an arm fabricated an
Unknown(Type::Int) leaf, so test builds numbered a slot a release build could
not export at all. Both vectors become Option<...>, filled through a named
create_state_or_none (virtualstate.py:707-710), and the VArray / VArrayStruct
guard arms take upstream's asymmetry verbatim (virtualstate.py:257-260,
:316-320): expected absent continues, expected present against incoming absent
raises.

jit: hand the CA blackhole leg the deadframe copy this hook rooted
compile.py:701-717 is an exclusive if/else, but pyre's
handle_fail_resume_guard runs the bridge hook and the blackhole hook back to
back over one host Vec<i64> that is not a GC root.
jit_ca_handle_guard_failure rooted its own copy but published it through
CA_WALK_RESUME_DEADFRAME only on the !compiled arm, leaving the successful
attach reading the caller's un-rooted buffer. Publish on both arms.
CA_WALK_ADOPTED_FRAME was also stamped from inside the walk closure, before
the bridge is optimised and compiled — work that allocates. It now stamps at the
return sites from a fresh FrameRoot::frame read, sharing a helper with the
four CA_WALK_RESUME_FRAME stamps.

wasm: publish the LABEL target tokens a bridge defines
fixup_target_tokens runs at the end of both assemble_loop and
assemble_bridge upstream (x86/assembler.py:612 and :706), and pyre's x86,
aarch64 and cranelift backends all run their publish step on the bridge path.
wasm ran it only from compile_loop. Nothing declines a LABEL-bearing bridge:
compile_retrace (compile.py:341-393) reaches the backend through
send_bridge_to_backend, so a retrace is a bridge with its own LABEL, and its
label never entered LABEL_TARGETS. The existing peeled / non-peeled split
needs no new gate — a retrace closing onto its own target token is peeled and
publishes, while a jump_to_preamble retrace is not peeled and correctly stays
unpublished. CompiledWasmLoop gains bridge_owned_label_targets and retracts
them in Drop under the same registry guard the loop's own labels use.

jit: clear optimizer forwarding before handing a trace to the backend
forget_optimization_info (compile.py:496-502) is the first statement of both
send_loop_to_backend and send_bridge_to_backend; pyre had no port, so the
optimizer's output ops reached the backend and were stored verbatim as
CompiledTrace.ops with their forwarded slots still populated, pinning that
trace's PtrInfo graph for the artifact's lifetime. Ported over OpRc and
InputArg and called at the five backend boundaries. The reset_values arm
stays unported — neither call site passes it. check_no_forwarding existed with
no caller; it is now a debug_assert! on the bridge leg where unroll.py:188
asserts it.

Counters (follow-ups to #1207's review)

dynasm(x86): restore the GUARD_VALUE per-value counter stamp
regalloc.py:496-499 calls make_a_counter_per_value while laying out every
GUARD_VALUE. aarch64 dynasm, cranelift and wasm all do; the x86 emitter had it
and lost it in an unrelated 254-line deletion. Without the stamp the descr keeps
store_hash's plain per-guard hash, so must_compile takes compile.py:745's
common-case arm instead of the per-value bucketing at compile.py:753-781 — every
failure of a polymorphic GUARD_VALUE ticks one bucket and it compiles a bridge
specialised to a value that does not recur. x86-only code, so the local darwin
gate cannot reach it; verified with
cargo check --target x86_64-apple-darwin -p majit-backend-dynasm. It will move
jit-stats counters on the linux and windows legs.

jit: narrow the decay multiplier to f32 before multiplying, as the C helper does
pypy__decay_jit_counters does float f = (float)f1 once and multiplies in
single precision. pyre widened each entry to f64, multiplied by the f64
multiplier and narrowed the product — two roundings, which disagree when the
exact product lands near an f32 tie. would_tick_fire narrows the same way so
the predicate still agrees with the tick draining the same generations.

Verification

pyre/check.py on darwin arm64, all three backends: dynasm 427/427.

Two categories of pre-existing red remain, neither from this branch:

  • synth/str_fstring cranelift jitstats, 657 -> 658. The committed per-OS
    baselines for this fixture disagree with what each host observes, on main
    itself; the same +1 gap is measurable on this base with these four commits
    reverted. jit: count eval-breaker back-edge poll failures separately #1194 shifted the observation and the baseline together by one, so
    the gap is unchanged. Being re-recorded separately.
  • Execution-ratio rows. The local machine was at load 26-56 (ten cores, four
    sibling worktrees building), so these are not measurements: this run failed
    cranelift fib_recursive (5s timeout) and wasm fannkuch (3.2x), while an
    earlier run on a quieter machine passed both and instead failed
    global_quasiimmut_invalidation and short_circuit_value_kept_stack — two
    rows main's own ubuntu leg also fails, along with raise_catch, which this
    branch passes. CI is the measurement that counts here.

The x86 GUARD_VALUE stamp cannot be exercised by a darwin gate at all; it is
compile-checked against x86_64-apple-darwin and will show up on the linux and
windows legs.

authored by Claude

…helper does

counter.py:278-279 hands `decay_by_mult` to `pypy__decay_jit_counters`, whose
C body does `float f = (float)f1` once and then multiplies each entry in
single precision. pyre widened the entry to f64, multiplied by the f64
multiplier and narrowed the product, which rounds twice and disagrees whenever
the exact product lands near an f32 tie.

`would_tick_fire` narrows the same way, so the predicate still agrees with the
`tick` that drains the same generations.

Reported by the Codex parity review on #1207 as a pre-existing mismatch. It
predates the decay drain but that change makes it compound: the multiply now
runs once per elapsed interval instead of saturating at one.

Assisted-by: Claude
regalloc.py:496-499 consider_guard_value calls
`op.getdescr().make_a_counter_per_value(op, loc)` while laying every
GUARD_VALUE out. pyre does this on aarch64 dynasm, cranelift and wasm; the x86
emitter had it when `dynasm: x86/aarch64 split` created the file and
`backend+metainterp: unify descr->JCT identity via meta_descr back-pointer`
deleted it along with 254 other lines, leaving the aarch64 twin untouched.

Without the stamp the descr's status keeps the plain per-guard hash
`store_hash` writes (compile.py:826-829 only fires while `status == 0`), so
`must_compile` takes compile.py:745's common-case arm instead of
compile.py:753-781's `addr(descr) * 777767777 + intval * 1442968193`. Every
failure of a given GUARD_VALUE then ticks one bucket regardless of which value
failed, so a polymorphic guard reaches trace_eagerness on aggregate failures
and compiles a bridge specialised to a value that does not recur.

The block is the aarch64 one verbatim, including the fail-arg index: pyre's
`must_compile_with_values` reads the value back out of `fail_values`, where
upstream passes a register index.

The x86 module is gated on `target_arch = "x86_64"`, so an aarch64 host never
compiles it and the local gate cannot reach this path. Verified with
`cargo check --target x86_64-apple-darwin -p majit-backend-dynasm`; the runtime
effect is only observable on the linux and windows CI legs, where it will move
jit-stats counters that currently record the unbucketed behaviour.

Assisted-by: Claude
`VirtualStateInfo::VArray::items` and `VArrayStruct::element_fields` held
`Rc<VirtualStateInfoNode>` with no way to express upstream's `None`
fieldstate. `export_state` reached its unreachable-panic for an
unwritten slot's `OpRef::NONE` operand; a `cfg!(test)` arm fabricated an
`Unknown(Type::Int)` leaf instead, so test builds numbered a slot that a
release build could not export at all.

Both slot vectors are now `Option<Rc<VirtualStateInfoNode>>`, filled by a
named `create_state_or_none` mirroring
`VirtualStateConstructor.create_state_or_none` (virtualstate.py:707-710).
`_generate_guards`, `_enum`, `enum_forced_boxes`, the numbering and
counting walks, the GC walk, position resets, deep clone, state import
and label-argument import all skip absent entries. The VArray and
VArrayStruct guard arms take upstream's asymmetry verbatim
(virtualstate.py:257-260, :316-320): expected absent continues, expected
present against incoming absent raises.

The `cfg!(test)` fabrication is gone; the export panic remains and now
distinguishes the absent-operand sentinel from a ref with no recorded
type.

Assisted-by: Claude
`handle_fail_resume_guard` (majit-backend-dynasm) runs the bridge hook and
the blackhole hook back to back over one host `Vec<i64>` that is not a GC
root, where compile.py:701-717 is an exclusive if/else and never reaches
`resume_in_blackhole` after a successful attach.
`jit_ca_handle_guard_failure` roots its own copy across the bridge hook but
published it through `CA_WALK_RESUME_DEADFRAME` only on the `!compiled`
arm, so the successful-attach leg left the blackhole reading the caller's
un-rooted buffer. Publish on both arms.

`CA_WALK_ADOPTED_FRAME` was stamped from inside the walk closure, before
the bridge was optimised and compiled. Stamp it at the return sites
instead, from a fresh `FrameRoot::frame` read, the way the four
`CA_WALK_RESUME_FRAME` stamps already do; the four resume stamps and the
new adopted stamp share one helper. `jit_blackhole_resume_from_guard`
compares that cell against the deadframe's slot 0, so both sides now name
the frame's current address.

Assisted-by: Claude
`fixup_target_tokens` runs at the end of both `assemble_loop` and
`assemble_bridge` upstream (x86/assembler.py:612 and :706), and pyre's x86,
aarch64 and cranelift backends all run their publish step on the bridge
path. wasm stamped `set_label_block_id` and called `publish_label_target`
only from `compile_loop`, while nothing declines a LABEL-bearing bridge —
`compile_retrace` (compile.py:341-393) reaches the backend through
`send_bridge_to_backend`, so a retrace is a bridge with its own LABEL, and
its label never entered `LABEL_TARGETS`.

The stamp + publish block moves into `stamp_and_publish_label_targets`,
called by `compile_loop` with its own handle and frame and by
`compile_bridge` with `bridge_slot` and `source_frame` past every decline.
The existing peeled / non-peeled split needs no new gate: a retrace closing
onto its own target token is peeled and publishes at key ordinal + 1, while
a `jump_to_preamble` retrace is not peeled and its label stays unpublished.

`CompiledWasmLoop` gains `bridge_owned_label_targets`, and `Drop` retracts
those entries under the same "the registry still names this handle" guard
it applies to the loop's own labels; a bridge module already lives as long
as its source loop. Counter 21 no longer fires for a bridge with no LABEL,
which has nothing to publish and nothing withheld.

Assisted-by: Claude
compile.py:496-502 `forget_optimization_info` is the first statement of both
`send_loop_to_backend` (compile.py:506-507) and `send_bridge_to_backend`
(compile.py:571-572); pyre had no port, so the optimizer's output `Rc<Op>`s
reached the backend and were stored verbatim as `CompiledTrace.ops` with
their `forwarded` slots still populated, pinning that trace's `PtrInfo`
graph for the artifact's lifetime.

Port it over `OpRc` and `InputArg` and call it at the five loop/bridge
backend boundaries, immediately ahead of the profiler scope. The
`reset_values` arm stays unported: neither send-to-backend call site passes
it.

`UnrollOptimizer::check_no_forwarding` existed with no caller; wire it as a
`debug_assert!` on the bridge leg, where unroll.py:188 asserts it.

Assisted-by: Claude
@coderabbitai

coderabbitai Bot commented Aug 14, 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: 25 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: 414741ee-ebed-4907-b281-cf280a1b1ff0

📥 Commits

Reviewing files that changed from the base of the PR and between d5ae680 and 45f9e5c.

📒 Files selected for processing (9)
  • majit/majit-backend-dynasm/src/x86/assembler.rs
  • majit/majit-backend-wasm/src/failguard.rs
  • majit/majit-backend-wasm/src/lib.rs
  • majit/majit-metainterp/src/optimizeopt/optimizer.rs
  • majit/majit-metainterp/src/optimizeopt/unroll.rs
  • majit/majit-metainterp/src/optimizeopt/virtualstate.rs
  • majit/majit-metainterp/src/pyjitpl.rs
  • majit/majit-trace/src/counter.rs
  • pyre/pyre-jit/src/call_jit.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

Copy link
Copy Markdown

🤖 Codex parity review

Static analysis of this diff vs the local RPython/PyPy sources (commit 45f9e5c).
Updated: 2026-08-14T11:04:25.075Z

Files in the reviewed diff
majit/majit-backend-dynasm/src/x86/assembler.rs
majit/majit-backend-wasm/src/failguard.rs
majit/majit-backend-wasm/src/lib.rs
majit/majit-metainterp/src/optimizeopt/optimizer.rs
majit/majit-metainterp/src/optimizeopt/unroll.rs
majit/majit-metainterp/src/optimizeopt/virtualstate.rs
majit/majit-metainterp/src/pyjitpl.rs
majit/majit-trace/src/counter.rs
pyre/pyre-jit/src/call_jit.rs

1. Regressions to PyPy parity introduced by this patch

  • pyre/pyre-jit/src/call_jit.rs:4081 ↔ rpython/jit/metainterp/compile.py:701 — the patch now unconditionally substitutes raw_values_vec for the backend deadframe after dropping ResumeDeadframeRoots. On a successfully compiled CA bridge, main left the backend-provided deadframe in use; now the blackhole hook consumes an unrooted copied vector, whose Ref slots may become stale across a moving GC. PyPy keeps the live deadframe through its exclusive guard-failure path.

2. Other mismatches introduced by this patch

None.

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

  • majit/majit-metainterp/src/optimizeopt/virtualstate.rs:2728 ↔ rpython/jit/metainterp/optimizeopt/virtualstate.py:720 — cyclic virtual-state export replaces a back-edge with fresh Unknown(Ref) instead of preserving the cached virtual-state node. Upstream inserts the virtual state into self.info before recursively walking fields, preserving cycles.
  • majit/majit-metainterp/src/optimizeopt/info.rs:1298 ↔ rpython/jit/metainterp/optimizeopt/info.py:147 — forcing a VirtualArrayStruct replaces its pointer info with nonnull(), losing array-struct identity. Upstream retains the same ArrayStructInfo and only clears its virtual flag.

4. Structural adaptations

  • majit/majit-backend-dynasm/src/x86/assembler.rs:5402 ↔ rpython/jit/backend/x86/regalloc.py:496 — Pyre records the logical fail-argument index because its guard callback receives a compact fail_values slice; PyPy records the physical deadframe/register location. This is a backend-ABI adaptation and preserves the intended per-value counter behavior.
  • majit/majit-backend-wasm/src/lib.rs:219 ↔ rpython/jit/backend/x86/assembler.py:990 — publishing bridge LABEL targets through the wasm function-table registry, and retracting them with the source loop, adapts PyPy’s native code-address patching to wasm module handles.

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