Skip to content

majit, jit-trace: the Option sum shell, a neg_inner descent, and named-struct constructor lowering - #1518

Merged
youknowone merged 6 commits into
mainfrom
manual-trace
Aug 27, 2026
Merged

majit, jit-trace: the Option sum shell, a neg_inner descent, and named-struct constructor lowering#1518
youknowone merged 6 commits into
mainfrom
manual-trace

Conversation

@youknowone

Copy link
Copy Markdown
Owner

Three slices on the descent campaign, rebased onto main (2b9371ede41).

1. Option gets the explicit sum shell Result already had

derive_program_metadata gave the non-overlapping [tag@0 | payload@8] shell
to core::result::Result only. Read from the generation's jit_metadata.json,
every registered core::option::Option<T> had __discriminant at offset 0
and T::Some.__pos_0 at offset 0 in an 8-byte parent — no room for a second
field, so the payload write clobbered the tag and the discriminant switch took
its otherwise arm.

Reach is any descent into a body holding an emit_tagged_pair_aggregate caller
(checked_neg, usize::try_from, i64::try_from(&RBigInt)). Both halves are
fixed: front::mir's explicit_sum_shell and the blackhole's result_variant
predicate in assembler.rs.

A correctness probe cannot see this. The descent aborts, the residual runs
the real interpreter body, and the answers stay right. It surfaces only as
UnsupportedOpname { key: "unreachable/" } — a generic ABORT_BRIDGE — under
PYRE_FBW_DEBUG_ABORT=1. Measured on the neg_inner split below:
consulted=10 fired=0 with +10 aborts before, 0 after.

2. Descend neg_inner for -x on an exact builtin int or long

neg splits the way invert did: its __neg__ override probe is
dont_look_inside and is the second operation the body executes, so neg_inner
is that body past it. The emitted guards are what let the caller skip the probe —
bool carries its own type, and an int/long subclass keeps the builtin
ob_type but retags w_class, so both side-exit to the residual.

Trace shape moves as intended: the hand fold's IntSubOvfx12 +
GuardNoOverflowx18 (63 ops) becomes IntNegx6 + IntNex6 + GuardTrue/False
(73 ops) — checked_neg's own decomposition.

unary_negative_int is NOT retired. Censused over all 484 fixtures: the
descent fires on 6, the fold still fires on 1 (unary_negative.py:main_int_min,
the INT_MIN promotion). Wall clock is unchanged — 0.11s over 20M iterations in
both arms. The commit message and the fold's doc were corrected to say what was
measured rather than what was expected.

3. A niladic named-struct transparent constructor lowers to New

front::mir records on CallTarget::SyntheticTransparentCtor whether the
aggregate resolved to a TypeDeclKind::Struct; jtransform rewrites it to
OpKind::New when it takes no arguments and bh_size_spec_from_callcontrol
answers with a spec that lists fields.

The field requirement is the whole correctness argument. A field-less spec is
materialized by the bare make_size_descr_with_type_and_vtable mint instead of
simple_descr_group_from_bh_size, so it never enters gc_cache._cache_size,
register_unresolved_struct_tids never walks it, and its allocation tid stays
the structural hash truncated to u32 — which the walker rejects as
UnregisteredNewGcType, after the descent has already run. Registering it
instead is not the alternative: an empty field list carries no ref offsets, so
the shape a registration would publish says "no pointers" for a struct whose
size says otherwise. The payload-less sum variant already declines for this
reason.

Found by locating the reported type_id 31113171 as the unique truncation match
among the image's 5639 descrs — index 70, size=16 is_gc_managed=true all_fielddescrs=[]. Only 12 such descrs exist image-wide, and frozen is
unchanged with or without the requirement, which is the direct evidence that
field-less owners were never registered in the first place.

Measured on bench/synth/unary_negative.py: 5 UnregisteredNewGcType aborts
without the field requirement, 0 with it
; Traces aborted 0 in both the
descent-on and descent-off arms.

TypeRegistry::MAX_TYPES goes 1024 -> 4096 (PYRE_GC_TYPE_COUNT=1 reports 1046
frozen; header::TYPE_ID_BITS is 32, so the constant is pre-allocated capacity,
not an encoding limit).

This does not open the neg wall. The remaining decline resolves to
<synthetic-transparent-ctor pyre_object::pyobject::PyObject>, and PyObject
has zero registered field rows in the image — it is one of the 12 the gate
declines. Registering those fields is a separate slice.

