Skip to content

jit: trace a nested _getframe chain through its virtualref pairs - #1401

Open
youknowone wants to merge 4 commits into
mainfrom
fix-foriter-review
Open

jit: trace a nested _getframe chain through its virtualref pairs#1401
youknowone wants to merge 4 commits into
mainfrom
fix-foriter-review

Conversation

@youknowone

@youknowone youknowone commented Aug 21, 2026

Copy link
Copy Markdown
Owner

sys._getframe(n) with n > 0 from an inlined MIFrame was residual: the walker
declined as soon as the recording-time f_backref chain crossed a
JitVirtualRef, so the specialized arm covered only depth 0. The generic
residual then forced the published callee frame and the loop aborted.

What changed

try_walker_specialize_sys_getframe now walks the whole concrete chain
before emitting anything, and admits a vref hop when the vref is still one of
MetaInterp.virtualref_boxes. For each such hop it runs the orthodox bracket
around the concrete force — vrefs_before_residual_call, the
CALL_MAY_FORCE + GUARD_NOT_FORCED, then vrefs_after_residual_call, which
publishes VIRTUAL_REF_FINISH(vref, virtual) and replaces the tracked vref
with CONST_NULL (pyjitpl.py vrefs_after_residual_call). With that proof in
the trace, optimize_jit_force_virtual forwards the force to the paired
virtual frame instead of materialising a vref with a null forced field, and
the JIT_FORCE_VIRTUAL/GUARD_NOT_FORCED pair leaves the optimized loop.

Two supporting resolvers on TraceCtx: live_virtualref_pair_for_ptr (the
list lookup implicit in pyjitpl.py vrefs_after_residual_call) and
virtualref_virtual_for_object_ptr, which still finds the virtual box of a
pair whose vref half stop_tracking_virtualref has already replaced with
CONST_NULL.

The chain census is all-or-nothing and runs before the first emission, so a
decline never leaves the residual getframe a shorter chain than the
interpreter's.

Scope of the positive-depth admission. It is gated on the walk landing on
the standard portal frame whose result is immediately consumed by f_locals
statically preflighted by next_op_is_f_locals_for_getframe_result. Generic
positive-depth consumers, and inline f_lineno / f_lasti, stay residual:
their single live-coordinate slot cannot describe a nested caller chain.
try_walker_specialize_load_attr's f_locals arm accordingly accepts a second
provable receiver — the standard virtualizable, gated on both its red box
and its concrete pointer, so no arbitrary inline callee collapses onto the
portal anchor.

The locals write-back that admission needs. pyframe.py fast2locals — the
body behind getdictscope, and so behind f_locals — is @jit.unroll_safe,
and the locals_cells_stack_w[i] reads it unrolls are getarrayitem_vable_r
against the virtualizable boxes. Upstream therefore neither forces the
virtualizable nor reads its array, which is what makes folding the getter
legitimate.

pyre answers f_locals with the 3.14 FrameLocalsProxy, which reads the
frame's array lazily instead of copying out of it at the call. The residual
getter's read barrier was the only thing writing that region out, so folding
it silently dropped every local the traced body had assigned:
frame_inlined_callee_own_image_regression caught exactly that, with
own_locals growing a second ('x',) entry. The fold now performs
pyjitpl.py synchronize_virtualizable (virtualizable.py write_boxes) for the
locals/cells region itself — mirrored onto the recording-time frame and emitted
into the trace — and declines, leaving the residual force, when the shadow
cannot supply a slot. The operand-stack region above nlocals is deliberately
excluded: it is not reachable through the proxy and its shadow slots read NULL
outside a merge point.

Hidden frames decline. executioncontext.py getnextframe_nohidden hops
f_backref and then keeps hopping while the result is hidden, without
consuming a depth level, so one raw hop per level reproduces getframe's walk
only on a chain that carries no hidden frame — which is also what the emitted
traversal's per-hop guard_false(hidden_applevel) pins. The census declines on
a hidden hop; that arm is what the emit loop's unreachable! had been
assuming. No fixture covers it: nothing in the tree ever sets
PyCode.hidden_applevel, and __pypy__.hidden_applevel is not exposed, so
frame.hide() is currently a constant false.

