Skip to content

majit: resolve boxing clusters across phi links - #1185

Merged
youknowone merged 3 commits into
mainfrom
str
Aug 13, 2026
Merged

majit: resolve boxing clusters across phi links#1185
youknowone merged 3 commits into
mainfrom
str

Conversation

@youknowone

@youknowone youknowone commented Aug 12, 2026

Copy link
Copy Markdown
Owner

fuse_boxing_alloc decides whether a boxing cluster can become a NewWithVtable by looking up four things — the aggregate's owner, its payload stores, its ob_header store, and the header's ob_type / w_class. Every one of those lookups matches on an exact variable. But resolve_addr, in the same function, follows Block.inputargs links, and its doc comment explains exactly why it has to: "each call ends a block, so the header value crosses the boundary as a link arg while its producer stays in the predecessor."

A field store is recorded against the producing variable. So any value that crosses a block boundary — one preceding call is enough — becomes a phi at the use site, and every exact-match lookup silently finds nothing. The cluster then declines, however constant its type pointer is. The comment describing the hazard sits two functions above the code that ignores it.

This turned out to bite at two levels.

The header crosses (commit 1)

A per-reason census of the build-time lowering put all nine declining sites on one verdict — and not the one the code reported. resolve_vtable_addr collapsed "no ob_type store" and "stored value isn't constant" into a single else via .and_then, so the reason read as unresolvable constant when nothing was being resolved at all:

decline:obtype-no-store  agg-fields=byte_len,hash,index_storage,len,ob_header,value,w_slots
                         header-fields=-   header-from=phi
    pyre_object::unicodeobject::w_str_new

header-from=phi with header-fields=-, uniform across 9/9.

store_roots resolves a phi back to the op-result variables it can be. The field lookup then runs per root, and every root must agree on the vtable — the same agreement rule resolve_addr already applies to a merged pointer. A header that is one of two types still declines.

Fused clusters went 6 → 14. Newly fusing: w_str_new, w_str_from_wtf8, w_str_from_wtf8_immortal, w_bytes_from_bytes, w_bytearray_alloc, w_set_new, w_frozenset_new, w_dict_proxy_new.

The ninth site, w_long_from_raw, now reports wclass-is-a-subclass, which is correct: it pairs ob_type: &LONG_TYPE with w_class: get_instantiate(&INT_TYPE), so the deliberate subclass guard refuses it. The old code declined it for the wrong reason and never reached that check.

The aggregate crosses (commit 3)

Fixing the header exposed the same defect one level up. Investigating what still rejected showed the residual sites were not the interesting ones I expected — three of five were w_float_new, the canonical float constructor the pass's own showcase test is written around, and it had never fused in production.

w_float_new builds the whole W_FloatObject before gc_interp::enabled() and try_gc_alloc_stable_raw, each of which ends a block, then stores it either through core::ptr::write (the GC arm) or lltype::malloc_typed. So the aggregate arrives as a phi, all three %agg-keyed lookups miss, and the verdict is no-ctor — 21 census sites. w_list_getitem and w_tuple_getitem_known were reaching the legacy walker through it.

Same remedy: resolve %agg to its ctor root before those lookups, declining when the roots disagree, since a merge of two aggregates has no single owner or payload set. Taking the root also keeps Site.aggregate naming the variable a core::ptr::write arm stores, which is what sink_fused_boxing_aggregates_at_raw_writes matches on.

What still rejects

Graphs rejected with survived fuse_boxing_alloc unfused went from 24 (steady at 24–26 over several days before this work) to 9, and all nine are now one cause: w_long_from_raw, directly and through unary_invert_value / unary_negative_value. That is the by-design subclass refusal above, so it is not a missed fusion — closing it would mean teaching the rewrite to emit an explicit w_class store rather than letting the vtable stand for it, which is a design change, not a fix.

The count rising from 5 to 9 midway is first-blocker unmasking, not a regression: with the float constructor fused, graphs that used to stop there now get far enough to reach the long refusal.

Tests

  • fuse_boxing_alloc_resolves_a_header_that_crosses_a_link — header crossing one link, two links, a merge naming one type (fuses), a merge naming two types (declines).
  • fuse_boxing_alloc_resolves_an_aggregate_that_crosses_a_link — the w_float_new shape: shared aggregate, core::ptr::write arm and malloc_typed arm. It also asserts the GC arm still stores a fully constructed object after the rewrite.

