Skip to content

jit: two exception-path fixes (callee is-None inline, too-long Ref image) - #980

Merged
youknowone merged 9 commits into
mainfrom
perf-exc
Aug 3, 2026
Merged

youknowone merged 9 commits into
mainfrom
perf-exc

Conversation

@youknowone

@youknowone youknowone commented Aug 2, 2026 •

Copy link
Copy Markdown
Collaborator

Two JIT fixes found while attributing the slowest exception benchmarks against pypy.

jit: key the callee is-None inline decline on the kept stack

The multiframe seed declined any callee containing POP_JUMP_IF_NONE /
POP_JUMP_IF_NOT_NONE. Its stated premise — a residual is/is_not whose Ref
operand a mid-body guard resume cannot source — no longer holds: the codewriter
emits a bare ptr_eq/ptr_ne with a Kind::Int result feeding the exitswitch.
LOAD_FAST_CHECK emits the same shape (ptr_nonzero/r>i) and was never declined.

What actually breaks is narrower: a branch that leaves operands on the value
stack, whose guard resume must restore kept slots the inline sub-walk's mirror
does not model. The scan now asks LiveVars::stack_depth_at instead of matching
the opcode, so a statement-level if x is None: inlines while the same test in
expression position keeps the residual. Strictly narrower than the old predicate.

Measured on dynasm, interleaved A/B, median of 9 pairwise ratios:
exception_nested_exc_info_restore 1.72x, gc_bug_bridge_flavor_traceback_names
1.32x. Everything else lands inside this box's ±15% noise floor.

jit: fill the dead-box_bool Ref color in the too-long image

Latent wrong-code on main. fill_trace_too_long_register_banks skips any Ref
color it cannot supply a concrete for, on the premise that an unfilled color is
one the interpreter does not read at that pc. That fails for the dead-box_bool
marker, which writes a comparison's raw truth Int straight into the Ref register:
neither fill arm can represent an Int in a Ref bank, so the color was skipped, the
blackhole read NULL, and is_true(NULL) sent a module-scope while to its exit
arm after one iteration.

Reachable only when the module-dict NAME cell fold fires, so a module carrying
any try never showed it — including trace_too_long_effect_replay, whose own
try: import pypyjit unfolds its whole body and left the phase-A contract it
documents untested.

Also corrects both cell-fold comments: the handler-free gate is not the
can-raise argument they claimed (that argument transfers from the ungated
LoadGlobal fold, which shares the emitter). Lifting it is held back by a retrace
storm instead — exception_reraise_tb_depth_jitstress 0.90s → 5.85s at
loops_compiled 305 → 1802 — so both gates stay.

Verification

  • check.py --backend dynasm 362 passed / --backend cranelift 362 passed, with the same 6 pre-existing jit-stats baseline failures as before the change
  • JIT-on vs JIT-off identical across all 353 synth fixtures
  • cargo test --all --features dynasm 97 suites ok; cargo test -p pyre-object ok

Summary by CodeRabbit

  • Bug Fixes

    • Improved exception handling across nested calls with more accurate traceback information.
    • Prevented unsafe multi-frame optimizations in unsupported scenarios.
    • Fixed edge cases involving conditional checks, preserved values, and boolean reconstruction.
    • Preserved consistent string interning and repeated code-name access.
  • Performance

    • Optimized runtime loading to materialize JIT code only when needed.
    • Reduced unnecessary compilation and guard failures in benchmarked workloads.
  • Documentation

    • Clarified boolean state resets, trace recovery, and optimized name-access behavior.

@coderabbitai

coderabbitai Bot commented Aug 2, 2026 •

Copy link
Copy Markdown

Review Change Stack

Note

Reviews paused

It looks like this branch is under active development. To avoid overwhelming you with review comments due to an influx of new commits, CodeRabbit has automatically paused this review. You can configure this behavior by changing the reviews.auto_review.auto_pause_after_reviewed_commits setting.

Use the following commands to manage reviews:

  • @coderabbitai resume to resume automatic reviews.
  • @coderabbitai review to trigger a single review.

Use the checkboxes below for quick actions:

  • ▶️ Resume reviews
  • 🔍 Trigger review

Walkthrough

The PR adds indexed lazy JIT-code loading, cached PyCode names, cached runtime address patching, safer inline eligibility checks, residual boolean recovery, and multiframe exception unwinding.

Changes

Indexed lazy JIT-code runtime

Layer / File(s) Summary
Serialized JIT-code index
pyre/pyre-jit-trace/build.rs
JIT-code bodies use a concatenated binary with separate names and offset indexes. Cache artifacts and size reporting were updated.
Lazy JIT-code materialization
pyre/pyre-jit-trace/src/jitcode_runtime.rs
Indexed lookups decode requested entries lazily. Full-table access remains available, and tests verify deferred decoding.
Runtime patching and state wiring
pyre/pyre-jit-trace/src/runtime_fnaddr_patch.rs, pyre/pyre-jit-trace/src/state.rs, pyre/pyre-jit-trace/src/jitcode_dispatch/mod.rs
Address maps are cached and primed before decoding. Dispatch and build-time reservation use indexed APIs.

Multiframe exception dispatch

