Skip to content

codewriter, majit-macros: the jitcode lowering rewrites, and the goto_if_not fusion that fused nothing - #1664

Merged
youknowone merged 13 commits into
mainfrom
jitcode
Sep 3, 2026
Merged

codewriter, majit-macros: the jitcode lowering rewrites, and the goto_if_not fusion that fused nothing#1664
youknowone merged 13 commits into
mainfrom
jitcode

Conversation

@youknowone

Copy link
Copy Markdown
Owner

Ports the jitcode-lowering rewrites the reviewer note listed, in the order it
gave, and follows two of them into the codewriter where the same rewrite was
missing or inert.

What landed

Lowerer (majit-macros, the #[jit_interp] jitcode lowering)

  • A continue the lowering cannot target now refuses instead of resolving to
    Some(()) and silently vanishing.
  • A recursive_portal_call! emits promote_greens and the real reds.
  • hint_fresh_virtualizable refuses rather than being dropped.
  • _rewrite_equality, _rewrite_symmetric and the pointer comparisons:
    a Ref/Ref == used to fall through the Int check and decline the whole
    dispatch arm; x == 0 used to materialize a zero and compare against it.

Codewriter (majit-translate)

  • _rewrite_equality and _rewrite_symmetric.
  • optimize_goto_if_not now actually fuses.

The fusion gap — found, fixed, measured, and backed out

optimize_goto_if_not was fusing nothing. Counting each startpoints offset
in the shipped jit_metadata.json (3459 jitcodes) before the fix:

key uses
goto_if_not/iL (unfused) 9284
the whole goto_if_not_int_*/iiL family 0
goto_if_not_ptr_ne/rrL 8

Three gates, each closed for a different reason:

  1. goto_if_not_fusable matched int_lt / int_le / …, but
    front::mir::canonical_binop_label leaves a comparison bare and
    assembler::op_kind_to_opname_with_kinds adds the int_ prefix after
    flatten. float_lt and ptr_eq matched only because rewrite_operation
    had already renamed them.
  2. The unary arm matched int_is_true / ptr_nonzero, but the front end
    spells the condition OpKind::UnaryOp { op: "bool" } and the same
    post-flatten renaming assigns those names. It now resolves "bool" the
    way the assembler will — by the operand's register kind — and declines
    when that kind is unresolved rather than naming a key with no wired
    handler.
  3. v.concretetype() != Some(LowLevelType::Bool) rejected whatever
    survived. Kinds are published through
    FunctionGraph::set_concretetype_of_inline, which canonicalises through
    concrete_to_canonical_lltype — five outputs, none of them Bool — and
    getkind(Bool) is 'i', so a Bool-producing op is stored as Signed.
    The two exits being the false and the true arm is the fact upstream's
    lltype.Bool stands for, and it is now read off the links through
    flatten::bool_llexitcase, the reader flatten::is_bool_branch already
    uses.

The pre-existing optimize_goto_if_not_fuses_int_lt_compare fixture builds
its input in the recognizer's spelling and stamps LowLevelType::Bool by
hand, which is why it passed over gates no production graph clears.

Closing gates 2 and 3 (0dacccbfe2d) does exactly what it should to the
shipped jitcode — goto_if_not/iL 9284 -> 0, goto_if_not_int_is_true/iL
0 -> 9272, standalone int_is_true/i>i 9348 -> 80, so roughly 9272
instructions and their registers disappear — and it breaks the JIT, so
it is reverted in 09b70e0cf64. Same tree, LLBC re-extracted for both arms
because jtransform.rs and flatten.rs are in the fingerprint closure:

pyre/check.py --backend dynasm passed failed
with the fusion 478 60
reverted 535 3

It is a wrong answer, not baseline movement:
pyre/bench/synth/listcomp_hot.py returns 6000000 under PYRE_NO_JIT=1
and raises TypeError: 'int' object is not an iterator from
for j in range(n) with the JIT on. Across the rest the signature is
loops_aborted 0 -> N, loops_compiled down and
fbw_blackhole_adopted_single_frame 0 -> N.

The commit and its revert are both kept so the census, the A/B and the
elimination work stay attached to the code they describe. Ruled out as
causes, all read: liveness.rs counts every GotoIfNotOp arg as a use;
the BC_GOTO_IF_NOT_INT_IS_TRUE dispatch arm matches
opimpl_goto_if_not_int_is_true including replace=false;
bhimpl_goto_if_not_int_is_true aliases bhimpl_goto_if_not upstream too;
body_branch_targets decodes any L argcode; fbw_callee_body_replay_scan
poisons only write-shaped opnames; -live- sits immediately before the
branch in both forms. fbw_blackhole_adopted_* points at the FOR_ITER
inline window, and PYRE_FBW_INLINE_DIAG=1 PYRE_FBW_REPLAY_DIRTY_BODY=1
prints that verdict per body.

A new opcode needs four registrations

The lowerer's int_is_zero fold broke every example crate with an int-typed
while: record_unary_i: unsupported opcode IntIsTrue. Extending that match
exposed that int_is_zero/i>i had a wired blackhole handler but no opcode
byte — wire_handler matches by name against the curated (key, byte) set
and no-ops when the name is absent, and the build-time write_insn path
calls the panicking insn_byte rather than the translator's insn_byte_opt
dynamic allocator. The key now has BC_INT_IS_ZERO, a curated-set entry and
a metainterp dispatch arm.

What is deliberately not here

Four items on the note have no producer in this tree. Each is now stated
where a reader looks for the missing branch, with the pipeline decision that
makes it unreachable:

  • constant_fold_ll_issubclass — it folds calls the inliner inserts, and
    Inliner::inline_once refuses that path with CannotInline; separately,
    cutover skips the ll_issubclass / ll_issubclass_const / ll_isinstance
    bodies because flowspace_adapter::translate_op has already rewritten every
    call site into issubtype / isinstance.
  • setup_cache_gcstruct2vtableGcStructVTableCache holds no rtyper
    handle to walk instance_reprs with, and get_vtable_for_gcstruct has no
    non-test caller.
  • The typeptr, strhash / unicodehash and _greenfield branches of
    rewrite_op_getfield — the class word is folded into new_with_vtable on
    the store side and heaptracker::is_header_word keeps it out of the
    descriptor census; there is no StrHash opcode; pyjitpl has no greenfield
    mechanism.
  • vable_array_vars and its escape assert were already complete
    (check_no_vable_array, cleared per block, checked on all three routes).

instance_ptr_eq / instance_ptr_ne are emitted by the lowerer, which
records a struct_type per binding, but not by the codewriter:
Variable.concretetype is one of five coarse kinds there, Variable.annotation
is cleared after use, and the surviving projection makes every Ref look like
a SomeInstance — promoting on that would hand optimize_oois_ooisnot the
known-class branch for array pointers and lose the length-bound fold.

Verification

pyre/check.py, macOS arm64, run separately per backend:

  • dynasm — 535 passed, 3 failed
  • cranelift — 535 passed, 3 failed

The three are list_append_virtual_payload (crash),
pickle_ctor_args (jit-stats) and zero_arg_super_attr (exit 124).
They are not this branch's: main's own CI at the merge-base
55e6fb87965 fails on exactly those three, on both backends
(run 33650098582).

Also green: cargo test -p majit-translate (3499 + 24 suites),
cargo test -p majit-macros (162 + 35), and the fourteen majit/examples
crates under --features dynasm. cargo fmt --all -- --check and
scripts/check-new-line-citations.py are clean.

authored by Claude

@coderabbitai

coderabbitai Bot commented Sep 2, 2026

Copy link
Copy Markdown

Warning

Review limit reached

Next included review available in 29 minutes.

Check out review usage here.

View limit details

Limit details: You’ve used all 2 included reviews currently available.

You've used all free OSS reviews for now. Wait for the free limit to reset to keep reviewing this public repository.

Learn how review limits work.

Review configuration:

⚙️ Run configuration

Configuration used: Organization UI

Review profile: ASSERTIVE

Plan: Team

Run ID: 253b3a7e-2651-4cf1-a999-ac3be247a7b5

📥 Commits

Reviewing files that changed from the base of the PR and between e751747 and 8649518.

📒 Files selected for processing (18)
  • majit/majit-macros/src/jit_interp/jitcode_lower/dispatch.rs
  • majit/majit-macros/src/jit_interp/jitcode_lower/helpers.rs
  • majit/majit-macros/src/jit_interp/jitcode_lower/lower_control.rs
  • majit/majit-macros/src/jit_interp/jitcode_lower/lower_stmt.rs
  • majit/majit-macros/src/jit_interp/jitcode_lower/lower_vable.rs
  • majit/majit-macros/src/jit_interp/jitcode_lower/lower_value.rs
  • majit/majit-macros/src/jit_interp/jitcode_lower/lowerer.rs
  • majit/majit-macros/src/jit_interp/jitcode_lower/mod.rs
  • majit/majit-metainterp/src/blackhole.rs
  • majit/majit-metainterp/src/jitcode/assembler.rs
  • majit/majit-metainterp/src/pyjitpl/dispatch.rs
  • majit/majit-translate/src/codewriter/heaptracker.rs
  • majit/majit-translate/src/codewriter/insns.rs
  • majit/majit-translate/src/codewriter/jtransform.rs
  • pyre/bench/synth/exec_namespace_code_object_rooting_regression.py
  • pyre/bench/synth/listcomp_float_element_regression.py
  • pyre/pyre-jit-trace/src/jitcode_dispatch/arith.rs
  • pyre/pyre-jit-trace/src/jitcode_dispatch/tests.rs

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 commented Sep 2, 2026

Copy link
Copy Markdown

Codex Review Summary

This comment shows the latest Codex review activity on this pull request.

Review Status Commit Review trigger
📝 Code Review Completed 2026-09-03T06:34:47.256279Z 8649518 New commits
ℹ️ 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" or "@codex security review".

Codex reacts with 👀 while any review is running, comments if it has suggestions, and reacts with 👍 once all reviews finish with no findings.

@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: 09b70e0cf6

ℹ️ 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".

BinOp::Ne(_) => false,
_ => return None,
};
let both_instances = lhs.struct_type.is_some() && rhs.struct_type.is_some();

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 Keep raw structs out of instance pointer comparisons