Both are mutation-checked: reverting the corresponding resolution fails that test and no other. The pre-existing ..._only_when_the_links_agree test was blind to both, because its cluster_in helper builds the header ctor, the outer ctor and the malloc_typed in one block, so only the ob_type/w_class values ever crossed a boundary.

Commit 2

The reject's comment and TyperError text enumerated the fusing owners as W_Float/W_Int/W_Complex/W_Long. That list was already wrong before this change — it named W_LongObject, which declines, and the pass is not numeric-only (fuse_boxing_alloc_lowers_non_numeric_struct_generically) — and this change widens the set further. It now states the condition the pass applies instead of listing types. The substring survived fuse_boxing_alloc unfused is unchanged; cutover.rs matches on it at two sites.

Verification

  • cargo test -p majit-translate — 3187 lib tests plus the integration binaries, all passing.
  • python3 ./pyre/check.pycranelift 424/424, wasm 417/417.
  • LLBC re-extracted for all five crates before gating.

One pre-existing failure, checked rather than assumed

The full gate reports test.test_pickle PASS -> FAIL on dynasm (test_deep_nested_struct_frozenset, 3 errors). origin/main fails identically under the same command, so it is not from this change.

Getting that right took a second attempt, and the first answer was wrong in a way worth recording. w_set_new / w_frozenset_new are among the constructors this PR newly fuses, so a frozenset failure looked like an obvious consequence, and there was a plausible mechanism to hand. But the two arms were not comparable: the base had been measured with --no-synthetic while the failing runs were the full gate, which runs 425 synthetic benchmarks first. Re-running the base with the same command reproduced the failure exactly.

The trigger is that preceding load, not the code: the module passes standalone (run.py --filter test_pickle, at both --jobs 1 and --jobs 17) and under check.py --no-synthetic, on both arms.

🤖 Generated with Claude Code

@coderabbitai

coderabbitai Bot commented Aug 12, 2026

Copy link
Copy Markdown

Review Change Stack

Warning

Review limit reached

@youknowone, you've reached your PR review limit, so we couldn't start this review.

Next review available in: 7 minutes

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

How can I continue?

After more reviews become available, a review can be triggered using the @coderabbitai review command as a PR comment. Alternatively, push new commits to this PR.

To avoid repeated limits, reduce automatic review volume by pausing incremental auto-reviews earlier, using label-based review opt-in, excluding WIP or generated PR titles, or requesting reviews manually when the PR is ready. If your team needs uninterrupted high-volume reviews, an organization admin can enable usage-based reviews.

How do review limits work?

CodeRabbit enforces per-developer PR review limits for each organization. Most developers receive the normal plan review availability.

For paid Pro and Pro+ PR reviews, CodeRabbit uses adaptive limits for sustained high-volume activity. When a developer's recent PR review activity reaches the 95th percentile or higher among CodeRabbit users, additional reviews become available more gradually as earlier reviews age out of the rolling window.

Please refer docs for additional details.

Review details
⚙️ Run configuration

Configuration used: Organization UI

Review profile: ASSERTIVE

Plan: Pro Plus

Run ID: 04e24ef7-da2d-4eb9-90ce-011cdb7df760

📥 Commits

Reviewing files that changed from the base of the PR and between f524900 and b1e8c11.

📒 Files selected for processing (1)
  • majit/majit-translate/src/model.rs

Walkthrough

Changes

Boxing Header Fusion

Layer / File(s) Summary
Header resolution and vtable validation
majit/majit-translate/src/model.rs, majit/majit-translate/src/translator/rtyper/flowspace_adapter.rs
fuse_boxing_alloc follows block-input and predecessor chains, then requires consistent ob_type and w_class identities. Failure diagnostics describe unresolved, non-constant, mismatched, and subclass cases.
Cross-block fusion regression coverage
majit/majit-translate/src/model.rs
Tests cover one-link, multi-link, agreeing-merge, and conflicting-merge header paths. Only resolvable and agreeing paths fuse.

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

Sequence Diagram(s)

