Skip to content

Exception-path JIT: fold exception construction/attrs/str, lower DELETE_FAST/DEREF - #562

Merged
youknowone merged 14 commits into
mainfrom
perf-exc
Jul 17, 2026
Merged

youknowone merged 14 commits into
mainfrom
perf-exc

Conversation

@youknowone

@youknowone youknowone commented Jul 15, 2026 •

Copy link
Copy Markdown
Collaborator

Removes a JIT trace abort on the exception hot path, and fixes an exception-reload
defect that kept two exception loops interpreter-only.

1. DELETE_DEREF no longer abort_permanent

When a nested function closes over an except E as e: name, the local becomes a
cell and the handler's implicit del e compiles to DELETE_DEREF. That opcode
reached abort_permanent, and loop_body_has_abort_permanent is a static scan —
one occurrence declines the whole loop.

It is now lowered by composing existing graph ops — no new residual:
load_deref_value (raise-if-unbound) + store_deref_value(cell, NULL) (clears the
cell contents, keeps the cell in the slot) + store_local_value. The live
interpreter impl is delete_deref. Added a guard bench
exception_as_cell_cleanup.py, which compiles (loops_compiled=1,
bridges_compiled=2) and matches the PYRE_NO_JIT=1 oracle.

2. Cleanup-landing exception reloaded from the frame's stack slot

A Copy handler landing reached by an explicit raise seeded its top-of-stack
exception with a last_exc_value read. A residual call between the raise landing
and that read clears the metainterp exception transient (execute_varargs clears
before a call that may itself raise), so the read found no value and the walk
aborted with LastExcValueWithoutActiveException.

Those landings now reload the propagated exception from the frame's durable top
stack slot via getarrayitem_vable_r — catch dispatch has already stored it there.
Reraise-entered landings keep the last_exc_value read, which still carries the
live payload from the exception edge. The per-thread current-exception slot is
deliberately not the source: at this landing it can still name the older
exception whose handler raised this one, which would mislink __context__.

Results

  • exception_bare_reraise_restore and exception_context_chain_inhandler now
    compile (loops_compiled 0 → 1), output byte-identical to the oracle.
  • Wins: swap_except_return_resume 2.79 → 0.06s, finally_bare_raise 0.86 → 0.05s,
    exception_oserror_fields 1.12 → 0.51s.

Withheld from this PR

DELETE_FAST → #586. The lowering in the earlier revision of this PR
miscompiled: it dropped the arm's ptr_iszero guard and emitted the slot clear
unconditionally after the can-raise load_fast_check, breaking the
pyopcode.py:998 shape where the raise prevents the clear. del x; del x in a
hot loop then swallowed exactly one UnboundLocalError (oracle 200000, JIT 199999
— loss is exactly 1 at every N, so it is a trace/compile transition artifact).
origin/main is correct here because it declines the loop. Caught by the Codex
parity review; full repro and characterisation in #586.

