Skip to content

jit-trace: anchor an inlined level's walk-time traceback on its own frame - #908

Merged
youknowone merged 3 commits into
mainfrom
fbw
Jul 31, 2026
Merged

jit-trace: anchor an inlined level's walk-time traceback on its own frame#908
youknowone merged 3 commits into
mainfrom
fbw

Conversation

@youknowone

@youknowone youknowone commented Jul 30, 2026

Copy link
Copy Markdown
Owner

Rebased onto current main. Two of the four original commits were dropped: #910 landed the same blocker on main by a better mechanism, mirroring an inlined MIFrame's standard-vable writes onto that level's own concrete red frame as they are made (current_inline_concrete_frame, store_live_frame_static_int) instead of publishing register images at the adopt. #910 also absorbed the per-level ref-bank rooting, so trace.rs / state.rs here resolve entirely to main's side.

What is left is the third blocker and its guard.

The walk-time traceback named a fabricated frame

record_inline_application_traceback recorded the walk's concrete node through record_inline_traceback_for_recording, which createframe_objs a throwaway frame from the promoted code and globals. That hook predates the inline seed. A seeded level owns a real frame — the sub-walk runs the callee on it, and the emitted node already names that same object, since traceback_node_site resolves its frame operand from the level's portal frame register and the seed stamps the concrete frame onto that operand's box.

So the walk and the compiled run named different frames for the same invocation, and the walk's answer is the one a multi-frame blackhole adopt commits: tb.tb_frame is sys._getframe() read False for exactly one iteration per adopt.

The obligation that rides with the anchor

The fabricating hook does two things — frame.last_instr = last_instruction, then record. Porting only the second reintroduced a different wrong answer: a level's own frame carries the -1 entry sentinel, because the recording walk does not make dispatch_bytecode's per-opcode last_instr store and a frame that leaves by the exception never reaches an exit that would publish one. f_lineno then answers the def line.

CI caught it and the local run did not: synth/exception_traceback_frame_lineno grew a second ('raises_out', 1, 0) shape beside the correct ('raises_out', 1, 1), on all three backends and all three OSes. The anchor now makes the same store the blackhole already makes for its replay in publish_last_instr_at_live_marker.

The runtime half was investigated and declined

The emit_runtime arm still emits the fabricating hook. Two measurements say to leave it:

  • It is unreachable. An lldb breakpoint on that arm's own call-descr construction counts 0 hits across 43 corpus exception fixtures and 15 hand-built probes, corroborated by a MAJIT_LOG scan finding no call with the hook's [Ref, Ref, Ref, Int, Int] signature in any dumped trace. emit_runtime is the negation of record_prepend_application_traceback, which never declines — the exc.is_constant() arm is suppressed because every raising residual assigns class_of_last_exc_is_const = false immediately before walker_record_guard_exception reads it. (This also retracts the "compiled code still calls the fabricating hook" claim from the previous revision of this PR.)
  • The obvious port breaks an allocation contract. w_pytraceback_new roots w_next and w_code but deliberately not frame, on the documented ground that executing frames are non-moving oldgen blocks. The top-level sibling, the walk and the fabricating hook all satisfy that; a compiled trace's inlined callee frame does not — it is the trace's own NewWithVtable, lowered to a nursery allocation. Passing it would hold a movable pointer across the parking allocation inside w_pytraceback_new. A future port needs the root-and-reload shape on the recorder first, which also covers the same pre-existing exposure on its w_next argument.

Both findings are recorded in gate-triage.md rather than left in a PR thread.

Verified

Rebuilt from a fresh LLBC extraction on the new base.

synth/exception_traceback_frame_lineno (the CI failure) matches the oracle, dynasm and cranelift
synth/blackhole_inlined_callee_local_after_escape [(True, 2)] = oracle, dynasm and cranelift
getframe_inline_subwalk_multiframe 5 builds / 5 adopts / 0 declines, output unchanged
getframe_while_escaping_read_frame_identity 10 / 10 / 0, output unchanged
getframe_while_inlined_callee_subwalk 5 / 5 / 0, output unchanged
full corpus, dynasm ALL PASSED 349/349
cargo fmt --check clean

