Skip to content

jit: restore the raise-bearing method-form inline, and inline a user binop/compare dunder that reads self.attr - #954

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

jit: restore the raise-bearing method-form inline, and inline a user binop/compare dunder that reads self.attr#954
youknowone merged 2 commits into
mainfrom
perf-bridge

Conversation

@youknowone

@youknowone youknowone commented Aug 1, 2026

Copy link
Copy Markdown
Owner

Two commits. The first is a regression fix for something already on main.

jit: key the raise decline on widened_method_form, not allow_method_load_attr

This fixes a regression currently on main. #942 was squash-merged with three of its four commits: the widening landed, the commit that scoped its decline did not. main today reads

let declined = if allow_method_load_attr {
    callee_body_contains_raise(body.code)     // widened_method_form missing

Five entries pass allow_method_load_attr, and four of them — the type.__call__ __init__ fold, the exception __str__/__repr__ override, and the property getter and setter — passed it before the widening. So the decline also withdraws inlines that were already happening. A method-form body with no attribute read has method_form_callee_body_supported == true, hence widened_method_form == false, and is admitted again.

class B:
    def bump(self, n):
        if n < 0:
            raise ValueError(n)
        return n + 1
for i in range(400000): t += b.bump(i)

min of 7 interleaved runs, startup subtracted:

main today this PR
dynasm 0.495s 0.022s (22.6x)
cranelift 0.411s 0.025s (16.5x)

None of the 358 synthetic benches cover this shape.

jit: inline a user binop/compare dunder whose body reads self.attr

try_walker_inline_user_binop and try_walker_inline_user_compareop were the last two entries passing allow_method_load_attr = false, so method_form_callee_body_supported declined any dunder body carrying a LoadAttr residual — def __lt__(self, o): return self.x < o.x, the ordinary shape.

With both flipped, all seven try_walker_inline_resolved_user_call call sites pass the same value, so the parameter and the branch it selected are gone; widened_method_form drops its now-constant conjunct.

There is no upstream counterpart to the check being removed. rpython/jit/codewriter/policy.py:35 look_inside_function returns True by default; _reject_function (:38-46) rejects only elidable functions and rpython.rtyper.module.* helpers; look_inside_graph (:48-64) rejects only loop-bearing graphs, _jit_look_inside_ overrides and unsupported variable types — all on RPython graphs, never on an app-level body. App-level dunders dispatch uniformly through pypy/objspace/descroperation.py:706. The gate was a pyre-only deviation.

4,000,000 iterations of a while loop, min of 5 interleaved runs, startup subtracted, against a binary built from origin/main:

body dynasm cranelift
def __lt__(self, o): return self.x < o.x 3.469s → 0.056s (62x) 3.961s → 0.093s (43x)
def __add__(self, o): return self.x + o.x 3.609s → 0.047s (76x) 4.053s → 0.066s (61x)

The for form is unchanged (0.99x / 1.01x). The FOR_ITER DeferredCall admission denies arg_class_guard.is_some(), which is exactly these two entries, and that denial is what keeps the BINARY_OP abort-rewind from resuming one operand short. Left alone.

Semantics

Eight probes in while form, 200000 iterations each, matching CPython on both backends and on an origin/main binary:

  • NotImplemented falling through to the reflected operand
  • an AttributeError raised inside the body escaping as AttributeError, with the TypeError counter at 0 (no confusion with the NotImplemented path)
  • a __getattr__ fallback inside the body
  • a property getter's read count landing on exactly 200000 per operand
  • a proper-subclass rhs keeping reflected priority
  • a ValueError raised once well after the trace is hot
  • the receiver's attribute mutating mid-loop
  • a store committed before the failing rhs map guard, at exactly one execution per iteration

Verification

  • check.py --backend dynasm 358/358
  • check.py --backend cranelift 358/358
  • cargo test --all --no-default-features 7332 passed, 0 failed (pyre-object's SIGABRT is the known multithread GC flake; 3/3 clean on rerun)

authored by Claude

Summary by CodeRabbit

  • Bug Fixes
    • Improved handling of method-form calls during inlining.
    • Method calls are now admitted more consistently, while unsupported cases involving attribute access and raised exceptions are safely declined.
    • Simplified call-processing behavior for more predictable execution.

…oad_attr

The previous commit computed `widened_method_form` for the FOR_ITER admission
but keyed the raise decline on `allow_method_load_attr`.  Five entries pass that
flag; four of them -- the `type.__call__` `__init__` fold, the exception
`__str__`/`__repr__` override, and the property getter and setter -- passed it
before the body-reads-`self.attr` widening, so the decline also withdrew inlines
that were already happening.

A method-form body with no attribute read is one the narrow surface admits, so
`method_form_callee_body_supported` returns true for it and
`widened_method_form` is false.  Measured on

    class B:
        def bump(self, n):
            if n < 0:
                raise ValueError(n)
            return n + 1
    for i in range(400000): t += b.bump(i)

min of 7 interleaved runs, startup subtracted:

  dynasm     origin/main 0.011s, before 0.504s, after 0.022s
  cranelift  origin/main 0.021s, before 0.418s, after 0.018s

`PYRE_FBW_INLINE_DIAG` prints the same 8 `[inline-body]` lines as origin/main
again, and `loops_compiled` returns from 2 to 1.

`synth/inline_subwalk_mutating_residual` keeps its gain: against an origin/main
binary, min of 7 interleaved, startup subtracted, 0.225s -> 0.075s (dynasm) and
0.363s -> 0.089s (cranelift).

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

Assisted-by: Claude
`try_walker_inline_user_binop` and `try_walker_inline_user_compareop` were the
last two entries passing `allow_method_load_attr = false`, so
`method_form_callee_body_supported` declined any dunder body carrying a
`LoadAttr` residual -- `def __lt__(self, o): return self.x < o.x`, the ordinary
shape.  Pass `true` there.

With that, all seven `try_walker_inline_resolved_user_call` call sites pass the
same value, so the parameter and the branch it selected are gone;
`widened_method_form` drops its now-constant conjunct and keeps naming the
bodies the two declines are scoped to.

There is no upstream counterpart to the check being removed.
`codewriter/policy.py:35` `look_inside_function` returns True by default,
`_reject_function` (:38-46) rejects only elidable functions and
`rpython.rtyper.module.*` helpers, and `look_inside_graph` (:48-64) rejects only
loop-bearing graphs, `_jit_look_inside_` overrides and unsupported variable
types -- all on RPython graphs, never on an app-level body.  App-level dunders
dispatch uniformly through `descroperation.py:706`.

Measured, 4,000,000 iterations of a `while` loop, min of 5 interleaved runs,
startup subtracted, against a binary built from origin/main:

  def __lt__(self, o): return self.x < o.x
    dynasm     3.469s -> 0.056s
    cranelift  3.961s -> 0.093s
  def __add__(self, o): return self.x + o.x
    dynasm     3.609s -> 0.047s
    cranelift  4.053s -> 0.066s

The `for` form is unchanged: the FOR_ITER `DeferredCall` admission denies
`arg_class_guard.is_some()`, which is exactly these two entries, and that denial
is what keeps the `BINARY_OP` rewind from resuming one operand short.

Seven semantics probes in `while` form, 200000 iterations each, match CPython on
both backends and on an origin/main binary: NotImplemented falling through to
the reflected operand; an AttributeError raised inside the body escaping as
AttributeError with the TypeError counter at 0; a `__getattr__` fallback; a
property getter's read count landing on exactly 200000 per operand; a
proper-subclass rhs keeping reflected priority; a ValueError raised once well
after the trace is hot; and the receiver's attribute mutating mid-loop.  An
eighth pins a store committed before the failing rhs map guard at exactly one
execution per iteration.

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

Assisted-by: Claude
@coderabbitai

coderabbitai Bot commented Aug 1, 2026

Copy link
Copy Markdown

Review Change Stack

No actionable comments were generated in the recent review. 🎉

ℹ️ Recent review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: ASSERTIVE

Plan: Pro Plus

Run ID: f29b533c-c482-41c0-9d75-0eab54806e09

📥 Commits

Reviewing files that changed from the base of the PR and between a9a867e and 79ed4a2.

📒 Files selected for processing (1)
  • pyre/pyre-jit-trace/src/jitcode_dispatch/inline_call.rs

Walkthrough

The PR removes allow_method_load_attr from user-call inlining and all affected call sites. Method-form callees are generally admitted. Widened unbound method-form callees with unsupported attribute reads and raises are declined.

Changes

Method-form inlining

Layer / File(s) Summary
Inline admission contract and method-form rules
pyre/pyre-jit-trace/src/jitcode_dispatch/inline_call.rs
The user-call signature no longer accepts allow_method_load_attr. Method-form detection and deferred foriter handling now apply the simplified admission rules.
Inline-call site updates
pyre/pyre-jit-trace/src/jitcode_dispatch/inline_call.rs
Type calls, exception-string overrides, property accessors, and dunder calls no longer pass the removed argument.

Estimated code review effort: 3 (Moderate) | ~20 minutes

Possibly related PRs

  • youknowone/pyre#779: Both changes modify try_walker_inline_resolved_user_call and method-form admission logic.
  • youknowone/pyre#942: This PR revises the method-form eligibility contract introduced there.

Poem

A rabbit hops through calls in line,
No method flag remains to bind.
Attribute reads now pass the gate,
Except raised widened forms that wait.
The inliner follows rules refined.

🚥 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 and specifically summarizes both main changes: restoring raise-bearing method-form inlining and enabling attribute-reading user binary-operation and comparison dunder inlining.
Docstring Coverage ✅ Passed Docstring coverage is 100.00% which is sufficient. The required threshold is 80.00%.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.
✨ Finishing Touches
📝 Generate docstrings
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch perf-bridge

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

❤️ Share

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

@github-actions

github-actions Bot commented Aug 1, 2026

Copy link
Copy Markdown

🤖 Codex parity review

Static analysis of this diff vs the local RPython/PyPy sources (commit 79ed4a2).
Updated: 2026-08-01T11:10:43.234Z

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

Codex did not produce a report (exit 1). Last log lines:

-                    "[inline-method-form] decline pc={} allow_load_attr={allow_method_load_attr}",
-                    op.pc
-                );
+                eprintln!("[inline-method-form] decline pc={}", op.pc);
             }
             return Ok(None);
         }