Verification

  • pyre/check.py --backend dynasm: ALL PASSED 497/497, rc=0
  • extra_tests/run.py --dynasm-only --gated-only (the set CI blocks on): 105/105
  • extra_tests/run.py --dynasm-only (full): 353/358. The 5 are unrelated to this
    branch — builtin_slice and stdlib_warnings fail under CPython too,
    stdlib_sqlite has no _sqlite3 built, stdlib_sys hits a getsizeof stub,
    stdlib_posix hits the known descriptor TypeError. Judged by cause, not by
    an A/B against a pre-change binary.
  • cargo test --release -p majit-translate --lib: 3293 passed, 0 failed. These
    had not been compiling — 16 SyntheticTransparentCtor struct literals in tests
    predated the new field; they now call CallTarget::synthetic_transparent_ctor,
    which is how the front end spells the same empty-owner_path shape.
  • cargo test --release -p majit-translate --test test_make_jitcodes_produces_graph_keyed_output: 2 passed
  • cargo fmt --all -- --check and scripts/check-majit-boundary.py: clean

Notes on the rebase

Nine of the sixteen replayed commits were dropped as already upstream (#1507's
squash merge). Three add/add conflicts all resolved to main's side, which was
a strict superset in each case. One redundant commit was dropped outright:
main had independently landed a byte-identical
unop_wrapper_residual_path, and because the two insertions sat at different
positions git merged both copies in with no conflict — a clean hunk, not a
marked one.

The rebase also silently invalidated every structural count in design.md §3.8;
those were re-derived with the recipes the section itself quotes (70 definitions
/ 68 in specialize.rs, 9,992 body lines inside 17,298, 77 rows, corpus 487).
The group table's n column still sums to 73 and is flagged as not re-derived
rather than having the difference apportioned across its groups.

`derive_program_metadata` handed the non-overlapping `[tag@0 | payload@8]`
shell to `core::result::Result` only; `core::option::Option` took the
`enum_layout` arm and borrowed Rust's native layout. In the built image that
registered `Option<i64>.__discriminant` and `Option<i64>::Some.__pos_0` both
at offset 0 of an 8-byte parent, so the payload write clobbered the tag.

A descent into any body holding a `checked_neg` -- which
`emit_tagged_pair_aggregate` lowers to `__discriminant = ne(v, i64::MIN)`
plus `__pos_0 = neg(v)` -- then read `-v` as the tag and left the match on
its `otherwise` arm, surfacing as
`UnsupportedOpname { key: "unreachable/" }` and a generic trace abort.

`codewriter/assembler.rs` carries the blackhole half of the same shell and
gated it on the two `Result` variants by name; `Option::Some` joins them,
so both sides place the payload past the inherited tag word.

After the change the same two descrs read `__discriminant off=0` and
`Some.__pos_0 off=8` in a 16-byte parent.

Assisted-by: Claude
Splits `descroperation::neg` at its `__neg__` override probe, the way
`invert` was split at its probe and bool slot, and adds
`try_walker_orthodox_unary_negative` on the `invert` model: admit an exact
builtin `int` or `long`, guard the class and the exact `w_class`, then
sub-walk `neg_inner` instead of emitting the negation by hand.

The probe is what a descent cannot enter -- its
`needs_numeric_unaryop_dispatch` is `dont_look_inside` and is the second
operation the body executes. `neg` has no bool slot, so the split leaves
only the probe behind and `neg_inner` keeps the bool operand.

Dispatched ahead of `unary_negative_int` rather than replacing it, so that
fold's `consulted` count reads whether the descent took the site. On
`unary_int_loop_carried` the descent is `consulted=2 fired=2` and the fold
`consulted=0`; the emitted ops move from the fold's `IntSubOvf` +
`GuardNoOverflow` to the body's own `IntNeg` + `IntNe` + branch, which is
`checked_neg`'s decomposition. Trace length 63 -> 73 ops, wall clock
unchanged at 0.11s over 20M iterations. `-0`, `+/-(1 << 62)` and the
`INT_MIN` promotion agree with `PYRE_NO_JIT=1` and cpython 3.14.

Requires the preceding Option shell commit: without it every attempt
aborted a trace instead of firing.

Assisted-by: Claude
`spec_folds!` now holds 75 rows and closes at `diag.rs:419`; `specialize.rs`
is 17,062 lines. The `try_walker_specialize_*` definition count is unchanged
at 69 -- `unary_negative_descent`'s function is a `try_walker_orthodox_*`,
not a fold.