Layer / File(s) Summary
Inline depth and residual register handling
pyre/pyre-jit-trace/src/jitcode_dispatch/inline_call.rs, pyre/pyre-jit-trace/src/jitcode_dispatch/residual_call.rs
Raising callees use a depth cap of 2. Unsafe is None branches are declined. Dead box_bool markers can reconstruct boolean singletons.
Carrier-frame exception unwinding
pyre/pyre-jit-trace/src/trace.rs, pyre/pyre-jit-trace/src/jitcode_dispatch/mod.rs, pyre/bench/synth/*jitstats
SubRaise validates and closes middle frames, records traceback nodes, and resumes root exception handling. Dispatch documentation and benchmark statistics were updated.

Cached code names

Layer / File(s) Summary
Cached PyCode name storage
pyre/pyre-interpreter/src/pycode.rs, pyre/pyre-interpreter/src/eval.rs
PyCode lazily stores and reuses its wrapped name object. Garbage-collection root traversal visits the cached slot.

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

Sequence Diagram(s)

sequenceDiagram
  participant RuntimeLookup
  participant JitCodeIndex
  participant LazyJitCodeCell
  participant JitCodeDecoder
  RuntimeLookup->>JitCodeIndex: resolve indexed JitCode
  JitCodeIndex->>LazyJitCodeCell: access requested entry
  LazyJitCodeCell->>JitCodeDecoder: decode and patch body
  JitCodeDecoder-->>RuntimeLookup: return JitCode
Loading
sequenceDiagram
  participant SubRaise
  participant CarrierFrames
  participant CatchTargetLookup
  participant TracebackNodes
  participant RootRaiseContinuation
  SubRaise->>CarrierFrames: validate middle frames
  CarrierFrames->>CatchTargetLookup: resolve catch targets
  CatchTargetLookup-->>SubRaise: accept or decline chain
  SubRaise->>TracebackNodes: record crossed frames
  SubRaise->>CarrierFrames: close frames deepest-first
  SubRaise->>RootRaiseContinuation: resume root raise
Loading

Possibly related PRs

Suggested reviewers: lifthrasiir

Poem

A rabbit loads one code at a time,
While cached names rest in rhyme.
Middle frames unwind with care,
Boolean markers answer fair,
And traceback paths align.

🚥 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 identifies the two main JIT exception-path fixes described in the objectives.
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 perf-exc

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@github-actions

github-actions Bot commented Aug 2, 2026 •

Copy link
Copy Markdown

🤖 Codex parity review

Static analysis of this diff vs the local RPython/PyPy sources (commit 4559376).
Updated: 2026-08-03T11:13:01.216Z

Files in the reviewed diff
pyre/pyre-interpreter/src/eval.rs
pyre/pyre-interpreter/src/pycode.rs
pyre/pyre-jit-trace/build.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/jitcode_runtime.rs
pyre/pyre-jit-trace/src/runtime_fnaddr_patch.rs
pyre/pyre-jit-trace/src/state.rs
pyre/pyre-jit-trace/src/trace.rs

1. Regressions to PyPy parity introduced by this patch

None.

2. Other mismatches introduced by this patch

  • pyre/pyre-jit-trace/src/trace.rs:1620 ↔ pypy/interpreter/pytraceback.py:104 — the new carrier-unwind path records a traceback node through a code/globals-based fabricated frame, rather than the actual paused frame. PyPy passes the live frame to record_application_traceback; consequently tb_frame locals and frame identity can differ for an exception crossing a reconstructed middle frame.

  • pyre/pyre-jit-trace/src/trace.rs:1925 ↔ rpython/jit/metainterp/pyjitpl.py:2541 — a middle frame with catch_exception is explicitly declined ("MiddleCatchesRaise"). PyPy sets that frame’s PC to the handler target and continues in it. This is a missing bridge-resume case, not an equivalent implementation.

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

  • pyre/pyre-jit-trace/src/jitcode_dispatch/mod.rs:707 ↔ pypy/interpreter/pytraceback.py:104 — the existing unseeded-inline fallback fabricates a frame from w_code/w_globals instead of preserving the live per-frame object PyPy records. This was already present in upstream/main; the new carrier code repeats the same limitation.

4. Structural adaptations

  • pyre/pyre-interpreter/src/pycode.rs:316 ↔ pypy/interpreter/pycode.py:137 — PyPy stores co_name as an RPython string; Pyre lazily materializes and GC-roots a wrapped string. The observable shared co_name identity is preserved, while the wrapper/root-walker machinery is Rust/GC-specific.

  • pyre/pyre-jit-trace/build.rs:514 ↔ rpython/jit/codewriter/codewriter.py:78 — splitting all_jitcodes into concatenated serialized bodies plus a name/offset index and decoding entries lazily is required by Pyre’s separate build-script/runtime processes; RPython constructs one in-memory list during translation.

  • pyre/pyre-jit-trace/src/runtime_fnaddr_patch.rs:217 ↔ rpython/jit/codewriter/codewriter.py:80 — runtime rebinding of serialized function/static addresses is required because Rust build-script addresses are invalid after ASLR/process separation. RPython’s translated pointers are linked into the runtime binary.

  • pyre/pyre-jit-trace/src/jitcode_runtime.rs:36 ↔ rpython/jit/metainterp/warmspot.py:281 — the per-thread leaked JitCode cache predates this patch and differs from PyPy’s single metainterp_sd.jitcodes store. It is a Rust !Sync/GIL-free-threading adaptation, not introduced by this change.

@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

🤖 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/jitcode_dispatch/residual_call.rs`:
- Around line 667-690: Move BOOL_BOX_TRUTH ownership from process-global TLS
into walker state scoped to each walk lifetime. Add the association map to the
walker context/state, pass the same state through nested walks, and update
bool_box_truth_lookup and its writers, including the residual_call
from_bool_marker path, to use that state. Remove the TLS registry and preserve
existing bool-box reconstruction 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: 7fd8acf2-aaa1-4634-ab6b-302821414ce8

📥 Commits

Reviewing files that changed from the base of the PR and between a72d9cb and b4960e5.

📒 Files selected for processing (4)
  • pyre/bench/synth/exception_catching_frame_tb_node.cranelift.jitstats
  • pyre/bench/synth/exception_catching_frame_tb_node.dynasm.jitstats
  • pyre/pyre-jit-trace/src/jitcode_dispatch/inline_call.rs
  • pyre/pyre-jit-trace/src/jitcode_dispatch/residual_call.rs

internal_compile_panics=0
loops_aborted=0
loops_compiled=4
loops_compiled=3

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

do not giveup this

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

ℹ️ 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 +1631 to +1637
majit_metainterp::record_inline_application_traceback_for_recording(
exc_ptr as usize as i64,
w_code as usize as i64,
w_globals as usize as i64,
recipe.jitcode_index,
recipe.jitcode_pc,
);

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2 Badge Journal middle-frame traceback attachments

When a carrier exception crosses a middle frame but the subsequent root full_body_walk_trace aborts or declines, this call has already prepended a concrete traceback node. Unlike record_bridge_handler_entry_traceback, it does not add that mutation to FBW_TRACEBACK_STORE_JOURNAL, so the non-commit rollback cannot remove it and blackhole/interpreter replay can attach the same middle frame again, producing duplicate traceback entries.

Useful? React with 👍 / 👎.

Comment on lines +683 to +686
let from_bool_marker = opref
.filter(|&value| value != OpRef::NONE)
.filter(|&value| bool_box_truth_lookup(value) == Some(value))
.and_then(|value| match ctx.trace_ctx.lookup_opref_concrete(value) {

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 Carry the boolean marker with the value instead of TLS

This new recovery path makes BOOL_BOX_TRUTH thread-local state determine the reconstructed Ref value and therefore observable control flow after ABORT_TOO_LONG; it is no longer a disposable cache whose loss can only affect optimization. Carry the marker on the equivalent value/forwarded representation so the blackhole image does not depend on an independently reset TLS side table.

AGENTS.md reference: AGENTS.md:L148-L162

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/pyre-jit-trace/src/jitcode_dispatch/inline_call.rs`:
- Around line 2946-2950: Update the effective_multiframe_depth calculation so
the raising-callee depth of 2 is clamped to fbw_max_multiframe_depth(). Preserve
the configured depth for non-raising calls and ensure the environment-configured
maximum remains an upper bound for both paths.

In `@pyre/pyre-jit-trace/src/trace.rs`:
- Around line 1936-1943: Update the raise middle-frame loop around
record_carrier_crossed_frame_traceback and carrier_ec_leave so each carrier
leave also finishes and pops/advances its matching virtual-ref pair via the
established walker_ec_leave flow. Ensure subsequent iterations select the next
innermost virtual frame, allowing every middle frame to reach a recordable leaf
while preserving deepest-first ordering.
- Around line 1924-1929: Guard the middle-frame catch check in the surrounding
reconstruction logic against ReconstructRecipe.jitcode_pc being NO_JITCODE_PC
before casting it to usize. When the value is NO_JITCODE_PC, decline the drain;
otherwise preserve the existing carrier_catch_target call using the valid
offset.
🪄 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: 4b609cf2-21e4-44ab-b364-c36de6dbcfc7

📥 Commits

Reviewing files that changed from the base of the PR and between b4960e5 and f466674.

📒 Files selected for processing (4)
  • 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/trace.rs

Comment on lines 2946 to 2950
let effective_multiframe_depth = if contains_raise {
let bounded = crate::jitcode_dispatch::inline_chain_catches_a_raise(ctx.session)
|| inline_chain_unrolls_recursion(ctx);
if bounded { 1 } else { 2 }
2
} else {
fbw_max_multiframe_depth()
};

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win

Clamp the raising-callee depth to the configured multiframe depth.

fbw_max_multiframe_depth() reads PYRE_FBW_MULTIFRAME_DEPTH and clamps it to [1, 7]. If an operator sets that variable to 1, a raising callee still gets 2, so the raising path exceeds the configured cap while the non-raising path honours it. Take the minimum so the environment variable remains an upper bound for both paths.

🛠️ Proposed fix
     let effective_multiframe_depth = if contains_raise {
-        2
+        2.min(fbw_max_multiframe_depth())
     } else {
         fbw_max_multiframe_depth()
     };
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
let effective_multiframe_depth = if contains_raise {
let bounded = crate::jitcode_dispatch::inline_chain_catches_a_raise(ctx.session)
|| inline_chain_unrolls_recursion(ctx);
if bounded { 1 } else { 2 }
2
} else {
fbw_max_multiframe_depth()
};
let effective_multiframe_depth = if contains_raise {
2.min(fbw_max_multiframe_depth())
} else {
fbw_max_multiframe_depth()
};
🤖 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 2946 -
2950, Update the effective_multiframe_depth calculation so the raising-callee
depth of 2 is clamped to fbw_max_multiframe_depth(). Preserve the configured
depth for non-raising calls and ensure the environment-configured maximum
remains an upper bound for both paths.

Comment on lines +1924 to +1929
if carrier_catch_target(
middle_pjc.jitcode.code.as_slice(),
middle.jitcode_pc as usize,
"middle",
)
.is_some()

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🩺 Stability & Availability | 🟠 Major | ⚡ Quick win

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
# Check the sentinel value and whether the catch scanners bounds-check their pc argument.
set -euo pipefail

rg -nP -C4 'NO_JITCODE_PC' --type=rust
rg -nP -C25 '\bfn\s+catch_target_after_resume_live\s*\(' --type=rust
rg -nP -C25 '\bfn\s+find_catch_before_resume_live\s*\(' --type=rust

Repository: youknowone/pyre

Length of output: 153


🏁 Script executed:

#!/bin/bash
set -euo pipefail

echo "repo root:"
git rev-parse --show-toplevel

echo
echo "tracked files matching trace.rs:"
git ls-files | grep -F 'pyre/pyre-jit-trace/src/trace.rs' || true

echo
echo "search sentinel identifiers:"
rg -n "NO_JITCODE_PC|jitcode_pc|catch_target_after_resume_live|find_catch_before_resume_live|carrier_catch_target" . || true

echo
echo "outline pyre/pyre-jit-trace/src/trace.rs around relevant function:"
if [ -f pyre/pyre-jit-trace/src/trace.rs ]; then
  wc -l pyre/pyre-jit-trace/src/trace.rs
  rust-analyzer outline pyre/pyre-jit-trace/src/trace.rs 2>/dev/null | grep -E "pub|fn|ReconstructRecipe|carrier_catch_target|Middle" | head -200 || true
fi

Repository: youknowone/pyre

Length of output: 50371


🏁 Script executed:

#!/bin/bash
set -euo pipefail

echo "Relevant constants:"
rg -n -C3 'NO_JITCODE_PC|type.*ReconstructRecipe|struct ReconstructRecipe|jitcode_pc:' pyre/pyre-jit-trace/src pyre/pyre-jit/src/majit_ir 2>/dev/null | head -200

echo
echo "trace.rs around carrier_catch_target caller:"
sed -n '1880,1955p' pyre/pyre-jit-trace/src/trace.rs | cat -n

echo
echo "jitcode_dispatch.rs catch scanners:"
sed -n '2940,3005p' pyre/pyre-jit-trace/src/jitcode_dispatch/mod.rs | cat -n

echo
echo "ReconstructRecipe field definitions:"
rg -n -C5 'jitcode_pc.*i32|jitcode_index.*i32' pyre/pyre-jit-trace/src pyre/pyre-jit/src/majit_ir 2>/dev/null | head -240

Repository: youknowone/pyre

Length of output: 16679


🏁 Script executed:

#!/bin/bash
set -euo pipefail

echo "ReconstructRecipe definition:"
rg -n -C8 'pub.*struct ReconstructRecipe|type ReconstructRecipe|struct ReconstructRecipe' pyre/pyre-jit-trace/src pyre/pyre-jit/src/majit_ir 2>/dev/null | head -160

echo
echo "trace.rs lines 1880-1945:"
sed -n '1880,1945p' pyre/pyre-jit-trace/src/trace.rs | cat -n

echo
echo "jitcode_dispatch catch scanners lines 2960-3015:"
sed -n '2960,3015p' pyre/pyre-jit-trace/src/jitcode_dispatch/mod.rs | cat -n

echo
echo "NO_JITCODE_PC definition lines:"
rg -n -C4 'const NO_JITCODE_PC|NO_JITCODE_PC =|enum|pub.*mod resumedata|NO_JITCODE_PC' pyre/pyre-jit/src/majit_ir pyre/pyre-jit-trace/src/pyjitcode.rs 2>/dev/null | head -140

echo
echo "Static verifier for relevant i32-to-usize cast in carrier_catch_target call:"
python3 - <<'PY'
from pathlib import Path
p = Path('pyre/pyre-jit-trace/src/trace.rs')
text = p.read_text()
needle = 'middle.jitcode_pc as usize'
if needle in text:
    idx = text.find(needle)
    start = text.rfind('\n', 0, idx-200)
    end = text.find('\n', idx+80)
    print(text[start+1:end] if start != -1 and end != -1 else '...')
PY

Repository: youknowone/pyre

Length of output: 183


🏁 Script executed:

#!/bin/bash
set -euo pipefail

echo "All ReconstructRecipe occurrences:"
rg -n -C6 'ReconstructRecipe' .

echo
echo "NO_JITCODE_PC origin:"
rg -n -C5 'NO_JITCODE_PC.*=' pyre/pyre-jit/src pyre/pyre-jit-trace/src | head -160

echo
echo "trace.rs relevant block:"
sed -n '1880,1945p' pyre/pyre-jit-trace/src/trace.rs | cat -n

echo
echo "jitcode_dispatch catch scanners:"
sed -n '2960,3015p' pyre/pyre-jit-trace/src/jitcode_dispatch/mod.rs | cat -n

echo
echo "Static verifier of cast and function behavior:"
python3 - <<'PY'
from pathlib import Path
for path in (Path('pyre/pyre-jit-trace/src/trace.rs'), Path('pyre/pyre-jit-trace/src/jitcode_dispatch/mod.rs')):
    text = path.read_text()
    print(f'# {path}')
    for needle in ['middle.jitcode_pc as usize', 'catch_target_after_resume_live', 'find_catch_before_resume_live']:
        idx = text.find(needle)
        print(f'{needle}: {idx}')
PY

Repository: youknowone/pyre

Length of output: 37972


🏁 Script executed:

#!/bin/bash
set -euo pipefail

echo "majit_metainterp ReconstructRecipe field definition:"
sed -n '635,665p' majit/majit-metainterp/src/trace_ctx.rs | cat -n

echo
echo "majit_metainterp NO_JITCODE_PC definition:"
rg -n -C4 'const NO_JITCODE_PC|NO_JITCODE_PC|pub mod resumedata|resumedata' majit/majit-metainterp pyre/pyre-jit/src/majit_ir 2>/dev/null | head -160

echo
echo "pyre-jit-trace catch_target_after_resume_live callers:"
rg -n -C6 'catch_target_after_resume_live|find_catch_before_resume_live' pyre/pyre-jit-trace/src/pyjitcode.rs pyre/pyre-jit-trace/src/jitcode_dispatch/pyjitcode.rs pyre/pyre-jit-trace/src 2>/dev/null | head -220

echo
echo "decode_op_at implementation:"
rg -n -C4 'fn decode_op_at|pub\(crate\) fn decode_op_at|fn can_decode_live_vars' pyre/pyre-jit-trace/src pyre/pyre-jit/src 2>/dev/null | head -220

Repository: youknowone/pyre

Length of output: 16191


🏁 Script executed:

#!/bin/bash
set -euo pipefail

echo "Find resumedata module:"
git ls-files | grep -E '(^|/)resumedata\.rs$|resumedata' | head -80

echo
echo "NO_JITCODE_PC exact:"
rg -n -C3 'NO_JITCODE_PC\s*=' pyre majit/majit/src majit/majit/dependencies majit/majit/pyre-jit . 2>/dev/null | head -200

echo
echo "Reconstructed frame setup/entry uses of jitcode_pc:"
sed -n '13626,13725p' pyre/pyre-jit-trace/src/state.rs | cat -n
sed -n '2084,2135p' pyre/pyre-jit-trace/src/trace.rs | cat -n
rg -n -C5 'recipe\.jitcode_pc|mid\.jitcode_pc|middle\.jitcode_pc' majit/majit-metainterp/src pyre/pyre-jit-trace/src pyre/pyre-jit/src 2>/dev/null | head -240

echo
echo "decode_op_at definition:"
file=$(rg -l 'fn decode_op_at|pub\(crate\) fn decode_op_at' pyre/pyre-jit-trace/src pyre/pyre/src majit || true | head -1)
if [ -n "$file" ]; then
  rg -n -C8 'fn decode_op_at|pub\(crate\) fn decode_op_at' "$file"
fi

Repository: youknowone/pyre

Length of output: 232


🏁 Script executed:

#!/bin/bash
set -euo pipefail

echo "majit_ir resumedata definition:"
sed -n '1,120p' majit/majit-ir/src/resumedata.rs | cat -n

echo
echo "Reconstructed frame setup uses:"
sed -n '13626,13725p' pyre/pyre-jit-trace/src/state.rs | cat -n
sed -n '2084,2135p' pyre/pyre-jit-trace/src/trace.rs | cat -n

echo
echo "All recipe jitcode_pc reads:"
rg -n -C5 'recipe\.jitcode_pc|middle\.jitcode_pc|mid\.jitcode_pc' majit/majit-metainterp/src pyre/pyre-jit-trace/src pyre/pyre-jit/src 2>/dev/null | head -240

echo
echo "decode_op_at definition:"
rg -n -C8 'fn decode_op_at|pub\(crate\) fn decode_op_at' majit/majit-metainterp/src pyre/pyre-jit-trace/src pyre/pyre-jit/src 2>/dev/null | head -240

Repository: youknowone/pyre

Length of output: 28713


Guard middle.jitcode_pc against NO_JITCODE_PC before the usize cast.

ReconstructRecipe.jitcode_pc is majit_ir::resumedata::NO_JITCODE_PC (-1) when the middle resume frame has no direct JitCode offset. Casting it to usize turns it into a huge code offset, and carrier_catch_target reads from that offset as a bytecode position. Reject -1 explicitly before the catch check and decline the drain.

🤖 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/trace.rs` around lines 1924 - 1929, Guard the
middle-frame catch check in the surrounding reconstruction logic against
ReconstructRecipe.jitcode_pc being NO_JITCODE_PC before casting it to usize.
When the value is NO_JITCODE_PC, decline the drain; otherwise preserve the
existing carrier_catch_target call using the valid offset.

Comment thread pyre/pyre-jit-trace/src/trace.rs Resolved
@youknowone
youknowone force-pushed the perf-exc branch 2 times, most recently from 9204db0 to 1ba1e12 Compare August 3, 2026 05:05

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

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

1903-1946: 🩺 Stability & Availability | 🟠 Major | ⚡ Quick win

Guard middle.jitcode_pc against NO_JITCODE_PC before the usize cast.

ReconstructRecipe.jitcode_pc is majit_ir::resumedata::NO_JITCODE_PC (-1) when the decoded resume frame carries no direct JitCode offset, per the field's own documentation. The check middle.jitcode_pc as usize casts -1 into a very large offset. carrier_catch_target then uses that offset as a bytecode position to decode ops. This can read out of bounds or select an unrelated instruction.

A prior review flagged this exact cast on an earlier commit as a stability issue. That flag does not show a resolution marker, and the same unguarded cast is present in this diff. Reject NO_JITCODE_PC before the cast, and decline the drain for that middle frame instead.

🛠️ Proposed fix
             && middles.iter().rev().all(|middle| {
                 let Some(middle_pjc) = crate::state::pyjitcode_for_code(middle.code_ptr) else {
                     crate::jitcode_dispatch::census_record("P2Drain::NoMiddlePjc");
                     return false;
                 };
+                if middle.jitcode_pc == majit_ir::resumedata::NO_JITCODE_PC {
+                    crate::jitcode_dispatch::census_record("P2Drain::MiddleNoJitcodePc");
+                    return false;
+                }
                 if carrier_catch_target(
                     middle_pjc.jitcode.code.as_slice(),
                     middle.jitcode_pc as usize,
                     "middle",
                 )
                 .is_some()
                 {

Run the following script to confirm the sentinel value and the decoder's bounds behavior:

#!/bin/bash
set -euo pipefail
rg -n 'NO_JITCODE_PC' majit/majit-ir/src pyre/pyre-jit-trace/src
rg -n 'fn decode_op_at' -A 20 pyre/pyre-jit-trace/src
🤖 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/trace.rs` around lines 1903 - 1946, Guard
middle.jitcode_pc against majit_ir::resumedata::NO_JITCODE_PC before converting
it to usize in the middles_ok validation within the carrier drain logic. When
the sentinel is present, record the existing decline census event and return
false for that middle so the drain is rejected; only call carrier_catch_target
with a valid non-sentinel PC.
🤖 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/jitcode_runtime.rs`:
- Around line 72-94: Deduplicate the jitcodes.bin embedding used by
load_jitcode_index and load_jitcode. Factor the include_bytes! expansion into a
shared helper or constant, then have both load_jitcode_index and load_jitcode
reuse it while preserving the existing length assertion and JIT body loading
behavior.

In `@pyre/pyre-jit-trace/src/runtime_fnaddr_patch.rs`:
- Around line 237-254: Make address-correspondence priming unconditional in the
lazy jitcode table/loading path by invoking prime_address_correspondences()
before load_jitcode() can decode or patch any jitcode. Preserve the existing
setup calls, but ensure the lazy initialization path itself forces both
correspondence tables before resolving patch helpers or loading jitcode.

---

Duplicate comments:
In `@pyre/pyre-jit-trace/src/trace.rs`:
- Around line 1903-1946: Guard middle.jitcode_pc against
majit_ir::resumedata::NO_JITCODE_PC before converting it to usize in the
middles_ok validation within the carrier drain logic. When the sentinel is
present, record the existing decline census event and return false for that
middle so the drain is rejected; only call carrier_catch_target with a valid
non-sentinel PC.
🪄 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: fced9590-290f-49ba-a2a5-286b896e0820

📥 Commits

Reviewing files that changed from the base of the PR and between f466674 and 1ba1e12.

📒 Files selected for processing (10)
  • pyre/bench/synth/exception_try_call_inlined_callee_raise.cranelift.jitstats
  • pyre/bench/synth/exception_try_call_inlined_callee_raise.dynasm.jitstats
  • pyre/pyre-jit-trace/build.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/jitcode_runtime.rs
  • pyre/pyre-jit-trace/src/runtime_fnaddr_patch.rs
  • pyre/pyre-jit-trace/src/state.rs
  • pyre/pyre-jit-trace/src/trace.rs

The multiframe seed declined any callee containing POP_JUMP_IF_NONE /
POP_JUMP_IF_NOT_NONE.  Its stated premise — that the opcode lowers to an
`is`/`is_not` residual whose Ref operand a mid-body guard resume cannot
source — no longer holds: the codewriter emits a bare `ptr_eq`/`ptr_ne`
with a `Kind::Int` result feeding the exitswitch, so there is no residual
call and no boxed bool.  The opcode was also never the discriminator, since
LOAD_FAST_CHECK emits `ptr_nonzero/r>i` — a Ref register read straight into
a branch guard, the same shape — and was never declined.

What does break is narrower: a branch that leaves operands on the value
stack.  Its guard resume has to restore those kept slots, and inside an
inline sub-walk the walk mirror does not model them, so the kept Ref reads
NULL and `walker_branch_guard` raises
BranchGuardUnrestorableKeptStackPermanent — a permanent abort that discards
the enclosing loop trace.  The scan now asks `LiveVars::stack_depth_at`
instead of matching the opcode alone: the depth is taken before the
instruction and the branch pops the tested value, so `depth > 1` is exactly
"a kept slot survives".  A statement-level `if x is None:` or
`while x is not None:` reads 1 and inlines; the same test in expression
position (`acc += 1 if x is None else 0`, which holds `acc` across the
branch) reads 2 and keeps the residual.  The new predicate is strictly
narrower than the old one, so no callee that inlined before is declined now.

Measured on dynasm, interleaved A/B against the same tree without this
change, median of 9 pairwise ratios:

  exception_nested_exc_info_restore      1.72x
  gc_bug_bridge_flavor_traceback_names   1.32x

Both callees are the statement-level shape — `classify(t)` with `if t is
None:` and `chain(e)` with `while tb is not None:`.  Everything else in the
exception set lands inside this box's +-15% noise floor, calibrated on
exception_bridge_traceback_head, whose jit-stats are identical in both arms.

exception_catching_frame_tb_node compiles one loop fewer, because the callee
now inlines into the caller's loop instead of taking its own function-entry
trace, and its guard_failures fall 601 -> 401.  Its baselines are left
alone: the jit-stats floor gates a loops_compiled fall so that someone
looks at it, and re-recording would spend that gate.  The wasm module is
byte-identical with and without this change.

Assisted-by: Claude
`fill_trace_too_long_register_banks` copies all three MIFrame banks for the
ABORT_TOO_LONG blackhole handoff and skips any Ref color it cannot supply a
concrete for, on the premise that an unfilled color is one the interpreter
does not read at that pc.

That premise does not hold for the dead-`box_bool` marker.  A comparison whose
box `compare_box_provably_dead` proves invisible to guard resume snapshots
writes its RAW TRUTH Int straight into the Ref register instead of boxing it.
Neither fill arm can represent an Int in a Ref bank, so the color was skipped
and the blackhole read NULL; `is_true(NULL)` is false, so a module-scope
`while` took its exit arm and dropped every remaining iteration.

Materialize the immortal bool singleton the box would have produced.  The
marker is recognisable because its sites record the truth against itself, while
a genuinely boxed bool records a distinct key whose concrete is a Ref.

Reached only when the module-dict NAME cell fold fires, since that is what
makes the loop compare specializable, so a module carrying any `try` never
showed it — including `trace_too_long_effect_replay`, whose own
`try: import pypyjit` unfolds its whole body and left the phase-A contract it
documents untested.  With the fold reachable, its phase A read `free_total` as
15 instead of 22900 at `trace_limit=8`; 16 and above were unaffected.

Also corrects the two cell-fold comments: the handler-free gate is not the
can-raise argument they claimed — that argument transfers from the LoadGlobal
fold, which shares the emitter and runs ungated.  Lifting the gate is held back
by a retrace storm instead (`exception_reraise_tb_depth_jitstress` 0.90s ->
5.85s at loops_compiled 305 -> 1802), so both gates stay.

check.py dynasm 362 passed / cranelift 362 passed, the same 6 pre-existing
jit-stats baselines failing as before the change; JIT-on vs JIT-off identical
across all 353 synth fixtures; cargo test --all --features dynasm 97 suites ok.

Assisted-by: Claude
`drive_bridge_carrier_walk`'s SubRaise arm was gated on
`carrier.recipes.len() == 1`. At depth 2 or more the raise fell through
with the trace already cut back, reaching `p2_drain_abort()`; jitdriver.rs
then saw a bridge Abort whose op count never passed
`bridge_body_start_op_count` and called `record_declined_bridge_guard`, so
the guard was permanently blacklisted and every later failure of it
short-circuited to a full blackhole resume. On a two-level caught-raise
shape that read `mc_entered` 1203 -> 19654 with 19053 short-circuits.

The arm now walks `recipes[n-2]..recipes[0]` — the frames between the
raising leaf and the root — the way `pyjitpl.py finishframe_exception`
pops the framestack. Per crossed frame: the `PyTraceback` node that
`pyopcode.py handle_operation_error` records for every frame an exception
passes through (without it `gc_bug_bridge_flavor_traceback_names` drops
`mid_two` on the deopt iterations), then `carrier_ec_leave`. A middle that
CATCHES has to be entered at its own handler and walked on from there, so
that shape records `P2Drain::MiddleCatchesRaise` and declines.

The whole chain is validated before any `carrier_ec_leave` runs, because
that helper performs the concrete leave as well as recording it: a
mid-chain decline that had already closed a frame would leave
`topframeref` one level short for the blackhole replay the abort epilogue
hands the iteration to.

`carrier_root_catch_target`'s body is factored into `carrier_catch_target`
so the middles ask the same question of their own jitcode.

With the drain complete, `effective_multiframe_depth` is a flat 2 for any
`contains_raise` chain; `inline_chain_catches_a_raise` and
`inline_chain_unrolls_recursion` computed the removed distinction and are
deleted. Nine interleaved reps per arm against one binary, median of
pairwise ratios with a self-A/B control at 1.00x:
exception_escape_caller_frame_tb_node 0.70x,
gc_bug_bridge_flavor_traceback_names 1.02x,
selfrec_tail_exception_unwind 0.99x.

`exception_try_call_inlined_callee_raise` now inlines `shape` -> `mid` ->
`leaf` as one trace, so its jit-stats move loops_compiled 3 -> 2,
bridges_compiled 2 -> 1, guard_failures 408 -> 201, loops_aborted 1 -> 0,
at 0.90x CPU with byte-identical output. Its baseline is NOT re-recorded:
the floor gates a loops_compiled fall precisely so someone looks, and
lowering it would hide the next one.

`bool_box_truth_reset`'s doc claimed two reset sites; there are seven, and
the ones it omitted are the sub-walk rollbacks that cut the very ops a
recorded key names. Restated, with the reason a reset can only cost a fold
and never mis-fold.

Assisted-by: Claude
`flush_with_latched_stack` returned false on the first operand slot that
did not resolve to a concrete Ref, without saying which slot or why, so a
declined `ForceQuasiImmutable` resume was indistinguishable from one that
never latched a stack.  Report slot index, OpRef and concrete value under
`PYRE_FBW_DEBUG_ABORT`, matching the neighbouring `[fbw-flush] DECLINE`
line.

Rewrite the LoadName cell-fold gate comment.  Its retrace-storm numbers
(`exception_reraise_tb_depth_jitstress` 0.90s -> 5.85s at loops_compiled
305 -> 1802) were measured before the walk aborted at the namespace
write; re-measured with the gate lifted, that fixture and
`exception_reraise_tb_depth_hot` both sit at 1.00x.  What lifting the
gate does reach is a dropped iteration: the fold installs the module
dict's `version?` watcher, a later bumping write abandons the walk, the
mid-expression flush declines on an unbound mirror slot, and the legacy
leg then refuses to re-deliver the in-flight FOR_ITER item —
`bench/synth/pickle_terminal_raise_resume` prints 214 under the JIT
against 216 interpreted.  Record that, and what the fold is worth
(1.87us -> 1.32us per iteration over 200k iterations of
`bench/synth/exc_info_module_loop_hot`).

Assisted-by: Claude
`a03b89c08a7` narrowed the multiframe seed's decline of a callee containing
POP_JUMP_IF_NONE / POP_JUMP_IF_NOT_NONE to fire only at
`LiveVars::stack_depth_at(pc) > 1`.  It miscompiles the unpickler:
`test.test_descr` and `test.test_pickletools` both go PASS -> FAIL in the
CPython suite gate, the first as `pickle._loads` -> `load_newobj_ex`
"Value after * must be an iterable, not int", the second as five read-position
errors out of `pickletools._genops`.  Bisected to that commit alone; reverse
-applying its hunk makes both pass again.

The depth rule covers only one of the two hazards the old comment named.  The
other — when the multiframe inline int-specializes the tested local, the
mid-body guard resume cannot source that operand's Ref form from the callee
register banks, the encoded liveness stream disagrees with the decoder and the
caller frame is corrupted — is about the branch's own operand, not about what
the branch keeps, so it does not depend on stack depth at all.  A
statement-level `if x is None:` reads depth 1 and now inlines, which is exactly
the shape that carries it.

The measured win it was buying is real (exception_nested_exc_info_restore 1.72x,
gc_bug_bridge_flavor_traceback_names 1.32x), so the gate is restored as it was
rather than replaced, and redoing it soundly is left to the resume-side rebox
the original comment already named.

`exception_catching_frame_tb_node` returns to its committed loops_compiled=4,
and the wasm loops_aborted rises on exception_inline_callee_tb_frames,
exception_raise_caught_same_frame_tb and exception_traceback_lineno_chain go
away with it.

Assisted-by: Claude
`201889611f2` made the fixture inline `shape` -> `mid` -> `leaf` as one trace
and left its baselines alone so the loops_compiled floor would force a look.
The look confirms an improvement on every counter it moves: loops_compiled
3 -> 2 and bridges_compiled 2 -> 1 because a callee that needed its own
function-entry trace now folds into the caller's, alongside loops_aborted
1 -> 0 and guard_failures 408 -> 201, at 0.90x CPU with byte-identical output.

Measured on both backends, which agree field for field.  A later fall to 1 is
still gated.

Assisted-by: Claude
`jitcodes.bin` was one bincode blob holding all 2042 `Arc<JitCode>`
entries, deserialized in a single pass the first time anything touched
`all_jitcodes()`.  Since that first touch is the first JIT compile, a
program that compiles one loop paid for the whole build-time universe,
including every jitcode it never reaches.

build.rs now emits the same entries individually concatenated, plus a
`jitcodes_index.bin` carrying the names and the byte offsets that
delimit them.  The runtime holds a leaked `[OnceCell<Arc<JitCode>>]`
and decodes an entry the first time its index is looked up.  The three
runtime patch passes and the `all_jitcodes[i].index == i` assertion move
from whole-table to per-entry, still running while the `Arc` refcount is
1 and before the cell publishes it.

Queries that do not need a body no longer decode one:
`compute_named_jitcode_index` scans the index's names and
`jitcode_count` (replacing `all_jitcodes().len()` in
`reserve_build_time_index_space`) reads the offset count.
`all_jitcodes()` remains, forcing the full table, and is now only
reached from tests.

The two build -> runtime address correspondences the patch passes
consult are cached, and `reserve_build_time_index_space` now forces both
through `prime_address_correspondences`.  Without that the caches would
be filled at whatever moment the first body happens to decode, which
once bodies are lazy is mid-trace, after `init_typeobjects` has retagged
the `PyType` singletons.  Every later-decoded body would then have its
`constants_r` patched against that stale view, and those constants reach
a compiled loop's gc_table, so the collector drags a bad root out of it:
`GC BUG: invalid type_id`, site `minor_root_target`, reproduced on
`synth/gc_bug_bridge_flavor_traceback_names` at 19/45 runs across three
nursery sizes where the whole-table loader was 0/45.  Forcing them from
`reserve_build_time_index_space` restores the instant the whole-table
loader took its snapshot, and that fixture is 0/70 over the same sizes.

`materialize_str_consts`'s local `interned` map is now per entry rather
than per table.  That does not split any identity: it is a fast path
over `box_str_constant`, which interns by content in a process-wide
table, so two jitcodes sharing a literal still resolve to one immortal
block.  A test pins that across separate calls, and a second test pins
that a name query decodes zero bodies.

`CODEGEN_CACHE_VERSION` v4 -> v5, since the artifact set gained a file
and `jitcodes.bin` changed shape.

Measured on dynasm, `scratchpad/oh_3000.py` (compiles one loop) against
`oh_3.py` (never reaches the threshold), so the difference is the
first-compile step.  Peak RSS attributable to that step falls 32.3 MiB
-> 25.7 MiB (min of 7 runs each).  Wall clock on the compiling script is
a -1.97ms median pairwise difference against a +2.84ms self-A/B control
on the same script, and -0.34ms on the non-compiling script, i.e. the
win is confined to the arm that compiles.  This box's noise floor spans
~10ms at p25/p75, so the RSS figure is the tighter of the two.

Assisted-by: Claude
`349b86f5e64` reverted the is-None inline widening because it was
wrong-code, restoring a blanket decline of any callee containing
`POP_JUMP_IF_NONE` / `POP_JUMP_IF_NOT_NONE`.  A `while tb is not None:`
helper is exactly that shape, so the whole traceback cluster went back
to failing to trace.

The decline is now conditional on three arms, each naming a distinct
mechanism rather than the opcode:

1. Kept operands.  A branch that leaves slots on the value stack needs
   its guard resume to restore them, and the inline sub-walk's mirror
   does not model them, so the kept Ref reads NULL and
   `walker_branch_guard` raises
   `BranchGuardUnrestorableKeptStackPermanent` — a permanent abort that
   discards the enclosing loop.  `stack_depth_at` is the depth before
   the instruction and the branch pops the tested value, so `depth > 1`
   is exactly "a kept slot survives".

2. The tested operand itself.  When the multiframe inline
   int-specializes the tested local, the mid-body guard resume cannot
   source that operand's Ref form from the callee register banks, so the
   encoded liveness stream disagrees with the decoder and the caller
   frame is corrupted.  This is independent of kept depth: a
   statement-level `if x is None:` reads depth 1 and still carries it.
   Bindings are re-classified through `ConcreteValue::from_pyobj` rather
   than matched on the variant, because a default argument is pushed as
   a raw `Ref` that never went through that classification —
   `_read_from_buffer(self, size=-1)` in `_io_app.py` reaches the scan
   with `size` as `Ref(<int object>)` and is the shape that miscompiled.

3. wasm keeps the blanket decline.  Tracing into the helper closes the
   enclosing trace with a CALL_ASSEMBLER into the helper's own loop, and
   the wasm backend declines every CALL_ASSEMBLER — each trace is its
   own module with no inter-module chaining.  That decline is the `Err`
   arm of the compile step, so the enclosing loop is aborted rather than
   merely left interpreted: `loops_aborted` 0 -> 3 on
   `synth/exception_traceback_lineno_chain`, 0 -> 5 on
   `synth/exception_inline_callee_tb_frames`, 0 -> 1/2/3 on three more,
   each measurably slower than declining (min-of-5 wall clock against a
   module rebuilt with the widening reverted).  `exc_edge_bridge_enabled`
   and `bridge_rec_root_selfrec` carve wasm out the same way.

`synth/exception_catching_frame_tb_node`'s jit-stats baseline is
re-recorded on both native backends: `loops_compiled` 4 -> 3 and
`bridges_compiled` 3 -> 2, because the helper is now inlined into the
caller instead of compiled as its own loop, and `guard_failures`
601 -> 401.  Every badness counter stays at 0.

Gates: check.py dynasm 370/370, cranelift 370/370, wasm 366/366;
pyre-jit-trace 339 unit tests; `test.test_descr` and
`test.test_pickletools` — the two oracles for the reverted miscompile —
both report no regressions.

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/440f40b975a67fbfaaf58bf1bd5b61a1226634e6/pyre-interpreter/src/pycode.rs#L771
P1 Badge Preserve per-access wrapping for co_name

For ordinary dynamically created code objects, this cache changes observable PyPy behavior: pypy/interpreter/typedef.py:716 exposes co_name through wrapfn="newtext", and pypy/objspace/std/objspace.py:409-417 constructs a fresh W_UnicodeObject unless the source string is an annotation-time constant. Returning the cached w_name instead makes f.__code__.co_name is f.__code__.co_name true rather than false, so retain the upstream per-read wrapping behavior instead of adding persistent wrapped-object identity.

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

@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

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-trace/src/jitcode_runtime.rs (1)

36-70: 🚀 Performance & Scalability | 🟠 Major | 🏗️ Heavy lift

Check whether get_jitcode_by_index() is intentionally process-global.

JITCODE_CELLS, FORCED_ALL_JITCODES, COMPILED_JIT_DRIVERS, and INDIRECTCALLTARGETS are process-wide state, but get_jitcode_by_index() and indirectcalltargets() currently hand out thread-local Arc<JitCode> allocations. That diverges from RPython's process-wide metainterp_sd.jitcodes identity semantics and can break any future cross-thread identity comparison. If JIT dispatch must be single-threaded, add the required written justification; otherwise make this table shared/synchronized.

🤖 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_runtime.rs` around lines 36 - 70, Determine
whether get_jitcode_by_index() and indirectcalltargets() must preserve
process-wide JitCode identity like RPython’s metainterp_sd.jitcodes. If
cross-thread identity is required, replace the thread-local storage and
initialization with shared synchronized state so all callers receive the same
Arc<JitCode> instances; otherwise document the single-threaded dispatch
invariant and why thread-local allocations are intentional.

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 `@pyre/pyre-jit-trace/src/jitcode_dispatch/inline_call.rs`:
- Around line 3465-3523: The is/None hazard scan in the seed block should cache
its callee-invariant results per jitcode instead of recomputing them for every
inline attempt. Extend the existing sub-jitcode body-facts cache used by
sub_jitcode_body_facts_for_code to store the decoded branch/liveness hazards for
kept-stack depth and wasm, then reuse those facts here; keep
callee_binds_unboxed_local evaluated at the call site and combine it with the
cached hazards before applying the existing try_multiframe and seed behavior.

---

Outside diff comments:
In `@pyre/pyre-jit-trace/src/jitcode_runtime.rs`:
- Around line 36-70: Determine whether get_jitcode_by_index() and
indirectcalltargets() must preserve process-wide JitCode identity like RPython’s
metainterp_sd.jitcodes. If cross-thread identity is required, replace the
thread-local storage and initialization with shared synchronized state so all
callers receive the same Arc<JitCode> instances; otherwise document the
single-threaded dispatch invariant and why thread-local allocations are
intentional.
🪄 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: 8bdb2c2b-b3fe-4012-98d0-baee04b380e5

📥 Commits

Reviewing files that changed from the base of the PR and between 1ba1e12 and 440f40b.

📒 Files selected for processing (14)
  • pyre/bench/synth/exception_catching_frame_tb_node.cranelift.jitstats
  • pyre/bench/synth/exception_catching_frame_tb_node.dynasm.jitstats
  • pyre/bench/synth/exception_try_call_inlined_callee_raise.cranelift.jitstats
  • pyre/bench/synth/exception_try_call_inlined_callee_raise.dynasm.jitstats
  • pyre/pyre-interpreter/src/eval.rs
  • pyre/pyre-interpreter/src/pycode.rs
  • pyre/pyre-jit-trace/build.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/jitcode_runtime.rs
  • pyre/pyre-jit-trace/src/runtime_fnaddr_patch.rs
  • pyre/pyre-jit-trace/src/state.rs
  • pyre/pyre-jit-trace/src/trace.rs

Comment on lines +3465 to 3523
if bound_method.is_none() {
let liveness = crate::liveness::liveness_for(raw_callee_code);
let has_is_none_branch = (0..callee_code.instructions.len()).any(|pc| {
matches!(
pyre_interpreter::decode_instruction_at(callee_code, pc),
Some((
pyre_interpreter::bytecode::Instruction::PopJumpIfNone { .. }
| pyre_interpreter::bytecode::Instruction::PopJumpIfNotNone { .. },
_
))
) && (
// Hazard 3 — the wasm backend cannot run the widened
// shape at all, so it keeps the blanket decline.
// Admitting a callee here traces into its body, and the
// helper this widening exists for carries its own loop
// (`while tb is not None:`), so the enclosing trace
// closes with a CALL_ASSEMBLER into that loop. Every
// wasm trace is its own module and there is no
// inter-module chaining, so the backend declines any
// CALL_ASSEMBLER outright; that decline is the `Err`
// arm of the compile step, so the enclosing loop is
// aborted rather than merely left interpreted.
// Measured `loops_aborted` 0 -> 3 on
// `synth/exception_traceback_lineno_chain` and 0 -> 5
// on `synth/exception_inline_callee_tb_frames`, each
// ~5-9% slower than declining. Drop this arm once the
// backend can chain modules.
cfg!(target_arch = "wasm32")
// Hazard 1 — kept operands. A branch that leaves slots
// on the value stack needs its guard resume to restore
// them, and the inline sub-walk's mirror does not model
// them, so the kept Ref reads NULL and
// `walker_branch_guard` raises
// BranchGuardUnrestorableKeptStackPermanent — a
// permanent abort that discards the enclosing loop
// trace. `stack_depth_at` is the depth BEFORE the
// instruction and the branch pops the tested value, so
// `depth > 1` is exactly "a kept slot survives". An
// unreachable pc has no depth and cannot fire a guard.
|| liveness.stack_depth_at(pc).is_some_and(|depth| depth > 1)
// Hazard 2 — the tested operand itself. When the
// multiframe inline int-specializes the tested
// local, the mid-body guard resume cannot source
// that operand's Ref form from the callee register
// banks (`collect_callee_active_boxes` reads a
// stale/mismatched box), so the encoded liveness
// stream disagrees with the decoder and the caller
// frame is corrupted. This is independent of kept
// depth: a statement-level `if x is None:` reads
// depth 1 and still carries it.
|| callee_binds_an_unboxed_local
)
})
{
if try_multiframe {
return Ok(None);
});
if has_is_none_branch {
if try_multiframe {
return Ok(None);
}
break 'seed;
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🚀 Performance & Scalability | 🔵 Trivial | ⚡ Quick win

Cache the static half of the is/is not None hazard scan.

The instruction/liveness scan at 3467-3517 re-decodes callee_code.instructions and recomputes crate::liveness::liveness_for(raw_callee_code) on every call to this inline route that reaches the seed block. Hazards 1 and 3 (kept-stack depth, wasm) do not depend on the call site and are candidates for the same per-jitcode OnceCell cache sub_jitcode_body_facts_for_code already uses for contains_raise, has_abort_permanent, etc. Only hazard 2 (callee_binds_an_unboxed_local) needs to stay call-site scoped. For a hot call site inside a loop, this avoids repeating an O(body size) scan on every inline attempt.

🤖 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 3465 -
3523, The is/None hazard scan in the seed block should cache its
callee-invariant results per jitcode instead of recomputing them for every
inline attempt. Extend the existing sub-jitcode body-facts cache used by
sub_jitcode_body_facts_for_code to store the decoded branch/liveness hazards for
kept-stack depth and wasm, then reuse those facts here; keep
callee_binds_unboxed_local evaluated at the call site and combine it with the
cached hazards before applying the existing try_multiframe and seed behavior.

`co_qualname` has held its wrapped string on the code object since it
was added, on the argument that `function.py:54 self.qualname =
qualname or self.name` and the getter hand out one immutable value, so
realizing it once reproduces that identity instead of minting a
`W_UnicodeObject` per read.

`co_name` sits directly beside it in the same getter match and had no
such slot: every read ran `w_str_new`, which is two allocations, a
`String` copy and a full `chars().count()`.  The identical argument
applies to it — `function.py:51 self.name = forcename or code.co_name`
— so give it the identical treatment: a `w_name` slot realized on first
demand by `w_code_name_obj`, a verbatim sibling of
`w_code_qualname_obj`.  `W_TypeObject` already carries the same
`w_name`/`w_qualname` pair.

Storage, immortality and root-walk handling follow `w_qualname`
exactly: `PY_NULL` until realized, `malloc_typed`-immortal so the
stored pointer is fixed, and forwarded by `walk_raw_code_roots` beside
its sibling.  `code_replace` clones the `CodeObject` and builds a fresh
wrapper, so a replaced `co_name` cannot read through a stale cache —
the same reason the existing qualname cache is sound.

Measured on a traceback walk (`tb.tb_frame.f_code.co_name` per hop),
against `co_argcount` in the same process as an untouched control,
because this box's absolute timings move too much between runs to
compare directly: `co_name`'s excess over that control falls from
+28.5% to +18.1%.  The remainder is the residual `getattr` itself,
which costs ~133 ns/hop on any `PyCode` attribute — `co_firstlineno`
and `co_argcount` measure the same — and is a separate fold.  This
change is what makes that fold possible, by giving `co_name` a
`PyObjectRef` slot and a `CODE_W_NAME_OFFSET` for a descr to name.

Gates: check.py dynasm 371/371, cranelift 371/371 and wasm 367/367.
`test.test_code`, `test.test_funcattrs`, `test.test_descr` and
`test.test_traceback` all report no regressions.

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/455937634f75ac984e7c2d4b2fd3b0b507ee68ed/pyre-jit-trace/src/trace.rs#L1631-L1637
P1 Badge Preserve the crossed middle frame in traceback nodes

When an inlined middle frame has observable state (for example, it saved sys._getframe() or has locals later inspected through tb_frame.f_locals), this callback reaches record_inline_traceback_for_recording, which creates a fresh frame from only w_code and w_globals. The traceback therefore contains a different frame with unpopulated locals instead of the reconstructed middle frame; previously this depth declined to blackhole/interpreter replay, which preserved that frame. Thread the recipe's per-frame red frame through this unwind and attach the node to it rather than using the fabricating hook.

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

ℹ️ 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: 1

🤖 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-interpreter/src/pycode.rs`:
- Around line 322-329: Add an alignment check after obtaining code_ptr in both
the current accessor and w_code_qualname_obj, rejecting null or misaligned
pointers before dereferencing CodeObject fields. Use CodeObject’s alignment
requirement with the specified bitmask check, and return the existing null
result for invalid pointers while preserving normal name and qualified-name
handling for valid ones.
🪄 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: 5114f221-bc26-4630-9a2d-f16dcce4d33f

📥 Commits

Reviewing files that changed from the base of the PR and between 440f40b and 4559376.

📒 Files selected for processing (2)
  • pyre/pyre-interpreter/src/eval.rs
  • pyre/pyre-interpreter/src/pycode.rs

Comment on lines +322 to +329
let code_ptr = w_code_get_ptr(w_code) as *const crate::CodeObject;
if code_ptr.is_null() {
return pyre_object::PY_NULL;
}
// `w_str_new` is a collection point, but the wrapper is Box-immortal
// and so never relocates; the fresh string is stored through the
// still-valid `w_code` before any further allocation can sweep it.
let w_name = w_str_new(&(*code_ptr).obj_name);

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🩺 Stability & Availability | 🟠 Major | ⚡ Quick win

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
set -euo pipefail

rg -n -C 8 '\bfn w_code_get_ptr\b|\bw_code_get_ptr\s*\(' \
  pyre/pyre-interpreter/src

rg -n -C 8 'w_code_name_obj|w_code_qualname_obj|co_name|co_qualname|code_ptr_aligned' \
  pyre/pyre-interpreter/src

Repository: youknowone/pyre

Length of output: 50373


🏁 Script executed:

#!/bin/bash
set -euo pipefail

echo "== pycode.rs relevant sections =="
sed -n '200,340p' pyre/pyre-interpreter/src/pycode.rs
echo
sed -n '660,705p' pyre/pyre-interpreter/src/pycode.rs
echo
sed -n '1640,1700p' pyre/pyre-interpreter/src/pycode.rs

echo
echo "== PyCode struct and code_ptr constructors =="
rg -n -C 6 'struct PyCode|code_ptr:|w_code_new\b|w_code_new_with_hidden_applevel\(' pyre/pyre-interpreter/src/pycode.rs pyre/pyre-interpreter/src/error.rs pyre/pyre-interpreter/src/function.rs

echo
echo "== precise call sites of w_code_get_ptr in pycode.rs with immediate deref/alignment =="
python3 - <<'PY'
from pathlib import Path
p=Path('pyre/pyre-interpreter/src/pycode.rs')
lines=p.read_text().splitlines()
for i,l in enumerate(lines,1):
    if 'w_code_get_ptr' in l:
        snippet='\n'.join(f'{j}: {lines[j-1]}' for j in range(max(1,i-2), min(len(lines), i+5)+1))
        print(f'--- pycode.rs:{i} ---')
        print(snippet)
PY

Repository: youknowone/pyre

Length of output: 29485


🏁 Script executed:

#!/bin/bash
set -euo pipefail

echo "== w_code_new_with_hidden_applevel implementation =="
sed -n '440,545p' pyre/pyre-interpreter/src/pycode.rs

Repository: youknowone/pyre

Length of output: 5321


Guard code_ptr alignment before reading obj_name.

w_code_new_with_hidden_applevel stores wrapped co_name table values when code_ptr is non-null but misaligned, so this accessor should not assume any non-null result is a valid CodeObject. Check (code_ptr as usize) & align_of::<crate::CodeObject>() - 1 == 0 before &(*code_ptr).obj_name, and apply the same guard in w_code_qualname_obj.

🤖 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-interpreter/src/pycode.rs` around lines 322 - 329, Add an alignment
check after obtaining code_ptr in both the current accessor and
w_code_qualname_obj, rejecting null or misaligned pointers before dereferencing
CodeObject fields. Use CodeObject’s alignment requirement with the specified
bitmask check, and return the existing null result for invalid pointers while
preserving normal name and qualified-name handling for valid ones.

@youknowone
youknowone merged commit a036267 into main Aug 3, 2026
18 of 19 checks passed
@youknowone
youknowone deleted the perf-exc branch August 3, 2026 12:49
youknowone added a commit that referenced this pull request Aug 3, 2026
… for the new base

The rebase onto origin/main put #980 under this branch, and both sides had
moved this fixture's recorded jit-stats.  Neither side's number describes the
combination, so the value here was measured on the rebased tree rather than
merged:

| | loops | bridges | guards |
|---|---|---|---|
| old base | 3 | 2 | 402 |
| this branch, on the old base | 3 | 3 | 604 |
| new base (#980) | 2 | 1 | 201 |
| measured on the rebase | 2 | 2 | 405 |

The branch's effect on this fixture is the same on either base — one more
bridge and ~203 more guard failures, with `loops_compiled` unchanged — so the
recorded move is that effect re-expressed, not a new regression.

check.py: dynasm 372/372, cranelift 372/372.

Assisted-by: Claude
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant