Skip to content

jit: stop truncating lowering at abort_permanent, attach the missing catch edge, and let residual calls re-enter the JIT - #860

Merged
youknowone merged 16 commits into
mainfrom
single-walker
Jul 29, 2026
Merged

jit: stop truncating lowering at abort_permanent, attach the missing catch edge, and let residual calls re-enter the JIT#860
youknowone merged 16 commits into
mainfrom
single-walker

Conversation

@youknowone

@youknowone youknowone commented Jul 28, 2026

Copy link
Copy Markdown
Owner

Twelve commits, continuing the call-abort epic. The last five are this cycle's
findings; each was reduced to a reproducer and grounded in the RPython/PyPy
source before the fix.

abort_permanent truncated the whole code object

emit_abort_permanent! appended a returnblock link, which made exits
non-empty; the dead-code gate in the lowering loop then skipped op dispatch for
every later PC, so merge_entry_by_green lost all following loop headers and
compile_and_run_once returned None — silently (loops_compiled=0 loops_aborted=0). Any code object containing a class statement
(LOAD_BUILD_CLASS), a del, an annotation, or a yield therefore never
compiled anything after that opcode.

abort_permanent terminates the run, not the graph. The block is now closed
only at the two arms that skip their own stack model (Call with nargs > 14 and
the LoadFastCheck unbound arm), via an explicit closes_block macro form.

Corpus: loops_compiled 1814 → 1823, three benchmarks newly compile.

A branch arm closed its can-raise block before the catch edge was attached

POP_JUMP_IF_FALSE/TRUE record the can-raise bool op and then close the
block with its two Bool exits in the same dispatch arm. The generic per-PC
catch emission runs after the arm and skips a block whose exits are already set,
so a bool covered by a try range got no catch_exception/L: a raising
__bool__ deopted into a frame with no catch and exited via
ExitFrameWithExceptionDescr instead of reaching the in-frame handler.

flowcontext.py:130-156 guessexception closes the recording block at the
can-raise op and resumes normal flow in a fresh EggBlock; guessbool then sets
the exitswitch on that second block. The cut is now performed inside the arm and
factored out of the FOR_ITER site into emit_catch_exception_and_split!, which
additionally threads the branch input through the link (unsimplify.py:59-76 split_block) so regalloc.rs make_dependencies — per-block liveness from
inputargs — sees it live in the successor. The FOR_ITER site had that gap too.

Nursery objects on the major marking worklist

seed_major_root and grey_child pushed any managed address, nursery included.
The worklist outlives the mutator resuming, so a nursery entry is popped after
the next reset_nursery recycled those bytes and object_total_size reads a
garbage type_id.

incminimark.py:2739-2753 _collect_obj appends only if not self.is_in_nursery(obj), and visit (:2797-2799) asserts the same. Both push
sites are now gated on that condition and the assertion is mirrored in
mark_object. The non-moving oldgen major keeps its nursery marking — it leaves
those bytes in place by contract.

Measured: synth/inline_subwalk_mutating_residual on cranelift went 14/20 crashes
to 0/20.

A residual call forced plain eval for its whole subtree

bh_call_fn_impl wrapped its callee in force_plain_eval(), a thread-local that
pins every Python frame under the residual CALL to eval_frame_plain. A hot
inner loop therefore stopped compiling for as long as its caller's loop was
compiled.

blackhole.py:1225 bhimpl_residual_call_r_i is cpu.bh_call_i(func, ...): it
invokes the translated function, and a callee whose graph reaches a
jit_merge_point enters the JIT. Residual means opaque to the trace, not "JIT
off for the extent of the call"; bhimpl_recursive_call_*
(blackhole.py:1095-1132) is the statically-known-portal form, not the only
route back. Re-entrant tracing stays blocked on the green key by
driver.is_tracing() (warmstate.py:473-477 JC_TRACING).

Nested-loop ladder (dynasm, CPU time, min of 3, 6000 outer x N inner):

inner N before after PYRE_NO_JIT=1
50 0.25s 0.08s 0.29s
200 0.76s 0.08s 0.97s
800 2.89s 0.09s 3.64s

Before, the compiled case tracked the interpreter linearly; after, it is flat.
Corpus census: loops_compiled 1823 → 1870, bridges 373 → 404.

Gate

python3 ./pyre/check.py — dynasm 333/333, cranelift 333/333, wasm 330/330.

🤖 Generated with Claude Code

Summary by CodeRabbit

  • Bug Fixes

    • Improved incremental garbage collection safety by filtering nursery entries during major marking.
    • Fixed JIT residual-call behavior to keep exception state consistent across JIT re-entry.
    • Hardened inline handling by declining risky inlining and better managing abort/permanent blocks and exception edge tracing.
    • Corrected write-barrier fast-path behavior for list updates involving empty-storage promotion.
  • Tests

    • Added/updated regression-oracle benchmarks for nested try/except + sys.exc_info() and regex workloads.
    • Removed an obsolete pending benchmark and refreshed benchmark notes/expected output for current behavior.

@coderabbitai

coderabbitai Bot commented Jul 28, 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: 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: aee59c48-a29f-4eaa-9dab-984552d80a3a

📥 Commits

Reviewing files that changed from the base of the PR and between 1f79ec9 and 9cc4940.

📒 Files selected for processing (16)
  • majit/majit-gc/src/collector.rs
  • pyre/bench/synth/_pending/exception_nested_exc_info_restore.py
  • pyre/bench/synth/exception_nested_exc_info_restore.py
  • pyre/bench/synth/gc_bug_bridge_flavor_traceback_names.py
  • pyre/bench/synth/loop_callee_shared_mutation.py
  • pyre/bench/synth/sre_wasm_min.py
  • pyre/bench/synth/sre_wasm_min1.py
  • pyre/pyre-interpreter/src/call.rs
  • pyre/pyre-interpreter/src/eval.rs
  • pyre/pyre-interpreter/src/pycode.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/trace.rs
  • pyre/pyre-jit/src/call_jit.rs
  • pyre/pyre-jit/src/eval.rs
  • pyre/pyre-jit/src/jit/codewriter.rs

Walkthrough