Both files described every row as a hand-written specialization. Three are
not: `subscr_tuple_descent`, `unary_invert_descent` and
`unary_negative_descent` sub-walk `w_tuple_getitem`, `invert_inner` and
`neg_inner`, and carry a row only so `PYRE_FBW_NO_SPECIALIZE` can suppress
and A/B them the way it does a fold.

Each figure re-derived with the command §3.8 publishes beside it: 75, 69,
484, 17,062.

Assisted-by: Claude
…cted to

The doc claimed the descended body's `checked_neg` covers the `INT_MIN`
promotion without the fold's `guard_value` pin. A census over all 484
synthetic fixtures says otherwise: the descent fires on six fixtures, and
`unary_negative_int` still fires on `unary_negative.py`'s `main_int_min`.

`PYRE_FBW_DEBUG_ABORT=1` puts the decline at pc=18 with symbolic
8853347690726780139, which `symbolic_fnaddr_paths` resolves to
`<synthetic-transparent-ctor pyre_object::pyobject::PyObject>` -- the
promotion arm's `w_long_new_fresh_rbigint_handle`. It is an
`OrthodoxSubWalkTraceUnsupported`, so the cut-back runs and the fold serves
the operand.

So the fold is not retired. The descent's coverage is every non-promoting
exact `int` plus the exact `long`, which reached no fold before.

Assisted-by: Claude
`front::mir` records on `CallTarget::SyntheticTransparentCtor` whether the
aggregate constructor resolved to a `TypeDeclKind::Struct`, and `jtransform`
rewrites such a constructor to `OpKind::New` when it takes no arguments and
`bh_size_spec_from_callcontrol` answers with a spec that lists fields.

A spec with no field rows is excluded.  It is materialized by the bare
`make_size_descr_with_type_and_vtable` mint rather than
`simple_descr_group_from_bh_size`, so it never enters `gc_cache._cache_size`,
`register_unresolved_struct_tids` does not walk it, and its allocation tid
stays the structural hash truncated to u32 -- which the walker rejects as
`UnregisteredNewGcType`.  Measured on `bench/synth/unary_negative.py`: 5 such
aborts without the field requirement, 0 with it.

306 transparent constructors are left unlowered, counted as the
`synthetic-transparent-ctor` entries of the generation's `jit_metadata.json`
`symbolic_fnaddr_paths`.  The arm cleared 177 of them when that A/B was run
(480 -> 303); those two numbers come from the tree before the rebase onto
main and the comparison has not been re-run since.

`PYRE_GC_TYPE_COUNT=1` reports 1046 frozen GC types, so
`TypeRegistry::MAX_TYPES` goes 1024 -> 4096.

Sixteen `SyntheticTransparentCtor` struct literals in tests now call
`CallTarget::synthetic_transparent_ctor`, which is how the front end spells
the same empty-`owner_path` shape.

Assisted-by: Claude
Rebasing onto main moved every structural number in the section, so re-run
the four recipes it quotes: 70 `try_walker_specialize_*` definitions (68 in
`specialize.rs`), 9,992 body lines inside that file's 17,298, and 77
`SPEC_FOLD_ROWS` rows -- `spec_folds!` now closes at `diag.rs:421`, so the
quoted `sed` range and the per-file `rg -c` example move with it.  The
corpus glob answers 487 non-recursively and 533 recursively, the same
46-file over-count as before.

The group table's `n` column sums to 73 and is not re-derived here; say so
next to it rather than apportioning the difference across the groups.

Assisted-by: Claude
@coderabbitai

coderabbitai Bot commented Aug 27, 2026

Copy link
Copy Markdown

Warning

Review limit reached

Next included review available in 5 minutes.

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: Pro Plus

Run ID: 15ba3681-cdaf-49cc-bbf1-5cb956e5ba29

📥 Commits

Reviewing files that changed from the base of the PR and between 2b9371e and 42a87f4.

📒 Files selected for processing (17)
  • majit/majit-gc/src/trace.rs
  • majit/majit-translate/src/codewriter/assembler.rs
  • majit/majit-translate/src/codewriter/format.rs
  • majit/majit-translate/src/codewriter/jtransform.rs
  • majit/majit-translate/src/front/mir.rs
  • majit/majit-translate/src/front/option_ctor.rs
  • majit/majit-translate/src/front/result_exc.rs
  • majit/majit-translate/src/model.rs
  • majit/majit-translate/src/translator/rtyper/flowspace_adapter.rs
  • majit/majit-translate/src/translator/rtyper/unit_variant_fold.rs
  • pyre/design.md
  • pyre/gate-triage.md
  • pyre/pyre-interpreter/src/objspace/descroperation.rs
  • pyre/pyre-jit-trace/src/jitcode_dispatch/diag.rs
  • pyre/pyre-jit-trace/src/jitcode_dispatch/residual_call.rs
  • pyre/pyre-jit-trace/src/jitcode_dispatch/specialize.rs
  • pyre/pyre-jit-trace/src/jitcode_runtime.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 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: 42a87f4487

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