sequenceDiagram
  participant CFGBlocks
  participant store_roots
  participant fuse_boxing_alloc
  participant malloc_typed
  CFGBlocks->>store_roots: pass block-input and predecessor header values
  store_roots->>fuse_boxing_alloc: return resolved operation roots
  fuse_boxing_alloc->>fuse_boxing_alloc: compare ob_type and w_class identities
  fuse_boxing_alloc->>malloc_typed: preserve malloc_typed when roots do not resolve or agree
Loading

Possibly related PRs

Poem

A rabbit hops through blocks of code,
Tracing headers on their road.
Matching vtables, roots align,
Conflicts leave malloc_typed behind.
“Fuse,” I cheer, “when types agree!” 🐇

🚥 Pre-merge checks | ✅ 5
✅ Passed checks (5 passed)
Check name Status Explanation
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.
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title clearly and concisely describes the main change: resolving boxing clusters across SSA phi links.
✨ 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 str

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: f5249009d5

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

}
}
}
!out.is_empty()

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 Reject phi arms that add no store root

When a header phi merges a locally constructed header with a function-entry input (or another inputarg block with no incoming edge) and the local predecessor is visited first, the recursive call for the entry input traverses zero links but returns true because the shared out already contains the earlier root. resolve_vtable_addr then validates only the local arm and replaces malloc_typed with its vtable even though the other arm can carry an arbitrary header, miscompiling ob_type/w_class; require each invocation to contribute at least one root, or collect roots independently per arm.

AGENTS.md reference: AGENTS.md:L16-L18

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: 1

🤖 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 `@majit/majit-translate/src/model.rs`:
- Around line 2992-2993: Remove the hard-coded eight-hop traversal limit used by
store_roots and its caller. Update the header-resolution traversal around
store_roots and the budget setup near chain(8) to derive a cycle-safe bound from
the graph size or track visited (block, inputarg slot) states, allowing valid
chains of eight or more links while terminating cycles. Add a chain(8)
regression case covering successful fusion.
🪄 Autofix

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: 790b9203-9f66-4a02-9792-ef1032983887

📥 Commits

Reviewing files that changed from the base of the PR and between d108e60 and f524900.

📒 Files selected for processing (2)
  • majit/majit-translate/src/model.rs
  • majit/majit-translate/src/translator/rtyper/flowspace_adapter.rs

