Skip to content

jit(wasm): fix the entry-bridge decline chain — record inputarg types on the token, and publish a peeled loop's resume-label prefix - #996

Merged
youknowone merged 5 commits into
mainfrom
wasm-jit
Aug 3, 2026
Merged

youknowone merged 5 commits into
mainfrom
wasm-jit

Conversation

@youknowone

@youknowone youknowone commented Aug 2, 2026 •

Copy link
Copy Markdown
Collaborator

Two wasm-backend defects that together kept the entry-bridge path from ever compiling, plus the diagnostic split that located them and a baseline re-record.

1. compile_loop never recorded the trace's inputarg types

majit-backend-wasm's Backend::compile_loop was missing

token.set_inputarg_types(inputargs.iter().map(|ia| ia.tp).collect());

which both native backends do (majit-backend-dynasm/src/runner.rs:2269, majit-backend-cranelift/src/compiler.rs:15613).

jitdriver.rs extend_compiled_live_values sizes the live-value list handed to execute_token from that list. On wasm it read 0, so the virtualizable extension was always skipped and a trace whose inputargs outnumber the portal's live values was entered with the short list: execute_token wrote only args.len() words at FRAME_SLOT_BASE + k, the module prologue loaded inputargs.len(), and every slot past the args read the zero-filled frame — a null Ref per Ref input, surfacing far away inside a blackhole resume. Upstream basis: warmstate.py:188 cell.loop_token.

This also closes a long-open wasm bug whose memo had concluded it was a metainterp vable-sync defect. Its 8-line repro (len([j for j in range(3)]) in a hot loop) now prints 6000 instead of raising.

The same commit lifts compile_loop's blanket decline of a terminal JUMP with no local LABEL: it now shares has_cross_loop_terminal_jump / resolve_cross_loop_jump_target with compile_bridge, resolves the entry bridge's target through LABEL_TARGETS, and adopts LabelTarget::frame after checking its own value slots and Ref homes fit. ⚠️The two halves must land together — the one-liner alone turns the comprehension repro from a wrong answer into a blackhole::handler_raise panic.

2. The peel predicate refused a prefix it could have kept

is_resumable_peeled returned false whenever the closing JUMP's target LABEL was not the trace's last one, so build_function emitted no resume dispatch and compile_loop published no LABEL_TARGETS entry for any of that loop's labels — every bridge naming one then declined as unpublished.

The underlying constraint is real but per-label: each label's (past_loader, loader) pair is opened before the wasm loop, so a pair belonging to a label inside the loop body would have to close inside the loop. That excludes the in-body labels, not the ones at or before the header. resumable_label_count names that prefix; build_function, LabelResumeData::collect and compile_loop all work over it, and the header — not the trace's last label — is what is_last_label now marks.

Measured (wasm loops_aborted)

bench before after dynasm
exception_reraise_tb_depth_hot 44 0 0
str_search_index_bounds 35 1 1
trace_too_long_inline_multiframe 30 23 —
gc_bug_bridge_flavor_traceback_names 26 2 1
exception_traceback_loop_forms 20 0 0
gc_deque_backing_list 14 0 —
blackhole_inlined_callee_local_after_escape 10 5 —
exception_loop_warmup 9 0 —
exception_inline_callee_tb_frames / gc_iterator_source_drop / foriter_call_resume_drops_iteration 7 0 / 0 / 4 —
pickle_terminal_raise_resume 54 37 —

26 fixtures in all, every one a reduction. Attribution comes from check.py --snapshot-diff --backend wasm run twice — once as-is, once with the backend change reverted and the wasm module rebuilt: exception_traceback_loop_forms is the only fixture the peel-prefix commit moves; the other 25 belong to the inputarg-types commit.

Baseline re-record

_jit_stats_regression_floor treats loops_aborted as a badness field, so it fails only on a rise — every reduction above passed while leaving the recorded ceiling untouched (exception_reraise_tb_depth_hot was still free to go back from 0 to 44). The last commit re-records those 26 baselines. The one fixture whose number rose — exception_args_virtual loops_aborted 0 -> 3, a base-inherited red that reproduces on a build of this base with none of these commits — was deliberately restored from HEAD rather than recorded. On the current base it is back at its recorded value, so leaving it untouched is what keeps it green.