🤖 Generated with Claude Code

@coderabbitai

coderabbitai Bot commented Jul 30, 2026

Copy link
Copy Markdown

Review Change Stack

Warning

Review limit reached

@youknowone, you've reached your PR review limit, so we couldn't start this review.

Next review available in: 41 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: a96ca8ab-903f-4e3b-81dc-9d2e24b2c101

📥 Commits

Reviewing files that changed from the base of the PR and between d0f48a3 and 8cf7471.

📒 Files selected for processing (4)
  • pyre/bench/synth/blackhole_inlined_callee_local_after_escape.py
  • pyre/bench/synth/getframe_inline_subwalk_multiframe.py
  • pyre/bench/synth/getframe_while_escaping_read_frame_identity.py
  • pyre/bench/synth/getframe_while_inlined_callee_subwalk.py

Walkthrough

The JIT now declines multi-frame blackhole adoption when reconstructed chains exceed one level, preserving legacy escape/replay behavior. New and updated synthetic benchmarks, inline-subwalk comments, and triage documentation describe the depth limit, frame-identity cases, and local-publication constraints.

Changes

Multiframe blackhole handling

Layer / File(s) Summary
Depth-limited adoption
pyre/pyre-jit-trace/src/trace.rs, pyre/pyre-jit-trace/src/jitcode_dispatch/residual_call.rs
try_adopt_multi_frame_blackhole rejects chains deeper than one level before relinking; comments document the frame-0 publication scope and legacy escape/replay fallback.
Regression benchmark coverage
pyre/bench/synth/*
Adds an inlined-callee traceback benchmark and updates synthetic fixture comments and tallies for depth-based adoption declines and frame-identity behavior.
Triage documentation
pyre/gate-triage.md
Records the locals-publication blocker, enforced depth refusal, legacy fallback, and remaining frame-identity mismatch details.

Estimated code review effort: 3 (Moderate) | ~20 minutes

Possibly related issues

  • youknowone/pyre#811 — The new benchmark covers the multi-frame blackhole inlined-callee and frame-identity failure scenario described by this issue.

Possibly related PRs

Poem

A rabbit saw frames hop in a chain,
Then barred deep jumps from forming again.
Frame zero stayed,
Escape paths replayed,
While benchmarks kept watch in the rain.

🚥 Pre-merge checks | ✅ 5
✅ Passed checks (5 passed)
Check name Status Explanation
Docstring Coverage ✅ Passed No functions found in the changed files to evaluate docstring coverage. Skipping docstring coverage check.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title is specific and relates to the traceback frame-identity fixture, although it does not state the primary multi-frame adoption decline.
✨ Finishing Touches
📝 Generate docstrings
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch fbw

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.

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

ℹ️ 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 thread pyre/pyre-jit-trace/src/trace.rs Outdated
Comment on lines +2526 to +2528
if per_frame.len() > 1 {
mfdbg!("inner levels unpublishable: depth {}", per_frame.len());
return 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.

P1 Badge Avoid replaying every multi-frame escape

For a vable escape inside an inlined callee, every successfully built multi-frame image has at least two levels: build_multi_frame_miframe requires a nonempty parent stack and then appends the innermost frame. This condition therefore disables the adoption path completely and routes all such escapes through legacy replay. That replay is already known to be incorrect for a side-effecting inlined callee that returns from a while loop: pyre/gate-triage.md:450-453 records roughly 5.2k duplicate side effects, whereas adoption produces the exact count. Fix the per-frame identity and locals publication rather than blanket-declining every candidate.

AGENTS.md reference: AGENTS.md:L32-L42

Useful? React with 👍 / 👎.

@github-actions

github-actions Bot commented Jul 30, 2026

Copy link
Copy Markdown

🤖 Codex parity review

Static analysis of this diff vs the local RPython/PyPy sources (commit 8cf7471).
Updated: 2026-07-31T11:14:57.303Z

Files in the reviewed diff
pyre/bench/synth/blackhole_inlined_callee_local_after_escape.py
pyre/bench/synth/getframe_inline_subwalk_multiframe.py
pyre/bench/synth/getframe_while_escaping_read_frame_identity.py
pyre/bench/synth/getframe_while_inlined_callee_subwalk.py
pyre/gate-triage.md
pyre/pyre-jit-trace/src/jitcode_dispatch/mod.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/mod.rs:648 ↔ pypy/interpreter/pytraceback.py:104: the new real-frame path enters record_caught_blackhole_traceback, whose same-frame/same-PC deduplication can suppress a traceback node. PyPy’s record_application_traceback unconditionally prepends a node; repeated propagation through the same frame and instruction must remain observable.

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

  • pyre/pyre-jit-trace/src/jitcode_dispatch/mod.rs:665 ↔ pypy/interpreter/pytraceback.py:104: emitted JIT traces still call the metadata-based inline hook, which fabricates a new traceback-only frame rather than using the executing callee frame. Thus tb.tb_frame cannot be identical to the live inlined frame, unlike PyPy’s direct PyTraceback(..., frame, ...) construction. This predates the patch; the new concrete-recording branch fixes only the recording walk.

  • pyre/pyre-jit-trace/src/jitcode_dispatch/mod.rs:655 ↔ pypy/interpreter/pytraceback.py:104: when the inline portal-frame register is unseeded, recording still fabricates a frame from code/globals. PyPy has one executing frame per call and records that frame; fabrication loses frame identity, locals, and frame-chain reachability. This fallback predates the patch.

4. Structural adaptations

  • pyre/pyre-jit-trace/src/jitcode_dispatch/mod.rs:645 ↔ pypy/interpreter/pyopcode.py:199: directly writing PyFrame.last_instr is a Rust/codewriter adaptation of PyPy’s per-opcode interpreter-loop assignment. It preserves the required executing-instruction coordinate for the traceback node, while avoiding a generated store at every JIT bytecode position.

@youknowone

Copy link
Copy Markdown
Owner Author

Pushed two more commits: both remaining blockers behind the multi-frame adopt
are closed, so the arm adopts rather than declining.

874730b930 — the walk-time traceback named a frame nothing else could reach

The concrete traceback node for an inlined level went through
record_inline_traceback_for_recording, which createframe_objs a
traceback-only frame from the promoted w_code / w_globals. Its comment says
why — "the recording walker cannot force the optimizer's virtual locals" — but
that predates the inline seed. A seeded level HAS a frame, the sub-walk runs the
callee on it, and the emitted node already names it: traceback_node_site takes
its frame operand from the level's portal frame register and the seed stamps the
concrete frame onto that operand's box.

So the walk and the compiled run disagreed about tb.tb_frame for the same
invocation, and only a path that COMMITS the walk's concrete effects can see it
— which is why it showed up as exactly one wrong iteration per adopt and never
in steady state.

The node is now recorded against that frame; a level inlined with no frame at
all keeps the fabricating hook, which is then its only frame identity. The
python pc is resolved first, so the record-or-skip decision is byte-identical
and only the frame changes.

857f48dccc — publish every level's locals, drop the depth decline

semantic_slot_owner resolves one locals_cells_stack_w slot to the single
(bank, color) that owns it at the level's resume pc; write_back_miframe_ locals boxes and stores every resolved slot behind an all-or-nothing preflight.
Every level's old locals image is captured before the first publish, and the
images, the latched Ref words and the pending exception stay
push_resume_ref_roots-rooted for the whole drive: publication boxes, boxing
allocates, and each image is the only remaining reference to the pre-walk
objects its publish overwrote.

Measured

fixture builds / adopts / declines output
getframe_inline_subwalk_multiframe 5 / 5 / 0 matches reference
getframe_while_escaping_read_frame_identity 10 / 10 / 0 matches reference
getframe_while_inlined_callee_subwalk 5 / 5 / 0 matches reference
blackhole_inlined_callee_local_after_escape [(True, 2)], matches

dynasm: 1 failed, 345 passed — the failure is synth/ast_compile_roundtrip,
the cpython/pypy reference mismatch that predates this branch. cargo fmt --check clean.

Not fixed here

The runtime-emitted hook is unchanged. When record_prepend_application_ traceback declines but the level is seeded — mainly exc.is_constant(), which
holds for every raise after the first in a walk — compiled code still calls the
fabricating hook, so a traceback built inside a compiled inlined callee names a
frame nothing else can reach. Same defect class, one layer over.

commented by Claude

@youknowone youknowone changed the title jit(fbw): decline multi-frame blackhole adoption below the locals publish jit(fbw): close the three blockers under the multi-frame blackhole adopt Jul 30, 2026

@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: 857f48dccc

ℹ️ 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 thread pyre/pyre-jit-trace/src/state.rs Outdated
Comment on lines +5182 to +5184
let owner = match semantic_slot_owner(&pcdep, slot) {
SemanticSlotOwner::None => None,
SemanticSlotOwner::One { bank, color } => Some((bank, color)),

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 dead fast locals when adopting frames

When an inlined callee assigns a fast local that has no later LOAD_FAST but remains observable through its escaped frame, this treats the missing owner as an unmaterialized hole and leaves the pre-walk slot unchanged. build_pcdep_color_slots explicitly filters locals through is_local_live (pyre/pyre-jit/src/jit/codewriter.rs:4839-4843), so a shape such as x = value; f = sys._getframe(); return f.f_locals["x"] omits x from pcdep; after multi-frame adoption, f_locals therefore exposes the stale/null value. The adopt must retain a complete per-frame locals source or decline when a changed fast local cannot be published.

AGENTS.md reference: AGENTS.md:L32-L42

Useful? React with 👍 / 👎.

@youknowone youknowone changed the title jit(fbw): close the three blockers under the multi-frame blackhole adopt jit-trace: anchor an inlined level's walk-time traceback on its own frame Jul 31, 2026
@youknowone

Copy link
Copy Markdown
Owner Author

Force-pushed after a rebase onto current main, and the CI failure is fixed.

Rebase changed the shape of this PR. #910 landed the inner-level-locals blocker on main by mirroring an inlined MIFrame's standard-vable writes onto that level's own concrete red frame, rather than publishing register images at the adopt. That supersedes two of the four commits here outright, so trace.rs and state.rs resolved to main's side and the branch is now three commits.

The pyre/check.py failure was mine. synth/exception_traceback_frame_lineno went WRONG on all three backends and all three OSes with a second ('raises_out', 1, 0) shape. Root cause: the frame-fabricating hook this PR replaces does two things — frame.last_instr = last_instruction, then record — and only the second was ported. A level's own frame carries the -1 entry sentinel, so f_lineno answered the def line. Fixed by making the same store publish_last_instr_at_live_marker already makes for the blackhole's replay.

The runtime-emitted half is deliberately not ported, and the previous revision's claim that "compiled code still calls the fabricating hook" is retracted. An lldb breakpoint on that arm counts 0 hits across 43 corpus exception fixtures and 15 probes, because record_prepend_application_traceback never declines in practice. And the obvious port would be the first path to hand record_application_traceback a nursery-resident frame, which its w_pytraceback_new contract explicitly does not allow. Both are written down in gate-triage.md.

Verified on the new base after a fresh LLBC extraction: full corpus ALL PASSED 349/349 on dynasm, the two target fixtures matching the oracle on cranelift as well, latch fixtures 5/5/0, 10/10/0, 5/5/0, cargo fmt --check clean.

commented 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: 138585d311

ℹ️ 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 +623 to +629
let node_frame = crate::state::python_pc_for_jitcode_pc_public(
consts.jitcode_index,
opcode_position as i32,
);
)
.and_then(|py_pc| {
concrete_portal_frame(ctx, consts.jitcode_index).map(|frame| (frame, py_pc))
});

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 the live frame on the emitted traceback path

This selects the seeded callee frame only for execute_concrete; the runtime arm below still emits the frame-fabricating inline hook. That arm is reachable when an inlined callee explicitly raises a promoted constant exception (for example, a module-global exception instance), because record_prepend_application_traceback returns false for exc.is_constant(). The recording/adopted iteration will therefore attach the live frame here, while compiled iterations attach a different fabricated frame, so exc.__traceback__.tb_frame is sys._getframe() changes across iterations. The emitted path must preserve the same per-frame identity, or the affected shape must be declined until it can.

AGENTS.md reference: AGENTS.md:L32-L42

Useful? React with 👍 / 👎.

…rame

`record_inline_application_traceback` recorded the walk-time concrete node
through `record_inline_traceback_for_recording`, which `createframe_obj`s a
traceback-only frame from the promoted code and globals. That hook predates the
inline seed. A seeded level owns a real frame -- the sub-walk runs the callee on
it, and the EMITTED node already names that same object, because
`traceback_node_site` resolves its frame operand from the level's portal frame
register and the seed stamps the concrete frame onto that operand's box. So the
walk and the compiled run named different frames for the same invocation, and
the walk's answer is the one a multi-frame blackhole adopt commits:
`tb.tb_frame is sys._getframe()` read False for exactly one iteration per adopt.

The concrete node is now recorded against that frame, falling back to the
fabricating hook only for a level inlined without one. The python pc is resolved
first, so the record-or-skip decision is unchanged: the fabricating hook drops
the node when the coordinate does not map, while the pointer-taking recorder
would substitute `frame.last_instr`.

Anchoring on the real frame moves the coordinate obligation with it. The
fabricated frame carried the raise coordinate because the hook stamped it; a
level's own frame carries the entry sentinel, since the recording walk does not
make `dispatch_bytecode`'s per-opcode `last_instr` store and a frame that leaves
by the exception reaches no exit that would publish one. `f_lineno` then answers
the code object's first line -- `synth/exception_traceback_frame_lineno` reports
a second `('raises_out', 1, 0)` shape beside `('raises_out', 1, 1)`. The anchor
makes the same store `publish_last_instr_at_live_marker` makes for the
blackhole's replay.

Assisted-by: Claude
…dopt

`synth/blackhole_inlined_callee_local_after_escape` drives an inlined callee
that assigns a local, escapes through a residual `sys._getframe()`, and reads
the local back after the escape. The shape holds the adopt to two separate
obligations and fails differently on each: a level whose locals are not
resumable resumes the local as null and faults in `object_getattr_miss` (rc=139,
no output), while a walk-time traceback anchored on any other object prints
False and still exits 0. No corpus fixture covered it, which is why 342/342
green and a firing latch did not catch either.

Corrects the three `getframe_*` headers, which described the chain-root gate as
still declining their shapes, and gate-triage.md's §1d narrative, which recorded
the 2026-07-30 flip as resolved when two blockers survived it.

Assisted-by: Claude
…declined

Only the walk-time record was moved onto the inlined level's own frame; the
`emit_runtime` arm of `record_inline_application_traceback` still emits the
frame-fabricating hook. Measuring that arm produced two results.

It is unreachable. An lldb breakpoint on the arm's own call-descr construction
counts zero hits across the 43 corpus exception fixtures and 15 hand-built
probes, and a `MAJIT_LOG` scan finds no call carrying the hook's
`[Ref, Ref, Ref, Int, Int]` signature in any dumped trace. `emit_runtime` is the
negation of `record_prepend_application_traceback`, which never declines: the
`exc.is_constant()` arm is suppressed because every raising residual assigns
`class_of_last_exc_is_const = false` immediately before
`walker_record_guard_exception` reads it.

Porting it would break an allocation contract. `w_pytraceback_new` roots
`w_next` and `w_code` but deliberately not `frame`, on the documented ground
that executing frames are non-moving oldgen blocks; the top-level sibling, the
walk and the fabricating hook all satisfy that. A compiled trace's inlined
callee frame does not -- it is the trace's own `NewWithVtable`, which the GC
rewriter lowers to a nursery allocation -- so passing it would hold a movable
pointer across the parking allocation inside `w_pytraceback_new`.

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