The PR filters nursery objects from moving major-GC marking, adds JIT inline hazard handling, restructures exception-edge and abort emission, routes residual calls through JIT-aware evaluation, adjusts write-barrier state, and adds or updates benchmark regression oracles.

Changes

Major marking worklist

Layer / File(s) Summary
Nursery worklist gating
majit/majit-gc/src/collector.rs
Moving major marking excludes nursery objects from root and child worklists, while debug builds assert valid processed entries.

JIT control-flow and residual dispatch

Layer / File(s) Summary
Abort and exception-edge emission
pyre/pyre-jit/src/jit/codewriter.rs
Abort emission distinguishes block-closing cases, and exception handling creates split successor blocks with threaded values.
Hazardous inline detection and decline
pyre/pyre-jit-trace/src/jitcode_dispatch/{fbw_state.rs,inline_call.rs}
Hazardous callee keys are remembered, abort-permanent bodies are memoized, and unsupported multiframe inline cases decline with Ok(None).
Residual dispatch and trace state
pyre/pyre-interpreter/src/call.rs, pyre/pyre-jit/src/{call_jit.rs,eval.rs}, pyre/pyre-jit-trace/src/{trace.rs,jitcode_dispatch/specialize.rs}
Residual calls use JIT-aware evaluation, residual exception state is parked and restored, bridge declines are recorded, backend exception peeking is crate-visible, and empty-storage promotion disables a write-barrier fast path.

Benchmark and regression oracles

Layer / File(s) Summary
Benchmark oracle scripts
pyre/bench/synth/exception_nested_exc_info_restore.py, pyre/bench/synth/sre_wasm_min*.py
Adds executable nested-exception and regex benchmark oracles with fixed iteration counts and accumulated outputs.
Regression oracle notes
pyre/bench/synth/gc_bug_bridge_flavor_traceback_names.py, pyre/bench/synth/loop_callee_shared_mutation.py
Updates comments describing current regression status and expected threshold behavior.

Estimated code review effort: 5 (Critical) | ~120 minutes

Sequence Diagram(s)

sequenceDiagram
  participant FBWWalker
  participant InlineResolver
  participant Codewriter
  participant ResidualCall
  FBWWalker->>InlineResolver: attempt callee inline
  InlineResolver->>FBWWalker: decline hazardous or unsupported callee
  FBWWalker->>Codewriter: emit abort or split exception edge
  Codewriter->>ResidualCall: route non-inlined execution
Loading

Possibly related PRs

  • youknowone/pyre#374 — Both PRs modify major-marking traversal and gray-child worklist handling.
  • youknowone/pyre#471 — Both PRs modify JIT inlining and abort handling around hazardous or abort_permanent callees.
  • youknowone/pyre#779 — Both PRs modify FBW hazardous-inline and residual-admission logic.

Poem

I hop through gray stacks, keeping young ones away,
While JIT paths learn when to pause or stray.
Split blocks catch exceptions mid-flight,
Residual calls keep dispatch right.
New regex oracles gleam—
A bunny applauds this debugging dream!