Binding::struct_type records any known pointee layout, including raw ref_params, field pointees, and array elements; it does not establish RPython's _is_rclass_instance predicate. Classifying every pair of typed refs as InstancePtrEq/InstancePtrNe therefore exposes non-instance pointers to instance-only optimizer reasoning, such as folding two known but different classes as unequal, even though raw pointers with different structural views may alias. Emit the plain pointer op until the binding carries an actual rclass-instance marker.

AGENTS.md reference: AGENTS.md:L223-L226

Useful? React with 👍 / 👎.

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

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

Agreed, and fixed — the arm now emits ptr_eq / ptr_ne for every Ref/Ref pair.

Upstream's predicate is _is_rclass_instance(v) = lltype._castdepth(v.concretetype.TO, rclass.OBJECT) >= 0, i.e. the pointee descends from the instance base. struct_type answers a different question — "the lowering tracked a pointee layout" — which a raw struct, a ref_params entry, a field pointee and an array element all satisfy, exactly as you say.

The consequence is concrete rather than theoretical: optimize_oois_ooisnot takes the instance flag and folds a pair whose two operands carry known-but-different classes straight to a constant. Two structural views of one raw address are known-different classes that do alias, so that fold would be a wrong answer.

Nothing in this tree carries the real predicate — rg 'is_rclass_instance' finds only the comment this arm added — so the pair stays on the plain pointer compare until something does. That is also upstream's own fallthrough: rewrite_op_ptr_eq promotes only under the test and otherwise leaves ptr_eq alone.