Comment on lines +2992 to +2993
if depth == 0 {
return 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.

🚀 Performance & Scalability | 🟠 Major | ⚡ Quick win

Remove the fixed eight-hop header-resolution limit.

Line 3144 sets the traversal budget to 8. store_roots returns false at Line 2992 before it reaches a producer after eight links. A valid boxing header with eight or more preceding call boundaries remains unfused. It then reaches the fail-closed malloc_typed path in flowspace_adapter.rs and falls back to the legacy walker.

Derive the cycle-safe budget from the graph size, or track visited (block, inputarg slot) states. Add a chain(8) regression case.

Proposed change
-        if !store_roots(graph, &header, 8, &mut roots) {
+        let max_phi_hops = graph.blocks.len().min(u32::MAX as usize) as u32;
+        if !store_roots(
+            graph,
+            &header,
+            max_phi_hops.saturating_add(1),
+            &mut roots,
+        ) {
             return 0;
         }
             ("header crosses two links", &|| chain(2), 1),
+            ("header crosses eight links", &|| chain(8), 1),

Also applies to: 3143-3145, 7495-7507

🤖 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 `@majit/majit-translate/src/model.rs` around lines 2992 - 2993, Remove the
hard-coded eight-hop traversal limit used by store_roots and its caller. Update
the header-resolution traversal around store_roots and the budget setup near
chain(8) to derive a cycle-safe bound from the graph size or track visited
(block, inputarg slot) states, allowing valid chains of eight or more links
while terminating cycles. Add a chain(8) regression case covering successful
fusion.

The comment and the TyperError text enumerated the fusing owners as
W_Float/W_Int/W_Complex/W_Long. That list named W_LongObject, which
declines -- its header pairs &LONG_TYPE with get_instantiate(&INT_TYPE),
so the subclass guard refuses it -- and fuse_boxing_alloc is not
numeric-only (see fuse_boxing_alloc_lowers_non_numeric_struct_generically).
State the condition the pass applies instead of listing types.

The substring `survived fuse_boxing_alloc unfused` is unchanged;
cutover.rs matches on it at two sites.

Assisted-by: Claude
@github-actions

github-actions Bot commented Aug 12, 2026

Copy link
Copy Markdown

🤖 Codex parity review

Static analysis of this diff vs the local RPython/PyPy sources (commit b1e8c11).
Updated: 2026-08-12T23:57:06.534Z

Files in the reviewed diff
majit/majit-translate/src/model.rs
majit/majit-translate/src/translator/rtyper/flowspace_adapter.rs

1. Regressions to PyPy parity introduced by this patch

  • majit/majit-translate/src/model.rs:3206 ↔ rpython/jit/codewriter/jtransform.py:1043 — the new phi-rooting permits fusion after a common aggregate is passed through a branch/join, but payload recovery at model.rs:3245 takes the first graph-wide FieldWrite. If either arm writes a different value to a field before the join, the emitted NewWithVtable gets one arbitrarily ordered value and drops the path-dependent write. PyPy rewrites the allocation only and retains the path-specific field operations.

2. Other mismatches introduced by this patch

None.

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

  • majit/majit-translate/src/translator/rtyper/flowspace_adapter.rs:2049 ↔ rpython/jit/codewriter/jtransform.py:1012 — unfused lltype::malloc_typed[_managed] is deliberately rejected and sent to the legacy walker, whereas PyPy’s general rewrite_op_malloc lowers every supported GC malloc to new, new_with_vtable, or alloc_with_del. This was pre-existing behavior; this patch only updates its explanatory comment.

4. Structural adaptations

  • majit/majit-translate/src/model.rs:3139 ↔ rpython/jit/codewriter/jtransform.py:1023 — PyPy obtains the allocation type and vtable directly from the typed malloc operation (STRUCT and heaptracker.get_vtable_for_gcstruct). Rust’s malloc_typed(value) instead carries a constructed value, so deriving the vtable from ob_header.ob_type and confirming w_class is a Rust/source-translation adaptation rather than a literal line-for-line port.

  • majit/majit-translate/src/model.rs:2986 ↔ rpython/translator/backendopt/malloc.py:169 — the explicit backward walk over Block.inputargs/Link.args is the Rust graph representation’s adaptation for recovering a constructed aggregate across CFG boundaries. PyPy’s lifetime analysis unions link endpoints directly, so it has no equivalent root-recovery helper.

@youknowone youknowone changed the title majit: resolve boxing headers across phi links majit: resolve boxing clusters across phi links Aug 12, 2026

@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: b96132d5c0

ℹ️ 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 +3203 to +3205
// declines. Using the root also keeps `Site.aggregate` naming the
// variable a `core::ptr::write` arm stores, which is what
// `sink_fused_boxing_aggregates_at_raw_writes` matches on.

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 Follow raw-write phis when sinking fused aggregates

When the aggregate crosses the branch links—the production shape this change newly supports—the raw-write arm consumes its block inputarg rather than the producer root stored in Site.aggregate (the added fixture itself writes gc_args[0]). sink_fused_boxing_aggregates_at_raw_writes only checks args[1] == aggregate, so it misses that write and leaves the aggregate/header construction in the dominator, causing it to remain on the ordinary JIT allocation path instead of performing the documented RPython-equivalent escape-sensitive motion. Resolve the write operand through the links, or retain the phi aliases for matching.

AGENTS.md reference: AGENTS.md:L231-L233

Useful? React with 👍 / 👎.

The aggregate reaches `malloc_typed` as a `Block.inputargs` phi whenever
the constructor builds the struct in a dominator and the blocks between
end in calls: `w_float_new` builds the `W_FloatObject` before
`gc_interp::enabled()` and `try_gc_alloc_stable_raw`. Owner, payload
stores and `ob_header` are all looked up by exact `%agg`, so the cluster
declined for want of a ctor -- the canonical float constructor never
fused, and `w_list_getitem` / `w_tuple_getitem_known` reached the legacy
walker through it.

Resolve `%agg` to its ctor root before those lookups, and decline when
the roots disagree: a merge of two aggregates has no single owner or
payload set. Taking the root also keeps `Site.aggregate` naming the
variable a `core::ptr::write` arm stores, which is what
`sink_fused_boxing_aggregates_at_raw_writes` matches on.

Assisted-by: Claude
@youknowone
youknowone merged commit 64c0370 into main Aug 13, 2026
16 of 17 checks passed
@youknowone
youknowone deleted the str branch August 13, 2026 00:57
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