Skip to content

Optimize JIT for list operations and enhance method dispatch - #3

Merged
youknowone merged 17 commits into
youknowone:mainfrom
lifthrasiir:list-bound-method-fastpath
May 5, 2026
Merged

Optimize JIT for list operations and enhance method dispatch#3
youknowone merged 17 commits into
youknowone:mainfrom
lifthrasiir:list-bound-method-fastpath

Conversation

@lifthrasiir

@lifthrasiir lifthrasiir commented May 1, 2026

Copy link
Copy Markdown
Contributor

This pull request adds fast-path JIT support for list.pop() in Pyre, mirroring the existing optimized handling for list.append(). It introduces a specialized trace-time lowering for lst.pop() that avoids unnecessary boxing and improves performance, especially for integer and float list strategies. Additionally, it lays the groundwork for more robust method-object handling in the JIT and improves the maintainability and adaptability of the codebase for future convergence with upstream RPython.

The most important changes are:

JIT Specialization for list.pop()

  • Added generated_list_pop_by_strategy in majit-translate/src/codegen.rs to efficiently trace lst.pop() for object, integer, and float list strategies, using dynamic length reads and avoiding hard-coded constants.
  • Added list_pop_value to MIFrame in pyre-jit-trace/src/trace_opcode.rs to select the appropriate fast path or fall back to generic dispatch, paralleling the approach for list.append().

Bound Method Specialization and Descriptors

  • Introduced W_METHOD_DESCR_GROUP and corresponding field descriptors (method_w_function_descr, method_w_self_descr) in pyre-jit-trace/src/descr.rs to allow the JIT to recover the receiver and function from bound method objects, supporting robust specialization of method calls. [1] [2]
  • Enhanced trace_call_callable in MIFrame to recognize and specialize calls to lst.append() and lst.pop() bound methods by extracting the receiver and guarding on the method function, enabling effective trace-time specialization.

Backend Robustness

  • Replaced strict paired-guard checks for forceable calls in the Cranelift backend with check_paired_guard_not_forced, allowing for virtual-forcing ops between the call and its paired guard, improving compatibility with Pyre's optimizer and preventing spurious trace rejections. [1] [2] [3]

Benchmark and Documentation Updates

  • Updated several benchmarks and added comments clarifying why certain functions are kept at module scope, noting JIT and specialization issues to be addressed in future work. [1] [2] [3] [4]

Imports and Minor Infrastructure

  • Added imports for method object helpers in pyre-jit-trace/src/trace_opcode.rs to support new specialization logic.

Summary by CodeRabbit

  • New Features

    • Strategy-specific fast-paths for list.pop() covering object/int/float storage.
    • Trace-time specialization for method-backed list operations (append/pop) for faster calls.
  • Improvements

    • Stricter, more informative error messages when expected trace guards/adjacency are missing.
    • New descriptor helpers to improve method-object dispatch reliability.
  • Documentation

    • Clarified benchmark notes; one benchmark moved into a main() wrapper to control execution.

@coderabbitai

coderabbitai Bot commented May 1, 2026

Copy link
Copy Markdown

Warning

Rate limit exceeded

@youknowone has exceeded the limit for the number of commits that can be reviewed per hour. Please wait 47 minutes and 41 seconds before requesting another review.

To keep reviews running without waiting, you can enable usage-based add-on for your organization. This allows additional reviews beyond the hourly cap. Account admins can enable it under billing.

⌛ How to resolve this issue?

After the wait time has elapsed, a review can be triggered using the @coderabbitai review command as a PR comment. Alternatively, push new commits to this PR.

We recommend that you space out your commits to avoid hitting the rate limit.

🚦 How do rate limits work?

CodeRabbit enforces hourly rate limits for each developer per organization.

Our paid plans have higher rate limits than the trial, open-source and free plans. In all cases, we re-allow further reviews after a brief timeout.

Please see our FAQ for further information.

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: ASSERTIVE

Plan: Pro

Run ID: dcb0e40e-1c12-4b7f-b615-dddc4dd812ce

📥 Commits

Reviewing files that changed from the base of the PR and between b43a166 and 1263849.

📒 Files selected for processing (6)
  • majit/majit-backend-cranelift/src/compiler.rs
  • majit/majit-translate/src/codegen.rs
  • pyre/pyre-jit-trace/src/descr.rs
  • pyre/pyre-jit-trace/src/trace_opcode.rs
  • pyre/pyre-object/src/float_array.rs
  • pyre/pyre-object/src/int_array.rs

Walkthrough

Adds a typed-list pop trace path and method-object specialization in the tracer, a centralized compiler check enforcing a paired GuardNotForced immediately after certain call opcodes, descriptor helpers for method-object fields, a small assembler lookup ordering change, and minor benchmark comments/structural edits. (50 words)

Changes

Compiler Guard Validation

Layer / File(s) Summary
Helper Extraction / Validation
majit/majit-backend-cranelift/src/compiler.rs
Adds private check_paired_guard_not_forced(ops, op_idx, opcode, label) which enforces that ops[op_idx + 1] exists and is GuardNotForced or GuardNotForced2, otherwise returns unsupported_semantics with a message referencing the expected guard at +1 and trace-shape details.
Opcode Lowering Wiring
majit/majit-backend-cranelift/src/compiler.rs
Replaces inline adjacency checks in lowering paths for CallMayForce* and CallReleaseGil* with calls to the new helper.
Tests
majit/majit-backend-cranelift/...
Updates test_call_may_force_with_intervening_ops to assert the new BackendError message contains expected guard_not_forced(_2) at +1 and mentions SameAsI.

List.pop Trace Feature (Tracer + Codegen + Descriptors)

Layer / File(s) Summary
Tracer Specialization
pyre/pyre-jit-trace/src/trace_opcode.rs
Adds W_MethodObject fast path in call_callable_value for builtin-coded method-backed append/pop, guarded via METHOD_TYPE and pinned w_function; dispatches append to list_append_value and pop to list_pop_value when concrete_len > 0.
Tracer Helper
pyre/pyre-jit-trace/src/trace_opcode.rs
Adds MIFrame::list_pop_value which inspects concrete list and length, selects storage strategy (object/int/float), and delegates lowering; falls back to generic trace-call when preconditions fail.
Codegen Generation
majit/majit-translate/src/codegen.rs
Adds pub fn generated_list_pop_by_strategy(frame, ctx, list, strategy_id) -> OpRef that guards class/strategy, reads runtime len (opimpl_getfield_gc_i), emits non-empty deopt guard, decrements/sets length (SetfieldGc + setfield_cached), and returns the last element per strategy (clearing object slot to const_ref(0) or boxing primitives).
Descriptors
pyre/pyre-jit-trace/src/descr.rs
Adds W_METHOD_DESCR_GROUP and public helpers method_w_function_descr() and method_w_self_descr() to access method-object w_function/w_self field descriptors used by the tracer.
Fallbacks & Imports
pyre/pyre-jit-trace/src/trace_opcode.rs
Adds pyre_object::methodobject imports and preserves fallback to trace_call_callable when storage strategy, length, or other guards prevent specialization.
Tests / Integration
(implicit)
No new explicit tests added in this diff; new paths are intended to be covered by existing tracing/integration tests.

Assembler OpRef Resolution Change

Layer / File(s) Summary
Lookup Order & Raw() Use
majit/majit-backend-dynasm/src/x86/assembler.rs
opref_type_at now uses opref.raw() for lookups and changes non-constant resolution order to check inputarg_index via raw() before falling back to op_index via raw(); constant handling also uses raw() into constant_types.

Benchmarks / Docs

Layer / File(s) Summary
Comments / Documentation
pyre/bench/list_insert.py, pyre/bench/list_reverse.py, pyre/bench/list_setslice.py
Adds module-level notes explaining why benchmarks are kept at module scope and describing known quirks/bugs when wrapped in main().
Structure Change
pyre/bench/list_pop_append.py
Moves benchmark body into a new def main(): and invokes it at module level so the script executes the benchmark when run.

Sequence Diagram

sequenceDiagram
    participant Tracer as Tracer\n(trace_opcode)
    participant Codegen as Codegen\n(codegen)
    participant Compiler as Compiler\n(cranelift)

    Tracer->>Tracer: Detect method-backed list.pop call
    Tracer->>Tracer: Guard method object (METHOD_TYPE) and pin w_function
    Tracer->>Tracer: Recover receiver and compute concrete_len

    alt concrete_len > 0
        Tracer->>Tracer: Determine storage strategy (obj/int/float)
        Tracer->>Codegen: generated_list_pop_by_strategy(frame, ctx, list, strategy)
        Codegen->>Codegen: Read runtime len via opimpl_getfield_gc_i
        Codegen->>Codegen: Emit guard len > 0, compute last_index = len - 1
        Codegen->>Codegen: Update length field (SetfieldGc + setfield_cached)
        Codegen->>Codegen: Load last element, clear slot (object) or read & box (int/float)
        Codegen-->>Tracer: Return popped OpRef
    else zero/unknown
        Tracer->>Tracer: Fallback to generic trace_call_callable
    end

    Tracer->>Compiler: Lower CallMayForce/CallReleaseGil
    Compiler->>Compiler: check_paired_guard_not_forced ensures GuardNotForced at +1
    Compiler-->>Tracer: Emit lowered code or unsupported_semantics error
Loading

Estimated code review effort

🎯 4 (Complex) | ⏱️ ~60 minutes

Poem

🐰 I hop through traces with a curious stare,
I pop the last item, box numbers with care,
A strict guard at +1 keeps surprises away,
Descriptors and codegen stitch night into day,
The JIT hums softly — the rabbit is gay.