It costs nothing measurable: the shipped jitcodes carry 1912 ptr_eq/rr>i and zero instance_ptr_eq/rr>i, so no body changes, and the arm still emits a pointer compare where the branch's earlier code declined the whole dispatch arm. The unit test that asserted the promotion now asserts its absence. cargo test -p majit-macros --features dynasm 197 passed, cargo test -p tl --features dynasm 31 passed.

commented by Claude

@github-actions

github-actions Bot commented Sep 2, 2026

Copy link
Copy Markdown

🤖 Codex parity review

Static analysis of this diff vs the local RPython/PyPy sources (commit 8649518).
Updated: 2026-09-03T07:07:03.210Z

Files in the reviewed diff
majit/majit-macros/src/jit_interp/jitcode_lower/dispatch.rs
majit/majit-macros/src/jit_interp/jitcode_lower/helpers.rs
majit/majit-macros/src/jit_interp/jitcode_lower/lower_control.rs
majit/majit-macros/src/jit_interp/jitcode_lower/lower_stmt.rs
majit/majit-macros/src/jit_interp/jitcode_lower/lower_vable.rs
majit/majit-macros/src/jit_interp/jitcode_lower/lower_value.rs
majit/majit-macros/src/jit_interp/jitcode_lower/lowerer.rs
majit/majit-macros/src/jit_interp/jitcode_lower/mod.rs
majit/majit-metainterp/src/blackhole.rs
majit/majit-metainterp/src/jitcode/assembler.rs
majit/majit-metainterp/src/pyjitpl/dispatch.rs
majit/majit-translate/src/codewriter/heaptracker.rs
majit/majit-translate/src/codewriter/insns.rs
majit/majit-translate/src/codewriter/jtransform.rs
pyre/bench/synth/exec_namespace_code_object_rooting_regression.py
pyre/bench/synth/listcomp_float_element_regression.py
pyre/pyre-jit-trace/src/jitcode_dispatch/arith.rs
pyre/pyre-jit-trace/src/jitcode_dispatch/tests.rs

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

    `@jit.*`, `_immutable_*`, `_attrs_`, `make_sure_not_resized`,
    `unrolling_iterable`, `rgc.*`, on the function, its helpers, or the class-
    and module-level bindings they read.
Missing any of (a)-(d), or leaving pyre matching NEITHER upstream on an
adjacent observable of the same decision, keep it in section 1 or 2 and say
which test it failed. Full rule: AGENTS.md "Spec follows CPython 3.14;
implementation follows PyPy".

Scope discipline: before writing the report, run
`git diff upstream/main --name-only -- . ':(exclude)*.jitstats'` and treat that
file list as the authoritative definition of "this patch" (when an authoritative
changed-file list is appended below, use that instead of re-deriving it). The
excluded `*.jitstats` files are `pyre/check.py`'s recorded jit-stats baselines —
generated golden data with no RPython/PyPy counterpart, so no parity finding can
cite one, and a bulk re-record of them is not a change to review. Findings under
sections 1 and 2 MUST cite our-side files from that list; a divergence in any
file NOT in the list is by definition not introduced by this patch — report
it under section 3 instead, or omit it. Verify every section-1/2 citation
against the list before finalizing the report.

---

Output format requirements (so the report can be parsed mechanically and
posted/triaged automatically). Use these four headings VERBATIM, in this
order, and nothing else at heading level 2:

## 1. Regressions to PyPy parity introduced by this patch
## 2. Other mismatches introduced by this patch
## 3. Pre-existing mismatches (already present before this patch)
## 4. Structural adaptations

Under each heading, list every finding as a bullet. For each finding cite the
concrete `our_file.rs:line ↔ rpython_or_pypy_file.py:line` pair and quote the
divergence concisely. If a section has no findings, still emit the heading
followed by a single line `None.` so all four sections are always present.
Do not modify any files; produce the report only.