Verification

  • cargo test -p majit-backend-wasm 22/22, plus all three --ignored runtime integration tests, including global_reassign_retraces_non_last_label_backedge_at_runtime — an existing test for exactly the non-last-label shape, which runs it under wasmtime and compares stdout against dynasm.
  • check.py --backend wasm on the current base (b44c30bae3b, jit: give W_UnicodeObject.len a parent SizeDescr; read the trailing -live- first for after-residual-call guards #989): 366/366 all passed — the 26 re-recorded baselines hold, and exception_args_virtual passes here too, which is the base-inherited failure resolving on the newer base.
  • check.py --backend dynasm,cranelift unchanged (this is a wasm-only backend change).

The abort counts in the table were measured on the previous base (c0891858087); an earlier three-backend gate was invalidated mid-run by a concurrent rebase onto #989, and the re-run above covers wasm on the new base. CI on this PR is the authority for the other two backends.

🤖 Generated with Claude Code

…xits

`compile_entry_bridge` returns `false` from six places and MC_DIAG slot 31
counted them as one number. Slots 34-39 name each: no compiled loop at the
target key, dead JitCellToken, `optimize_bridge` InvalidLoop, retrace
requested, backend panic, backend `Err`. Slot 31 stays their total.

`MAJIT_LOG`'s per-exit lines never reach a wasm run — the guest is built for
wasm32-unknown-unknown, whose `std::env` is empty — so on that backend the
counters are the only channel that can distinguish them. Measured on
`str_search_index_bounds`: wasm `ct_entry_bridge_failed=34` resolves to
`ceb_backend_err=34`, dynasm 0.

Also print the `InvalidLoop` reason the `Err(_invalid_loop)` binding
discarded, and note in the runner that its label list is a positional mirror
of `MC_DIAG_LABELS` that must be appended to, never inserted into.

Assisted-by: Claude
…e the entry bridge's cross-loop JUMP target

`compile_loop` never called `token.set_inputarg_types`; the dynasm
(`runner.rs:2269`) and cranelift (`compiler.rs:15613`) backends both do.
`jitdriver.rs extend_compiled_live_values` reads that list to size the
live-value list it hands `execute_token`, so on wasm it read 0 and
skipped the virtualizable extension: a trace whose inputargs outnumber
the portal's live values was entered with the short list, and every
frame slot past it read as the zero-filled frame, which the module
prologue loaded as a null Ref.

`compile_loop` also declined every trace whose terminal JUMP has no
local LABEL, because it passed `external_jump_slot = 0` while only
`compile_bridge` resolved the target. Both callers now share
`has_cross_loop_terminal_jump` and `resolve_cross_loop_jump_target`;
`compile_loop` resolves an entry bridge's target through
`LABEL_TARGETS`, adopts its frozen `FrameGeometry` after checking its
own value slots and Ref homes fit, and passes the target's table slot
and resume key to `build_wasm_module`.

The two changes are one unit: with only the first, a comprehension
inside a hot loop turns from a wrong result into a
`blackhole::handler_raise` panic.

`len([j for j in range(3)])` called 2000 times prints 6000 on wasm
instead of raising `TypeError`. wasm `loops_aborted`:
`str_search_index_bounds` 35 -> 1, `gc_bug_bridge_flavor_traceback_names`
26 -> 2, `exception_inline_callee_tb_frames` 7 -> 0,
`gc_iterator_source_drop` 7 -> 0.

`check.py --backend wasm` 1 failed / 363 passed, up from 2 / 362;
dynasm 2 / 366 and cranelift 2 / 365 are unchanged, and every remaining
failure reproduces on a build of the base. `cargo test
-p majit-backend-wasm` 27 passed.

Assisted-by: Claude
…tead of refusing every non-last-label backedge

`is_resumable_peeled` returned false whenever the closing JUMP's target
LABEL was not the trace's last one. `build_function` then emitted no
resume dispatch and `compile_loop` published no `LABEL_TARGETS` entry for
any of that loop's labels, so every bridge whose JUMP named one declined
as unpublished.

The restriction came from the wrapper's block structure: each label's
(past_loader, loader) pair is opened before the wasm `loop`, so a pair
belonging to a label inside the loop body would have to close inside the
loop. That excludes the in-body labels, not the ones at or before the
header.

`resumable_label_count` names that prefix. `build_function` opens a pair,
collects label args and emits a loader for exactly those labels;
`LabelResumeData::collect` plans captures for them only; `compile_loop`
publishes them and marks the header -- not the trace's last label -- as
`is_last_label`.

`bench/synth/exception_traceback_loop_forms.py` on wasm: loops_aborted
20 -> 0, ml_unpublished 21 -> 0, ceb_backend_err 19 -> 0, BRIDGE_OK 2 -> 4,
pub_peeled 10 -> 12. A `--snapshot-diff` of the whole wasm corpus against
a build of the same tree without this change reports identical numbers on
every other fixture.

`test_non_last_label_backedge_validates` pinned the old refusal; it now
asserts the shape is peeled with one resumable label and still validates.
The three `--ignored` runtime integration tests pass, including
`global_reassign_retraces_non_last_label_backedge_at_runtime`, which runs
this shape and compares wasm stdout against dynasm.

Assisted-by: Claude
…fell

The floor (`_jit_stats_regression_floor`) treats `loops_aborted` as a
badness field, so it fails only on a rise. Every reduction therefore
passes while leaving the recorded ceiling where it was: after the entry
bridge work the committed baselines still permitted
`exception_reraise_tb_depth_hot` to go back from 0 to 44 and
`pickle_terminal_raise_resume` from 37 to 54 without failing.

`check.py --snapshot --backend wasm` rewrote every baseline; the one
fixture whose number ROSE, `exception_args_virtual` (`loops_aborted`
0 -> 3, a base-inherited red that reproduces on a build of this base with
none of these commits), was restored from HEAD rather than recorded. All
26 remaining changes are decreases in `loops_aborted`; no other field
moved.

Attribution, measured with `--snapshot-diff` against a build of this tree
with the backend change reverted: `exception_traceback_loop_forms`
20 -> 0 comes from 4e621148188, the other 25 from bdeb4ab10e2.

Assisted-by: Claude
@coderabbitai

coderabbitai Bot commented Aug 2, 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: 37 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 Plus

Run ID: 251baffa-fc94-4977-9331-a59e340243e1

📥 Commits

Reviewing files that changed from the base of the PR and between b44c30b and 463212d.

📒 Files selected for processing (33)
  • majit/majit-backend-wasm/src/codegen.rs
  • majit/majit-backend-wasm/src/lib.rs
  • majit/majit-backend-wasm/tests/codegen_test.rs
  • majit/majit-metainterp/src/lib.rs
  • majit/majit-metainterp/src/pyjitpl.rs
  • pyre/bench/fannkuch.wasm.jitstats
  • pyre/bench/synth/blackhole_inlined_callee_local_after_escape.wasm.jitstats
  • pyre/bench/synth/closure_per_call.wasm.jitstats
  • pyre/bench/synth/exc_bridge_entry_guard_not_removed.wasm.jitstats
  • pyre/bench/synth/exc_caught_in_callee_return_loop.wasm.jitstats
  • pyre/bench/synth/exc_mixed_classes_bridge_flavor.wasm.jitstats
  • pyre/bench/synth/exception_catching_frame_tb_node.wasm.jitstats
  • pyre/bench/synth/exception_inline_callee_tb_frames.wasm.jitstats
  • pyre/bench/synth/exception_loop_warmup.wasm.jitstats
  • pyre/bench/synth/exception_oserror_fields.wasm.jitstats
  • pyre/bench/synth/exception_reentry_guard_finally_residual.wasm.jitstats
  • pyre/bench/synth/exception_reraise_tb_depth_hot.wasm.jitstats
  • pyre/bench/synth/exception_traceback_frame_lineno.wasm.jitstats
  • pyre/bench/synth/exception_traceback_lineno_chain.wasm.jitstats
  • pyre/bench/synth/exception_traceback_loop_forms.wasm.jitstats
  • pyre/bench/synth/exception_try_call_inlined_callee_raise.wasm.jitstats
  • pyre/bench/synth/foriter_call_resume_drops_iteration.wasm.jitstats
  • pyre/bench/synth/gc_bug_bridge_flavor_traceback_names.wasm.jitstats
  • pyre/bench/synth/gc_deque_backing_list.wasm.jitstats
  • pyre/bench/synth/gc_iterator_source_drop.wasm.jitstats
  • pyre/bench/synth/loop_callee_return.wasm.jitstats
  • pyre/bench/synth/nested_callee_chain_mutation_abort.wasm.jitstats
  • pyre/bench/synth/pickle_terminal_raise_resume.wasm.jitstats
  • pyre/bench/synth/str_search_index_bounds.wasm.jitstats
  • pyre/bench/synth/trace_too_long_inline_multiframe.wasm.jitstats
  • pyre/bench/synth/type_name_surrogate_reject.wasm.jitstats
  • pyre/pyre-sandbox/src/seccomp.rs
  • pyre/pyre-wasm-runner/src/main.rs

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: 2523f3fd95

ℹ️ 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 4728 to 4730
ops[..loop_label]
.iter()
.any(|op| op.opcode != OpCode::Label)

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 Base the advance census on the actual loop header

When the newly supported backedge targets a LABEL that precedes another in-body LABEL, guard_fail_args_advanced still starts its census after rposition(Label) (lib.rs:148-152) rather than after the LABEL selected by the terminal JUMP. Consequently, arithmetic between the real header and the later marker is not recorded; a loop-closing bridge that forwards such an advanced guard fail-arg is then classified as non-advancing at lib.rs:2616-2619 and permanently declined despite making progress. Use the same descr-based header lookup introduced here for this census.

Useful? React with 👍 / 👎.

…mapping

mimalloc is pyrex's default global allocator (`pyre/pyrex/Cargo.toml:26`) and
follows every successful `mmap` with
`prctl(PR_SET_VMA, PR_SET_VMA_ANON_NAME, p, size, "mimalloc")`
(`mimalloc/src/prim/unix/prim.c` `unix_mmap_prim`). `allowed_syscalls` listed
`mmap` but not `prctl`, so the first region mimalloc reserved after
`install_runtime_filter` trapped: `pyre: sandbox seccomp blocked syscall 157`
(x86_64 `SYS_prctl`) and `_exit(159)` out of the SIGSYS handler. Whether that
mapping happens before or after lockdown depends on how much the workload
allocates, which is why it surfaced on `sandbox_blocks_known_escapes` — the
probe printed `ALL-BLOCKED` and then died on the exit code.

`prctl` is a multiplexer, so it is not added to the flat number list:
`PR_SET_SPECULATION_CTRL` drops this process's Spectre mitigations and
`PR_SET_DUMPABLE`/`PR_SET_PTRACER` open its memory to a sibling. The filter
now checks `args[0]` for that one syscall and admits only `PR_SET_VMA`; any
other option still traps and names itself through the same handler.

Program construction moves into `build_filter_program` so the jump arithmetic
— which now has three landing sites instead of two — is covered by tests that
execute the emitted program over a synthetic `seccomp_data` rather than
asserting on instruction indices.

Assisted-by: Claude
@github-actions

github-actions Bot commented Aug 3, 2026

Copy link
Copy Markdown

🤖 Codex parity review

Static analysis of this diff vs the local RPython/PyPy sources (commit 463212d).
Updated: 2026-08-03T02:49:04.075Z

Files in the reviewed diff
majit/majit-backend-wasm/src/codegen.rs
majit/majit-backend-wasm/src/lib.rs
majit/majit-backend-wasm/tests/codegen_test.rs
majit/majit-metainterp/src/lib.rs
majit/majit-metainterp/src/pyjitpl.rs
pyre/pyre-sandbox/src/seccomp.rs
pyre/pyre-wasm-runner/src/main.rs

1. Regressions to PyPy parity introduced by this patch

None.

2. Other mismatches introduced by this patch

  • majit/majit-backend-wasm/src/lib.rs:150 ↔ rpython/jit/metainterp/compile.py:332 — the patch now supports a JUMP targeting a non-final LABEL, but guard_fail_args_advanced still defines the iterating body as after the last LABEL. RPython derives loop-carried semantics from the JUMP’s actual target descriptor. This can misclassify an advance between the target LABEL and a later in-body LABEL, incorrectly declining a valid chained bridge.

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

None.

4. Structural adaptations

  • majit/majit-backend-wasm/src/codegen.rs:2013 ↔ rpython/jit/metainterp/compile.py:320 — wasm’s structured control flow requires entry br_table loaders and explicit LABEL capture slots; RPython’s native backend can preserve state directly across its compiled control-flow edges.
  • majit/majit-backend-wasm/src/lib.rs:1374 ↔ rpython/jit/metainterp/compile.py:1006 — resolving a cross-module wasm tail-call through LABEL_TARGETS and adopting a frozen frame geometry is a wasm-module adaptation of RPython’s entry bridge into an existing compiled loop.
  • pyre/pyre-sandbox/src/seccomp.rs:241 ↔ rpython/translator/sandbox/sandlib.py:225 — the in-child classic-BPF allowlist is a Rust/Linux containment adaptation; RPython’s sandbox controller enables seccomp externally after child startup.
  • majit/majit-metainterp/src/lib.rs:621 ↔ rpython/jit/metainterp/compile.py:589 — added per-exit diagnostic counters are wasm-runner telemetry; RPython exposes backend progress through its profiler/debug logging rather than guest-exported counters.

@youknowone
youknowone merged commit 949b530 into main Aug 3, 2026
18 of 19 checks passed
@youknowone
youknowone deleted the wasm-jit branch August 3, 2026 03:45
youknowone added a commit that referenced this pull request Aug 3, 2026
…ch moved

`check.py --snapshot-diff` reported 53 rows, all `jit-stats diff` and none
`snapshot output diff`, so no fixture's output changed. Recorded with
`check.py --snapshot`; only `.jitstats` files moved.

Direction of the 109 field moves, against the gate groups in check.py:
93 in the improving direction, 0 rises in a badness field, 0 falls in
`loops_compiled`, and 16 rises in `guard_failures` — all inside the
`base + max(base // 4, 2)` band the field is gated on.

Twelve of the sixteen `guard_failures` rises accompany a `loops_compiled`
rise or a `loops_aborted` fall in the same fixture, i.e. more of the frame
compiles so more guards exist to fail: `attr_cache_invalidation` 802 -> 1002
with `loops_compiled` 1 -> 2, `exception_reentry_guard_finally_residual`
2044 -> 2261 with `loops_aborted` 5 -> 0 and `loops_compiled` 2 -> 3,
`goto_if_not_same_box` 0 -> 2 with `loops_compiled` 0 -> 2,
`blackhole_inlined_callee_local_after_escape` 0 -> 1 with `loops_aborted`
10 -> 5. The remaining four are +1 or +2 on cranelift.

`loops_aborted` summed over the 53 recorded files: 120 -> 56.

The wasm baselines are unchanged; main re-recorded them in #996.

Assisted-by: Claude
youknowone added a commit that referenced this pull request Aug 3, 2026
…ch moved

`check.py --snapshot-diff` reported 53 rows, all `jit-stats diff` and none
`snapshot output diff`, so no fixture's output changed. Recorded with
`check.py --snapshot`; only `.jitstats` files moved.

Direction of the 109 field moves, against the gate groups in check.py:
93 in the improving direction, 0 rises in a badness field, 0 falls in
`loops_compiled`, and 16 rises in `guard_failures` — all inside the
`base + max(base // 4, 2)` band the field is gated on.

Twelve of the sixteen `guard_failures` rises accompany a `loops_compiled`
rise or a `loops_aborted` fall in the same fixture, i.e. more of the frame
compiles so more guards exist to fail: `attr_cache_invalidation` 802 -> 1002
with `loops_compiled` 1 -> 2, `exception_reentry_guard_finally_residual`
2044 -> 2261 with `loops_aborted` 5 -> 0 and `loops_compiled` 2 -> 3,
`goto_if_not_same_box` 0 -> 2 with `loops_compiled` 0 -> 2,
`blackhole_inlined_callee_local_after_escape` 0 -> 1 with `loops_aborted`
10 -> 5. The remaining four are +1 or +2 on cranelift.

`loops_aborted` summed over the 53 recorded files: 120 -> 56.

The wasm baselines are unchanged; main re-recorded them in #996.

Assisted-by: Claude
youknowone added a commit that referenced this pull request Aug 3, 2026
…ch moved

`check.py --snapshot-diff` reported 53 rows, all `jit-stats diff` and none
`snapshot output diff`, so no fixture's output changed. Recorded with
`check.py --snapshot`; only `.jitstats` files moved.

Direction of the 109 field moves, against the gate groups in check.py:
93 in the improving direction, 0 rises in a badness field, 0 falls in
`loops_compiled`, and 16 rises in `guard_failures` — all inside the
`base + max(base // 4, 2)` band the field is gated on.

Twelve of the sixteen `guard_failures` rises accompany a `loops_compiled`
rise or a `loops_aborted` fall in the same fixture, i.e. more of the frame
compiles so more guards exist to fail: `attr_cache_invalidation` 802 -> 1002
with `loops_compiled` 1 -> 2, `exception_reentry_guard_finally_residual`
2044 -> 2261 with `loops_aborted` 5 -> 0 and `loops_compiled` 2 -> 3,
`goto_if_not_same_box` 0 -> 2 with `loops_compiled` 0 -> 2,
`blackhole_inlined_callee_local_after_escape` 0 -> 1 with `loops_aborted`
10 -> 5. The remaining four are +1 or +2 on cranelift.

`loops_aborted` summed over the 53 recorded files: 120 -> 56.

The wasm baselines are unchanged; main re-recorded them in #996.

Assisted-by: Claude
youknowone added a commit that referenced this pull request Aug 3, 2026
… merge points, quasi-immut deps, front-entry split (#960)

* majit-macros: take raw-store width and signedness from the intrinsic name

`lower_raw_store_stmt` accepted only `majit_raw_store_i64` and minted
`add_raw_int_array_descr(8)`, which is unsigned. It now recognizes the
full `majit_raw_store_{i,u}{8,16,32,64}` table and mints
`add_raw_int_array_descr_signed(item_size, is_signed)`, mirroring the
load-side table at `lower_value.rs:471-505`.

`jtransform.py:1156-1163 rewrite_op_raw_store` builds the descr from the
stored value's own type, so width and sign come off the descr.

All three backends and the blackhole already read `item_size` from the
descr (`compiler.rs:12822`, `aarch64/assembler.rs:2900`,
`codegen.rs:2739`, `runner.rs:3363`); the store path does not read
signedness, so the i64 arm changing from unsigned to signed writes the
same bytes.

Assisted-by: Claude

* majit: seed bridge input boxes with deadframe values, build live_arg_boxes once

`start_retrace_from_guard` minted the bridge recorder's inputargs from types
alone, and `compile_bridge_trace` rebuilt them the same way, so their concrete
value slots were empty. resume.py:1267-1282 `load_box_from_cpu` constructs the
frontend box as `IntFrontendOp(num, cpu.get_int_value(deadframe, num))`;
`closing_jump_runtime_boxes` reads those values to build `runtime_boxes`, which
virtualstate.py:493-498 consults. Stamp both sites from `fail_values` /
`recorder.inputargs()`.

`reached_loop_header` (pyjitpl.py:2974-2989) builds `live_arg_boxes` once and
hands the same list to the bridge JUMP, the merge-point scan and
`current_merge_points.append`. The CloseLoop arm built and normalized it twice;
`remove_consts_and_duplicates` records a SAME_AS per rewrite, so the bridge
fall-through path appended a second set after `compile_trace` had sampled
`potential_retrace_position`. Hoist the construction above the bridge branch.

`compile_retrace` never set `Optimizer::trace_inputargs`, so the Phase 2
TraceIterator seeded an empty cache (opencoder.py:264-267) and `_get` panicked
on the first body operand referring to a cut inputarg.

`resolve_to_operand` returned `inputarg_refs[idx]` without checking that the
slot belongs to `idx`; slots grown by `resize_with(.., new_int(0))` hold an
index-0 Int placeholder, so an out-of-range Ref position resolved to an Int box
at position 0.

Records in `compile_loop_body` why the merge-point scan stays a two-way test:
after a cancelled close the walk emits a loop-close action at every pc it steps
to, so upstream's third branch (append and keep tracing) does not terminate.

pyre/check.py: dynasm 352/352, cranelift 352/352, wasm 348/348.

Assisted-by: Claude

* majit: key the loop-header stamp on the visited merge point's greens, port the partial-trace gate and the merge-point append

pyjitpl.py:1553-1554 reads `ptoken = self.get_procedure_token(greenboxes)`
where `greenboxes` are the greens of the merge point being visited.
`dispatch.rs` keyed that lookup on the fixed `ctx.green_key` instead, so
once the trace-start key had compiled targets the stamp re-armed at every
merge point the trace reached and `reached_loop_header` ran on body merge
points that upstream returns from at 1554.

pyjitpl.py:3003 `if not self.partial_trace:` gates the bridge attempt;
`jitdriver.rs` computed the value into `_has_partial_trace` and discarded
it.

pyjitpl.py:3018-3060 has three outcomes, not two: a same-greenkey merge
point at `retracing_from` retraces, one elsewhere aborts, and NO
same-greenkey entry falls through to `current_merge_points.append` with
the history still live. `compile_loop_body` collapsed that to a two-way
test and compiled the retrace in the same call, so the retrace body was
`Label; Jump`. It now registers the merge point through
`register_retrace_merge_point` and returns `Cancelled`;
`MetaInterp::keep_tracing_after_close` tells the driver to keep `self.sym`
so the walk records the next iteration, and the retrace closes on the
following header visit.

With CEL_RETRACE_LIMIT=5 on the cel poison reproducer the close census
goes from one CloseLoop per element to 8 at pc=45 plus 1 at pc=27, and
`compile_retrace` cuts a 28-op body instead of an empty one.

Assisted-by: Claude

* majit: drop the single unresolvable short-preamble op instead of the whole inline

shortpreamble.py:128-139 `PureOp.add_op_to_short` (and its HeapOp /
LoopInvariantOp siblings) returns None as soon as one arg's `produce_arg`
returns None, and `add_op_to_short` (:311-341) then leaves THAT ONE op out
of `produced_short_boxes`. Anything that depended on it drops in turn
because its own `produce_arg` now misses. Upstream therefore reaches the
inline with the filtering already done, which is why
`ExtendedShortPreambleBuilder.setup` (:460-463) is three field assignments
and cannot fail.

`ExtendedShortPreambleBuilder::setup` cleared `short`/`short_results` and
returned false on the first unresolvable arg, and the caller turned that
into `invalid_loop`. Pyre's short boxes are exported against one label-arg
set and replayed against another — a retrace runs no Phase 1 and imports
the partial trace's exported state — so an arg that was produce-able at
export can be unresolvable at setup. Skip that op and keep going;
`short_results` already carries the transitive drop.

On the cel poison reproducer with CEL_RETRACE_LIMIT=5 exactly one op is
dropped (IntAdd at IntOp(241), whose arg InputArgInt(222) is not a body
label arg), `jump_to_existing_trace` reports `jumped=true,
invalid_loop=false` instead of falling back to jump_to_preamble, and the
compiled retrace terminates instead of looping forever.

Assisted-by: Claude

* majit: re-enter the walk when a merge point only appends, instead of handing the guest back

pyjitpl.py:1570-1578 calls `reached_loop_header` with `self.pc = orgpc`
and, when it returns without raising (the `current_merge_points.append`
path at :3058-3060), restores `self.pc = saved_pc` and keeps
interpreting under the recorder. This driver returned from
`merge_point` instead, and the `jit_merge_point!` expansion's
`#pc = __sp_pc; continue;` handed the guest to the native dispatch loop,
which executed the merge point's own instruction unrecorded.

`merge_point` now loops on that path: it discards the walk-final handoff
staged at the top of the CloseLoop arm, sets `TraceCtx::merge_point_resumed`,
and re-enters the walk. The flag is read-and-clear; the dispatch
merge-point arm consumes it and falls through once, and the macro
wrapper's header-revisit fast path peeks at it so the resumed segment
records the instruction rather than closing again with nothing between.

`merge_point` and its delegate `jit_merge_point_keyed` take `FnMut`.
pyre's bridge closure moved a non-Clone `FrameBox` in, so it snapshots
the rooted frame per entry.

Assisted-by: Claude

* majit: drop the start label from compile_retrace's assembly

compile.py:327-328 `compile_loop` assembles `[start_label] + preamble_ops +
extra_same_as + extra_before_label + [label_op] + loop_ops`, with the start
label FIRST. compile.py:381-382 `compile_retrace` assembles
`loop.operations + extra_same_as + [label_op] + loop_ops` and emits no start
label at all.

majit's retrace reused the compile_loop assembly, so a second LABEL landed
between the saved preamble ops and the loop label, declaring the pre-peel
argument set for slots the loop label rebinds.

`UnrollOptimizer::emit_start_label` defaults to true and is cleared by
`compile_retrace`; `assemble_peeled_trace_with_jump_args` already emits the
start label only when handed a descr.

Assisted-by: Claude

* majit: cancel a retrace whose closing JUMP does not target its own label

compile.py:390 `target_token = loop.operations[-1].getdescr()`: the retrace's
closing JUMP names the label the same trace installs. The other optimizer
outcome, `unroll.py:238-242 jump_to_preamble`, retargets the JUMP at the
original loop's start descr, which is a jump into another compiled trace —
`x86/assembler.py:2461-2467 closing_jump` emits
`JMP(imm(target_token._ll_loop_code))`. This backend compiles one function per
trace and cannot emit that, so such a JUMP would name no label in the trace it
lands in.

compile_retrace now takes compile.py:368-371's cancel shape when the closing
JUMP's descr is not the trailing label's.

Assisted-by: Claude

* majit: normalize the bridge virtualizable seed for consts and duplicates

pyjitpl.py:2985-2987 runs `remove_consts_and_duplicates` over
`virtualizable_boxes[:-1]`; the bridge seed builds that same list from
resume data, where the reader cache can return one OpRef for two slots.

Assisted-by: Claude

* majit: persist reached_loop_header's virtualizable normalization into the ctx

pyjitpl.py:2985-2988 runs `remove_consts_and_duplicates` over
`self.virtualizable_boxes[:-1]` IN PLACE and then appends the mutated
list to `live_arg_boxes`; the loop-carried list built here normalizes a
copy, leaving `TraceCtx::virtualizable_boxes` unrewritten.

Assisted-by: Claude

* majit: dispatch compile_retrace on the resumekey class

compile.py:392 ends compile_retrace with
`resumekey.compile_and_attach(metainterp, loop, inputargs)`, and the two
resumekey classes install the finished trace differently:

  ResumeGuardDescr    (compile.py:797-809) sets
    `new_loop.original_jitcell_token =
     metainterp.resumekey_original_loop_token` and calls
    `send_bridge_to_backend` — the trace is attached to the guard it grew
    from, under the source loop's token.
  ResumeFromInterpDescr (compile.py:1006-1023) makes a new jitcell token,
    calls `send_loop_to_backend` and `attach_procedure_to_interp`.

`compile_retrace` applied the second form to both: it always minted a
JitCellToken, called `backend.compile_loop`, then
`compiled_loops.swap_remove` + `insert` + `attach_procedure_with_redirect`.

Fork on `bridge_info()`, which is `Some` exactly when the session started
at `start_retrace_from_guard`. That arm goes to a new
`attach_retrace_to_source_guard`, mirroring `compile_bridge`'s tail:
`backend.compile_bridge(fail_descr, inputargs, ops, &source_jct,
previous_tokens, caller_recovery_layout)`, then
`record_loop_or_bridge(&source_jct, ..)` and
`compiled_loops[green_key].traces.insert(bridge_trace_id, ..)`. No token
mint, no `compiled_loops` key mutation, no warmstate attach.

`patch_new_loop_to_load_virtualizable_fields` stays on the front-door arm
only: compile.py:509-511 places that hook in `send_loop_to_backend`;
`send_bridge_to_backend` (compile.py:569-576) has no counterpart.

Target tokens are rebound to the source token's number
(`propagate_original_jitcell_token`, compile.py:463-468) and recorded on
`loop_jitcell_token` (unroll.py:290-297), which also carries
`retraced_count` (unroll.py:213-215). `unroll.py:198/239/277` scans the
same list it appends to, so the retrace's labels are written back into
`front_target_tokens`; the write-back is order-preserving.

check.py: dynasm 357/357, cranelift 357/357, wasm 353/353.
cargo test -p majit-metainterp --release: 0 failed.

Assisted-by: Claude

* Revert "majit: normalize the bridge virtualizable seed for consts and duplicates"

This reverts commit 326c040d005c158679ba21ca51421b117fdc86d4.

`remove_consts_and_duplicates` has one upstream call site,
pyjitpl.py:2980-2988 inside `reached_loop_header`, which normalizes
`self.virtualizable_boxes` in place; nothing normalizes the list where a
bridge rebuilds it from resume data. That in-place site is now covered by
aa9538f17cc, so the seed-side call is both non-upstream and redundant.

It also had no measured effect: with it, `cut-original-boxes` reports
`dups=[]`, and the retrace's element-to-inputarg shift and the SEGV are
unchanged. optimizer.py:889-894 `optimize_SAME_AS_I` does
`make_equal_to(op, op.getarg(0))` ("These needs to always be optimized
out"), so the `record_same_as` a normalization emits cannot survive
optimization as a distinct box.

check.py: dynasm 357/357, cranelift 357/357, wasm 353/353.
cargo test -p majit-metainterp --release: 0 failed.

Assisted-by: Claude

* majit: stop clearing the resumekey when a retrace is requested

`MetaInterp.bridge_info` is pyre's port of `self.resumekey`'s class: `Some`
is a `ResumeGuardDescr` session, `None` a `ResumeFromInterpDescr` one.
Upstream writes that field exactly twice, both at session start —
pyjitpl.py:2905 `self.resumekey = compile.ResumeFromInterpDescr(
original_greenkey)` and pyjitpl.py:2939 `self.resumekey = resumedescr` —
and `retrace_needed` (pyjitpl.py:2438-2442) sets only `partial_trace`,
`retracing_from`, `exported_state` and `heapcache.reset()`.

The `BridgeCompileResult::RetraceNeeded` fall-through cleared it, so by the
time `compile_retrace` read it for compile.py:392's
`resumekey.compile_and_attach` dispatch it was always `None`. Every
guard-originated retrace therefore took the `ResumeFromInterpDescr` install
(compile.py:1006-1023: new jitcell token, `send_loop_to_backend`, front
door) instead of compile.py:797-810's `send_bridge_to_backend`.

The two differ in the entry contract, not just bookkeeping: a front door is
fed positionally in loop-carried order, while a guard-originated trace's
`loop.inputargs` are `partial_trace.inputargs` (compile.py:379 + :1084),
minted in the parent guard's failarg order.

The bridge attempt on this arm is already gated on `partial_trace`
(jitdriver.rs:2918-2919, pyjitpl.py:3003 `if not self.partial_trace:`), so
a retained `bridge_info` cannot re-enter it.

cel `poison` (POISON_SHORT=2 POISON_LONG=8 CEL_RETRACE_LIMIT=5): the
retrace now logs `attached retrace to guard`, no front door is installed,
and ROWS 16/40/64/80/100/128/160/200/2000 all exit 0 where every ROWS
previously ended in SIGSEGV.

Not sufficient on its own: with the artifact reachable, phase3 checksums
diverge from the `CEL_RETRACE_LIMIT=0` control whenever the retrace stays
in compiled code (ROWS=80 phase3 deopts=1 -> 232 vs 24960; ROWS=64 phase3
deopts=127 -> correct). That is a separate defect in the retrace artifact
itself, previously unobservable because the process died first.

check.py: dynasm 358/358, cranelift 358/358, wasm 354/354.
cargo test -p majit-metainterp --release: 0 failed.

Assisted-by: Claude

* majit: gate the CloseLoopWithArgs bridge attempt on partial_trace

`pyjitpl.py:3003 if not self.partial_trace:` guards the whole bridge
attempt in `reached_loop_header`. The `CloseLoop` arm applies it
(jitdriver.rs:2918-2919); the `CloseLoopWithArgs` twin bound
`partial_trace().is_some()` to a discarded `_has_partial_trace` and
entered the arm unconditionally, with `take_bridge_info()` on
`RetraceNeeded` standing in for the gate.

Restore the gate and drop that `take`. `bridge_info` is the
`self.resumekey` class discriminator; upstream writes it only at session
start (pyjitpl.py:2905 / 2939) and `retrace_needed`
(pyjitpl.py:2438-2442) writes none, so it has to reach
`resumekey.compile_and_attach` (compile.py:392-393).

`bridge_info` surviving `RetraceNeeded` means it no longer separates the
bridge phase from the retrace phase, so the majit-only
structural-decline predicate that permanently records a declined bridge
guard now also tests `partial_trace().is_none()`.

python3 ./pyre/check.py exit=0 — dynasm 358/358, cranelift 358/358,
wasm 354/354. cargo test -p majit-metainterp --release: 0 failed.

Assisted-by: Claude

* majit: match retrace merge points on the greens the trace closes with

`pyjitpl.py:3021 same_greenkey(original_boxes, live_arg_boxes,
num_green_args)` compares a merge point's greens against the greens the
trace is closing WITH. majit looked merge points up by `ctx.header_pc`,
the header the walk last registered, and registered them under that same
field. A trace closing on a different loop than the one the session last
registered leaves that pc stale, so the scan matched a merge point whose
greens differ from the close and ran `compile_retrace` on it. Upstream's
`continue` falls out of the scan and appends (pyjitpl.py:3059-3060).

Add `TraceCtx::close_header_pc`, which reads the guest pc out of
`close_greens` (majit's `live_arg_boxes[:num_green_args]`) and falls
back to `header_pc` when the walk recorded none, and use it for both the
lookup and the registration.

Measured on the cel `nums.map(y, y * 2)` retrace reproducer
(POISON_SHORT=2 POISON_LONG=8, CEL_RETRACE_LIMIT=5 against the
CEL_RETRACE_LIMIT=0 control). That workload registered merge points at
guest pc 45 and 89 under one green key, and closed the pc-89 one with
greens naming pc 27; the retrace grown there was attached to the pc-45
loop's guard. phase3 returned checksum 232 — row 0 only, every later row
emitting a zero-length list — at ROWS 80, 128, 160, 200 and 2000. All
nine row counts (16 40 64 80 100 128 160 200 2000) and all six
CEL_RETRACE_LIMIT values 0..5 now match the control.

ROWS=2000 phase3: 59.50 ns/row at limit=0, 34.10 ns/row at limit=5.

python3 ./pyre/check.py exit=0 — dynasm 358/358, cranelift 358/358,
wasm 354/354. cargo test -p majit-metainterp --release: 0 failed.

Assisted-by: Claude

* majit: carry the preamble's quasi_immutable_deps into the retrace

`compile.py:384-390` builds a retrace's `quasi_immutable_deps` from two
sources — `loop_info.quasi_immutable_deps` first, then
`start_state.quasi_immutable_deps`. majit's `compile_retrace` wrote only
the `loop_info` half (`std::mem::take(&mut unroll_opt.quasi_immutable_deps)`)
because `ExportedState` had no such field.

- `ExportedState` gains `quasi_immutable_deps`, populated at
  `export_state` from the exporting `Optimizer` (`unroll.py:106` /
  `unroll.py:234`).
- `merge_quasi_immutable_deps` becomes `pub(crate)`.
- `compile_retrace` clones the deps off `start_state` before it is moved
  into `unroll_opt.imported_state`, and merges loop_info-then-start_state
  into the value both installation arms (front door and
  `attach_retrace_to_source_guard`) store in
  `last_quasi_immutable_deps`.

`cargo test -p majit-metainterp --release`: 1515 passed, 0 failed.
`pyre/check.py`: exit 0.

Assisted-by: Claude

* majit: store the Cranelift front-entry choice instead of rescanning the target list

`CompiledEntry::front_target_tokens` carried two roles. One is upstream's
`JitCellToken.target_tokens` (history.py:440), which is only scanned
(unroll.py:198/239/277/325) and appended to (unroll.py:297). The other has no
upstream counterpart: `selected_front_target_token` rescanned the same list
for "first non-preamble, else first" to name the LABEL the Cranelift host
enters through, so appending a newly specialized label could move that choice.

- `CompiledEntry` gains `front_entry_index: Option<usize>`, computed by
  `front_entry_index_for` with the same predicate the rescan used.
- Every non-test install site and the three post-hoc list assignments populate
  it; the two fill-when-empty paths only set it when it is still `None`.
- `selected_front_target_token` reads the stored index;
  `selected_front_target_token_from_slice` is deleted and
  `compact_label_values_for_selected_target` resolves through
  `front_entry_index_for`.
- An index, not a cloned `TargetToken`: the list is mutated in place by the
  `walk_const_ptr_refs_mut` GC walk.

Role-A sites are unchanged: `optimize_bridge`'s `&mut Vec` parameter, the GC
walk, `has_compiled_targets`, and the `.first()` tentative JUMP descr.

`cargo test -p majit-metainterp --release`: 1515 passed, 0 failed.
`pyre/check.py`: dynasm 358/358, cranelift 358/358, wasm 354/354.
cel poison ladder (9 row counts, CEL_RETRACE_LIMIT=5 vs 0): all match;
ROWS=2000 phase3 50.5 ns/row at limit=0, 29.7 at limit=5.

Assisted-by: Claude

* majit: latch a declined bridge attempt separately from the resumekey

`MetaInterp::bridge_info` is majit's `self.resumekey`. Upstream writes it
only at session start (pyjitpl.py:2905 / 2939) and never clears it —
`reached_loop_header` (pyjitpl.py:2979-3060) does not touch it, and
`compile.py:392-393 resumekey.compile_and_attach` dispatches on it at the
very end. majit cleared it on four fall-through-to-`compile_loop` paths
(the `BridgeCompileResult::Declined` arm and the "no targets" branch of
each of `CloseLoop` / `CloseLoopWithArgs`) to express a session PHASE,
destroying the resumekey CLASS as a side effect.

Four later readers dispatch on that class and were miscategorising a
declined session:

- pyjitpl.rs `compile_retrace` reads `bridge_info_cloned()`; with it taken
  the retrace installs as a new front door instead of
  `attach_retrace_to_source_guard` — the same defect 10916ce fixed on
  the `RetraceNeeded` arm, still reachable through `Declined`.
- `compile_finish_from_active_session` takes the root FINISH branch
  (pyjitpl.py:3216/3242 dispatches on resumekey class only).
- `jitcode_dispatch/mod.rs` routes to `compile_trace_from_interp`.
- `trace_opcode.rs` falls back to `ctx.inputarg_types()`.

`JitDriver::bridge_attempt_declined` now carries the phase, with the same
lifecycle as its sibling `bridge_body_start_op_count` plus a reset on the
`AbortPermanent` arm, which that sibling lacks. The three phase readers
keep their exact answers: `bridge_info().is_none()` becomes
`is_none() || bridge_attempt_declined`, and the `setup_aborted_bridge_descr`
predicate and the two `has_targets` gates gain `!bridge_attempt_declined`.

`is_bridge_tracing` is left reporting the class. Both its consumers want a
live session, but `self.sym` is not that signal: `start_bridge_tracing`
sets `bridge_info` and runs `rebuild_from_resumedata` before
`self.sym = Some(sym)`, so the window opens while `sym` is `None`. The
comment now records this.

MC_DIAG grows to 38 slots: `bridge_declined_close`,
`bridge_no_targets_close`, `abort_after_declined`, and
`ct_compile_bridge_false` for the `compile_bridge`-returned-false producer,
which had no tally.

Reachability, `PYRE_MC_DIAG=1` over 350 synth fixtures run directly (check.py
prints child stderr only for failing runs, so it cannot census this):

    bridge_declined_close     3   exception_args_virtual
    bridge_no_targets_close   7   7 fixtures, one each
    ct_compile_bridge_false   6   exception_args_virtual
    abort_after_declined      0

The latched paths are live, so the four calls were not dead. No session that
declined went on to abort in this corpus, so the two phase-reader predicates
are exercised only in their behaviour-preserving form.

`cargo test -p majit-metainterp --release`: 1515 passed, 0 failed.
`pyre/check.py`: failure set byte-identical to the same-HEAD baseline
without this change (47 entries; 17 dynasm / 19 cranelift / 11 wasm, all
from the branch base).

Assisted-by: Claude

* majit: reindent the merge-point walk body in jitdriver.rs

8cc2aaf wrapped the body in `loop { ... break; }` without re-indenting it,
so `cargo fmt --all -- --check` reported a diff from line 2614 on.

Assisted-by: Claude

* majit: guard the short-preamble per-op drop and latch the RetraceNeeded gate

`ExtendedShortPreambleBuilder::setup` drops a single unresolvable op the way
`add_op_to_short` leaves one out (shortpreamble.py:283-296, 311-341), but
`short_preamble.ops` and `short_preamble.jump_args` are filled in lockstep, so
a dropped op whose result the short preamble's own JUMP carries leaves
`short_jump_args` naming a position `inline_short_preamble` never inserts into
`mapping` — `unroll.rs:3920`'s `expect("mapping missing jump_arg")`. Restore
the whole-inline decline for that case only.

The two `BridgeCompileResult::RetraceNeeded` arms lost `take_bridge_info()` so
the resumekey class survives into `compile_retrace`; that also left the bridge
gate open. Upstream closes it through `partial_trace` (pyjitpl.py:3003), armed
by an unconditional `retrace_needed` (compile.py:1079), while majit arms it only
when `compile_bridge` exported a state (pyjitpl.rs:11823). Set the attempt latch
on both arms.

`compile_retrace`'s closing-JUMP check said the backend cannot emit a
`jump_to_preamble` JUMP. Cranelift emits one as an external exit
(compiler.rs:13247-13302) and wasm has the same path (codegen.rs:2195-2229).
The real blocker is that wasm dispatches on `has_loop` (codegen.rs:1983): a
retrace has its own LABEL, so `find_loop_label_index`'s trailing-label fallback
makes `has_loop` true and `find_label_args` turns the preamble JUMP into a
back-edge to this trace's own label. Comment corrected; check unchanged.

Assisted-by: Claude

* majit: drop the positional deadframe stamp in start_retrace_from_guard

421d070 added a loop stamping every `recorder.inputargs()` slot from
`fail_values` by position. `compile_bridge` already stamps the bridge input
boxes from the deadframe, zipping `liveboxes` with `frontend_boxes` under
`bridgeopt.py:126 assert len(frontend_boxes) == len(liveboxes)` — the
live-filtered form `resume.py:1267-1282 load_box_from_cpu` has, since it runs
once per live box while `rebuild_from_resumedata` walks the resume data, not
once per `fail_arg_types` slot.

The positional loop also gave a value to slots the resume data never
resurrected. A dead Ref slot then read back as the constant NULL, and
`virtualstate.py:400-405` found it equal to a target label's LEVEL_CONSTANT and
appended a GUARD_VALUE pinning it.

Measured on synth/defaults_reassigned_midloop (dynasm): 4 extra GUARD_VALUEs,
two of them on `ptr(0x0)`; guard_failures 404 -> 812, bridges_compiled 2 -> 4,
loops_compiled 3 -> 4. Bisected to 421d070 — 37b2d78 reports 404.

Restores the committed `.jitstats` baselines on both dynasm and cranelift for
the five fixtures CI reported: defaults_reassigned_midloop,
mapdict_unboxed_type_change_attr, callee_store_global_read_after_call,
foriter_inplace_immutable, inline_callee_constructs_object.

cargo test -p majit-metainterp --release: 1515 passed, 0 failed.

Assisted-by: Claude

* majit: decline the short-preamble inline when the unresolvable op is a guard

`ExtendedShortPreambleBuilder::setup` drops a single op whose args cannot be
resolved and keeps inlining the rest. `short_preamble.ops` holds
`is_guard() || is_always_pure()` ops, and a guard has no result box, so it can
never appear in `short_preamble.jump_args` — the arity test added in the
previous commit cannot reach it. A guard was therefore droppable, and the
closing JUMP would enter a body optimized under that guard's fact without the
guard being replayed.

Upstream has no drop at this point. `ExtendedShortPreambleBuilder.setup`
(shortpreamble.py:460-463) is three field assignments, and
`inline_short_preamble` (unroll.py:372-410) replays every op in `short`,
guards through their own `sop.is_guard()` arm at :405-409 which restamps each
with a `ResumeAtPositionDescr`; `_map_args` (unroll.py:364-370) raises KeyError
rather than dropping. The per-op filtering the comment cited
(shortpreamble.py:128-139, :311-341) runs at EXPORT, before the peeled body is
optimized against the short boxes. The comment claimed that export-time rule as
the inline-time outcome; corrected here.

The drop is now bounded to a pure producer whose result the short preamble's own
JUMP does not carry. A guard, or a producer the JUMP carries, declines the whole
inline so `jump_to_existing_trace` falls back to `jump_to_preamble`
(unroll.py:154/167).

Census: the unresolvable path fires 0 times across 371 pyre/bench fixtures
(MAJIT_LOG=1 against target/release/pyre-dynasm), so this changes nothing pyre
reaches today. `setup` sees a non-coincident arg set only on a retrace close,
which `retrace_limit = 0` (rlib/jit.py:595) keeps dormant.

cargo test -p majit-metainterp --release: 1515 passed, 0 failed.
The five .jitstats fixtures from the previous commit are unchanged.

Assisted-by: Claude

* majit: decline the short-preamble inline when the unresolvable op is an ovf producer

`extract_short_preamble` admits an `is_guard_overflow()` entry only as the
paired successor of an included `is_ovf()` producer, and pushes the producer
first, so the two sit adjacently in `short_preamble.ops`. The guard entry is
argless, so `setup`'s resolvability test is vacuously true for it and it
survives when its producer is dropped for an unresolvable arg. The `is_guard()`
arm added in fbd2ac4 tests the op's own args and cannot see this pairing.

An orphaned GUARD_NO_OVERFLOW reaches `optimize_guard_no_overflow`
(intbounds.rs:737), which reads `last_emitted_opcode`: with no ovf op before it
the guard is removed, and with an unrelated ovf op before it the guard attaches
to that op and registers its inverse pure operations (intbounds.py:222-228) as
facts about the wrong values.

Upstream has no orphan because it never stores the two separately: `use_box`
(shortpreamble.py:398-400) appends `ResOperation(rop.GUARD_NO_OVERFLOW, [])`
immediately after appending an `is_ovf()` op. majit's `use_box` (shortpreamble.rs
:3108) and `insert_dep_recursive` both do the same; only `setup`'s main loop
reads the pair back as two entries.

Declining rather than dropping both: GUARD_NO_OVERFLOW's postprocess registers
inverse pure operations that the target body may already have been optimized
under, so dropping the pair loses facts the same way dropping the guard alone
does.

cargo test -p majit-metainterp --release: 1515 passed, 0 failed.
pyre/check.py --backend dynasm: failure set byte-identical to the parent commit
(16 entries).

Assisted-by: Claude

* majit: correct the bridge-inputarg value-channel comment in compile_trace_inner

The comment said the values on the recorder's inputargs are the ones
`start_retrace_from_guard` seeded from the deadframe. That seeding was removed
in 5c1ec82, so the citation names a channel that no longer exists. The
values that reach this copy are written by the frontend through
`TraceCtx::try_set_opref_concrete`, once per box the resume rebuild resurrects.

Also record what `compile_bridge`'s `PendingBridgeRd` zip actually is:
`prd.liveboxes` is built from every bridge inputarg (pyjitpl.rs:11581) and
zipped positionally with the raw `fail_values`, so it is not the live-filtered
form `bridgeopt.py:126` is cited for -- that line is a length assert. It also
runs after `closing_jump_runtime_boxes` reads the inputargs, which is why the
removed trace-start stamp changed `runtime_boxes` and this one does not.

Comment only; no code change.

Assisted-by: Claude

* majit: decline compile_retrace when the merge-point re-lookup misses instead of assembling an uncut trace

`compile_retrace` re-derives the merge point with
`ctx.get_merge_point_at(green_key, ctx.header_pc)` and, on a miss, fell through
to `else { trace }` and assembled the trace uncut. `combined_ops` still prepends
`partial.ops`, which covers [0, retracing_from), to a body optimized from
position 0, and `root_inputargs` still comes from `partial.inputargs` while the
body references the recorder namespace.

compile.py:347 `trace = metainterp.history.trace.cut_trace_from(start,
inputargs)` is unconditional: `start` is read once at the caller's single
merge-point selection (pyjitpl.py:3019) and handed straight down, so there is no
upstream "merge point not found" state for the fallback to mirror.

Nothing downstream rejects the uncut shape.
`normalize_root_loop_entry_contract` (pyjitpl.rs:747-796) compares LABEL against
JUMP arity within the same optimized output -- both minted by the same peel --
and returns `root_inputargs` untouched. The closing-JUMP cancel
(`closes_on_own_label`, pyjitpl.rs:7581) rejects only foreign-target closes,
which a self-close is not. 65aaf8d records a wrong-cut retrace passing both
gates, attaching, and executing with divergent checksums.

The caller reads `false` as pyjitpl.py:3004 "creation of the loop was
cancelled", which is the outcome it already handles for every other decline in
this function.

The re-lookup itself should not exist; the caller selected this merge point
under `close_header_pc()` and this reads `header_pc`. Threading the selected
merge point down is tracked separately, as is the `mp.position._pos > 0`
conjunct, which has no upstream counterpart -- pyjitpl.py:2902 seeds
`current_merge_points = [(original_boxes, (0, 0, 0, 0, 0))]`, so position 0 is a
valid selection upstream.

cargo test -p majit-metainterp --release: 1515 passed, 0 failed.
pyre/check.py --backend dynasm: failure set byte-identical to the parent commit
(16 entries). `retracing_from` is armed only by `retrace_needed`, gated on
`retrace_limit = 0` (rlib/jit.py:595), so pyre does not reach this path.

Assisted-by: Claude

* majit: tear the session down on the post-take retrace abort arm

`compile_retrace` consumes `self.tracing`, so every `false` it returns after
that point reaches the caller's `if self.tracing.is_none()` arm. That arm
returned `CompileOutcome::Aborted` without touching the session, unlike the two
sibling abort arms beside it which run `clear_retrace_state` +
`warm_state.abort_tracing` + `clear_trace_session`.

The driver's `Aborted` arm calls `abort_trace(false)`, and `abort_trace_live`'s
whole body sits inside `if let Some(ctx) = self.tracing.take()` — already `None`
here, so all of it is skipped and nothing calls `clear_trace_session`. That
leaves `active_trace_session` and `bridge_info` set (pyjitpl.rs:2992-2993 are
their only clear sites) and `profiler_tracing_active` true, because
`leave_profiler_tracing` runs only from `clear_trace_session`. The next trace
start calls `enter_profiler_tracing`, whose `assert!` on that flag is a release
assert, so the process aborts on a later trace rather than here.

The arm now mirrors its siblings. `green_key` is captured at the call site
before `compile_retrace` takes the ctx.

`retracing_from` is also now required rather than degraded: `compile.py:341-347`
takes `start` as a parameter and has no `compile_retrace` without one, so the
`else { trace }` arm is unreachable and can be deleted when the merge-point
selection is threaded down from the caller.

The arm predates this branch; the preceding commit made it the standard exit for
a merge-point re-lookup miss.

cargo test -p majit-metainterp --release: 1515 passed, 0 failed.
pyre/check.py --backend dynasm: failure set byte-identical to the parent commit
(16 entries).

Assisted-by: Claude

* majit: assert the virtualizable element block is a suffix of live_arg_boxes

The write-back skipped `adopt_normalized_virtualizable_elements` whenever the
element count exceeded `live_arg_boxes.len()`. Upstream has no such state:
`pyjitpl.py:2985-2988` normalizes `self.virtualizable_boxes` in place and then
appends it, so every later reader sees the rewrite. Skipping leaves the trace
ctx's copy unnormalized, and a later cut mints a LABEL whose collapsed slots are
missing -- the artifact 7e305fc added this write-back to prevent.
`adopt_normalized_virtualizable_elements` (trace_ctx.rs:2203) already asserts the
same invariant from the other side.

The invariant holds by construction: `collect_jump_args_with_boxes` appends the
element block to the loop-carried list, and
`remove_consts_and_duplicates_untyped` takes `&mut [OpRef]`, so it substitutes
SameAs ops in place and cannot shorten it -- matching `pyjitpl.py:2958-2964`,
which assigns `boxes[i]` rather than removing.

The `n > 0` short-circuit is kept: it skips a call that would copy zero
elements.

cargo test -p majit-metainterp --release: 1515 passed, 0 failed.
pyre/check.py --backend dynasm: the assert does not fire on any of the 366
fixtures, and the failure set is byte-identical to the parent commit
(16 entries).

Assisted-by: Claude

* majit: publish the abort image's operand stack from the walker's OpRef mirror

`capture_frame_stack_for_publish` reads `locals_cells_stack_w`, which a root
walk never writes: `setarrayitem_vable_via_metainterp` mirrors into a concrete
frame only through `current_inline_vable_target`, i.e. only for a frame an
INLINE level owns. Measured at the single adopt in `list_length_hint_validate`,
the slot array reads `[0]` while the walker's mirror holds the live operand.
Resolve the stack from `WalkContext::vstack_boxes` instead
(`capture_frame_stack_from_mirror`), keyed on `vstack_cur_pypc`.

Convert a walk abort into a blackhole resume only for the classes whose MIFrame
image is complete (`DispatchError::leaves_complete_image`). Every RPython
`SwitchToBlackhole` is a stop-tracing decision taken with the registers bound,
which is why `_copy_data_from_miframe` (`blackhole.py:1713-1730`) copies the
banks unconditionally and has no failing path. The walker resolves registers
lazily against the live trace context, so a second family exists where the
abort IS the report that a value is unavailable (`RegisterReadUnbound`,
`*NotConcrete`); converting one resumes on the hole the walker refused to read,
and broke `list_length_hint_validate` and `raise_reg_unbound_jitstress`.

`WalkEndCommitLeg::WalkAbort` carries the leg and `PYRE_WALKABORT_OFF` disables
it. 353 synth fixtures produce identical output with the leg on and off.

Assisted-by: Claude

* majit: forward the abort image mirror stack as a GC root, and stand aside for the qmut leg

The walk-abort mirror stack is resolved to concrete refs when the abort
latches and then held in a plain `Vec` until the walk-end adopter publishes
it. That is the same collector-invisible window as the latched MIFrame Ref
bank beside it, which `fbw_store_journal_walk_roots` already forwards, so
forward the mirror slots there too. `flush_with_latched_stack` does not need
this because it roots its stack (`push_resume_ref_roots`) for the duration of
one flush call; this image outlives the call that builds it.

`ForceQuasiImmutable` is the counterpart of `pyjitpl.py:1116`
`raise SwitchToBlackhole(Counters.ABORT_FORCE_QUASIIMMUT)`, taken with the
registers bound after `do_force_quasi_immutable` ran, so classify it in
`leaves_complete_image`. Exclude it at the general adopt site instead: it owns
`flush_qmut_abort_state`, which resumes AT the forcing opcode rather than
finishing the frame past it.

Assisted-by: Claude

* majit: rustfmt publishes_root_stack, and re-cite the walk-abort gate against the current corpus

`cargo fmt --all -- --check` reported the `publishes_root_stack` binding.

The gate comment cited `raise_reg_unbound_jitstress`, which no longer
reproduces: #973 refuses the frame when a live color has no concrete, and that
covers the failure the comment described. Re-measured with the classification
removed, 351 of 353 synth fixtures are unchanged and two break —
`list_length_hint_validate` (its one `RegisterReadUnbound` walk, pc=456 reg=3
bank=r, underflows the operand stack) and `getframe_while_subwalk_decline_shapes`
(an inline-escape shape whose caller banks are incomplete, dying on an unwired
blackhole opcode; the fixture exists to pin that decline).

Assisted-by: Claude

* jit: reuse catch-landing edge exc_value instead of a producerless fresh Ref

The catch-landing exception-value push allocated a fresh Ref Variable with
no defining op. Regalloc treats a producerless Ref as dead-until-first-use
and coalesces it onto a body colour (a local live across the `try`). On a
guard-failure bridge that re-walks the handler, that body colour's def sits
in the skipped prefix, so the pushed value is read unbound
(RegisterReadUnbound), aborting the walk.

Reuse the landing's own last_exception value Variable (the edge pair set by
exception_landing_state, whose colour generate_last_exc writes) as the
pushed value. It is re-derived on any bridge and interferes with the
surrounding live ranges, so it earns a distinct colour. Same fix as the
PUSH_EXC_INFO last_exc_value re-read.

Restores list_length_hint_validate to its recorded jit-stats baseline
(loops_aborted 34->14, guard_failures 4923->828); raise_reg_unbound_jitstress
unchanged. dynasm synthetic gate: 352 passed, only exception_args_virtual red.

Assisted-by: Claude

* majit: mint the rebuilt bridge inputargs from types alone again

`compile_bridge_trace` copied each recorder inputarg's concrete value onto
the freshly minted `InputArg`. The channel that writes those values is not
resume-walked: pyre's bridge setup stamps `try_set_opref_concrete` over every
Ref input arg positionally, `Value::Ref(0)` included, while
resume.py:1267-1282 `load_box_from_cpu` constructs a box only for a live
resume entry. A dead Ref slot therefore reached `closing_jump_runtime_boxes`
as a constant NULL and the NULL folded into the trace as an operand: the
dynasm backend emitted `mov x0, #0` for the `jit_next` FOR_ITER residual, so
`baseobjspace::next` dereferenced NULL.

Measured on dynasm / macOS aarch64 with `lib-python/3/test/test_strftime.py`
under `MAJIT_STRICT=1 MAJIT_STATS=1`: 3/27 runs SIGSEGV with the copy, 1/120
without it, `loops_compiled` 21 and `bridges_compiled` 21 -> 20.

pyre/check.py: dynasm 370/370, cranelift 370/370, wasm 365/366 (the remaining
`synth/pickle_terminal_raise_resume` `loops_aborted 54 -> 59` failure is
unchanged from before this commit).

Assisted-by: Claude

* bench: re-record the 53 dynasm/cranelift jit-stats baselines the branch moved

`check.py --snapshot-diff` reported 53 rows, all `jit-stats diff` and none
`snapshot output diff`, so no fixture's output changed. Recorded with
`check.py --snapshot`; only `.jitstats` files moved.

Direction of the 109 field moves, against the gate groups in check.py:
93 in the improving direction, 0 rises in a badness field, 0 falls in
`loops_compiled`, and 16 rises in `guard_failures` — all inside the
`base + max(base // 4, 2)` band the field is gated on.

Twelve of the sixteen `guard_failures` rises accompany a `loops_compiled`
rise or a `loops_aborted` fall in the same fixture, i.e. more of the frame
compiles so more guards exist to fail: `attr_cache_invalidation` 802 -> 1002
with `loops_compiled` 1 -> 2, `exception_reentry_guard_finally_residual`
2044 -> 2261 with `loops_aborted` 5 -> 0 and `loops_compiled` 2 -> 3,
`goto_if_not_same_box` 0 -> 2 with `loops_compiled` 0 -> 2,
`blackhole_inlined_callee_local_after_escape` 0 -> 1 with `loops_aborted`
10 -> 5. The remaining four are +1 or +2 on cranelift.

`loops_aborted` summed over the 53 recorded files: 120 -> 56.

The wasm baselines are unchanged; main re-recorded them in #996.

Assisted-by: Claude

* jit: trace newbool as a truth guard plus the prebuilt bool, not a residual call

The six compare/bitwise specializations boxed their result with
`emit_trace_bool_value_from_truth`, which records a
`CallR(jit_bool_value_from_truth)` carrying
`EffectInfo::new(CannotRaise, None)`. That effect set is not pure, so no
optimizer pass removes the call even when the box is dead; on
`pyre/bench/raise_catch_loop.py` two survived per iteration.

`baseobjspace.py:896-900 newbool` returns one of the prebuilt
`boolobject.py:79-80` singletons, and `pyjitpl.py:511-534` records that
branch as `GUARD_TRUE`/`GUARD_FALSE` plus a promoted constant
(`pyjitpl.py:525-526 replace_box`). `walker_newbool_guarded` emits that
shape: the directional guard over the raw truth, `w_bool_from(observed)`
as a Const ref, and `bool_box_truth_record` against the promoted Int so
the following `is_true` folds. The residual box stays as the fallback.

The new emission is restricted to the shape the forward JitCode lookahead
already recognizes, so it is split out of `compare_box_provably_dead`:
`classify_compare_box_use` returns `CompareBoxUse::FeedsBranchOnly`
for conditions 1-3 (single reader, that reader is the `is_true` residual,
scan terminates at `goto_if_not`) and carries condition 4 (`dst_reg`'s
color dead at both arms) as a field. `compare_box_provably_dead` keeps
requiring both; the guarded emission requires only the shape.

`raise_catch_loop` traces 64 ops -> 44 after opt with no `CallR`, and its
recorded `loops_compiled=1 bridges_compiled=1 loops_aborted=0
guard_failures=201` is unchanged. check.py --snapshot-diff: 370/370
dynasm, 370/370 cranelift, 366/366 wasm, jit-stats diff clean.

Assisted-by: Claude

* bench: size exc_in_loop_divzero_continue and finally_bare_raise above the exec floor

`check.py` floors each side's startup-subtracted exec at `EXEC_TIME_FLOOR_S`
(0.005s off Windows, check.py:112). Measured startup-subtracted pypy exec was
0.0018s for `exc_in_loop_divzero_continue` (N=120000) and 0.0025s for
`finally_bare_raise` (N=2000000), so both ratio gates were dividing by the
floor. The ratios they reported, 4.7x and 4.3x on dynasm, were 12.8x and 8.4x
against real work, and a startup sample landing high moved the reported number
without any change in pyre's own time — the wandering single-OS red that
check.py:104-110 already describes for the startup half of the same divisor.

N goes to 6000000 and 32000000. pypy exec is then 0.036s and 0.032s, and the
ratios are 2.1x/2.5x and 1.8x/2.7x (dynasm/cranelift) against gates of 20 and
17; both fall rather than rise with size, so the compiled tier amortizes the
added work. No gate number is changed. Cost: cpython 0.73s/1.13s, pyre
0.13s/0.12s, wasm 0.17s/0.12s.

`exc_in_loop_divzero_continue`'s recorded jitstats move with the workload:
guard_failures 360 -> 603 and bridges_compiled 0 -> 3 for 50x the iterations
(sub-linear, i.e. the compiled loop holds), with loops_compiled=3,
loops_aborted=0, internal_compile_panics=0 and descr_set_* unchanged.
`finally_bare_raise`'s baseline is unchanged.

Assisted-by: Claude

* pyre-jit-trace: name try_walker_inline_builtin_call's four declines under the inline diag

The builtin-helper inline path returned Ok(None) silently at each of its
four screens.  Print which one fired, the callable's type, and the
BuiltinCode.func address, so a builtin missing from the
`builtin_wrapper_indirect_graphs` PBC family is distinguishable from a
non-Function callable.

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