Skip to content

jit: root the blackhole virtualizable_ptr slot, drop the re-entrant trace-too-long teardown, and give the exception-edge bridge its discarded-frame traceback nodes - #972

Merged
youknowone merged 8 commits into
mainfrom
single-walker
Aug 2, 2026

Conversation

@youknowone

@youknowone youknowone commented Aug 2, 2026

Copy link
Copy Markdown
Owner

Eight commits on top of main. Each is independently reproducible.

Rebased 2026-08-02 onto ca1a421e847. Three commits that were in the
earlier version of this PR are gone:

1. virtualizable_ptr was a bare copy across a collection

BlackholeInterpreter::run roots every chain level's register bank and, for a
level with a virtualizable_info, the array-field slots inside its
virtualizable. virtualizable_ptr itself was neither — it is a bare copy of the
frame red the level was bound to, so a collection forwarded the register and left
the copy naming the pre-move address. A compiled trace allocates an inlined
callee's PyFrame through its own NewWithVtable, which the GC rewriter lowers
to a nursery allocation, so a minor collection inside run_inner relocates it;
the propagation loop then stores the stale pointer into PyTraceback.frame,
whose custom trace forwards it on the next collection and reads a data word as a
type id. Register the slot, the shape blackhole_from_resumedata already applies
for the chain-build window.

2. Two epilogue blocks both handled the same abort, running the callee twice

LoopBearingCalleeInlineUnsupported is handled by the carrier block (which can
take CalleeRebuild, resuming INSIDE the rebuilt callee) and by the
FBW_ABORT_OUTER_RESUME block (which rewinds the outer frame to its CALL and
re-executes it). Nothing kept them apart: the second block's gates are inclusion
tests on fbw_executed_nonpure_residual / fbw_has_unjournaled_effect, and
walk_end_resume_provable samples FBW_EXECUTED_EFFECT_COUNT, which the rebuilt
callee's plain interpretation never bumps. Read WALK_END_FLUSH_COMMITTED first
and reset the latch.

3. The after-residual-call -live- markers were never narrowed