optimizer.rsdrain_extra_operations_from discarded the level's pending
queue when the drain returned InvalidLoop. InvalidLoop unwinds the recursive
Rust drain in place of RPython's exception unwinding, so the operations the
failing propagation emitted, plus this level's untouched tail, must stay visible
to the caller's unwind. They now move to extra_operations_after.

Measured

Uniform across dynasm, cranelift and wasm:

fixture loops_compiled loops_aborted guard_failures bh adopted
getframe_bridge_force_after_store_declined — (+1 bridge) 20 → 0 4114 → 201 20 → 0
getframe_bridge_force_plain_declined — (+1 bridge) 20 → 0 4114 → 201 20 → 0
getframe_inline_subwalk_multiframe 5 → 0 0 → 1 5 → 0
getframe_residual_callee_own_frame_declined 1 → 2 5 → 0 5 → 0
getframe_root_loop_force_blackhole_crn_declined 0 → 1 5 → 0 0 → 1 5 → 0
..._crn_nonidempotent_declined 0 → 1 5 → 0 0 → 5 5 → 0
blackhole_inlined_callee_local_after_escape_declined 1 → 2 5 → 0 5 → 0

getframe_inline_subwalk_multiframe is the regression fixture for the new path:
leaf reads _getframe(0).f_locals["x"] and _getframe(2).f_locals["base"],
so collapsing either lookup onto the portal loses a distinct name. Its one
remaining guard failure is also present in the depth-zero-only control.

The five *_declined fixtures keep their historical names; each header records
what its shape now does and why the _declined half of the name is history.

Notes

Rebased onto origin/main after #1399. Every upstream citation this branch adds
names a symbol; scripts/check-new-line-citations.py --base origin/main is
clean. Resolving each number against the vendored tree before dropping it found
two that had already rotted: pyjitpl.py:3317 pointed at
initialize_virtualizable, not the virtualref_boxes walk it claimed, and
vm.py:54 sat four lines past the f.mark_as_escaped() call it named, on
getnextframe_nohidden. A third, pyjitpl.py:3358-3367, straddled
vable_and_vrefs_before_residual_call and vrefs_after_residual_call. The
remaining three resolved correctly.

A third commit on this branch fixed the two dead-token warmstate fixtures; it
was dropped during the rebase as a duplicate of #1398, which had landed the same
fix. The resolution was verified byte-identical to origin/main.

authored by Claude

@coderabbitai

coderabbitai Bot commented Aug 21, 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: 3 minutes

Limit details: You’ve used all 2 included reviews currently available.

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?

Wait for the limit to reset, then comment @coderabbitai review or push new commits to the PR.

An organization admin can change what happens after included review limits in Billing.

How do review limits work?

CodeRabbit enforces per-developer PR review limits within each organization.

For paid Pro and Pro+ reviews, CodeRabbit uses a developer's included PR review attempts over the past 7 days to set the current hourly allowance. At typical activity levels, the full plan allowance applies. Higher sustained activity can lower the allowance until earlier attempts leave the 7-day window.

Please refer docs for additional details.

Review details
⚙️ Run configuration

Configuration used: Organization UI

Review profile: ASSERTIVE

Plan: Pro Plus

Run ID: df93e27a-d547-4275-b8ff-6649ccfcb7b2

📥 Commits

Reviewing files that changed from the base of the PR and between 504dd96 and ab29330.