Authoritative changed-file list for this patch (git diff upstream/main --name-only,
minus 0 generated `*.jitstats` baseline file(s)):
majit/majit-macros/src/jit_interp/jitcode_lower/dispatch.rs
majit/majit-macros/src/jit_interp/jitcode_lower/helpers.rs
majit/majit-macros/src/jit_interp/jitcode_lower/lower_control.rs
majit/majit-macros/src/jit_interp/jitcode_lower/lower_stmt.rs
majit/majit-macros/src/jit_interp/jitcode_lower/lower_vable.rs
majit/majit-macros/src/jit_interp/jitcode_lower/lower_value.rs
majit/majit-macros/src/jit_interp/jitcode_lower/lowerer.rs
majit/majit-macros/src/jit_interp/jitcode_lower/mod.rs
majit/majit-metainterp/src/blackhole.rs
majit/majit-metainterp/src/jitcode/assembler.rs
majit/majit-metainterp/src/pyjitpl/dispatch.rs
majit/majit-translate/src/codewriter/heaptracker.rs
majit/majit-translate/src/codewriter/insns.rs
majit/majit-translate/src/codewriter/jtransform.rs
pyre/bench/synth/exec_namespace_code_object_rooting_regression.py
pyre/bench/synth/listcomp_float_element_regression.py
pyre/pyre-jit-trace/src/jitcode_dispatch/arith.rs
pyre/pyre-jit-trace/src/jitcode_dispatch/tests.rs
warning: Codex could not find bubblewrap on PATH. Install bubblewrap with your OS package manager. See the sandbox prerequisites: https://developers.openai.com/codex/concepts/sandboxing#prerequisites. Codex will use the bundled bubblewrap in the meantime.
ERROR: You've hit your usage limit. Visit https://chatgpt.com/codex/settings/usage to purchase more credits or try again at Sep 7th, 2026 2:28 AM.
ERROR: You've hit your usage limit. Visit https://chatgpt.com/codex/settings/usage to purchase more credits or try again at Sep 7th, 2026 2:28 AM.

@codspeed-hq

codspeed-hq Bot commented Sep 2, 2026

Copy link
Copy Markdown
Contributor

Merging this PR will not alter performance

✅ 10 untouched benchmarks
⏩ 6 skipped benchmarks1


Comparing jitcode (8649518) with main (e751747)

Open in CodSpeed

Footnotes

  1. 6 benchmarks were skipped, so the baseline results were used instead. If they were deleted from the codebase, click here and archive them to remove them from the performance reports.

@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: 59ae43732b

ℹ️ 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".

"encloses a `hint_fresh_virtualizable` this lowering has no flag to carry",
stmt,
);
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.

P1 Badge Carry fresh-virtualizable state through lowering

When a #[jit_interp] body contains hint_fresh_virtualizable, this unconditional refusal makes try_inline_dispatch_arm discard the body’s JitCode and leaves that hot path permanently interpreted. Upstream instead records fresh_virtualizable in vable_flags and continues lowering subsequent field accesses, so the lowerer needs an equivalent per-binding flag rather than treating the missing carrier as a reason to abandon translation.

AGENTS.md reference: AGENTS.md:L225-L226

Useful? React with 👍 / 👎.

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

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

The end state you describe is right, but the harm this comment names does not exist yet, and implementing the carrier now would ship machinery nothing exercises. Three measurements:

1. The refusal declines zero bodies. hint_fresh_virtualizable has two production call sites in the whole tree, pyre/pyre-interpreter/src/pyframe.rs in finish_for_call_with_globals_obj and createframe_obj. Neither function is #[jit_interp] / #[jit_inline], so this lowering never sees them. majit/examples/* has none either.

