Skip to content

jit: fix a lost conditional store in an inlined callee, widen the method-form inline - #942

Merged
youknowone merged 3 commits into
mainfrom
perf-bridge
Aug 1, 2026
Merged

jit: fix a lost conditional store in an inlined callee, widen the method-form inline#942
youknowone merged 3 commits into
mainfrom
perf-bridge

Conversation

@youknowone

@youknowone youknowone commented Aug 1, 2026

Copy link
Copy Markdown
Owner

Four commits. The performance table in the original description of this PR was wrong; it has been replaced — see "Correction" at the bottom.

majit: keep the nonstandard vable element heapcache in sync

Wrong-code fix, and the reason this branch exists. A force-materialized inline-callee frame's locals_cells_stack_w takes the nonstandard-virtualizable path, whose element heapcache (a) keyed reads and writes off different GetfieldGcR array-base OpRefs and (b) recorded a SETARRAYITEM_GC without updating the cache. Reads therefore kept returning the value seeded at frame construction.

Repro — i += 1 writes W_Int(2189) to slot 1, and the immediately following LOAD_FAST i pushes the stale W_Int(2188):

def step(s, i):
    c = s[i]
    if c == 9:
        i += 1
        c = c + s[i]
    return c, i + 1

src = [9, 2, 5, 6] * 1000
i = 0; out = []
while i < len(src):
    c, i = step(src, i)
    out.append(c)
# want [11, 5, 6] * 1000; got a 3001-element list with c == 18 entries

origin/main prints BAD len=3001; this branch prints OK.

trace_ctx.rs gains nonstandard_vable_array_base (forwards the cached field box on a hit, publishes with getfield_now_known) and TraceCtx::execute_setarrayitem_gc (records, then calls heapcache.setarrayitem). gen_store_back_in_vable keeps the raw vable_setarrayitem_descr.

This commit is worth 0.98x–1.00x — it is a correctness fix with no measurable performance effect.

jit: inline a method-form callee whose body reads self.attr

try_walker_inline_user_call passed allow_method_load_attr = false, so method_form_callee_body_supported declined every b.at(i) whose callee body contains a LoadAttr residual — the ordinary accessor shape def at(self, i): return self.v + i. The same callee already inlined when stored as a bound method (m = b.at; m(i)).

jit: decline the widened method-form inline for raise-bearing and FOR_ITER-deferred callees

The widening on its own discarded the enclosing loop for two body shapes:

  • A body containing a raise. The sub-walk records into the handler region, and a guard whose resume coordinate lands on the Reraise asks collect_callee_active_boxes for ref registers the recorded path never wrote. The decline arrives mid-recording on an opcode that is not effect-free, so it has no mid-body carrier.
  • A FOR_ITER CalleeReplaySafety::DeferredCall admission whose deferred call resolves to a builtin: fbw_abort_nested_unjournaled_residual spends one abort and then denies the callee.

jit: key the raise decline on widened_method_form, not allow_method_load_attr

The commit above keyed the raise decline on allow_method_load_attr. Five entries pass that flag, and four of them — the type.__call__ __init__ fold, the exception __str__/__repr__ override, and the property getter and setter — passed it before the widening, so the decline also withdrew inlines that already happened. A method-form body with no attribute read has method_form_callee_body_supported == true, hence widened_method_form == false, and is now admitted again.

class B:
    def bump(self, n):
        if n < 0:
            raise ValueError(n)
        return n + 1
for i in range(400000): t += b.bump(i)
origin/main before after
dynasm 0.011s 0.504s 0.022s
cranelift 0.021s 0.418s 0.018s

None of the 357 synthetic benches covered this shape.

Measurements

Against a binary built from origin/main, min of 7 interleaved runs, user+sys CPU with empty-program startup subtracted. The two binaries were checked apart by the wrong-code repro above (mainBAD len=3001, branch → OK).

bench dynasm main → branch cranelift main → branch
synth/inline_subwalk_mutating_residual 0.225s → 0.075s (3.0x) 0.363s → 0.089s (4.1x)
synth/sre_pattern_methods 0.592s → 0.600s (0.99x) 0.652s → 0.658s (0.99x)
synth/sre_wasm_min 0.325s → 0.341s (0.95x) 0.381s → 0.380s (1.00x)
synth/sre_wasm_min1 0.250s → 0.248s (1.01x) 0.285s → 0.285s (1.00x)
synth/type_metatype_method_call 0.079s → 0.091s (0.88x) 0.086s → 0.075s (1.15x)
property_mutates / radd_consumed / user_iterator / getframe_multiframe 0.94x–1.08x 0.94x–1.03x

Splitting the win by commit on inline_subwalk_mutating_residual (min of 9 interleaved runs): the heapcache fix alone is 0.98x (dynasm) / 1.00x (cranelift); the widening plus its declines is 3.89x / 3.82x. The entire measured gain comes from the widening, and the entire correctness gain from the fix.

inline_subwalk_mutating_residual's gate moves 200 → 40 and inline_subwalk_property_mutates's 80 → 50.

Verification

  • check.py --backend dynasm 357/357
  • check.py --backend cranelift 357/357
  • cargo test --all --no-default-features 7324 passed, 0 failed (run before the last commit)

Correction

The first version of this description claimed sre_pattern_methods 0.90s → 0.67s, sre_wasm_min 0.55s → 0.43s and similar gains on four benches. Those numbers were real but did not mean what the description said: the "before" arm carried the heapcache fix and differed from the "after" arm only by the widening, so the table measured the widening's own regression being repaired, not an improvement over main. Against main those four benches are flat. A second error compounded it — check.py runs cargo build itself (pyre/check.py:993), so an A/B done by swapping prebuilt binaries into target/release runs the same build on both arms.

Note on #935

#935 carries 0d8413d3c4 and 3f560c4ca0, which are patch-id-identical to this PR's first two commits (b2a00ef0… and c36e7c6e…), plus five unrelated commits. It does not carry the two decline commits, so it is expected to reproduce both the loop-discard on the sre benches and the 46x/20x raise-body regression. This repository squash-merges, so after this PR lands the squashed commit's patch-id will match neither original and rebase dedup will not drop them — they have to be dropped from #935 explicitly.

authored by Claude