get_list_of_active_boxes reads the marker AFTER the call whenever in_a_call
or after_residual_call holds and the one before the op otherwise
(pyjitpl.py:194-198); compute_liveness is one uniform pass over every
-live-. Group the after-call markers alongside the per-PC ones so both marker
families go through the same pass, adding the preceding call's own Ref result
register (pyjitpl.py:186). Declines at collect_callee_active_boxes 5 → 1 over
pyre/bench/**.

4. A re-entrant interpreter run tore down the outer trace

blackhole_if_trace_too_long runs in the tracer's own stepping loop
(pyjitpl.py:2861-2867 _interpret); pyframe.py dispatch_bytecode has no such
call. Pyre's walk loop already runs the check, so the copy in eval_loop_jit's
opcode dispatch was a second caller of the same teardown — and a residual call
executed inside an inline sub-walk runs Python through eval_loop_jit, whose
per-step check read the OUTER trace's op count and moved that TraceCtx out of
the shared slot. The in-flight walk then kept recording through a &mut TraceCtx
whose recorder buffer had been freed. RPython cannot reach this: warmstate.py:437-441 bound_reached builds a fresh MetaInterp per trace attempt.

5. The exception-edge bridge emitted one traceback node for a whole unwind

route_exc_edge re-points the live frame at its own handler and discards the
inlined callee frames outright, so the flat walk that follows records only the
catching frame's node. pyopcode.py:148 record_application_traceback runs BEFORE
the :152 exception-table lookup, so a frame the unwind only passes through
contributes a node exactly like the one that catches. resume_coords[1..] is
exactly the discarded set: publish it at the routing point and emit one node per
level at the handler entry, innermost-first.

6. The quasi-immut watcher was installed after the value was read

quasiimmut.py:124-126 orders self.qmut = get_current_qmut_instance(...) before
self.constantfieldbox = self.get_current_constant_fieldvalue(). Reading first
leaves a window in which the field moves with no watcher installed, so nothing
invalidates, nothing bumps the force counter, and the trace keeps a value that is
already stale. Without a GIL that window is a real interleaving. (Ordering fix on
top of #977's install.)

7. The discarded level's traceback pc was off by one instruction

record_discarded_level_traceback received py_pc straight out of
resume_coords, a next_instr-style coordinate — the same one exc_table_offset
converts with saturating_sub(1) and the live frame converts with
set_last_instr_from_next_instr. Its three consumers all want the instruction
that RAN, so nodes named the instruction AFTER the raising or calling opcode and a
bare RERAISE decoded as whatever follows it, gaining a node the
RaiseWithExplicitTraceback rule forbids. (Raised by both reviewers on this PR.)

8. One grouping helper for both marker families

filter_liveness_in_place had grown a second copy of the "push onto the entry
with this insn_idx, or start one" loop. Extract group_py_pcs_by_insn.
(Raised by CodeRabbit on this PR.)

Withdrawn: the FOR_ITER framestack-scan deletion

The earlier version replaced fbw_inline_callee_hazardous' loop-bearing
framestack scan with a screen at the for_iter_next consume, on the measurement
that "the deleted scan claimed no abort the other two arms do not" — declines at
that site 16 → 14, synth corpus byte-identical.

That measurement counted declines at the fbw site, not the aborts the admitted
callee goes on to cause
, and it held only because the -live- Ref-bank retain
narrowed away the colors that make collect_callee_active_boxes decline.
Once #973 deleted that retain, the same change takes
synth/inline_subwalk_user_iterator from loops_aborted=1 (main's recorded
baseline, which main still meets) to 5, and panics on wasm with
blackhole recursive_call: jitdrivers_sd[0] carries no portal runner. Measured
by reverting fbw_state.rs/residual_call.rs to main's version: 5 → 1.
foriter_exempt_shared_generator, the fixture that change existed to fix, passes
on main's version either way — the framestack scan masks that bug too. So the
commit, its repair, and the baseline re-record that recorded all three of their
consequences are withdrawn rather than re-baselined.

Status

cargo fmt --all -- --check clean; cargo test --all --no-default-features --features dynasm green (101 suites).

backend result
dynasm 2 failed / 366 passed
cranelift 2 failed / 366 passed
wasm 2 failed / 362 passed

All six failures are inherited from main, verified locally by reverse-applying
this branch's whole diff and re-running each fixture on the resulting tree — the
numbers are identical, not merely the fixture names:

fixture main this branch
synth/exception_args_virtual (all 3 backends) FAIL loops_aborted 0 -> 3, guard_failures 401 -> 1002 identical
synth/list_length_hint_validate (dynasm, cranelift) FAIL loops_aborted 14 -> 34, guard_failures 828 -> 4923 identical
synth/pickle_terminal_raise_resume (wasm) FAIL loops_aborted 54 -> 59 identical

@coderabbitai

coderabbitai Bot commented Aug 2, 2026

Copy link
Copy Markdown

Review Change Stack

Walkthrough

The PR adds traceback recording for discarded inline frames, separates nested FOR_ITER hazards, adds force-quasi-immutable trace aborts, preserves post-residual-call liveness, and roots blackhole virtualizable pointers during garbage collection.

Changes

JIT runtime control flow

Layer / File(s) Summary
Discarded inline traceback levels
majit/majit-metainterp/src/*, pyre/pyre-jit-trace/src/jitcode_dispatch/*, pyre/pyre-jit/src/*
Exception-edge routing now publishes discarded levels and records their traceback frames through a registered callback.
Nested FOR_ITER hazard handling
pyre/pyre-interpreter/src/pyopcode.rs, pyre/pyre-jit-trace/src/jitcode_dispatch/*, pyre/bench/synth/*, pyre/check.py
Nested residual handling separates FOR_ITER consumption from self-recursion hazards and updates synthetic JIT statistics.
Quasi-immutable force aborts
pyre/pyre-object/src/*, pyre/pyre-jit-trace/src/{state.rs,trace.rs,jitcode_dispatch/mod.rs}, pyre/pyre-jit/src/eval.rs, majit/majit-metainterp/src/pyjitpl.rs
Watcher installation occurs before recording. Forced invalidation stages an abort and adopts blackhole execution.
Post-residual-call resume liveness
pyre/pyre-jit/src/jit/codewriter.rs
Liveness narrowing includes post-call markers and preserves reference-producing call results for resume handling.

Estimated code review effort: 4 (Complex) | ~60 minutes

Possibly related PRs

  • youknowone/pyre#563: Shared callback-based traceback handling for resumed and discarded inline frames.
  • youknowone/pyre#308: Shared nested FOR_ITER hazard and residual trace-abort handling.
  • youknowone/pyre#940: Shared quasi-immutable watcher invalidation and retracing infrastructure.

Sequence Diagram(s)

sequenceDiagram
  participant ExceptionEdge
  participant JitcodeDispatch
  participant TracebackHook
  participant PythonException
  ExceptionEdge->>JitcodeDispatch: publish discarded resume levels
  JitcodeDispatch->>TracebackHook: record discarded levels
  TracebackHook->>PythonException: attach traceback frames
Loading
sequenceDiagram
  participant TraceWalker
  participant QuasiImmut
  participant AbortState
  participant Blackhole
  TraceWalker->>QuasiImmut: check invalidation baseline
  QuasiImmut->>TraceWalker: report forced invalidation
  TraceWalker->>AbortState: stage ABORT_FORCE_QUASIIMMUT
  TraceWalker->>Blackhole: adopt forward resume
Loading

Poem

A rabbit hops through traces bright,
And roots each frame through moving light.
Lost traceback levels find their place,
Quasi-immutables halt the race.
Safe resumes bound through code anew.

🚥 Pre-merge checks | ✅ 5
✅ Passed checks (5 passed)
Check name Status Explanation
Docstring Coverage ✅ Passed Docstring coverage is 100.00% which is sufficient. The required threshold is 80.00%.
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 clearly identifies three significant changes in the pull request: blackhole rooting, trace teardown removal, and discarded-frame traceback support.
✨ 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 single-walker

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

https://github.com/youknowone/pyre/blob/689a883d60febb1816857657fdf6f04d0e8d3490/pyre-object/src/quasiimmut.rs#L261-L262
P1 Badge Scope quasi-immutable force events to the active walk

When one thread is tracing while another mutates any watched module dictionary or type, this process-global counter advances and the tracing thread interprets the unrelated mutation as its own jit_force_quasi_immutable, aborting after its next opcode; sustained mutations can therefore prevent otherwise independent loops from compiling. The single TLS baseline also cannot represent nested same-thread traces: a nested trace resets it and can hide a force already performed by the outer residual call. Track the event on a stack/per-trace context tied to the walk that executed the write rather than using a process-wide odometer.

AGENTS.md reference: AGENTS.md:L194-L196

ℹ️ 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".

@github-actions

github-actions Bot commented Aug 2, 2026

Copy link
Copy Markdown

🤖 Codex parity review

Static analysis of this diff vs the local RPython/PyPy sources (commit 95e9437).
Updated: 2026-08-02T14:41:57.996Z

Files in the reviewed diff
majit/majit-metainterp/src/blackhole.rs
majit/majit-metainterp/src/lib.rs
majit/majit-metainterp/src/pyjitpl.rs
pyre/pyre-jit-trace/src/jitcode_dispatch/bridge_subwalk.rs
pyre/pyre-jit-trace/src/jitcode_dispatch/mod.rs
pyre/pyre-jit-trace/src/state.rs
pyre/pyre-jit-trace/src/trace.rs
pyre/pyre-jit/src/call_jit.rs
pyre/pyre-jit/src/eval.rs
pyre/pyre-jit/src/jit/codewriter.rs

1. Regressions to PyPy parity introduced by this patch

None.

2. Other mismatches introduced by this patch

  • pyre/pyre-jit/src/call_jit.rs:3413 ↔ pypy/interpreter/pyframe.py:103-132 — discarded exception-edge levels retain only (w_code, py_pc), then record_discarded_level_traceback reconstructs the frame with w_code_get_w_globals (call_jit.rs:862). PyPy preserves each frame’s own globals through PyFrame.get_w_globals(); if one code object runs under a non-default globals dict, the fabricated traceback frame has the wrong f_globals.

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

None.

4. Structural adaptations

  • majit/majit-metainterp/src/blackhole.rs:1481 ↔ rpython/jit/metainterp/blackhole.py:351-361 — explicit shadow-stack rooting of virtualizable_ptr is a Rust moving-GC adaptation; RPython’s GC object graph traces the blackhole interpreter and its references directly.
  • pyre/pyre-jit-trace/src/state.rs:4467 ↔ rpython/jit/metainterp/quasiimmut.py:119-125 — moving watcher installation before the field read now matches upstream QuasiImmutDescr.__init__; pyre carries the captured value on the recorded op instead of allocating an upstream-style per-read descriptor.
  • pyre/pyre-jit/src/eval.rs:7478 ↔ rpython/jit/metainterp/pyjitpl.py:2861-2867 — the trace-length check is correctly confined to pyre’s tracing walker, rather than the concrete interpreter loop; this is required by pyre’s re-entrant Rust execution model.
  • pyre/pyre-jit/src/jit/codewriter.rs:4470 ↔ rpython/jit/metainterp/pyjitpl.py:177-199 — pyre separately tracks and narrows post-residual-call -live- markers because its Python-frame-slot resume representation differs from RPython’s complete box-register snapshot.
  • pyre/pyre-jit-trace/src/trace.rs:3967 ↔ rpython/jit/metainterp/pyjitpl.py:2861-2867 — the one-commit walk-end latch prevents replaying an already-executed inlined callee; it compensates for pyre’s speculative sub-walk/flush mechanism, which has no direct upstream counterpart.

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

ℹ️ 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 +2327 to +2328
FBW_FORCED_QUASIIMMUT_BASE
.with(|c| pyre_object::quasiimmut::forced_quasi_immut_count() != c.get())

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 Scope force detection to the active tracing thread

When Python threads trace concurrently, this thread-local baseline is compared with FORCED_QUASI_IMMUTS, a process-global atomic incremented by every watcher sweep in quasiimmut.rs. A mutation of any watched module or type on thread B therefore makes thread A return ForceQuasiImmut at its next safe opcode even when A neither performed that write nor depends on that object; sustained cross-thread mutations can repeatedly abort and eventually retire otherwise valid hot traces. Attribute the event to the active trace/execution context instead of using the process-wide counter.

AGENTS.md reference: AGENTS.md:L157-L162

Useful? React with 👍 / 👎.

@youknowone youknowone changed the title jit: install the quasi-immut watcher at the recorded read; screen the FOR_ITER consume at its reach point; four teardown, replay and rooting fixes jit: install the quasi-immut watcher at the recorded read; screen the FOR_ITER consume at its reach point; five teardown, replay, rooting and traceback fixes Aug 2, 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

https://github.com/youknowone/pyre/blob/09c232b4ac432dc42b7865dcc774b4d87711c880/pyre-jit-trace/src/jitcode_dispatch/residual_call.rs#L2345-L2349
P1 Badge Decline loop-bearing callees before their first mutation

When an inlined callee executes a non-pure residual before reaching its FOR_ITER (for example, mutates an object and then enters a for loop), fbw_abort_nested_unjournaled_residual now allows that earlier residual because the loop-bearing scan was removed, and this later consume-time check aborts only after the mutation has happened. The effect-count gate then cannot install the outer rewind leg, while the LoopBearingCalleeInlineUnsupported epilogue keeps the legacy replay for unjournaled effects, so the caller re-executes the callee and applies that mutation twice. Detect the reached loop before allowing preceding irreversible residuals, or provide a forward-resume path that does not replay them.


https://github.com/youknowone/pyre/blob/09c232b4ac432dc42b7865dcc774b4d87711c880/pyre-jit/src/call_jit.rs#L859-L865
P1 Badge Rebuild discarded traceback frames from resume data

When an exception-guard bridge unwinds through an inlined callee to an outer handler, this creates a fresh empty frame from only the code and globals rather than reconstructing the frame encoded in the guard resume data. The resulting traceback exposes a different tb_frame whose arguments, locals, cells, value stack, and back-reference are missing, so code inspecting exc.__traceback__.tb_frame.f_locals observes fabricated state. Preserve and materialize each discarded level's actual per-frame resume state instead of reducing it to (w_code, py_pc).

AGENTS.md reference: AGENTS.md:L24-L30


https://github.com/youknowone/pyre/blob/09c232b4ac432dc42b7865dcc774b4d87711c880/pyre-jit/src/call_jit.rs#L842-L848
P2 Badge Convert the resume PC to the failing opcode

For multi-frame exception bridges, each resume_coords entry is a next-instruction coordinate—the same function uses py_pc.saturating_sub(1) for exception-table lookup and set_last_instr_from_next_instr for the live frame—but this callback decodes py_pc directly and later records it as tb_lasti. Consequently discarded traceback nodes point at the instruction after the raising/calling opcode, producing incorrect line information; a bare RERAISE is also missed and gains a spurious traceback node. Convert the coordinate to the preceding opcode before both the reraise check and traceback recording.

ℹ️ 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".

@coderabbitai coderabbitai 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.

Actionable comments posted: 3

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (1)
pyre/pyre-jit/src/jit/codewriter.rs (1)

4449-4456: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Extract a shared "group by insn_idx" helper.

after_call_groups (Lines 4470-4485) duplicates the same linear-scan grouping pattern as groups (Lines 4449-4456): iterate, .find(|(idx, _)| *idx == insn_idx), push-or-insert. Factor this into one helper, for example fn group_by_insn_idx(pairs: impl Iterator<Item = (usize, usize)>) -> Vec<(usize, Vec<usize>)>, and call it twice. This removes the duplicated logic and keeps both call sites in sync if the grouping strategy changes later.

♻️ Proposed helper extraction
+fn group_by_insn_idx(pairs: impl Iterator<Item = (usize, usize)>) -> Vec<(usize, Vec<usize>)> {
+    let mut groups: Vec<(usize, Vec<usize>)> = Vec::new();
+    for (py_pc, insn_idx) in pairs {
+        if let Some(entry) = groups.iter_mut().find(|(idx, _)| *idx == insn_idx) {
+            entry.1.push(py_pc);
+        } else {
+            groups.push((insn_idx, vec![py_pc]));
+        }
+    }
+    groups
+}
+
-    let mut groups: Vec<(usize, Vec<usize>)> = Vec::new();
-    for (py_pc, &insn_idx) in live_markers.iter().enumerate() {
-        if let Some(entry) = groups.iter_mut().find(|(idx, _)| *idx == insn_idx) {
-            entry.1.push(py_pc);
-        } else {
-            groups.push((insn_idx, vec![py_pc]));
-        }
-    }
+    let groups = group_by_insn_idx(
+        live_markers.iter().enumerate().map(|(py_pc, &insn_idx)| (py_pc, insn_idx)),
+    );
-    let mut after_call_groups: Vec<(usize, Vec<usize>)> = Vec::new();
-    for (py_pc, anchor) in after_call_post_merge.iter().enumerate() {
-        let Some(insn_idx) = *anchor else { continue };
-        if per_pc_markers.contains(&insn_idx) {
-            continue;
-        }
-        if let Some(entry) = after_call_groups
-            .iter_mut()
-            .find(|(idx, _)| *idx == insn_idx)
-        {
-            entry.1.push(py_pc);
-        } else {
-            after_call_groups.push((insn_idx, vec![py_pc]));
-        }
-    }
+    let after_call_groups = group_by_insn_idx(
+        after_call_post_merge
+            .iter()
+            .enumerate()
+            .filter_map(|(py_pc, anchor)| anchor.map(|insn_idx| (py_pc, insn_idx)))
+            .filter(|(_, insn_idx)| !per_pc_markers.contains(insn_idx)),
+    );

Also applies to: 4468-4485

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@pyre/pyre-jit/src/jit/codewriter.rs` around lines 4449 - 4456, Extract the
duplicated insn_idx grouping logic into a shared helper near the grouping code,
such as group_by_insn_idx accepting an iterator of (usize, usize) pairs and
returning Vec<(usize, Vec<usize>)>. Replace both the groups construction and
after_call_groups construction with calls to this helper, preserving their
existing input ordering and output behavior.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@pyre/pyre-jit-trace/src/state.rs`:
- Around line 4456-4465: In the trace setup around
current_quasiimmut_field_value and ensure_quasi_immut_installed, invoke
ensure_quasi_immut_installed(ctx, obj, field_index) before reading the
quasi-immutable value. Preserve the existing calls and maintain strict
line-by-line structural parity with interpreter semantics.

In `@pyre/pyre-jit-trace/src/trace.rs`:
- Around line 3721-3745: Update the blackhole adoption failure handling around
try_adopt_blackhole and try_adopt_single_frame_blackhole so an effectful
ForceQuasiImmut adoption failure is treated as fatal, matching the existing
TraceTooLong behavior. Apply this consistently to both single-frame and
multi-frame adoption paths, preventing an uncommitted walk from falling back to
legacy replay after the force-causing residual has executed.

In `@pyre/pyre-jit/src/call_jit.rs`:
- Around line 826-849: Update record_discarded_level_traceback to derive the
current-bytecode coordinate from the full-frame py_pc before decoding
RaiseVarargs/Reraise, updating frame.last_instr, or calculating source-line
information. Use that adjusted coordinate for bare-reraise detection and
frame/source-line accounting, while retaining the original full-frame resume
coordinate where record_application_traceback requires it.

---

Outside diff comments:
In `@pyre/pyre-jit/src/jit/codewriter.rs`:
- Around line 4449-4456: Extract the duplicated insn_idx grouping logic into a
shared helper near the grouping code, such as group_by_insn_idx accepting an
iterator of (usize, usize) pairs and returning Vec<(usize, Vec<usize>)>. Replace
both the groups construction and after_call_groups construction with calls to
this helper, preserving their existing input ordering and output behavior.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: ASSERTIVE

Plan: Pro Plus

Run ID: dd196fd4-1563-4d02-a515-b71bb7a22119

📥 Commits

Reviewing files that changed from the base of the PR and between e1b5fa7 and 09c232b.

📒 Files selected for processing (25)
  • majit/majit-metainterp/src/blackhole.rs
  • majit/majit-metainterp/src/lib.rs
  • majit/majit-metainterp/src/pyjitpl.rs
  • pyre/bench/synth/exception_reraise_tb_depth_jitstress.cranelift.jitstats
  • pyre/bench/synth/exception_reraise_tb_depth_jitstress.dynasm.jitstats
  • pyre/bench/synth/exception_reraise_tb_depth_jitstress.wasm.jitstats
  • pyre/bench/synth/inline_subwalk_user_iterator.cranelift.jitstats
  • pyre/bench/synth/inline_subwalk_user_iterator.dynasm.jitstats
  • pyre/bench/synth/str_search_index_bounds.wasm.jitstats
  • pyre/check.py
  • pyre/pyre-interpreter/src/pyopcode.rs
  • pyre/pyre-jit-trace/src/jitcode_dispatch/bridge_subwalk.rs
  • pyre/pyre-jit-trace/src/jitcode_dispatch/fbw_state.rs
  • pyre/pyre-jit-trace/src/jitcode_dispatch/inline_call.rs
  • pyre/pyre-jit-trace/src/jitcode_dispatch/mod.rs
  • pyre/pyre-jit-trace/src/jitcode_dispatch/residual_call.rs
  • pyre/pyre-jit-trace/src/state.rs
  • pyre/pyre-jit-trace/src/trace.rs
  • pyre/pyre-jit/src/call_jit.rs
  • pyre/pyre-jit/src/eval.rs
  • pyre/pyre-jit/src/jit/codewriter.rs
  • pyre/pyre-object/src/celldict.rs
  • pyre/pyre-object/src/dictmultiobject.rs
  • pyre/pyre-object/src/quasiimmut.rs
  • pyre/pyre-object/src/typeobject.rs

Comment thread pyre/pyre-jit-trace/src/state.rs Outdated
Comment thread pyre/pyre-jit-trace/src/trace.rs Outdated
Comment thread pyre/pyre-jit/src/call_jit.rs Outdated
…indow

`BlackholeInterpreter::run` roots every chain level's register bank and, for a
level with a `virtualizable_info`, the array-field slots inside its
virtualizable.  `virtualizable_ptr` itself was neither: it is a bare copy of
the frame red the level was bound to (`call_jit.rs` binds it from
`registers_r[portal_red]`), so a collection forwarded the register and left the
copy naming the pre-move address.

A virtualizable in the nursery makes that visible.  A compiled trace allocates
an inlined callee's `PyFrame` through its own `NewWithVtable`, which the GC
rewriter lowers to a nursery allocation, so a minor collection inside
`run_inner` relocates it.  The propagation loop then hands the stale pointer to
`record_application_traceback`, which stores it into `PyTraceback.frame` — a
slot `pytraceback_object_custom_trace` forwards whenever `try_gc_owns_object`
holds, and that predicate is a plain address-range test
(`is_valid_gc_object && (nursery.contains || oldgen.contains)`), so the vacated
block passes and the next minor collection reads a data word as a type id.

Register the slot instead of the value, the shape
`blackhole_from_resumedata` already applies to the resume reader's own
`virtualizable_ptr` for the chain-build window.

`cargo test --all --no-default-features --features dynasm` and
`pyre/check.py` (dynasm 357/357, cranelift 357/357, wasm 353/353) are green,
and the 342-fixture synth corpus is byte-identical before and after.

Assisted-by: Claude
…ady committed

`LoopBearingCalleeInlineUnsupported` is handled by two independent blocks in the
walk epilogue: the carrier block, which can take `CalleeRebuild` (resume INSIDE
the rebuilt callee, past what it applied), and the `FBW_ABORT_OUTER_RESUME`
block, which rewinds the outer frame to its CALL and re-executes it. Nothing
kept the two apart — the second block's gates are inclusion tests on
`fbw_executed_nonpure_residual` / `fbw_has_unjournaled_effect`, and
`walk_end_resume_provable` samples `FBW_EXECUTED_EFFECT_COUNT`, which the
rebuilt callee's plain interpretation never bumps. When both fire the callee
body runs twice.

Read `WALK_END_FLUSH_COMMITTED` first and reset the latch instead. Same
`MidBodyDecline::AfterRun` argument the carrier block already applies to its own
entry-carrier fallback.

Assisted-by: Claude
`filter_liveness_in_place` applied the LV∩SSA retain — keep only the pcdep
frame-slot colors plus the portal reds — to the per-PC `-live-` markers only.
The after-residual-call markers named by `after_call_post_merge` kept the raw
SSA-live set, which includes Ref colors no trace-time writer populates.

Group those markers alongside the per-PC ones and run them through the same
narrowing, adding the preceding call's own Ref result register to the retained
set (`get_list_of_active_boxes` names it at `ord(self.bytecode[self.pc - 1])`,
pyjitpl.py:186). `marker_pcdep` publication stays per-PC-marker-only.
`original_markers` is keyed by insn index instead of py_pc so both marker
classes read their own pre-mutation snapshot.

Measured over pyre/bench/** (404 scripts, dynasm): declines at
`collect_callee_active_boxes` 5 -> 1; `fbw_abort_nested_residual` denies
unchanged at 16. check.py: dynasm 358/358, cranelift 358/358; wasm keeps the
two `exception_reraise_tb_depth_*` jit-stats failures that reproduce
unchanged at the branch base.

Assisted-by: Claude
`blackhole_if_trace_too_long` runs in the tracer's own stepping loop
(`pyjitpl.py:2861-2867 _interpret`, which drives
`framestack[-1].run_one_step()`); `pyframe.py dispatch_bytecode` has no
such call. Pyre's tracer is the walker and its walk loop already runs the
check, so the copy in `eval_loop_jit`'s opcode dispatch was a second
caller of the same teardown.

That copy tore down `MetaInterp.tracing` from a re-entrant interpreter
run: a residual call executed inside an inline sub-walk runs Python
through `eval_loop_jit`, whose per-step check read the OUTER trace's op
count, found it over the limit, and moved that `TraceCtx` out of the
shared slot via `abort_trace_live`. The in-flight walk then kept
recording through a `&mut TraceCtx` whose recorder buffer had been
freed. `bench/synth/trace_too_long_inline_multiframe.py` aborts in
libmalloc under `PYRE_FBW_NESTED_RESID_ABORT`-equivalent conditions;
after this change it exits 0 and matches `PYRE_NO_JIT=1` byte for byte.

RPython cannot reach the same state: `warmstate.py:437-441 bound_reached`
builds a fresh `MetaInterp` per trace attempt, so a nested JIT entry
never touches the outer attempt's history.

Assisted-by: Claude
… resumes past

`route_exc_edge` (`call_jit.rs`) takes a raise that unwinds clear out of every
inlined callee into the live frame's own handler, and re-points the live frame at
that handler.  Its own comment states what that costs: "that unwind discards the
callee frames outright, so there is no inlined framestack left to rebuild".  The
walk that follows starts flat, so the only node it records is the catching
frame's.

`pyopcode.py:148 pytraceback.record_application_traceback` runs BEFORE the `:152`
exception-table lookup, so a frame the unwind only passes through contributes a
node exactly like the one that catches.  Upstream gets that for free: it resumes
onto a rebuilt MIFrame stack and the unwind is traced code running each level's
own recorder.  Pyre synthesizes that loop, and this route synthesized it for one
level only.

`resume_coords[1..]` is exactly the set of discarded levels, so publish it at the
routing point and emit one node per level at the handler entry, innermost-first —
both recorders prepend, so emission order is the chain read outermost-first.  The
coordinate the resume data carries is a PYTHON pc, not the jitcode pc the two
existing recorders translate from, hence the third hook arity;
`record_discarded_level_traceback` fabricates the node's frame from the code
object as `record_inline_traceback_for_recording` does, the level's own `PyFrame`
having stayed virtual in the compiled trace.  Emitted as IR because
`trace_and_compile_from_bridge` runs once and every later failure of this class
enters the compiled bridge directly.

Latent behind the `CalleeReplaySafety::DeferredCall` arm, which residualizes the
intermediate call instead of inlining it.  With that arm forced off,
`bench/synth/gc_bug_bridge_flavor_traceback_names.py` printed
`('T', 'a_bridge_two_classes', 'leaf_two')` alongside the correct shape — the
`mid_two` frame dropped — and now matches `PYRE_NO_JIT=1`; a three-level variant
(`driver`/`deep_a`/`deep_b`/`deep_c`/`leaf`) matches too.

`cargo test --all --no-default-features --features dynasm`: 101 binaries, 0
failed.  `pyre/check.py` dynasm 15 / cranelift 15 / wasm 9 failed — keyed on
(fixture, backend, reason) that set adds NOTHING to `origin/main`'s own 41 and
drops the two `exception_reraise_tb_depth_jitstress` entries this branch fixes.

Assisted-by: Claude
`quasiimmut.py:124-125 QuasiImmutDescr.__init__` calls
`get_current_qmut_instance` first and `get_current_constant_fieldvalue`
second.  `record_quasiimmut_field` had the two in the opposite order, so a
write landing between them moved the field with no watcher installed: nothing
invalidated and nothing bumped the force counter, and the trace kept a value
that was already stale.  Without a GIL that window is a real interleaving.

Assisted-by: Claude
`record_discarded_level_traceback` received `py_pc` straight out of
`resume_coords`, which is a `next_instr`-style coordinate — the same one
`exc_table_offset` converts with `saturating_sub(1)` and the live frame
converts with `set_last_instr_from_next_instr`.  All three consumers below it
want the instruction that RAN: `decode_instruction_at` for the bare-reraise
test, `frame.last_instr`, and `record_application_traceback`'s `tb_lasti`.

So the node named the instruction AFTER the raising or calling opcode: the
traceback line was one instruction late, and a bare `RERAISE` decoded as
whatever follows it and gained a node the `RaiseWithExplicitTraceback` rule
says it must not have.  Convert once at entry and use that for all three.

Assisted-by: Claude
`filter_liveness_in_place` grew a second copy of the "push onto the entry with
this `insn_idx`, or start one" loop when the after-residual-call markers began
being narrowed alongside the per-PC ones.  Extract `group_py_pcs_by_insn` and
call it twice; the after-call site's skip of a marker already folded onto a
per-PC group becomes a `filter_map` on the input iterator.

Assisted-by: Claude
@youknowone youknowone changed the title jit: install the quasi-immut watcher at the recorded read; screen the FOR_ITER consume at its reach point; five teardown, replay, rooting and traceback fixes jit: root the blackhole virtualizable_ptr slot, drop the re-entrant trace-too-long teardown, and give the exception-edge bridge its discarded-frame traceback nodes Aug 2, 2026
@youknowone

Copy link
Copy Markdown
Owner Author

Review dispositions after the rebase onto ca1a421e847. Two findings fixed, one
confirmed with a reproducer and filed as pre-existing, one whose commit is
withdrawn, two now moot.

Fixed

  • CodeRabbit, state.rs:4465 — install the watcher before reading the value.
    Correct; quasiimmut.py:124-126 orders get_current_qmut_instance before
    get_current_constant_fieldvalue. Fixed on top of jit: install the quasi-immutable mutate field at the record, and abort the walk at the namespace write #977's install (commit 6).
  • Codex P2 call_jit.rs:842-848 / CodeRabbit call_jit.rs:849 — convert the
    resume PC to the failing opcode.
    Both reviewers found the same defect and
    both are right: resume_coords carries next_instr-style coordinates — the
    same function's exc_table_offset converts them with saturating_sub(1) — but
    record_discarded_level_traceback fed py_pc unconverted to
    decode_instruction_at, frame.last_instr and record_application_traceback.
    Converted once at entry (commit 7).
  • CodeRabbit, codewriter.rs:4449-4456 — extract a shared grouping helper.
    Done, group_py_pcs_by_insn (commit 8).

Confirmed, and it is pre-existing — filed, not fixed here

  • Codex P1 residual_call.rs:2345-2349 — decline loop-bearing callees before
    their first mutation.
    The mechanism is real and I reproduced it as an output
    divergence, but it is not introduced by the commit it was raised against.

    Probe: an inlined callee that runs an irreversible residual before reaching
    its own FOR_ITER, called 30000 times; c.pos should end at 30000.

    def step(c, it):
        c.pos = c.pos + 1      # irreversible residual, BEFORE the FOR_ITER
        s = 0
        for x in it:           # user __next__, StopIteration after 3
            s += x
        return s

    fbw_abort_nested_unjournaled_residual returns Ok for the store (the callee
    is neither self-recursive nor DeferredCall-admitted), the walk aborts later,
    the effect-count gate at fbw_state.rs:1420 correctly refuses to install the
    rewind leg, the walk exits non-committed, and the legacy replay re-runs the
    call. The store is outside the journal, so it applies twice.

    tree c.pos (correct 30000)
    origin/main + this branch 30005
    + pre-jit: refuse the abort image again on a live color with no concrete, and census the parent-frame bank hole #973 pc_live_r.retain restored 30001
    + retain restored and the FOR_ITER consume screen made a no-op 30001

    So the double-apply is pre-existing on main; jit: refuse the abort image again on a live color with no concrete, and census the parent-frame bank hole #973's retain deletion
    amplifies it ×5 by opening collect_callee_active_boxes' OpRef::NONE decline
    as a fresh abort site; the screened commit contributes 0. Corpus-wide there are
    only 7 committed=false effects>0 journal=0 census lines over 414
    pyre/bench scripts and none of them diverge — the census shape alone is not
    proof, which is why this needed a constructed case. Tracked separately; the fix
    is either the CONST_NULL substitution collect_outer_active_boxes already has
    at mod.rs:4721-4724, or landing the abort on a flush leg so there is no replay.

  • Codex P1 call_jit.rs:859-865 — rebuild discarded traceback frames from
    resume data.
    Correct: the node's tb_frame is fabricated from (w_code, py_pc) and has no arguments, locals, cells, value stack or back-reference.
    Deferred with a named blocker — the recorded IR bakes w_code/py_pc as
    constants, so materializing per-frame resume state needs the resume data
    carried to the recorder, which is a separate slice.

Moot after the rebase

  • Codex P1 quasiimmut.rs:261-262 / mod.rs:2412 — scope force events to the
    active walk
    , and CodeRabbit trace.rs:3745 — do not replay after an
    effectful ForceQuasiImmut handoff fails.
    Both name the
    FORCED_QUASI_IMMUTS process counter and the FBW_FORCED_QUASIIMMUT_BASE
    thread-local baseline, which this branch no longer carries: that work merged to
    main as jit: install the quasi-immutable mutate field at the record, and abort the walk at the namespace write #977 with a different mechanism. Re-raise against main if the concern
    survives there.

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

https://github.com/youknowone/pyre/blob/95e9437b591a45fd7aace1bf23611a10977d9c55/pyre-jit/src/call_jit.rs#L868-L872
P1 Badge Preserve closure state when reconstructing discarded frames

When a discarded inlined callee has free variables, passing None as outer_func makes createframe_obj return the documented “directly executed code object may not contain free variables” error; the else { return; } then silently omits that callee's traceback node, recreating the missing-frame bug this path is intended to fix. More generally, a newly initialized frame also loses the discarded frame's locals. Preserve or materialize the per-frame resume state rather than reconstructing from only (w_code, py_pc).

AGENTS.md reference: AGENTS.md:L24-L41


https://github.com/youknowone/pyre/blob/95e9437b591a45fd7aace1bf23611a10977d9c55/pyre-jit-trace/src/jitcode_dispatch/mod.rs#L600-L605
P2 Badge Journal discarded traceback attachments before an abort

When this exception-edge walk enters the handler but later aborts or declines, this concrete callback has already prepended every discarded-frame node to the live exception. Unlike record_bridge_handler_entry_traceback, these mutations are never added to FBW_TRACEBACK_STORE_JOURNAL, so rollback removes only the catching-frame node; blackhole replay then records the discarded frames again and exposes duplicate traceback entries. Journal each concrete attachment, or avoid applying it until the walk commits.

ℹ️ 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".

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