Skip to content

jit: inline CALL_KW / CALL_FUNCTION_EX and lift the FOR_ITER-body inline gate - #779

Merged
youknowone merged 4 commits into
mainfrom
perf-loop
Jul 25, 2026
Merged

jit: inline CALL_KW / CALL_FUNCTION_EX and lift the FOR_ITER-body inline gate#779
youknowone merged 4 commits into
mainfrom
perf-loop

Conversation

@youknowone

@youknowone youknowone commented Jul 25, 2026

Copy link
Copy Markdown
Owner

Four commits on the FBW inline-at-residual lever. Each one removes a decline that kept a Python-level call in a for body as a residual.

8cd58ae18d — inline keyword calls

try_walker_inline_user_call returned None for any CallKw residual, so a keyword call was never inlined. fbw_reorder_call_kw_args folds the kwnames tuple into a parameter permutation at trace time and hands the positional inline path the reordered boxes. Callees carrying VARARGS / VARKEYWORDS / keyword-only params decline.

d9ac64ebc7 — admit deferred-call callees into FOR_ITER-body inlining

An inline sub-walk inside a FOR_ITER body resumes a guard at the caller's CALL boundary, so a deopt re-executes the whole callee; the gate therefore declined any body it could not prove write-free. The static scan rejected on LOAD_GLOBAL and box_intnot on the nested CALL, which was the obvious-but-wrong hypothesis. effectinfo.rs documents that load_const / load_global / box_int carry CanRaise purely to satisfy the _OS_CANRAISE invariant; each is a read or a fresh allocation.

fbw_callee_body_side_effect_free becomes fbw_callee_body_replay_safety returning Clean / DeferredCall / Dirty:

  • LoadConst | LoadGlobal | BoxInt join the provably-side-effect-free set.
  • A residual whose helper is a Python-level CALL reports DeferredCall — its callee is a runtime value, so replay safety is decidable only at the call.
  • fbw_abort_nested_unjournaled_residual aborts before executing a residual that did not inline underneath a deferred-admitted sub-walk, and denies that callee for the rest of the process.

Soundness: the static scan clears every direct heap write, and the backstop clears every impure residual, so a deferred sub-walk is write-free everywhere it can abort or deopt. Denial is per callee code key, so a callee that turns out non-inlinable costs exactly one abort and then declines statically.

shape (N=2M, user CPU) before after
for i in range(N): t += helper(i), helper(i){return add(i,1,2)} 3.67s 0.11s (33x)
same with add(i, c=2, b=1) 4.36s 0.11s (40x)
identical work under while 0.13s 0.11s

The for-vs-while asymmetry is gone. The residual gap to pypy is the known backend-regalloc gap, out of scope here.

732f1193bd — virtualize array-backed BUILD_TUPLE, inline f(*args)

Same shape of surprise: the scan rejected at BUILD_TUPLE's setarrayitem_gc, before ever reaching the CallFunctionEx residual.

  • try_walker_specialize_newtuple_object re-emits the canonical W_TupleObject shape for the arities makespecialisedtuple2 does not claim, reading elements from the array heap-cache so the new_array_clear build keeps no consumer. Arity 2 declines: w_tuple_new routes len == 2 to Cls_ii / Cls_ff / Cls_oo, which has no wrappeditems, so emitting the array-backed shape there would diverge from what the blackhole rebuilds.
  • The scan tracks new_array* results as fresh, accepts setarrayitem_gc into a fresh array as an initialization, and adds NewtupleFromArray / NewlistFromArray and CallFunctionEx to the respective sets.
  • fbw_unpack_call_function_ex_args binds a star tuple to positional arguments from the cached element boxes, so it folds exactly when the tuple is virtual at the call. Declines a ** merge, an arity that is not the callee's co_argcount, the method form, and a tuple with no cached backing block.

synth/call_function_ex_star: 0.89s → 0.14s user, loops_aborted=0.

8ec7b3150e — freshness drops at branch targets, not branch sites