🚥 Pre-merge checks | ✅ 5
✅ Passed checks (5 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title clearly reflects the main JIT changes in the PR, including abort_permanent lowering, missing catch edges, and residual-call re-entry.
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.
✨ 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.

@github-actions

github-actions Bot commented Jul 28, 2026

Copy link
Copy Markdown

🤖 Codex parity review

Static analysis of this diff vs the local RPython/PyPy sources (commit 9cc4940).
Updated: 2026-07-29T06:55:38.184Z

Files in the reviewed diff
majit/majit-gc/src/collector.rs
pyre/bench/synth/_pending/exception_nested_exc_info_restore.py
pyre/bench/synth/exception_nested_exc_info_restore.py
pyre/bench/synth/gc_bug_bridge_flavor_traceback_names.py
pyre/bench/synth/loop_callee_shared_mutation.py
pyre/bench/synth/sre_wasm_min.py
pyre/bench/synth/sre_wasm_min1.py
pyre/pyre-interpreter/src/call.rs
pyre/pyre-interpreter/src/eval.rs
pyre/pyre-interpreter/src/pycode.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/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-trace/src/jitcode_dispatch/fbw_state.rs:1074-1079 ↔ rpython/jit/metainterp/warmstate.py:485-495 — the new hazardous-inline deny omits upstream’s immediate bound_reached behavior for a newly denied callee. It leaves the callee residual until its normal threshold instead of promptly giving it an independent trace.

  • pyre/pyre-jit-trace/src/jitcode_dispatch/fbw_state.rs:1068-1072,1138-1145 ↔ rpython/jit/metainterp/warmstate.py:331-333,669-676FBW_HAZARDOUS_INLINE_DENY is TLS-local, while PyPy stores JC_DONT_TRACE_HERE on the shared green-key JitCell. Under free-threading, a different thread can re-inline a callee already denied elsewhere.

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

  • pyre/pyre-jit-trace/src/jitcode_dispatch/inline_call.rs:2265-2278 ↔ rpython/jit/metainterp/pyjitpl.py:1376-1390 — a multi-frame callee containing POP_JUMP_IF_NONE/POP_JUMP_IF_NOT_NONE aborts the enclosing trace (Err) rather than declining that inline and residualizing the call. The patch documents this existing traceback-loss issue but does not fix it.

  • pyre/pyre-jit-trace/src/jitcode_dispatch/inline_call.rs:2482-2494 ↔ rpython/jit/metainterp/pyjitpl.py:1376-1390 — a caller-side catch-marker incompatibility aborts the entire outer trace instead of declining only the callee. Upstream’s call path falls through to a residual call when it cannot inline.

  • pyre/pyre-jit-trace/src/jitcode_dispatch/inline_call.rs:2498-2502 ↔ pypy/interpreter/pyframe.py:128-132 — the retained “single-frame collapse” resumes an inlined callee at the caller’s CALL boundary. PyPy retains a frame per inline level, so get_w_globals() is always read from the live callee frame; collapsing frames can still lose callee frame identity on these fallback paths.

4. Structural adaptations

  • majit/majit-gc/src/collector.rs:2092-2123,2579-2600 ↔ rpython/memory/gc/incminimark.py:2739-2753,2777-2809 — Rust’s non-moving-oldgen mode may mark nursery objects in place; upstream always excludes nursery/pinned objects from objects_to_trace. The conditional worklist admission and later mark clearing are a collector-mode adaptation.

  • pyre/pyre-interpreter/src/pycode.rs:1950-2000 ↔ pypy/interpreter/pycode.py:146-165 — PyPy’s GC-managed PyCode.w_globals is traced normally; Pyre’s Box-immortal PyCode requires a registered root side table to forward stamped globals. This preserves the permanent-first-globals semantics despite different object ownership.

  • pyre/pyre-jit/src/call_jit.rs:481-555 ↔ rpython/jit/metainterp/pyjitpl.py:2417,2772-2782 — PyPy keeps residual exception state on each MetaInterp; Pyre uses per-thread cells shared by nested JIT entry. Parking/pinning the pair across re-entry is required by the Rust/TLS representation.

  • pyre/pyre-jit/src/jit/codewriter.rs:6472-6565,6758-6786 ↔ rpython/jit/codewriter/policy.py:48-84abort_permanent and its continuation/splitting logic compensate for unsupported CPython-compatible bytecodes in the generated Rust JIT. Upstream instead decides graph visibility before tracing; this is an opcode/compiler-version adaptation.

@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: 744331d899

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

/// away once every `gc_ptr_offsets` target is a real GC allocation.
fn grey_child(&mut self, addr: usize) {
if self.is_managed_heap_object(addr) {
if self.is_managed_heap_object(addr) && self.may_enter_marking_worklist(addr) {

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 Trace old children before skipping nursery objects

When marking finishes through the standalone gc_step() between minor collections, a black old object may have been updated to point to a young container that contains a still-white old object. rescan_remembered_black_and_drain requeues the old parent, but this new condition skips the young container entirely, so its old child remains white and is swept while still reachable, leaving a dangling pointer in the live nursery object. Upstream's nursery-worklist exclusion relies on major progress being driven after a minor; preserve that scheduling here or synchronously trace nursery objects without retaining their addresses on the worklist.

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

Useful? React with 👍 / 👎.

@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: 1eefdf308a

ℹ️ 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 +1080 to +1081
static FBW_HAZARDOUS_INLINE_DENY: std::cell::RefCell<std::collections::HashSet<usize>> =
std::cell::RefCell::new(std::collections::HashSet::new());

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 Store the inline-deny flag on the callee JitCell

When a hazardous callee is encountered, this records the upstream JC_DONT_TRACE_HERE decision in a parallel per-thread HashSet rather than on the callee's JitCell. Consequently the normal warmstate consumers never see the flag: each worker thread must rediscover the same static hazard and abort once, and the denied callee misses upstream's immediate bound_reached behavior and remains residual until its ordinary counter warms up. Store the decision on the callee JitCell and route both inlining and warmstate behavior through that authoritative field instead.

AGENTS.md reference: AGENTS.md:L126-L132

Useful? React with 👍 / 👎.

@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

🤖 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/bench/synth/exception_nested_exc_info_restore.py`:
- Line 3: Rewrite the comment near the nested exception handling example to
clearly state that POP_EXCEPT restores the value saved by its matching
PUSH_EXC_INFO, removing the malformed “the prev its matching” wording.

In `@pyre/pyre-jit-trace/src/jitcode_dispatch/inline_call.rs`:
- Around line 1417-1442: Replace the thread-local CALLEE_ABORT_PERMANENT_SEEN
cache used by callee_body_has_abort_permanent with a process-global,
lock-guarded map shared across threads, following the existing
call_descr_stub_cache pattern. Preserve the stable CodeObject-pointer key and
memoized abort_permanent scan while ensuring concurrent access is synchronized
and duplicate per-thread scans are eliminated.

In `@pyre/pyre-jit/src/jit/codewriter.rs`:
- Around line 10143-10149: In the FOR_ITER handling at the catch_for_pc call to
emit_catch_exception_and_split!, set exception_edge_handled = true immediately
after the macro invocation. Match the existing handling in the PopJumpIfFalse
and PopJumpIfTrue arms so the generic per-PC catch check does not reprocess the
already-split exception edge.
🪄 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: 29a3e47c-972b-42c1-97d9-2af39beda939

📥 Commits

Reviewing files that changed from the base of the PR and between b8a00be and 1eefdf3.

📒 Files selected for processing (14)
  • majit/majit-gc/src/collector.rs
  • pyre/bench/synth/_pending/exception_nested_exc_info_restore.py
  • pyre/bench/synth/exception_nested_exc_info_restore.py
  • pyre/bench/synth/gc_bug_bridge_flavor_traceback_names.py
  • pyre/bench/synth/loop_callee_shared_mutation.py
  • pyre/bench/synth/sre_wasm_min.py
  • pyre/bench/synth/sre_wasm_min1.py
  • pyre/pyre-interpreter/src/call.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/specialize.rs
  • pyre/pyre-jit-trace/src/trace.rs
  • pyre/pyre-jit/src/call_jit.rs
  • pyre/pyre-jit/src/jit/codewriter.rs
💤 Files with no reviewable changes (1)
  • pyre/bench/synth/_pending/exception_nested_exc_info_restore.py

@@ -0,0 +1,48 @@
# Regression oracle: nested try/except where each handler reads
# `sys.exc_info()`. After an inner handler unwinds, POP_EXCEPT must restore the
# slot to the prev its matching PUSH_EXC_INFO saved (the outer ValueError), and

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

📐 Maintainability & Code Quality | 🟡 Minor | ⚡ Quick win

Clarify the PUSH_EXC_INFO comment.

Line 3 contains the malformed phrase “the prev its matching,” which makes the documented restoration behavior difficult to understand. Rewrite it to state that POP_EXCEPT restores the value saved by the matching PUSH_EXC_INFO.

🤖 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/bench/synth/exception_nested_exc_info_restore.py` at line 3, Rewrite the
comment near the nested exception handling example to clearly state that
POP_EXCEPT restores the value saved by its matching PUSH_EXC_INFO, removing the
malformed “the prev its matching” wording.

Comment thread pyre/pyre-jit-trace/src/jitcode_dispatch/inline_call.rs
Comment thread pyre/pyre-jit/src/jit/codewriter.rs

@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: 1f79ec980f

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

callable: PyObjectRef,
args: &[PyObjectRef],
) -> PyResult {
call_user_function_with_eval(frame, callable, args, get_eval_fn())

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 Increment depth before entering the residual callee

When a compiled CALL residualizes a user function, this new entry bypasses the increment_call_depth() guard used by call_user_function; consequently eval_with_jit performs its entry stack_check() using the caller's depth. Residual or indirect recursion can therefore run past the configured Python recursion limit, and sys.setrecursionlimit() observes too few active frames. Wrap this call with the same depth guard as the ordinary user-function entry.

AGENTS.md reference: AGENTS.md:L16-L18

Useful? React with 👍 / 👎.

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

♻️ Duplicate comments (2)
pyre/pyre-jit/src/jit/codewriter.rs (1)

10143-10149: 🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win

Missing exception_edge_handled = true; after emit_catch_exception_and_split! in FOR_ITER — same gap flagged previously.

Unlike the PopJumpIfFalse/PopJumpIfTrue sites, this call does not set exception_edge_handled = true; afterward. It stays safe only because the two unconditional mergeblock calls immediately below populate current_block's exits before the generic per-PC catch check (if !exception_edge_handled && let Some(catch_label) = catch_for_pc[py_pc] {...}) runs, so block_already_closed evaluates true and the redundant emission is skipped. That safety net is incidental, not explicit — a future change to the exhaustion-branch ordering could silently reintroduce a duplicate/garbled catch-exception emission on an already-split block. This exact concern was raised in an earlier review pass on this same call site and reportedly addressed, but the code as provided still lacks the fix.

🐛 Proposed fix for consistency with sibling call sites
             if let Some(catch_label) = catch_for_pc[py_pc] {
                 emit_catch_exception_and_split!(
                     catch_label,
                     py_pc,
                     [next_value.clone()]
                 );
+                exception_edge_handled = true;
             }
🤖 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 10143 - 10149, Set
exception_edge_handled = true immediately after emit_catch_exception_and_split!
in the FOR_ITER catch-label branch, matching the PopJumpIfFalse and
PopJumpIfTrue call sites. Keep the existing mergeblock handling unchanged.
pyre/pyre-jit-trace/src/jitcode_dispatch/inline_call.rs (1)

1417-1443: 📐 Maintainability & Code Quality | 🟡 Minor | ⚡ Quick win

TLS cache for a thread-independent, static property still lacks the required justification.

CALLEE_ABORT_PERMANENT_SEEN's own comment states the cached value is "a static property of the callee's assembled body" — not thread-specific, not disposable. Contrast with FBW_HAZARDOUS_INLINE_DENY in fbw_state.rs, which cites warmstate.py:331 and explains why walk state must be per-thread. This same gap was flagged previously; a shared, lock-guarded map (mirroring codewriter.rs's call_descr_stub_cache: Mutex<HashMap<...>>) would avoid duplicate per-thread scans and satisfy the guideline.

♻️ Proposed fix: share the memo across threads
-thread_local! {
-    static CALLEE_ABORT_PERMANENT_SEEN: std::cell::RefCell<std::collections::BTreeMap<usize, bool>> =
-        const { std::cell::RefCell::new(std::collections::BTreeMap::new()) };
-}
+static CALLEE_ABORT_PERMANENT_SEEN: std::sync::Mutex<std::collections::BTreeMap<usize, bool>> =
+    std::sync::Mutex::new(std::collections::BTreeMap::new());

 fn callee_body_has_abort_permanent(w_code: *const (), body: &SubJitCodeBody) -> bool {
     let key = w_code as usize;
-    if let Some(hit) = CALLEE_ABORT_PERMANENT_SEEN.with(|m| m.borrow().get(&key).copied()) {
+    if let Some(hit) = CALLEE_ABORT_PERMANENT_SEEN.lock().unwrap().get(&key).copied() {
         return hit;
     }
     let hit =
         crate::jitcode_runtime::decoded_ops(body.code).any(|op| op.opname == "abort_permanent");
-    CALLEE_ABORT_PERMANENT_SEEN.with(|m| {
-        m.borrow_mut().insert(key, hit);
-    });
+    CALLEE_ABORT_PERMANENT_SEEN.lock().unwrap().insert(key, hit);
     hit
 }

Based on learnings, the **/*.rs guideline: "Do not use TLS for process-global, interpreter-owned, identity-sensitive, semantic, registry, cache, or GC-relevant runtime state... Use TLS only when upstream state is genuinely thread-specific or is a disposable temporary cache; all other TLS uses require an upstream citation and written justification."

🤖 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-trace/src/jitcode_dispatch/inline_call.rs` around lines 1417 -
1443, Replace the thread-local CALLEE_ABORT_PERMANENT_SEEN cache with a
process-shared, lock-guarded map keyed by the stable CodeObject pointer,
mirroring the existing call_descr_stub_cache pattern. Update
callee_body_has_abort_permanent to read and populate this shared cache while
preserving its current memoization behavior.

Source: Coding guidelines

🤖 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 `@majit/majit-gc/src/collector.rs`:
- Around line 2005-2009: Establish the promotion boundary before normal
moving-major root seeding: in the relevant collector entry flow, run a minor
collection for moving-major cycles initiated through gc_step(), while preserving
the explicit non-moving-major exception. At majit/majit-gc/src/collector.rs
lines 2005-2009, update the lifecycle before the gcref gates; at lines
2529-2534, retain the nursery exclusion only under that established invariant.
Preserve strict line-by-line structural parity with the RPython/PyPy
implementation.

---

Duplicate comments:
In `@pyre/pyre-jit-trace/src/jitcode_dispatch/inline_call.rs`:
- Around line 1417-1443: Replace the thread-local CALLEE_ABORT_PERMANENT_SEEN
cache with a process-shared, lock-guarded map keyed by the stable CodeObject
pointer, mirroring the existing call_descr_stub_cache pattern. Update
callee_body_has_abort_permanent to read and populate this shared cache while
preserving its current memoization behavior.

In `@pyre/pyre-jit/src/jit/codewriter.rs`:
- Around line 10143-10149: Set exception_edge_handled = true immediately after
emit_catch_exception_and_split! in the FOR_ITER catch-label branch, matching the
PopJumpIfFalse and PopJumpIfTrue call sites. Keep the existing mergeblock
handling unchanged.
🪄 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: b817e16f-65a2-429e-9d83-794c472cec2b

📥 Commits

Reviewing files that changed from the base of the PR and between 1eefdf3 and 1f79ec9.

📒 Files selected for processing (15)
  • majit/majit-gc/src/collector.rs
  • pyre/bench/synth/_pending/exception_nested_exc_info_restore.py
  • pyre/bench/synth/exception_nested_exc_info_restore.py
  • pyre/bench/synth/gc_bug_bridge_flavor_traceback_names.py
  • pyre/bench/synth/loop_callee_shared_mutation.py
  • pyre/bench/synth/sre_wasm_min.py
  • pyre/bench/synth/sre_wasm_min1.py
  • pyre/pyre-interpreter/src/call.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/specialize.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
💤 Files with no reviewable changes (1)
  • pyre/bench/synth/_pending/exception_nested_exc_info_restore.py

Comment thread majit/majit-gc/src/collector.rs
…ltiframe seed preconditions

`try_walker_inline_resolved_user_call`'s multiframe seed block answered "this
callee cannot be seeded" with `Err(DispatchError::callee_inline_unsupported)`.
`trace.rs` maps that variant to `TraceAction::Abort`, so the caller's whole loop
trace was discarded rather than the one inline. The arm does not call
`fbw_decline`, so the identical static precondition failed again on every
retrace and the loop never settled. The strict-seed path at the same five
checks already declines with `break 'seed`.

Return `Ok(None)` for the `try_multiframe` path at `raw.is_null()`,
`ncells != 0`, `ensure_jitcode_index` None, unresolved `portal_red_regs_at`,
and null `snapshot_sym`. `Ok(None)` is this function's existing did-not-inline
answer; every caller follows it to the ordinary residual call. All five precede
the first recorded op (`GETFIELD_GC_R`), so nothing is left half-recorded.

Upstream has no cannot-inline-this-callee abort: `pyjitpl.py`
`do_residual_or_indirect_call` residualizes a callee it cannot follow, the
recursion-budget path calls `dont_trace_here` and still falls through to
`do_residual_call`, and `rlib/jit.py`'s ABORT_* set is TOO_LONG / BRIDGE /
BAD_LOOP / ESCAPE / FORCE_QUASIIMMUT / SEGMENTED_TRACE.

`bench/synth/_pending/wasm_parse_template_int_ref` compiles 4 loops where it
compiled 0.

The POP_JUMP_IF_NONE scan in the same block keeps its abort: residualizing it
clears 98 aborts in `_pending/gc_bug_bridge_flavor_traceback_names` and 5 in
`_pending/exception_nested_exc_info_restore`, but the newly compiled loops emit
traceback tuples missing their outermost frame. Recorded at the site. The
try-block caller-frame decline further down has the same wrong `Err` and needs
a hoist above the seed block to fix; recorded as a FIXME.

check.py: dynasm 326/326, cranelift 326/326, wasm 323/323.

Assisted-by: Claude
…allsite

Inlining a callee whose assembled body carries an `abort_permanent` marker
walks the sub-walk into it. That surfaces as `TraceAction::AbortPermanent`,
which stamps `DONT_TRACE_HERE` on the CALLER loop's green key, so one unported
opcode anywhere in a callee permanently un-JITs every loop that calls it.

Scan the callee body in `try_walker_inline_user_call` once the body is
resolved, and return `Ok(None)` on a hit, which routes the call through the
ordinary residual path. Memoized per code object: the answer is a static
property of the assembled body, while the callsite is reached on every retrace
of every caller.

`loop_inlines_abort_permanent_callee` (`trace.rs`) already screens this before
the walk, but only for callees it resolves statically out of globals and frame
slots; a bound method, a container element or a call result reaches the
callsite unscreened. There the callee is concrete.

The screen is whole-body, matching `codewriter/policy.py:48-84`
`look_inside_graph`, whose own comment at :78-79 states the consequence of a
negative answer: "the call will be turned into a residual call".

    bench/synth/list_length_hint_validate   loops_compiled 0 -> 2, aborted 19 -> 8
    bench/synth/slots_class_var_conflict    loops_compiled 0 -> 2

Both matched `PYRE_NO_JIT=1` output before and after.

check.py: dynasm 326/326, cranelift 326/326, wasm 323/323.

Assisted-by: Claude
… aborting

`decline_inline_caller_frame_for_catch_marker` was consulted only from
inside the seed block of `try_walker_inline_resolved_user_call`, after
`GETFIELD_GC_R` / `emit_new_pyframe_inline_with_params` had already
recorded IR and a concrete `FrameBox` had been stamped.  Its `Err` there
became `LoopBearingCalleeInlineUnsupported`, which `trace.rs` maps to
`TraceAction::Abort` — discarding the caller's whole loop trace.

Add `inline_caller_frame_declines_for_catch_marker`, which replicates
the two caller-frame lookup preambles (nested: framestack top `w_code` →
`ensure_jitcode_index` → `pyjitcode_for_jitcode_index`; top-level:
`fbw_mode.snapshot_sym`) and runs only the catch-marker predicate, which
is a pure function of the caller jitcode and the CALL pc.  Call it above
the seed block under `try_multiframe` and return `Ok(None)`, so the call
falls through to the ordinary residual call.

The original in-seed arm stays `Err` for the strict-seed path, which
reaches it with IR already recorded.  `Unavailable` keeps going through
`compute_inline_caller_frame`: it depends on live register banks and its
fallback is the single-frame collapse, not a decline.

`list_length_hint_validate` reports `LoopBearingCalleeInlineUnsupported`
10 → 0 with no `[lb-site]` output remaining; `loops_compiled` and
`loops_aborted` there are unchanged, since 714d109320's callee
`abort_permanent` screen already declines the same calls earlier.

check.py: dynasm 326/326, cranelift 326/326, wasm 323/323.

Assisted-by: Claude
…of retrying it

`fbw_abort_nested_unjournaled_residual`'s hazard arm declines when a frame on
the inline framestack is loop-bearing or self-recursive.  That is a static
property of the callee's `CodeObject`, so the next trace attempt rebuilt the
same framestack and reached the same abort: `PYRE_FBW_DEBUG_ABORT=1` on
`foriter_exempt_nested_foriter` printed five byte-identical records
(`start_pc=51 Err=LoopBearingCalleeInlineUnsupported { pc: 240 }`), which is
`MAX_TRACE_ABORT_COUNT` (`majit/majit-metainterp/src/warmstate.rs:294`,
consumed at `:745`), so the enclosing green key was marked `DONT_TRACE_HERE`
for a decline belonging to the callee.

`fbw_inline_callee_hazardous` now returns the offending frame's `w_code`
instead of a bool.  The hazard arm records it in `FBW_HAZARDOUS_INLINE_DENY`
and `try_walker_inline_resolved_user_call` declines that callee to `Ok(None)`,
so the call residualizes and the enclosing loop compiles.  `w_code` and
`callee_code_key` are the same value (`inline_call.rs:1955` pushes
`w_code: callee_code_key`).  The `DeferredCall` arm's existing
`FBW_FORITER_DEFERRED_DENY` is the same shape; upstream's is
`disable_noninlinable_function` (`warmstate.py:331`), applied to the callee
`find_biggest_function` (`pyjitpl.py:3538`) names while the enclosing loop is
left to retrace (`pyjitpl.py:2818-2828`).  Like the upstream flag the set has
no removal path.

`bridge_rec_root_selfrec` is exempt at the callsite: it carries its own
`SELFREC_CA_FOLD_ACTIVE` exemption from the hazard arm (`inline_call.rs:2696`).

loops_compiled / loops_aborted, dynasm:
  foriter_exempt_nested_foriter      1/5 -> 2/1
  foriter_exempt_shared_generator    1/5 -> 2/1
  inline_subwalk_user_iterator       2/5 -> 3/1
  selfrec_tail_exception_unwind      2/2 -> 3/1
  bridge_recursion_overflow          1 bridge -> 2 bridges, 1/1
  ca_bridge_multiframe_resume_double_call, exception_escape_caller_frame_tb_node,
  wasm_ca_trampoline_decline         unchanged
All seven produce byte-identical output under JIT and PYRE_NO_JIT=1.

check.py: dynasm 326/326, cranelift 326/326, wasm 323/323.

Assisted-by: Claude
…stead of aborting"

This reverts commit 14765c632c.

The hoist made `_pending/gc_bug_bridge_flavor_traceback_names` diverge from the
interpreter on dynasm, whose header records that dynasm and the no-JIT run are
both clean and pins the expected output.  Disabling only the hoisted condition
and rebuilding isolates it:

  hoist off:  A [('T', 'a_bridge_two_classes', 'mid_two', 'leaf_two'),
                 ('V', 'a_bridge_two_classes', 'mid_two', 'leaf_two')]
              loops_compiled=4 loops_aborted=98  (LoopBearing 97)
  hoist on:   A [('T', 'a_bridge_two_classes', 'mid_two', 'leaf_two'),
                 ('T', 'mid_two', 'leaf_two'),
                 ('V', 'a_bridge_two_classes', 'mid_two', 'leaf_two'),
                 ('V', 'mid_two', 'leaf_two')]
              loops_compiled=4 loops_aborted=2   (LoopBearing 1)

The extra tuples lack the outermost frame, so the loops the hoist newly compiles
build a traceback without a `PyTraceback` node for the frame that catches.  That
is the same signature already recorded at the `POP_JUMP_IF_NONE` seed
precondition in `try_walker_inline_resolved_user_call`, which keeps its `Err`
for this reason; the abort was masking the missing node rather than preventing
it.  `check.py` did not catch this: `Path(SYNTHETIC_BENCH_DIR).glob(pattern)`
(`check.py:1483`) does not descend into `_pending/`.

The hoist is otherwise sound and worth relanding once the node is recorded.

Assisted-by: Claude
…urn abort

`ExcEdgeCrossFrameReturnUnsupported` is raised when an exception-guard bridge
was routed to the exc edge but the handler returns out of the frame, which
`bridge_subwalk.rs:211-227` decides with `find_catch_for_exc_resume` and
`exc_handler_rejoins_loop` over `(jitcode_code, position)` alone.  The same
guard therefore reaches it on every retrace: `PYRE_FBW_DEBUG_ABORT=1` prints 47
records of `start_pc=14 Err=ExcEdgeCrossFrameReturnUnsupported { pc: 202 }` in
`type_name_surrogate_reject` and 57 of `start_pc=34 ... { pc: 392 }` in
`inline_subwalk_mutating_residual`, with no other record between them.  Each
retry re-walks the whole body and concretely executes its residual calls before
failing again, which is the cost `trace.rs`'s `AbortPermanent` bridge-decline
already documents for the same premise: a bridge entry is keyed on the guard
descr, which the location's `DONT_TRACE_HERE` cell never gates.

The error cannot take the `AbortPermanent` mapping — it is raised before any
recording precisely so the guard resumes via the blackhole, which is the
correct caught-exception + callee-return handling, not a location to retire.
Record only the bridge-guard decline (`fbw_bridge_decline` →
`take_fbw_bridge_declined` → `record_declined_bridge_guard`), leaving the plain
`Abort` and the blackhole resume unchanged.

ExcEdgeCrossFrameReturnUnsupported records, dynasm:
  type_name_surrogate_reject         47 -> 1
  inline_subwalk_mutating_residual   57 -> 1
  inline_subwalk_property_mutates    57 -> 1
  list_length_hint_validate           2 -> 2
All four produce byte-identical output under JIT and PYRE_NO_JIT=1.

check.py: dynasm 328/328, cranelift 328/328, wasm 325/325.

Assisted-by: Claude
`check.py --synthetic-only --synthetic-pattern '_pending/*.py'` passes these on
dynasm, cranelift and wasm, so they now run in the default gate:

  exception_nested_exc_info_restore    JIT answered 3320000 (trait) / 3360000
                                       (FBW walker); now 360000, matching the
                                       interpreter and CPython
  gc_bug_bridge_flavor_traceback_names cranelift aborted 8/10 to 9/10 runs with
                                       `GC BUG: invalid type_id=` out of
                                       `incremental_mark_step`; now 20/20 clean
                                       with the output its header pins
  loop_callee_shared_mutation          JIT printed 2*N + 3; now 2*N
  sre_wasm_min, sre_wasm_min1          pass on all three backends

Their headers described the failures as current, so each is restated as a
regression oracle keeping what the defect was.

The remaining four stay in `_pending/`, none of them for JIT reasons:
`dict_set_clear_in_eq_ops` and `dict_set_clear_in_eq_restart` record a
deliberate CPython/PyPy divergence, which check.py reports as BASEFAIL by
construction; `wasm_parse_template_int_ref` imports `re._parser`, absent before
CPython 3.11, and the local baseline is 3.9.6; `cranelift_finalizer_ordering_crash`
exceeds the 20s per-benchmark timeout.

check.py: dynasm 333/333, cranelift 333/333, wasm 330/330.

Assisted-by: Claude
`emit_abort_permanent!` appended a returnblock link, which set the block's
`exits`. The `block_closed_by_terminator` gate in the lowering loop then
skipped op dispatch for every later PC, so nothing behind the opcode was
lowered: `merge_entry_by_green` lost every loop header that followed, and
`compile_and_run_once` refused through `pjc.n(target_pc).is_none()` without
recording an abort.

One `class` statement (`LoadBuildClass`), `del` (`DeleteName` /
`DeleteGlobal`), or annotated assignment (`SetupAnnotations`) in a module
prologue therefore left every loop in that module permanently un-JITtable
and silent — `loops_compiled=0 loops_aborted=0`, only a repeated
`[jit][bound-reached]`.

Emit the marker without closing the block. Each arm already models its
stack effect (`push_fresh_ref` / `pop_and_decr_depth`), so the
fall-through FrameState is well typed and a real terminator closes the
block; the runtime bails to the interpreter at the marker and never
reaches the continuation. The returnblock link stays for the one case
with no successor to close the block (abort at the last instruction),
where `flatten.py:107-109` would read an exit-less block's FrameState
tuple as return arguments.

`pyopcode.py:865-870 LOAD_BUILD_CLASS` and `:777-778 LOAD_LOCALS` are
plain value pushes, and `flowcontext.py` has no notion of blacklisting
the graph an untraceable operation appears in.

Ladder (CPU time, interleaved, min of 3, dynasm), `for _ in range(40000):
for i in range(64): <shape>`:

  shape              JIT before / after   speedup before -> after
  t = i + 1           0.09s / 0.10s        9.22x -> 8.37x
  t = [i]             0.21s / 0.22s        4.67x -> 4.30x
  F()                 2.59s / 1.85s        0.91x -> 1.16x
  F(i) with __init__  8.44s / 4.29s        0.55x -> 1.12x

check.py: dynasm 333/333, cranelift 333/333, wasm 330/330.

Assisted-by: Claude
… stack model

The previous commit stopped closing the block at every `emit_abort_permanent!`
site. Two of the 23 do not fall through: the `Call` arm with `nargs > 14`
`continue`s past its `push_and_bump!`, and the `LoadFastCheck` unbound arm
switches into a dedicated dead-end block whose fall-through PC the bound arm
has already merged. Leaving those open walked the next PC with an incomplete
stack model, and cost `load_fast_check.py` its loop (loops_compiled 1 -> 0,
loops_aborted 0 -> 5).

Add an explicit `emit_abort_permanent!(pc, closes_block)` form and use it at
those two sites; the auto-close for an abort at the last instruction moves
into the same parameter.

319-benchmark synth census (dynasm, both binaries built with the same
`--no-default-features --features dynasm`): loops_compiled 1814 -> 1823,
bridges_compiled +0, loops_aborted +1, guard_failures +4, no returncode
change. Five benchmarks change: list_inplace_mul_parity,
module_body_truncated_jitcode_replay and module_getattr_surrogate_cls newly
compile a loop (the last also drops 5 aborts to 0),
exception_reraise_tb_depth_hot 1 -> 6 loops, gc_deque_backing_list 4 -> 5.

Assisted-by: Claude
…lock

POP_JUMP_IF_FALSE / POP_JUMP_IF_TRUE record the `bool` graph op and then
close the block with its two Bool exits in the same dispatch arm.  The
generic per-PC catch emission runs after the arm and skips a block whose
exits are already set, so a `bool` covered by a `try` range got no
`catch_exception/L`: a raising `__bool__` deopted into a frame with no
catch and exited via ExitFrameWithExceptionDescr instead of reaching the
in-frame handler.

Cut the block at the can-raise op inside the arm — the
`flowcontext.py:130-156 guessexception` shape — and wire the branch into
the successor.  The cut is factored out of the FOR_ITER site into
`emit_catch_exception_and_split!`, which additionally threads the branch
input through the link (`unsimplify.py:59-76 split_block`) so
`regalloc.py:26-77 make_dependencies`, whose liveness is per block from
`inputargs`, sees it live in the successor.

Assisted-by: Claude
`seed_major_root` and `grey_child` pushed any managed address, nursery
included.  The marking worklist outlives the mutator resuming, so a
nursery entry is popped after the next `reset_nursery` has recycled those
bytes and `object_total_size` reads a garbage `type_id` out of the
recycled header — `GC BUG: invalid type_id=... site=object_total_size`.

`incminimark.py:2739-2753 _collect_obj` appends to `objects_to_trace`
only `if not self.is_in_nursery(obj)`, and `visit` (:2797-2799) asserts
`not self.is_in_nursery(obj)` on each popped entry.  Gate both push sites
on the same condition and mirror the `visit` assertion in `mark_object`.

The non-moving oldgen major keeps its nursery marking: it leaves the
nursery bytes in place by contract and `note_nonmoving_nursery_mark`
clears the marks as its last step.

Assisted-by: Claude
`bh_call_fn_impl` wrapped its callee in `force_plain_eval()` and called
`call_user_function_plain`, so every Python frame under a residual CALL —
the whole nested subtree, not just the callee — ran on
`eval_frame_plain`.  A hot inner loop therefore stopped compiling for as
long as its caller's loop was compiled.

`blackhole.py:1225 bhimpl_residual_call_r_i` is `cpu.bh_call_i(func, ...)`:
it invokes the translated function, and a callee whose graph reaches a
`jit_merge_point` enters the JIT.  Residual means opaque to the trace, not
"JIT off for the extent of the call"; `bhimpl_recursive_call_*`
(`blackhole.py:1095-1132`) is the statically-known-portal form, not the
only route back to the portal.  Re-entrant tracing stays blocked on the
green key by `driver.is_tracing()` (`warmstate.py:473-477` JC_TRACING).

Add `call_user_function_residual`, which keeps `get_eval_fn()`, and use it
here.  The seven other `force_plain_eval` sites are unchanged.

Nested-loop ladder (dynasm, CPU time, min of 3, 6000 outer x N inner):
inner N=50/200/800 goes 0.25s/0.76s/2.89s -> 0.08s/0.08/0.09s, i.e. from
tracking the interpreter (0.29s/0.97s/3.64s) to flat.  Corpus census:
loops_compiled 1823 -> 1870, bridges 373 -> 404.

Assisted-by: Claude
…Object

A parity review read the code-address key as a truncated green key that
could suppress inlining at unrelated callsites.  The flag this mirrors is
consumed by `can_inline_callable` (`warmstate.py:669-677`), whose only
caller `_opimpl_recursive_call` (`pyjitpl.py:1376-1382`) passes the
CALLEE's green args; a callee reached through a CALL is entered at its own
entry, so `next_instr` is constant and `pycode` carries the decision.  The
deny is recorded and queried at exactly that CALL-boundary inline, never a
mid-body resume, so the key is complete.

Also note the half of `JC_DONT_TRACE_HERE` that is not ported:
`warmstate.py:485-495` treats it as "trace from here as soon as possible"
and reaches `bound_reached` immediately for a denied cell that never had a
procedure token.

Comments only.

Assisted-by: Claude
…entry

`bh_call_fn_impl`'s user-function arm calls `call_user_function_residual`
(4cd51e8), so the callee can enter the JIT.  A nested compiled or
blackhole execution then writes the same two cells the helper publishes
to: the thread-local `BH_LAST_EXC_VALUE` and the backend
`_store_exception` pair.  Upstream cannot alias them — the raise lives in
`metainterp.last_exc_value`, a field of the MetaInterp instance that owns
the call, and `llmodel.py:194 _store_exception` is read back by the same
`bh_call_*` that armed it.

Take both cells out of their slots for the span of the call and put them
back, so the helper publishes only its own outcome.  A value taken out is
pinned on the shadow stack for the span: the cell is the only root it has
(`walk_bh_last_exc_value` / `walk_jit_exc_value`).  The other seven
residual sites keep `force_plain_eval` and cannot nest.
`jit_exc_value_peek_backend` becomes `pub(crate)` for the non-destructive
read.

Measured on `lib-python/3/test/test_strftime.py` with `time.time()` pinned
through the environment, swept over 180 consecutive `now` values: 7 fail
without this change and 0 with it, the same 7 that separate 4cd51e8
from its parent.  The failures were an `IndexError` escaping
`re/_parser.py:243`'s own `except IndexError`, and one
`compile.py:458 assert i == len(inputargs)`.  dynasm 337/337, cranelift
337/337, wasm 334/334, CPython suite gate 39/39.  The nested-loop ladder
4cd51e8 measured is unchanged (6000 outer x 50/200/800 inner: 0.06s
each, interpreted 0.09/0.21/0.69s).

Assisted-by: Claude
…ly on

`FBW_HAZARDOUS_INLINE_DENY` and `CALLEE_ABORT_PERMANENT_SEEN` are permanent
maps keyed by a raw `PyCode` address with no removal path.  That is sound
only because `w_code_new` (`pycode.rs`) allocates every `PyCode` with
`Box::into_raw` and nothing frees it, so the address is unique for the
process and never moves — upstream instead keys on a `JitCell` that holds
its greens and is pruned by `should_remove_jitcell` (`warmstate.py:212`).

`eval.rs`'s `PyCode` GC registration already names the change that would end
the immortality (switching `w_code_new` to `try_gc_alloc_stable`); cite it
from both memos, and name what breaks there — a reclaimed address returning
another code object's answer, which for `CALLEE_ABORT_PERMANENT_SEEN` is a
stale `false` admitting an inline whose body does carry `abort_permanent`.

Comments only.

Assisted-by: Claude
`pycode.py:159-165 frame_stores_global` stamps `w_globals` permanently.
pyre code objects are Box-immortal (`w_code_new` uses `Box::into_raw`), so
the collector never traces into them; the slot was forwarded only where
`walk_raw_code_roots` ran on a `frame.pycode` or `func.code` that the root
walk already reached. A stamped code object off the frame chain and absent
from every walked frame slot kept a pre-move nursery address once its
globals dict was promoted, and the next call through that code object
forwarded the stale pointer.

Register every stamped code object in `W_GLOBALS_STAMPED_CODES` and
forward its `w_globals` slot from the PyFrame root area, next to the
existing `_mapdict_caches` `w_method` walk.

`lib-python/3/test/test_tokenize.py` aborted in `copy_nursery_object` with
`invalid type_id` 3/3 before, and completes 3/3 after with the two
failures the `PYRE_NO_JIT=1` run also reports.

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

https://github.com/youknowone/pyre/blob/9cc49409c3afcbf518e69e24136e8c25d6894d1f/pyre-interpreter/src/pycode.rs#L1966-L1967
P1 Badge Keep the stamped-code root registry process-global

When a code object first stamps w_globals on a short-lived worker thread, this TLS registry is destroyed when that thread unregisters, even though the Box-immortal PyCode can remain callable from another thread. Because w_code_frame_stores_global only registers during the initial null-to-value transition, a later thread does not restore the entry; after a moving collection, code.w_globals can therefore retain the old nursery address and subsequent LOAD_GLOBAL accesses a dangling pointer. Store this GC-root registry on the shared interpreter/process owner rather than the stamping thread.

AGENTS.md reference: AGENTS.md:L148-L155

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

@youknowone
youknowone merged commit 622bdce into main Jul 29, 2026
18 of 19 checks passed
@youknowone
youknowone deleted the single-walker branch July 29, 2026 10:12
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