2. This lowerer has no pyre users at all. rg '#\[jit_interp|#\[jit_inline' pyre/ --type rust returns comments only; every real consumer is majit/examples/* (tl, tlc, tla, tlr, tiny2/3, tinyframe, braininterp, dualtape, regex, i64env, spcount, cel). pyre's own jitcodes come from the translator codewriter, majit-translate/src/codewriter/jtransform.rs. So "leaves that hot path permanently interpreted" has no hot path to leave.

3. The translator side — the producer pyre actually uses — already has the flag. VableFlag::FreshVirtualizable in jtransform.rs, recorded and read back the way upstream's vable_flags is.

There is also a design gap the carrier would have to close first. Upstream's flag is per-SSA-variable, and is_vable_getfield reads 'fresh_virtualizable' in flags for the variable being accessed. This lowering decides the vable path by namelower_vable_field_read matches expr_matches_local_name(&field.base, vable_var) — so there is no per-variable slot to hang the flag on. The rebinding form let fresh = hint_fresh_virtualizable(state); would already get upstream's semantics for free under name matching, but the in-place form is the one with no carrier, and that is the shape the refusal exists for.

So the refusal stays, as a decline that currently declines nothing, rather than a per-binding flag with no producer to guard it. If a #[jit_interp] body ever needs the hint, that body is what should land with the carrier.

commented by Claude

@youknowone

Copy link
Copy Markdown
Owner Author

The CI failures were one missing dispatch arm — root cause, fix, and two open defects it exposed

What was red

Four jobs × three fixtures (synth/list_append_virtual_payload,
synth/zero_arg_super_attr, synth/pickle_ctor_args) plus test_heapq,
test_random and test_pickle in the CPython suite. Main's own run at the
merge-base (55e6fb87965, run 33650098582) had macOS and Windows check.py
green, so none of it was inherited.

Root cause

pyre/pyre-jit-trace/src/jitcode_dispatch/arith.rs's unop_int_record is a
port of the integer unary list pyjitpl.py generates
(int_is_true, int_is_zero, int_neg, int_invert, …). It carried every
entry of that list except int_is_zero. A key the table does not name
falls to other => Err(DispatchError::UnsupportedOpname), which stops the walk
part way through the body.

The gap was invisible until this branch, because nothing produced the opname.
_rewrite_equality (aed5be43154) folds int_eq(x, 0) into int_is_zero,
which put 1232 occurrences into the shipped jitcodes.

Fixed in 59ae43732be — one table entry, plus unit tests for both
int_is_true and int_is_zero (drive_int_unop panics on a dispatch error,
so each asserts the arm exists) and a selfcheck fixture.

Verification, same tree, only that arm differing

pre-fix post-fix
synth/list_append_virtual_payload rc=1, TypeError: 'float' object is not an iterator rc=0
synth/zero_arg_super_attr does not terminate (CI's exit 124) rc=0
synth/pickle_ctor_args jitstats loops_compiled=0 loops_compiled=2 loops_aborted=0 fbw_blackhole_adopted_*=0
new fixture 4 FAIL lines PASS (also on CPython 3.14 and under PYRE_NO_JIT=1)

The last row answers the "is the jitstats change intentional?" question: it was
a regression, and the committed pickle_ctor_args.dynasm.jitstats baseline is
reached again exactly, with no re-record. Note the gate's direction table reads
a rise in fbw_blackhole_adopted_* as an improvement; here it was the JIT
abandoning loops it used to compile.

An opname census, so this class is closed rather than patched

The codewriter runs at build time, so the shipped opname universe is in
target/release/build/pyre-jit-trace-*/out/jit_metadata.json. Inverting insns
and counting body.code[pos] for every pos in body.startpoints:

  • 3459 bodies, 152888 instructions, 114 distinct opnames — exactly the size of
    the insns table
    , so no registered opname is dead and any key the walker
    cannot dispatch is reachable in principle.
  • Every one of the 114 now has an arm except vtable_method_ptr/rd>i, one
    occurrence, in view_as_kwargs (index 3104, pos 63), between a live/ and an
    int_guard_value + residual_call_r_r. The unit test
    unsupported_opname_surfaces_typed_error picks that key as its "durable
    still-unsupported slot" on the stated ground of "Zero JitCode hits in
    production traces"
    that premise is now stale. A f(**d) probe did not
    reach it, so reachability is unproven; left as-is here rather than widened
    into this PR.

Two open defects this exposed — neither belongs to this branch

1. The WalkAbort blackhole-adopt leg answers wrong when the abort came from
an inlined sub-walk.
UnsupportedOpname is deliberately in
leaves_complete_image(), i.e. it is supposed to hand off to a blackhole that
resumes at the stopped pc. On the pre-fix binary, one env var flips it:

pre-fix binary, synth/listcomp_float_element_regression.py result
default (leg on) TypeError: 'float' object is not an iterator, rc=1
PYRE_WALKABORT_OFF=1 PASS, rc=0

