Optimize JIT for list operations and enhance method dispatch - #3
Conversation
|
Warning Rate limit exceeded
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 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 configurationConfiguration used: Organization UI Review profile: ASSERTIVE Plan: Pro Run ID: 📒 Files selected for processing (6)
WalkthroughAdds a typed-list ChangesCompiler Guard Validation
List.pop Trace Feature (Tracer + Codegen + Descriptors)
Assembler OpRef Resolution Change
Benchmarks / Docs
Sequence DiagramsequenceDiagram
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
Estimated code review effort🎯 4 (Complex) | ⏱️ ~60 minutes Poem
🚥 Pre-merge checks | ✅ 5✅ Passed checks (5 passed)
✏️ Tip: You can configure your own custom pre-merge checks in the settings. ✨ Finishing Touches🧪 Generate unit tests (beta)
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.
Built for teams:
One agent for your entire SDLC. Right inside Slack. 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. Comment |
There was a problem hiding this comment.
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
📒 Files selected for processing (8)
majit/majit-backend-cranelift/src/compiler.rsmajit/majit-translate/src/codegen.rspyre/bench/list_insert.pypyre/bench/list_pop_append.pypyre/bench/list_reverse.pypyre/bench/list_setslice.pypyre/pyre-jit-trace/src/descr.rspyre/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( |
There was a problem hiding this comment.
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( |
There was a problem hiding this comment.
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.
0bbe50a to
feb6848
Compare
There was a problem hiding this comment.
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
📒 Files selected for processing (4)
majit/majit-backend-cranelift/src/compiler.rsmajit/majit-translate/src/codegen.rspyre/pyre-jit-trace/src/descr.rspyre/pyre-jit-trace/src/trace_opcode.rs
| 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, | ||
| ) |
There was a problem hiding this comment.
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.
7a5a077 to
5b4cd86
Compare
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)`.
|
Sorry, I accidently closed this pr. not intended |
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.
e687079 to
b43a166
Compare
…+ 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
… + 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
…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
…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
…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
…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
…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
…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
…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
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
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
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
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
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
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
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
…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
…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
`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
`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
`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
…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
This pull request adds fast-path JIT support for
list.pop()in Pyre, mirroring the existing optimized handling forlist.append(). It introduces a specialized trace-time lowering forlst.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()generated_list_pop_by_strategyinmajit-translate/src/codegen.rsto efficiently tracelst.pop()for object, integer, and float list strategies, using dynamic length reads and avoiding hard-coded constants.list_pop_valuetoMIFrameinpyre-jit-trace/src/trace_opcode.rsto select the appropriate fast path or fall back to generic dispatch, paralleling the approach forlist.append().Bound Method Specialization and Descriptors
W_METHOD_DESCR_GROUPand corresponding field descriptors (method_w_function_descr,method_w_self_descr) inpyre-jit-trace/src/descr.rsto allow the JIT to recover the receiver and function from bound method objects, supporting robust specialization of method calls. [1] [2]trace_call_callableinMIFrameto recognize and specialize calls tolst.append()andlst.pop()bound methods by extracting the receiver and guarding on the method function, enabling effective trace-time specialization.Backend Robustness
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
Imports and Minor Infrastructure
pyre-jit-trace/src/trace_opcode.rsto support new specialization logic.Summary by CodeRabbit
New Features
Improvements
Documentation