Exit-frame raise FINISH → #581. It made
synth/exception_inlined_callee_caught compile a bridge for the first time, which
exposed a pre-existing GC hole and crashed wasm. Root cause is not in that commit:
the wasm backend has no gcreftracer / LoadFromGcTable path and bakes
OpRef::ConstPtr(GcRef) into emitted code as a raw address immediate, where the GC
can neither find nor forward it — dynasm and cranelift both run
remove_constptrs_in → GcTable::from_gcrefs (RPython's GCREFTRACER).

Gates

check.py dynasm and cranelift; lib tests pyre-jit, pyre-jit-trace,
majit-metainterp. wasm's one failing fixture,
synth/residual_raise_except_resume, fails identically on origin/main
(0.21s vs pypy 0.01s = 21.4x): #576 added # pyre-check: max-pypy-ratio=10 to it
and the wasm backend does not meet it, while unannotated fixtures in the same sweep
run at 13–33x and pass ungated. This branch adds no new wasm failure.

🤖 Generated with Claude Code

@coderabbitai

coderabbitai Bot commented Jul 15, 2026 •

Copy link
Copy Markdown

Warning

Review limit reached

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

Next review available in: 13 minutes

Enable usage-based reviews in Billing to review now. Otherwise, wait until the next included review is available.
You're only billed for reviews past your plan's rate limits ($0.25/file).

How can I continue?

After more reviews become available, a review can be triggered using the @coderabbitai review command as a PR comment. Alternatively, push new commits to this PR.

To avoid repeated limits, reduce automatic review volume by pausing incremental auto-reviews earlier, using label-based review opt-in, excluding WIP or generated PR titles, or requesting reviews manually when the PR is ready. If your team needs uninterrupted high-volume reviews, an organization admin can enable usage-based reviews.

How do review limits work?

CodeRabbit enforces per-developer PR review limits for each organization. Most developers receive the normal plan review availability.

For paid Pro and Pro+ PR reviews, CodeRabbit uses adaptive limits for sustained high-volume activity. When a developer's recent PR review activity reaches the 95th percentile or higher among CodeRabbit users, additional reviews become available more gradually as earlier reviews age out of the rolling window.

Please refer docs for additional details.

Review details
⚙️ Run configuration

Configuration used: Organization UI

Review profile: ASSERTIVE

Plan: Pro

Run ID: a9fe4adc-a693-4ef4-ac4e-ed3a31c4e19c

📥 Commits

Reviewing files that changed from the base of the PR and between 189ee7d and a601a48.

📒 Files selected for processing (17)
  • majit/majit-ir/src/resumedata.rs
  • majit/majit-translate/src/tool/algo/unionfind.rs
  • pyre/bench/synth/exception_as_cell_cleanup.py
  • pyre/pyre-interpreter/src/baseobjspace.rs
  • pyre/pyre-interpreter/src/builtins.rs
  • pyre/pyre-interpreter/src/call.rs
  • pyre/pyre-interpreter/src/display.rs
  • pyre/pyre-interpreter/src/typedef.rs
  • pyre/pyre-jit-trace/src/descr.rs
  • pyre/pyre-jit-trace/src/helpers.rs
  • pyre/pyre-jit-trace/src/jitcode_dispatch.rs
  • pyre/pyre-jit-trace/src/state.rs
  • pyre/pyre-jit/src/call_jit.rs
  • pyre/pyre-jit/src/jit/codewriter.rs
  • pyre/pyre-jit/src/jit/cpu.rs
  • pyre/pyre-jit/src/jit/flatten.rs
  • pyre/pyre-object/src/interp_exceptions.rs
✨ 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: 5aaf76f9a3

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

// disposition `Raise` so the `Terminate` consumer builds a
// `TraceAction::Finish { exit_with_exception: true }`.
FBW_FINISH_PAYLOAD.with(|c| c.set(Some((exc, Type::Ref))));
fbw_finish_raise_set(exc_concrete);

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P1 Badge Avoid stashing non-concrete raises

When the exception's concrete shadow is unavailable (for example an unseeded ref register on a bridge/resume path, where read_ref_reg_concrete returns ConcreteValue::Null), this now records Some(FinishConcrete::Raise(Null)). The no-replay epilogue only checks that the concrete stash is Some, so it can keep this value and the later eval.rs/call_jit.rs consumers hit unreachable!("FinishConcrete::Raise must hold a concrete Ref") instead of deopting or replaying. Keep the symbolic exception FINISH payload separate from the concrete no-replay result, or only stash a concrete raise when it is a non-null Ref.

Useful? React with 👍 / 👎.

Comment thread pyre/pyre-jit-trace/src/trace.rs Outdated
Comment on lines +2665 to +2668
if matches!(
crate::jitcode_dispatch::fbw_finish_concrete_peek(),
Some(crate::jitcode_dispatch::FinishConcrete::Raise(_))
) {

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P1 Badge Preserve raise disposition after no-replay is declined

When an uncaught raise follows an unjournaled effect, run_perfn_walk clears FBW_FINISH_CONCRETE before this Terminate mapping but leaves FBW_FINISH_PAYLOAD intact for replay. Because this new block uses the concrete no-replay stash as the only Raise discriminator, that case falls through to the normal-return payload handling below and compiles FINISH(exc, done_with_this_frame_descr_ref) instead of exit_frame_with_exception_descr_ref, making the replayed raising trace return the exception object. The raise/return disposition needs to be stored independently of the concrete no-replay value.

Useful? React with 👍 / 👎.

@github-actions

github-actions Bot commented Jul 15, 2026 •

Copy link
Copy Markdown

🤖 Codex parity review

Static analysis of this diff vs the local RPython/PyPy sources (commit a601a48).
Updated: 2026-07-17T02:57:26.368Z

Files in the reviewed diff
majit/majit-ir/src/resumedata.rs
majit/majit-translate/src/tool/algo/unionfind.rs
pyre/bench/synth/exception_as_cell_cleanup.py
pyre/pyre-interpreter/src/baseobjspace.rs
pyre/pyre-interpreter/src/builtins.rs
pyre/pyre-interpreter/src/call.rs
pyre/pyre-interpreter/src/display.rs
pyre/pyre-interpreter/src/typedef.rs
pyre/pyre-jit-trace/src/descr.rs
pyre/pyre-jit-trace/src/helpers.rs
pyre/pyre-jit-trace/src/jitcode_dispatch.rs
pyre/pyre-jit-trace/src/state.rs
pyre/pyre-jit/src/call_jit.rs
pyre/pyre-jit/src/jit/codewriter.rs
pyre/pyre-jit/src/jit/cpu.rs
pyre/pyre-jit/src/jit/flatten.rs
pyre/pyre-object/src/interp_exceptions.rs

1. Regressions to PyPy parity introduced by this patch

None.

2. Other mismatches introduced by this patch

None.

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

  • majit/majit-translate/src/tool/algo/unionfind.rs:118 ↔ rpython/tool/algo/unionfind.py:14 — Rust get returns None for an absent key; PyPy __getitem__ raises KeyError.

  • pyre/pyre-object/src/interp_exceptions.rs:643 ↔ pypy/module/exceptions/interp_exceptions.py:123 — the Rust getter accepts legacy tuple-backed or invalid args_w storage and silently converts invalid storage to (), while PyPy stores args_w as an RPython list.

4. Structural adaptations

  • majit/majit-translate/src/tool/algo/unionfind.rs:13 ↔ rpython/tool/algo/unionfind.py:45 — Rust separates the returned representative from mutable root info (find/union return (bool, K)); PyPy returns (new_root, representative, info). This is a borrow-lifetime adaptation, not a regression.

  • majit/majit-translate/src/tool/algo/unionfind.rs:65 ↔ rpython/tool/algo/unionfind.py:9 — the patch changes private weight/root_info storage from insertion-ordered IndexMap to HashMap; externally observable keys() and infos() ordering remains derived from ordered link_to_parent, matching PyPy’s dictionary iteration.

  • pyre/pyre-jit/src/jit/codewriter.rs:11595 ↔ pypy/interpreter/pyopcode.py:597 — DELETE_DEREF is represented through the CPython-compatible compiler instruction stream and lowered into Rust residual helpers; it preserves PyPy’s “read/check, then clear” behavior.

  • pyre/pyre-jit-trace/src/state.rs:6258 ↔ rpython/jit/metainterp/resume.py:993 — bridge pending fields are decoded into Rust trace-state seeds rather than applied directly to a PyPy blackhole frame. This is required by Pyre’s separate bridge-walk/runtime model.

@youknowone

Copy link
Copy Markdown
Collaborator Author

Rebased onto origin/main (49ec51c3b9c) and resolved both P1 review findings in 7ddf9729537.

Both findings traced to one root: the disposition was conflated with the no-replay value

FBW_FINISH_PAYLOAD (the symbolic OpRef the compile records) and FBW_FINISH_CONCRETE (the concrete value the no-replay portal exit returns) have different lifetimes. 879676a211a used the concrete stash as the only raise-vs-return discriminator, which is what both findings caught:

  1. "Avoid stashing non-concrete raises" — confirmed. fbw_finish_raise_set(exc_concrete) recorded Raise(Null) when the concrete shadow was absent, and the no-replay epilogue only checks .is_some(), so call_jit.rs / eval.rs reached unreachable!("FinishConcrete::Raise must hold a concrete Ref"). Notably the two pre-existing !fbw_raise_enabled() sites already had the ConcreteValue::Ref(p) + !p.is_null() guard — 879676a211a simply dropped it at the new site. The guard now lives inside fbw_finish_raise_set, so the invariant is enforced at the single producer and those two callers collapse to a bare call.

  2. "Preserve raise disposition after no-replay is declined" — confirmed, and it was a real miscompile. run_perfn_walk clears the concrete stash when the shortcut is declined while the payload stays live, so a declined raising walk built FINISH(exc, done_with_this_frame_descr_ref) — the trace returned the exception object.

The fix went further than "a separate disposition channel"

The first attempt added a sibling FBW_FINISH_DISPOSITION cell. That was wrong: only 1 of the 3 payload writers would have written it, making it a one-way latch whose correctness rested solely on the shared reset — and the previous design at least self-corrected (fbw_finish_concrete_set overwrote Raise with Return). It is also the parallel store AGENTS.md rule 2 forbids by name.

So the disposition is carried in the payload: Option<(OpRef, Type, FinishDisposition)>. Every writer names its disposition, drift is unrepresentable, and a Raise payload without a value cannot exist — which retires the Terminate::RaiseNoFinishPayload / P2Framestack::OuterRaiseNoFinishPayload census keys. This also matches how the sibling channel and upstream both work: FinishConcrete fuses disposition and value in one enum, and finishframe / finishframe_exception raise DoneWithThisFrameRef / ExitFrameWithExceptionRef (pyjitpl.py:2513-2561, jitexc.py) — the disposition rides with its value, never beside it.

Auditing that fix surfaced a third defect in the same commit: in drive_outer_continuation_and_map both return arms retarget the root green key before emitting Finish, but the raise arm 879676a211a added did not — so the outer frame's exit-with-exception loop was attached/redirected under the bridge's trace-start key (finish_and_compile reads ctx.green_key for compiled_loops.swap_remove + attach_procedure_with_redirect). The retarget is now hoisted once to the top of the Terminate arm, covering all three outcomes.

⚠️ The DELETE_FAST arm intentionally overrides merged upstream #533

#533 landed its own DeleteFast walker lowering gated on fbw_delete_fast_enabled(), but it still emits abort_permanent (when local_value_at(idx).is_none(), and as the unbound-path block). loop_body_has_abort_permanent is a static scan, so it declines the whole loop — every except X as e (implicit del e → DELETE_FAST) stays comp=0. A/B proven: skipping our commit regressed exception_context_chain_inhandler loops 1→0, abrt 0→5 (FullBodyWalk::LoopBodyAbortPermanent). Ours is the orthodox port (load_fast_check = raise-if-unbound + clear, per pyopcode.py:998-1004; no trace abort, no env flag), so the rebase keeps our arm and deletes the now-orphaned gate. Flagging it explicitly since it replaces already-merged code.

Verification

  • check.py cranelift 189/189; dynasm 188/189 — the single failure is raise_catch at 0.17s > pypy 0.11s ×1.5, a perf-margin gate, not correctness. A/B against this branch's own base shows the change is not the cause (with: median 0.172s, min 0.166; without: median 0.202s, min 0.166 — the gate is 0.165s, so both sides straddle it).
  • Exception benches on both backends, JIT output byte-identical to the PYRE_JIT=0 oracle: exception_bare_reraise_restore (15000 15000), exception_context_chain_inhandler (30000, so the __context__ chain is intact), exception_as_cell_cleanup (90000), exceptions (1125000), exception_args_virtual (1140629 1954). Compile stats hold (exceptions: brdg=1, abrt=5, gf=200).
  • Units: pyre-jit-trace 263, pyre-jit 306, majit-metainterp 1399.

— 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: 7ddf972953

ℹ️ 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 +11420 to +11424
let result_value = emit_graph_op_with_result(
&mut graph,
&current_block.block(),
"store_deref_value",
vec![cell_value.into(), super::flow::Constant::none().into()],

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 Anchor DELETE_DEREF catches at the unbound check

When DELETE_DEREF is inside a try and the cell is empty, the preceding load_deref_value residual raises the NameError, but this added store_deref_value residual is also lowered as a Plain/can-raise residual. The flattening path anchors a block's catch_exception to the last can-raise residual, so the handler is placed after this store call instead of next to the load_deref_value guard; an unbound-cell del x in an enclosing except NameError path will therefore escape instead of being caught. Split before the store or make the store helper cannot-raise so the catch stays attached to the unbound check.

Useful? React with 👍 / 👎.

@youknowone youknowone changed the title Exception-path JIT: lower DELETE_FAST/DELETE_DEREF + exit-frame raise FINISH Exception-path JIT: lower DELETE_FAST/DELETE_DEREF + cleanup-landing exc reload Jul 16, 2026
@youknowone

Copy link
Copy Markdown
Collaborator Author

Force-pushed a reduced stack, rebased onto 6552f3cd55. The exit-frame raise
FINISH commit and its review follow-up are withheld; what remains is
DELETE_FAST/DELETE_DEREF plus the cleanup-landing exc reload.

Why the wasm job was red. synth/exception_inlined_callee_caught aborted with
GC BUG: invalid type_id=44289240 ... site=object_total_size
(majit-gc/src/collector.rs:1511). Bisected: origin/main PASS →
+DELETE_FAST/+DELETE_DEREF PASS → +exit-frame FINISH PANIC. dynasm and
cranelift passed throughout.

It is not a bug in that commit. The commit's only sin is that it made this
raise trace compile a bridge for the first time (bridges_compiled 0 → 1);
previously the trace aborted and was thrown away, so it never reached the compile
at all. What it stepped on:

The wasm backend has no gcreftracer / LoadFromGcTable path. emit_resolve
(majit-backend-wasm/src/codegen.rs) lowers OpRef::ConstPtr(GcRef) to
i64.const <raw address> — baked into the emitted bytecode, where the GC can
neither find nor forward it. codegen.rs panics on LoadFromGcTable with
"the GC rewrite must not run for wasm", and set_constants_pool flattens the
typed ConstMap<Const> into IndexMap<u32, i64>, erasing Const::Ref so even
a walker could not tell refs from ints.

dynasm (runner.rs) and cranelift (compiler.rs) both run gc_rewriter() →
rewrite_for_gc_with_constants → remove_constptrs_in (majit-gc/src/rewrite.rs,
rewrite.py:105-126 parity), pulling every non-null ref constant into
GcTable::from_gcrefs, which self-registers a root walker
(gcreftracer.rs install_gc_table_walker) that both keeps the constant alive
and forwards it in place for the loop's whole life — RPython's GCREFTRACER
reached through asmmemmgr_gcreftracers.

So wasm is the parity deviation, and it is latent for any trace that carries a
nursery-resident ref constant — not specific to the raise path. Filed separately;
the FINISH work lands on top of that fix rather than being rushed in beside it.

A secondary, backend-symmetric hole was also confirmed and is in the same issue:
walk_active_trace_refs gates the op-graph walk on self.tracing, which
finish_and_compile take()s before recording the FINISH — and never restores,
since the ops then move into compiled_loops. That window has no end.

residual_raise_except_resume (the remaining wasm failure) is pre-existing.
Measured on pristine origin/main @ 6552f3cd55: 0.21s vs pypy 0.01s = 21.4x,
same FAIL. #576 added # pyre-check: max-pypy-ratio=10 to that fixture three
hours before this push, and the wasm backend does not meet it; unannotated
fixtures in the same sweep run at 13–33x pypy and pass ungated. This branch adds
no new wasm failure.

Gates on the pushed stack: dynasm 192/192, cranelift 192/192, wasm 190 passed /
1 failed (the fixture above). Lib: pyre-jit 311, pyre-jit-trace 263,
majit-metainterp 1399, 0 failed.

— commented by Claude

@youknowone

Copy link
Copy Markdown
Collaborator Author

The wasm GC parity gap described above is now tracked as #581 (three layered defects, all cited to source). The withheld exit-frame FINISH commits are preserved locally at tag perf-exc-full-stack and land on top of that fix.

— commented by Claude

@youknowone

Copy link
Copy Markdown
Collaborator Author

⚠️ Do not merge yet — the Codex parity review's §1 finding is real, reproduced against the oracle.

x = 1
del x
del x        # must raise UnboundLocalError

In a hot loop (200000 iterations), PYRE_NO_JIT=1 prints 200000, the JIT prints 199999 — one UnboundLocalError is swallowed. Same for a never-bound local (del y where y was only assigned under a false branch). loops_compiled=2. This is a silent wrong answer, not a perf issue.

Codex's mechanism reads correct: commit 12db975068 removed the arm's two abort paths (local_value_at(idx).is_none() and the ptr_iszero branch), leaving load_fast_check's result ignored. After a prior delete the codewriter's FrameState for that local is None, and the can-raise residual's guard can resume its normal continuation instead of raising — pyopcode.py:998 requires DELETE_FAST to raise before clearing an already-empty slot.

The PR body's claim that both lowerings are "byte-identical to the interpreter path by construction" is therefore wrong for DELETE_FAST; I'm retracting it. A/B against origin/main is running to confirm this is a regression rather than pre-existing, then I'll fix it in-session and re-push. Flagging now so nobody merges on the earlier green report.

— commented by Claude

@youknowone youknowone changed the title Exception-path JIT: lower DELETE_FAST/DELETE_DEREF + cleanup-landing exc reload Exception-path JIT: lower DELETE_DEREF + cleanup-landing exc reload Jul 16, 2026
@youknowone

Copy link
Copy Markdown
Collaborator Author

⛔ Do not merge — this PR introduces a JIT miscompile (retracting my previous green claim)

My earlier comment said this stack was verified green. That was wrong, and I am retracting it. The failing CPython suite (gate) check on this PR is not the known test_strftime flake — it is a regression introduced by these two commits.

The A/B, both sides rebuilt with the canonical command, binaries hash differently

cargo build --release -p pyrex --bin pyre-dynasm --no-default-features --features dynasm

build binary sha256 (16) TZ=UTC + JIT on TZ=UTC + PYRE_NO_JIT=1
6564959c41 (origin/main — this PR's exact base) 828087dba9be6d48 Ran 4 tests → OK OK
6311e8a5c0 (this PR head) 599f6118e6be9c58 TypeError: not an iterator → FAILED OK

Deterministic local repro — no CI, no timing, 3/3 reproductions:

TZ=UTC ./target/release/pyre-dynasm lib-python/3/test/test_strftime.py

JIT off passes, JIT on fails (loops_compiled=5), so this is a compiled-trace defect, not a time-module or formatting problem.

Why I initially misread it as a pre-existing flake

Two plausible signals lined up and both were misleading:

  1. The failure is recorded in our notes as a "wall-clock-dependent flake — do not attribute it to whatever patch is in flight." It is not wall-clock dependent at all: the trigger is simply whether TZ is set (TZ=UTC, TZ=Asia/Seoul, and even empty TZ= all fail; unset passes). That is 100% deterministic, which is why it looked intermittent across environments rather than broken.
  2. main really does show test_strftime: PASS -> FAIL on several recent commits (2b6b42c6e1, 9d79802222, d405c6b83a). But those are other commits — this PR's base 6564959c41 is green, both in CI and locally. The right control is the base this branch actually sits on, not "does main fail somewhere".

TypeError: not an iterator is a familiar miscompile symptom in this codebase (it was the #533 signature fixed by PR#558, where codegen picked the last label instead of the JUMP-descr target), so the iterator/label path is the place to start.

Bisecting the two commits now. I will report which one is responsible and either fix it or drop it, as with the DELETE_FAST commit earlier in this branch (#586).

— commented by Claude

@youknowone

Copy link
Copy Markdown
Collaborator Author

✅ Retracting the do-not-merge hold — my accusation was wrong, these commits are innocent

My previous comment claimed these two commits introduce a TypeError: not an iterator miscompile. That was wrong, and the A/B I based it on was invalid. Correcting the record.

What was wrong with my experiment

I compared this branch against git checkout origin/main — but origin/main had advanced during the session. So my "main" binary was not this PR's base; it was the newer tip, which carries three commits my base lacks:

eca75827fe  #346: union keystone (#590)
e297968a0b  jit: walker-native range FOR_ITER-next specialization with polymorphic demotion (#545)
091a7953fc  jit: virtualize BUILD_LIST on the FBW walker (#580)

So I was measuring "my base without #545" vs "newer main with #545" and attributing the whole delta to my two commits. The tell was there and I explained it away: the two "main" builds produced different binary hashes (828087db → 86de731c) and I dismissed that as non-reproducible builds.

The control I never ran, run now — my base ALONE

red2.py is a clock-pinned reduction of the failure (the bug is wall-clock dependent — only ~11 of 40 sampled clock values trigger it, which is why it reads as a flake).

build contents TZ=UTC + pinned clock ×3 values
828087dba9be6d48 6564959c41 = this PR's base, without my commits not an iterator ✘
599f6118e6be9c58 base + these 2 commits not an iterator ✘ — identical to base
86de731c17a56ee5 origin/main tip (has #545) DONE ✔

Removing my commits does not change the outcome. The defect is pre-existing on the base and already fixed on main, almost certainly by #545 — not an iterator is raised by the specialized iterator helpers range_iter_has_next / range_iter_next_or_null (pyre/pyre-interpreter/src/runtime_ops.rs:1395, :1468), which reject anything that is not a range/long-range/seq iterator, and #545 is precisely the range FOR_ITER-next specialization with polymorphic demotion.

Independent confirmation from a parallel investigation: for the only loop whose trace differs, the per-CodeObject jitcode dump is byte-identical between the two binaries (same md5, 596/596 lines), and the abort_permanent census is identical (zero hits on both). These commits do not touch that loop at all. My earlier theory that removing abort_permanent made the branch compile more, and thereby expose a latent bug, was also refuted by that census.

Next

Rebasing this branch onto current origin/main to pick up #545, then re-verifying. The CI failure on this PR should resolve with the rebase; it reflects the base predating the fix, not this patch.

Apologies for the noise — the earlier hold was posted on a confounded measurement.

— commented by Claude

@youknowone
youknowone force-pushed the perf-exc branch 2 times, most recently from 9aa5209 to 18eae96 Compare July 16, 2026 15:14

@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: 18eae963f0

ℹ️ 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 +3143 to +3145
let last_exception = link.last_exception;
link.last_exception = link.last_exc_value;
link.last_exc_value = last_exception;

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 Keep catch-link exception metadata unswapped

When the dynamic-unbound DELETE_FAST arm is caught, carry_explicit_raise_value_on_catch_stack only replaces the handler stack-slot argument with the constructed exception; the original (last_exception, last_exc_value) pair remains later in link.args and target.inputargs (the target input list is not deduplicated). Swapping these metadata fields makes generate_last_exc treat the Ref exception-value variable as the Int exception-type destination and vice versa, so caught unbound del x paths can assemble with kind-mismatched last_exception/last_exc_value moves or seed the handler with the wrong values.

Useful? React with 👍 / 👎.

@youknowone

Copy link
Copy Markdown
Collaborator Author

CI failure diagnosis for the latest run (29510274730):

cargo test (ubuntu + macos) — real bug, fixed on this branch by f1d9ba36d1. The flaky index out of bounds: the len is 23 but the index is 23 panics inside indexmap (hitting different tests each run: argument::tests::raise_type_error_with_function, baseobjspace::tests::object_functionstr_scalar_fallback on CI; various pyre-jit eval::tests locally) are one race: init_typeobjects ran its five post-init patch_* passes outside the TYPEOBJECT_CACHE get_or_init closure on every call, so a second test thread returning from the already-initialized OnceLock re-entered the passes while the first thread was still inserting object.__class__ (the 24th entry) into the 23-entry object type dict — a concurrent IndexMap read/write. The fix wraps the passes in std::sync::Once (they cannot move inside the closure because patch_typeobject_descriptor_names reads TYPEOBJECT_CACHE.get()), plus an 8-thread regression test. Local stress: ~1 failure per 5–10 suite runs before, 0/30 after.

Note this race predates the branch — main's CI has been flaking with the same panic since 7/15, so the cargo test legs there should also stabilize once this lands.

pyre/check.py (ubuntu) — perf-gate flakes, no action. int_loop 0.40s > pypy 0.18×2 and fib_recursive 2.98s > cpython 1.31×2 are the known shared-runner load flakes (int_loop is already on record as such); correctness was 207/209 with only those two timing gates failing.

— commented by Claude

DELETE_DEREF still emitted `abort_permanent`, so a hot loop reaching it
fell back to the interpreter. This blocks `except E as e:` handlers whose
exception variable is captured by a nested function: the capture makes `e`
a cell, so the implicit handler cleanup emits DELETE_DEREF (not DELETE_FAST).

Lower it traceably by composing existing graph ops, mirroring the LoadDeref
and StoreDeref arms: `load_deref_value` dereferences the cell and raises the
unbound error when the contents are empty (pyopcode.py:597), then
`store_deref_value(cell, NULL)` clears the cell contents while the slot keeps
holding the same cell. No new residual; byte-identical to the interpreter.
DELETE_GLOBAL / DELETE_NAME / SETUP_ANNOTATIONS still abort.

Add exception_as_cell_cleanup guard bench (no bench previously exercised
DELETE_DEREF). check.py dynasm 187/187, cranelift 187/187; pyre-jit lib 306/0.

Assisted-by: Claude
A `Copy` handler landing reached by an explicit raise seeded its
top-of-stack exception with a `last_exc_value` read. A residual call
between the raise landing and that read clears the metainterp exception
transient (`execute_varargs` clears before a call that may itself raise),
so the read found no value and the walk aborted with
LastExcValueWithoutActiveException, leaving the loop interpreter-only.

Reload the propagated exception from the frame's durable top stack slot
via `getarrayitem_vable_r` for those landings — catch dispatch has already
stored it there. Reraise-entered landings keep the `last_exc_value` read,
which still carries the live payload supplied by the exception edge. The
per-thread current-exception slot is not used as the source: at this
landing it can still name the older exception whose handler raised this
one, which would mislink `__context__`.

Also apply the `__context__` write to the concrete exception built in
`try_walker_trace_raise_builtin`, mirroring the recorded SETFIELD, since
the full-body walk is also the authoritative execution of the tracing
iteration.

exception_bare_reraise_restore and exception_context_chain_inhandler now
compile (loops_compiled 0 -> 1) with output byte-identical to the
interpreter oracle. check.py dynasm 192/192, cranelift 192/192; pyre-jit
311, pyre-jit-trace 263, majit-metainterp 1399 lib tests pass.

Assisted-by: Claude
Match pyopcode.py:998 by checking the raw local before clearing it.
Construct and explicitly raise UnboundLocalError only on the null arm,
while the bound arm performs one PY_NULL write.

The three-N delete repro reports loops_compiled=1 at 1000, 20000,
and 200000 iterations. Both full backend gates pass 192/192.

Assisted-by: Claude
Decode the guard-owned sys_exc_value pending setfield during bridge setup and carry its symbolic fieldbox plus concrete shadow through current-exception reads, including nullary bare-reraise and multi-frame carrier walks.

exception_bare_reraise_nested_outer improved from loops_compiled=1 bridges_compiled=1 loops_aborted=166 guard_failures=33401 to loops_compiled=1 bridges_compiled=2 loops_aborted=0 guard_failures=401. Output matches the no-JIT oracle; measured wall time was 0.06s versus pypy3 0.08s.

Assisted-by: Claude
Store union-find weights and root info in hash maps so union removes absorbed roots in expected O(1) time, matching upstream dictionary deletion semantics. Preserve upstream infos() ordering by filtering the insertion-only link_to_parent keys to surviving roots.

On seqiter_tuple_error_parity.py, release dynasm wall time fell from 0.62s to 0.26s. JIT stats stayed at loops_compiled=2, bridges_compiled=1, loops_aborted=0, and guard_failures=202.

Assisted-by: Claude
Mirror typed exception-slot loads and stores in the full-body walker with class, Python class, version-tag, and non-null guards. Reuse the cached per-kind field descriptors, preserve args list/tuple conversion, and apply authoritative live writes with GC barriers.

exception_subclass_attrs: CallMayForce 16 -> 10 per traced iteration (80 -> 50 scaled); wall 5.90s -> 0.95s.

exception_oserror_fields: wall 1.39s -> 0.41s; loops_compiled=1 and loops_aborted=0.

Assisted-by: Claude
Accept cacheable heap exception subclasses when their __new__ and __init__ descriptors match the canonical class for the constructed kind. Guard the exact class and its version tag, verify the physical exception pytype, and reuse typed integer-list virtualization for SystemExit(i) arguments.

Measured exception_subclass_attrs residuals over the five-iteration trace body: 50 -> 40. Wall time: ~0.92s -> 0.78s.

Assisted-by: Claude
Mirror OSError slot filling and errno-based class retagging in the walker-native exception constructor fold. Guard the value-dependent branches and preserve materialized args-list class identity.

Assisted-by: Claude
Assisted-by: Claude
descriptor.py:139-143 catches only AttributeError when reading
obj.__class__; any other exception from a property or
__getattribute__ override re-raises. The if-let Ok arm swallowed
every error into the final TypeError.

Assisted-by: Claude
W_AbstractRangeIterator (functional.py:823) exposes no __getitem__;
iter(range(n))[i] must raise TypeError. The getitem_range_iter arm
made range iterators indexable and sliceable.

Assisted-by: Claude
Eight threads call init_typeobjects concurrently and the main thread
probes object.__class__ afterwards. Guards the PATCH_TYPEOBJECTS Once
barrier: without it a second caller returning from the initialized
OnceLock re-entered the patch passes while the first was still storing
into the shared type dicts (flaky IndexMap index-out-of-bounds panics
across cargo test threads).

Assisted-by: Claude
Class creation unconditionally deleted __class__/__classdict__ from
the namespace (and delitem'd them on custom __prepare__ mappings),
destroying attributes the class body stored explicitly, e.g. a
__class__ property (gh#605). The deletions guarded against a
fast2locals cellvar mirror from the retired class-namespace-scratch
architecture; since the body writes through setdictscope and the
__class__/__classdict__ cells stay empty during the body, those keys
never reach the namespace by scaffolding — only by explicit
assignment, which type.__new__ must keep.

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

Here are some automated review suggestions for this pull request.

Reviewed commit: a601a48393

ℹ️ 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 +19441 to +19442
unsafe {
match slot {

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 Journal direct exception-slot stores before abortable walks

When this direct STORE_ATTR fold runs inside an inlined callee, the raw exception-slot write mutates the live exception object but bypasses the generic residual path that records executed effects and rollback state. If a later supported abort in that same sub-walk takes the gh#467 call-forward path, fbw_executed_effect_count() still appears unchanged, so the abandoned callee is re-executed by the interpreter and a mutation like e.args = e.args + (x,) is applied on top of the already-mutated object. Either journal/rollback the displaced exception slot and bump the executed-effect odometer, or keep this on the residual path so abort handling can see the committed heap write.

Useful? React with 👍 / 👎.

@youknowone youknowone changed the title Exception-path JIT: lower DELETE_DEREF + cleanup-landing exc reload Exception-path JIT: fold exception construction/attrs/str, lower DELETE_FAST/DEREF Jul 17, 2026
@youknowone
youknowone merged commit 34e979e into main Jul 17, 2026
30 of 31 checks passed
@youknowone
youknowone deleted the perf-exc branch July 17, 2026 04:52
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