📒 Files selected for processing (33)
  • majit/majit-metainterp/src/optimizeopt/optimizer.rs
  • majit/majit-metainterp/src/trace_ctx.rs
  • pyre/bench/synth/blackhole_inlined_callee_local_after_escape_declined.cranelift.jitstats
  • pyre/bench/synth/blackhole_inlined_callee_local_after_escape_declined.dynasm.jitstats
  • pyre/bench/synth/blackhole_inlined_callee_local_after_escape_declined.wasm.jitstats
  • pyre/bench/synth/getframe_bridge_force_after_store_declined.cranelift.jitstats
  • pyre/bench/synth/getframe_bridge_force_after_store_declined.dynasm.jitstats
  • pyre/bench/synth/getframe_bridge_force_after_store_declined.py
  • pyre/bench/synth/getframe_bridge_force_after_store_declined.wasm.jitstats
  • pyre/bench/synth/getframe_bridge_force_plain_declined.cranelift.jitstats
  • pyre/bench/synth/getframe_bridge_force_plain_declined.dynasm.jitstats
  • pyre/bench/synth/getframe_bridge_force_plain_declined.py
  • pyre/bench/synth/getframe_bridge_force_plain_declined.wasm.jitstats
  • pyre/bench/synth/getframe_inline_subwalk_multiframe.cranelift.jitstats
  • pyre/bench/synth/getframe_inline_subwalk_multiframe.dynasm.jitstats
  • pyre/bench/synth/getframe_inline_subwalk_multiframe.py
  • pyre/bench/synth/getframe_inline_subwalk_multiframe.wasm.jitstats
  • pyre/bench/synth/getframe_residual_callee_own_frame_declined.cranelift.jitstats
  • pyre/bench/synth/getframe_residual_callee_own_frame_declined.dynasm.jitstats
  • pyre/bench/synth/getframe_residual_callee_own_frame_declined.py
  • pyre/bench/synth/getframe_residual_callee_own_frame_declined.wasm.jitstats
  • pyre/bench/synth/getframe_root_loop_force_blackhole_crn_declined.cranelift.jitstats
  • pyre/bench/synth/getframe_root_loop_force_blackhole_crn_declined.dynasm.jitstats
  • pyre/bench/synth/getframe_root_loop_force_blackhole_crn_declined.py
  • pyre/bench/synth/getframe_root_loop_force_blackhole_crn_declined.wasm.jitstats
  • pyre/bench/synth/getframe_root_loop_force_blackhole_crn_nonidempotent_declined.cranelift.jitstats
  • pyre/bench/synth/getframe_root_loop_force_blackhole_crn_nonidempotent_declined.dynasm.jitstats
  • pyre/bench/synth/getframe_root_loop_force_blackhole_crn_nonidempotent_declined.py
  • pyre/bench/synth/getframe_root_loop_force_blackhole_crn_nonidempotent_declined.wasm.jitstats
  • pyre/pyre-interpreter/src/module/sys/vm.rs
  • pyre/pyre-jit-trace/src/jitcode_dispatch/residual_call.rs
  • pyre/pyre-jit-trace/src/jitcode_dispatch/specialize.rs
  • pyre/pyre-jit-trace/src/virtualizable_spec.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 21, 2026

Copy link
Copy Markdown

🤖 Codex parity review

Static analysis of this diff vs the local RPython/PyPy sources (commit ab29330).
Updated: 2026-08-21T22:30:18.354Z

Files in the reviewed diff
majit/majit-metainterp/src/optimizeopt/optimizer.rs
majit/majit-metainterp/src/trace_ctx.rs
pyre/bench/synth/getframe_bridge_force_after_store_declined.py
pyre/bench/synth/getframe_bridge_force_plain_declined.py
pyre/bench/synth/getframe_inline_subwalk_multiframe.py
pyre/bench/synth/getframe_residual_callee_own_frame_declined.py
pyre/bench/synth/getframe_root_loop_force_blackhole_crn_declined.py
pyre/bench/synth/getframe_root_loop_force_blackhole_crn_nonidempotent_declined.py
pyre/pyre-interpreter/src/module/sys/vm.rs
pyre/pyre-jit-trace/src/jitcode_dispatch/residual_call.rs
pyre/pyre-jit-trace/src/jitcode_dispatch/specialize.rs
pyre/pyre-jit-trace/src/virtualizable_spec.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-interpreter/src/pyframe.rs:70 ↔ pypy/interpreter/typedef.py:749 — pyre exposes CPython 3.14’s write-through FrameLocalsProxy for optimized-frame f_locals; PyPy exposes PyFrame.fget_getdictscope, its cached dict. CPython 3.14 confirms the proxy at lib-python/3/test/test_frame.py:580-590, but this cannot be filed as a CPython structural adaptation: PyPy’s deciding fast2locals has @jit.unroll_safe at pypy/interpreter/pyframe.py:539, failing condition (d). This behaviour predates the patch.