// payload row, so it needs no reconstruction.
let result_variant = layout_owner.ends_with("result::Result::Ok")
|| layout_owner.ends_with("result::Result::Err")
|| layout_owner.ends_with("option::Option::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.

P2 Badge Anchor the Option variant check to the core module

When a non-core enum is declared under a path ending in option::Option::Some (for example, mycrate::option::Option::Some), this suffix check classifies it as the synthetic core Option shell even though front::mir only assigns that shell to core::option::Option. Since bh_size_spec_from_callcontrol is also used by field descriptor lookup, the user enum's payload is consequently described at offset 8 with an injected discriminant instead of using its registered native layout, so reads and writes can access the wrong bytes. Use the same fully anchored core-path predicate as option_variant_ctor_tag rather than an ends_with match.

Useful? React with 👍 / 👎.

@github-actions

Copy link
Copy Markdown

🤖 Codex parity review

Static analysis of this diff vs the local RPython/PyPy sources (commit 42a87f4).
Updated: 2026-08-27T02:39:21.416Z

Files in the reviewed diff
majit/majit-gc/src/trace.rs
majit/majit-translate/src/codewriter/assembler.rs
majit/majit-translate/src/codewriter/format.rs
majit/majit-translate/src/codewriter/jtransform.rs
majit/majit-translate/src/front/mir.rs
majit/majit-translate/src/front/option_ctor.rs
majit/majit-translate/src/front/result_exc.rs
majit/majit-translate/src/model.rs
majit/majit-translate/src/translator/rtyper/flowspace_adapter.rs
majit/majit-translate/src/translator/rtyper/unit_variant_fold.rs
pyre/design.md
pyre/gate-triage.md
pyre/pyre-interpreter/src/objspace/descroperation.rs
pyre/pyre-jit-trace/src/jitcode_dispatch/diag.rs
pyre/pyre-jit-trace/src/jitcode_dispatch/residual_call.rs
pyre/pyre-jit-trace/src/jitcode_dispatch/specialize.rs
pyre/pyre-jit-trace/src/jitcode_runtime.rs

1. Regressions to PyPy parity introduced by this patch

None.

2. Other mismatches introduced by this patch

None.

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

None.

4. Structural adaptations

  • majit/majit-translate/src/front/mir.rs:1682 ↔ rpython/jit/codewriter/jtransform.py:1012 — uses an explicit [tag@0 | payload@8] shell for Rust Result/Option, rather than Rust’s native/niche enum layout. This preserves the low-level allocation-and-field representation that upstream obtains by lowering malloc to new.

  • majit/majit-translate/src/front/mir.rs:5212 ↔ rpython/jit/codewriter/jtransform.py:1012 — distinguishes resolved Rust Struct aggregate constructors from enum variants and lowers only the former to New; this is the Rust/Charon equivalent of upstream’s malloc(GcStruct)new.

  • pyre/pyre-interpreter/src/objspace/descroperation.rs:5523 ↔ pypy/objspace/std/intobject.py:628 — splits neg after the override dispatch so the generated tracer can descend into the exact-builtin numeric path. The preserved inner behavior matches PyPy’s machine-int negation and overflow promotion.

  • pyre/pyre-jit-trace/src/jitcode_dispatch/specialize.rs:7620 ↔ pypy/objspace/std/longobject.py:272 — guarded exact-int/long sub-walk replaces an opaque Rust residual call; the guards retain subclass override behavior, while the descended long path performs PyPy’s rbigint.neg.

  • majit/majit-gc/src/trace.rs:327 ↔ rpython/memory/gctypelayout.py:326 — raises the Rust registry’s fixed preallocation bound from 1024 to 4096. Upstream uses a translator-built llgroup; the bounded, stable Vec capacity is a Rust runtime-storage adaptation, not an observable semantic divergence.

@youknowone
youknowone merged commit 218963f into main Aug 27, 2026
31 of 33 checks passed
@youknowone
youknowone deleted the manual-trace branch August 27, 2026 07:07
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