🚥 Pre-merge checks | ✅ 5
✅ Passed checks (5 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title 'Optimize JIT for list operations and enhance method dispatch' directly summarizes the main changes: JIT optimization for list.pop() and list.append(), improved method dispatch via bound-method specialization, and backend improvements.
Docstring Coverage ✅ Passed Docstring coverage is 100.00% which is sufficient. The required threshold is 80.00%.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests

Tip

💬 Introducing Slack Agent: The best way for teams to turn conversations into code.

Slack Agent is built on CodeRabbit's deep understanding of your code, so your team can collaborate across the entire SDLC without losing context.

  • Generate code and open pull requests
  • Plan features and break down work
  • Investigate incidents and troubleshoot customer tickets together
  • Automate recurring tasks and respond to alerts with triggers
  • Summarize progress and report instantly

Built for teams:

  • Shared memory across your entire org—no repeating context
  • Per-thread sandboxes to safely plan and execute work
  • Governance built-in—scoped access, auditability, and budget controls

One agent for your entire SDLC. Right inside Slack.

👉 Get started


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 and usage tips.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Actionable comments posted: 3

🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@majit/majit-translate/src/codegen.rs`:
- Around line 1570-1577: Compute last_index only after guarding that the list is
non-empty: before calling ctx.record_op(OpCode::IntSub, &[len, one]) for
last_index/new_len, insert a non-empty check on len (use ctx.record_op with
OpCode::IntEq or OpCode::IntLE as appropriate) and emit a conditional
guard/branch that jumps to the fallback path when len == 0; only when the guard
passes (len > 0) compute last_index = ctx.record_op(OpCode::IntSub, &[len, one])
and set new_len = last_index. Reference symbols: opimpl_getfield_gc_i, len, one,
last_index, new_len, and ctx.record_op to locate where to add the check and
guard.
- Around line 1580-1585: The pop implementation leaves the removed reference in
storage, which can keep objects alive; after retrieving the popped value via
crate::state::trace_items_block_getitem_value(ctx, items_block, last_index) you
should overwrite that slot with a nil/none sentinel so the GC no longer sees a
live reference. Concretely, after obtaining item, record a SetfieldGc (or
equivalent write) to the items block at last_index to store the nil value and
update any heap cache entry for that slot (similar to the existing
ctx.record_op_with_descr(OpCode::SetfieldGc, &[list, new_len], len_descr) and
ctx.heap_cache_mut().setfield_cached calls) so both the emitted op and
heap_cache reflect the cleared slot.

In `@pyre/pyre-jit-trace/src/trace_opcode.rs`:
- Around line 3981-4010: The fallback incorrectly calls
trace_call_callable(list, &[]) which traces invoking the receiver as a callable
(list()), not the bound pop method; in list_pop_value replace those fallback
calls so they invoke the bound pop callable instead of the receiver — obtain or
construct the OpRef that represents the bound method (the same callable you
would pass into generated_list_pop_by_strategy when using strategies) and pass
that OpRef into trace_call_callable with the empty args; update both
early-return and final-return paths in list_pop_value to use that bound-pop
OpRef (use existing helpers for attribute/method lookup if available, e.g., the
code path that produces the callable used by generated_list_pop_by_strategy).
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

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

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: ASSERTIVE

Plan: Pro

Run ID: 94444d26-ce96-4d0e-a367-1924581efe35

📥 Commits

Reviewing files that changed from the base of the PR and between 95a8cda and 246edd9.

📒 Files selected for processing (8)
  • majit/majit-backend-cranelift/src/compiler.rs
  • majit/majit-translate/src/codegen.rs
  • pyre/bench/list_insert.py
  • pyre/bench/list_pop_append.py
  • pyre/bench/list_reverse.py
  • pyre/bench/list_setslice.py
  • pyre/pyre-jit-trace/src/descr.rs
  • pyre/pyre-jit-trace/src/trace_opcode.rs

Comment thread majit/majit-translate/src/codegen.rs
Comment thread majit/majit-translate/src/codegen.rs
Comment thread pyre/pyre-jit-trace/src/trace_opcode.rs
/// the call and `GuardNotForced` lands at +1 unconditionally. Once
/// that lands this scanner can collapse back to upstream's `+1`-only
/// check.
fn check_paired_guard_not_forced(

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

CI test asserts stale error contract after check_paired_guard_not_forced was relaxed

Commit 9c234d0788 relaxed the adjacency check (forward-scan instead of strict +1), but test_call_may_force_with_intervening_ops still called .unwrap_err() on the compile result — a compile-time panic since the backend now returns Ok. The test comment already said "This proves the codegen accepts non-adjacent placement" but the assertion contradicted it. Fixed by replacing the .unwrap_err() + error-message check with .unwrap() + execution assertions (non-forced path returns 42, forced path fires GuardNotForced with correct fail_args and savedata).

in majit/majit-backend-cranelift/src/compiler.rs

@@ fn test_call_may_force_with_intervening_ops() {
        let _guard = may_force_test_lock()
            .lock()
            .unwrap_or_else(|err| err.into_inner());
-        // RPython backend parity requires GUARD_NOT_FORCED to be the
-        // immediate next operation after CALL_MAY_FORCE.
+        // RPython backend parity: GUARD_NOT_FORCED may have intervening ops
+        // between CALL_MAY_FORCE and itself; the codegen must accept non-adjacent placement.
+        // (Relaxed in check_paired_guard_not_forced forward-scan.)

@@
-        let err = backend
-            .compile_loop(&inputargs, &ops, &mut token)
-            .unwrap_err();
-        match err {
-            BackendError::Unsupported(msg) => {
-                assert!(msg.contains("CallMayForceI"));
-                assert!(msg.contains("ops[position+1] must be guard_not_forced(_2)"));
-            }
-            other => panic!("expected unsupported error, got {other:?}"),
-        }
+        // Non-adjacent GuardNotForced placement is now accepted.
+        backend.compile_loop(&inputargs, &ops, &mut token).unwrap();
+
+        // Not forced: function returns 42; SameAsI propagates it to Finish.
+        let frame = backend.execute_token(&token, &[Value::Int(20), Value::Int(0)]);
+        assert!(backend.get_latest_descr(&frame).is_finish());
+        assert_eq!(backend.get_int_value(&frame, 0), 42);
+
+        // Forced: GuardNotForced fires; fail_args = [flag=1, call_result=42, inputarg0=10].
+        let frame = backend.execute_token(&token, &[Value::Int(10), Value::Int(1)]);
+        assert_eq!(backend.get_latest_descr(&frame).fail_index(), 0);
+        assert_eq!(backend.get_int_value(&frame, 0), 1);
+        assert_eq!(backend.get_int_value(&frame, 1), 42);
+        assert_eq!(backend.get_int_value(&frame, 2), 10);
+        assert_eq!(backend.get_savedata_ref(&frame).unwrap(), GcRef(0xBABA));
}

/// `trace_call_callable` since pyre has no `jit_list_pop` residual
/// helper yet — the bound-method call dispatcher handles the slow
/// case).
pub(crate) fn list_pop_value(

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

list_pop_value fallback traces list() instead of calling the bound method

The original fallback path called trace_call_callable(list, &[]) where list is the receiver OpRef (the list object), not the W_MethodObject. At runtime this would trace a call to list as a constructor (list()TypeError). Fixed by adding callable: OpRef as a parameter and using it in all fallback sites.

Comment thread pyre/pyre-jit-trace/src/trace_opcode.rs Outdated
Comment thread pyre/pyre-jit-trace/src/trace_opcode.rs
Comment thread majit/majit-translate/src/codegen.rs
Comment thread pyre/pyre-jit-trace/src/trace_opcode.rs Outdated
@lifthrasiir
lifthrasiir force-pushed the list-bound-method-fastpath branch from 0bbe50a to feb6848 Compare May 1, 2026 11:21

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Actionable comments posted: 2

🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@majit/majit-translate/src/codegen.rs`:
- Around line 1617-1624: The module path for boxing is inconsistent: replace the
direct call to crate::trace_box_float with crate::generated::trace_box_float to
match crate::generated::trace_box_int, or better yet switch both boxing calls to
use the established state helpers crate::state::wrapint and
crate::state::wrapfloat (used elsewhere) so boxing is consistent across the
file; update the calls at the locations invoking trace_box_int/trace_box_float
(and the similar block at 1637-1644) to use either crate::generated::trace_box_*
consistently or to call crate::state::wrapint/crate::state::wrapfloat instead.

In `@pyre/pyre-jit-trace/src/trace_opcode.rs`:
- Around line 4182-4235: The current fast-path only checks func_name
("append"/"pop") which can match other builtin-coded methods on list subclasses;
instead guard that inner_func is the exact built-in list method by comparing its
code/function object to the canonical list append/pop implementation before
rewiring. Locate inner_func (and where func_name is computed using
pyre_interpreter::function_get_name) and replace or augment the func_name check
with an equality check between pyre_interpreter::getcode(inner_func) (or
inner_func pointer) and the canonical list method code/function object
(obtainable once and stored as a constant or fetched via the runtime for the
list builtin), and only call list_append_value or list_pop_value when that
equality succeeds (leave other cases to trace_call_callable).
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

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

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: ASSERTIVE

Plan: Pro

Run ID: b33e5d6e-7af3-4393-9655-60e805d409db

📥 Commits

Reviewing files that changed from the base of the PR and between 246edd9 and 7a5a077.

📒 Files selected for processing (4)
  • majit/majit-backend-cranelift/src/compiler.rs
  • majit/majit-translate/src/codegen.rs
  • pyre/pyre-jit-trace/src/descr.rs
  • pyre/pyre-jit-trace/src/trace_opcode.rs

Comment on lines +1617 to +1624
crate::generated::trace_box_int(
ctx,
raw,
crate::descr::w_int_size_descr(),
crate::descr::ob_type_descr(),
crate::descr::int_intval_descr(),
int_type_addr,
)

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟠 Major | ⚡ Quick win

Inconsistent module paths for boxing functions.

The int strategy uses crate::generated::trace_box_int while the float strategy uses crate::trace_box_float (missing the generated:: prefix). Since both functions are defined at the same level in the generated code, the paths must be consistent.

Additionally, the rest of this file uses crate::state::wrapint / crate::state::wrapfloat for boxing (e.g., lines 924, 1046, 1743, 1949, 1968). Consider using the same pattern here for consistency.

🔧 Proposed fix using wrapint/wrapfloat pattern
         1 => {
             let items_ptr = crate::state::opimpl_getfield_gc_i(
                 ctx,
                 list,
                 crate::descr::list_int_items_ptr_descr(),
             );
             let raw =
                 crate::state::trace_raw_int_array_getitem_value(ctx, items_ptr, last_index);
             ctx.record_op_with_descr(OpCode::SetfieldGc, &[list, new_len], len_descr);
             ctx.heap_cache_mut().setfield_cached(list, len_descr_idx, new_len);
-            let int_type_addr = &pyre_object::pyobject::INT_TYPE as *const _ as i64;
-            crate::generated::trace_box_int(
-                ctx,
-                raw,
-                crate::descr::w_int_size_descr(),
-                crate::descr::ob_type_descr(),
-                crate::descr::int_intval_descr(),
-                int_type_addr,
-            )
+            crate::state::wrapint(ctx, raw)
         }
         2 => {
             let items_ptr = crate::state::opimpl_getfield_gc_i(
                 ctx,
                 list,
                 crate::descr::list_float_items_ptr_descr(),
             );
             let raw =
                 crate::state::trace_raw_float_array_getitem_value(ctx, items_ptr, last_index);
             ctx.record_op_with_descr(OpCode::SetfieldGc, &[list, new_len], len_descr);
             ctx.heap_cache_mut().setfield_cached(list, len_descr_idx, new_len);
-            let float_type_addr = &pyre_object::pyobject::FLOAT_TYPE as *const _ as i64;
-            crate::trace_box_float(
-                ctx,
-                raw,
-                crate::descr::w_float_size_descr(),
-                crate::descr::ob_type_descr(),
-                crate::descr::float_floatval_descr(),
-                float_type_addr,
-            )
+            crate::state::wrapfloat(ctx, raw)
         }

Also applies to: 1637-1644

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@majit/majit-translate/src/codegen.rs` around lines 1617 - 1624, The module
path for boxing is inconsistent: replace the direct call to
crate::trace_box_float with crate::generated::trace_box_float to match
crate::generated::trace_box_int, or better yet switch both boxing calls to use
the established state helpers crate::state::wrapint and crate::state::wrapfloat
(used elsewhere) so boxing is consistent across the file; update the calls at
the locations invoking trace_box_int/trace_box_float (and the similar block at
1637-1644) to use either crate::generated::trace_box_* consistently or to call
crate::state::wrapint/crate::state::wrapfloat instead.

Comment thread pyre/pyre-jit-trace/src/trace_opcode.rs Outdated
@lifthrasiir
lifthrasiir force-pushed the list-bound-method-fastpath branch from 7a5a077 to 5b4cd86 Compare May 3, 2026 22:25
youknowone added a commit that referenced this pull request May 4, 2026
flowspace_adapter::translate_op gains line-by-line parity with
RPython's `add_operator(...)` registrations (operation.py:465-506):

- BinOp opname normalisation: `bitand`→`and_`, `bitor`→`or_`,
`bitxor`→`xor`; `*_assign`→`inplace_*` covering add/sub/mul/div/mod/
lshift/rshift/and/or/xor.  `and`/`or` fail-loud because RPython
lowers Python short-circuit logical-and/or to control-flow
branches via `flowcontext`, never as flowspace BinOp nodes.
- UnaryOp arm passes opname through unchanged (`neg`, `invert`,
`pos`, `bool`, `abs` per operation.py:465-474).
- GuardTrue/GuardFalse/GuardValue/VableForce arms now skip with
`Ok(Vec::new())`.  Upstream high-level flowspace has no JIT guard
markers (those live in the metainterp's resoperation stream); the
virtualizable force hint is a `pyjitpl`-time injection, not a
flowspace op.

`opkind_variant_name` extended with the BinOp/UnaryOp/VableForce
tags so the deferred fail-loud path produces a greppable variant
name when other unported variants reach the adapter.

`InputArg::opref(&self) -> OpRef` added on `value.rs` to centralise
the `OpRef(self.index)` conversion at the InputArg-as-Box duality
site.

Verified: majit-translate 2382/0, pyre-jit-trace 204/0/14,
majit-metainterp 1325/3 (3 = baseline `previous_token_backend_*`
isolation flakes, identical pattern at HEAD~1), check.py
14/14+14/14 ALL PASSED.

trace_ctx: drop signature_hash surrogate cache + forbidden test

Remove the FNV-1a `signature_hash(arg_types, ret_type)` surrogate from
`call_descr.rs` and the heapcache lookup/store it backed in
`trace_ctx.rs::call_loopinvariant_impl`.  The surrogate violated
`heapcache.py:629 self.loop_invariant_descr is not descr` semantics in
two ways: hash collisions (~1/2^32) and same-signature over-merge
across distinct upstream descrs.  RPython-orthodox loop-invariant
caching already lives at the per-descr-index layer
(`pyjitpl/mod.rs:11985-12028 do_residual_or_indirect_call` and
`pyre-jit-trace::jitcode_dispatch::loopinvariant_lookup`); the typed
helper now emits the op verbatim and relies on
`optimizeopt/rewrite.rs:2805-2865 optimize_CALL_LOOPINVARIANT`
(rewrite.py:448-470) for dedup, which keys on the constant func value
the same way upstream's optimizer does.

Drop the defensive test
`call_loopinvariant_typed_distinguishes_signatures_for_same_func_ptr`:
it exercised a scenario `rpython/jit/codewriter/call.py:249-251`
explicitly forbids (non-void args on `_jit_loop_invariant_` functions)
and only existed to protect the surrogate's behaviour.

Update `trace_opcode.rs:1588` comment to describe the actual
`GetfieldRawI` emission and acknowledge the upstream-orthodox
`GETFIELD_GC_R` form.  The direct opcode swap is gated on bringing
cranelift's GC-barrier coverage for the `PYFRAME_DESCR_GROUP` read
path up first; a naive swap SIGABRTs in fib_recursive.

parity: bridge local fallback + loopinvariant cache key + multi-session TODO

trace_opcode: bridge local fallback reads via locals_cells_stack_w array
(virtualizable.py:94 parity).  The pre-fix path used the PyFrame* as if
it were the array base, mismatching upstream's
`virtualizable.py:85-99 read_boxes` which routes the per-slot read
through `locals_cells_stack_w` after `wrap()`.

trace_ctx: replace `descr_index = func_ref.0` at
`call_loopinvariant_impl` with
`call_descr::signature_hash(arg_types, ret_type)` (FNV-1a 32-bit).
The previous surrogate doubly-encoded the func_ptr already captured by
`arg0_int` and conflated two distinct signatures sharing the same
callee address, risking a false hit returning a wrong-shape result.
The signature hash distinguishes same-callee different-signature,
mirroring `heapcache.py:629-634 call_loopinvariant_known_result`'s
"descr is descr" identity at the typed-helper layer where the freshly-
constructed `MetaCallDescr.index()` returns the `u32::MAX` sentinel.
New unit test
`call_loopinvariant_typed_distinguishes_signatures_for_same_func_ptr`
covers the regression.

docs/parity-blockers-todo.md: track four pre-existing parity blockers
as multi-session TODO with citations + convergence paths:

SameAs emission) is structurally impossible — `optimizer.rs:3231-3239
optimize_SAME_AS_*` calls `make_equal_to(op.pos, op.arg(0))` and
`resume.rs:3695 _number_boxes` applies `get_box_replacement` before
dedup, so a forwarded SameAs is invisible at numbering.  Real options
are Path C-1 (new `SameAsForResumeOnly` opcode the optimizer doesn't
forward) or Path C-2 (out-of-IR OpRef index space); both multi-session
epics.  Reclassified as "structural parity not yet complete" rather
than "correctness bug" — 14/14+14/14 sweep confirms no observable
divergence.  #2/#3 dependency on #1 relaxed because pyre's liveness-
based recovery is upstream-aligned.  Recommended sequencing:
`#4#2#3#1 (deferred)`.
@youknowone youknowone closed this in 2137585 May 4, 2026
@youknowone youknowone reopened this May 5, 2026
@youknowone

Copy link
Copy Markdown
Owner

Sorry, I accidently closed this pr. not intended

lifthrasiir and others added 16 commits May 5, 2026 15:20
pyre's eval gates JIT entry on `code.obj_name != "<module>"` so module-level
loops never reach `maybe_compile_and_run`. The four list benches were all at
module level, leaving JIT dormant; cranelift list_pop_append measured 38x
slower than PyPy not from a JIT path issue but because no JIT path ran.

Wrapping list_pop_append in `def main(): ... main()` lets JIT compile and
brings dynasm to ~5x (near PyPy parity, from 38x) and cranelift to ~21x. The
remaining cranelift gap is the next-layer issue: even when JIT fires, list
methods stay as residual `CallR` helpers instead of lowering to direct
IntegerListStrategy array ops the way PyPy's oopspec recognition does.

The other three benches stay at module level for now — wrapping them
exposed JIT-path bugs that the module-level skip had been masking:

- list_setslice: SIGABRT in opcode_ops.rs:178 with "list indices must be
  integers, not tuple" — STORE_SUBSCR slice-assignment lowering bug
- list_insert: dynasm-only wrong output (lst[0]=N-1 expected, got 0) —
  dynasm backend insert lowering bug
- list_reverse: result lands ~13x cpython, straddling the 10x threshold
  flakily depending on cpython measurement noise

Each file carries an inline NOTE pointing at the blocker so the wrap can be
re-applied once the upstream issue is resolved.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
The Cranelift backend rejected `CallMayForce*` / `CallReleaseGil*` whenever
`ops[op_idx + 1]` wasn't `GuardNotForced(_2)`. RPython's `_genop_call_may_force`
finds the paired guard via `_find_nearby_operation(+1)` because RPython traces
keep the guard immediately after the call; pyre's optimizer is allowed to slot
non-guard ops (e.g. `NewWithVtable` + `SetfieldGc` from forcing a virtual int
box) between them, so list-method-heavy traces hit a false-positive
"unsupported in the Cranelift backend yet" rejection — most visibly on the
wrapped `list_pop_append` bench, where every iteration's `i = i + 1` boxes
into a `New` that gets pulled in front of the second `GuardNotForced`.

The codegen below the check tolerates the gap. `guard_idx` advances per guard,
so as long as no *other* guard precedes the paired `GuardNotForced`,
`guard_infos[guard_idx]` already points at the right entry — both
`fail_descr_ptr` (stored to `jf_force_descr` before the call) and
`fail_arg_refs` (spilled to `jf_frame`) come out correct.

`check_paired_guard_not_forced` scans forward instead of demanding +1, and
explicitly rejects the case that would actually break codegen (an intervening
guard that would shift `guard_idx`).

Effect, measured via `pyre/check.py`:
- cranelift `list_pop_append`: 20.5x cpython → 4.8x cpython
  (matching dynasm's 4.7x — both within ~5x of PyPy's 0.01s)
- No movement on other benches; the strict +1 was only false-positive on
  call-heavy traces with virtual int boxing in the loop body.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…path

`baseobjspace::getattr(lst, "append")` returns a fresh `W_MethodObject`
each iteration (load_method:6334 then pushes `null_value` for
`is_method` attrs), so `call_callable_value` saw `concrete_callable =
W_MethodObject` and `args = [item]` only. Neither the `is_builtin` arm
(W_MethodObject is not is_function) nor the `is_function` arm matched,
so the call fell through to `trace_call_callable` and emitted a generic
`jit_call_callable_1` dispatch — leaving every iteration paying for
bound-method lookup, allocation, and a residual CallMayForceR even
though the per-strategy `list_append_value` fast path was already in
place for `LIST_APPEND` (list-comprehension) bytecode.

This routes `lst.append(item)` to that same fast path. The receiver was
discarded by load_method, so we recover it from the method object via
`GetfieldGcR(callable, w_self)` after guarding the method's class. The
function pointer inside the method object IS stable across iterations
(unlike the method-object pointer itself), so a `GuardValue` on the
`w_function` slot pins the specialization without invalidating on the
next iteration's fresh allocation.

Adds a `W_METHOD_DESCR_GROUP` (descr.rs) and exposes
`method_w_function_descr` / `method_w_self_descr` accessors. The
`w_class` slot is included in the layout for completeness even though
the JIT doesn't read it yet.

Trace shape on `list_pop_append`'s `lst.append(i)`, after opt:

  GuardNonnullClass(method, METHOD_TYPE)
  GetfieldGcR(method, w_function)
  GuardValue(w_function, append_func_ptr)
  GetfieldGcR(method, w_self)
  GuardNonnullClass(self, LIST_TYPE)
  GuardTrue(strategy == int)
  GuardFalse(heap_cap == 0)
  GetfieldGcI(self, int_items.ptr)
  SetarrayitemRaw(int_items_ptr, 5, item)
  SetfieldGc(self, 6, int_items.len)
  GuardNoException

— matching the bench's "PYPYLOG confirms: guard_class(IntegerListStrategy)
+ ArrayS 8 ops only" reference.

`pop()` still goes through generic dispatch since this commit only
specializes `append`; `pop` follows in a separate commit (its
`generated_list_pop_by_strategy` doesn't exist yet).

Effect, measured via `pyre/check.py`:
- list_pop_append dynasm: 4.7x cpython → 3.4x cpython
- list_pop_append cranelift: 4.8x cpython → 3.6x cpython
  (vs PyPy 0.01s: now ~4x; was ~5x.)
- All other benches unchanged; 14/14 pass on both backends.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…t path

Mirror of the lst.append(item) specialization in the previous commit.
Shares the W_MethodObject receiver-recovery path (recover_self closure)
in `call_callable_value` so the same shape — guard_class on the method
object, guard_value on the underlying function pointer, and a typed
`GetfieldGcR` for the receiver — covers both methods. Adds
`list_pop_value` (parallel to `list_append_value`) and
`generated_list_pop_by_strategy` (parallel to the append generator).

The pop generator deliberately reads `len` via `GetfieldGcI` instead of
threading `concrete_len` through as a constant. `list_append_value` only
emits IR — it does not concretely mutate the list — so by the time pop
is recorded right after append in `lst.append(i); lst.pop()`, the
trace-time concrete length still reflects the *post-iteration* state
(5 in `list_pop_append`'s steady state) while the runtime length the
compiled code reads is the *post-append* length (6). An empirical
verification of an earlier `concrete_len`-based draft showed cranelift
producing "6 0" instead of "5 0" because the `GuardValue(len, 5)` and
`SetfieldGc(len, 4)` indices were both off by one. Reading length
dynamically lets the JIT heap cache resolve it to the constant cached
by append's prior `setfield_cached`, which the optimizer's IntSub
constant-fold then folds to the correct `last_index` and `new_len`.

Effect, measured via `pyre/check.py`:
- list_pop_append dynasm: 3.4x cpython → 2.5x cpython
- list_pop_append cranelift: 3.6x cpython → 2.6x cpython
  (vs PyPy 0.01s: now 2.5x; was 4x)
- All other benches unchanged; 14/14 pass on both backends.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Both adaptations landed in this session without naming the upstream blocker
they compensate for. Strengthens the comments to cite RPython file:line and
the specific port that would let the deviation collapse back to canonical
shape. No behavior change.

1. `cranelift::check_paired_guard_not_forced`. RPython
   `rpython/jit/backend/x86/assembler.py:2234-2235 _genop_call_may_force` /
   `:2242-2244 _genop_call_release_gil` use `_find_nearby_operation(+1)` and
   assert `isinstance(GuardOp)`. Upstream's invariant holds because
   `optimizeopt/heap.py` does not schedule `NEW_WITH_VTABLE` /
   `SETFIELD_GC` between a force-able call and its paired guard. Pyre's
   heap pass currently does, so the relaxed scanner accommodates the gap.
   Convergence path: align pyre's `optimizeopt/heap.py` op-scheduling
   with upstream so the +1 invariant holds again.

2. `trace_opcode::call_callable_value` bound-method specialization arm.
   RPython recognizes `lst.append(i)` / `lst.pop()` at codewriter time via
   `@oopspec("list.append")` (`rpython/rtyper/extregistry.py` registration
   consumed by
   `rpython/jit/codewriter/jtransform.py:do_resizable_list_append`), which
   rewrites the IR before tracing runs. Pyre's codewriter is `partial`
   (per `majit/COMPATIBILITY_MATRIX.md`), so the specialization happens
   at trace recording time. Convergence path: port the codewriter's
   oopspec pass and remove this arm.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…eceiver

The original tail fallback at the end of `list_pop_value` called
`trace_call_callable(list, &[])` where `list` is the receiver `OpRef`
(the `W_ListObject`), not the `W_MethodObject` callable. If the
strategy fast path is unavailable (unknown strategy), runtime would
trace `list(...)` — which resolves to `list.__call__` and raises
`TypeError: 'list' object is not callable` — instead of
`bound_method()`.

Switches the tail fallback to `trace_call_callable(callable, &[])`.
The function-signature change adding the `callable` parameter follows
in the next commit.

Co-authored-by: Jiseok CHOI <jiseok.dev@gmail.com>
Adds the `callable: OpRef` parameter that the prior commit's tail
fallback already references, and routes the early-return fallback
(`concrete_list.is_null() || concrete_len == 0`) through the same
`trace_call_callable(callable, &[])` path so a defensive bail still
emits a residual call against the bound method, not the receiver.

Updates the docstring to spell out why the parameter is the bound
`W_MethodObject` and not the receiver: the residual must emit
`jit_call_callable_0` on the *method* — calling the list itself would
be a TypeError. Caller-site update (passing `callable` to the new
parameter) follows in a later commit.

Co-authored-by: Jiseok CHOI <jiseok.dev@gmail.com>
Two RPython-parity fixes to `generated_list_pop_by_strategy`:

1. Empty-list guard. RPython `rtyper/rlist.py:633-634 ll_pop_default`
   carries `ll_assert(length > 0, "pop from empty list")`. Pyre's caller
   (`list_pop_value`) verifies `concrete_len > 0` only at recording
   time, but the same compiled trace can re-enter on a later iteration
   (or a different list sharing the green key) where the live length
   is 0; without a guard `IntSub(0, 1) = -1` feeds a negative index
   into the raw getitem and corrupts memory before the interpreter
   fallback that should raise `IndexError` ever runs. Emits an
   `IntGt(len, 0); GuardTrue` so the compiled code deopts on empty.

2. Clear popped slot for Object strategy. RPython
   `rtyper/rlist.py:641-643` does:
       null = ll_null_item(l)
       if null is not None:
           l.ll_setitem_fast(index, null)
   For a Ptr item type `ll_null_item` returns a null pointer. Object
   strategy stores `Ref`s in `items_block`, so a stale entry past
   `length` would keep the popped object reachable as a GC root.
   Int/Float strategies skip the clear because their items are raw
   payloads (ll_null_item returns None for primitive item types).

Co-authored-by: Jiseok CHOI <jiseok.dev@gmail.com>
Caller-site update for the `callable` parameter added in the earlier
commit. Without this, `list_pop_value` would never see the bound
`W_MethodObject` and the fallback paths would still effectively trace
a call on the receiver.

Co-authored-by: Jiseok CHOI <jiseok.dev@gmail.com>
…check

Commit 9c234d0 relaxed `check_paired_guard_not_forced` to a forward
scan, but `test_call_may_force_with_intervening_ops` still asserted
`.unwrap_err()` against the strict-check error message — a
compile-time panic since the backend now returns `Ok` on the same
input. The test comment ("This proves the codegen accepts non-adjacent
placement") already contradicted the assertion.

Replaces the `.unwrap_err()` + error-message check with `.unwrap()` +
execution assertions: the not-forced path returns 42 (Finish via
SameAsI), and the forced path fires GuardNotForced with fail_args
[flag=1, call_result=42, inputarg0=10] and savedata `GcRef(0xBABA)`.
Same shape as `test_call_may_force_i_guard_not_forced_uses_real_call_result`,
just with the SameAsI hop in between.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
The earlier `is_method`+`func_name` arm could misroute a list subclass
that overrides `append`/`pop` with a Python `def` — the override is
still `is_function == true` and matches the name, so it would silently
get builtin list IR. PyPy's `_Method` dispatch (`baseobjspace.py:1252`
→ `function.py:566`) just unwraps and calls `w_function` generically,
so the override runs.

Adds an `is_builtin_code(getcode(inner_func))` filter alongside the
existing `is_function`/`is_list` gates. Python overrides now fall
through to `trace_call_callable`; only the C-implemented
`list.append`/`list.pop` route to the strategy fast path.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
The forward-scan relaxation in 9c234d0 deviated from RPython
(`x86/assembler.py:2225-2244`), which takes the paired guard via
`_find_nearby_operation(+1)` and asserts `isinstance(GuardOp)`. The
relaxation also allowed a second force-able call to misclaim a later
GuardNotForced as its pair — RPython's +1 invariant prevents this by
construction.

Restores the strict `ops[op_idx + 1]` check and aligns the test:
`test_call_may_force_with_intervening_ops` now asserts the backend
rejects the SameAsI gap with a "expected guard_not_forced(_2) at +1"
error, matching upstream's invariant. Verified `python3 pyre/check.py`
14/14 on both dynasm and cranelift; the optimizer already preserves
CMF/GuardNotForced adjacency in practice.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
PyPy `_Method._immutable_fields_ = ['w_function', 'w_instance']`
(`pypy/interpreter/function.py:567`). Pyre's W_METHOD_DESCR_GROUP had
both as mutable, breaking structural parity and preventing the JIT's
immutable-field cache (heap.rs:665) from carrying these reads across
calls. `w_class` is not in `_immutable_fields_` and stays mutable.

Note: this triggers a list_pop_append benchmark regression
(2.4x -> 3.7x cpython on both backends, 14/14 tests still pass).
The regression is in pyre's immutable-field handling — likely
interacting badly with the per-iteration `getattr` that allocates a
fresh W_MethodObject — and warrants a separate investigation. The
parity fix itself is correct.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Two comment-only fixes from review:

* descr.rs:1131 — the `rlist.py:116 l.length` doc had been misattached
  to `method_w_function_descr`, leaving `list_length_descr` undocumented.
  Reorder so each doc sits above its actual descr function. Also note
  the immutable-field source (`function.py:567`) on the method descrs.

* trace_opcode.rs:4154 — the previous comment cited
  `rpython/jit/codewriter/jtransform.py:do_resizable_list_append`, which
  doesn't exist (jtransform only has `do_resizable_list_{getitem,setitem,
  len}`). Replace with the actual rtyper helpers: `rlist.py:588 ll_append`
  is documented "no oopspec — inlined by the JIT", and the pop path
  picks `ll_pop_nonneg` / `ll_pop_zero` (the only variants carrying an
  `@oopspec`) over `ll_pop_default` based on the index sign. The
  metainterp sees direct array ops because those helpers are inlined
  or oopspec'd before tracing.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Records the review observation that the wrong optimizer shape to watch
for is `CMF -> NEW_WITH_VTABLE -> SETFIELD_GC -> GUARD_NOT_FORCED`
(virtual escapes after the call), versus the RPython-correct
`NEW_WITH_VTABLE -> SETFIELD_GC -> CMF -> GUARD_NOT_FORCED`. If the
strict +1 check ever rejects a real trace, the fix belongs in
optimizeopt/virtualize, not in this backend assertion — `_store_force_index`
runs once on the operation at +1, so post-call materialization would
leave `GuardNotForced.fail_args` pointing at boxes the force path
cannot read coherently. Comment-only.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Main commit `0991e66de8` (OpRef → AbstractValue Phase 1-5) migrated
`OpRef` from a tuple struct to an enum with a `.raw()` accessor, but
left four `&opref.0` accesses in `Assembler386::opref_type_at`. The
arm64 darwin build path skips this module via
`#[cfg(target_arch = "x86_64")]`, hiding the breakage from local
checks; CI on x86_64 trips `error[E0609]: no field 0 on type OpRef`.

Mechanical fix at lines 401, 403, 412, 415: `&opref.0` → `&opref.raw()`.
No behavior change — `raw()` returns the same u32 the tuple field
exposed pre-migration.
@youknowone
youknowone force-pushed the list-bound-method-fastpath branch from e687079 to b43a166 Compare May 5, 2026 08:26
youknowone added a commit that referenced this pull request Jun 6, 2026
…+ regression guard

The walker-safe subset wired ahead of flatten (eliminate_empty_blocks +
constfold_exitswitch + remove_trivial_links) removes every graph shape that
yields an unmarked jitcode label: dead/trivial forwarders and dead
constant-switch arms. The all_passes entries left unwired only rename or dedup
variables, are op-level rewrites, or are documented no-ops, so they do not
affect label structure.

- simplify.rs module doc: per-pass coverage analysis of the unmarked-label gap.
- assembler.rs patch_labels: note the panic is a fail-loud backstop, not a
  live failure mode.
- simplify.rs: regression test asserting the port-boundary invariant (no
  reachable link targets a dead/dropped block) after the subset on a graph
  carrying both a dead switch arm and a trivial forwarder.

Assisted-by: Claude
youknowone added a commit that referenced this pull request Jun 6, 2026
… + simplify_graph (#127) and canonical-flatten (#73) JIT passes (#130)

* pyre-jit: faithful operations-based eliminate_empty_blocks for simplify_graph (codex P2, #127)

all_passes() / simplify_graph() reused codewriter::eliminate_empty_blocks,
whose predicate only collapses block.dead==true targets — the walker-only
proxy. On a normal flow graph that leaves a non-dead empty forwarding block
that carries args (start -> empty(input) -> next) uncollapsed, since
remove_trivial_links also skips it (the incoming link has args), so the
orthodox driver failed to normalize the very forwarding shape it targets
(codex P2, PR #127).

Add the faithful operations-based eliminate_empty_blocks (simplify.py:52-69,
`not link.target.operations`) to simplify.rs and use it in all_passes. The
walker wiring keeps calling codewriter::eliminate_empty_blocks (dead
predicate) because every walker block has empty operations (inline SSARepr),
so a not-operations predicate would collapse all of them. New graph-shape
test; pyre-jit lib 253 passed.

Assisted-by: Claude

* pyre-jit: drop the old boundary goto when merging trivial links (codex P1, #127)

The remove_trivial_links merge bridge appended a merged target's
per_block_ssarepr onto its source but left source's old block-boundary
`goto TLabel(target) + Unreachable` in the middle of the merged stream.
emit_link_renamings_into_block's SourceBeforeTerminator splice forward-scans
for the FIRST terminator, so the absorbed link's renamings were inserted
before that stale goto — and before target's opcodes. In a trivial chain
where target computes a value passed to its successor, the copy would read
the pre-target slot and the successor see stale data (codex P1, PR #127).

Strip source's trailing boundary `goto target + ---` before absorbing, so
target's own terminator is the first terminator in the merged block and the
splice lands after target's opcodes. Full check.py dynasm 39/39; pyre-jit
lib 253 passed.

Assisted-by: Claude

* pyre-jit: lower canonical ConstRef ref_return via constants-window register (#73)

The canonical flatten_graph path lowers a returned graph Constant ref to
Operand::ConstRef (flatten_constant_operand), but the assembler's
ref_return dispatch called expect_reg and panicked on the non-Register
operand, blocking canonical-assemble for graphs that return a constant.

Add JitCodeBuilder::ref_return_const, mirroring load_const_r: it encodes
the operand as a constants-window register index (num_regs_r + pool_idx)
patched in finish(), keeping ref_return/r register-uniform. The constants
window is already pre-loaded into the ref register file (load_const_r
relies on it), so no blackhole or machine-code opcode variant is needed.
Route ref_return ConstRef to it in the pyre-jit assembler dispatch.

Also enrich the expect_reg panic with the dispatch opname via a
thread-local set in write_insn, so operand-shape mismatches name the
offending opcode instead of forcing a SSARepr grep.

PYRE_PHASE4_DIFF_CANONICAL now reports phase4-canonical-assemble OK for
int_arithmetic (was: PANIC ref_return ConstRef).

Assisted-by: Claude

* pyre-jit: elide setattr HLOp in canonical flatten_graph (#73)

The StoreAttr walker arm (codewriter.rs:8551) records a setattr HLOp
into the shadow graph and pairs it with an inline emit_abort_permanent!,
so the production walker's SSARepr carries only abort_permanent — never
a literal setattr Insn. Canonical flatten_graph re-flattened the graph
and leaked setattr into the SSARepr, where the assembler rejected it as
an unimplemented opname.

Add setattr to is_pyre_canonical_elidable_hlop alongside getattr and
type, all three sharing the same abort_permanent pairing: the compiled
trace bails to the interpreter before the HLOp result is read, so the
canonical SSARepr elides the undispatchable op.

PYRE_PHASE4_DIFF_CANONICAL now reports phase4-canonical-assemble OK for
iteration_protocol __init__/__next__ (was: PANIC unimplemented setattr).
Every synth graph reaching the probe now assembles.

Assisted-by: Claude

* pyre-jit: emit trailing -live- after canonical residual/inline calls

serialize_op appends a bare `-live-` after every `residual_call_*` /
`inline_call_*` Insn when running under `lowering_ctx` (the canonical
driver), matching jtransform.py:467-482 handle_residual_call /
handle_regular_call. The canonical stream previously emitted `-live-`
only at branch/raise/return boundaries (insert_exits / make_return), so
the post-call guard_no_exception / inline-boundary resume point had no
marker; the production walker covers it via its per-PC `-live-`.

Conservative superset: emitted for all residual_call_*, not only the
calldescr_canraise subset. The retired HLOp families lowered here
(add/lt/bool/setitem/...) all carry EF_CAN_RAISE.

Gated on lowering_ctx, so the production walker stream is unchanged
(check.py dynasm 39/39). Under PYRE_PHASE4_DIFF_CANONICAL=1,
canonical_total_live rises (float_arithmetic 2 -> 20) with canonical
assemble still OK. Adds a unit test pinning the trailing marker.

Assisted-by: Claude

* majit-vector: document why forwarded_vecinfo scratch uses a pos-keyed cache, not op._forwarded

The vecinfo_cache field comment now records that Op::clone resets the
forwarded slot while preserving pos (resoperation.rs:1344,1352), that the
scheduler reads vecinfo off cloned ops (dependency.rs:221 and the
unroll/schedule clones of loop_.operations), and that INT_SIGNEXT bytesize
is the dynamic arg1 value (cast_to_bytesize_static returns None,
resoperation.rs:2310) recoverable only through int_signext_vecinfo's
setup-time resolver, which vectorization_info_for_op(&Op) cannot reach. The
pos-keyed cache is clone-stable; op._forwarded storage would lose the stamp.

Assisted-by: Claude

* pyre-jit: rustfmt flatten.rs trailing_live binding and position closure

Joins the `trailing_live` let-binding onto one line (91 cols) and rewraps
the `position` closure in the trailing_live test to rustfmt's single-arg
form. Pure formatting; `cargo fmt --check` failed on these two spots.

Assisted-by: Claude

* mapdict: implement DevolvedDictTerminator read/write via instance dict

terminator_read and write_terminator left the DevolvedDictTerminator DICT
arms as unimplemented!. Port them faithfully (mapdict.py:383-395): read via
obj.getdict(space) + finditem_str, write via getdict + setitem_str returning
True. Add _mapdict_self_ref to the MapdictObject trait so the path can reach
_obj_getdict, which keys the instance dict by object address; implement it
for the test MockObj. The write arm is gated on attrkind == DICT so a non-DICT
Devolved write falls through to add_attr, matching the Terminator base.

Replace the LIMIT_MAP_ATTRIBUTES stub comment with a precise statement of the
missing infra (no MapDictStrategy / materialize_str_dict / _make_devolved /
_set_mapdict_storage_and_map) that blocks switch_to_text_strategy; left as a
documented deferral rather than a partial port that would strand attributes.

Assisted-by: Claude

* pyre-interpreter: register prebuilt object singleton addresses for the JIT static catalogue (#127)

`jit_static_ref_addrs` now supplies the captured addresses of the `None`,
`NotImplemented`, `Ellipsis`, `True`, and `False` singletons under their
`module::NAME` catalogue keys. The accessors `w_none` / `w_not_implemented` /
`w_ellipsis` / `w_bool_from` read these private statics as a bare same-file
`LOAD_GLOBAL`; with the address catalogued, the front-end `Expr::Path` same-file
fold emits `ConstRefAddr` with the real runtime identity instead of a cross-block
body-`OpKind::Input` the flowspace adapter rejects. The statics stay private —
the address is captured through the public accessor rather than the `ref_addr!`
`&pyre_object::X` path form.

Build-time `*_SINGLETON` body-Input skips (NONE 69, TRUE 10, ELLIPSIS 3, …)
drop to 0. Skip graph-set unchanged (943); check.py 41/41 both backends.

Assisted-by: Claude

* simplify_graph: record issue #112 scope #3 unmarked-label conclusion + regression guard

The walker-safe subset wired ahead of flatten (eliminate_empty_blocks +
constfold_exitswitch + remove_trivial_links) removes every graph shape that
yields an unmarked jitcode label: dead/trivial forwarders and dead
constant-switch arms. The all_passes entries left unwired only rename or dedup
variables, are op-level rewrites, or are documented no-ops, so they do not
affect label structure.

- simplify.rs module doc: per-pass coverage analysis of the unmarked-label gap.
- assembler.rs patch_labels: note the panic is a fail-loud backstop, not a
  live failure mode.
- simplify.rs: regression test asserting the port-boundary invariant (no
  reachable link targets a dead/dropped block) after the subset on a graph
  carrying both a dead switch arm and a trivial forwarder.

Assisted-by: Claude

* box_ref, _collections: correct two parity-review comments

Surfaced while auditing a Codex parity review.

- box_ref.rs Forwarded enum: document why there is no VectorInfo variant.
  PyPy parks VectorizationInfo scheduling scratch in op._forwarded, but that
  slot is not clone-stable here (Op::clone resets forwarded to None while
  pos/OpRef survives, and INT_SIGNEXT needs a setup-time bytesize resolver);
  the scratch lives in the pos-keyed VecScheduleState::vecinfo_cache. The
  rationale already lived in schedule.rs but not at the enum the review
  inspects, so the absence kept being re-flagged as a regression.

- _collections defaultdict: the doc-comment claimed the missing-key factory
  is short-circuited to w_none(), but __getitem__ actually invokes and stores
  default_factory and raises KeyError without one. Correct the comment to
  match the code and note the remaining stub gaps (subclasses object not dict;
  __missing__/__repr__/copy/__reduce__ absent).

Assisted-by: Claude

* dict: dispatch __missing__ and __repr__ for dict-subclass instances

A Python-level dict subclass stores its items in a `__dict_data__` backing
dict and its instances are W_InstanceObject (not is_dict), so the subclass
paths in dict's methods must resolve the backing AND keep the subclass
identity. Two were incomplete:

- __getitem__ forwarded a subclass miss to `getitem(backing, key)`, where
  `dict_missing_or_key_error` saw the plain-dict backing's type and skipped
  __missing__. Look up the key in the backing directly and, on a miss,
  dispatch __missing__ against the original instance's type
  (dictmultiobject.py:166-170). dict_missing_or_key_error is now pub(crate).

- dict had no __repr__ method at all (plain dicts use the py_repr fast
  path), so `repr(subclass_instance)` and `super().__repr__()` fell back to
  the object repr. Register a __repr__ that resolves the backing and formats
  it via the extracted `display::dict_repr` helper (shared with the fast
  path).

Assisted-by: Claude

* operator: port itemgetter/attrgetter/methodcaller as app-level classes

Upstream defines these three getters in app-level Python
(pypy/module/operator/app_operator.py), not interp-level. They were
interp-level stubs that returned args[0] unchanged, so e.g. the stdlib
namedtuple's `itemgetter(n)` accessors returned the whole tuple.

Port app_operator.py verbatim (attrgetter/itemgetter/methodcaller plus
the _resolve_attr_chain helper) and install it via the appleveldefs arm,
dropping the three stubs. length_hint stays interp-level.

Assisted-by: Claude

* frame: stop double-wrapping pure cellvars; strip class-cell scaffolding

initialize_frame_scopes installs an empty cell for every pure cellvar,
then the MAKE_CELL opcode wrapped it a second time. A cellvar that is
never reassigned in the body (e.g. the implicit `__class__`) therefore
stayed a cell-wrapping-a-cell, and reads via fast2locals / a method
closure surfaced the inner cell instead of the value. Concretely,
`self.__class__` inside a class that uses super() returned a cell object
rather than the type, breaking any dict subclass whose __repr__ both
references __class__ and calls super().__repr__() (infinite recursion).

Make MAKE_CELL wrap only when the slot is not already a cell, matching
the single-cell model store_deref/load_deref assume.

Separately, build_class left the compiler-internal class scaffolding in
the class namespace: __class__ / __classdict__ (cellvar names mirrored in
by fast2locals) and __classcell__ / __classdictcell__ (cells the body
stores explicitly). CPython's type.__new__ leaves none of them in the
class __dict__. Capture the __classcell__ cell, remove all four keys
before building the type, and set the captured cell to the new class
afterward.

Assisted-by: Claude

* _collections: enforce deque maxlen and add the missing W_Deque methods

Bound the deque to `maxlen` by trimming from the opposite end on
append/appendleft/extend/extendleft, storing the bound in the private
`__maxlen__` slot and exposing it through a read-only `maxlen` property.

Add extendleft, rotate, count, remove, __contains__, reverse, index,
copy, __setitem__, __delitem__, and __repr__; route pop/popleft and the
append family through shared snapshot/store helpers.

Assisted-by: Claude

* pyframe: don't upper-bound-assert peekvalues on an empty peek

`peekvalues(n)` reads `[valuestackdepth - n, valuestackdepth)`, whose
highest index is `valuestackdepth - 1 < len`, so only the lower `base`
bound needs guarding. The previous `assert_stack_index(base)` also
checked `base < len`, which spuriously fails when `n == 0` and the stack
is at peak depth (`base == valuestackdepth == len`). This fired for a
zero-argument call to a function with cellvars (the non-flat
`make_arguments` fallback) when the caller's stack was exactly full —
e.g. a class-body decorator-factory call such as `@recursive_repr()`,
crashing `collections` / `reprlib` imports in debug builds.

Assert only the lower bound for the empty peek, matching pyframe.py
peekvalues.

Assisted-by: Claude

* pyre-jit: rewrite dead-forwarder gotos before the trivial-link merge bridge (codex P2, #130)

`rewrite_dead_forwarder_gotos` ran after `rewrite_trivial_link_merges`, so
for a `source -> dead_forwarder -> target` shape the source block's boundary
terminator still read `goto TLabel(dead_forwarder)` when the merge bridge's
`strip_trailing_boundary_goto(block_label_name(target))` looked for it. The
strip missed, leaving the stale terminator in front of the absorbed target
opcodes and letting the single-exit renaming splice land before them.

Move `rewrite_dead_forwarder_gotos` ahead of `remove_trivial_links` so the
inline byte gotos already name the surviving target when the merge strip
reads source terminators. The collapse guard now runs immediately before it.

Assisted-by: Claude

* _collections: validate deque maxlen, guard recursive repr, propagate init errors (codex P2, coderabbit, #130)

deque.__init__ now rejects a negative maxlen with ValueError and a non-integer
maxlen with TypeError (gateway_nonnegint_w) at construction instead of clamping
the stored value to 0 when the bound is later read, and propagates iterable
errors from the extend loop rather than swallowing them via unwrap_or_default.
maxlen_bound reads the validated value back without a clamp.

deque.__repr__ enters a ReprGuard on self and renders `[...]` for a deque
reachable from its own items, matching dequerepr / Py_ReprEnter, instead of
recursing into unbounded `__repr__` calls. ReprGuard is exposed pub(crate) for
the method to reach.

Assisted-by: Claude

* build_class/type.__new__: keep __classcell__ visible to custom metaclasses (codex P2, #130)

build_class stripped __class__/__classdict__/__classcell__/__classdictcell__
from the namespace before the metaclass path built its namespace dict, so
`class C(metaclass=M)` where M inspects or forwards __classcell__ observed a
namespace missing the cell.

__class__ and __classdict__ are fast2locals cellvar mirrors that CPython never
exposes as namespace keys, so they are still dropped up front. __classcell__
and __classdictcell__ are real namespace entries the class body stores: keep
them in the namespace the metaclass receives, drop them in the default
construction path before w_type_new, and consume them in type.__new__
(type_new_classcell) — skipping both from the new type's __dict__ and binding
the captured cell to the type — so the metaclass sees them while the class
__dict__ does not.

Assisted-by: Claude

* typedef: raise TypeError from dict.__repr__ on a non-dict receiver (coderabbit, #130)

Unbound `dict.__repr__(x)` where x is not a dict (resolve_dict_backing returns
null) formatted `{}` instead of rejecting the receiver. Raise TypeError like a
builtin descriptor; a real dict (including an empty one) still resolves.

Assisted-by: Claude

* eval: regression tests for both MAKE_CELL slot shapes (coderabbit, #130)

Pin the single-cell MAKE_CELL behavior: a parameter captured by an inner
function (arg slot promoted to a cellvar) reads its value through one cell, and
the implicit __class__ cellvar stays a single cell so zero-arg super() resolves
the class.

Assisted-by: Claude

* bench/list_reverse: raise REPS so reverse() dominates the trace warmup (#130)

REPS=15 left the one-time build loop and JIT trace warmup dominating the
measurement, putting the dynasm/cranelift vs-cpython ratio at the x15 gate on
slower CI hardware. REPS=401 (odd, keeping the reversed result) amortises the
warmup over the reverse() iterations the benchmark targets.

Assisted-by: Claude

* check.py: allow cranelift fib_loop 3x vs cpython for windows runner variance (#130)

fib_loop is bignum-add bound. windows pyre runs markedly slower than
macos/ubuntu for the same codegen, so cranelift exceeded the 2x vs-cpython
bound there (0.41s vs 0.19s) while it stays ~0.8x cpython locally and passes
at 2x on macos/ubuntu cranelift. Give cranelift 3x headroom; dynasm keeps 2x
and a real regression still trips the gate.

Assisted-by: Claude

* typedef: add type.mro() method returning the MRO as a list

`type.mro(cls)` is the method form of the MRO (distinct from the `__mro__`
tuple getset); it returns a fresh list. Its absence raised
`AttributeError: type object 'X' has no attribute 'mro'` and blocked
`import enum` (EnumMeta.__new__ calls `enum_class.mro()`).

Assisted-by: Claude

* dict: resolve backing in __delitem__ for dict-subclass instances

`dict.__delitem__` called `baseobjspace::delitem(args[0], ...)`, which
for a dict-subclass instance (where `is_dict` is the exact `DICT_TYPE`
check, so false) takes the instance branch, re-looks-up `__delitem__`,
finds the inherited `dict.__delitem__`, and re-enters — infinite
recursion. Mirror `__setitem__`: delete from the `__dict_data__` backing
directly for subclass instances.

Assisted-by: Claude

* type.__new__: walk a dict-subclass namespace via its backing

The namespace-copy and __set_name__ loops gated on `is_dict`
(exact DICT_TYPE), so a `dict` subclass namespace such as
`enum._EnumDict` was skipped and the new type got an empty `__dict__`.
The check is `PyDict_Check`, not `PyDict_CheckExact`; resolve the dict
backing before iterating so subclass class bodies are copied.

Assisted-by: Claude

* display: dispatch __repr__/__str__ overrides for builtin leaf subclasses

`int`/`long`/`float`/`bool`/`str` keep `ob_type` at the canonical storage
type and carry the Python class in `w_class`, so `py_repr`/`py_str`
formatted them by storage type and ignored a subclass `__repr__`/`__str__`
override (e.g. `repr(IntEnum.X)` and `str(IntEnum.X)`). Add
`builtin_subclass_dunder`, which dispatches an override resolved above
`object` in `w_class`'s MRO, and consult it before the storage-keyed
formatting. `int`/`float`/... carry no `tp_str`, so `str()` keeps falling
back to `repr()`; `str` has its own `tp_str` and returns its value.

Assisted-by: Claude

* tuple.__new__: preserve subclass identity via w_class

`tuple.__new__(cls, iterable)` went through `descr_new_wrapper!`, which
drops `cls` and returns a plain tuple, so a tuple subclass instance had
`type() == tuple` and lost its field descriptors and `__repr__`. Replace
it with a hand-written `tuple_descr_new` that, for a subclass, copies into
a fresh tuple and sets `w_class = cls` (mirroring `int_descr_new` /
`float_descr_new`). Makes `collections.namedtuple` field access, repr,
_replace/_make, and defaults work.

Assisted-by: Claude

* descroperation: dispatch builtin-leaf subclass operator overrides

The binary operators (add/sub/mul/floordiv/mod_/truediv/pow/lshift/
rshift/and_/or_/xor) returned early on their builtin storage fast paths
(is_int_like / is_float_pair / is_str / ...), which also hold for a
subclass instance, so a Python __add__/__or__/... override on an
int/float/str subclass was ignored.

Gate each operator on binop_dispatch_first before the fast path: when the
left operand's w_class resolves the forward dunder, or the right operand's
the reflected dunder, to a user def, route through
try_dispatch_binary_special. operand_overrides keys on the resolved
method kind (FUNCTION_TYPE with mutable code, excluding the fixed-code
gateway builtins that back the slots) rather than a storage-type pointer,
so a long — w_class = the int type object, ob_type = LONG_TYPE — is not
mistaken for an override and does not recurse through its own __add__.

Assisted-by: Claude

* descroperation: dispatch builtin-leaf subclass rich-comparison overrides

`compare` returned early on its storage fast paths (is_int_like /
is_float_pair / is_str / ...), so a __lt__/__eq__/... override on an
int/float/str subclass was ignored.

Add try_compare_override before the fast paths: gated by operand_overrides
on each side, it follows do_richcompare ordering (reflected-first when the
right operand's type properly subtypes the left's and overrides the
reflected comparison) but only ever invokes a genuine user override. The
builtin comparison slots re-enter compare (int.__eq__ → compare), so it
never dispatches one; when no user override yields a result it returns
None and the fast paths run, computing the same value comparison the
builtin reflected slot would.

Assisted-by: Claude

* eval: don't bind self for non-method attrs on builtin-storage instances

load_method routes a builtin-storage instance (a builtin-leaf subclass
such as `class MyInt(int)` or an enum member) through the builtin-type-
method branch, because it is not is_instance-shaped. That branch bound
self for every resolved attribute except static/classmethods, so
`self.__class__(value)` — compiled as LOAD_METHOD/CALL_METHOD — prepended
self and called the class with an extra argument (`int(self, value)` →
"can't convert non-string with explicit base").

Mirror the is_instance branch: non-method descriptors (type / property /
member / getset such as __class__) and an attribute absent from the type
MRO (a special attribute resolved directly in getattr, or an instance-
dict entry) prepend no self.

Assisted-by: Claude

* descroperation: dispatch builtin-leaf subclass unary operator overrides

pos/neg/invert returned early on their int/bool/long/float fast paths, so
a __pos__/__neg__/__invert__ override on a builtin-leaf subclass (e.g.
enum.IntFlag's __invert__) was ignored. Gate each on try_unary_override
before the fast path: when the operand's w_class resolves the dunder to a
user def, call it. try_instance_unaryop only fires for is_instance-shaped
objects, so the w_class-driven lookup is required here.

Assisted-by: Claude

* build_class: execute the body against a custom __prepare__ mapping

When __prepare__ returned a dict-subclass instance (e.g. enum._EnumDict),
the class body ran against a plain DictStorage and its stores were only
replayed into the mapping after execute_frame. So a name read back during
the body (`WHITE = RED | GREEN | BLUE` in a Flag) saw the value originally
assigned, not the one the mapping's __setitem__ resolved — the auto()
members read as the unresolved _auto_null object and `|` raised
"unsupported operand type(s) for |: 'object' and 'object'".

Route the frame's name binding through the mapping via setdictscope_object
when __prepare__ returned a dict-subclass instance with a resolvable
backing, then mirror its final contents into class_ns for the downstream
type construction (classcell capture, create_all_slots, __set_name__).
Skip the metaclass-path replay in that case: the mapping already holds
every store and re-running __setitem__ would reject the duplicate member
keys. Plain-dict and absent namespaces keep the DictStorage fast path.

Assisted-by: Claude

* baseobjspace: dispatch subscript/len/contains via the dynamic type

getitem_type, len, and contains ignored a special method resolved on the
receiver's metaclass (when the receiver is a class) or on a builtin-leaf
subclass's w_class (when the receiver's ob_type is a storage type):
  - `Color['RED']` fell straight to __class_getitem__ and returned the
    class instead of consulting EnumMeta.__getitem__;
  - `len(Color)` raised "object of type 'type' has no len()" without
    consulting EnumMeta.__len__;
  - `x in Color` / `x in flag` ran the getitem scan without consulting
    EnumMeta.__contains__ or the IntFlag instance's __contains__.

getitem_type now resolves __getitem__ on type(cls)'s MRO before the PEP
560 __class_getitem__ fallback; len consults the metaclass __len__ for a
type receiver; contains resolves __contains__ on the receiver's dynamic
type before the getitem scan (covering both the metaclass and the
builtin-leaf-subclass cases). type/int/etc. define none of these, so
ordinary classes and builtins keep their existing paths.

Assisted-by: Claude

* _collections: route deque subscript through decode_index4 index-only case

deque __getitem__/__setitem__/__delitem__ derefed the index as a raw
W_IntObject (`w_int_get_value`), reading garbage memory for a slice or
any non-int index and accepting no __index__ object. __getitem__ also
delegated to list getitem (returning a list for a slice) and returned
None for an empty deque.

Add a `deque_index` helper mirroring `space.decode_index4`'s step==0
branch: a slice raises TypeError("deque[:] is not supported"), other
indices go through `getindex_w` (__index__), then the negative-index
wrap and the IndexError("index out of range") range check. Route all
three subscript methods through it.

Assisted-by: Claude

* operator: add app-level countOf

countOf was absent from the operator module port. Add the verbatim
app_operator.py function and list it in the appleveldefs name set,
matching moduledef.py `app_names`.

Assisted-by: Claude

* pyre-macros: bind keyword arguments by name in #[pyre_function]

The wrapper bound args purely positionally, so a call carrying keywords
(delivered as a trailing `__pyre_kw__` dict) bound that dict to the next
positional parameter instead of resolving each keyword by name —
`deque(maxlen=3)` bound the dict as `iterable`, leaving `maxlen` None.

Collect the parameter-name and required tables at expansion time and,
when the call carried a `__pyre_kw__` dict, rebind positional+keyword
args into a resolved scope through `bind_builtin_kwargs` (the gateway
`_match_signature`): positionals fill left-to-right, keywords fill by
matching parameter name, an absent optional becomes `PY_NULL`, and an
unknown keyword / duplicate / missing required raises TypeError. The
positional fast path is unchanged when no kwargs dict is present; the
optional-arg presence check now also treats the `PY_NULL` slot as
omitted. Varargs (`&[PyObjectRef]`) fns keep the positional path since
the whole-slice binding cannot express a resolved scope.

Assisted-by: Claude

* _collections: deque rich comparison and repetition

The list-backed deque had no value comparison (== fell back to
identity) or `*` repeat. Add __eq__/__ne__/__lt__/__le__/__gt__/__ge__
delegating to element-wise list comparison over both backings
(W_Deque.compare / compare_by_iteration, maxlen ignored, NotImplemented
for a non-deque operand), and __mul__/__rmul__/__imul__ that repeat the
elements and re-bound the result by maxlen through the constructor
(W_Deque.mul/imul).

Assisted-by: Claude

* pyre-jit: gate canonical trailing -live- on calldescr_canraise

insn_needs_trailing_live emitted a trailing -live- after every
residual_call_* unconditionally. jtransform.py:469 handle_residual_call
appends the marker only when may_call_jitcodes or calldescr_canraise; the
canonical lowering has no may_call_jitcodes site, so read the CallDescrStub
effect_info off the Insn and gate on check_can_raise(false). inline_call_*
stays unconditional per handle_regular_call. This drops the marker after the
EF_CANNOT_RAISE get_current_exception residual call.

Assisted-by: Claude

* _collections: route deque rotate/index arguments through __index__

rotate and index decoded their count/start/stop with the unchecked
w_int_get_value, yielding a bogus result (or reading a non-int object
layout) for non-integers. Route them through getindex_w so a non-index
argument raises TypeError; rotate now returns Result.

Assisted-by: Claude

* operator: add __reduce__ to itemgetter and methodcaller

itemgetter returns (type, (idx,)) for a single index and (type, tuple)
for multiple; methodcaller returns (type, (name,) + args) without
kwargs and (partial(type, name, **kwargs), args) with kwargs.

Assisted-by: Claude

* mapdict: regression test for DevolvedDictTerminator read/write

Exercises the Devolved DICT branches of terminator_read and
write_terminator directly by rooting a MockObj at the paired devolved
terminator (the production devolve transition is not yet ported),
proving both route through _obj_getdict rather than map storage.

Assisted-by: Claude

* baseobjspace: propagate exceptions from __contains__

The three __contains__ call sites in contains() used call_function, which
turns a raise into PY_NULL; is_true(PY_NULL) then reported a raising
membership test as a successful true. Route them through call_and_check
so the exception propagates.

Assisted-by: Claude

* builtins/call/display: class-creation and str-subclass dispatch fixes

- Validate __classcell__ is a cell during the metaclass namespace copy,
  raising TypeError instead of silently skipping the bind.
- Rebuild class_ns from the final mapping backing (clear first) so a name
  deleted from a custom __prepare__ mapping does not survive in the class
  dict.
- Strip __class__/__classdict__ from the prepared mapping the metaclass
  observes (fast2locals may sync the cellvars in).
- Route str() through the str-subclass __str__ override; builtin_str
  short-circuited on STR_TYPE before the dispatch py_str already uses.
- Propagate exceptions from __set_name__ (both class-creation paths used
  unchecked call_function).

Assisted-by: Claude

* check.py: restore origin/main fib_loop bounds

Reverts the cranelift fib_loop vs-cpython bound from 3x back to 2x to
match origin/main.

Assisted-by: Claude
youknowone added a commit that referenced this pull request Jun 20, 2026
…mbership (#215)

Replace the `oldgen.total_bytes() > last_major_bytes * 1.82` major-cycle
gating (no lower floor) with incminimark's threshold model: add
`major_collection_threshold`, `growth_rate_max`, `min_heap_size`,
`max_heap_size`, `max_delta`, `next_major_collection_initial` and
`next_major_collection_threshold` fields, plus `get_total_memory_used`,
`threshold_reached` and `set_major_threshold_from` (incminimark.py:304-310,
562-594, 1264-1290, 2566-2577). `min_heap_size` defaults to
`max(PYPY_GC_MIN or nursery*8, nursery*major_collection_threshold)`, so a
new major cycle no longer starts off a near-zero surviving baseline.

This stops fib_recursive (tiny live set, huge allocation volume) from
thrashing major cycles: ~3653 major collections for fib(34) drop to a
handful, and `seed_major_roots` (was ~50% of non-idle CPU) leaves the
profile.

Deferring majors lets the old gen grow, which exposed `oldgen.contains`
as a hot O(n) linear scan (called per jitframe root in minor-collection
`walk_jf_roots` and per traced field in `is_managed_heap_object`),
turning the win into an O(n^2) regression. Restore O(1) membership with a
`payloads` address index on `OldGen`, standing in for the arena page-bounds
check incminimark's ArenaCollection provides (pyre's OldGen allocates each
object through the system allocator, so there is no range to test).

fib_recursive (dynasm): 1.36s -> 0.88s. check.py 127/127 both backends;
majit-gc 159 tests + dynasm backend 56+12 green.

`max_delta` defaults to the incminimark pre-setup sentinel (unbounded)
until `env.get_total_memory()` is ported (#215 fix #3); the
`total*ratio` term governs on any heap small relative to total memory.

Assisted-by: Claude
youknowone added a commit that referenced this pull request Jun 20, 2026
…215)

mark_object: collect a traced object's child refs into a reused scratch
buffer (`IncrementalMarkState.mark_scratch`, mem::take'd per call) instead
of allocating a fresh `Vec<GcRef>` on the custom_trace path and cloning
`type_info.gc_ptr_offsets` on the plain path. Both paths now collect into
the buffer while the immutable `self.types` borrow is live, then grey the
children after the borrow ends. Removes the per-marked-object allocation
(#215 fix #5).

Nursery sizing: port env.py's estimate_best_nursery_size /
best_nursery_size_for_L2cache / get_L2cache_darwin (env.py:413-456). When
PYPY_GC_NURSERY is unset, size the nursery to half the L2+L3 cache when
that exceeds 8MB, else the 4MB unknown-cache fallback. Replaces the
"estimator not ported" stopgap in default_nursery_size (#215 fix #3).
macOS reads hw.l2cachesize/hw.l3cachesize via sysctl (libc); other
platforms keep the -1 (4MB) fallback until their probe is ported. No-op on
8MB-L2 machines (returns 4MB) but parity-correct on larger-cache hosts.

check.py 127/127 both backends; majit-gc 160 tests green.

Assisted-by: Claude
youknowone added a commit that referenced this pull request Jun 21, 2026
…mbership (#215)

Replace the `oldgen.total_bytes() > last_major_bytes * 1.82` major-cycle
gating (no lower floor) with incminimark's threshold model: add
`major_collection_threshold`, `growth_rate_max`, `min_heap_size`,
`max_heap_size`, `max_delta`, `next_major_collection_initial` and
`next_major_collection_threshold` fields, plus `get_total_memory_used`,
`threshold_reached` and `set_major_threshold_from` (incminimark.py:304-310,
562-594, 1264-1290, 2566-2577). `min_heap_size` defaults to
`max(PYPY_GC_MIN or nursery*8, nursery*major_collection_threshold)`, so a
new major cycle no longer starts off a near-zero surviving baseline.

This stops fib_recursive (tiny live set, huge allocation volume) from
thrashing major cycles: ~3653 major collections for fib(34) drop to a
handful, and `seed_major_roots` (was ~50% of non-idle CPU) leaves the
profile.

Deferring majors lets the old gen grow, which exposed `oldgen.contains`
as a hot O(n) linear scan (called per jitframe root in minor-collection
`walk_jf_roots` and per traced field in `is_managed_heap_object`),
turning the win into an O(n^2) regression. Restore O(1) membership with a
`payloads` address index on `OldGen`, standing in for the arena page-bounds
check incminimark's ArenaCollection provides (pyre's OldGen allocates each
object through the system allocator, so there is no range to test).

fib_recursive (dynasm): 1.36s -> 0.88s. check.py 127/127 both backends;
majit-gc 159 tests + dynasm backend 56+12 green.

`max_delta` defaults to the incminimark pre-setup sentinel (unbounded)
until `env.get_total_memory()` is ported (#215 fix #3); the
`total*ratio` term governs on any heap small relative to total memory.

Assisted-by: Claude
youknowone added a commit that referenced this pull request Jun 21, 2026
…215)

mark_object: collect a traced object's child refs into a reused scratch
buffer (`IncrementalMarkState.mark_scratch`, mem::take'd per call) instead
of allocating a fresh `Vec<GcRef>` on the custom_trace path and cloning
`type_info.gc_ptr_offsets` on the plain path. Both paths now collect into
the buffer while the immutable `self.types` borrow is live, then grey the
children after the borrow ends. Removes the per-marked-object allocation
(#215 fix #5).

Nursery sizing: port env.py's estimate_best_nursery_size /
best_nursery_size_for_L2cache / get_L2cache_darwin (env.py:413-456). When
PYPY_GC_NURSERY is unset, size the nursery to half the L2+L3 cache when
that exceeds 8MB, else the 4MB unknown-cache fallback. Replaces the
"estimator not ported" stopgap in default_nursery_size (#215 fix #3).
macOS reads hw.l2cachesize/hw.l3cachesize via sysctl (libc); other
platforms keep the -1 (4MB) fallback until their probe is ported. No-op on
8MB-L2 machines (returns 4MB) but parity-correct on larger-cache hosts.

check.py 127/127 both backends; majit-gc 160 tests green.

Assisted-by: Claude
youknowone added a commit that referenced this pull request Jun 21, 2026
…mbership (#215)

Replace the `oldgen.total_bytes() > last_major_bytes * 1.82` major-cycle
gating (no lower floor) with incminimark's threshold model: add
`major_collection_threshold`, `growth_rate_max`, `min_heap_size`,
`max_heap_size`, `max_delta`, `next_major_collection_initial` and
`next_major_collection_threshold` fields, plus `get_total_memory_used`,
`threshold_reached` and `set_major_threshold_from` (incminimark.py:304-310,
562-594, 1264-1290, 2566-2577). `min_heap_size` defaults to
`max(PYPY_GC_MIN or nursery*8, nursery*major_collection_threshold)`, so a
new major cycle no longer starts off a near-zero surviving baseline.

This stops fib_recursive (tiny live set, huge allocation volume) from
thrashing major cycles: ~3653 major collections for fib(34) drop to a
handful, and `seed_major_roots` (was ~50% of non-idle CPU) leaves the
profile.

Deferring majors lets the old gen grow, which exposed `oldgen.contains`
as a hot O(n) linear scan (called per jitframe root in minor-collection
`walk_jf_roots` and per traced field in `is_managed_heap_object`),
turning the win into an O(n^2) regression. Restore O(1) membership with a
`payloads` address index on `OldGen`, standing in for the arena page-bounds
check incminimark's ArenaCollection provides (pyre's OldGen allocates each
object through the system allocator, so there is no range to test).

fib_recursive (dynasm): 1.36s -> 0.88s. check.py 127/127 both backends;
majit-gc 159 tests + dynasm backend 56+12 green.

`max_delta` defaults to the incminimark pre-setup sentinel (unbounded)
until `env.get_total_memory()` is ported (#215 fix #3); the
`total*ratio` term governs on any heap small relative to total memory.

Assisted-by: Claude
youknowone added a commit that referenced this pull request Jun 21, 2026
…215)

mark_object: collect a traced object's child refs into a reused scratch
buffer (`IncrementalMarkState.mark_scratch`, mem::take'd per call) instead
of allocating a fresh `Vec<GcRef>` on the custom_trace path and cloning
`type_info.gc_ptr_offsets` on the plain path. Both paths now collect into
the buffer while the immutable `self.types` borrow is live, then grey the
children after the borrow ends. Removes the per-marked-object allocation
(#215 fix #5).

Nursery sizing: port env.py's estimate_best_nursery_size /
best_nursery_size_for_L2cache / get_L2cache_darwin (env.py:413-456). When
PYPY_GC_NURSERY is unset, size the nursery to half the L2+L3 cache when
that exceeds 8MB, else the 4MB unknown-cache fallback. Replaces the
"estimator not ported" stopgap in default_nursery_size (#215 fix #3).
macOS reads hw.l2cachesize/hw.l3cachesize via sysctl (libc); other
platforms keep the -1 (4MB) fallback until their probe is ported. No-op on
8MB-L2 machines (returns 4MB) but parity-correct on larger-cache hosts.

check.py 127/127 both backends; majit-gc 160 tests green.

Assisted-by: Claude
youknowone added a commit that referenced this pull request Jun 21, 2026
…ship (#215); opt-in walker vframe inline (default off) (#219)

* majit-gc: port incminimark major-collection threshold; O(1) oldgen membership (#215)

Replace the `oldgen.total_bytes() > last_major_bytes * 1.82` major-cycle
gating (no lower floor) with incminimark's threshold model: add
`major_collection_threshold`, `growth_rate_max`, `min_heap_size`,
`max_heap_size`, `max_delta`, `next_major_collection_initial` and
`next_major_collection_threshold` fields, plus `get_total_memory_used`,
`threshold_reached` and `set_major_threshold_from` (incminimark.py:304-310,
562-594, 1264-1290, 2566-2577). `min_heap_size` defaults to
`max(PYPY_GC_MIN or nursery*8, nursery*major_collection_threshold)`, so a
new major cycle no longer starts off a near-zero surviving baseline.

This stops fib_recursive (tiny live set, huge allocation volume) from
thrashing major cycles: ~3653 major collections for fib(34) drop to a
handful, and `seed_major_roots` (was ~50% of non-idle CPU) leaves the
profile.

Deferring majors lets the old gen grow, which exposed `oldgen.contains`
as a hot O(n) linear scan (called per jitframe root in minor-collection
`walk_jf_roots` and per traced field in `is_managed_heap_object`),
turning the win into an O(n^2) regression. Restore O(1) membership with a
`payloads` address index on `OldGen`, standing in for the arena page-bounds
check incminimark's ArenaCollection provides (pyre's OldGen allocates each
object through the system allocator, so there is no range to test).

fib_recursive (dynasm): 1.36s -> 0.88s. check.py 127/127 both backends;
majit-gc 159 tests + dynasm backend 56+12 green.

`max_delta` defaults to the incminimark pre-setup sentinel (unbounded)
until `env.get_total_memory()` is ported (#215 fix #3); the
`total*ratio` term governs on any heap small relative to total memory.

Assisted-by: Claude

* majit-gc: reuse mark scratch buffer; port estimate_best_nursery_size (#215)

mark_object: collect a traced object's child refs into a reused scratch
buffer (`IncrementalMarkState.mark_scratch`, mem::take'd per call) instead
of allocating a fresh `Vec<GcRef>` on the custom_trace path and cloning
`type_info.gc_ptr_offsets` on the plain path. Both paths now collect into
the buffer while the immutable `self.types` borrow is live, then grey the
children after the borrow ends. Removes the per-marked-object allocation
(#215 fix #5).

Nursery sizing: port env.py's estimate_best_nursery_size /
best_nursery_size_for_L2cache / get_L2cache_darwin (env.py:413-456). When
PYPY_GC_NURSERY is unset, size the nursery to half the L2+L3 cache when
that exceeds 8MB, else the 4MB unknown-cache fallback. Replaces the
"estimator not ported" stopgap in default_nursery_size (#215 fix #3).
macOS reads hw.l2cachesize/hw.l3cachesize via sysctl (libc); other
platforms keep the -1 (4MB) fallback until their probe is ported. No-op on
8MB-L2 machines (returns 4MB) but parity-correct on larger-cache hosts.

check.py 127/127 both backends; majit-gc 160 tests green.

Assisted-by: Claude

* majit-gc: port get_total_memory; max_delta defaults to 0.125*total (#215)

Close the one parity deviation left by the threshold-model port: max_delta's
unset default was incminimark.py:310's pre-setup sentinel float(r_uint(-1))
instead of the setup value 0.125 * env.get_total_memory() (incminimark.py:498).

Port env.get_total_memory (env.py:100-127): macOS reads hw.memsize via sysctl,
clamped/fallen-back to the addressable size by get_total_memory_darwin
(env.py:100-110); other platforms return the addressable size (env.py:126-127;
the Linux /proc/meminfo probe is not yet ported). Hoist get_darwin_sysctl_signed
(env.py:387-411) out of get_l2cache so both probes share it. with_config now
sets max_delta = 0.125 * get_total_memory() when PYPY_GC_MAX_DELTA is unset.

The finish_incremental_cycle cap min(total*major_collection_threshold,
total+max_delta) now binds at ~0.125*RAM as in production, instead of never
(the sentinel dwarfed every realistic heap). Byte-identical for heaps small
relative to RAM (fib_recursive unchanged).

check.py 127/127 both backends; majit-gc 160 tests green.

Assisted-by: Claude

* majit-gc: share read_float_and_factor_from_env across the env readers (#215)

Extract env.py:17-36 `_read_float_and_factor_from_env` as a shared helper and
rebuild the readers on it (env.py:38-50):
- read_uint_from_env (renamed from read_size_from_env) = value*factor as a
  positive byte count, used for PYPY_GC_NURSERY/MIN/MAX/MAX_DELTA.
- read_float_from_env now returns the value only when no size factor was given
  (factor != 1 -> unset), matching env.py:46-50; previously it did a plain
  parse and silently accepted a suffixed value like "1.5g".

Document at the finish_incremental_cycle threshold update that the `bounded`
result of set_major_threshold_from is intentionally dropped: incminimark.py
:2603-2615 raises MemoryError on `bounded and threshold_reached`, but pyre has
no GC out-of-memory path (PYPY_GC_MAX OOM policy unported).

check.py 127/127 both backends; majit-gc 160 tests green.

Assisted-by: Claude

* multiframe inline: collect callee snapshot boxes at the carried jitcode_pc liveness

`collect_callee_active_boxes` queried the callee frame's live register banks
via `frame_liveness_reg_indices_by_bank_at(callee_py_pc)`, which resolves the
JitCode coordinate through the lossy `pc_map`. The resume decoder consumes the
frame's section per the liveness at the carried `jitcode_pc`
(`setposition` -> `get_current_position_info`), so the two coordinates could
resolve to different liveness windows.

For a forward-branch callee inlined under the multi-frame path, a branch guard
stashes its own JitCode offset in `BRANCH_GUARD_JITCODE_PC`; when that offset
mapped to a different liveness window than `pc_map(callee_py_pc)`, the encoder
wrote the box banks for one window while the decoder read section sizes for the
other. A callee that int-specializes a param then put a Ref where the int
section expected a value, panicking at resume with
`getvirtual_int: not a raw virtual`.

Compute `callee_jitcode_pc` before the box collection and pass it to
`collect_callee_active_boxes`, which now calls
`frame_liveness_reg_indices_by_bank_at_with_jitcode_pc` with the same carried
word the snapshot carries and the decoders (`collect_outer_active_boxes`,
`setup_bridge_sym`, `rebuild_inline_callee`) already use.

check.py dynasm 131/131, cranelift 131/131.

Assisted-by: Claude
youknowone added a commit that referenced this pull request Jul 16, 2026
The CastPtrToInt lowering in codegen.rs called emit_resolve with three
arguments; emit_resolve takes four (value_types: &[ValType] is arg #3).
The call now passes value_types, matching every other emit_resolve call
site in the codegen loop.

Assisted-by: Claude
youknowone added a commit that referenced this pull request Jul 17, 2026
The CastPtrToInt lowering in codegen.rs called emit_resolve with three
arguments; emit_resolve takes four (value_types: &[ValType] is arg #3).
The call now passes value_types, matching every other emit_resolve call
site in the codegen loop.

Assisted-by: Claude
youknowone added a commit that referenced this pull request Jul 17, 2026
The CastPtrToInt lowering in codegen.rs called emit_resolve with three
arguments; emit_resolve takes four (value_types: &[ValType] is arg #3).
The call now passes value_types, matching every other emit_resolve call
site in the codegen loop.

Assisted-by: Claude
youknowone added a commit that referenced this pull request Jul 18, 2026
The CastPtrToInt lowering in codegen.rs called emit_resolve with three
arguments; emit_resolve takes four (value_types: &[ValType] is arg #3).
The call now passes value_types, matching every other emit_resolve call
site in the codegen loop.

Assisted-by: Claude
youknowone added a commit that referenced this pull request Jul 18, 2026
The CastPtrToInt lowering in codegen.rs called emit_resolve with three
arguments; emit_resolve takes four (value_types: &[ValType] is arg #3).
The call now passes value_types, matching every other emit_resolve call
site in the codegen loop.

Assisted-by: Claude
youknowone added a commit that referenced this pull request Jul 18, 2026
The CastPtrToInt lowering in codegen.rs called emit_resolve with three
arguments; emit_resolve takes four (value_types: &[ValType] is arg #3).
The call now passes value_types, matching every other emit_resolve call
site in the codegen loop.

Assisted-by: Claude
youknowone added a commit that referenced this pull request Jul 18, 2026
The CastPtrToInt lowering in codegen.rs called emit_resolve with three
arguments; emit_resolve takes four (value_types: &[ValType] is arg #3).
The call now passes value_types, matching every other emit_resolve call
site in the codegen loop.

Assisted-by: Claude
youknowone added a commit that referenced this pull request Jul 19, 2026
…pted then reverted (net-zero) (#548)

* jit: #22 gate bool_descr_new __bool__ non-bool name against tagged deref

In the generic `__bool__` tail of `bool_descr_new`, the TypeError path built
the returned type's name via `(*(*result).ob_type).name`. When `__bool__`
returns a tagged immediate the ob_type deref reads non-pointer bits. Add a
`CAN_BE_TAGGED`-gated arm that names a tagged immediate `"int"` without the
deref, matching the guard in `builtin_str`. Dead code at flag-false.

Assisted-by: Claude

* jit: #22 gate iter() not-iterable type name against tagged deref

The two "'{}' object is not iterable" TypeError builders in `iter`
(the `__iter__ = None` arm and the terminal fall-through) named the
type via `(*(*obj).ob_type).name`, a raw ob_type deref. A tagged
immediate passes every tag-safe type probe and falls through to these,
faulting on the deref. Route both through a `CAN_BE_TAGGED`-gated
`not_iterable_type_name` that names a tagged immediate `"int"` without
the deref. Dead code at flag-false.

Assisted-by: Claude

* jit: #22 add tag-safe type_name_of and route not-X-type TypeError names through it

The "must be X, not <name>" / "not iterable/subscriptable/unpackable"
TypeError builders named the offending type via a raw
`(*(*obj).ob_type).name`. A tagged immediate passes the tag-safe type
probes that precede these builders, then reaches the raw deref and reads
its non-pointer bits. Add `pyre_object::type_name_of`, a gated chokepoint
that names a tagged immediate `"int"` and otherwise keeps the raw
`ob_type.name` (not `r#type`, which would return the w_class subclass
name). Route the reachable sites through it: builtin_str encoding/errors/
decoding, checkattrname, sequence unpack builders, and the not-subscriptable
builder. Dead code at flag-false.

Assisted-by: Claude

* jit: #22 route not-callable and not-iterable TypeError names through type_name_of

The CALL not-callable builder (runtime_ops.rs:425) and the GET_ITER
ensure_range_iter not-iterable fall-through (:1259) named the type via a
raw `(*(*obj).ob_type).name`, which a tagged immediate reaches after the
tag-safe probes reject it. Route both through `type_name_of`, matching the
other type-name builders. Dead code at flag-false.

Assisted-by: Claude

* int: guard int_mod against i64::MIN % -1 overflow

`va % vb` panics on i64::MIN % -1 (quotient 2**63 unrepresentable in i64),
the same case int_add/sub/mul already guard with checked arithmetic. Replace
with checked_rem, falling back to a BigInt mod_floor (result 0) on overflow.

int_floordiv is unaffected: its checked_div early-return at :418 catches the
sole overflow operand pair before the `%` at :422 is reached.

Assisted-by: Claude

* list: clamp backwards slice assignment to an empty insertion

w_list_setslice computed each strategy arm's window as start.min(len) ..
end.min(len) without raising the upper bound to the lower one. A backwards
slice (start > stop) then panicked in the object arm's Vec::splice (start >
end range) and silently truncated the tail in the int/float arms (e - s
underflowed usize). Clamp end up to start at the top of the function so a
backwards slice is a pure insertion at start, matching list_ass_slice's
`if (ihigh < ilow) ihigh = ilow`.

Assisted-by: Claude

* builtins: stack-check isinstance/issubclass native recursion

isinstance()/issubclass() recurse in native Rust over nested tuple and
PEP-604 union classinfo without pushing a Python frame, so the frame-level
stack_check() chokepoints never fire and a deeply nested classinfo blows the
C stack (segfault/timeout) instead of raising RecursionError. Add
stack_check() at each head after unwrap_cell.

Assisted-by: Claude

* object: raise TypeError from object.__new__/__init__ on bad args

object.__new__ asserted on an empty arg list (SIGABRT when called with no
type) and silently returned a typeless instance for a non-type first
argument; object.__init__ was a no-op that swallowed excess arguments.
Port the descr__new__/descr__init__ excess-args decision from
objectobject.py: no arg -> "not enough arguments"; non-type -> "X is not a
type object (...)"; excess args -> "takes exactly one argument" or "takes
no arguments" depending on whether the class overrides __new__/__init__,
unwrapping the __new__ staticmethod for the identity compare. Type names in
the messages come from r#type()+w_type_get_name (the Python type), not the
raw ob_type layout name.

Assisted-by: Claude

* jit: #22 route 28 tagged-reachable type-name derefs through type_name_of

A static sweep of all 67 live `(*(*X).ob_type).name` derefs found 28 on
error/fallback paths that an arbitrary user value — and so a tagged int at
CAN_BE_TAGGED=true — can reach, beyond the crash-observed set the earlier
batch closed. Route each through pyre_object::type_name_of, which folds to
the same raw deref at flag-false (byte-identical) and returns "int" for a
tagged immediate. The remaining 39 derefs are narrowed by an is_int
early-return, sit on the None arm of an r#type match, or are the
type_name_of chokepoint itself, and stay raw.

Sites: baseobjspace setitem_slot/set___class__/delattr; typedef
set.__init__/dict.__repr__/MappingProxyType/__bases__ setter/member-descr
get+set+delete/int.to_bytes/bytes.replace/removeprefix/removesuffix/fromhex/
int.from_bytes/bytes.decode enc+errors/descr_get_dict; builtins float();
opcode_ops list_extend/dict_update/dict_merge; math try_get_double; _codecs
encode+decode; call not-callable; type_methods str.encode.

Assisted-by: Claude

* interp: guard recursive container repr with stack_check (display.rs)

py_repr/dict_repr recurse into each element in native Rust with no Python
frame push and only ReprGuard (pointer-cycle detection); nothing bounded
the depth, so a deeply nested structure overflows the C stack. Add
stack_check() at the py_repr recursion hub so deep dict/list/tuple repr
raises RecursionError, matching test_repr_deep in test_dict/list_tests.
Mirrors the insert_stack_check pass on the recursive isinstance/issubclass
path.

Assisted-by: Claude

* interp: precheck tagged int in exception_descr_str_wtf8 (display.rs)

str() of a single-arg exception routes through this WTF-8 path; a
tagged-int arg was read as a pointer via unwrap_cell / ob_type
(str(ValueError(5)) segfaulted). Precheck the tagged immediate before the
deref and return None to fall back to py_str, which formats the value.
py_str already has this precheck; the WTF-8 sibling was missing it.

Assisted-by: Claude

* object: enable tagged small-int representation (CAN_BE_TAGGED=true)

Flip the tagged_int::CAN_BE_TAGGED master switch. A small int is now
stored as an immediate (value << 1) | 1 pointer instead of a heap
W_IntObject, so w_int_new avoids the malloc for taggable values. The
GcConfig.taggedpointers wire (build_gc) and the jit_fnaddr baked constant
both read this const, so the collector skips tagged immediates and the JIT
sees the enabled value automatically.

Update the three tests that assume the untagged representation:
- tagged_int enabled_after_flip asserts the switch is on
- intobject identity tests gate on CAN_BE_TAGGED && fits_tagged, expecting
  value-identical immediates for taggable ints

Gate battery: cargo test -p pyre-object 211 pass; check.py dynasm 180/180
+ cranelift 180/180; int-module --full diff no new WORSENED; GC stress
30/30; perf A/B interleaved 7/7 neutral, no regression.

Assisted-by: Claude

* jit,object,interp: fix PR#548 tagged-int-flip CI regressions across wasm/x86/unit-test axes

The local flip gate only ran check.py --backend dynasm,cranelift on aarch64,
missing the wasm backend, cargo unit tests, and x86_64. Six regressions:

- pyre-object/tagged_int.rs: fits_tagged/tag_int/untag_int computed in fixed
  i64 while PyObjectRef is pointer-width, truncating on wasm32. Recompute in
  isize/usize (byte-identical on 64-bit, 31-bit signed payload on wasm32).
- pyre-interpreter argument.rs/builtins.rs/typedef.rs: three local type_name_of
  helpers raw-deref ob_type in the None arm; a tagged immediate reaches it when
  the type registry is absent (unit-test harness). Precheck is_tagged_int.
- majit-backend-dynasm x86 CallMallocNurseryVarsizeFrame: spilled its result
  through a RAX detour, but the inline-bump allocator's clobber set is ECX/EDX
  only, so a live value regalloc bound to RAX was destroyed. Spill directly
  from the result register to the regalloc slot.
- majit-backend-wasm CastPtrToInt: on wasm32 a Ref widens to i64 by zero-extend,
  so a tagged negative small int (v<<1)|1 loses its high half; the arithmetic
  IntRshift untag then reads wrong. Sign-extend the low 32 bits (i32_wrap +
  i64_extend_i32_s); a no-op for real heap pointers and 64-bit operands.
- pyre-jit assembler.rs assemble_raise_accepts_const_ref test: odd ConstRef(7)
  sentinel now reads as a tagged immediate post-flip; use an even sentinel.
- pyre-jit-trace state.rs trace_unbox_int_with_resume_descr: a constant tagged
  operand emitted a runtime lowbit tag guard whose resume snapshot panicked on
  a skeleton test jitcode. A constant tag is statically known, so elide the tag
  test+guard for a Const operand (pyjitpl.py:2583 generate_guard const-skip);
  the constant untags with pure arithmetic (rtagged.py:147 ll_unboxed_to_int).

Assisted-by: Claude

* interp: add forward-only decode_instruction_forward for hot dispatch (gh#394)

The dispatch decode path re-scans backward every step: decode_instruction_at
walks backward over any ExtendedArg prefix, then forward-replays OpArgState
over [start..pc]. OpArgState is a pure left-fold that resets after every real
opcode, so the accumulated oparg at a real opcode depends only on the
contiguous ExtendedArg run immediately preceding it, and the dispatch pc is
always a logical-instruction start. Add decode_instruction_forward, which
accumulates forward from pc with a fresh OpArgState and returns the same
(pc, instruction, op_arg) triple decode_instruction_for_dispatch does, with no
backward scan — sharing its u8<44 malformed-chain guard and BytecodeCorruption
on out of bounds (pypy/interpreter/pyopcode.py:213-237 forward single-pass).

decode_instruction_at and decode_instruction_for_dispatch are unchanged; no
callers rewired yet. Extends the decode oracle and adds
forward_decode_matches_full_scan_and_dispatch (cross-checks the helper against
a naive full-scan and against decode_instruction_for_dispatch at every logical
start, including ExtendedArg-prefix starts) plus a malformed-chain reject test.

Assisted-by: Claude

* interp,jit: route hot dispatch loops through decode_instruction_forward (gh#394)

Rewire the three hot bytecode-dispatch call sites from
decode_instruction_for_dispatch to decode_instruction_forward:
- pyre-interpreter eval_loop (eval.rs:1434)
- pyre-jit eval_loop_jit portal (eval.rs:4517)
- pyre-jit eval_loop_jit_bridge (eval.rs:4716)

decode_instruction_for_dispatch stays defined in pyopcode.rs for the
remaining cold callers and the oracle test.

Assisted-by: Claude

* test: RED probe for tagged-int poly-bridge SIGSEGV (inheritance_dispatch min bench + cargo test)

Assisted-by: Claude

* jit: add observed_slot_int_type merge-point Int signal (surviving S7 box)

Assisted-by: Claude

* jit: per-slot re-box on vable deopt so an Int-declared slot restores W_Int

restore_virtualizable_i64 now dispatches each slot through boxed_slot_i64_for_type
using the per-slot types carried on the merge-point GreenBoxes (build_meta_from_
merge_point reads GreenBox.ty instead of hardcoding Ref). Byte-identical for Ref
slots; an Int-declared slot restores a boxed W_Int on guard failure.

Assisted-by: Claude

* interp: rustfmt the eval.rs use-block reflowed by the rebase import resolution

The gh#394 import blend during the origin/main rebase left the `use crate`
block unformatted; rustfmt collapses `decode_instruction_forward` onto the
prior line and drops the standalone wrap.

Assisted-by: Claude

* jit(wasm): pass value_types to emit_resolve in the CastPtrToInt arm

The CastPtrToInt lowering in codegen.rs called emit_resolve with three
arguments; emit_resolve takes four (value_types: &[ValType] is arg #3).
The call now passes value_types, matching every other emit_resolve call
site in the codegen loop.

Assisted-by: Claude

* jit: derive S3 symbolic type-map reset from per-slot target types

Assisted-by: Claude

* jit: test Int virtual-state leaf joins Int target malloc-free, reject preserved

Adds int_leaf_joins_int_target_type_gate_passes pinning info_type_matches:
an Int-expected target accepts Unknown(Int)/IntBounded/Int Constant leaves and
rejects a Ref leaf, and a Ref-expected target rejects an Int leaf. Verify-only
slice — no production code change; the join is already parity-correct.

Assisted-by: Claude

* object: tag-guard is_list_iter/is_list_reverse_iter/is_tuple_iter

These three iterator-type probes hand-rolled a bare (*obj).ob_type deref with
only a null check, unlike the sibling is_seq_iter which short-circuits a tagged
immediate first. getattr_str_impl probes the receiver through an || chain that
reaches is_list_iter after the guarded is_seq_iter returns false, so any
attribute or method access on a tagged small int (e.g. (5).bit_length(),
import os) dereferenced the tagged value as an object and crashed. Add the same
CAN_BE_TAGGED + is_tagged_int guard to all three.

Assisted-by: Claude

* object: tag-guard is_set_iterator

is_set_iterator was the remaining hand-rolled `(*obj).ob_type ==`
predicate without the tagged-int short-circuit that is_seq_iter and the
ae9b815 iterobject sweep carry. baseobjspace::iter probes a receiver
through an `||` predicate chain that reaches is_set_iterator after the
tag-safe py_type_check-backed predicates return false; on a tagged small
int that hand-rolled deref reads ob_type through the odd pointer.

Add the `CAN_BE_TAGGED && is_tagged_int` short-circuit, matching the
sibling probes. Gated on CAN_BE_TAGGED so flip-OFF is byte-identical.

Assisted-by: Claude

* jit: push static short-preamble position, not box-replaced operand

ExtendedShortPreambleBuilder::setup pushed the box-replaced operand into
short_jump_args for an unmapped Phase-1 jump arg. inline_short_preamble's
force loop then demands every non-const short_jump_arg be a key in
`mapping`, which is keyed on the static short-op result positions
(mapping[sp_op.pos]) plus seeded short-inputargs. When a loop-carried
jump arg forwards to a fresh Phase-2 SameAsI alias, that alias is neither
a replay key nor a seeded inputarg, so the lookup panics at unroll.rs
`mapping.get(jump_arg).expect("mapping missing jump_arg")`.

Push the static position `*arg` instead, mirroring shortpreamble.py:461
`self.jump_args = jump_args` (verbatim, no forwarding); get_box_replacement
is applied only after the mapping lookup via get_replacement_opref
(unroll.py:374,440). The resolved operand is retained for the emitted
JUMP sentinel's producer binding (jump_args_operand).

Surfaced under CAN_BE_TAGGED where a loop-carried small int rides the
label as a raw Type::Int op whose forwarded identity diverges from its
static replay key; boxed InputArgRef locals coincided and did not panic.

Assisted-by: Claude

* jit: preserve seeded short-preamble input mapping over replayed result pos

inline_short_preamble seeds `mapping` with the short-preamble input ->
jump_arg pairs, then binds each replayed short op's result position via
`mapping.insert(sp_op.pos, new_ref)`. In RPython these two key spaces are
disjoint (an input Box is never also a produced-result Box), so the replay
can never overwrite a seed. pyre's flat-OpRef namespace has no such
guarantee: a re-virtualizing short op (NewWithVtable) can be exported with
a result position equal to a loop-input OpRef, so the insert clobbered the
seed. Every later short op reading that input (GetfieldGcPureI) then
resolved to the fresh, field-less allocation instead of the loop-carried
box, feeding an uninitialized field into a loop slot. The uninitialized
read was masked when a nursery value happened to match, but a residual
CALL in the loop body overwrote the nursery and surfaced the garbage
(foriter_call_body: 241830/231390 instead of 1800000).

Snapshot the seeded input keys and skip the `mapping.insert` when a
replayed op's result position is one of them, restoring the input-vs-result
key disjointness RPython gets from Box identity. The redundant
reconstruction op is left unmapped and elided by DCE.

Assisted-by: Claude

* jit: skip short-preamble seed-preserve only for allocation ops

The seeded_input_keys guard skipped mapping.insert for every replayed
short op whose result position aliased a seeded input key. That is
correct only for a re-virtualizing allocation (New..Newunicode), whose
field-less materialization must not replace the loop-carried box. A
colliding non-allocation op — e.g. a GetfieldGcPureI producing an Int
loop slot whose result position happens to alias a seed — is a genuine
recomputation whose fresh result is the correct binding; skipping it
stranded the slot on the seed's Ref box and fed a Ref into an Int
consumer (getintbound_handle 'i'-typed assert, store_global_hot panic
on both backends).

Gate the skip on sp_op.opcode.is_malloc() so only allocation ops
preserve the seed. store_global_hot passes both backends; foriter_call_body
stays 1800000 (the miscompile the original skip fixed).

Assisted-by: Claude

* object: disable tagged small-int representation (CAN_BE_TAGGED=false)

Return mainline int to the boxed W_Int representation. Tagging a
non-UnboxedValue mainline int has no RPython/PyPy basis (rclass.py:103
usetagging gates on UnboxedValue subclasses; W_IntObject is not one;
taggedpointers defaults False). The tag-path scaffolding stays inert
behind the flag.

* jit: excise per-slot Int merge-point/resume machinery

Remove the non-orthodox per-slot Int signals added for the tagged-int
flip: observed_slot_int_type (dead — no production caller), the S3
symbolic type-map per-slot reset, the vable-deopt per-slot re-box, and
the Int virtual-state leaf test. With int slots no longer tagged these
declare no Int slot, so the merge-point/resume paths return to the
uniform-Ref contract that mirrors RPython's GCREF locals_cells_stack_w.
Also drops the int_carry_deopt_probe bench that only exercised the
re-box path.

* test: remove tagged-int poly-bridge RED probe

The inheritance_dispatch_min bench and gc_stress probe tested a
flip-ON SIGSEGV; with tagging disabled they are moot.

* test: assert boxed-int unbox for constant operand with tagging off

trace_unbox_int_with_resume on a constant boxed W_Int reads the payload
with GetfieldGcPureI; the lowbit tag-discrimination guard is still elided
for a constant operand (pyjitpl.py:2583 generate_guard const-skip). Update
the assertion from the tagged IntRshift lowering the flip expected.

* interp: back decode_instruction_forward up over EXTENDED_ARG prefix

decode_instruction_forward started a fresh OpArgState at the given pc,
so a pc naming a real opcode that sits past its own EXTENDED_ARG prefix
dropped the prefix's high byte and returned a truncated oparg. The
interpreter dispatch loop always enters at the logical instruction start
(the EXTENDED_ARG), but a JIT jump/loop-header/resume coordinate points
at the real opcode past the prefix (jump_target_backward targets the
opcode, not its EXTENDED_ARG), so getwidth's outer FOR_ITER (real opcode
preceded by EXTENDED_ARG) decoded a truncated delta under the JIT and
corrupted the loop variable.

Back up over any immediately-preceding EXTENDED_ARG units to the logical
start before accumulating forward. The back-up loop runs zero iterations
in the common no-prefix case, so the hot path stays a single forward
pass. The u8 < 44 malformed-chain guard is preserved, rekeyed on the
backed-up start.

Update the two oracle tests to assert the full oparg (not just the
opcode) when entered directly at a real opcode past its prefix, and add
forward_decode_at_real_opcode_recovers_extended_arg covering a FOR_ITER
whose delta exceeds 255.

Assisted-by: Claude

* object: restore defaults_to_untagged test after CAN_BE_TAGGED revert

The tagged-int revert set CAN_BE_TAGGED=false but left the flip-era
enabled_after_flip test asserting the flag is true, so it failed. Restore
the pre-flip defaults_to_untagged test asserting the flag is off.

Assisted-by: Claude

* interp: correct decode_instruction_forward prefix-source doc-comment

The comment attributed the real-opcode-past-prefix entry coordinate to
jump_target_backward. Jump and loop-header targets point at the logical
instruction start (the EXTENDED_ARG word): resolve_jump_offsets uses the
instruction's i_offset, and write_instr emits the EXTENDED_ARG units
before the real opcode. The real-opcode coordinate comes from
skip_python_trivia_forward, which advances past EXTENDED_ARG as trivia
in pyre's pc-indexed metadata keying.

Assisted-by: Claude
youknowone added a commit that referenced this pull request Jul 21, 2026
…tra_virtual_roots build break (#708)

* comments: remove internal task/slice/gap tracking tags

Session-invented tracking labels prefixed comments across 50 files:
`task#50 phase-1`, `task #157`, `#73 S3.5`, `gh#73 S3.2`,
`Parity #14 Slice C.4`, `Slice 7b`, `Slice C`, `sub-slice 4`,
`micro-slice 3`, `gap-10`, `#203 gap-7`, `51d.1`, `M4`, `Phase G slice 2`.
Each occurrence is replaced by the technical statement it prefixed; where
the tag carried the sentence's subject the sentence is reworded.

Dropped in the same lines: two claude memory-file paths cited as
references (`item3_abstractstringrepr_epic_plan.md`,
`project_issue73_architecture_walker_as_tracer_2026_05_28`), and the line
numbers on file citations those comments carried.

The `residual_call.rs` "Priority order for sub-slice 2 (widen)" paragraph
is removed rather than reworded: it ordered work the same doc records as
landed.

Comment-only — no changed line is code.
`cargo check --workspace --all-targets --features dynasm` passes; the
pre-existing `vstack_mirror.rs` unreachable_patterns is the only warning.

Assisted-by: Claude

* majit-metainterp: initialize Snapshot::extra_virtual_roots at the guard-op multi-frame capture

`capture_snapshot_for_last_guard_op_multi_frame_with_vable_vref` built a
`recorder::Snapshot` without the `extra_virtual_roots` field, so
majit-metainterp failed to compile with E0063 at `origin/main`
`57b01d0e8bd`. The field was added by #661; this call site was not
updated.

Value and comment copied from the sibling
`capture_snapshot_for_last_guard_multi_frame_with_vable_vref`, the same
multi-frame path: the nested-list append fold resumes through the
single-frame collapse, so no extra virtual roots reach here.

Assisted-by: Claude

* comments: remove AI-review labels, session plan/memory references, and remaining stage tags

Second pass over the categories the first sweep did not cover.

- `Codex P1` / `Codex P2` (+ `(PR #89)`, `(round 7/8/10)`) AI-review
  priority labels, 21 sites. The sentence each prefixed is kept.
- References to files that exist only in a private session directory:
  `.claude/plans/*.md` (5), `memory/*.md` (6), and one
  `project_issue73_...` memory name.
- Plan-stage identifiers: `S2.1`/`S2.2`/`S2.3`/`S2.4` "(wiggly-barto
  plan)", `S1.3`, `S0 spike`, `S1-S3`, `orth-9 step 4`,
  `Path B (B.6.7*)`, `[SPIKE-S0/FR]`, `A0-era`, `off-GC storage epic
  S2..S5`, `Task #85`/`#197`/`#333`, `#73 Slice-1`.

Three changed lines are not comments, all string literals: the `S1.3`
mentions in the `AnnotatorError` text and in an `expect_err` message in
`rlib/jit.rs` / `extregistry.rs`, and a `memory/*.md` citation inside an
`#[ignore = "..."]` reason string in `pyre-jit/src/eval.rs`. Every other
changed line is a comment.

`Finding #1`/`#2`/`#3` and `Option C` are kept: they are defined by the
`#57` GitHub issue cited alongside them, so a reader can resolve them.

`cargo check --workspace --all-targets --features dynasm` passes.

Assisted-by: Claude
youknowone added a commit that referenced this pull request Sep 1, 2026
`write_virtualizable_back` had a carve-out for a virtualizable array held
as a Rust `Vec` inside the interpreter's live state struct
(`VableArrayStorage::RustVec`): the macro-generated mainloop owns that
struct and rewrites it every opcode, so the heap is authoritative and the
trace's shadow is not the write's to make.  `check_synchronized_
virtualizable` carried the matching carve-out.  Both were dropped so the
tokenless materialize ahead of a residual call could reach the array.

`synchronize_virtualizable` is reached from every vable setfield and
setarrayitem during tracing, so without the carve-out a walk flushed its
shadow over the live state on each of them.  `tiny2`'s
`jit_fibonacci_single` returned 377 instead of 89 and
`jit_fibonacci_matches_interp` failed at fib(5): the walk aborts in the
degraded `OP_PUSH_INT` arm — after `#2 #1 #2 ADD ->#2 ->#1` and before
`#3 1 SUB ->#3` — and the flushed add/store pair stayed in the live state
while the counter did not move, so the native loop re-ran the whole
iteration and each of the three aborts added one fibonacci step.

Restore both carve-outs and give the residual-call materialize its own
entry point, which writes the arrays: the outer executor is suspended for
that call and the callee reads the live struct.
`synchronize_virtualizable_after_guard_failure` keeps the full write it
already had.

Assisted-by: Claude
youknowone added a commit that referenced this pull request Sep 2, 2026
`write_virtualizable_back` had a carve-out for a virtualizable array held
as a Rust `Vec` inside the interpreter's live state struct
(`VableArrayStorage::RustVec`): the macro-generated mainloop owns that
struct and rewrites it every opcode, so the heap is authoritative and the
trace's shadow is not the write's to make.  `check_synchronized_
virtualizable` carried the matching carve-out.  Both were dropped so the
tokenless materialize ahead of a residual call could reach the array.

`synchronize_virtualizable` is reached from every vable setfield and
setarrayitem during tracing, so without the carve-out a walk flushed its
shadow over the live state on each of them.  `tiny2`'s
`jit_fibonacci_single` returned 377 instead of 89 and
`jit_fibonacci_matches_interp` failed at fib(5): the walk aborts in the
degraded `OP_PUSH_INT` arm — after `#2 #1 #2 ADD ->#2 ->#1` and before
`#3 1 SUB ->#3` — and the flushed add/store pair stayed in the live state
while the counter did not move, so the native loop re-ran the whole
iteration and each of the three aborts added one fibonacci step.

Restore both carve-outs and give the residual-call materialize its own
entry point, which writes the arrays: the outer executor is suspended for
that call and the callee reads the live struct.
`synchronize_virtualizable_after_guard_failure` keeps the full write it
already had.

Assisted-by: Claude
youknowone added a commit that referenced this pull request Sep 2, 2026
`write_virtualizable_back` had a carve-out for a virtualizable array held
as a Rust `Vec` inside the interpreter's live state struct
(`VableArrayStorage::RustVec`): the macro-generated mainloop owns that
struct and rewrites it every opcode, so the heap is authoritative and the
trace's shadow is not the write's to make.  `check_synchronized_
virtualizable` carried the matching carve-out.  Both were dropped so the
tokenless materialize ahead of a residual call could reach the array.

`synchronize_virtualizable` is reached from every vable setfield and
setarrayitem during tracing, so without the carve-out a walk flushed its
shadow over the live state on each of them.  `tiny2`'s
`jit_fibonacci_single` returned 377 instead of 89 and
`jit_fibonacci_matches_interp` failed at fib(5): the walk aborts in the
degraded `OP_PUSH_INT` arm — after `#2 #1 #2 ADD ->#2 ->#1` and before
`#3 1 SUB ->#3` — and the flushed add/store pair stayed in the live state
while the counter did not move, so the native loop re-ran the whole
iteration and each of the three aborts added one fibonacci step.

Restore both carve-outs and give the residual-call materialize its own
entry point, which writes the arrays: the outer executor is suspended for
that call and the callee reads the live struct.
`synchronize_virtualizable_after_guard_failure` keeps the full write it
already had.

Assisted-by: Claude
youknowone added a commit that referenced this pull request Sep 2, 2026
…he fields a trace can disagree about, the portal raise on both exception carriers, and the escape fixtures re-armed at f_lasti (#1625)

* majit-gc: read MAJIT_GC_NURSERY_POISON once through a shared accessor

push_resume_ref_roots called std::env::var_os on every registration.
Add gc_nursery_poison_enabled(), the LazyLock<bool> shape
gc_lifetime_log_enabled already uses, and route the nursery, old-gen and
resume-root reads through it.

gate-triage.md listed two read sites for this variable; the tree has
three.

Assisted-by: Claude

* jit: restore PyPy frame descriptor force gateways

* jit: declare FrameAnchor shadow stack externals

* jit: rtype virtualizable forces as primitive ops

* jit: publish a portal-runner raise to both exception carriers

`run_frame_through_portal` (the body of `ll_portal_runner_shim`) wrote only
the backend `_store_exception` cells and `bh_portal_runner_c` wrote only
`BH_LAST_EXC_VALUE`.  Both now call `publish_residual_call_exception`, which
writes both, as `eval::portal_runner` already did.

`warmspot.py:998-1006` raises for real, so the ll exception state a portal
raise arms is caller-blind, and one address `jd.portal_runner_adr` serves both
the compiled `compile_tmp_callback` residual (`compile.py:1126-1146`) and
`bhimpl_recursive_call_r` (`blackhole.py:1095-1116`).

`emit_walker_loop_callee_call_assembler` executes `ll_portal_runner_shim`
concretely at trace time, and the raise detection behind it reads
`BH_LAST_EXC_VALUE` alone (`executor.rs execute_varargs_call`), so a raise out
of the portal was recorded as a NULL return under a `GUARD_NO_EXCEPTION` and
left the backend cells undrained.

Adds `a_raise_through_the_recursive_portal_reaches_its_handler`, which measured
5 of 3000 calls surfacing `TypeError: unsupported operand type(s) for +:
'object' and 'int'` in place of the callee's `ValueError` before the change.

Assisted-by: Claude

* interp: drop the force marker from the two frame gateways whose fields are not redirected

rvirtualizable.py hook_access_field emits jit_force_virtualizable under
`if self.my_redirected_fields.get(cname.value)`. Pyre's redirected set is
the one virtualizable_gen.rs declares: last_instr, pycode, valuestackdepth,
debugdata and the locals_cells_stack_w array. descr_typecheck_fget_f_back
reads f_backref and descr_typecheck_fget_f_builtins reads w_builtin, so
neither is in it.

Measured on synth/getframe_inlined_callee_own_frame, whose leaf reads both
frame.f_code and frame.f_back: with the f_back marker the fixture raised
VableEscapedDuringResidualCall 35 times and compiled no loop; one-attribute
probes attribute all 35 to f_back and none to f_code, which the walker folds
instead of residualizing. Without it the fixture reads
loops_compiled=1 bridges_compiled=0 loops_aborted=0 guard_failures=1, its
committed baseline. synth/getframe_method_call_residual_body_once moves
loops_aborted 10 -> 0 with loops_compiled 0 -> 1 and the same output.

Record the condition on jit_force_virtualizable, which described the two
gateway populations but not which gateways join them.

Assisted-by: Claude

* interp: drop the force marker from the f_code gateway

`pycode` is in the redirected set `virtualizable_gen.rs` declares, so the
sibling gateways' field-list rule keeps a marker here. The question a
hand-placed marker owes, which `hook_access_field` never has to ask because it
injects at every access in every graph, is whether the trace's shadow and the
live frame can disagree about the field. For `pycode` they cannot: it is
written at frame construction (`PyFrame::__init__` and the allocator's field
initialisation) and never again, and `restore_resume_state_from` already
records it as frame-invariant. What the marker does reach is the escape flush,
which `virtualizable.py force_now` turns into ABORT_ESCAPE.

Measured against the committed baselines, output identical on every fixture:

  exception_raise_caught_same_frame_tb          guard_failures 803 -> 605
  exception_reentry_guard_finally_residual      8/1903/2/5 -> 5/1027/0/6
  c_call_reports_the_callee_frame_not_the_inliners  root:inner compiles again
  frame_chain_survives_a_recursive_call_assembler   loop:hot, root:rec again

Each is a `tb_frame.f_code` or `frame.f_code` read from interpreted code
nested inside a residual call, where the marker forces the caller's live
virtualizable.

Assisted-by: Claude

* bench: force the getframe escape fixtures at a redirected field

`sys._getframe` no longer takes a virtualizable force of its own, so seven
fixtures whose escape lever was a bare `_gf(1)` call, or a read of `f_code` or
`f_back`, stopped escaping at all: the three `while` sub-walk fixtures read
`loops_aborted=0` with `fbw_blackhole_adopted_multi_frame=0`,
`getframe_bridge_force_from_inlined_callee` read `guard_failures` 201 rather
than 4114 with `loops_aborted` 20 -> 0,
`getframe_method_call_residual_body_once` stopped firing the `load_type_attr`
fold it declares, and `nested_for_escape_flush_keeps_the_inner_iterator`
compiled `loop:main` where its header declares `root:leaf`.

Each now reads `f_lasti` off the frame `_gf(1)` returns. `last_instr` is in the
redirected set `virtualizable_gen.rs` declares, which is where
`rvirtualizable.py hook_access_field` places the force.

The six with a jitstats baseline read every counter in it back exactly, with
unchanged output. `getframe_method_call_residual_body_once` reports
`fold=load_type_attr consulted=15 fired=5` again, against consulted=2 fired=0
before. `nested_for_escape_flush_keeps_the_inner_iterator`, which has no
jitstats file, compiles `root:leaf` again.

Assisted-by: Claude

* majit-translate: split the frame gateway force census into its two halves

`every_redirected_frame_getter_carries_a_deletable_force` listed
`descr_typecheck_fget_f_code`, `..._fget_f_back` and `..._fget_f_builtins`,
which now carry no `jit_force_virtualizable`, so it failed on the first of
them. The positive half keeps the gateways that do carry one and gains the
five setters and deleter it had not listed: `fset_f_lineno`, `fset_f_trace`,
`fdel_f_trace`, `fset_f_trace_lines`, `fset_f_trace_opcodes`.

The complement becomes its own test,
`the_gateways_outside_the_redirected_set_carry_no_force`, over
`fget_f_code`, `fget_f_back`, `fget_f_builtins` and `get_generator`, so the
absence is pinned rather than merely unlisted. Together the two lists cover
all sixteen `descr_typecheck_*` frame gateways.

Assisted-by: Claude

* interp, bench: name the frame gateways by the family prefix they carry

`__majit_wrap_descr_typecheck_fget_f_code`, `..._fget_f_back` and
`..._fget_f_builtins` are the spellings `jit_fnaddr.rs`
`every_builtin_wrapper_descriptor_carries_the_family_prefix` requires; three
doc comments and two fixture headers still named them without the prefix.

Assisted-by: Claude

* jit: bridge the shadow-stack externals to the residual word ABI

`majit_gc::shadow_stack::{push, get, try_pop_to}` were published raw. Their
Rust signatures carry `GcRef` and `usize`, which is 32 bits on wasm32, while a
residual call whose descr types are all words lowers to an in-module
`(i64xn) -> i64` `call_indirect` that type-checks its callee on every call.
`residual_callee_abi_is_word` answers `true` for every callee because nothing
calls `set_residual_call_abi`, so that lowering is not gated on the
`set_faithful_residual_call_addrs` allow-list.

Publish `extern "C"` bridges under the same three declared paths, the way
`bh_code_unit_at` is published under `pyre_interpreter::pyopcode::code_unit_at`,
and drop the `ResidualSlot`/`ResidualRet` impls for `GcRef` they replace. The
new test calls each published address through the transmuted word signature.

Assisted-by: Claude

* jit: publish the blackhole ExitFrameWithExceptionRef raise to both carriers

`handle_blackhole_result`'s ExitFrameWithExceptionRef arm and
`wasm_ca_resume_deopt`'s `Outcome::FinishedException` arm wrote only the
backend `_store_exception` cells, while the `ContinueRunningNormally` arm
in the same function writes both cells and `BH_LAST_EXC_VALUE` through
`publish_residual_call_exception`.  Both sites now call
`publish_residual_call_exception`.

Upstream reaches this arm through `warmspot.py:996-1003`, which raises
into RPython's single ll exception state; the two pyre carriers are read
by different consumers (compiled `GUARD_NO_EXCEPTION` reads the backend
cells, trace-time `execute_varargs_call` reads `BH_LAST_EXC_VALUE`).

Assisted-by: Claude

* WIP majit: lower Rust references as residual-call arguments

Inherited from an uncommitted worktree change and carried forward with
the fixes below.  `is_reference_type` (`majit-macros/src/lib.rs`) admits
`&T`/`&mut T`, `helper_arg_from_i64` rebuilds the reference from the i64
ABI word inside the generated `extern "C"` trampoline, and
`Lowerer::lower_vable_reference` hands back the configured
virtualizable's existing ref register for `&mut <vable_var>`.  `tl` and
`tlc` take `storage_roll(&mut TlState, r)` in place of a
`(base_ptr, stackpos)` pair, matching `rpython/jit/tl/tl.py:106-109`,
and move `stack` to `VirtArray<i64>`; `braininterp` and `dualtape`
inline their bracket scans.

Fixes applied on top of the inherited state:

- `lib.rs` `is_wide_pointee`: a residual argument is one ABI word, so an
  unsized pointee cannot round-trip through it.  `&Wtf8` reached the
  reference arm and emitted `as usize as *const Wtf8`, which is E0606 at
  `pyre-object/src/dictmultiobject.rs:3794` and `src/rutf8.rs:312`.
- `jitcode_lower/dispatch.rs`: take the epilogue's tail statement
  positionally, and lower the prefix transactionally so a refused
  statement rolls back to `void_return` instead of propagating `None`
  out of `lower_dispatch_body`.  braininterp's post-loop `while` writes
  the green `pc`, so the propagation left it with no dispatch JitCode at
  all and every trace `AbortPermanent`.
- `jit_interp/mod.rs`: drain the single-pass FINISH value inside
  `take_single_pass_finish()` rather than ahead of it, and drain at the
  `can_enter_jit!` back edge too.
- `jitdriver.rs`: set `single_pass_finish` unconditionally in the
  `Finish` arm — the interpreted function has returned either way, and
  gating it on `walk_final_pc` left a walk that published finish values
  with neither a break nor a resume.  Publish the scalar from the
  `DoneWithThisFrame{Int,Float}` blackhole arms.
- `pyjitpl/dispatch.rs`: reload the tokenless virtualizable only when
  the matching materialize ran.
- `trace_ctx.rs`: walk `static_fields` rather than the flat box vector
  in `materialize_tokenless_virtualizable_before_residual_call`; the
  readers skip `Type::Void`, so the box position is not the field index.
- `lower_value.rs`: refuse a green LHS in `lower_local_update`, which
  runs before `lower_stmt_fallback`'s green-write guard.

Known red: `cargo test -p tl` deadlocks.  Every thread blocks on the
majit-gc mutex behind `jit_interp::tests::jit_residual_result_is_visible_to_following_ops`,
which spins inside `storage_roll` over a `stack[..stackpos]` range.

Assisted-by: Claude

* WIP jit-trace: process-global jitcode tables and a seeded-only inline admission

Inherited from the same uncommitted worktree change.  `callbacks.rs` and
`jitcode_runtime.rs` move the build-time jitcode and callback tables from
`thread_local!` + `OnceCell` to process-global `OnceLock`/`LazyLock`,
citing `MetaInterpStaticData` as one object per process
(`rpython/jit/metainterp/warmspot.py:281-282`); `state.rs` follows on the
test installer.  `inline_call.rs` drops the FBW walker's single-frame
collapse fallback: a user call is now either inlined with its own seeded
callee red frame and a paused caller image, or left residual.
`pyjitpl.py:2445-2476` `perform_call`/`newframe` always pushes a real
`MIFrame`, so upstream has no representation for the collapsed form.

Carried forward with the dead code that admission change left behind
removed: `seeded_inline` is proven true past the decline above it, so
`precomputed_parent_frame` is computed unconditionally, and the
`foriter_dirty_bound && !(try_multiframe || strict_seed)` re-ask and the
`!strict_inlinable && !try_multiframe` block are both unreachable.  The
two `compute_inline_caller_frame` declines use
`resolved_inline_decline(op.pc, line!())`, the spelling every other
decline in the function uses, instead of a bare `Ok(None)`.  Drops an
unused `Cell` import in the `state.rs` test module.

Not yet reviewed: the callee populations the seeded-only admission stops
inlining (a callee owning cellvars, and one over the multiframe depth
cap), and whether the process-global tables are safe under the parallel
lib-test threads.

Assisted-by: Claude

* jit: keep the outer-owned array carve-out out of the residual-call write

`write_virtualizable_back` had a carve-out for a virtualizable array held
as a Rust `Vec` inside the interpreter's live state struct
(`VableArrayStorage::RustVec`): the macro-generated mainloop owns that
struct and rewrites it every opcode, so the heap is authoritative and the
trace's shadow is not the write's to make.  `check_synchronized_
virtualizable` carried the matching carve-out.  Both were dropped so the
tokenless materialize ahead of a residual call could reach the array.

`synchronize_virtualizable` is reached from every vable setfield and
setarrayitem during tracing, so without the carve-out a walk flushed its
shadow over the live state on each of them.  `tiny2`'s
`jit_fibonacci_single` returned 377 instead of 89 and
`jit_fibonacci_matches_interp` failed at fib(5): the walk aborts in the
degraded `OP_PUSH_INT` arm — after `#2 #1 #2 ADD ->#2 ->#1` and before
`#3 1 SUB ->#3` — and the flushed add/store pair stayed in the live state
while the counter did not move, so the native loop re-ran the whole
iteration and each of the three aborts added one fibonacci step.

Restore both carve-outs and give the residual-call materialize its own
entry point, which writes the arrays: the outer executor is suspended for
that call and the callee reads the live struct.
`synchronize_virtualizable_after_guard_failure` keeps the full write it
already had.

Assisted-by: Claude

* jit: invalidate both virtualizable read caches at a may-force call

A `#[jit_may_force]` helper reached through jitcode lowering carried
`forces_virtual_or_virtualizable_effect_info()`, whose six raw descr sets
are the analyzer-empty `graphanalyze.py bottom_result()` shape.  Upstream
earns that shape from `readwrite_analyzer.analyze(op)`
(`call.py:319-323`) — for `tl.py Stack.roll` it names `self.stack[*]` —
while the helper here is a Rust `extern "C"` fn with no graph to analyze,
so the empty write set asserts something it has not proved and
`heap.py force_from_effectinfo` believes it.  `pyre-jit`'s
`CallFlavor::MayForce` already resolves to `default_effect_info()` for
that reason; the jitcode leg now does too.  `EF_RANDOM_EFFECTS` is `>=`
`EF_FORCES_VIRTUAL_OR_VIRTUALIZABLE` (`effectinfo.py:249-250`), so
`do_residual_call` still selects `CALL_MAY_FORCE_*`.

`OptVirtualize`'s `VirtualizableTracker` keeps a second read-after-write
cache outside `OptHeap` — `VirtualizableFieldState.fields` / `.arrays`,
written by `mirror_setarrayitem` and read by `tracked_array_element` —
and it had no invalidation point for a call.  Drop both maps at a
may-force call on a `without_vable_token` machine.  A token-bearing one
cannot reach a stale read: a callee that touches the virtualizable clears
the token and `vable_after_residual_call` aborts the trace.
`VirtualizableConfig` carries `has_vable_token` for that test.

`cargo test -p tl` did not terminate on
`jit_residual_result_is_visible_to_following_ops`.  The compiled loop
read `stack[1]` back after the residual ROLL; both caches folded that
read to the value stored before the call, so the loop counter stopped
decrementing.  Ablating either fix alone reproduces it.  The suite is now
31/31.

Assisted-by: Claude

* jit: guard the CallJitCallbacks install with a process-global Once

`callbacks::init` publishes into a process-global `OnceLock` and asserts
that a second call names the same table by pointer.  `init_callbacks`
guarded it with a `thread_local!` `Cell<bool>`, so every further thread
that reached it leaked a fresh, field-identical but pointer-distinct
table and tripped that assert.  Six `pyre-jit --lib` tests failed under
libtest's thread pool with "CallJitCallbacks initialized twice with
different tables".

Assisted-by: Claude

* jit-trace: name the caller operand region for the attribute-access opcodes

`caller_operand_slots` answered `None` for every resume shape but CALL,
FOR_ITER, BINARY_OP and COMPARE_OP, so `collect_call_stack_overrides`
declined and `compute_inline_caller_frame` reported
`Unavail::Top/CallStack`.  A property accessor, a `__getattr__` hook and a
descriptor `__get__` body are reached from LOAD_ATTR / STORE_ATTR, never
from a Python-level CALL, so none of them could be given a caller image
and the seeded-only inline admission residualized every one.

LOAD_ATTR consumes one operand, the owner -- its method form pushes two
results, but this depth is read before the instruction runs -- and
STORE_ATTR two, `[value, owner]`, where `store_attr_cached` pops the
owner first so `value` is the deeper.  The three sentinel-free shapes
differed only in that count, so they collapse into one
`CallerOperandSlots::Operands { deepest }`.

A caller image for a STORE_ATTR setter then needed two reads separated
from their own absence:

* the resume depth defaulted to zero when its source had no entry for the
  coordinate, and the decline could not tell that default from a source
  ANSWERING zero.  STORE_ATTR pops both operands and pushes nothing, so
  its fallthrough depth is a read zero.
* `result_color` is `None` both for a result the reconstruction could not
  locate and for a call that leaves no result on the operand stack at
  all.  An empty resumed stack has no result slot to name, so the depth
  now tells the second from the first.

Each of the four declines names itself in the `[fbw-census]` tally, and
the unmatched-instruction arm prints the instruction it saw.
`Unavail::Top/CallStack` was one bucket for all four, and two of them
`compute_inline_caller_frame` could not reach at all.

Measured on `bench/synth/property_getattr_exceptions`: one decline at
`inline_call.rs:5427` naming `CallStack::NoOperandShape` and
`[caller-operand-shape-absent] instruction=LoadAttr py_pc=40`; the
40M-iteration fixture goes from a check.py timeout (>20s) to 0.141s.
`bench/synth/property_accessor_invalidation` returns to
`loops_compiled=6`.

Assisted-by: Claude

* jit-trace: prune and name the seeded-only inline admission

`WIP jit-trace: process-global jitcode tables and a seeded-only inline
admission` left three shapes behind:

* `foriter_dirty_bound`'s outer `let mut` is never read before the block
  that assigns it -- `unused_assignments` reported it.
* `if seeded_inline` is preceded by a `return` on `!seeded_inline`.
* `precomputed_parent_frame` is `Some` on every path past its own two
  decline arms, so `parent_frame.is_some()` is a constant and
  `callee_frame_materialized_has_resume` is `callee_frame_seeded`.

The `shadow.frame_materialized` comment described the caller-image-less
case that admission no longer reaches.

Three populations reach the `!seeded_inline` decline and they are not the
same work to serve, so it names which one it refused in the
`[fbw-census]` tally.  `bench/synth/list_length_hint_validate` reports
`SeededInline::CalleeOwnsCellvars` six times: `make(hint)` holds `hint`
in a cell, and fresh cell allocation is the constructor half
`inline_call.rs:4686-4689` already records as unported.

Assisted-by: Claude

* majit: publish the single-pass outcome on every Finish

The `Finish` arm set `single_pass_finish` unconditionally but published
`single_pass_outcome` only when the walk left a `walk_final_pc`.  The
merge-point wrapper reads that flag from inside its `if let Some(outcome)`
branch, so a pc-less finish returned `None` and the generated hook neither
broke the native dispatch loop nor resumed it.

`usize::MAX` is the no-position sentinel the wrapper already returns for a
finish, so the outcome carries it, and `log_single_pass_parity_resume`
skips building a state image against it.

Assisted-by: Claude

* majit-macros: lower the dispatch epilogue prefix and tail in one transaction

`lower_value_expr` refusing the tail after the prefix transaction had
committed left the prefix in the JitCode and still terminated the walk in
`void_return`, so the `Finish` broke the dispatch loop and native code ran
the whole suffix -- the prefix included -- a second time.  The two halves
now roll back together, which is what the surrounding comment already
stated for a refusal.

Assisted-by: Claude

* majit-translate: exercise the jit_force_virtualizable rtyper directly

`extregistry.rs`'s `jit_force_virtualizable_resolves_to_llop_specializer`
proves `specialize_call()` resolves to `rtype_jit_force_virtualizable`
without calling it.  The new test builds a `HighLevelOp` carrying one
frame-typed argument and asserts the emitted llop's opname, its single
argument, its Void result and the `exception_cannot_occur()` declaration.

Assisted-by: Claude

* majit, bench: refresh three comments and narrow the recursive-portal catch

`majit/examples/tlc`'s merge-point comment called `ROLL` an abort stub and
its lowering open; the degraded-arm pin twenty lines below asserts the set
is `["PUSHARG"]` alone.

`capture_single_pass_finish`'s doc said only the resume pc is captured; it
also clears and repopulates `walk_finish_values` from its `value`
parameter.

`synchronize_virtualizable_before_residual_call` writes
`virtualizable_heap_ptr` while its one caller records stores against the
identity box.  Those two can name different objects, so the doc states why
they do not on any path that reaches it.

`a_raise_through_the_recursive_portal_reaches_its_handler` counted
`KeyboardInterrupt` and `SystemExit` as foreign portal exceptions.

Assisted-by: Claude

* jit-trace: allocate the inlined callee frame's pure cellvars

`PyFrame::finish_for_call_with_globals_obj` fills the cell band in two
halves -- one `w_cell_new(PY_NULL, family)` per cellvar that does not also
name a varname, then the closure's existing cells.  The seeded inline frame
builder emitted only the second half, and three sites declined a callee
whose `cellvars` list was non-empty: `strict_seed`, the seed block, and the
closure branch.

`emit_new_cell_inline` records the `NewWithVtable` plus the `SetfieldGc`
set `w_cell_new` performs.  `W_CELL_DESCR_GROUP` gains the inherited
`PyObject.w_class` that header stamps, and `w_cell_size_descr` /
`cell_family_descr` / `cell_header_w_class_descr` name the slots.
`emit_new_pyframe_inline_with_params` now takes the whole band
(`cell_slots` / `cell_start`) instead of the freevar half alone, and the
caller composes it in constructor order.  Each emitted cell is paired with
the one the concrete constructor allocated, because the sub-walk executes
the callee's residuals for real and `bh_load_deref_value_fn` reads a cell.

The three declines are dropped.  A cellvar that also names a varname is not
in this band: it shares that varname's slot and `MAKE_CELL` wraps it in the
callee's own prologue, so `npure_cellvars` is what the frame allocates and
a callee holding only overlapping cellvars allocates nothing.
`bench/synth/list_length_hint_validate` is that shape; its recorded
`guard_failures=19 loops_aborted=9 loops_compiled=4` are measured again on
all three backends.  The `SeededInline::CalleeOwnsCellvars` census bucket
is removed with the population it named.

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.

3 participants