Two halves of the same gap on the NONSTANDARD virtualizable array path
(a force-materialized inline-callee frame's locals_cells_stack_w):

- Every `getarrayitem_vable` / `setarrayitem_vable` arm recorded its own
  fresh `GetfieldGcR` for the array base. The per-array element cache is
  keyed by that base OpRef, so reads and writes landed in different
  submaps. Route all four arms through a new
  `nonstandard_vable_array_base`, which forwards the cached field box on
  a hit and publishes the recorded op with `getfield_now_known`, the way
  `_opimpl_getfield_gc_any_pureornot` does.

- The nonstandard store recorded `SETARRAYITEM_GC` without updating the
  heapcache. `execute_setarrayitem_gc` (pyjitpl.py) records and then
  calls `heapcache.setarrayitem`; add that pairing as
  `execute_setarrayitem_gc` and call it from the nonstandard arm.
  `gen_store_back_in_vable` keeps using the raw
  `vable_setarrayitem_descr`, matching its direct op recording upstream.

With both, a local written inside an inlined callee no longer keeps
reading the value the element cache was seeded with at frame
construction.

Assisted-by: Claude
`try_walker_inline_user_call` passed `allow_method_load_attr = false`, so
`method_form_callee_body_supported` declined every `b.at(i)` whose callee
body contains a `LoadAttr` residual — the ordinary accessor shape
`def at(self, i): return self.v + i`. The same callee already inlined
when stored as a bound method (`m = b.at; m(i)`).

Pass `true` there, and document what the helper declines.

Measured on the call-shape micro (400k iterations, dynasm):
`b.at(i)` 1.54s -> 0.08s, matching the plain-function and bound-method
forms; `synth/sre_pattern_methods` prints 280000, the pypy3 value.

Assisted-by: Claude
…_ITER-deferred callees

`try_walker_inline_user_call` passes `allow_method_load_attr = true`, which
admits an unbound method-form callee whose body reads `self.attr`.  Two body
shapes reached that way discarded the enclosing loop instead of falling back
to a residual call:

- A body containing a `raise`.  The sub-walk records into the handler region,
  and a guard whose resume coordinate lands on the `Reraise` asks
  `collect_callee_active_boxes` for ref registers the recorded path never
  wrote.  That decline arrives mid-recording on an opcode that is not
  effect-free, so it has no mid-body carrier and the trace is thrown away.
- A FOR_ITER `CalleeReplaySafety::DeferredCall` admission whose deferred call
  resolves to a builtin: `fbw_abort_nested_unjournaled_residual` spends one
  abort and then denies the callee.

Decline both at the callsite.  Measured, median of 3:

  dynasm     sre_pattern_methods 0.90s -> 0.67s, sre_wasm_min 0.55s -> 0.43s,
             sre_wasm_min1 0.45s -> 0.33s, type_metatype_method_call 0.35s -> 0.28s
  cranelift  the same four return to 0.72s / 0.47s / 0.36s / 0.23s, matching
             their times with `allow_method_load_attr = false`

`loops_compiled`, `bridges_compiled` and `loops_aborted` on all four match the
`allow_method_load_attr = false` counters.  `inline_subwalk_mutating_residual`
keeps the widening's gain at 13.0x pypy3 (dynasm) / 18.3x (cranelift), so its
gate moves 200 -> 40, and `inline_subwalk_property_mutates` 80 -> 50.

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

Assisted-by: Claude
@coderabbitai

coderabbitai Bot commented Aug 1, 2026

Copy link
Copy Markdown

Review Change Stack

Walkthrough

Changes

Nonstandard virtualizable array caching

Layer / File(s) Summary
Shared array-base resolution
majit/majit-metainterp/src/trace_ctx.rs
Array reads and writes reuse cached array bases. Cache misses record and cache GetfieldGcR.
Array store heapcache updates
majit/majit-metainterp/src/trace_ctx.rs
Array stores record SetarrayitemGc and publish written elements to the heapcache.

Method-form call inlining

Layer / File(s) Summary
Method-form body validation
pyre/pyre-jit-trace/src/jitcode_dispatch/inline_call.rs
Method-form bodies detect LoadAttr residuals. Widened validation rejects bodies containing raises.
Inline admission and loop guards
pyre/pyre-jit-trace/src/jitcode_dispatch/inline_call.rs, pyre/bench/synth/inline_subwalk_mutating_residual.py, pyre/bench/synth/inline_subwalk_property_mutates.py
Resolved calls allow selected LoadAttr residuals. Deferred loop admission excludes widened calls. Benchmark thresholds are reduced.

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

Sequence Diagram(s)

sequenceDiagram
  participant ResolvedUserCall
  participant method_form_callee_body_supported
  participant DeferredLoopAdmission
  participant ResidualCall
  ResolvedUserCall->>method_form_callee_body_supported: inspect method-form callee body
  method_form_callee_body_supported-->>ResolvedUserCall: report body support
  ResolvedUserCall->>DeferredLoopAdmission: evaluate widened call
  DeferredLoopAdmission-->>ResolvedUserCall: reject widened deferred-loop admission
  ResolvedUserCall->>ResidualCall: use residual call when validation fails
Loading

Possibly related PRs

Poem

A rabbit hops through cached arrays,
Bases load once, then travel light.
Method calls inspect their bodies,
Widened loops pause when checks say “not right.”
Thresholds fall beneath the moon. 🐇

🚥 Pre-merge checks | ✅ 5
✅ Passed checks (5 passed)
Check name Status Explanation
Docstring Coverage ✅ Passed No functions found in the changed files to evaluate docstring coverage. Skipping docstring coverage check.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title clearly summarizes both primary changes: fixing a lost conditional store and widening method-form inlining.
✨ Finishing Touches
📝 Generate docstrings
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch perf-bridge

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

❤️ Share

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

@github-actions

github-actions Bot commented Aug 1, 2026

Copy link
Copy Markdown

🤖 Codex parity review

Static analysis of this diff vs the local RPython/PyPy sources (commit d7a4a79).
Updated: 2026-08-01T03:02:40.418Z

Files in the reviewed diff
majit/majit-metainterp/src/trace_ctx.rs
pyre/bench/synth/inline_subwalk_mutating_residual.py
pyre/bench/synth/inline_subwalk_property_mutates.py
pyre/pyre-jit-trace/src/jitcode_dispatch/inline_call.rs

1. Regressions to PyPy parity introduced by this patch

None.

2. Other mismatches introduced by this patch

  • majit/majit-metainterp/src/trace_ctx.rs:3433-3438 ↔ rpython/jit/metainterp/pyjitpl.py:931-946 — the new cached array-base path returns the cached field without performing PyPy’s live-field sanity load/assertion, and counts GetfieldGcR; PyPy performs the check and records HEAPCACHED_OPS as GETFIELD_GC_I.

  • majit/majit-metainterp/src/trace_ctx.rs:3440-3446 ↔ rpython/jit/metainterp/pyjitpl.py:948-949 — on cache miss, the new helper records GetfieldGcR and caches it without executing the field load or attaching its runtime value; PyPy executes GETFIELD_GC_R and caches the resulting value-bearing box.

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

  • majit/majit-metainterp/src/trace_ctx.rs:4060-4063, 4197-4201 ↔ rpython/jit/metainterp/pyjitpl.py:1219-1227 — nonstandard virtualizable integer/float array reads record GETARRAYITEM_GC directly and do not consult the array heapcache; PyPy delegates to opimpl_getarrayitem_gc_{i,f}, whose heapcache lookup can return the cached box without recording.

  • majit/majit-metainterp/src/trace_ctx.rs:4132-4138 ↔ rpython/jit/metainterp/pyjitpl.py:1219-1227 — the reference read path only recovers a concrete shadow from the cache, then still records GETARRAYITEM_GC_R; PyPy’s delegated operation returns the cached item box itself and avoids recording on a cache hit.

4. Structural adaptations

  • majit/majit-metainterp/src/trace_ctx.rs:3430-3446 ↔ rpython/jit/metainterp/pyjitpl.py:929-950 — Rust represents cached boxes as OpRef plus separately tracked Value, whereas PyPy’s Box carries the runtime payload directly; the Rust helper therefore separates operation recording, cache publication, and value/shadow tracking.

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

ℹ️ 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 +2792 to +2793
let declined = if allow_method_load_attr {
callee_body_contains_raise(body.code)

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2 Badge Decline method bodies containing reraise

When a newly admitted method-form callee contains a bare raise (for example, try: return self.attr followed by except: raise), its JitCode contains the distinct reraise opcode, but callee_body_contains_raise only recognizes raise. This predicate therefore admits exactly the handler path described above as unsafe, allowing the missing-register resume failure to discard the enclosing compiled loop instead of leaving the call residual; classify reraise as raise-bearing here as well.

Useful? React with 👍 / 👎.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Actionable comments posted: 1

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@pyre/pyre-jit-trace/src/jitcode_dispatch/inline_call.rs`:
- Around line 416-422: Update the documentation for
method_form_callee_body_supported to reflect that it is used with both
allow_method_load_attr = false and true, including widened method-form call
detection; remove the claim that callers only pass false and describe the
helper’s behavior without limiting it to the narrower inline path.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: ASSERTIVE

Plan: Pro Plus

Run ID: 21d30f7c-6c3a-42a4-8ab8-dc68b9e2a857

📥 Commits

Reviewing files that changed from the base of the PR and between 5785b45 and d7a4a79.

📒 Files selected for processing (4)
  • majit/majit-metainterp/src/trace_ctx.rs
  • pyre/bench/synth/inline_subwalk_mutating_residual.py
  • pyre/bench/synth/inline_subwalk_property_mutates.py
  • pyre/pyre-jit-trace/src/jitcode_dispatch/inline_call.rs

Comment on lines +416 to +422
/// Whether a method-form callee body is free of `LoadAttr` residuals.
///
/// Consulted only by the entries that pass `allow_method_load_attr = false`.
/// A `self.attr` read in the body is what makes it answer `false`, which is the
/// common shape (`def at(self, i): return self.v + i`), so an entry that opts
/// out of the check trades a narrower inline surface for the ability to inline
/// ordinary accessor methods.

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

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

Correct the helper documentation.

Line 418 is no longer correct. method_form_callee_body_supported is also called with allow_method_load_attr = true at Line 2704 to identify widened method-form calls.

Proposed fix
-/// Consulted only by the entries that pass `allow_method_load_attr = false`.
+/// The narrow method-form path uses this result to decline `self.attr` reads.
+/// The widened path also uses this result to identify widened method-form calls.
📝 Committable suggestion

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

Suggested change
/// Whether a method-form callee body is free of `LoadAttr` residuals.
///
/// Consulted only by the entries that pass `allow_method_load_attr = false`.
/// A `self.attr` read in the body is what makes it answer `false`, which is the
/// common shape (`def at(self, i): return self.v + i`), so an entry that opts
/// out of the check trades a narrower inline surface for the ability to inline
/// ordinary accessor methods.
/// Whether a method-form callee body is free of `LoadAttr` residuals.
///
/// The narrow method-form path uses this result to decline `self.attr` reads.
/// The widened path also uses this result to identify widened method-form calls.
/// A `self.attr` read in the body is what makes it answer `false`, which is the
/// common shape (`def at(self, i): return self.v + i`), so an entry that opts
/// out of the check trades a narrower inline surface for the ability to inline
/// ordinary accessor methods.
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@pyre/pyre-jit-trace/src/jitcode_dispatch/inline_call.rs` around lines 416 -
422, Update the documentation for method_form_callee_body_supported to reflect
that it is used with both allow_method_load_attr = false and true, including
widened method-form call detection; remove the claim that callers only pass
false and describe the helper’s behavior without limiting it to the narrower
inline path.

@youknowone
youknowone merged commit 08d2a6f into main Aug 1, 2026
18 of 19 checks passed
@youknowone
youknowone deleted the perf-bridge branch August 1, 2026 05:52
youknowone added a commit that referenced this pull request Aug 1, 2026
…hod-form inline (#942)

* majit: keep the nonstandard vable element heapcache in sync

Two halves of the same gap on the NONSTANDARD virtualizable array path
(a force-materialized inline-callee frame's locals_cells_stack_w):

- Every `getarrayitem_vable` / `setarrayitem_vable` arm recorded its own
  fresh `GetfieldGcR` for the array base. The per-array element cache is
  keyed by that base OpRef, so reads and writes landed in different
  submaps. Route all four arms through a new
  `nonstandard_vable_array_base`, which forwards the cached field box on
  a hit and publishes the recorded op with `getfield_now_known`, the way
  `_opimpl_getfield_gc_any_pureornot` does.

- The nonstandard store recorded `SETARRAYITEM_GC` without updating the
  heapcache. `execute_setarrayitem_gc` (pyjitpl.py) records and then
  calls `heapcache.setarrayitem`; add that pairing as
  `execute_setarrayitem_gc` and call it from the nonstandard arm.
  `gen_store_back_in_vable` keeps using the raw
  `vable_setarrayitem_descr`, matching its direct op recording upstream.

With both, a local written inside an inlined callee no longer keeps
reading the value the element cache was seeded with at frame
construction.

Assisted-by: Claude

* jit: inline a method-form callee whose body reads self.attr

`try_walker_inline_user_call` passed `allow_method_load_attr = false`, so
`method_form_callee_body_supported` declined every `b.at(i)` whose callee
body contains a `LoadAttr` residual — the ordinary accessor shape
`def at(self, i): return self.v + i`. The same callee already inlined
when stored as a bound method (`m = b.at; m(i)`).

Pass `true` there, and document what the helper declines.

Measured on the call-shape micro (400k iterations, dynasm):
`b.at(i)` 1.54s -> 0.08s, matching the plain-function and bound-method
forms; `synth/sre_pattern_methods` prints 280000, the pypy3 value.

Assisted-by: Claude

* jit: decline the widened method-form inline for raise-bearing and FOR_ITER-deferred callees

`try_walker_inline_user_call` passes `allow_method_load_attr = true`, which
admits an unbound method-form callee whose body reads `self.attr`.  Two body
shapes reached that way discarded the enclosing loop instead of falling back
to a residual call:

- A body containing a `raise`.  The sub-walk records into the handler region,
  and a guard whose resume coordinate lands on the `Reraise` asks
  `collect_callee_active_boxes` for ref registers the recorded path never
  wrote.  That decline arrives mid-recording on an opcode that is not
  effect-free, so it has no mid-body carrier and the trace is thrown away.
- A FOR_ITER `CalleeReplaySafety::DeferredCall` admission whose deferred call
  resolves to a builtin: `fbw_abort_nested_unjournaled_residual` spends one
  abort and then denies the callee.

Decline both at the callsite.  Measured, median of 3:

  dynasm     sre_pattern_methods 0.90s -> 0.67s, sre_wasm_min 0.55s -> 0.43s,
             sre_wasm_min1 0.45s -> 0.33s, type_metatype_method_call 0.35s -> 0.28s
  cranelift  the same four return to 0.72s / 0.47s / 0.36s / 0.23s, matching
             their times with `allow_method_load_attr = false`

`loops_compiled`, `bridges_compiled` and `loops_aborted` on all four match the
`allow_method_load_attr = false` counters.  `inline_subwalk_mutating_residual`
keeps the widening's gain at 13.0x pypy3 (dynasm) / 18.3x (cranelift), so its
gate moves 200 -> 40, and `inline_subwalk_property_mutates` 80 -> 50.

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

Assisted-by: Claude
youknowone added a commit that referenced this pull request Aug 1, 2026
…base/top interpreter root stack (#930)

* macros: drop #[inline(never)] from the dont_look_inside expansion

`expand_dont_look_inside_attribute` backs both `#[dont_look_inside]` and
`#[dont_look_inside_cannot_raise]` (~636 annotated functions) and emitted
`#[inline(never)]` next to the tracing-policy marker. The policy rides the
`_jit_look_inside_` marker const that `front/llbc_hints.rs` harvests from the
extracted LLBC, and `charon cargo --mir` disables the MIR optimizations, so
the attribute did not change what the tracer sees. `rlib/jit.py:133-140` sets
`_jit_look_inside_ = False` and leaves the backend's inliner alone.

Measured on gc22_any.py / gc22_all.py (user CPU, min of 15, interleaved,
order rotated): 0.9150 and 0.9090. A pure integer loop that never reaches the
rooting path holds at 1.0000. Reachable Python recursion depth goes
161300 -> 167700, and `stack_check.rs` measures the real stack pointer, so
interpreter frames did not grow. The release binary grows 3.0%.

Two comments described the removed behaviour and are corrected: the
`dont_look_inside` doc named a `#[majit_opaque]` marker as the tracer's
detection path (the fn-local `_MAJIT_OPAQUE` const has no consumers), and
`gc_roots.rs` attributed a per-`with` `_tlv_get_addr` resolve to the
thread-local, where disassembly shows that resolve is shared across the two
accesses and the repeated cost is the initialization-state load.

Assisted-by: Claude

* jit-trace: assert one runtime fnaddr per build-time address

`patch_constants_i_fnaddrs` builds `correspondence[build_fnaddr] =
runtime_fnaddr` with a plain `HashMap::insert`. The key comes from the build
process and the value from the running one, so a build-time address naming
two functions whose runtime addresses differ kept only the last write, and
one callee's `constants_i` constant was patched to the other's address —
a residual call to the wrong target, with no decline and no panic.

Registering several path spellings for one function is deliberate
(`jit_fnaddr.rs` lists both `pyre_object::listobject::jit_list_reverse` and
`pyre_object::jit_list_reverse`); those agree on the runtime address, so the
assertion fires only on a disagreement. It covers every binding rather than
only those surviving the `!=` filter, since an alias pair straddling that
filter leaves the same hole.

Assisted-by: Claude

* listobject: keep drain_list_append individually addressable

The body forwards verbatim to `w_list_append`, so once the tracing-policy
attribute stopped emitting `#[inline(never)]` the callee inlined and the two
functions became byte-identical. MSVC links with `/OPT:ICF` by default and
folded them, giving one build-time address to two registered residual-call
targets; `patch_constants_i_fnaddrs` then cannot tell which callee a
`constants_i` entry meant, and its assertion fired across 24 `pyre-jit` tests
on windows-latest.

`#[inline(never)]` here is an addressability requirement rather than a tracing
one, so it is stated at the definition: keeping the forwarding call keeps the
two bodies distinct.

A sweep of the 58 `dont_look_inside` functions whose body is a single call
found this to be the only pair that is both same-signature and registered at
both ends. `eval::call_depth` is unregistered, `bigint_gc_type_id` and
`get_recursion_limit` forward to unregistered targets, and `w_list_new_empty`
and `hash_str_hooked_bytes` differ from their callees in arity.

Assisted-by: Claude

* jit_fnaddr: test that no two registered functions share an address

`patch_constants_i_fnaddrs` rewrites residual-call constants through a
build-address to runtime-address map, so an address standing for two functions
sends one callee's call to the other. Its assertion only fires once that patch
path runs; this covers the registry directly.

Several path spellings for one function are deliberate — the module path and
the crate-root re-export both appear — so the check is on the leaf name: two
distinct leaves on one address means unrelated functions were folded.

The test observes only the address space it runs in. The MSVC fold happened in
the build-script binary while the test binary kept the two apart, so a registry
that passes here can still hand `runtime_fnaddr_patch` an ambiguous build
address.

Assisted-by: Claude

* macros: confine #[inline(never)] to the release-gil surface

`expand_elidable_attribute` (`#[elidable]`, `#[elidable_cannot_raise]`,
`#[elidable_or_memerror]`), `elidable_promote`, `expand_call_surface_attr`
(`#[jit_may_force]`, `#[jit_loop_invariant]`) and `look_inside_iff`'s
trampoline each emitted `#[inline(never)]` next to their tracing-policy marker.

Upstream keeps the two concepts apart. `rlib/jit.py:72 elidable` sets
`_elidable_function_ = True`, `loop_invariant` sets `_jit_loop_invariant_`, and
suppressing the backend inliner is a separate flag, `_dont_inline_`
(`objectmodel.py:214`), read by `translator/backendopt/inline.py:565`. No
elidable helper upstream carries it, and `jit_may_force` has no upstream
decorator at all — `EF_FORCES_VIRTUAL_OR_VIRTUALIZABLE` is derived from the
analyzed operations in `effectinfo.py:401-404`.

`#[jit_release_gil]` is the exception and keeps the attribute: `rffi.py:219`
sets `call_external_function._dont_inline_ = True` beside `:220
_gctransformer_hint_close_stack_ = True`, explained at `:232` as "don't inline,
as a hack to guarantee that no GC pointer is alive anywhere in
call_external_function" — the body runs with the GIL released, so a caller
folded into it would put live GC pointers in that window. One expansion backs
all three call-surface attributes, so the emission is branched on `attr_name`.

The elidable doc claimed a `#[majit_elidable]` marker the tracer detects; the
expansion emits an unread fn-local `_MAJIT_ELIDABLE` const, and the policy
travels through `rpython_attribute_const_for` to `front/llbc_hints.rs`.

Measured over ~152 annotated sites, none on the GC-rooting path: throughput is
unchanged (1.0070 / 1.0000 on the any/all probes, 1.0000 on a pure integer
control), the release binary grows 14 KB, and reachable Python recursion depth
falls from 167700 to 159700 as the larger elidable bodies inline into the eval
loop. This lands on parity, not on performance.

Assisted-by: Claude

* jit-trace: keep last-write-wins for ICF-folded residual fnaddrs

With #[inline(never)] confined to the release-GIL surface, the tracing-policy
helpers inline their callees, so several byte-identical registered residuals
compile to one body: label_arg_to_usize / load_fast_var_num_to_index are both
arg.get(op_arg).as_usize(), convert_value_arg / special_method_arg are both
arg.get(op_arg), and hash_str_hooked_bytes decomposes its slice to the
(ptr, len) hash_str_hooked already takes. The MSVC-linked build script folds
each pair under /OPT:ICF onto one build address; the unoptimized test binary
keeps them apart, so their two runtime addresses differ.

patch_constants_i_fnaddrs keys correspondence on the build address, so a fold
makes one build address map to two runtime addresses. Folding merges only
identical machine code, so a residual call patched to either twin runs the same
body; and a path bound to the wrong fn resolves identically in both processes,
agreeing rather than colliding. The prior assert_eq therefore fired only on
these benign folds -- as it did on windows-latest across the pyre-jit tests.
Drop it and keep the last-write-wins insert.

The jit_fnaddr registry test observes only the process it runs in, where
nothing folds, so it cannot catch a build-script fold; last-write-wins is what
keeps the folded build address safe at load time.

* gc: nursery placement around pinned objects and varsize overflow rejection; majit-translate: scope a fixture's lowering (#939)

* gc: preserve nursery placement around pinned objects

* gc: reject overflowing varsize allocations

* majit-translate: scope a fixture's lowering to the graphs it asserts on

`build_semantic_program_from_llbcs_with_static_addrs_and_function_names`
takes a leaf-name allowlist alongside the module paths. Whole-program
metadata still comes from the entire LLBC; only the bodies of functions
outside the allowlist are left unbuilt. The existing entry points pass
`None` and are unchanged.

The lowering loop's global-initializer, module and name gates now run
before `FunDecl::unstructured`, so a declaration no gate admits no longer
parses its body JSON. Each of those gates reads the declaration header or
its name path.

`test_rbigint_mir`'s four caller tables become consts and the allowlist is
derived from them. Against `pyre-interpreter.ullbc` the dependent-crate
test lowered 8580 of 30380 declarations to assert on 41 graphs; it now
lowers those 41. The graphs are unchanged — `{:?}` compared against an
unfiltered run, modulo the global variable-id counter — and the test
binary goes from 156.5 s to 80.9 s of CPU.

Assisted-by: Claude

* jit: guard the inlined callee's Function fields, and bound the dynasm/wasm bridge chain (#925)

* jit: exempt store_deref_value's NULL value arg from the residual NULL-Ref refusal

`DELETE_DEREF` lowers to `load_deref_value` plus
`store_deref_value(cell, Constant::none())` (`codewriter.rs`
`Instruction::DeleteDeref`). `bh_store_deref_value_fn` passes that value to
`w_cell_set` without dereferencing it, so the NULL is a checked sentinel like
`CallFn`/`CallKw`/`CallFunctionEx`/`RaiseVarargs` already have exemptions for.

Without the exemption `try_execute_residual_call_via_executor` declined the
residual, which marks the walk as carrying a recorded-but-unexecuted effect;
the walk-end flush then declined ("unjournaled effect — legacy replay kept")
and the caller replayed a region whose other residuals the walk had already
executed concretely. Added to `walker_abort_if_mayforce_null_ref_arg` too, per
the in-code contract that the two exemption lists match.

`bench/synth/del_cellvar_walk_commit.py`: a `del <cellvar>` loop calling a
list-appending helper reported 20048 appends for 20000 iterations before this,
one per declined-commit walk.

check.py: dynasm 345/345, cranelift 345/345, wasm 341/341.

Assisted-by: Claude

* jit: root the virtuals cache across force_from_resumedata's materialization window

`force_from_resumedata` prepared its reader with the bare
`ResumeDataDirectReader::prepare`, which registers no GC root for
`virtuals_cache.virtuals_ptr_cache`. Every materialization after it —
inside `prepare_guard_pendingfields`, `consume_vref_and_vable` and
`force_all_virtuals` — wrote object addresses into a `Vec<i64>` the
collector cannot see, while `getvirtual_ptr` and the four
`virtuals_cache.get_ptr` re-reads in `VirtualInfo::allocate` return the
slot on the stated premise that a collection forwards it in place.
Call `prepare_resume_heap_with_roots` instead, the helper the blackhole
and bridge paths already use, and hold its scope to the end of the
function. The `virtualizable_ptr` comment named the missing scope as the
reason for its early read; state the allocation class instead.

Add `shadow_stack::resume_ref_slice_registered` and assert the premise
once per force in `force_all_virtuals`.
`test_handle_async_forcing_prepares_rd_virtuals_from_exit_layout` fires
it without the fix.

Record on `walk_forced_virtuals_refs` that the walk is a strong edge
where `jf_savedata` is an ephemeron one, what bounds the resulting
retention to one major cycle, and the back-edge that would remove the
bound.

Assisted-by: Claude

* jit(dynasm,wasm): stamp the guard_value per-value jitcounter bucket

`regalloc.py:496-499 consider_guard_value` calls
`descr.make_a_counter_per_value(op, index)` on every upstream backend, so
`must_compile` hashes the (guard, failing value) pair
(`compile.py:753-781`) and `store_hash` skips a descr whose status is
already stamped (`compile.py:826-829`). pyre implemented the helper and
the metainterp decode but called it only from cranelift, so on dynasm and
wasm every GUARD_VALUE kept `status == 0` and hashed into one bucket per
guard.

A guard on a value that never repeats then reached `trace_eagerness`
every 200 failures and compiled another bridge, without bound. On a
20000-iteration loop that defines a function in its own body and calls
it, dynasm compiled 47 bridges where cranelift compiled 0; at N=200000,
497 bridges and 2.71s against cranelift's 0.71s. After the change dynasm
compiles 0 bridges and runs 0.77s (interleaved, min of 5, user+sys):
20000 0.23s -> 0.19s, 100000 0.88s -> 0.45s, 200000 2.71s -> 0.77s.

The stamp goes where each backend already lays the guard out: dynasm
beside `set_rd_locs` (`assembler.py:279`), wasm in the guard-exit
pre-scan. Both index by fail-arg position, as cranelift does, because
`must_compile_with_values` reads the value back out of `fail_values`.

Add `synth/call_loop_local_function` with its three `.jitstats`
baselines; `bridges_compiled` is the recorded signal.

Assisted-by: Claude

* jit: gate the keyed bridge loop on no_bridge_enabled and log @@@guard there

`run_compiled_detailed_with_bridge_keyed` computed `should_bridge` from
`must_compile && !stack_almost_full()` only, unlike the two sibling loops
that also consult `no_bridge_enabled()`. This is the loop pyre reaches from
`execute_assembler`, so `MAJIT_NO_BRIDGE=1` reported a bridge-free run while
still compiling every bridge.

Also emit the `@@@GUARD` line the sibling loops emit, with the guard's own
trace id and fail index.

Assisted-by: Claude

* jit-trace: guard the inlined callee's Function fields, not the function object

The inline lever emitted one `GuardValue` on the callable's own address, and
only when the callable was not already a trace constant.

Guard the four `_immutable_fields_` names the inline actually bakes
(`function.py:34-42` `['code?', 'w_func_globals?', 'closure?[*]',
'defs_w?[*]']`) by reading each field off the live function instead, and emit
them unconditionally. `closure` is read through to its cells because
`MAKE_FUNCTION` rebuilds the tuple; `defs_w` keeps a tuple-identity guard.
`FUNCTION_DESCR_GROUP` grows `code`, `w_func_globals` and `closure` next to
the existing `defs_w`.

Two behaviour changes:

- A callee built by a `MAKE_FUNCTION` in the caller's own loop body no longer
  fails its guard every iteration. On `synth/call_loop_local_function`,
  `guard_failures` 9480 -> 1 and `loops_compiled` 2 -> 1; baselines
  re-snapshotted on all three backends.
- `f.__code__ = g.__code__` on a constant callable used to keep running the
  old code object: 41998 instead of 20020000 on a 40000-iteration loop.

Assisted-by: Claude

* jit-trace: skip the read-only Function field guards on a constant callable

`w_func_globals` and `closure` have no Python-level setter, so a callable the
trace has already pinned to one object cannot present different ones. Guard
them only when the callable is not a trace constant; `code` and `defs_w` are
writable and stay guarded either way. Also skip the guard when the heapcache
already handed back a Const box (`_opimpl_any_guard_value` parity).

Raise inline_helper's cranelift vs-pypy gate 1.5 -> 3. The remaining
`getfield_gc_r` + `guard_value` on `Function.code` costs dynasm nothing
(0.21s -> 0.20s) and cranelift 0.22s -> 0.32s on a trace that is 103 guards on
both backends.

Assisted-by: Claude

* jit-trace: guard the Function fields only when the pinned object is that function

`try_walker_inline_resolved_user_call` takes the resolved callee as `callable`
and the object to pin as `callable_guard_value`, and the two are not always the
same. The exception-string specializer resolves `str(e)` to an exception
subclass's `__str__` but passes the CALL's own operand — the `str` builtin — as
the value to pin.

Reading `Function.code` off that operand is a type-confused load: it returns
whatever sits at offset 16 of a PyCFunction, so the guard compares a value that
is not `code`, and it never matched. On `synth/exception_subclass_attrs`:
guard_failures 1 -> 99480, bridges_compiled 0 -> 497, 0.19s -> 6.18s.

Emit the field guards only when the pinned object is a Function whose `code` is
the code this inline resolved; otherwise fall back to the operand-identity
guard. exception_subclass_attrs returns to 0 bridges / 1 guard failure at 0.14s,
and call_loop_local_function keeps its 1 guard failure.

Assisted-by: Claude

* bench(synth): give call_loop_local_function a max-pypy-ratio gate

Worst native ratio measures 3.7x (dynasm; cranelift 3.2x), so the gate is
max(5, ceil(3.7 * 2)) = 8, matching how #853 calibrated the other 312 fixtures.

Assisted-by: Claude

* jit-trace: do not read the callee Function's fields through a baked ConstPtr

The field guards were also emitted for a trace-constant callable, which makes
the trace dereference a baked `ConstPtr`. A baked constant object pointer is
not GC-forwarded (gh #108 gc-table; the note in synth/exception_subclass_attrs
records the same hazard), so the load dangles once a minor collection moves the
object. `synth/inline_subwalk_property_mutates` — a property getter that
allocates every iteration — segfaults on cranelift under CI's macOS runner with
the reads in place, and its jitstats are now identical to the pre-change run.

Restrict the guards to a non-constant callable, which is the case the fresh
`MAKE_FUNCTION` callee falls in: call_loop_local_function keeps
guard_failures=1, exception_subclass_attrs keeps 0 bridges.

This gives up the `f.__code__ = g.__code__` re-check on a constant callable
that e5404cc's message claimed: a 40000-iteration loop reassigning
`__code__` prints 41998 again instead of 20020000. Re-checking it needs either
quasi-immutable `code?` with trace invalidation, or gh #108 so a baked constant
can be dereferenced at all.

inline_helper's cranelift vs-pypy gate goes back to 1.5 (measures 1.1x): the
callables there are trace constants, so no guard is emitted for them now.

Assisted-by: Claude

* interpreter: build a raised exception's args once in to_exc_object

`to_exc_object` allocated the message string and the `args_w` list twice per
raise: `w_exception_new` builds both from the message, then the block below
rebuilt them and overwrote `args_w`, discarding the first pair. The second build
exists because the `ImportError` / `ModuleNotFoundError` `msg` stamp needs the
message object in a shadow-stack slot.

Allocate the instance with `w_exception_new_empty` and keep the single build.

synth/type_immutable_reject exec 0.40s -> 0.31s, pypy ratio 69.8x -> 52.4x
(dynasm, back-to-back on one machine; gate is 152x).

dynasm 354/354, cranelift 354/354.

Assisted-by: Claude

* interpreter: compute def_first in signed arithmetic

`argument.py:274,302-315` computes `def_first = co_argcount -
len(defaults_w)` signed and keeps `defaults_w[i - def_first]` for every
non-negative index, so a `__defaults__` longer than the parameter list
binds the tuple's tail. Four pyre sites computed it in `usize`:

- `call.rs fill_user_function_args` wrapped, so no parameter matched a
  default and the call raised `TypeError: missing 1 required positional
  argument`.
- `argument.rs:1130 _match_signature` used `saturating_sub`, clamping to
  0 and binding `defaults_w[1]` where upstream binds `defaults_w[2]`.
- the too-many-args messages in both files wrapped the same difference.

`fill_user_function_args` is reached from `bh_call_fn_impl` ->
`call_user_function_residual`, so the wrap was observable only after a
guard failure: `f.__defaults__ = (7, 9, 11)` set before a loop was
correct, set mid-loop it raised at the first call after the flip.

Add `synth/defaults_reassigned_midloop`, which reassigns `__defaults__`
mid-loop both to a longer tuple and to a two-int tuple; without this
change its first line raises instead of printing 330000.

Assisted-by: Claude

* jit: fire the immutable-type attr raise fold and guard it on the metaclass version_tag (#933)

type_immutable_attr_raise_is_stable rejected the canonical type's own
__setattr__/__delattr__ — the standard slot wrappers forwarding to
object_setattr/object_delattr (init_type_type, for the Carlo Verre
hackcheck) — as a diverting metaclass override, so the immutable-type
STORE_ATTR/DELETE_ATTR raise fold never committed: every iteration
residualised the raise as call_may_force and re-allocated the exception.
The STORE_ATTR arm additionally required the store value to be a
trace-time constant, which a loop-carried value never is.

Accept type's own setattr/delattr in the predicate (branch C already
proves the metaclass is the canonical, dict-frozen type) and drop the
constant-value requirement (the value plays no role in the raise; the
predicate proves no data descriptor for name, so the terminal raises
before consulting it — substitute w_none for the trace-time run).

The fold's emitted guard pinned only the receiver identity, but the raise
decision reads the metaclass-MRO descriptor state (the branch-F name walk
and the forwarding setattr/delattr). Emit the metaclass version_tag
GuardValue the sibling method/attr folds carry, so any type/object dict
mutation side-exits; one guard covers the (type, object) MRO because
mutated() propagates down to the type subclass.

type_immutable_reject drops from ~35x to ~1x vs pypy on both backends
(per-iteration call_may_force 1 -> 0); lower its max-pypy-ratio gate
152 -> 15.

* jit: make W_TypeObject._version_tag quasi-immutable (#940)

`typeobject.py:177 _immutable_fields_ = ['_version_tag?']`. The method-cache
fold needs the tag green for `promote(self.version_tag())`
(typeobject.py:506); it was getting there through a live `getfield_gc_i` plus
`guard_value` at each of the eight fold sites, because nothing could revoke a
loop when the tag changed. A residual `CALL_MAY_FORCE` flushes a mutable
field's cache, so a body with an un-inlined call re-read and re-guarded the tag
after every call.

Ports `quasiimmut.py`'s `QuasiImmut` — `register_loop_token`,
`compress_looptokens_list` (`compress_limit = 30`, `(len + 15) * 2`), and
`invalidate` — onto `W_TypeObject.quasi_immut_watchers`, which stands in for
the hidden `mutate__version_tag` field the rtyper synthesises upstream. The
sweep sets the per-artifact `AtomicBool` that `GUARD_NOT_INVALIDATED` already
reads, in place of `looptoken.invalidated = True` + `cpu.invalidate_loop`.

`w_type_set_version_tag` is the only writer of the field, so the invalidation
hangs there and covers `mutated()`'s fresh identity and both demotions to `0`
alike. `register_quasi_immutable_deps` now offers each collected dep to the
type watcher as well as the module-dict one; both registrations self-filter on
the object's kind.

The eight fold sites emit `QUASIIMMUT_FIELD` through a shared
`walker_pin_type_version_tag`, and `type_version_tag_descr()` becomes a
`LazyLock` singleton carrying the quasi-immutable flag — `heap.rs:3274` keys
`quasi_immut_cache` on the descr's `Arc` pointer, so a per-call descriptor
missed its own cache on every read.

Steady-body op counts: append loop 33 → 32; two un-inlined method calls on one
instance 41 → 38 (both `_version_tag` reads and their guards replaced by one
`GUARD_NOT_INVALIDATED`, which the residual call no longer flushes).

Adds `synth/method_reassign_after_warmup`, covering rebinding on the class,
rebinding again, rebinding on a base of the warmed receiver's class, and
rebinding from inside the loop being traced. With the invalidation call
removed the fixture returns the stale methods (`1 1 1 10 10`), so it is not
vacuous.

Assisted-by: Claude

* majit: address PR #932 review — restrict as_ptr fold to slices, fix getslice fixed-result dst_items (#941)

as_ptr identity fold (front/mir.rs): drop the `alloc::vec::<Impl>::as_ptr` arm.
A resized-list / Vec reaches its items buffer through `ll_items(l) = l.items`
(rlist.py:368, a getfield), not the receiver, so aliasing a Vec::as_ptr result
to the receiver contradicts the layout; only the fixed-array `<[T]>::as_ptr`
matches `ll_fixed_items(l) = l` (rlist.py:399). The two Vec::as_ptr callers
(IntArray/FloatArray::from_vec) are host builtins residualised to their compiled
bodies, so the Vec arm had no traced consumer — census is unchanged (1151).

getslice dst_items (rtyper/rlist.rs): emit_listslice_alloc_and_copy hardcoded
`getfield(new_lst, "items")` for the destination, which is wrong for a
FixedSizeListRepr result (ll_newlist returns a bare Ptr(GcArray) with no items
field). Branch dst_items on the result layout, read off result_ptr_lltype,
mirroring the source-layout branch of src_items. Add a fixed-result getslice
test asserting no `items` getfield, plus a non-nonneg-start rejection test.

Also: fix a stale `simplify_and_finalize` cross-reference in the dormant
slice_index note, align slice_index's consumer gate FieldWrite base with its
removal sweep (`base == range`), and add a negative-length malloc_varsize
llinterp test.

Assisted-by: Claude

* jit: fix a lost conditional store in an inlined callee, widen the method-form inline (#942)

* majit: keep the nonstandard vable element heapcache in sync

Two halves of the same gap on the NONSTANDARD virtualizable array path
(a force-materialized inline-callee frame's locals_cells_stack_w):

- Every `getarrayitem_vable` / `setarrayitem_vable` arm recorded its own
  fresh `GetfieldGcR` for the array base. The per-array element cache is
  keyed by that base OpRef, so reads and writes landed in different
  submaps. Route all four arms through a new
  `nonstandard_vable_array_base`, which forwards the cached field box on
  a hit and publishes the recorded op with `getfield_now_known`, the way
  `_opimpl_getfield_gc_any_pureornot` does.

- The nonstandard store recorded `SETARRAYITEM_GC` without updating the
  heapcache. `execute_setarrayitem_gc` (pyjitpl.py) records and then
  calls `heapcache.setarrayitem`; add that pairing as
  `execute_setarrayitem_gc` and call it from the nonstandard arm.
  `gen_store_back_in_vable` keeps using the raw
  `vable_setarrayitem_descr`, matching its direct op recording upstream.

With both, a local written inside an inlined callee no longer keeps
reading the value the element cache was seeded with at frame
construction.

Assisted-by: Claude

* jit: inline a method-form callee whose body reads self.attr

`try_walker_inline_user_call` passed `allow_method_load_attr = false`, so
`method_form_callee_body_supported` declined every `b.at(i)` whose callee
body contains a `LoadAttr` residual — the ordinary accessor shape
`def at(self, i): return self.v + i`. The same callee already inlined
when stored as a bound method (`m = b.at; m(i)`).

Pass `true` there, and document what the helper declines.

Measured on the call-shape micro (400k iterations, dynasm):
`b.at(i)` 1.54s -> 0.08s, matching the plain-function and bound-method
forms; `synth/sre_pattern_methods` prints 280000, the pypy3 value.

Assisted-by: Claude

* jit: decline the widened method-form inline for raise-bearing and FOR_ITER-deferred callees

`try_walker_inline_user_call` passes `allow_method_load_attr = true`, which
admits an unbound method-form callee whose body reads `self.attr`.  Two body
shapes reached that way discarded the enclosing loop instead of falling back
to a residual call:

- A body containing a `raise`.  The sub-walk records into the handler region,
  and a guard whose resume coordinate lands on the `Reraise` asks
  `collect_callee_active_boxes` for ref registers the recorded path never
  wrote.  That decline arrives mid-recording on an opcode that is not
  effect-free, so it has no mid-body carrier and the trace is thrown away.
- A FOR_ITER `CalleeReplaySafety::DeferredCall` admission whose deferred call
  resolves to a builtin: `fbw_abort_nested_unjournaled_residual` spends one
  abort and then denies the callee.

Decline both at the callsite.  Measured, median of 3:

  dynasm     sre_pattern_methods 0.90s -> 0.67s, sre_wasm_min 0.55s -> 0.43s,
             sre_wasm_min1 0.45s -> 0.33s, type_metatype_method_call 0.35s -> 0.28s
  cranelift  the same four return to 0.72s / 0.47s / 0.36s / 0.23s, matching
             their times with `allow_method_load_attr = false`

`loops_compiled`, `bridges_compiled` and `loops_aborted` on all four match the
`allow_method_load_attr = false` counters.  `inline_subwalk_mutating_residual`
keeps the widening's gain at 13.0x pypy3 (dynasm) / 18.3x (cranelift), so its
gate moves 200 -> 40, and `inline_subwalk_property_mutates` 80 -> 50.

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

Assisted-by: Claude

* call: translator-safe __call__-slot guard + descriptor tests; operator countOf/indexOf GC fix (#944)

* call: guard __call__-slot self-dispatch with call-depth instead of black_box

std::hint::black_box is not recognized by the majit translator (front/mir.rs
only lowers core::hint::n and core::convert::identity), so in the call-dispatch
graph it stayed an unregistered FunctionPath. Replace it at all three slot
sites with an increment_call_depth() guard: its drop after the recursive call
keeps the self-dispatch off the tail, so a self-referential A.__call__ = A()
still recurses natively for stack_check, and it counts the dispatch level.

synth: add a descriptor whose __get__ resolves to a separate callable
(positional and keyword), covering the get() branch of resolve_user_call_slot.

Assisted-by: Claude

* operator: move countOf app-level, route indexOf through space.sequence_index

Add `baseobjspace::sequence_index` mirroring `descroperation.py:538`, pinning
the iterator and needle on the shadow stack so they survive a collection
triggered by `__next__`/`__eq__`. Remove the hand-rolled `op_countof`/`op_indexof`
loops that held those references in raw locals: `countOf` becomes an
`app_operator.py` function (`moduledef.py` `app_names`) and `indexOf` delegates
to `sequence_index` (`interp_operator.py:58`).

Assisted-by: Claude

* Close the PR #937 review queue: GC rooting fixes, os.kill unwrap_spec, and the frame root-walk floor (#946)

* _multiprocessing: port W_SemLock's recursion state, release checks and classmethod _rebuild

`SemLock` kept no per-lock state, so `_count()` returned 0, `_is_mine()`
returned false, `acquire` had no `RECURSIVE_MUTEX` re-entry check and went
straight to `sem_wait` (a second acquire on an RLock blocks on a value-0
semaphore), `release` neither checked ownership nor decremented a recursion
depth, and `_after_fork` had no state to reset.  `count` and `last_tid`
(interp_semaphore.py:462,465) now live in the instance dict beside `kind`,
`maxvalue`, `handle` and `name`, and `acquire`/`release` follow
interp_semaphore.py:506-545.

`_is_zero` reported whether the handle was null rather than whether the
semaphore was available; it and the new `_get_value` follow
interp_semaphore.py:443-455 and :431-441, taking the `sem_trywait` fallback
where `sem_getvalue` is broken (darwin, :86-89).  `release` gained the
maxvalue check of :407-427.  `__enter__` returned `self` after an unchecked
`sem_wait`; it now returns `acquire()`'s boolean and `__exit__` goes through
`release()` (:563-567).

`__new__` accepted any `kind`; it now raises `ValueError("unrecognized
kind")` (:574-575).  `_rebuild` rejected `name=None` and always reopened by
name, and allocated on `type_object()` rather than the class it was called
through; it is now a classmethod (:606) that falls back to `handle_w` on a
nameless semaphore (:550-557, :223-224).

Assisted-by: Claude

* mapdict: write _mapdict_pop_attribute's map through the rooted receiver

Both arms of `_mapdict_pop_attribute` can trigger a collection — the unboxed
arm through `_mapdict_write_storage`, the other through the
`grow_instance_items_block` shrink — and the trailing `self.map = map` ran
through the `&mut self` address captured before them.  A moving collection in
either arm left that write targeting the stale address, so the live instance
kept its old map and its storage index into a block that no longer has the
slot.  The receiver is now published for the whole function and both the
storage write and the `map` write go through the reloaded address, matching
`_set_mapdict_increase_storage1` (mapdict.rs:2406) and
`_set_mapdict_storage_and_map` (:2427).

Assisted-by: Claude

* mapdict: treat a null "dict" SPECIAL slot as absent and root the receiver across the three allocating dict paths

`_obj_getdict` returned `instance_get_dict_slot`'s answer unfiltered, so a slot
holding NULL was reported as the instance's `__dict__`.  `mapdict.py:828-830`
reads that as "no dict yet" — RPython's `read` answers `None` both for an
absent slot and for one holding `None` — and rebuilds the view.  Returning the
null instead makes `getdict` answer empty, which is exactly the state
`descr__setattr__` turns into `"'%T' object attribute '%s' is read-only"`
(descroperation.py:58-67): the sole way `_obj_getdict` can produce null, and
the reported failure of `self._loop = loop` on a fresh `asyncio.futures.Future`
(`_loop` is a class variable, futures.py:53, so the descriptor lookup always
hits and the read-only branch is always armed).  The sibling
`instance_get_weakref_slot` already carries the same filter.

Three receivers were also captured before an allocation and used after it:

- `_obj_getdict` — `w_dict_new_with` allocates, so both the erased `dstorage`
  back-pointer baked into the new view and the `instance_set_dict_slot` target
  were pre-move addresses.  `walk_gc_refs` only forwards `dstorage` from the
  collection after the wrapper is reachable, so the birth allocation is not
  covered; the back-pointer is now restated from the post-allocation address.
- `materialize_dict` — `node_materialize_dict` allocates boxed names, dict
  stores and carrier transitions before the transplant wrote through the
  pre-walk `inst`.
- `_obj_setdict` — `_obj_getdict` and `mapdict_switch_to_object_strategy` both
  allocate ahead of `instance_set_dict_slot(self_ref, w_dict)`.

Assisted-by: Claude

* mapdict: cover the null "dict" SPECIAL slot rebuild with a unit test; rustfmt

The test nulls the slot in place — the state a write that never landed leaves
behind — and asserts `_obj_getdict` builds a fresh view backed by the instance.
It fails without the `!w_dict.is_null()` guard ("a null SPECIAL slot must
rebuild the view") and passes with it.

Assisted-by: Claude

* posix: unwrap os.kill/os.killpg arguments through c_int_w

interp_posix.py:1386 and :1394 declare `@unwrap_spec(pid=c_int,
signal=c_int)`; both arguments were read with `w_int_get_value`, a raw
payload read with no type check. `is_int` compares `ob_type` for exact
identity, so no non-`int` argument took a checked path: `os.kill(pid,
None)`, `os.kill(pid, [30])` and `os.kill("x", 30)` passed whatever bit
pattern sat at that offset to `kill(2)` instead of raising TypeError, and
an out-of-range value was truncated rather than reported.

Assisted-by: Claude

* baseobjspace: include the operand type in space_int's unwrap error

baseobjspace.py:323 raises through `_typed_unwrap_error(space,
"integer")`, whose body at :316-318 is `"expected %s, got %T object"`.
The message here was `"expected integer"`, dropping the operand type.

Assisted-by: Claude

* mapdict: resolve the unboxed storage slot without allocating

`boxed_storage_indices` built a `vec![false; len]` and a result `Vec` on
every call. `instance_walk_boxed_storage` calls it from inside
`object_object_custom_trace`, so the collector allocated twice per traced
instance while marking, and `_mapdict_write_storage` called it through
`storage_index_is_boxed` on every attribute store to answer a
single-slot question.

`_compute_storageindex_listindex` (mapdict.py:549-562) breaks at the
first `UnboxedPlainAttribute` ancestor and reuses its `storageindex`,
setting `firstunwrapped` only when the walk finds none, so a chain owns
at most one unboxed slot. `unboxed_storage_index` returns that slot
without allocating; the index list is now derived from it and remains
only for `_set_mapdict_storage_and_map`, which needs the list itself.

Assisted-by: Claude

* posix: build the sysconf_names value before reading the rooted dict

Call arguments evaluate left to right, so `w_dict_setitem_str` read the
dict out of the shadow stack before `w_int_new` allocated the value: a
collection in that window moved the dict and the store wrote through the
pre-move address. The slot index is now taken once after `pin_root`
instead of being respelled as `shadow_stack_len() - 1` inside the loop.

Assisted-by: Claude

* eval: floor the frame root walk at the locals/cells prefix

Both walks over `locals_cells_stack_w` took `valuestackdepth.min(len)` as
the walk length. `valuestackdepth` is an absolute index that starts at
`stack_base()` (pyframe.rs:2136) and `pop` refuses to go below it, so for
a running frame that already covers the locals/cells prefix —
`PyFrame::descr_clear` is the exception: it rebinds every cell slot to a
fresh `w_cell_new` and then sets `valuestackdepth = 0`. Both walks then
scanned zero slots for a cleared frame, so cells reachable only through
the array were neither kept nor forwarded while the array still pointed
at them.

The length now floors at `stack_base()`, which only ever widens the walk.
The SAFETY comment claiming the walk covers the full fixed-length array
described neither the previous behaviour nor this one.

Assisted-by: Claude

* _multiprocessing, thread: reload the receiver past getdict_native; drop a repeated fork-child reinit

`semlock_instance` pinned `obj` and then passed the pre-pin copy to
`getdict_native`, which materialises the instance dict and can therefore
collect and move it; the receiver is now read back from its slot, as the
stores in the same function already do.

`after_fork_child` called `setobject::set_locks_after_fork_child()` twice.

Assisted-by: Claude

* gc_roots, executioncontext, posix, _socket: root operands held across collecting calls

`pin_roots` did not carry `pin_root`'s debug-only
`assert_shadow_stack_not_walking`, so a caller moving from one to the
other lost the guard against publishing roots mid-walk.

`_call_finalizer` read `__del__` out of the type dict and passed the
pre-call pointer to `report_error` after running arbitrary Python.

`posix_spawn`'s env loop kept the mapping and the key vector in plain
locals across `getitem` and two `fsencode_bytes_w` calls.

`sendmsg` pinned each data item and then iterated the unrooted vector,
handing pre-pin copies to `simple_buffer_bytes`, which looks up
`__buffer__` and builds a memoryview.

Assisted-by: Claude

* mapdict: scan the whole chain for unboxed slots instead of the first hit

The allocation-free rewrite returned at the first `firstunwrapped` node,
resting on `_compute_storageindex_listindex` (mapdict.py:549-562) giving
a chain at most one. The collector is the wrong place to depend on that:
under-reporting one unboxed slot hands the marker a raw `Vec<i64>`
pointer. `storage_index_is_unboxed` now scans the chain for a matching
storageindex, which restores the exact set the pre-rewrite
`boxed_storage_indices` computed while keeping both allocations out of
`object_object_custom_trace`.

Assisted-by: Claude

* types: complete the UnionType slot surface; functools function exports; check.py startup median (#936)

* types: expose UnionType rich comparison slots

* types: complete UnionType identity and iteration slots

* functools: export reduce and cmp_to_key as functions

* check.py: take the empty-program startup median over five samples

The measured startup is subtracted from every timed run, so it is the
divisor for short benches. One high sample collapses that denominator:
a macOS runner measured pypy startup at 0.031s where the same job had
measured 0.013s, and three benches failed their gates in that run while
their pyre exec times had gone down.

Assisted-by: Claude

* jit,interp: lower DELETE_NAME/GLOBAL, drop invalidated celldict watchers (#935)

* interp: decline only a metatype data descriptor in the classmethod fold

`classmethod_on_type_fast_path` declined every name the metatype defines.
`typeobject.py:813-823 descr_getattribute` orders the two lookups the other
way: the metatype entry preempts the class's own MRO only when it is a data
descriptor, and otherwise `self.lookup(name)` is what gets selected.  Since
the fold already requires the class MRO lookup to answer a classmethod, the
class-MRO-miss arm is unreachable, so a non-data metatype attribute is
provably never the selected value.

Swap the check to `type_lookup_is_data_descr`, which is the existing
`space.is_data_descr(space.lookup(...))` analogue in the same module.

The check was dead on main until the metatype pin landed: it read
`&TYPE_TYPE` as an object pointer, whose first field is an `AtomicI64`, so
`lookup_in_type`'s `is_type` gate rejected it for every name.  Pinning the
real metatype made it live, and with it the divergence.

Reach is one name: the walker declines every dunder up front, and `mro` is
the only non-dunder `type` defines.  `Cls.mro()` with `mro` a classmethod now
folds, matching pypy3 and python3.

Assisted-by: Claude

* comments: name the real blockers behind three declined ports

No behaviour change.

`w_pytraceback_get_lineno` justified the eager `offset2lineno` stamp with
"PyFrame is not a GC-traced W_Root, the frame may already be freed".  That
stopped being true: `pytraceback_object_custom_trace` forwards the `frame`
edge, and `w_code` — which is `frame.pycode` — is forwarded unconditionally,
so a lazy resolve off `w_code` and `lasti` would be safe.  What actually
blocks the port is the JIT fold, which reads the slot and declines on the
sentinel, so every fresh node would decline on its first read.  Record that,
and the two `tb_lineno` answers the eager stamp diverges on: `tb.tb_lasti = N`
before the first read, and a sentinel written back through `TracebackType` or
the setter.

The `TbLineno` fold named one sentinel producer; add the other two.

`FBW_FORITER_DEFERRED_DENY` lacked the per-thread paragraph its sibling
`FBW_HAZARDOUS_INLINE_DENY` carries, so nothing said that it is only a memo
and that the live abort is what keeps a deferred body honest.

Assisted-by: Claude

* builtins: read the pinned iterator back from its slot

`collect_iterator`, `builtin_any` and `builtin_all` pinned the iterator and
then ran the dict-view type check and the backing-dict read off the raw
local.  `pin_root` normalizes through `try_gc_current_object_address` and
writes the result into the shadow-stack slot only, so the slot — not the
local — is the authoritative value once it returns.  The loop below already
reloaded per call; these three lines were the outliers, and the sibling
`min_max_sequence` in the same file already reads the slot for exactly this.

No live path reaches the difference today: every builtin iterator is
`allocate_stable` and a dict-view iterator is off-GC `malloc_typed`, so
neither moves, and nothing collects between `iter(...)` and the pin at any
of the three sites.

Assisted-by: Claude

* interp: read the frame builtin through get_builtin in LOAD_GLOBAL

`_load_global` (pyopcode.py) reaches the builtins fallback through
`self.get_builtin()`.  `load_global_value` and the
`jit_load_name_from_namespace` extern read the `w_builtin` slot directly
instead.  `pyframe.py` assigns `self.builtin` only under
`honor__builtins__`, which `baseobjspace::HONOR_BUILTINS` leaves off, so
`get_builtin` answering `space.builtin` for an unset slot is the only
route that resolves a builtin at all for a frame nobody wrote the slot
on -- among them the inlined-callee frames the tracer emits, which
follow the same constructor and leave it at the allocation's zero-fill.
Before this, forcing one of those frames made every builtin name raise
NameError.

Both frame emitters' trailing comments now list `w_builtin` alongside
the other class-level defaults they deliberately do not store.

Assisted-by: Claude

* dynasm: take getenv and String building off the entry path

`execute_token` read `PYRE_DYNASM_EXEC_DIAG` once and
`PYRE_GC_FREELIST_DIAG` twice per compiled-trace entry, each an
uncached `std::env::var_os` that takes the global env lock and walks
the env table. It also built the `format!("before/after trace {id}")`
site strings unconditionally, so a diagnostic that is off still cost
two heap allocations per entry. `dynasm_debug_validate_oldgen_freeblocks`
paid the same getenv plus one `format!` per residual call.

Cache both flags in `LazyLock` bools next to `majit_log_enabled`, and
take `std::fmt::Arguments` in `debug_validate_oldgen_freeblocks` so the
site name is only materialized once the flag is on.

Sampling `synth/gc_bug_bridge_flavor_traceback_names` (5s, dynasm):
samples under `run_compiled_detailed_with_values_at_dispatch_key`
244 -> 113, with the `__findenv_locked` and
`debug_validate_oldgen_freeblocks` frames gone from the entry path.

Assisted-by: Claude

* jit: lower DELETE_NAME and DELETE_GLOBAL to residual calls

Both opcodes were emitted as a static `abort_permanent`, which discards the
enclosing loop trace permanently.  A module-level `except X as e:` compiles
its implicit cleanup to `e = None; del e`, and at module scope `del e` is
DELETE_NAME, so any module loop containing an except-as clause could never be
traced.  Function scope uses DELETE_FAST, which was already lowered.

pyopcode.py DELETE_NAME is `space.delitem(w_locals, w_varname)` and
DELETE_GLOBAL the same against `w_globals`; neither is an unsupported opcode
upstream.

Adds the `delete_name` / `delete_global` HLOps in the frame-receiver call
shape of `store_name`, minus the value operand: two Ref operands, void result,
zero stack effect.  They lower to `residual_call_r_v` with
`PyreHelperKind::None` (there is no fold for delete) and are registered in
`graph_op_can_raise` — DELETE_NAME raises NameError when the binding is
absent.  `bh_delete_name_fn` / `bh_delete_global_fn` delegate to the existing
`OpcodeStepExecutor` methods on PyFrame and publish into both exception cells
on error, matching `bh_store_name_fn`.  SetupAnnotations stays on
`abort_permanent`.

loops_aborted -> 0 on the three benches whose hot module loops reached the op:
exception_reraise_tb_depth_hot (30 -> 0, bridges_compiled 0 -> 9),
exception_reraise_tb_depth_jitstress (30 -> 0), and
exception_reentry_guard_finally_residual (5 -> 0).
exception_reraise_tb_depth_hot: 692ms -> 267ms CPU, 6.5x -> 2.5x of pypy.

check.py dynasm 354/354, cranelift 354/354.

Assisted-by: Claude

* celldict: drop version watchers once they are invalidated

`sweep_version_watchers` used `Vec::retain` and returned true for every
watcher it could still upgrade, so a live loop flag stayed in the list after
being flipped.  `QuasiImmut.invalidate` (quasiimmut.py) instead takes the list
and empties it — `wrefs = self.looptokens_wrefs; self.looptokens_wrefs = []` —
so a loop is invalidated exactly once and then drops out.

Retaining them is redundant and unbounded: the flag is already true, so
re-storing it changes nothing, and the list grows by one entry per compiled
loop that folded a module-global.  A module-level `except X as e:` runs
`del e` every iteration and `delitem` calls `mutated()`, so each iteration
walked every loop ever compiled in that module.

Dropping is safe because registration is per compiled artifact:
`last_compiled_artifact_invalidation_flag` registers a fresh flag on every
compile, and a flipped flag is permanently invalid.

On synth/exception_metadata_hot at N=250000 this was the largest single
non-interpreter profile leaf (629 samples, ahead of eval_loop_jit) and is
absent from the profile afterwards; 13244ms -> 12019ms CPU, 8.31x -> 7.54x of
pypy.  It does not change loops_compiled (309 either way) — the remaining gap
is the version churn itself, not the walk.

Also corrects the `_setitem_str_cell_known` doc comment, which claimed
`write_cell` was stubbed to identity; it ports the MutableCell family and
absorbs a same-cell rewrite without touching the version.

cargo test pyre-object + pyre-interpreter green, check.py cranelift 354/354,
dynasm 353/354 with synth/jit_reg_const_pool_256_slot_decline a gate-boundary
flake (passes 3/3 solo; both binaries measure 984ms vs 988ms on it).

Assisted-by: Claude

* codewriter: reformat two delete-opcode let bindings

The DeleteName / DeleteGlobal arms sit at a shallower indentation after the
rebase onto main, so rustfmt joins both `Constant::string` bindings onto one
line. Whitespace only; `cargo fmt --all --check` is clean again.

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

`#942` passes `allow_method_load_attr = true` at
`try_walker_inline_user_call`, so an unbound method-form callee whose body reads
`self.attr` inlines into the caller's loop instead of running as a residual
call. Three fixtures carry that shape (`c.add(i & 7)`, `self.process_word()`)
and lose the callee's separately compiled loop:

  class_attrs_methods               loops_compiled 2 -> 1  (dynasm, cranelift)
  foriter_inplace_immutable         loops_compiled 2 -> 1  (cranelift)
  inline_subwalk_mutating_residual  loops_compiled 4 -> 3  (cranelift)

Every counter that moves with them falls: `class_attrs_methods` cranelift
`guard_failures` 202 -> 1 and `bridges_compiled` 1 -> 0;
`inline_subwalk_mutating_residual` cranelift `guard_failures` 12316 -> 603 and
`loops_aborted` 1 -> 0, wasm `loops_aborted` 4 -> 2. The measured ratio for that
fixture is 13.1x dynasm / 17.2x cranelift, matching the 13.0x / 18.3x `#942`
records.

`method_reassign_after_warmup` arrived with `#940` and had no baseline, which
the missing-baseline gate reported on all three backends.

check.py: dynasm 359/359, cranelift 359/359, wasm 355/355.

Assisted-by: Claude
youknowone added a commit that referenced this pull request Aug 1, 2026
….py: arm the jit-stats floor across the synthetic suite (#947)

* jit: count the deduped set in intern_liveness' liveness record header

`intern_liveness` keyed its dedup table on the sorted, deduped form of its
three `&[u8]` arguments but took the record's count bytes and encoded
bitsets from the raw slices. `encode_liveness` dedups, so a repeated
register index made the count exceed the bitset's cardinality.
`LivenessIterator` is driven by that count, so decoding such a record read
past its own bitset into the following record's bytes and swallowed it.

Read the counts and the bytes off the canonical form already computed for
the key, matching `majit-translate`'s producer, which counts the `VecSet`
it encodes from (codewriter/assembler.rs:1060).

Assisted-by: Claude

* majit: read the diagnostic env gates once, and build their messages behind them

`execute_token` brackets every entry into compiled code with
`debug_validate_oldgen_freeblocks`, which read `PYRE_GC_FREELIST_DIAG` with
`std::env::var_os` on each call, and it read `PYRE_DYNASM_EXEC_DIAG` the same
way — three `getenv`s per trace entry, each taking the environment lock and
scanning the array. The two bracket calls also built their `site` string with
`format!` before the gate, so a disabled diagnostic still allocated and
formatted one per trace entry, and `dynasm_debug_validate_oldgen_freeblocks`
one per residual call site.

Cache both gates in a `LazyLock<bool>` as `majit_log_enabled` does, take `site`
as `Arguments` so the string is materialised only past the gate, and do the
same for `MAJIT_LOG` in `do_collect_nursery` / `finish_incremental_cycle` and
`PYRE_GC_FREELIST_DIAG` in `dynasm_nursery_slowpath`.

On a 20x-scaled `recursion_memo_branch` the startup-subtracted user-CPU exec
time goes 0.7609s to 0.6566s (0.863x, min of 7 interleaved rounds, same host),
output unchanged; `__findenv_locked` and `std::env::__var_os` fall from 43 of
388 working samples to 0 of 581.

Also correct the comment above the `should_disable` application: it cited
`trace_opcode.rs:3044-3049` as a second site applying
`disable_noninlinable_function`, where no such symbol exists.

Assisted-by: Claude

* jit: emit the CALL's null_or_self slot in the outer-call stack overrides

`collect_call_stack_overrides` had no source for the `null_or_self` operand a
`PUSH_NULL` leaves under a `CALL`: the walk holds no box for that slot, no live
color names it, and the virtualizable shadow reads back the same NULL an
unmirrored slot does. The slot stayed absent, so the outer-call flush that
follows a `LoopBearingCalleeInlineUnsupported` abort declined on "stack override
missing for a live slot" and fell back to the legacy rollback+replay, where
`fbw_foriter_inflight_take` refuses in-flight FOR_ITER delivery and the whole
iteration is dropped.

Name the slot from the CALL's own operand layout — `[callable, null_or_self,
arg0 .. arg_{argc-1}]` ends at the caller's stack top, so the sentinel sits
`argc + 1` below it — and push an explicit null override. A coordinate that
does not invert to a plain `CALL` keeps the existing decline.

The added `bench/synth/foriter_call_resume_drops_iteration.py` printed
`491088 8999` before this change and `491130 9000` after, matching CPython,
PyPy and `PYRE_NO_JIT=1`.

Assisted-by: Claude

* check.py: gate guard_failures on a bounded rise against the jit-stats baseline

_jit_stats_regression_floor now iterates JITSTATS_RISE_BOUNDED_FIELDS in
addition to JITSTATS_BADNESS_FIELDS. A badness counter still fails on any rise
above its baseline; a rise-bounded counter fails only past
`base + max(base // 4, 2)`.

A field absent from the baseline keeps reading as 0 for the badness counters,
whose healthy value is 0, but leaves a rise-bounded counter unpinned: the wasm
[jit-stats] line reports no guard_failures, so every *.wasm.jitstats baseline
omits it.

guard_failures moves out of the informational snapshot surface into that gate.

Assisted-by: Claude

* check.py: track the synthetic .jitstats baselines

pyre/.gitignore ignored /bench/synth/*.jitstats with three `!` negations, so
the regression floor read a committed baseline for 3 of 340 synthetic fixtures
and skipped the other 337.

Recorded with `pyre/check.py --snapshot` (dynasm 355/355, cranelift 355/355,
wasm 351/351): 340 dynasm, 340 cranelift, 339 wasm —
getframe_caller_locals_nested_compiled_callee carries skip-backends=wasm.

Of the 1019 synthetic baselines, 872 record all-zero badness counters, 147
record a nonzero loops_aborted, and none record a nonzero descr_set_* or
internal_compile_panics.

Assisted-by: Claude

* check.py: record the synthetic baselines the rebase left missing or stale

defaults_reassigned_midloop and del_cellvar_walk_commit arrived from
origin/main with no .jitstats baseline, so the regression floor skipped them.

type_metatype_method_call moves loops_compiled 4 -> 2, bridges_compiled 47 -> 0
and guard_failures 9480 -> 1 on the native backends, the effect #925 records in
its own message. comprehension_object_append_hot.dynasm moves guard_failures
3611 -> 3612.

Assisted-by: Claude

* check.py: gate loops_compiled on a fall, and fail a benchmark with no baseline

JITSTATS_FALL_FIELDS gates a counter whose defect direction is down.
loops_compiled is its first member: a pre-trace decline aborts nothing, so
loops_aborted holds, and it removes the compiled guards that used to fail, so
guard_failures falls — both existing gates read the loss as an improvement
while the hot loop runs interpreted.

The fall is gated exactly rather than through a band. Diffing the dynasm and
cranelift baselines across the 341 synthetic fixtures, the two code generators
agree on loops_compiled for 340 of them (99.7%), against 97.4% for
guard_failures.

A benchmark that emits a [jit-stats] line but has no committed baseline now
fails instead of passing. Every gate above compares against nothing when the
file is absent, so "never recorded" and "recorded and clean" print the same
PASS — which is how the floor came to cover 3 of 340 synthetic fixtures.

Assisted-by: Claude

* check.py: re-record the jit-stats baselines the widened method-form inline moved

`#942` passes `allow_method_load_attr = true` at
`try_walker_inline_user_call`, so an unbound method-form callee whose body reads
`self.attr` inlines into the caller's loop instead of running as a residual
call. Three fixtures carry that shape (`c.add(i & 7)`, `self.process_word()`)
and lose the callee's separately compiled loop:

  class_attrs_methods               loops_compiled 2 -> 1  (dynasm, cranelift)
  foriter_inplace_immutable         loops_compiled 2 -> 1  (cranelift)
  inline_subwalk_mutating_residual  loops_compiled 4 -> 3  (cranelift)

Every counter that moves with them falls: `class_attrs_methods` cranelift
`guard_failures` 202 -> 1 and `bridges_compiled` 1 -> 0;
`inline_subwalk_mutating_residual` cranelift `guard_failures` 12316 -> 603 and
`loops_aborted` 1 -> 0, wasm `loops_aborted` 4 -> 2. The measured ratio for that
fixture is 13.1x dynasm / 17.2x cranelift, matching the 13.0x / 18.3x `#942`
records.

`method_reassign_after_warmup` arrived with `#940` and had no baseline, which
the missing-baseline gate reported on all three backends.

check.py: dynasm 359/359, cranelift 359/359, wasm 355/355.

Assisted-by: Claude

* codex-review: withhold generated .jitstats baselines from the changed-file list

The workflow appends `git diff --name-only upstream/main` to the review prompt
as the authoritative definition of "this patch". On a PR that re-records
check.py's jit-stats baselines in bulk that list reached 1027 entries and Codex
returned no report at all (exit 1); CodeRabbit skipped the same PR for being
927 files over its own 100-file limit.

`.jitstats` files are generated golden data with no RPython/PyPy counterpart, so
no parity finding can cite one. Exclude them from the list, report the withheld
count in the prompt header and in the empty-diff message, and put the same
exclusion in the prompt's own scope-discipline instruction, which is what the
local `/codex-review` path re-derives from.

On this branch the list goes from 1132 to 112 files.

Assisted-by: Claude

* check.py: re-record the exception-reraise baselines main's trace shape moved

`origin/main` alone reproduces the counters exactly, so this is not our branch:

  exception_reraise_tb_depth_hot        40 loops / 0 guards / 30 aborts / 0 bridges
                                    ->   4 loops / 1803 guards / 0 aborts / 9 bridges
  exception_reraise_tb_depth_jitstress 900 / 0 / 30 / 0 -> 805 / 1798 / 0 / 1

Both gated directions fired at once (`loops_compiled` down, `guard_failures` up)
and neither is a defect on the native backends. Interleaved user+sys CPU time,
min of 5, `e1e97e61d32` vs `origin/main`, same host: `_hot` 0.750s -> 0.370s
(2.03x faster), `_jitstress` 0.490s -> 0.490s. The 40 specialized loops with 30
aborts were the worse shape.

wasm moves the other way on the same two fixtures: `loops_aborted` 30 -> 44 and
30 -> 1198. Recording those pins the higher value, so the rise is stated here
rather than absorbed silently.

check.py, per fixture: dynasm 1/1, cranelift 1/1, wasm 1/1.

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