synth/list_append_virtual_payload flips identically (rc=1 empty vs rc=0
197000). So the missing arm was only the trigger; the wrong answer is the
leg's. PYRE_FBW_DEBUG_ABORT=1 shows why the shape matters — the abort is
raised inside an inlined helper and propagates out through
[subwalk-propagate], so stop_pc stays the inner pc (55) while
opcode_position walks out to the outer body's (508), and
DispatchError::stop_pc's doc-comment claims that value "doubles as the resume
coordinate a blackhole conversion has to setposition to". The adopt does latch
the outer jitcode at pc=508 with boundary=true and snapshot-array/mirror
agree, so the residual suspect is the image rebuilt for the outer frame, not the
pc alone — not root-caused past that point. The leg predates this branch
(PYRE_WALKABORT_OFF is present at the merge-base; it came in with #1322).

2. PYRE_WALKABORT_OFF=1 — the documented operator kill switch — is itself
not sound on the current tree.
A 529-fixture synth/ sweep of output+exit
status, default vs kill switch, on the fixed binary:

DIFF pyre/bench/synth/selfrec_multiframe_return_value_regression.py rc=0/1
  default: PASS selfrec multiframe return value
  leg-off: FAIL checksum 368302976063 against 592304421450

Deterministic over three runs; PYRE_NO_JIT=1 and CPython both PASS, so the
kill switch is the wrong one. That fixture's own header documents the abort as
LoopBearingCalleeInlineUnsupported { blackhole_required: true } reaching a
multi-frame adopt — also in leaves_complete_image() — so with the leg off it
falls to the legacy entry replay, and that replay gets this shape wrong. Neither
path is untouched by this PR's commits; both want their own change.

The only other sweep difference on the fixed binary was loop_reentry_regression
reporting different nanosecond figures, which the leg changes by design.

(Sweep methodology note: this box has no timeout/gtimeout, so a first
timeout-based sweep ran vacuously — rc=127, empty output on both arms, zero
diffs. Redone with perl -e 'alarm shift; exec @ARGV' and only trusted after the
control — the pre-fix binary on the new fixture — reported the difference it was
known to have.)

commented by Claude

`exec` / `eval` plant `__builtins__` through `setdefault` (`pyopcode.py`) and
`space.contains_w` + `space.setitem_str` (`compiling.py`), both dispatched on
the caller's own mapping, so a dict subclass's override runs user Python while
`exec_or_eval` still holds the code object it just compiled, the namespace
arguments and a raw pointer interior to the code object's payload.

The rooting this fixture guards is `0ba64ba2627`'s on main, which publishes
each of those operands and reads them back at the site that consumes them;
this branch's own earlier version of it is dropped as redundant. The fixture
itself is new: it drives all five shapes (`setdefault`, `__contains__`,
`__setitem__`, separate locals, a precompiled code object) plus a warm loop,
and every expectation is the value CPython 3.14 and PyPy both produce.

Assisted-by: Claude
`lower_stmt_dispatch`'s `Expr::Continue` arm emitted a jump to
`dispatch_loop_label` whenever one was set, and reported success with no
emission when it was not.  Neither answer is right for a `continue` that
names a different loop:

  * inside a loop body written in an arm, `lower_loop_stmt` and
    `lower_loop_if` take the direct and `if`-branch spellings with that
    loop's own labels; a `match` arm or a nested block falls through to
    this arm and jumped to the dispatch head instead of the loop's;
  * a labelled `continue 'l` names a loop with no label here at all;
  * with no dispatch label the bare `Some(())` emitted nothing, dropping
    the transfer and letting control fall to the next statement.

The arm now takes the jump only for an unlabelled `continue` with a
dispatch label in scope, and otherwise routes to `lower_stmt_fallback`,
whose `stmt_contains_loop_control` guard refuses and records the reason.
`lower_loop_body`'s nested lowerer no longer inherits
`dispatch_loop_label`.

Four tests; three of them fail on the previous lowering, one of those
printing the `jump(__l_dispatch)` a `while` body's `match` arm emitted
beside the loop's own back-edge.

Assisted-by: Claude
`handle_recursive_call` opens with `promote_greens`, which emits a
`-live-` + `<kind>_guard_value` pair for every green before the
`recursive_call_<kind>`.  `lower_recursive_portal_call` emitted the call
alone.

The greens are what selects the callee: `exec_recursive_call` reads them
out of the caller's registers as concrete values and keys
`recursive_inline_decision` on them, so a green the trace never guarded
lets a recorded loop replay with a different key and enter the portal at
a pc it was not compiled for.

The pair was already emitted for the merge point's greens
(`emit_promote_greens`), which is upstream's other `promote_greens` call
site.  Both now go through `Lowerer::emit_live_and_guard_value`.

`cargo test -p tl --features dynasm`: 31 passed.  That includes
`jit_portal_call_in_loop_body_blocks_tracing`, whose pinned
`compiles == 0` is unchanged — the portal call still aborts above the
guard, in `decide_recursive_inline`.

Assisted-by: Claude
The lowerer answered `hint_access_directly` and
`hint_fresh_virtualizable` the same way — the call form lowered to its
argument, the macro form emitted nothing — on the ground that both are
identity functions.  That is right for only one of them.

`rewrite_op_hint` drops `access_directly` because the flag it records is
read back for `fresh_virtualizable` alone: `is_vable_getfield` /
`is_vable_setfield` consult `'fresh_virtualizable' in flags` and answer
`False`, which keeps the following field accesses off the
`getfield_vable_*` / `setfield_vable_*` path.  This lowering has no
carrier for that flag, so dropping the hint leaves every following access
on the vable path — the opposite of what the hint asks, with nothing
emitted to say so.

`hint_fresh_virtualizable` now refuses in both positions.  The refusal is
closed by a fourth guard in `lower_stmt_fallback`, beside the ones for
`return`, `break`/`continue` and a green write: without it the purity
test scores the hint inert and drops the statement, which is the same
silent outcome by another route.  The guard scans the whole statement, so
it also covers the value position, where the statement that has to refuse
is the enclosing `let`.

Four tests.  The two refusals fail on the previous lowering; the two
`access_directly` controls pass on both, which is what attributes the
change to the hint rather than to the statement shape.

Assisted-by: Claude
jtransform.py binds `_rewrite_symmetric` as `rewrite_op_<name>` for the
symmetric arithmetic and bitwise ops and for every ordered comparison, and
`_rewrite_equality` for `int_eq`/`int_ne`/`ptr_eq`/`ptr_ne`.  Neither had a
counterpart here, so an equality against zero stayed a binary compare and a
constant left-hand operand stayed on the left.

`rewrite_symmetric` runs over each operation on its way into
`rewrite_operation`, because upstream it is the whole rewrite rather than
one arm of a dispatch.  It moves a materialised source constant to the
right-hand side and mirrors `lt`/`le`/`gt`/`ge` (and the already-typed
`uint_` spellings `front::checked_arith_uint` emits).  `sub`, the divisions
and the shifts are absent, matching upstream's binding list.

Two new `rewrite_operation` arms then fold a comparison against the zero of
its register class into the unary test: `int_is_zero`/`int_is_true` over
`'i'` operands, `ptr_iszero`/`ptr_nonzero` over `'r'` operands.  The zeros
are recovered from the `Const*` operation that produced the operand, since
the front end materialises source constants as SSA Variables.  Every one of
the four opnames already had a bytecode, a blackhole handler and a
`goto_if_not_*` fusion entry in `goto_if_not_fusable`; production
`jtransform.rs` had emitted none of them.

Assisted-by: Claude
`goto_if_not_fusable` listed the RPython opnames only.  `int_lt` and its
five siblings are names no operation carries at that point:
`front::mir::canonical_binop_label` leaves a comparison bare and the `int_`
prefix comes from `assembler::op_kind_to_opname_with_kinds`, which runs
after flatten.  `float_lt` and `ptr_eq` did match because
`rewrite_operation` renames those, so the fusion fired for float and ref
comparisons and for nothing else -- every integer comparison branch emitted
a standalone compare plus a generic `goto_if_not` instead of the
`goto_if_not_int_lt/iiL` family `insns.rs` reserves.

Accept the bare spellings alongside the RPython ones, prefixing them so the
fused exitswitch names the opcode the assembler looks up.  The bare arm
requires both operands to be `Signed`: the argcodes follow the register
kinds and there is no `goto_if_not_int_eq/rrL` entry.

Assisted-by: Claude
…ares to the jitcode lowerer

`lower_binary` handled only `BindingKind::Int` operands and emitted
`record_binop_i` for every recognized `BinOp`.  Three `jtransform.py`
rewrites had no counterpart there:

* `rewrite_op_ptr_eq` / `rewrite_op_ptr_ne` — a Ref/Ref `==` or `!=` fell
  through the Int check and returned `None`, declining the whole dispatch
  arm.  Both sides carrying a `struct_type` now emit `InstancePtrEq` /
  `InstancePtrNe` (RPython's `_is_rclass_instance` promotion); one side
  without it takes `PtrEq` / `PtrNe`, because a missing `struct_type`
  records that the lowering never tracked the struct, not that the operand
  is not an instance.
* `_rewrite_symmetric` — a symmetric binop with the literal on the left
  now swaps the operands, reversing the comparison opname.
* `_rewrite_equality` — `x == 0` / `x != 0` now lower to `IntIsZero` /
  `IntIsTrue` instead of an `IntEq` / `IntNe` against a materialized zero.

`record_unary_i` accepted only `IntNeg` and `IntInvert`, so the new
`IntIsTrue` panicked in every example crate with an int-typed `while`.
Extending its match exposed that `int_is_zero/i>i` had a wired blackhole
handler but no opcode byte: `wire_handler` matches by name against the
curated `(key, byte)` set and no-ops when the name is absent, and the
build-time `write_insn` path calls the panicking `insn_byte` rather than
the translator's `insn_byte_opt` dynamic allocator.  The key now has
`BC_INT_IS_ZERO`, a curated-set entry, and a metainterp dispatch arm.

Assisted-by: Claude
…nd the vtable cache are absent

`constant_fold_ll_issubclass`, the `typeptr` / `strhash` / `_greenfield`
branches of `rewrite_op_getfield`, and the body of
`setup_cache_gcstruct2vtable` all have no counterpart here.  Each gap is a
consequence of an earlier pipeline decision rather than an oversight, and
the reachability census that establishes that is not recoverable from the
code, so it is stated where a reader looks for the missing branch.

No behaviour change.

Assisted-by: Claude
… off its links

`optimize_goto_if_not` fused nothing in production: of 3459 shipped
jitcodes, `goto_if_not/iL` accounted for 9284 branches and the whole
`goto_if_not_int_*/iiL` family for none.  Two independent gates were
closed.

`goto_if_not_fusable` looked for `int_is_true` / `ptr_nonzero`, but the
front end spells the condition `OpKind::UnaryOp { op: "bool" }` and the
`int_is_true` name is assigned by `assembler::op_kind_to_opname_with_kinds`
after flatten -- so the arm matched nothing, the same gap the bare
comparison arm covers.  It now resolves `"bool"` the way the assembler
will, by the operand's register kind, and declines when that kind is
unresolved rather than naming a key with no wired handler.

`v.concretetype() != Some(LowLevelType::Bool)` then rejected whatever
survived.  Kinds are published through `set_concretetype_of_inline`,
which canonicalises through `concrete_to_canonical_lltype`, and
`getkind(Bool)` is `'i'` -- so a Bool-producing op is stored as `Signed`
and the predicate is false for every graph that took that path.  The two
exits being the false and the true arm is the fact upstream's
`lltype.Bool` stands for; it is now read off the links through
`flatten::bool_llexitcase`, the same reader `flatten::is_bool_branch`
uses, so the two cannot drift.

The existing `optimize_goto_if_not_fuses_int_lt_compare` fixture stamps
`LowLevelType::Bool` by hand, which is why it passed over a gate no
production graph clears.

Assisted-by: Claude
…l branch off its links"

This reverts commit 0dacccb.

The fusion reached the shipped jitcode and is a large reduction — of 3459
jitcodes, `goto_if_not/iL` went 9284 -> 0, `goto_if_not_int_is_true/iL` 0 ->
9272, and the standalone `int_is_true/i>i` 9348 -> 80.  It also breaks the
JIT.

`pyre/check.py --backend dynasm`, same tree either way, LLBC re-extracted
for both: 478 passed / 60 failed with the commit, 535 passed / 3 failed
without it.  The 3 that remain are the ones the branch already had
(`list_append_virtual_payload`, `pickle_ctor_args`, `zero_arg_super_attr`).

The 57 it adds are not baseline movement.  `pyre/bench/synth/listcomp_hot.py`
returns 6000000 under `PYRE_NO_JIT=1` and raises `TypeError: 'int' object is
not an iterator` from `for j in range(n)` with the JIT on.  Across the rest
the signature is `loops_aborted 0 -> N`, `loops_compiled` down and
`fbw_blackhole_adopted_single_frame 0 -> N` — loops that used to compile now
abort into the blackhole — plus 11 crashes, 3 timeouts and one wrong output.

Cleared as causes: `liveness.rs` counts every `GotoIfNotOp` arg as a use;
the `BC_GOTO_IF_NOT_INT_IS_TRUE` dispatch arm matches
`opimpl_goto_if_not_int_is_true` exactly, down to `replace=false`;
`bhimpl_goto_if_not_int_is_true` aliases `bhimpl_goto_if_not` upstream too,
and branching on `x` is branching on `int_is_true(x)`; `body_branch_targets`
decodes any `L` argcode; `fbw_callee_body_replay_scan` poisons only
write-shaped opnames.  The `fbw_blackhole_adopted_*` counter points at the
FOR_ITER inline window; `PYRE_FBW_INLINE_DIAG=1 PYRE_FBW_REPLAY_DIRTY_BODY=1`
prints that verdict per body.

Assisted-by: Claude
`unop_int_record` in the jitcode walker carries `int_neg`, `int_invert`,
`int_same_as` and `int_is_true` but not `int_is_zero`, which `pyjitpl.py`
generates from that same unary list. An opname the table does not name
falls to `DispatchError::UnsupportedOpname`, which aborts the trace part
way through the body being walked.

`_rewrite_equality` folds `int_eq(x, 0)` into `int_is_zero`, so this branch
is the first to put the opname in a body -- 1232 occurrences in the shipped
jitcodes. Against that, `synth/list_append_virtual_payload` answered
`TypeError: 'float' object is not an iterator`, `synth/zero_arg_super_attr`
did not terminate, and `synth/pickle_ctor_args` recorded `loops_compiled=0`
where its committed sidecar records `loops_compiled=2 loops_aborted=0
fbw_blackhole_adopted_*=0`. With the arm in place those three, and
test_heapq / test_random / test_pickle, answer as before.

Add unit tests for `int_is_true` and `int_is_zero` -- `drive_int_unop`
panics on a dispatch error, so both assert the arm exists -- and a selfcheck
fixture over float-element list comprehensions, the shape that reproduces
the wrong answer.

Assisted-by: Claude
`unsupported_opname_surfaces_typed_error` justified picking
`vtable_method_ptr/rd>i` on the ground that it has "Zero JitCode hits in
production traces". Counting `body.code[pos]` over every
`pos in body.startpoints` of the shipped `jit_metadata.json` puts one
occurrence in `view_as_kwargs`, between a `live/` and an `int_guard_value`
+ `residual_call_r_r`.

The same census reports 3459 bodies, 152888 instructions and 114 distinct
opnames -- the whole `insns` table -- so no registered opname is dead, and
`vtable_method_ptr/rd>i` is the only one left with no walker arm. That is
why the test can use it, and the comment now says so instead of claiming
it is unreachable. A `f(**d)` probe did not reach it, so whether a walk
gets there is unproven either way.

Assisted-by: Claude
…compare

The Ref/Ref arm read `struct_type.is_some()` on both operands as this
layer's `_is_rclass_instance` and emitted `instance_ptr_eq` /
`instance_ptr_ne` for the pair. Upstream's predicate is
`lltype._castdepth(v.concretetype.TO, rclass.OBJECT) >= 0` -- the pointee
descends from the instance base. `struct_type` records that the lowering
tracked a pointee layout, which a raw struct, a `ref_params` entry, a field
pointee and an array element all satisfy.

The difference is observable: `optimize_oois_ooisnot` folds a pair whose two
operands carry known-but-different classes to a constant, and two structural
views of one raw address are known-different classes that do alias.

Nothing in this tree carries the real predicate, so both operators stay on
`ptr_eq` / `ptr_ne` -- upstream's own fallthrough. Shipped jitcodes carry
1912 `ptr_eq/rr>i` and no `instance_ptr_eq/rr>i`, so no body changes; the
arm still emits a pointer compare where the branch's earlier code declined
the whole dispatch arm.

`cargo test -p majit-macros --features dynasm` 197 passed,
`cargo test -p tl --features dynasm` 31 passed.

Assisted-by: Claude

@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: 8649518eb0

ℹ️ 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".

fn is_symmetric_binop(name: &str) -> bool {
matches!(
name,
"add"

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 Keep reference addition out of symmetric rewrites

When the left operand is a ConstStr and the right operand is a variable Ref, this generic "add" classification swaps them before rewrite_operation routes the two Refs to the order-sensitive jit_str_concat helper, so "prefix" + value is compiled as value + "prefix". Upstream applies _rewrite_symmetric only to typed integer and float addition; restrict this rewrite to numeric operand kinds rather than every bare add.

AGENTS.md reference: AGENTS.md:L223-L226

Useful? React with 👍 / 👎.

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