4. Structural adaptations

  • majit/majit-metainterp/src/optimizeopt/optimizer.rs:4619 ↔ rpython/jit/metainterp/optimizeopt/optimizer.py:544 — preserving queued extra operations while a Rust Result::Err(InvalidLoop) unwinds is a Rust replacement for RPython exception unwinding; it does not alter the successful optimization path.

`try_walker_specialize_load_attr`'s `f_locals` arm folds the getter for the
standard virtualizable as well as for an inline callee's own frame. The
residual getter it replaces carries a read barrier, and that force was the
only writer of `locals_cells_stack_w` out of the virtualizable image. pyre's
`FrameLocalsProxy` reads that array rather than copying out of it at the call,
so a local the traced body had assigned read back UNBOUND and the proxy
dropped it -- `frame_inlined_callee_own_image_regression` reported `own_locals`
as `('x',)` once the trace compiled.

`pyframe.py fast2locals` is `@jit.unroll_safe`, so upstream reaches the same
mapping by reading the virtualizable boxes and neither forces nor touches the
array. The fold now performs the `pyjitpl.py synchronize_virtualizable`
(`virtualizable.py write_boxes`) write-back for the locals/cells region
itself, mirrored onto the recording-time frame and emitted into the trace
through the new `TraceCtx::vable_array_item_write_back`. A slot the shadow
cannot answer declines the whole write-back and the fold with it, leaving the
residual force; the validation pass runs before the first emission. The
operand-stack region above `nlocals` is not written back: it is unreachable
through the proxy and its shadow slots read NULL outside a merge point.

Re-records `blackhole_inlined_callee_local_after_escape_declined` on all three
backends: loops_aborted 5 -> 0, loops_compiled 1 -> 2,
fbw_blackhole_adopted_single_frame 5 -> 0.

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: 1ba94b1fb9

ℹ️ 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 +2681 to +2684
if is_standard_frame
&& !walker_write_back_standard_frame_locals(ctx, obj, concrete_obj as usize)
{
return Ok(None);

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 Keep the portal proxy and virtualizable shadow synchronized

Folding f_locals for the standard frame performs only this one-time shadow-to-array write-back, but the returned FrameLocalsProxy remains live and reads and writes the frame array directly. For example, after p = sys._getframe().f_locals, a later x = 2 updates only the virtualizable shadow, so p["x"] can read the value present when the proxy was created; conversely, p["x"] = 2 updates the array while a subsequent compiled LOAD_FAST x still reads the old shadow. The standard-frame fold must either provide bidirectional synchronization for the proxy's lifetime or decline/escape the virtualizable.

AGENTS.md reference: AGENTS.md:L12-L15

Useful? React with 👍 / 👎.

Comment on lines 8714 to 8715
for _ in 0..depth_value {
let raw = unsafe { (*scan).f_backref };

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 Count only visible frames in the preflight walk

When an inline frame chain contains a hidden_applevel frame, this census decrements the requested depth once per raw f_backref, whereas ExecutionContext::getnextframe_nohidden skips hidden frames without consuming a depth level. A depth whose raw-hop endpoint happens to be the standard frame can therefore pass the positive-depth gate and return that frame even though _getframe(depth) should have continued to the next visible caller; other depths unnecessarily decline. The census and emitted traversal need to implement the same hidden-frame loop as getnextframe_nohidden.

AGENTS.md reference: AGENTS.md:L184-L185

Useful? React with 👍 / 👎.

`executioncontext.py getnextframe_nohidden` hops `f_backref` and then keeps
hopping while the result is hidden, without consuming a depth level. The
pre-emission census took exactly one raw hop per level, so it reproduces
`getframe`'s walk only on a chain that carries no hidden frame; the emitted
traversal already pins that with a per-hop `guard_false(hidden_applevel)`.

The census also left the emit loop's `unreachable!` reachable: that arm covers
a hidden hop, and nothing ahead of it had rejected one.

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