@@ -4530,7 +4526,6 @@ pub(crate) fn try_walker_inline_type_call<Sym: WalkSym>(
         // `__init__` bodies are `self.x = ...` stores; the sub-walk folds them
         // to slot writes on the fresh instance exactly as the property-setter
         // route folds its own.
-        true,
         false,
         Some((instance, ConcreteValue::Ref(concrete_instance))),
     )?;
@@ -4678,7 +4673,6 @@ pub(crate) fn try_walker_inline_exception_string_override<Sym: WalkSym>(
         Some((r_args[2], concrete_receiver, w_class, version_tag)),
         None,
         true,
-        true,
         None,
     )?
     else {
@@ -4800,7 +4794,6 @@ pub(crate) fn try_walker_inline_property_get<Sym: WalkSym>(
         // Getter bodies commonly read `self._slot` — a LOAD_ATTR the method-form
         // support gate would otherwise reject; the sub-walk folds it to a slot
         // read (same allowance the exception `__str__`/`__repr__` override uses).
-        true,
         false,
         None,
     )
@@ -4899,7 +4892,6 @@ pub(crate) fn try_walker_inline_property_set<Sym: WalkSym>(
         has_closure,
         Some((obj, concrete_obj, w_type, version_tag)),
         None,
-        true,
         false,
         None,
     )
@@ -5056,7 +5048,6 @@ pub(crate) fn try_walker_inline_user_binop<Sym: WalkSym>(
         Some((lhs, concrete_lhs, w_class, version_tag)),
         Some((rhs, concrete_rhs, w_typ_r.as_ptr())),
         false,
-        false,
         None,
     )?
     else {
@@ -5202,7 +5193,6 @@ pub(crate) fn try_walker_inline_user_compareop<Sym: WalkSym>(
         Some((lhs, concrete_lhs, w_class, version_tag)),
         Some((rhs, concrete_rhs, w_typ_r.as_ptr())),
         false,
-        false,
         None,
     )?
     else {

ERROR: Selected model is at capacity. Please try a different model.
ERROR: Selected model is at capacity. Please try a different model.
tokens used
17,689

@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: 79ed4a2862

ℹ️ 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 5048 to 5050
Some((lhs, concrete_lhs, w_class, version_tag)),
Some((rhs, concrete_rhs, w_typ_r.as_ptr())),
false,

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2 Badge Guard the RHS type version before widening dunder inlining

With the method-attribute gate removed here (and in the compare twin), a dunder such as A.__add__(self, other): return self.x is now inlined, but the reflected-method decision was made using the RHS subtype relationship while arg_class_guard only pins the RHS object's physical type and w_class (lines 3072-3104), not that class's version tag. If a compatible RHS class changes __bases__ after the trace becomes hot, its objects retain the same w_class, so the trace keeps calling A.__add__ even when the interpreter would now prioritize the RHS subclass's __radd__; the same issue affects rich comparisons. Pin the RHS type version/MRO dependency before admitting these newly widened bodies.

Useful? React with 👍 / 👎.

@youknowone
youknowone merged commit 66ab6b7 into main Aug 1, 2026
17 of 19 checks passed
@youknowone
youknowone deleted the perf-bridge branch August 1, 2026 15:58
youknowone added a commit that referenced this pull request Aug 2, 2026
…d closure_freevar_branch_resume

Six synthetic fixtures landed without a `.jitstats` file, so check.py's
always-on regression floor hard-failed each of them with "no committed
jit-stats baseline" on every backend it ran:

  hot_loop_exit_then_class_stmt, raise_reg_unbound_jitstress     (#890)
  inline_freevar_after_mayforce, math_log_trig_hot,
  tuple_unpack_array_backed_hot                                  (#934)
  pypy_dict_primitives_nonbinding                                (#957)

Record all three backends for each. A native-only record desynchronizes the
tracked `*.wasm.jitstats` set, because `--backend wasm` is not in the default
backend list and so would keep comparing against nothing.

`closure_freevar_branch_resume` is re-recorded deliberately. Its committed
baseline is `loops_aborted=3 loops_compiled=6 guard_failures=23380
bridges_compiled=0`; it now measures `0 / 3 / 606 / 3`, identically on both
native backends. The compiled-unit total is unchanged at 6 — three units that
were compiled as separate loops are now compiled as bridges — while the aborts
disappear and the guard failures fall 38x. Every gated field moves the
tightening way (`loops_aborted` and `guard_failures` down, `loops_compiled`
down against a fall-gate that now pins 3 instead of 6), so the re-record arms
the floor further rather than disarming it. Only `bridges_compiled` rises, and
it is in no gated group.

Deliberately NOT re-recorded, so the floor keeps reporting them:

  exception_args_virtual                loops_aborted 0 -> 3
  exception_multi_handler_warmup        loops_aborted 0 -> 23
  exception_reraise_tb_depth_jitstress  loops_aborted 0 -> 1198, loops_compiled 805 -> 305
  list_length_hint_validate             loops_aborted 14 -> 34

These raise a `JITSTATS_BADNESS_FIELDS` counter, whose healthy value is 0.
Recording them would switch the floor off for those fixtures permanently,
which is the opposite of what the counter is for. They are main's own drift
away from the baselines `#947` recorded: every one reproduces on both native
backends with byte-identical counters, so the movement is walker-level rather
than backend-level. Attribution did not converge on a single commit — the nine
commits main landed after `#947` include three whose pre-merge check.py was
fully green, and reverting `#954` reproduces the counters exactly.

`pickle_terminal_raise_resume` (#845) also has no baseline, and is not
recorded here either: it does not fail the floor, it crashes (exit 1) on both
native backends. The unpickler's read position desyncs under the low JIT
thresholds the fixture sets, dispatching `readline()` payload bytes as
opcodes. `PYRE_JIT=0` and `pypy3` both pass, so a baseline would only record
the crash.

Assisted-by: Claude
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant