Skip to content

perf-exc: exception JIT perf (attr fold + loop-header dominance) - #689

Merged
youknowone merged 7 commits into
mainfrom
perf-exc
Jul 21, 2026
Merged

youknowone merged 7 commits into
mainfrom
perf-exc

Conversation

@youknowone

@youknowone youknowone commented Jul 20, 2026 •

Copy link
Copy Markdown
Collaborator

Summary

Exception-handling JIT performance work, stacked on perf-exc.

Commits

  1. guard inlined-op arg by ob_type + w_class — a P1 rhs-guard passed a
    W_TypeObject to GuardClass (which needs a physical ob_type); a
    builtin-int RHS was then provably-false and the loop never compiled. Emit a
    physical ob_type GuardClass plus a conditional w_class GuardValue.

  2. decline str-override inline on nested call — a str __str__-override
    inline admitted nested-call bodies, whose abort poisoned the whole loop. Add
    exception_string_override_has_nested_call so only the single-invoke body
    inlines.

  3. fold __context__/__cause__ exception attr reads — extend
    exception_attr_slot_fold so LOAD_ATTR of __context__/__cause__ on an
    exception folds to a guarded field read (GetfieldGcR) instead of a residual
    getset-descriptor call. Stores decline (descr_setcause sets
    suppress_context; descr_setcontext type-validates). Removes ~3 residual
    calls/iter from raising-in-except loops.

  4. classify loop backedges by target dominance — the loop-header predicate
    excluded a genuine loop header whenever a loop body contained a try/except:
    the loop's own backedge is laid out after the handler block, so the prior
    landing-pad heuristic never saw it, and same-frame raise-catch loops stayed
    uncompiled. Decide by dominance instead — build a CFG (fall-through, forward/
    backward jumps, exception-table edges); a backward jump is a loop backedge
    iff its target dominates its source. Validated against an iterative-dominator
    oracle across 8 try/except-in-loop shapes.

Effect

Same-frame raise-catch loops now compile: vbase_big @3m JIT 0.04s vs
interp 12.86s (was JIT slower than interp); context_chain compiles. This
also unblocks commit 3's benefit (a set __context__ requires raise-in-except
= the previously non-compiling shape).

Verification

pyre/check.py 241/241 on both backends (dynasm + cranelift). Two new
codewriter unit tests (loop_with_try_except_body_keeps_its_loop_header,
handler_rejoin_backward_jump_is_not_a_loop_header).

— commented by Claude

Summary by CodeRabbit

  • Bug Fixes
    • Correctly handle __context__ and __cause__ exception attributes, preserving expected setter side effects.
    • Prevent unsafe specialization for exception attribute stores and avoid incorrect folding during execution.
    • Improved JIT loop detection in the presence of nested try/except handlers.
    • Avoid inlining exception-string overrides when they contain nested calls.
  • Performance
    • Faster exception attribute reads and broader safe for-loop tracing when try/except appears in the loop body.
    • More targeted operator inlining with improved type/guard handling.

@coderabbitai

coderabbitai Bot commented Jul 20, 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: 40 seconds

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

Run ID: 3669e61e-4bb3-48ab-b3fe-72027583f128

📥 Commits

Reviewing files that changed from the base of the PR and between 0747916 and 7620f6f.

📒 Files selected for processing (7)
  • pyre/pyre-interpreter/src/baseobjspace.rs
  • pyre/pyre-jit-trace/src/descr.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/specialize.rs
  • pyre/pyre-jit/src/eval.rs
  • pyre/pyre-jit/src/jit/codewriter.rs

Walkthrough

The PR adds folding support for exception __context__ and __cause__ loads, refines JIT inline-call guards and override eligibility, and replaces loop-header detection with successor-graph dominance analysis while admitting try/except loop bodies.

Changes

Exception attribute slot folding

Layer / File(s) Summary
Context and cause slot folding
pyre/pyre-interpreter/src/baseobjspace.rs, pyre/pyre-jit-trace/src/descr.rs, pyre/pyre-jit-trace/src/jitcode_dispatch/specialize.rs
__context__ and __cause__ receive dedicated slots and descriptor indices, fold through their getters on loads, and remain unfurled for stores.

Inline-call dispatch guards

Layer / File(s) Summary
Concrete argument class-guard contract
pyre/pyre-jit-trace/src/jitcode_dispatch/mod.rs, pyre/pyre-jit-trace/src/jitcode_dispatch/inline_call.rs
ArgClassGuard carries the operand, concrete argument, and reflected type through operator inlining call sites.
Inline eligibility and guard specialization
pyre/pyre-jit-trace/src/jitcode_dispatch/inline_call.rs
Resolved user-call inlining guards physical ob_type and optional w_class values, rejects overrides with nested calls, and declines tagged integer operands.