The freshness set was cleared when the linear scan reached a goto, which leaves a join hole: in r1 = getfield_gc(...); goto L; r1 = new_array; L: setarrayitem_gc r1 the scan reads r1 as fresh at L although one path arrives holding a live-heap reference. Now every label operand is collected up front and cleared at the target pc, which is both sound and strictly more precise (a conditional goto's fall-through keeps its exact state).

No current producer reaches the holesetarrayitem_gc is emitted only by the BUILD_TUPLE / BUILD_LIST lowerings, whose target register is defined in the same basic block, and a three-case probe does not discriminate the old scan from the new. This is invariant hardening, not a bug fix, and it measured neutral on four benches.

Verification

  • check.py dynasm 308/308 + cranelift 308/308. A later full run showed nested_loop at 2.1x against its 2x gate; discriminated as a load flake three ways — jit-stats byte-identical across the pre-change, post-change and current binaries (loops_compiled=2 bridges_compiled=1 loops_aborted=0 guard_failures=202), interleaved timings overlapping with no separation, and an isolated re-run passing 14/14 at 1.6x. Five check.py runs were concurrent on the machine at the time.
  • 41-case adversarial matrix byte-identical to pypy and cpython at N=20k/200k on both backends: arity 1/2/3/4, arity-mismatch TypeError text, defaults / *args / **kwargs / keyword-only callees, list unpack, parameter-derived tuples, side-effecting and polymorphic callees, tuple unpack / escape / is / == / repr, non-range FOR_ITER shapes (list iter, generator, user __next__, zip, break, continue, mid-loop type-switch deopt).

Note

PYRE_FBW_FORITER_INLINE no longer exists — #757 retired it — so there is no env-gate rollback on this path. The pre-change behaviour is Clean-only admission.

🤖 Generated with Claude Code

Summary by CodeRabbit

  • New Features

    • Improved support for optimizing function calls that use keyword arguments or expanded positional arguments.
    • Added additional optimization for constructing tuples from existing array-backed data.
  • Bug Fixes

    • Improved correctness when optimizing function calls inside loops, preventing unsafe replay or execution behavior.
  • Performance

    • Expanded opportunities for call inlining and tuple construction specialization, which may improve execution speed in eligible code paths.

… time

The FBW inline lever admitted only `PyreHelperKind::CallFn`, so every
CALL_KW site stayed a `bh_call_kw` residual that re-ran the whole keyword
binding — frame allocation, signature match, kwnames binding, argument
boxing — on each iteration.

Admit `PyreHelperKind::CallKw` too. `fbw_reorder_call_kw_args` reads the
constant kwnames tuple and the callee's `co_varnames`, both known at record
time, and reorders the argument boxes into parameter order so the existing
positional seeding in `try_walker_inline_resolved_user_call` serves the call
unchanged. It declines to the residual for anything it cannot settle
statically: a non-constant or non-str kwnames tuple, a name that matches no
parameter or one already filled positionally, `*args`/`**kwargs`/kwonly
callees, an argument count other than the parameter count, and the
bound-method form.

The receiver slot (argument index 1) arrives as `ConcreteValue::Null` for
`call_kw` where `call_fn` produces `Ref(PY_NULL)`; both are the checked
"no receiver" sentinel, so accept either.

`synth/call_kw_hot_loop` drops from ~700ns to ~1.0ns per iteration and its
compiled loop records no `call_may_force`.

Assisted-by: Claude
`fbw_callee_body_side_effect_free` rejected a callee body at its first
`LOAD_GLOBAL` or `box_int` residual. Those two, with `load_const`, carry
`CanRaise` only to satisfy the `_OS_CANRAISE` invariant (effectinfo.rs);
each is a read or a fresh allocation and commits nothing to the live heap,
so the `check_is_elidable() || LoopInvariant` proxy mis-rejected them.

Rename the predicate to `fbw_callee_body_replay_safety` and return
`CalleeReplaySafety::{Clean, DeferredCall, Dirty}`. The three read/allocate
helpers join the provably-side-effect-free set. A `CallFn` / `CallKw`
residual no longer forces `Dirty`: its callee is a runtime value, so the
body reports `DeferredCall` and the decision moves to the call itself.

`fbw_abort_nested_unjournaled_residual` backs that deferral: while a
deferred-admitted sub-walk is active it aborts before executing any residual
that is not provably side-effect-free, and records the outermost deferred
callee in a deny set the gate then consults, so the abort costs one attempt
per callee rather than one per trace attempt.

The static scan clears every direct heap write and the backstop clears every
impure residual, so a deferred sub-walk is write-free wherever it can abort
or deopt, which is what both the walk-abort replay and the caller-boundary
deopt re-execute.

`for i in range(N): total += helper(i)` with `helper(i)` calling
`add(i, 1, 2)` goes from 3.67s to 0.11s at N=2M, matching the `while`-loop
form; `synth/call_kw_star` from 1.15s to 0.18s.

Assisted-by: Claude
…star calls

`try_walker_specialize_newtuple_object` re-emits the canonical
`W_TupleObject` shape (`new_with_vtable` + `w_class` / `wrappeditems`
`setfield_gc` over a fresh items block) for the BUILD_TUPLE arities
`makespecialisedtuple2` does not claim, reading the elements out of the
array heap-cache so the `new_array_clear` build keeps no consumer.  Arity
2 and the empty tuple are declined.  Dispatched from
`dispatch_residual_call_iRd_kind` after the existing `spec_ii` fold.

`fbw_callee_body_replay_safety` tracks `new_array*` results as fresh and
accepts a `setarrayitem_gc` into a fresh array as an initialization, adds
`NewtupleFromArray` / `NewlistFromArray` to the replay-safe read/alloc
set, and adds `CallFunctionEx` to the deferred-call set.

`fbw_unpack_call_function_ex_args` binds a `f(*args)` star tuple to
positional arguments by reading the element boxes from the heap cache,
so the fold applies when the tuple is virtual at the call.  It declines a
`**` merge, an arity that is not the callee's `co_argcount`, the method
form, and a tuple with no cached backing block.

synth/call_function_ex_star: 0.89s -> 0.14s user, loops_aborted 0.
check.py dynasm 308/308 + cranelift 308/308.

Assisted-by: Claude
`fbw_callee_body_replay_safety` cleared its `fresh_ref_regs` set when the
linear scan reached a `goto`.  A join whose incoming branch sits earlier
in the body then kept whatever freshness the fall-through predecessor
had: in `r1 = getfield_gc(...); goto L; r1 = new_array; L: setarrayitem_gc r1`
the scan reads `r1` as fresh at `L` although one path arrives holding a
live-heap reference.

Collect every label operand up front (`body_branch_targets`, covering the
`goto` family, `catch_exception` and `int_*_jump_if_ovf`) and clear at the
target pcs instead.  A pc with no incoming branch edge keeps the exact
linear state, so a conditional goto's fall-through no longer discards
freshness.  A label this decode cannot locate reports `Dirty`.

No current producer reaches the join: `setarrayitem_gc` is emitted only by
the BUILD_TUPLE / BUILD_LIST lowerings, whose target register is defined
in the same basic block.  Measured neutral on call_kw_hot_loop,
call_kw_star, call_ex_kwargs_mapping and call_function_ex_star.

Assisted-by: Claude
@coderabbitai

coderabbitai Bot commented Jul 25, 2026

Copy link
Copy Markdown

Review Change Stack

Walkthrough

The JIT tracer adds replay-safety classification and deferred-inline tracking for FOR_ITER, supports additional keyword and star-argument call shapes, and specializes array-backed tuple construction from heap-cached elements.

Changes

FOR_ITER inline replay safety

Layer / File(s) Summary
Replay-safety analysis and deferred state
pyre/pyre-jit-trace/src/jitcode_dispatch/fbw_state.rs
Adds branch-aware CalleeReplaySafety classification, deferred-call denial tracking, and guards for nested FOR_ITER inline walks.
Call-shape assembly and FOR_ITER admission
pyre/pyre-jit-trace/src/jitcode_dispatch/inline_call.rs
Adds call_kw and call_function_ex argument assembly and applies replay-safety results to callee admission and sub-walk guarding.

Array-backed tuple specialization

Layer / File(s) Summary
Array-backed tuple construction
pyre/pyre-jit-trace/src/jitcode_dispatch/specialize.rs, pyre/pyre-jit-trace/src/jitcode_dispatch/residual_call.rs
Adds specialization for eligible array-backed BUILD_TUPLE residuals and dispatches to it after the existing specialization declines.

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

Sequence Diagram(s)

sequenceDiagram
  participant InlineCall
  participant ReplaySafety
  participant CalleeWalk
  participant ResidualDispatch
  participant TupleSpecializer
  InlineCall->>ReplaySafety: classify FOR_ITER callee
  ReplaySafety-->>InlineCall: return replay safety
  InlineCall->>CalleeWalk: run admitted callee sub-walk
  CalleeWalk->>ResidualDispatch: process NewtupleFromArray residual
  ResidualDispatch->>TupleSpecializer: try array-backed tuple specialization
  TupleSpecializer-->>ResidualDispatch: continue with specialized tuple or fallback
Loading

Possibly related PRs

  • youknowone/pyre#58: Extends call_kw tracing support, related to the new CallKw inline argument assembly.
  • youknowone/pyre#387: Modifies FOR_ITER JIT safety and inline sub-walk behavior.
  • youknowone/pyre#671: Introduces the earlier callee side-effect scan replaced by the new replay-safety classification.

Poem

A bunny hops through calls with care,
Folding keywords through the air.
Deferred loops now guard the way,
Tuples bloom from arrays today.
JIT tracks each trail it treads—
“Clean!” the rabbit proudly says.

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

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

❤️ Share

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

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 8ec7b3150e

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment on lines +1328 to +1330
let pi = varnames[..nparams]
.iter()
.position(|v| v.as_str() == name)?;

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P1 Badge Reject keywords that name positional-only parameters

When a CALL_KW targets a function declared with positional-only parameters, this lookup treats those parameter names as keyword-bindable. For example, def f(x, /): ...; f(x=1) should raise TypeError, but the fold places the value into slot 0 and inlines the function body instead. Check posonlyarg_count before accepting the matched slot so the residual path preserves the interpreter's argument-binding semantics.

AGENTS.md reference: AGENTS.md:L14-L20

Useful? React with 👍 / 👎.

Comment on lines +1498 to +1499
let Some(unpacked) = fbw_unpack_call_function_ex_args(ctx, r_args, &arg_concretes, nparams)
else {

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P1 Badge Decline star-call inlining for scope-producing signatures

This CALL_FUNCTION_EX path passes only nparams into the unpacker and never rejects VARARGS, VARKEYWORDS, or keyword-only parameters. Consequently, a valid call such as def f(a, *, b=5): return b; f(*(1,)) can enter the inline path without constructing or seeding b; similarly, *args and **kwargs locals are never packed. Inspect the resolved CodeObject flags and kwonlyarg_count, as the keyword-call fold already does, and leave these signatures residual until their complete scope can be seeded.

AGENTS.md reference: AGENTS.md:L14-L20

Useful? React with 👍 / 👎.

Comment on lines +985 to +986
static FBW_FORITER_DEFERRED_DENY: std::cell::RefCell<std::collections::HashSet<usize>> =
std::cell::RefCell::new(std::collections::HashSet::new());

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P1 Badge Move the deferred-call deny set out of TLS

When multiple threads trace the same deferred callee, this thread-local set records the failed inline only on the thread that encountered it, so every other tracing thread repeats the abort that the comment says should occur once per callee for the rest of the process. It also retains unrooted code-object addresses for the lifetime of each thread. Store this persistent runtime cache on the shared interpreter/process owner rather than duplicating it in TLS.

AGENTS.md reference: AGENTS.md:L148-L162

Useful? React with 👍 / 👎.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Actionable comments posted: 4

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

Inline comments:
In `@pyre/pyre-jit-trace/src/jitcode_dispatch/fbw_state.rs`:
- Around line 1503-1516: Update the setfield_gc handling branch around
decode_descr_index so replay safety is evaluated only for the canonical
r<value>d shape. Reject unsupported /iid and /ird opcodes, returning
CalleeReplaySafety::Dirty before reading target or descriptor offsets, while
preserving the existing fresh-target and immutable-field checks for supported
forms.
- Around line 1346-1382: Update body_branch_targets to account for switch/id
case targets, which are stored in the instruction descriptor rather than an L
operand. Either decode and add every switch target to the returned set, or
return None for bodies containing switch/id so replay safety classifies them as
Dirty; preserve existing handling for other branch opcodes.

In `@pyre/pyre-jit-trace/src/jitcode_dispatch/inline_call.rs`:
- Around line 1377-1389: In the starargs handling around
heapcache_getfield_cached, add the same concrete tuple-shape validation used by
the kwnames path before reading tuple_wrappeditems_descr from starargs. Reject
non-tuple values before the arity check and preserve the existing cached-length
validation for confirmed tuples.
- Around line 1302-1338: Update the keyword-call handling around the positional
parameter binding loop to reject callees with positional-only parameters, using
the available posonlyarg_count metadata before matching keyword names against
varnames. Preserve the existing positional and keyword duplicate checks for
supported signatures.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

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

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: ASSERTIVE

Plan: Pro Plus

Run ID: 4250a70d-2273-4f17-a2c1-0ed014181888

📥 Commits

Reviewing files that changed from the base of the PR and between c38f0f2 and 8ec7b31.

📒 Files selected for processing (4)
  • pyre/pyre-jit-trace/src/jitcode_dispatch/fbw_state.rs
  • pyre/pyre-jit-trace/src/jitcode_dispatch/inline_call.rs
  • pyre/pyre-jit-trace/src/jitcode_dispatch/residual_call.rs
  • pyre/pyre-jit-trace/src/jitcode_dispatch/specialize.rs

Comment on lines +1346 to +1382
/// Every pc in `body_code` that some op can branch to: the `goto` family and
/// `catch_exception` carry their target as the label operand, and
/// `int_*_jump_if_ovf` carries an overflow target ahead of its operands.
///
/// `None` when an op carries a label this decode cannot locate — a var-list
/// or a pyre payload ahead of the `L` — since a missed target would let a
/// freshness claim survive a join it does not hold across.
fn body_branch_targets(body_code: &[u8]) -> Option<std::collections::HashSet<usize>> {
let mut targets = std::collections::HashSet::new();
let mut pc = 0usize;
while pc < body_code.len() {
let d = crate::jitcode_runtime::decode_op_at(body_code, pc)?;
if d.argcodes.contains('L') {
// Operand widths follow `decode_op_at`; only the fixed-width forms
// can precede the label, so anything else gives up.
let mut cursor = d.pc + 1;
let mut target = None;
for operand in d.argcodes.chars() {
match operand {
'L' => {
target = Some(u16::from_le_bytes([
*body_code.get(cursor)?,
*body_code.get(cursor + 1)?,
]) as usize);
break;
}
'i' | 'c' | 'r' | 'f' => cursor += 1,
'd' | 'j' => cursor += 2,
_ => break,
}
}
targets.insert(target?);
}
pc = d.next_pc;
}
Some(targets)
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🗄️ Data Integrity & Integration | 🟠 Major | ⚡ Quick win

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
# Enumerate every label/branch-bearing op key and its argcodes to confirm which
# control-flow ops carry no 'L' operand.
rg -nP --type=rust -C2 '"(goto|switch|catch_exception|.*jump_if_ovf)[^"]*/' pyre/ majit/ | head -80

Repository: youknowone/pyre

Length of output: 7044


🏁 Script executed:

#!/bin/bash
set -euo pipefail

echo "## candidate file outline"
ast-grep outline pyre/pyre-jit-trace/src/jitcode_dispatch/fbw_state.rs || true

echo
echo "## fbw_state relevant lines 1280-1415"
sed -n '1280,1415p' pyre/pyre-jit-trace/src/jitcode_dispatch/fbw_state.rs | nl -ba -v1280

echo
echo "## jitcode_runtime candidates"
fd -p '.*jitcode_runtime.*|.*decode.*|.*op.*|.*insns.*' pyre/pyre-jit-trace/src | sed -n '1,120p'

echo
echo "## occurrences of switch/id / switch and control-flow opnames"
rg -n --type=rust -C2 '(^|/)"switch|switch/id|catch_exception|goto_if_not|jump_if_ovf|int_add_jump_if_ovf|decode_op_at|decode_descr_index' pyre/pyre-jit-trace | sed -n '1,240p'

Repository: youknowone/pyre

Length of output: 5943


🏁 Script executed:

#!/bin/bash
set -euo pipefail

echo "## fbw_state relevant lines 1340-1410"
sed -n '1340,1410p' pyre/pyre-jit-trace/src/jitcode_dispatch/fbw_state.rs

echo
echo "## jitcode_runtime files"
fd -a 'jitcode_runtime.*|mod.rs|decode.*|insns.*' pyre/pyre-jit-trace/src | sed -n '1,200p'

echo
echo "## decode_op_at and switch declarations/usages"
rg -n --type=rust -C3 'pub\s+fn\s+decode_op_at|fn\s+decode_op_at|decode_desc|r?decode_descr_index|switch/id|switch/|catch_exception|goto_if_not|jump_if_ovf|int_add_jump_if_ovf' pyre/pyre-jit-trace | sed -n '1,260p'

Repository: youknowone/pyre

Length of output: 29427


🏁 Script executed:

#!/bin/bash
set -euo pipefail

echo "## jitcode_runtime.rs relevant decode_op_at and switch"
rg -n -A120 'pub\s+fn\s+decode_op_at|fn\s+decode_op_at|impl\sOpDescr|struct\sOpDescr|switch/|switch/id|catch_exception/L|goto_if_not/iL|int_add_jump_if_ovf/L' pyre/pyre-jit-trace/src/jitcode_runtime.rs | sed -n '1,340p'

echo
echo "## fbw_callee_body_replay_safety scan 1410-1590"
sed -n '1410,1590p' pyre/pyre-jit-trace/src/jitcode_dispatch/fbw_state.rs

echo
echo "## switch exact declarations/usages in pyre-tree"
rg -n -C4 '"switch"|switch/id|switch/' pyre pyre-jit-trace majit 2>/dev/null | sed -n '1,240p' || true

Repository: youknowone/pyre

Length of output: 48481


🏁 Script executed:

#!/bin/bash
set -euo pipefail

echo "## switch handler"
sed -n '7980,8115p' pyre/pyre-jit-trace/src/jitcode_dispatch/mod.rs

echo
echo "## decode_descriptor helpers"
rg -n -A12 -B4 'decode_descr_index|read_descr|SwitchDictDescr|SwitchDescr|struct.*Switch|impl.*Switch' pyre/pyre-jit-trace/src/jitcode_dispatch pyre/pyre-jit-trace/src/jitcode_runtime.rs | sed -n '1,240p'

echo
echo "## all insns opname keys (if exposed) and switch count"
rg -n 'INSNS_OPNAMES|setup_insns|insns_opname_to_byte|pub static.*INSNS|map!\(|vec\!|switch/id|catch_exception/L|int_add_jump_if_ovf/Lii' pyre/pyre-jit-trace/src -g '*.rs' | sed -n '1,260p'

Repository: youknowone/pyre

Length of output: 32158


🏁 Script executed:

#!/bin/bash
set -euo pipefail

echo "## switch handler lines around it"
sed -n '7945,8025p' pyre/pyre-jit-trace/src/jitcode_dispatch/mod.rs

echo
echo "## switch tests cases and descriptors"
sed -n '1360,1535p' pyre/pyre-jit-trace/src/jitcode_dispatch/tests.rs

echo
echo "## PyreSwitchDescr and switch-target handling"
rg -n -C5 'PyreSwitchDescr|SwitchDictDescr|struct.*PyreSwitchDescr|as_switch|as_switch_dict|case.*target|dict\(|getint\(|orgpc|resumepc' pyre/pyre-jit-trace/src -g '*.rs' | sed -n '1,280p'

echo
echo "## label operand helper"
rg -n -A12 'fn read_label|label_operand_offset|fn decode_side_other_target' pyre/pyre-jit-trace/src/jitcode_dispatch

Repository: youknowone/pyre

Length of output: 41866


Treat untracked switch targets as dirty replay safety.

body_branch_targets() uses 'L' operand positions to discover joins, but switch/id carries each case pc in its descr instead of an L operand. A body containing switch/id can keep fresh_ref_regs alive across a switch join and classify a live-heap store as Clean. Reject such bodies as Dirty, or decode switch targets from the descr if that is intended here.

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

In `@pyre/pyre-jit-trace/src/jitcode_dispatch/fbw_state.rs` around lines 1346 -
1382, Update body_branch_targets to account for switch/id case targets, which
are stored in the instruction descriptor rather than an L operand. Either decode
and add every switch target to the returned set, or return None for bodies
containing switch/id so replay safety classifies them as Dirty; preserve
existing handling for other branch opcodes.

Comment on lines 1503 to 1516
} else if d.opname.starts_with("setfield_gc") {
// Canonical setfield shapes are `r<value>d`: the target ref is
// operand 0 and the field descr is operand 2.
let Some(&target_reg) = body_code.get(d.pc + 1) else {
return false;
return CalleeReplaySafety::Dirty;
};
let descr_index = decode_descr_index(body_code, &d, 2);
let immutable_field = callee_descr_refs
.get(descr_index)
.and_then(|descr| descr.as_field_descr())
.is_some_and(|field| field.is_immutable());
if !fresh_ref_regs[target_reg as usize] || !immutable_field {
return false;
return CalleeReplaySafety::Dirty;
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🗄️ Data Integrity & Integration | 🟠 Major | ⚡ Quick win

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
# Enumerate every setfield_gc op key/argcodes to confirm the assumed `r<value>d` shape is exhaustive.
rg -nP --type=rust '"setfield_gc[^"]*"' pyre/ majit/ | sort -u

Repository: youknowone/pyre

Length of output: 6753


🏁 Script executed:

#!/bin/bash
set -euo pipefail

echo "== fbw_state outline =="
ast-grep outline pyre/pyre-jit-trace/src/jitcode_dispatch/fbw_state.rs --view compact 2>/dev/null | head -120 || true

echo "== fbw_state relevant lines =="
sed -n '1460,1535p' pyre/pyre-jit-trace/src/jitcode_dispatch/fbw_state.rs

echo "== opcode width / decoding search =="
rg -n "fn decode_descr_index|decode_opcode|argcode|argcodes|width_map|BYTE_ARG|WORD_ARG|fieldwrite|setfield_gc" pyre/pyre-jit-trace/src pyre/pyre-jit-trace/src/jitcode_dispatch | head -250

echo "== module-level opcode table references =="
rg -n "BC_(SET|CALL|NEW|ARRAY|FIELD)" pyre/pyre-jit-trace/src/jitcode_dispatch/mod.rs | head -120

Repository: youknowone/pyre

Length of output: 36828


🏁 Script executed:

#!/bin/bash
set -euo pipefail

echo "== all setfield_gc opname/argcodes from jitcode_runtime.rs and tests =="
python3 - <<'PY'
from pathlib import Path
import re
patterns = [
    Path("pyre/pyre-jit-trace/src/jitcode_runtime.rs"),
    Path("majit/majit-translate/src/codewriter/insns.rs"),
    Path("majit/majit-metainterp/src/blackhole.rs"),
    Path("pyre/pyre-jit-trace/src/jitcode_dispatch/tests.rs"),
]
seen=set()
for p in patterns:
    if p.exists():
        for i,line in enumerate(p.read_text(errors='replace').splitlines(),1):
            if "setfield_gc" in line:
                for m in re.findall(r'"([^"]*setfield_gc[^"]*)"', line):
                    seen.add((i,m,str(p)))
seen_sorted=sorted(seen, key=lambda x:x[1])
for row in seen_sorted:
    print(f"{row[2]}:{row[0]}\t{row[1]}")
PY

echo "== decode_descr_index implementation =="
sed -n '3390,3435p' pyre/pyre-jit-trace/src/jitcode_dispatch/mod.rs

echo "== decode_op_at decoding of argcodes around setfield =="
sed -n '850,930p' pyre/pyre-jit-trace/src/jitcode_runtime.rs

echo "== jit code opcode width chars =="
sed -n '660,750p' pyre/pyre-jit-trace/src/jitcode_runtime.rs

Repository: youknowone/pyre

Length of output: 11097


Gate setfield_gc replay safety on the actual r<value>d shape.

decode_descr_index(.., 2) is applied to every setfield_gc* op, even unsupported /iid and /ird forms whose first operand is not a fresh ref. Those shapes would accept the wrong byte as the target register and can resolve the descriptr to a later field; guard this path by rejecting non-canonical setfield_gc argcodes or returning Dirty before trusting offsets.

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

In `@pyre/pyre-jit-trace/src/jitcode_dispatch/fbw_state.rs` around lines 1503 -
1516, Update the setfield_gc handling branch around decode_descr_index so replay
safety is evaluated only for the canonical r<value>d shape. Reject unsupported
/iid and /ird opcodes, returning CalleeReplaySafety::Dirty before reading target
or descriptor offsets, while preserving the existing fresh-target and
immutable-field checks for supported forms.

Comment on lines +1302 to +1338
let flags = unsafe { (*raw).flags };
if flags.contains(pyre_interpreter::CodeFlags::VARARGS)
|| flags.contains(pyre_interpreter::CodeFlags::VARKEYWORDS)
|| unsafe { (*raw).kwonlyarg_count } != 0
{
return None;
}
let varnames = unsafe { &(*raw).varnames };
if varnames.len() < nparams {
return None;
}
let n_pos = nargs - nkw;
let mut slot_args: Vec<Option<OpRef>> = vec![None; nparams];
let mut slot_conc: Vec<Option<ConcreteValue>> = vec![None; nparams];
for k in 0..n_pos {
slot_args[k] = Some(args[k]);
slot_conc[k] = Some(arg_conc[k]);
}
for j in 0..nkw {
let name_obj = unsafe { pyre_object::w_tuple_getitem(kwnames, j as i64) }?;
if !unsafe { pyre_object::is_str(name_obj) } {
return None;
}
let name = unsafe { pyre_object::w_str_get_wtf8(name_obj) }
.as_str()
.ok()?;
let pi = varnames[..nparams]
.iter()
.position(|v| v.as_str() == name)?;
// A keyword may only bind a parameter past the positional fill, and each
// parameter at most once (else Python raises "multiple values for
// argument").
if pi < n_pos || slot_args[pi].is_some() {
return None;
}
slot_args[pi] = Some(args[n_pos + j]);
slot_conc[pi] = Some(arg_conc[n_pos + j]);

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🎯 Functional Correctness | 🟠 Major | ⚡ Quick win

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
# Confirm the CodeObject carries a positional-only arg count and how it relates to arg_count.
ast-grep run --pattern 'pub struct CodeObject { $$$ }' --lang rust pyre/pyre-interpreter/src \
  || rg -nP --type=rust -A40 'pub struct CodeObject' pyre/pyre-interpreter/src
rg -nP --type=rust '\bposonly\w*' pyre/ | head -30

Repository: youknowone/pyre

Length of output: 3057


🏁 Script executed:

#!/bin/bash
set -euo pipefail

echo "== Locate inline_call.rs and relevant function =="
fd -a 'inline_call.rs' . | sed 's#^\./##'
echo

echo "== Section around target lines =="
cat -n pyre/pyre-jit-trace/src/jitcode_dispatch/inline_call.rs | sed -n '1220,1360p'

echo
echo "== Resolve callee and arg_count usage =="
rg -n --type=rust 'resolver|fn fbw_reorder|arg_count|nparams|varnames' pyre/pyre-jit-trace/src/jitcode_dispatch/inline_call.rs | sed -n '1,220p'

echo
echo "== CodeObject definition and posonlyarg_count relationship =="
rg -n --type=rust -A80 'pub struct CodeObject' pyre/pyre-interpreter/src/pycode.rs pyre/pyre-interpreter/src/lib.rs 2>/dev/null || true
rg -n --type=rust 'argcount.*posonly|posonly.*argcount|arg_count' pyre/pyre-interpreter/src/pycode.rs pyre/pyre-interpreter/src/call.rs | sed -n '1,120p'

Repository: youknowone/pyre

Length of output: 14213


🏁 Script executed:

#!/bin/bash
set -euo pipefail

echo "== resolve_inlinable_callee definitions/usages =="
rg -n --type=rust 'fn resolve_inlinable_callee|resolve_inlinable_callee\(' pyre/pyre-jit-trace/src pyre/pyre-interpreter/src -A50 -B5

echo
echo "== pycode.rs CodeObject and argument construction slices =="
cat -n pyre/pyre-interpreter/src/pycode.rs | sed -n '600,660p'
cat -n pyre/pyre-interpreter/src/pycode.rs | sed -n '1080,1115p'

echo
echo "== call.rs call_kw posonly behavior =="
cat -n pyre/pyre-interpreter/src/call.rs | sed -n '1430,1495p'
cat -n pyre/pyre-interpreter/src/call.rs | sed -n '2030,2052p'

echo
echo "== behavioral model of current fbw_ behavior for positional-only kwargs =="
python3 - <<'PY'
def simulate(nparams, arg_count, posonly, kwargs):
    # Mirrors fbw_reorder_call_kw_args:
    nkw = len(kwargs)
    nargs = nparams  # caller only reaches this when nargs == nparams
    n_pos = nparams - nkw
    if nparams == 0 or nkw > nargs or nargs != nparams:
        return "None"
    varnames = [f"p{i}" for i in range(nparams)]
    if len(varnames) < nparams:
        return "None"
    # positional-fill guard for kwargs: pi < n_pos rejects
    slot_filled = [False] * nparams
    for name, start_index in [(kwargs[0], min(n_pos, nparams - 1))]:
        try:
            pi = varnames[:nparams].index(name)
        except ValueError:
            return "None"
        if pi < n_pos or slot_filled[pi]:
            return "None"
        slot_filled[pi] = True
        # would inline normally; remaining positional slots from n_pos before key slots remain None?
        # fold only succeeds below if all slots Some. With f(a=a) and nargs=nparams=1: n_pos=0, pi=0, pass, out=[arg] => inline success.
    slots_some = all(slot_filled)
    return "inline failure (missing args)" if not slots_some else f"inline success, slot={pi}"

for posonlyargcount in [0, 1]:
    nparams = 1
    code_argcount = posonlyargcount
    print("posonlyargcount=", posonlyargcount, "code.arg_count=", code_argcount, "nparams=", nparams,
          "with f(a=a)", simulate(nparams, code_argcount, pos_onlyargcount, ["p0"]))
PY

Repository: youknowone/pyre

Length of output: 50371


Reject keyword calls when the callee has positional-only parameters.

nparams comes from code.arg_count, which includes positional-only slots, so varnames[..nparams] exposes those names. A keyword like f(a=2) can bind a positional-only parameter and the fold then succeeds, diverging from the interpreter’s TypeError. Either reject any callee with posonlyarg_count != 0 here, or reject any keyword name in the positional-only range.

🐛 Proposed fix
     let flags = unsafe { (*raw).flags };
     if flags.contains(pyre_interpreter::CodeFlags::VARARGS)
         || flags.contains(pyre_interpreter::CodeFlags::VARKEYWORDS)
         || unsafe { (*raw).kwonlyarg_count } != 0
+        // A keyword may not bind a positional-only parameter; `arg_count` includes
+        // those slots, so `varnames[..nparams]` would otherwise expose their names.
+        || unsafe { (*raw).posonlyarg_count } != 0
     {
         return None;
     }
📝 Committable suggestion

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

Suggested change
let flags = unsafe { (*raw).flags };
if flags.contains(pyre_interpreter::CodeFlags::VARARGS)
|| flags.contains(pyre_interpreter::CodeFlags::VARKEYWORDS)
|| unsafe { (*raw).kwonlyarg_count } != 0
{
return None;
}
let varnames = unsafe { &(*raw).varnames };
if varnames.len() < nparams {
return None;
}
let n_pos = nargs - nkw;
let mut slot_args: Vec<Option<OpRef>> = vec![None; nparams];
let mut slot_conc: Vec<Option<ConcreteValue>> = vec![None; nparams];
for k in 0..n_pos {
slot_args[k] = Some(args[k]);
slot_conc[k] = Some(arg_conc[k]);
}
for j in 0..nkw {
let name_obj = unsafe { pyre_object::w_tuple_getitem(kwnames, j as i64) }?;
if !unsafe { pyre_object::is_str(name_obj) } {
return None;
}
let name = unsafe { pyre_object::w_str_get_wtf8(name_obj) }
.as_str()
.ok()?;
let pi = varnames[..nparams]
.iter()
.position(|v| v.as_str() == name)?;
// A keyword may only bind a parameter past the positional fill, and each
// parameter at most once (else Python raises "multiple values for
// argument").
if pi < n_pos || slot_args[pi].is_some() {
return None;
}
slot_args[pi] = Some(args[n_pos + j]);
slot_conc[pi] = Some(arg_conc[n_pos + j]);
let flags = unsafe { (*raw).flags };
if flags.contains(pyre_interpreter::CodeFlags::VARARGS)
|| flags.contains(pyre_interpreter::CodeFlags::VARKEYWORDS)
|| unsafe { (*raw).kwonlyarg_count } != 0
// A keyword may not bind a positional-only parameter; `arg_count` includes
// those slots, so `varnames[..nparams]` would otherwise expose their names.
|| unsafe { (*raw).posonlyarg_count } != 0
{
return None;
}
let varnames = unsafe { &(*raw).varnames };
if varnames.len() < nparams {
return None;
}
let n_pos = nargs - nkw;
let mut slot_args: Vec<Option<OpRef>> = vec![None; nparams];
let mut slot_conc: Vec<Option<ConcreteValue>> = vec![None; nparams];
for k in 0..n_pos {
slot_args[k] = Some(args[k]);
slot_conc[k] = Some(arg_conc[k]);
}
for j in 0..nkw {
let name_obj = unsafe { pyre_object::w_tuple_getitem(kwnames, j as i64) }?;
if !unsafe { pyre_object::is_str(name_obj) } {
return None;
}
let name = unsafe { pyre_object::w_str_get_wtf8(name_obj) }
.as_str()
.ok()?;
let pi = varnames[..nparams]
.iter()
.position(|v| v.as_str() == name)?;
// A keyword may only bind a parameter past the positional fill, and each
// parameter at most once (else Python raises "multiple values for
// argument").
if pi < n_pos || slot_args[pi].is_some() {
return None;
}
slot_args[pi] = Some(args[n_pos + j]);
slot_conc[pi] = Some(arg_conc[n_pos + j]);
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@pyre/pyre-jit-trace/src/jitcode_dispatch/inline_call.rs` around lines 1302 -
1338, Update the keyword-call handling around the positional parameter binding
loop to reject callees with positional-only parameters, using the available
posonlyarg_count metadata before matching keyword names against varnames.
Preserve the existing positional and keyword duplicate checks for supported
signatures.

Source: Coding guidelines

Comment on lines +1377 to +1389
let starargs = r_args[2];
let items_descr = crate::descr::tuple_wrappeditems_descr();
let block = ctx
.trace_ctx
.heapcache_getfield_cached(starargs, items_descr.index())?;
// The cached length pins the arity: the callee takes exactly `nparams`
// positional parameters, and a mismatch is a runtime TypeError the inline
// path does not model.
let len_op = ctx.trace_ctx.heap_cache().arraylen(block)?;
match len_op.inline_const_to_value() {
Some(majit_ir::Value::Int(n)) if n as usize == nparams => {}
_ => return None,
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🩺 Stability & Availability | 🟠 Major | ⚡ Quick win

No concrete type check that starargs is a tuple.

The keyword path validates is_tuple(kwnames) before trusting it, but this fold reads the W_Tuple.wrappeditems field straight off r_args[2] with no type check. tuple_wrappeditems_descr() resolves through field_descr_from_groupstable_field_index(offset, size, type, signed), so the descr index is structural: any other object whose slot 0 has the same offset/size/type (a W_List's storage, for instance) yields the same index and can hit a cached getfield entry. f(*some_list) is ordinary Python and would pass the arity check, at which point a list's strategy-specific backing store is unpacked as if it were tuple element refs. The walker_concrete_ref_object requirement per element is incidental cover, not a type guarantee.

Add the same concrete-shape gate the kwnames path uses.

🛡️ Proposed fix
     let starargs = r_args[2];
+    // Structural field-descr identity is not type identity: pin the concrete
+    // receiver to an exact tuple before reading `wrappeditems` off it.
+    match arg_concretes[2] {
+        ConcreteValue::Ref(obj)
+            if !obj.is_null() && unsafe { pyre_object::is_tuple(obj) } => {}
+        _ => return None,
+    }
     let items_descr = crate::descr::tuple_wrappeditems_descr();
📝 Committable suggestion

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

Suggested change
let starargs = r_args[2];
let items_descr = crate::descr::tuple_wrappeditems_descr();
let block = ctx
.trace_ctx
.heapcache_getfield_cached(starargs, items_descr.index())?;
// The cached length pins the arity: the callee takes exactly `nparams`
// positional parameters, and a mismatch is a runtime TypeError the inline
// path does not model.
let len_op = ctx.trace_ctx.heap_cache().arraylen(block)?;
match len_op.inline_const_to_value() {
Some(majit_ir::Value::Int(n)) if n as usize == nparams => {}
_ => return None,
}
let starargs = r_args[2];
// Structural field-descr identity is not type identity: pin the concrete
// receiver to an exact tuple before reading `wrappeditems` off it.
match arg_concretes[2] {
ConcreteValue::Ref(obj)
if !obj.is_null() && unsafe { pyre_object::is_tuple(obj) } => {}
_ => return None,
}
let items_descr = crate::descr::tuple_wrappeditems_descr();
let block = ctx
.trace_ctx
.heapcache_getfield_cached(starargs, items_descr.index())?;
// The cached length pins the arity: the callee takes exactly `nparams`
// positional parameters, and a mismatch is a runtime TypeError the inline
// path does not model.
let len_op = ctx.trace_ctx.heap_cache().arraylen(block)?;
match len_op.inline_const_to_value() {
Some(majit_ir::Value::Int(n)) if n as usize == nparams => {}
_ => return None,
}
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@pyre/pyre-jit-trace/src/jitcode_dispatch/inline_call.rs` around lines 1377 -
1389, In the starargs handling around heapcache_getfield_cached, add the same
concrete tuple-shape validation used by the kwnames path before reading
tuple_wrappeditems_descr from starargs. Reject non-tuple values before the arity
check and preserve the existing cached-length validation for confirmed tuples.

@github-actions

Copy link
Copy Markdown

🤖 Codex parity review

Static analysis of this diff vs the local RPython/PyPy sources (commit 8ec7b31).
Updated: 2026-07-25T05:16:54.341Z

Files in the reviewed diff
pyre/pyre-jit-trace/src/jitcode_dispatch/fbw_state.rs
pyre/pyre-jit-trace/src/jitcode_dispatch/inline_call.rs
pyre/pyre-jit-trace/src/jitcode_dispatch/residual_call.rs
pyre/pyre-jit-trace/src/jitcode_dispatch/specialize.rs

1. Regressions to PyPy parity introduced by this patch

  • pyre/pyre-jit-trace/src/jitcode_dispatch/inline_call.rs:1328 ↔ pypy/interpreter/argument.py:473: keyword inlining binds a keyword to any matching varnames[..nparams] slot, including positional-only parameters. PyPy rejects f(a=1) for def f(a, /) when there is no **kwargs (_match_keywords raises ArgErrPosonlyAsKwds). The new inline path instead seeds a and executes the callee, changing a required TypeError into a successful call. Main left CallKw residualized, so this is a regression.

2. Other mismatches introduced by this patch

None.

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

None.

4. Structural adaptations

  • pyre/pyre-jit-trace/src/jitcode_dispatch/inline_call.rs:1436 ↔ pypy/interpreter/pyopcode.py:1403: support is attached to the CPython-compatible compiler’s CallKw helper rather than PyPy’s CALL_FUNCTION_KW opcode. This is an opcode/lowering adaptation; its argument-binding semantics must still follow PyPy’s matcher (including the regression above).
  • pyre/pyre-jit-trace/src/jitcode_dispatch/inline_call.rs:1490 ↔ pypy/interpreter/pyopcode.py:1429: CallFunctionEx is represented as a residual helper with a virtual-tuple heap-cache fold, rather than PyPy’s Arguments(w_star=..., w_starstar=...) construction. This is a compiler/runtime representation adaptation.
  • pyre/pyre-jit-trace/src/jitcode_dispatch/fbw_state.rs:980 ↔ rpython/jit/metainterp/pyjitpl.py:2475: deferred-inline bookkeeping is held in Rust thread-local state, whereas PyPy keeps tracing-frame state on metainterp.framestack. This is a Rust execution-state adaptation, not a direct source-level counterpart.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant