Skip to content

jit: hand every abort_permanent the value stack the resumed opcode reads; gc: arena range cache; check.py measurement fixes - #992

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

youknowone merged 5 commits into
mainfrom
perf-bridge

Conversation

@youknowone

@youknowone youknowone commented Aug 2, 2026 •

Copy link
Copy Markdown
Collaborator

The defect

A call past the per-arity helper ceiling, reached after a hot loop, miscompiles.
Both backends, and in both shapes:

L = []
def f(*a): return sum(a)
i = 0
while i < 50000: i += 1
L.append(1)
print(f(1,2,3,4,5,6,7,8,9,10,11,12,13,14,15,16,17), L)   # value-stack underflow, rc=101
def take(*a, **k): return (a, k)
# ... after a hot loop ...
take(1,2,3,4,5,6,7,8,9,10,11,12,13,14, kw=99)
# UnboundLocalError: cannot access local variable 'a'  -- raised inside the callee

abort_permanent/ publishes last_instr = py_pc - 1, so the interpreter resumes
at the marker's own opcode and runs it. Every operand that opcode reads has
to still be readable out of the frame. Two independent reasons it was not:

  1. The wide Call arm consumed its operands before testing the arity. It ran
    emit_popvalue_ref! over every argument, null_or_self and the callable
    first; each of those nulls the slot and lowers valuestackdepth, so the
    frame reached the marker at depth 0. The arity test is now hoisted above the
    pop loop — nargs is known on the arm's first line.

  2. push_and_bump! never mirrors the pushed value into the vable. It does
    current_state.stack.push + current_depth += 1 + emit_vsd! and nothing
    else, so a slot last written that way still reads PY_NULL. LoadSmallInt and
    LoadConst push through it; only emit_load_fast_ref! /
    emit_pushvalue_ref! / emit_pushvalue_ref_const! write the array. After (1)
    the resumed call read seventeen NULLs. Fixed by flushing the value stack with
    setarrayitem_vable_r in the shared emit_abort_permanent! path, so every
    marker — not only the closes_block ones — hands back a real frame.

The flush uses a per-PC snapshot of the entry stack, not current_state: an
arm that aborts has usually already modelled the opcode's stack effect, so at the
marker current_state describes the state after an opcode that has not run.

Nothing on a run that keeps executing pays for the stores — the walk reaches a
marker only on a run that is bailing.

Also: the LoadFastCheck unbound arm walked its depth back on the abstract state
only, leaving the vable one slot above the frame's real top.

Why it surfaced now

Before the blackhole rollback was removed, the deopt restored locals /
valuestackdepth / last_instr from a pre-blackhole snapshot, which overwrote
the published coordinate and hid all of this. Removing the rollback was correct —
it double-executed side effects — and made the coordinate load-bearing.

The deviation this compensates for