Dominance-based loop-header analysis

Layer / File(s) Summary
Successor graph and dominance classification
pyre/pyre-jit/src/jit/codewriter.rs
Loop-header detection uses decoded fallthrough, jump, and exception-handler successors with reachability-based dominance classification.
Exception-aware loop tracing and coverage
pyre/pyre-jit/src/eval.rs, pyre/pyre-jit/src/jit/codewriter.rs
FOR_ITER admits in-frame exception-handling bytecodes, with tests covering handler rejoin jumps and loop backedges within try/except bodies.

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

Possibly related PRs

Suggested reviewers: lifthrasiir

Poem

I’m a rabbit with slots in my den,
Context and cause now hop in.
Guards watch each call,
Dominance maps all,
And loop headers spring forth again.

🚥 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 summarizes the main exception-JIT performance changes, especially attribute folding and loop-header dominance.
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 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.

@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: 8cfbfa83c3

ℹ️ 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 +438 to +440
if d.opname.starts_with("residual_call")
&& residual_call_helper_kind_in_body(body_code, &d, callee_descr_refs)
== Some(majit_ir::PyreHelperKind::CallFn)

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 Decline keyword/star nested calls too

For an exception __str__/__repr__ override like return helper(x=1) or return helper(*args), the body is still straight-line but the nested Python call is lowered as PyreHelperKind::CallKw/CallFunctionEx (checked in pyre/pyre-jit/src/jit/flatten.rs's call_kw and call_function_ex lowering), not CallFn. This check lets those through to the bounded inline path even though the surrounding comment says nested Python calls need to stay residual, so the sub-walk can still hit the same mid-recording abort/trace discard this commit is trying to avoid.

Useful? React with 👍 / 👎.

@github-actions

github-actions Bot commented Jul 20, 2026 •

Copy link
Copy Markdown

🤖 Codex parity review

Static analysis of this diff vs the local RPython/PyPy sources (commit 7620f6f).
Updated: 2026-07-21T06:22:31.799Z

Files in the reviewed diff
pyre/pyre-interpreter/src/baseobjspace.rs
pyre/pyre-jit-trace/src/descr.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/specialize.rs
pyre/pyre-jit/src/eval.rs
pyre/pyre-jit/src/jit/codewriter.rs

1. Regressions to PyPy parity introduced by this patch

  • pyre/pyre-jit/src/eval.rs:4890 ↔ pypy/interpreter/pyopcode.py:72: for_iter_frame_has_raising_named_handler treats only syntactic RAISE_VARARGS as a raising handler body. A handler such as except E as e: f() can raise through CALL, which PyPy routes through its general OperationError dispatch into the compiler-generated named-handler cleanup. The new allow-list admits that frame (eval.rs:4669-4678), while this detector returns false, reintroducing the bridge/last_exc_value failure that main avoided by declining exception-handling FOR_ITER bodies.

2. Other mismatches introduced by this patch

None.

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

None.

4. Structural adaptations

  • pyre/pyre-interpreter/src/baseobjspace.rs:8511 ↔ pypy/module/exceptions/interp_exceptions.py:163: folding populated __cause__/__context__ slots is a Rust JIT optimization over PyPy’s descriptor getters. Declining stores preserves PyPy’s validation and __cause__’s suppress_context side effect.
  • pyre/pyre-jit-trace/src/jitcode_dispatch/inline_call.rs:1553 ↔ pypy/interpreter/pyopcode.py:1395: guarding Rust object layout (ob_type/w_class) and declining tagged immediates adapts PyPy object identity/type dispatch to Pyre’s tagged-value representation.
  • pyre/pyre-jit/src/jit/codewriter.rs:13890 ↔ pypy/module/pypyjit/interp_jit.py:101: deriving semantic loop headers from a CPython-compatible 3.14 bytecode CFG, including exception-table edges, is required because PyPy receives explicit backward-jump can_enter_jit calls rather than reconstructing them from bytecode.

@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: 98ed9367b9

ℹ️ 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 +13907 to +13912
Instruction::PopJumpIfFalse { delta }
| Instruction::PopJumpIfTrue { delta }
| Instruction::PopJumpIfNone { delta }
| Instruction::PopJumpIfNotNone { delta }
| Instruction::JumpForward { delta }
| Instruction::ForIter { delta } => Some(delta.get(op_arg).as_usize()),

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 Include SEND in dominance successors

When this new CFG is used for coroutine/generator bytecode, SEND is a real conditional forward branch (execute_send jumps via jump_target_forward_from_oparg, and liveness already treats Instruction::Send as a successor), but this match only records the fall-through edge. That under-approximates reachability for target_dominates, so a backward cleanup/rejoin jump reachable through the SEND taken edge can be misclassified as a real loop backedge and get a loop_header marker instead of being filtered out.

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

🤖 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/baseobjspace.rs`:
- Line 8465: After updating the interpreter source around ExceptionAttrSlot,
re-extract the corresponding Charon .ullbc files using scripts/extract-llbc.py
before rebuilding the rtyper prepass.

In `@pyre/pyre-jit/src/jit/codewriter.rs`:
- Around line 13889-13948: Update the forward-branch matching in both
code_successors and find_branch_target_pcs to include Instruction::Send { delta
}, using the same delta-based target calculation as the existing forward branch
instructions. Keep the behavior aligned with liveness::target_pc so SEND targets
are recorded as control-flow successors and branch targets.
🪄 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

Run ID: eaeaf3c7-ce3e-4a18-9299-130d241ab032

📥 Commits

Reviewing files that changed from the base of the PR and between b159a2f and 98ed936.

📒 Files selected for processing (6)
  • pyre/pyre-interpreter/src/baseobjspace.rs
  • pyre/pyre-jit-trace/src/descr.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/specialize.rs
  • pyre/pyre-jit/src/jit/codewriter.rs

@@ -8464,6 +8464,8 @@ pub fn object_setattr(obj: PyObjectRef, name: &str, value: PyObjectRef) -> PyRes
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum ExceptionAttrSlot {

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 | 🔵 Trivial | 💤 Low value

Re-extract Charon .ullbc files.

As per coding guidelines, after changing interpreter, object, or JIT source, re-extract the corresponding Charon .ullbc files with scripts/extract-llbc.py before rebuilding the rtyper prepass.

🤖 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/baseobjspace.rs` at line 8465, After updating the
interpreter source around ExceptionAttrSlot, re-extract the corresponding Charon
.ullbc files using scripts/extract-llbc.py before rebuilding the rtyper prepass.

Source: Coding guidelines

Comment on lines +13889 to +13948
/// Control-flow successors of every code-unit index: fall-through (except
/// after an unconditional transfer), forward and backward jump targets, and
/// the exception edge from each protected pc to its handler landing. Built in
/// one sequential decode pass so an `EXTENDED_ARG` prefix folds into the
/// following opcode's argument, matching the edge model of
/// `find_branch_target_pcs`.
fn code_successors(code: &CodeObject) -> Vec<Vec<usize>> {
let num_instrs = code.instructions.len();
let mut succ: Vec<Vec<usize>> = vec![Vec::new(); num_instrs];
let mut scan_state = OpArgState::default();
for pc in 0..num_instrs {
let (instr, op_arg) = scan_state.get(code.instructions[pc]);
if let Some(target) = backward_jump_target(code, pc, instr, op_arg) {
if target < num_instrs {
succ[pc].push(target);
}
}
let forward_delta = match instr {
Instruction::PopJumpIfFalse { delta }
| Instruction::PopJumpIfTrue { delta }
| Instruction::PopJumpIfNone { delta }
| Instruction::PopJumpIfNotNone { delta }
| Instruction::JumpForward { delta }
| Instruction::ForIter { delta } => Some(delta.get(op_arg).as_usize()),
_ => None,
};
if let Some(delta) = forward_delta {
let target = jump_target_forward(code, num_instrs, pc + 1, delta);
if target < num_instrs {
succ[pc].push(target);
}
}
let terminates = matches!(
instr,
Instruction::JumpForward { .. }
| Instruction::JumpBackward { .. }
| Instruction::JumpBackwardNoInterrupt { .. }
| Instruction::ReturnValue
| Instruction::RaiseVarargs { .. }
| Instruction::Reraise { .. }
);
if !terminates {
let fallthrough = pc + 1;
if fallthrough < num_instrs {
succ[pc].push(fallthrough);
}
}
}
for entry in pyre_interpreter::pycode::decode_exceptiontable(&code.exceptiontable) {
let start = entry.start as usize / 2;
let end = (entry.end as usize / 2).min(num_instrs);
let handler = entry.target as usize / 2;
if handler < num_instrs {
for pc in start..end {
succ[pc].push(handler);
}
}
}
succ
}

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 | 🟠 Major | ⚡ Quick win

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
# Confirm Instruction::Send exists with a `delta` field and check whether
# other branch-target helpers in this file (find_branch_target_pcs,
# jump_target_forward) already special-case it, to see if code_successors
# is the only place missing this edge.
rg -n 'Send\s*\{' --type=rust
rg -nP -A3 -B3 '\bfn find_branch_target_pcs\b' pyre/pyre-jit/src/jit/codewriter.rs
rg -n 'Instruction::Send' pyre/pyre-jit/src/jit/codewriter.rs pyre/pyre-jit-trace/src/liveness.rs

Repository: youknowone/pyre

Length of output: 153


🏁 Script executed:

#!/bin/bash
set -euo pipefail

# Map relevant sections before reading them.
ast-grep outline pyre/pyre-jit/src/jit/codewriter.rs --view expanded | sed -n '1,220p'

printf '\n=== codewriter Send / branch-target references ===\n'
rg -n -C 3 'Instruction::Send|find_branch_target_pcs|code_successors|jump_target_forward|backward_jump_is_handler_only_target' pyre/pyre-jit/src/jit/codewriter.rs

printf '\n=== liveness target_pc references ===\n'
rg -n -C 3 'Send\s*\{|target_pc|PopJumpIfFalse|ForIter|JumpBackwardNoInterrupt' pyre/pyre-jit-trace/src/liveness.rs

printf '\n=== file sizes ===\n'
wc -l pyre/pyre-jit/src/jit/codewriter.rs pyre/pyre-jit-trace/src/liveness.rs

Repository: youknowone/pyre

Length of output: 23788


🏁 Script executed:

#!/bin/bash
set -euo pipefail

sed -n '14299,14335p' pyre/pyre-jit/src/jit/codewriter.rs

Repository: youknowone/pyre

Length of output: 1992


Include SEND in the forward-edge scan

code_successors under-approximates control flow here: pyre-jit-trace::liveness::target_pc already treats Instruction::Send { delta } as a forward branch, so omitting it can hide a real bypass path and misclassify a non-header backedge as a semantic loop header. The same arm is also missing from find_branch_target_pcs.

Fix
         let forward_delta = match instr {
             Instruction::PopJumpIfFalse { delta }
             | Instruction::PopJumpIfTrue { delta }
             | Instruction::PopJumpIfNone { delta }
             | Instruction::PopJumpIfNotNone { delta }
             | Instruction::JumpForward { delta }
-            | Instruction::ForIter { delta } => Some(delta.get(op_arg).as_usize()),
+            | Instruction::ForIter { delta }
+            | Instruction::Send { delta } => Some(delta.get(op_arg).as_usize()),
             _ => None,
         };
📝 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
/// Control-flow successors of every code-unit index: fall-through (except
/// after an unconditional transfer), forward and backward jump targets, and
/// the exception edge from each protected pc to its handler landing. Built in
/// one sequential decode pass so an `EXTENDED_ARG` prefix folds into the
/// following opcode's argument, matching the edge model of
/// `find_branch_target_pcs`.
fn code_successors(code: &CodeObject) -> Vec<Vec<usize>> {
let num_instrs = code.instructions.len();
let mut succ: Vec<Vec<usize>> = vec![Vec::new(); num_instrs];
let mut scan_state = OpArgState::default();
for pc in 0..num_instrs {
let (instr, op_arg) = scan_state.get(code.instructions[pc]);
if let Some(target) = backward_jump_target(code, pc, instr, op_arg) {
if target < num_instrs {
succ[pc].push(target);
}
}
let forward_delta = match instr {
Instruction::PopJumpIfFalse { delta }
| Instruction::PopJumpIfTrue { delta }
| Instruction::PopJumpIfNone { delta }
| Instruction::PopJumpIfNotNone { delta }
| Instruction::JumpForward { delta }
| Instruction::ForIter { delta } => Some(delta.get(op_arg).as_usize()),
_ => None,
};
if let Some(delta) = forward_delta {
let target = jump_target_forward(code, num_instrs, pc + 1, delta);
if target < num_instrs {
succ[pc].push(target);
}
}
let terminates = matches!(
instr,
Instruction::JumpForward { .. }
| Instruction::JumpBackward { .. }
| Instruction::JumpBackwardNoInterrupt { .. }
| Instruction::ReturnValue
| Instruction::RaiseVarargs { .. }
| Instruction::Reraise { .. }
);
if !terminates {
let fallthrough = pc + 1;
if fallthrough < num_instrs {
succ[pc].push(fallthrough);
}
}
}
for entry in pyre_interpreter::pycode::decode_exceptiontable(&code.exceptiontable) {
let start = entry.start as usize / 2;
let end = (entry.end as usize / 2).min(num_instrs);
let handler = entry.target as usize / 2;
if handler < num_instrs {
for pc in start..end {
succ[pc].push(handler);
}
}
}
succ
}
let forward_delta = match instr {
Instruction::PopJumpIfFalse { delta }
| Instruction::PopJumpIfTrue { delta }
| Instruction::PopJumpIfNone { delta }
| Instruction::PopJumpIfNotNone { delta }
| Instruction::JumpForward { delta }
| Instruction::ForIter { delta }
| Instruction::Send { delta } => Some(delta.get(op_arg).as_usize()),
_ => None,
};
🤖 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 13889 - 13948, Update the
forward-branch matching in both code_successors and find_branch_target_pcs to
include Instruction::Send { delta }, using the same delta-based target
calculation as the existing forward branch instructions. Keep the behavior
aligned with liveness::target_pc so SEND targets are recorded as control-flow
successors and branch targets.

Source: Coding guidelines

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Actionable comments posted: 3

Caution

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

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

8257-8262: 🎯 Functional Correctness | 🔴 Critical | ⚡ Quick win

Fix take() short-circuiting to ensure bridge_entry_merge_pc is cleared on the first crossing.

Due to Rust's strict left-to-right short-circuit evaluation of &&, the take() call will not execute if ctx.trace_ctx.seen_loop_header_for_jdindex >= 0 or ctx.is_top_level is false. This violates the intent stated in the comment ("take() clears on the first crossing regardless of pc") and leaks the bridge_entry_merge_pc state to subsequent loop crossings, where it could incorrectly skip a valid loop iteration.

Extract the take() evaluation before the conditional to ensure it is cleared unconditionally at the first jit_merge_point.

🐛 Proposed fix
-            if ctx.is_top_level
-                && ctx.trace_ctx.seen_loop_header_for_jdindex < 0
-                && ctx.fbw_mode.bridge_entry_merge_pc.take() == Some(next_instr)
-            {
-                return Ok((DispatchOutcome::Continue, op.next_pc));
-            }
+            let bridge_entry = ctx.fbw_mode.bridge_entry_merge_pc.take();
+            if ctx.is_top_level
+                && ctx.trace_ctx.seen_loop_header_for_jdindex < 0
+                && bridge_entry == Some(next_instr)
+            {
+                return Ok((DispatchOutcome::Continue, op.next_pc));
+            }
🤖 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/mod.rs` around lines 8257 - 8262,
Extract the ctx.fbw_mode.bridge_entry_merge_pc.take() result before the
conditional in the dispatch logic so it executes unconditionally at the first
crossing. Use the captured value in the existing top-level and loop-header
checks, preserving the current Continue outcome and next-instruction behavior
when it matches next_instr.
🤖 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 1561-1576: Route all direct pointer-to-i64 conversions through
usize for consistency and portability. Update physical_type and w_class_const in
the shown guard logic, and apply the same conversion pattern to other direct
pointer casts such as method as i64 in the affected functions.
- Around line 438-443: Update the residual_call filter in the inline-call
dispatch logic to also return true for PyreHelperKind::CallFunctionEx and
PyreHelperKind::CallKw, alongside CallFn. Ensure all three Python call variants
use the residual dispatch path and are excluded from nested inlining.

In `@pyre/pyre-jit/src/jit/codewriter.rs`:
- Around line 13978-13995: Update backward_jump_is_handler_only_target to accept
a reference to a precomputed successor graph and use it for target_dominates
instead of calling code_successors. In CodeWriter::transform_graph_to_jitcode,
compute the graph once before the bytecode iteration and pass &succ to both
backward_jump_is_handler_only_target call sites.

---

Outside diff comments:
In `@pyre/pyre-jit-trace/src/jitcode_dispatch/mod.rs`:
- Around line 8257-8262: Extract the ctx.fbw_mode.bridge_entry_merge_pc.take()
result before the conditional in the dispatch logic so it executes
unconditionally at the first crossing. Use the captured value in the existing
top-level and loop-header checks, preserving the current Continue outcome and
next-instruction behavior when it matches next_instr.
🪄 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

Run ID: c28a8ac8-bbfa-4f17-8a1a-f0d8753f1cfe

📥 Commits

Reviewing files that changed from the base of the PR and between 98ed936 and 0747916.

📒 Files selected for processing (7)
  • pyre/pyre-interpreter/src/baseobjspace.rs
  • pyre/pyre-jit-trace/src/descr.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/specialize.rs
  • pyre/pyre-jit/src/eval.rs
  • pyre/pyre-jit/src/jit/codewriter.rs

Comment on lines +438 to +443
if d.opname.starts_with("residual_call")
&& residual_call_helper_kind_in_body(body_code, &d, callee_descr_refs)
== Some(majit_ir::PyreHelperKind::CallFn)
{
return true;
}

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 | 🟠 Major | ⚡ Quick win

Reject CallFunctionEx and CallKw alongside CallFn to completely block nested Python calls.

The residual_call filter only rejects CallFn, but CallFunctionEx and CallKw are also valid Python calls that will trigger the exact same unsupported multi-frame guard-resume snapshot you are avoiding here. Include them to ensure all nested Python calls fall back to the residual dispatch path.

🐛 Proposed fix to exhaustively cover Python call variants
-        if d.opname.starts_with("residual_call")
-            && residual_call_helper_kind_in_body(body_code, &d, callee_descr_refs)
-                == Some(majit_ir::PyreHelperKind::CallFn)
-        {
-            return true;
-        }
+        if d.opname.starts_with("residual_call") {
+            let kind = residual_call_helper_kind_in_body(body_code, &d, callee_descr_refs);
+            if matches!(
+                kind,
+                Some(
+                    majit_ir::PyreHelperKind::CallFn
+                        | majit_ir::PyreHelperKind::CallFunctionEx
+                        | majit_ir::PyreHelperKind::CallKw
+                )
+            ) {
+                return true;
+            }
+        }
📝 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
if d.opname.starts_with("residual_call")
&& residual_call_helper_kind_in_body(body_code, &d, callee_descr_refs)
== Some(majit_ir::PyreHelperKind::CallFn)
{
return true;
}
if d.opname.starts_with("residual_call") {
let kind = residual_call_helper_kind_in_body(body_code, &d, callee_descr_refs);
if matches!(
kind,
Some(
majit_ir::PyreHelperKind::CallFn
| majit_ir::PyreHelperKind::CallFunctionEx
| majit_ir::PyreHelperKind::CallKw
)
) {
return 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-trace/src/jitcode_dispatch/inline_call.rs` around lines 438 -
443, Update the residual_call filter in the inline-call dispatch logic to also
return true for PyreHelperKind::CallFunctionEx and PyreHelperKind::CallKw,
alongside CallFn. Ensure all three Python call variants use the residual
dispatch path and are excluded from nested inlining.

Comment on lines +1561 to +1576
let physical_type = unsafe { (*concrete_arg).ob_type } as i64;
walker_guard_class(ctx, op.pc, arg, physical_type)?;
// A builtin subclass / user instance shares its `ob_type` with the base
// layout, so `ob_type` alone does not pin the class the reflected-op
// decline (`w_type_issubtype`) was computed against. Guard the live
// `w_class` too so an arg of a distinct class deopts. Singletons with a
// null `w_class` (`Ellipsis`/`NotImplemented`) are pinned exactly by
// `ob_type`, and guarding their null slot against `w_type` would itself
// be provably false — so guard `w_class` only when it is populated.
if !unsafe { (*concrete_arg).w_class }.is_null() {
let live_w_class = crate::state::opimpl_getfield_gc_r(
ctx.trace_ctx,
arg,
crate::descr::w_class_descr(),
);
let w_class_const = ctx.trace_ctx.const_ref(w_type as i64);

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 | 🔵 Trivial | 💤 Low value

Consistently cast pointers through usize before i64.

For consistency with line 1591 (callable_guard_value as usize as i64), consider routing direct pointer-to-i64 casts through usize.

        let physical_type = unsafe { (*concrete_arg).ob_type } as usize as i64;
// ...
            let w_class_const = ctx.trace_ctx.const_ref(w_type as usize as i64);

(This also applies to other instances like method as i64 on lines 2611 and 2759).

🤖 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 1561 -
1576, Route all direct pointer-to-i64 conversions through usize for consistency
and portability. Update physical_type and w_class_const in the shown guard
logic, and apply the same conversion pattern to other direct pointer casts such
as method as i64 in the affected functions.

Comment thread pyre/pyre-jit/src/jit/codewriter.rs Resolved
The inlined operator's non-receiver argument guard passed the Python
W_TypeObject to walker_guard_class, which emits a GuardClass comparing the
object's physical ob_type. For a boxed builtin argument whose ob_type the
optimizer already knows, that guard is provably false and the loop is
discarded (InvalidLoop), so the operator-inline loop never compiles.

Emit the GuardClass against the argument's physical ob_type. When the
argument carries a populated w_class (a builtin subclass or user instance,
whose ob_type is shared with the base layout), additionally guard the live
w_class against the recorded type so an argument of a distinct class deopts,
preserving reflected-operator priority. Arguments with a null w_class
(Ellipsis/NotImplemented) are pinned by ob_type alone.

Assisted-by: Claude
The exception __str__/__repr__ override inline admitted bodies whose only
disqualifier was a branch: exception_string_override_straight_line rejected
goto_if_not/switch but not a nested call.  A body such as
`return "c:" + repr(self.args)` therefore entered the bounded inline, and
recording the nested call's own residual plus its guard-resume snapshot
aborted mid-trace with LoopBearingCalleeInlineUnsupported, discarding the
whole loop (loops_compiled=0) rather than declining.

Add exception_string_override_has_nested_call and gate the inline on it before
any IR is emitted: a CallFn residual (or a cond_call/call_assembler/inline_call)
keeps the override on the residual dispatch path, where the interpreter owns
the nested frame.  Str-concat and attribute reads (BinaryOp/getfield) still
inline.

Assisted-by: Claude
Extend `exception_attr_slot_fold` to resolve `__context__` / `__cause__`
loads to a direct `W_BaseException.w_context` / `w_cause` slot read, the
same guarded-inline path already used for `args` / `code` / `errno`. A
LOAD_ATTR of these on an exception now lowers to a class + version-tag
guarded `GetfieldGcR` + `GuardNonnull` instead of a residual
getset-descriptor `__get__` call.

Loads only: stores decline, because `descr_setcause`
(interp_exceptions.py:166-174) also sets `suppress_context = True` and
`descr_setcontext` (:183-190) type-validates, so a raw slot write would
drop those effects. A null slot declines and falls back to the residual
path (which returns None), matching the getter.

Assisted-by: Claude
backward_jump_is_handler_only_target decided whether a JUMP_BACKWARD is a
loop backedge or a handler return by checking for an exception handler
landing between the target and the source and scanning only the code
before it for a competing backedge. A while/for loop whose body contains
a try/except lays its own backedge after the handler block, so that scan
never saw it and the genuine loop header was excluded from
find_loop_header_pcs; eval_loop_jit then skipped JIT entry for the frame.

Decide by dominance instead: build a control-flow graph (fall-through,
forward/backward jumps, exception-table edges) and treat a backward jump
as a loop backedge iff its target dominates its source. A handler return
(a break/continue laid out inside an except handler) targets a merge the
handler does not pass through, so it stays excluded. find_loop_header_pcs
builds the successor graph once and reuses it.

Replace handler_break_backward_jump_is_not_a_loop_header (its example had
no handler-only backward jump and asserted the for-loop header be
excluded) with handler_rejoin_backward_jump_is_not_a_loop_header, and add
loop_with_try_except_body_keeps_its_loop_header.

Assisted-by: Claude
try_walker_inline_user_binop / _compareop dereference the concrete
operand's ob_type and w_class to pin the argument's physical class. A
tagged immediate has no heap type object, so those derefs would fault on
the immediate under CAN_BE_TAGGED. Decline the inline when either operand
is a tagged int, before the derefs, matching typedef::r#type's tagged
guard. Inert behind CAN_BE_TAGGED (default false); the residual dispatch
already handles a tagged operand.

Assisted-by: Claude
for_iter_body_op_is_jit_safe now admits RAISE_VARARGS, PUSH_EXC_INFO,
CHECK_EXC_MATCH, POP_EXCEPT and RERAISE, so for_iter_bodies_all_jit_safe
no longer declines a for-loop whose body raises or handles an exception
in-frame. These opcodes already trace in the while-loop form; the
finally-duplicated FOR_ITER hazard stays gated by
for_iter_frame_is_finally_duplicated. Adds a positive gate test.

Assisted-by: Claude
`except ... as name:` whose handler body itself raises reaches the
exceptional cleanup tail `STORE_FAST name; DELETE_FAST name; RERAISE 1`
on every visit. That tail lowers to a `last_exc_value` jitcode op which a
bridge walk cannot answer, so the walk aborts with
`LastExcValueWithoutActiveException`; the abort is not permanent, so the
loop is retraced and re-aborts for its whole run. With a `FOR_ITER` in the
frame an abort can land while an item is in flight and that iteration is
dropped, leaving the result short by exactly one handler visit per abort.

Add `for_iter_frame_has_raising_named_handler` and OR it into the
`FOR_ITER` frame decline beside `for_iter_frame_is_finally_duplicated`.
The predicate fires only when a handler binds a name and a RAISE_VARARGS
lies between that binding and the cleanup that clears it, so
`except E:` handlers, non-raising `as` handlers, and a raise sitting in
the `try` block keep tracing.

Assisted-by: Claude
@youknowone

Copy link
Copy Markdown
Collaborator Author

Rebased onto origin/main (#692) + a correctness fix

Force-pushed 0747916e5c3 → 7620f6fd27b, rebased onto e7fd9b8f852 (#692), no conflicts.

New commit: jit: decline FOR_ITER frames with a raising named handler

Fuzzing same-frame exception shapes surfaced a miscompile introduced by this branch's own jit: allow exception opcodes in FOR_ITER loop bodies, which CI did not catch:

for i in range(N):
    for j in range(M):
        try:
            try:
                if j > 2: raise TypeError('x')
            except TypeError as e:
                raise e            # named reraise
        except TypeError:
            acc += 9

Before that commit the frame was declined (interpreted, correct). After it, the frame compiles and the result is short by exactly one handler visit per trace abort (measured: loops_aborted=54, deficit 486 = 54 × 9); some variants ran to a 60s timeout. A/B confirmed it: commenting the five added opcodes out restores loops_compiled=0 and the correct answer.

Mechanism. except ... as name: emits an exceptional cleanup tail STORE_FAST name; DELETE_FAST name; RERAISE 1, reached only when the handler body itself raises. That tail lowers to a last_exc_value jitcode op which a bridge walk cannot answer, so the walk aborts with LastExcValueWithoutActiveException (abort census is 1:1 with loops_aborted, and scales linearly with iteration count — 13/53/213 at N=8k/32k/128k). With a FOR_ITER in the frame an abort can land while an item is in flight, and that iteration is dropped. A bare raise emits no such tail and never aborts — the clean discriminator.

Fix. A frame-level gate beside for_iter_frame_is_finally_duplicated, firing only when a handler binds a name and a RAISE_VARARGS lies between that binding and the cleanup that clears it. A body-range predicate cannot work: for-loop handlers are emitted in the cold section after the loop, outside the scanned FOR_ITER body range. The cleanup tail alone is not a usable signal either — it is emitted for every except as e:, raising or not.

Verified: 6/6 previously-miscompiling programs correct; 19/21 for-loop exception shapes still compile (the only declines are the bug shape and a pre-existing unrelated one); 3 new unit tests.

CI failure triage — none of the three is caused by this branch

job verdict evidence
cargo test (ubuntu-24.04) pre-existing on main mapdict.rs cache_attrs is a RefCell on process-global interned map nodes; libtest runs each test on its own thread, so two threads writing attributes on the same global type race. Reproduced locally at --test-threads=32 (6/40). main's own run fails the same way across 10 tests. No commit here touches mapdict.rs, and pyre-interpreter does not even link pyre-jit/pyre-jit-trace.
pyre/check.py (ubuntu-24.04) pre-existing on main FAIL wasm synth/delete_negative_open_slice_hot wrong output, byte-identical (1 failed, 239 passed) on the merge-base commit this PR sits on. dynasm/cranelift are 241/241 in the same job.
pyre/check.py (windows-latest) timer quantization, not a regression check.py subtracts a measured per-interpreter startup; Windows quantizes it to 15.625 ms. raise_catch raw times are identical on main and here (pypy 0.19s / dynasm 0.23s) yet the ratio reads 1.0x vs 2.4x purely from that subtraction. raise_catch_loop.py is a while loop with no as e and no FOR_ITER, so the new gate cannot touch it. The merge-base run fails this job too.

Known follow-ups (not in this PR)

  • The same shape in a while frame stays correct but is Θ(N²) — 0.98s/4.05s/16.21s at N=200/400/800 vs a linear interpreter. The compiled trace leaves ExecutionContext.sys_exc_value stale after the handler exits, so the __context__ chain grows without bound and 92.5% of runtime lands in _break_context_cycle.
  • Root cause of the abort itself: reads_last_exc_before_next_catch is a flat byte-stream scan that does not follow goto/L and does not stop at a block terminator, so it can read a physically-adjacent unrelated block's catch_exception and clear an exception the real successor still needs. Fixing that would remove the aborts outright and make this gate unnecessary.
  • LIST_EXTEND is missing from the FOR_ITER body allow-list, so a ≥3-element constant list literal or any [*a] declines the whole frame (unrelated to exceptions).

— commented by Claude

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 7620f6fd27

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment thread pyre/pyre-jit/src/eval.rs
{
break;
}
Some((I::RaiseVarargs { .. }, _)) => return true,

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 Decline named handlers with raising calls

This check only treats an explicit RAISE_VARARGS before the DELETE_FAST cleanup as hazardous. The same exceptional cleanup tail is reached when any admitted handler opcode raises, e.g. except ValueError as e: may_raise(e) leaves only a Call in the handler body, and Call/LoadAttr are allow-listed by the FOR_ITER body scan. Those frames now bypass CurrentFrameOnly even though they can still hit the last_exc_value cleanup abort described above, so a FOR_ITER item can be dropped when the handler call raises.

Useful? React with 👍 / 👎.

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