pyframe.py's pushvalue lowers every push to a setarrayitem_vable_r
(jtransform.py's do_fixed_list_setitem); the optimizer then elides the
redundant ones and forces at a may_force boundary. push_and_bump! omitting
the store is the deviation, and the abort-site flush compensates for it. Making
push_and_bump! mirror is the orthodox fix, but it puts a store after every
computed push on the hot path and moves jit-stats broadly — a separate, measured
change.

Regression coverage

bench/synth/wide_callkw_resume.py, verified against a binary built without the
fix, where it fails with exactly the UnboundLocalError above. nargs <= 13
(no abort) is the passing control.

It carries no max-pypy-ratio: the CALL_KW is refused every time so the
enclosing frame falls back to the interpreter, and pypy's own execution time here
sits on the harness measurement floor, so no ratio computed against it is a
measurement. The committed jit-stats and the output comparison are what gate it.

check.py never caught any of this; the probe scripts did.

Also here

  • gc: cache the last ArenaCollection::contains range hit, invalidated in
    free_arena. A doc note on walk_roots records that null entries are skipped
    on the walk and nowhere else — the save side stores a livevar untested
    (shadowcolor.py's expand_one_push_roots emits a gc_save_root per live
    GCREF with no value test, and marks slots unused through the translation-time
    make_bitmask), and the only null test is walk_stack_root's if content: in
    shadowstack.py.

  • check.py: annotate a gate failure whose baseline exec was clamped to
    EXEC_TIME_FLOOR_S with [... exec clamped to floor; ratio not a measurement], so a ratio that is an artifact of the clamp says so instead of
    reading as a regression. This is the whole behavioural delta against main;
    the rest of those two commits is a comment.

    The first of them also switched the startup estimator from the median of
    STARTUP_SAMPLES to the minimum, and the second puts it back. The minimum
    does recover a fixed per-process cost — on an idle machine. It is subtracted
    from bench runs that were not idle, and a loaded bench carries an inflated
    startup inside it, so taking the unloaded minimum away leaves that inflation
    in exec. On the previous CI run of this branch the minimum came in 15ms
    under the median for dynasm and 13ms for cranelift, and nine short benches
    whose pypy baseline was pinned to the floor crossed their gates at once, where
    main at the same base had none. The comment records this so it is not
    retried; note it cannot be reproduced locally, because on an idle box the
    median and the minimum agree.

Verification

Locally, on both backends: the 15 probe scripts and a 12-repro comparison
against CPython all pass, and cargo test --all --no-default-features is
7372 passed, 0 failed.

CI on the previous push of this branch (base 6fbef5f7c7c) had cargo test
green on all three platforms. Its check.py red was measured against main's
own run at that same base, per platform. Windows is the clean comparison —
its exec floor is a scheduler tick rather than 5ms, so short benches do not sit
on the floor and the ratio-noise class is absent there:

main @ 6fbef5f7c7c this branch, same base
windows exception_args_virtual ×2, list_length_hint_validate ×2 — 2 failed, 368 passed the same four lines, identical counters — 2 failed, 369 passed

Zero branch-attributable difference; the extra pass is the new bench. Those two
have since been fixed on main by #993 and no longer appear at this branch's
current base.

— authored by Claude

@coderabbitai

coderabbitai Bot commented Aug 2, 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: 1 minute

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: bcde5cc9-0146-453e-a38a-e87af2ed7a9e

📥 Commits

Reviewing files that changed from the base of the PR and between 5fc310a and ccf1242.

📒 Files selected for processing (8)
  • majit/majit-gc/src/minimarkpage.rs
  • majit/majit-gc/src/shadow_stack.rs
  • pyre/bench/synth/wide_callkw_resume.cranelift.jitstats
  • pyre/bench/synth/wide_callkw_resume.dynasm.jitstats
  • pyre/bench/synth/wide_callkw_resume.py
  • pyre/bench/synth/wide_callkw_resume.wasm.jitstats
  • pyre/check.py
  • pyre/pyre-jit/src/jit/codewriter.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 3, 2026 •

Copy link
Copy Markdown

🤖 Codex parity review

Static analysis of this diff vs the local RPython/PyPy sources (commit ccf1242).
Updated: 2026-08-03T05:38:18.634Z

Files in the reviewed diff
majit/majit-gc/src/minimarkpage.rs
majit/majit-gc/src/shadow_stack.rs
pyre/bench/synth/wide_callkw_resume.py
pyre/check.py
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:5544 ↔ rpython/jit/metainterp/pyjitpl.py:2445-2476: pyre constructs one portal frame_var, which its own non-portal-callee paths acknowledge aliases the outermost frame; PyPy creates an MIFrame per called jitcode. This can still collapse an inlined callee’s frame identity, including its locals/globals/line coordinate, onto its caller. The relevant aliasing behavior was already present in upstream/main (also documented at current codewriter.rs:8855-8868 and :8933-8940).

4. Structural adaptations

  • pyre/pyre-jit/src/jit/codewriter.rs:6783-6798 ↔ pypy/interpreter/pyframe.py:363-377: PyPy writes each pushed value directly into locals_cells_stack_w; pyre normally keeps pushed values in SSA registers, then materializes the pre-opcode stack into the virtualizable frame only at a permanent interpreter bail. This is a Rust/JIT representation adaptation, and the patch restores the required observable frame state before resumption.

  • pyre/pyre-jit/src/jit/codewriter.rs:9126-9144 ↔ pypy/interpreter/pyopcode.py:1395-1400: PyPy accepts the opcode’s dynamic argument count directly; pyre’s fixed native helper ABI supports at most 14 positional arguments and returns wider calls to the interpreter before consuming operands. This is an implementation/ABI limitation, not a semantic mismatch after this patch.

  • majit/majit-gc/src/minimarkpage.rs:57-64,505-513,545-551 ↔ rpython/memory/gc/minimarkpage.py:298-321,404-410: pyre adds a one-entry Cell cache for arena-range membership, invalidated when an arena is freed. Upstream stores allocation state in arena/page structures and has no equivalent arbitrary-address membership cache. The cache preserves the same membership result and is a Rust-side performance adaptation.

…aseline

`measure_startups` took the median of STARTUP_SAMPLES empty-program user-CPU
runs; it now takes the minimum.  Every error source on that fixed per-process
cost is one-sided -- contention, cache pressure and page faults only add user
CPU -- so under sustained load every sample inflates together and the median
inflates with them, which is why raising the sample count did not help.

That estimate is subtracted from every timed run and is the divisor for a short
bench, so an inflated one drives the baseline's exec time onto
EXEC_TIME_FLOOR_S and the reported ratio becomes `pyre_exec / floor`.
`_gate_fail_detail` now appends a marker when the baseline exec sits on the
floor, so such a line reads as a clamp rather than as a measurement.

Assisted-by: Claude
`ArenaCollection::contains` ran a binary search over `arena_ranges` on every
call; it now re-tests the range it matched last before searching.  `free_arena`
clears the cache -- that is the only way a cached range can go stale, since
inserting an arena leaves every existing range where it was.

Measured by retired instructions, min of 7, both arms built from one LLBC
extraction (whole-process totals; the same binary measured twice under two
labels differs by 0.010%):

    polymorphic_binary_receiver  10.477G -> 10.071G
    recursion_memo_branch         2.712G ->  2.665G
    polymorphic_slot_retype       2.758G ->  2.726G
    call_loop_local_function     896.5M  -> 881.5M

Those arms also carried a null-root skip in `OwnerRootGuard::new`, which is
dropped: the save side stores a livevar without testing its value
(`shadowcolor.py`'s `expand_one_push_roots` emits a `gc_save_root` per live
GCREF and marks unused slots through the translation-time `make_bitmask`), so
skipping the acquire moved a null test that belongs on the walk to the save.
Recorded the walk-side rule on `walk_roots` instead.

Assisted-by: Claude
… a closes_block marker

`abort_permanent/` stores `last_instr = py_pc - 1` into the vable, so the
interpreter resumes at the marker's own opcode and runs it. Two arms reached
the marker with a frame that could not run it.

The `Call` nargs > 14 arm ran `emit_popvalue_ref!` over the callable,
null_or_self and every argument first; each of those nulls its stack slot and
syncs the lowered `valuestackdepth` into the vable. Resuming a 17-argument
CALL then hit the frame's own `report_stack_underflow` at depth 0. Move the
arity test above the pop loop and bail there, and drop the `nargs > 14` legs
that the earlier `continue` leaves unreachable.

`push_and_bump!` keeps a pushed value in its SSA register and syncs only the
depth, so a slot last written that way still reads PY_NULL out of the vable;
the resumed CALL read seventeen NULLs. Mirror every live stack slot with
`setarrayitem_vable_r` before the terminal marker.

The `LoadFastCheck` unbound arm walked its depth back on the abstract state
only, leaving the vable one slot above the frame's real top.

Assisted-by: Claude
`abort_permanent/` stores `last_instr = py_pc - 1`, so the interpreter resumes
AT the marker's opcode and re-runs it. `pyframe.py`'s `pushvalue` lowers every
push to a `setarrayitem_vable_r` (`jtransform.py`'s `do_fixed_list_setitem`),
but `push_and_bump!` keeps the pushed value in its SSA register and syncs only
`valuestackdepth`, so a stack slot last written that way still reads PY_NULL
out of the vable. A CALL_KW past the per-arity ceiling (nargs > 13) resumed
against empty argument slots and raised `UnboundLocalError` inside the callee
for its `*args` parameter.

Write the stack back at the marker. The snapshot taken as the opcode is
entered, not `current_state`: an aborting arm has usually already modelled the
opcode's stack effect, and the frame the interpreter needs is the one from
before the opcode ran. `pre_opcode_stack` is declared beside the macro because
a `macro_rules!` body resolves a non-parameter identifier in its definition
scope.

This subsumes the `closes_block`-only flush, which used the post-arm state, and
the `LoadFastCheck` unbound arm's separate `emit_vsd!`; both are removed. The
depth is now published from the snapshot at every marker.

Adds `synth/wide_callkw_resume`.

Assisted-by: Claude
`measure_startups` goes back to the median of STARTUP_SAMPLES empty-program
user-CPU runs.  The minimum recovers the fixed per-process cost for an idle
machine, but that estimate is subtracted from bench runs that were not idle: a
loaded bench carries an inflated startup inside it, and taking the unloaded
minimum away leaves the inflation in the reported exec time.  On one CI job the
minimum came in 15ms under the median for dynasm and 13ms for cranelift, and
nine short benches whose baseline exec was pinned to EXEC_TIME_FLOOR_S crossed
their gates in that run.

The floor-clamp marker on a gate-failure line is unchanged.

Assisted-by: Claude

@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: ccf12421a7

ℹ️ 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 +6789 to +6793
vable_setarrayitem_ref_graph_args(
frame_var.into(),
v_idx.into(),
value.clone().into(),
),

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 Preserve runtime receiver slots when flushing aborts

When a wide CALL abort is reached from a pre-call resume, pre_opcode_stack can contain the symbolic ConstRef(0) null-or-self sentinel while the tracer-seeded vable slot contains a real method receiver—the existing call lowering explicitly handles this at lines 9160-9196 by reading the physical slot instead of const-folding it. This loop writes the symbolic constant back before abort_permanent, replacing the receiver with null; the interpreter then re-runs the wide call without self, producing wrong arguments or a TypeError. Preserve tracer-seeded slot values here (or restore the upstream per-push vable mirroring) rather than blindly flushing symbolic constants.

AGENTS.md reference: AGENTS.md:L14-L20

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