Skip to content

codewriter parity, and the virtualizable array escape: the entry framestate was never copied - #1087

Merged
youknowone merged 14 commits into
mainfrom
jitcode
Aug 9, 2026
Merged

codewriter parity, and the virtualizable array escape: the entry framestate was never copied#1087
youknowone merged 14 commits into
mainfrom
jitcode

Conversation

@youknowone

@youknowone youknowone commented Aug 6, 2026

Copy link
Copy Markdown
Owner

Next round of the codewriter parity audit against rpython/jit/codewriter/.
Two gaps found and fixed; the rest of the round is recorded below as negatives
so the same ground is not re-walked.

1. get_fielddescr_index_in() double-counts before a nested struct

get_fielddescr_index_in() hands the recursion its own cur_index, so the
"not found" value the recursion returns already counts the fields walked
before the nested struct. Adding it back with += counted them twice.

Measured on Outer { a, inner: Inner{x,y}, b }:

field all_fielddescrs_into get_fielddescr_index_in
a 0 0
inner.x 1 1
inner.y 2 2
b 3 4

Every field after a non-leading nested struct is one too high per preceding
field of the enclosing struct.

Both pre-existing tests (heaptracker.rs, call.rs) put the nested struct
first, so the recursion always started at cur_index == 0 and the bug was
invisible. The new test places it after a field.

Why it matters

field_pos_in() states the invariant it depends on: all_fielddescrs(S)[i] .get_index() == i, which holds upstream because one walker answers both
questions. pyre has three numberers, and this was the only one that
disagreed — all_fielddescrs_into() and the assembler's
bh_all_field_specs_for_struct_into() both count flat into a shared list.

Blast radius is bounded: descr.rs derive_index_in_parent() re-derives the
index from the parent that will actually be indexed and overrides the
caller's number. The stale value survives only where that returns None
no parent descr (FIELD_PARENT_ABSENT), an empty parent all_fielddescrs()
(FIELD_PARENT_EMPTY), or an unresolved field name
(FIELD_INDEX_UNRESOLVED). descr.rs describes what happens then: a
too-high index "either runs off the end or silently names a DIFFERENT field
and emits the store against it".

No real pyre struct with a non-leading inlined sub-struct has been exhibited,
so the miscompile is latent; the wrong number on a load-bearing path is not.

This is the same defect as heaptracker.py:130 upstream, reported separately
as pypy/pypy#5537 — pyre inherited it verbatim in the port. The upstream red
symptom recorded there ("index 4 for a field that all_fielddescrs() puts at
3") reproduces here unchanged.

2. A block keeps its exception exits after its last operation is elided

optimize_block() did not port jtransform.py:116-118. The exception exits
and the last_exception exitswitch belong to the block's last operation,
so when rewriting that operation contributes no operation at all they have to
go with it — otherwise the block carries an exception edge for an operation
that is not in it any more.

Ported in upstream order: the count is taken right after rewrite_operation()
returns, and the exits are truncated before the exitswitch/exits remap,
matching _killed_exception_raising_operation() (jtransform.py:176-179).
Block::canraise() already means exactly upstream's exitswitch == c_last_exception, and Link.exitcase is already there, so the port is
mechanical.

Latent today, and measured rather than assumed. A census over the
pyre-object / pyre-interpreter / pyre-jit corpus that build.rs
analyzes:

count
canraise blocks reached 1590
...whose last operation rewrote to nothing 0

The 1590 is what makes the 0 meaningful — the path is heavily exercised, and
no rewrite currently produces the combination. So no wrong exception edge is
being emitted today. The instrumentation was removed after measuring; the
test pins the behaviour with cast_int_to_uint, one of the no-op casts that
rewrite to RewriteResult::Identity.

Audit method

Line-count ratios were tried first and discarded: effectinfo.rs reads 0.3x
upstream only because the EffectInfo data class is extracted into
majit-ir (2134 lines combined, 3.9x — normal). Instead, all 400 top-level
defs under rpython/jit/codewriter/ were diffed against an identifier
index of majit/ + pyre/.

Negatives, so the ground is not re-walked

  • jtransform.rs _ => RewriteResult::Keep. Upstream raises on an
    unregistered opname, and pass-through is gated on the blackhole actually
    carrying a bhimpl_ (_add_default_ops: "All other operations are
    forbidden"). pyre has no such gate. But OpKind is a closed enum and all
    45 variants that reach the default arm are named by the assembler, so
    there is no unencodable-op hole today. Structural deviation only.
  • jtransform_shadow.rs mirrors _add_default_ops, but is explicitly
    inert: gated on PYRE_JTRANSFORM_SHADOW, "never feeds the production
    jitcode path", and currently reads zero. It is an audit gauge, not
    enforcement.
  • interiorfield family. InteriorFieldRead/Write reach the default arm
    and are encoded as {get,set}interiorfield_gc_{i,r,f}. All six handlers
    are wired in blackhole.rs, matching upstream's six bhimpl_s. Clean.
  • c__pad skip-set asymmetry. all_fielddescrs_into() skips c__pad*
    and get_fielddescr_index_in() does not — the same asymmetry upstream has.
    Dead in pyre: c__pad appears only in skip predicates, never as a produced
    field name.

Verification

cargo test --all --features dynasm7496 passed across 101 suites, 0
failed. Both new tests were checked red before their fix and green after:
left: 4, right: 3 for the index walk, and the last_exception exitswitch must go with the elided operation for the exception exits.

One note on getting there: the gate first aborted in pyre-jit-trace's build
script with duplicate variable v398 (flowspace/model.rs checkgraph).
That was not this change — reverting the one line in place reproduced it
identically — and not main either. build/llbc/ was 9 days stale (154
source files newer than their fingerprints) after the branch was rebased;
re-running pyre/scripts/extract-llbc.py cleared it and the build script
then completed. Worth knowing before anyone else reads that panic as a code
defect.


Second round: the virtualizable array escape

Everything below landed on this branch after the section above was written.
It starts from one wired assertion and ends with the front-end bug that
assertion was unable to be wired against.

3. optimize_block treated an operation-less block as an elision

Follow-up to §2. The elision test len(newoperations) == count_before also
fires for a block that had no operations to begin with, so an untouched
empty block had its exception exits stripped. Upstream reaches the same
check only from inside the for op in block.operations loop, which an empty
block never enters.

The predicate that decides this is is_bool_branch on the exitswitch value,
and it is load-bearing: it is what separates "this block's switch belongs to
an operation" from "this block's switch is a plain bool test".

4. Virtualizable lowering is gated on whether the field base is dereferenced

FieldDescriptor carries base_is_deref: Option<bool>, and the
virtualizable protocol only fires when it is Some(true). None — the
default — is deliberately not treated as "yes": a descriptor built by a
path that has not established the base is a real dereference must not
silently enable the protocol.

Also in this commit:

  • object_array::len is retargeted to ArrayLen. It had been matched to a
    __len Call, i.e. it was not unmatched — it was matched to the wrong
    thing, and the call form is what carried the array into an argument
    position.
  • _check_no_vable_array ported from jtransform.py:124-127, initially
    #[allow(dead_code)].

5. locals_w() accessors defeated the protocol; expanded as macros

The interpreter reached the virtualizable array through locals_w(&self)
helper methods. A helper's body reads the field off its own &self
parameter, so the base is a fresh local rather than the frame the caller
holds, and the protocol cannot see through it.

108 call sites now expand locals_w! / locals_w_mut! in place. A
by-value compile-time guard on FrameBox::new keeps the shape from
regressing silently.

6. Census + CI step

pyre/scripts/vable-projection-census.py counts non-dereferenced
PyFrame virtualizable-field projections per LLBC artefact and fails on any
function not on the expected list. Current state:

artefact non-deref projections
pyre-object.ullbc 0
pyre-interpreter.ullbc 12 — all FrameBox::new, expected
pyre-jit.ullbc 0

Wired as a Linux-gated CI step. scripts/llbc_extract.py was also missing
from both CI path filters, so an edit to the extractor could not trigger the
jobs that consume its output.

7. The front did not copy the entry framestate

Wiring _check_no_vable_array was blocked by a real escape:
PyFrame::_check_stack_index threaded the virtualizable array into a
successor block that never reads it. Two independent causes, both needed:

(a) make_next_block did not copy. flowcontext.py:464-471 is
newstate = state.copy(), and framestate.py:42 FrameState.copy is "make
a copy of this state in which all Variables are fresh" — per slot, so two
slots holding one Variable become two distinct Variables. pyre carried the
state through unchanged on a first-arrival edge, which made the target's
inputargs literally the predecessor's own Variables. Measured at 142598
of 153927 blocks
. transform_dead_op_vars' liveness is positional, so
with the Variables shared it could not tell a read in the predecessor from a
read in the target, and kept every arg.

(b) the sweep was gated on dirty. transform_dead_op_vars is the
first entry in all_passes (simplify.py:1067) and simplify_graph applies
every pass to every graph — there is no "only if something changed" gate
upstream. _check_stack_index reports no removal from any other pass, so
the gate meant the sweep never ran on it.

Both halves were verified load-bearing by in-place revert: copy alone still
escapes at link arg 8, ungating alone still escapes at arg 5, both together
is clean.

_check_no_vable_array is now wired at all four upstream sites —
optimize_block (fused exitswitch operands and every link arg),
rewrite_call_three_lists (call arguments), and rewrite_op_setfield.

8. What the wired check then found: addr_of_mut! on the array slot

// FrameLocalsRoot::new
let slot = addr_of_mut!((*frame_ptr).locals_cells_stack_w) as *mut *mut u8;
let registered = try_gc_add_root(slot);

The front lowers a place-address as a FieldRead (6372 sites corpus-wide),
so the codewriter recorded the array in vable_array_vars and dropped the
op
, leaving try_gc_add_root with an undefined operand. It was silent
before; with the check wired it aborts the build, which is how it surfaced.

Fixed narrowly: FieldDescriptor::taken_by_address, set only when the
rvalue is Ref/RawPtr over a projection and the marked op is the
field read that rvalue just produced. Both guards matter — a local's
Variable is the Variable of the op that produced it, so an unguarded match
marks an unrelated earlier read, and self.method() on &mut self is a
Ref over a local at every call site. A false mark disables the protocol
silently, the same failure the Option default on base_is_deref exists to
prevent.

This does not make the aliasing right — the getfield still yields the
array's value where the source asked for the slot's address, as every
other place-address in the corpus does. It stops the virtualizable path from
turning that into a dropped operand. The general fix is tracked separately.

9. The sweep had to stay out of the result-exc pre-pass

Making the sweep unconditional cost 6 jitcodes (2356 → 2350). Not waved
off as noise, and worth recording how nearly it was: the first attempt to
dismiss it scanned jitcode bodies for callee fnaddr integers and reported
100% of all 2356 jitcodes unreferenced — which falsifies the method, not the
regression. Callee references resolve through fnaddr_bindings.bin.

Real cause: exactly three _io __init__ graphs failed to lower with
block N's exit does not carry the tracked value, taking the
reader_reset_buf / writer_reset_buf callees they queue. pyre's
result_exc diamond walk follows one Variable by identity from link
args into the target's inputargs, and a Result dead downstream is exactly
what the sweep removes.

simplify_lowered_graph now takes sweep_dead_vars. End-of-lowering passes
true; the pyre-only pre-pass keeps its dirty gate. That also leaves
one sweep per graph, which is upstream's shape.

Verification

gate result
extract-llbc.py exit 0
corpus build exit 0, LLBC stale count 0
_check_no_vable_array aborts 0, with the check armed
jitcodes 2356, name-level diff vs the pre-branch cache: none
projection census OK (0 / 12-expected / 0)
cargo test -p majit-translate green — 17 binaries, 3132 lib tests

The jitcode count and the per-name diff are both reported because the count
alone hides a swap. reader_reset_buf 1, writer_reset_buf 2, __init__
10 — the three the regression had zeroed.

Also on the branch: jitcode dump labels are numbered by first printed
mention rather than by internal id, so a dump diff is readable.

Summary by CodeRabbit

  • Bug Fixes

    • Fixed incorrect field indexing for nested structures, including fields after recursive traversal and missing fields.
    • Improved virtualizable-array handling, field access, and array-length operations.
    • Removed stale exception-flow metadata when optimized operations are eliminated.
    • Corrected label numbering to follow first printed occurrence.
    • Improved lowering for constructors, pointer operations, collections, markers, and supported primitive operations.
    • Improved frame-local handling for more reliable JIT execution.
  • Tests

    • Added regression coverage for nested fields, virtualizable arrays, exception exits, label numbering, and call behavior.

@coderabbitai

coderabbitai Bot commented Aug 6, 2026

Copy link
Copy Markdown

Review Change Stack

Note

Reviews paused

It looks like this branch is under active development. To avoid overwhelming you with review comments due to an influx of new commits, CodeRabbit has automatically paused this review. You can configure this behavior by changing the reviews.auto_review.auto_pause_after_reviewed_commits setting.

Use the following commands to manage reviews:

  • @coderabbitai resume to resume automatic reviews.
  • @coderabbitai review to trigger a single review.

Use the checkboxes below for quick actions:

  • ▶️ Resume reviews
  • 🔍 Trigger review

Walkthrough

The change updates virtualizable field and array lowering, frame-local access paths, graph cleanup, label formatting, nested-struct indexing, and related regression checks. It also adds runtime hints and LLBC projection validation.

Changes

Virtualizable lowering and frame access

Layer / File(s) Summary
Access metadata and MIR propagation
majit/majit-translate/src/model.rs, majit/majit-translate/src/front/*
FieldDescriptor tracks dereference and address-taking state. MIR lowering propagates this metadata and emits ArrayLen for object-array length operations.
Virtualizable rewrite and validation
majit/majit-translate/src/codewriter/jtransform.rs
Virtualizable descriptors match conservatively. Fresh values, non-dereferenced aggregates, and address-taken projections suppress lowering. Escape checks and shared call-argument validation cover multiple call routes.
Frame-local macro access
pyre/pyre-interpreter/src/pyframe.rs, pyre/pyre-interpreter/src/{builtins,eval}.rs, pyre/pyre-jit*/src/**
locals_w! and locals_w_mut! replace the removed PyFrame accessor methods across interpreter and JIT code.
Hints and LLBC validation
majit/majit-metainterp/src/jit.rs, pyre/scripts/vable-projection-census.py, .github/workflows/pyre-ci.yml, majit/majit-translate/tests/test_vable_array_len.rs
Runtime identity hints, a projection census, CI validation, and LLBC tests cover virtualizable projections, array length, escapes, and address-taking.

Codewriter corrections

Layer / File(s) Summary
Lazy label numbering
majit/majit-translate/src/codewriter/format.rs, majit/majit-translate/src/codewriter/flatten.rs
Labels receive ordinals at first printed mention across jumps, switches, and overflow branches.
Nested field index correction
majit/majit-translate/src/codewriter/heaptracker.rs, rpython/jit/codewriter/heaptracker.py, rpython/jit/codewriter/test/test_heaptracker.py
Recursive field lookup no longer double-counts fields before nested structures. Tests cover nested layouts and missing fields.
Graph cleanup and backend documentation
majit/majit-translate/src/front/mir.rs, majit/majit-translate/src/translator/backendopt/mod.rs
Graph simplification controls dead-variable sweeping, rewires empty blocks, clears orphaned blocks, and documents graph adaptation and optimizer limits.

Estimated code review effort: 5 (Critical) | ~90 minutes

Possibly related PRs

Poem

A rabbit guards each fresh field,
While nested counts no longer yield.
Labels wait for printed light,
Frame macros keep access right.
LLBC checks the path tonight.

🚥 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 identifies the main codewriter parity and virtualizable array escape changes, including the entry framestate fix.
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 jitcode

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: 8ab468e689

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

// the recursion was handed our own `cur_index`, so the index it
// reports back already counts the fields walked before the nested
// struct; adding it again would count them twice
cur_index = -r - 1;

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 Port the corrected upstream recursion verbatim

Although this assignment fixes the non-leading nested-struct arithmetic, it deliberately diverges from the vendored RPython implementation at rpython/jit/codewriter/heaptracker.py:108, which still uses cur_index += -r - 1. This repository requires strict line-by-line structural parity, so the upstream/vendored implementation should be corrected first and then mirrored here rather than introducing a pyre-only rewrite that future parity syncs will undo.

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

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.

Fixed as suggested — the vendored RPython side is now corrected first and the majit port mirrors it.

rpython/jit/codewriter/heaptracker.py now uses cur_index = -r - 1, with a new rpython/jit/codewriter/test/test_heaptracker.py; three of its four cases fail without it (assert 4 == 3, the same symptom the majit test shows). Vendored suites: codewriter 524 passed, backend/llsupport/test/test_descr.py 20 passed, metainterp/test/test_ajit.py 217 passed, backend/llgraph 138 passed.

For context, this is the same defect as pypy/pypy#5537, which is still open upstream — so a future parity sync will now find the two sides already in agreement rather than reverting the port.

commented by Claude

@coderabbitai

coderabbitai Bot commented Aug 6, 2026

Copy link
Copy Markdown

Note

GitHub couldn't provide a complete incremental comparison for this pull request, so CodeRabbit is performing a full review instead. This review may take a little longer.

@youknowone youknowone changed the title jit: stop get_fielddescr_index_in double-counting before a nested struct codewriter parity: the nested-struct field index, and exception exits left behind by an elided operation Aug 6, 2026

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

🤖 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/codewriter/jtransform.rs`:
- Around line 9352-9365: Extend the assertions in the block validation near the
last_exception exitswitch checks to verify that the sole retained exit targets
continuation. Keep the existing exit-count and exc_block exclusion assertions,
and add an explicit target assertion using the retained edge’s target and the
continuation symbol.
- Around line 719-723: Restore the upstream ordering in the loop around
rewrite_operation: assign count_before_last_operation immediately after
self.rewrite_operation returns, before any RewriteResult::Replace handling
extends new_ops. Preserve the existing rewritten-operation processing while
moving only this assignment.
🪄 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: 71b2c07a-3b1e-4fe2-be4d-6bb33d6302ee

📥 Commits

Reviewing files that changed from the base of the PR and between 678319f and 488527c.

📒 Files selected for processing (2)
  • majit/majit-translate/src/codewriter/heaptracker.rs
  • majit/majit-translate/src/codewriter/jtransform.rs

Comment thread majit/majit-translate/src/codewriter/jtransform.rs
Comment thread majit/majit-translate/src/codewriter/jtransform.rs

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

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

// exits and the `last_exception` exitswitch have to go with it —
// otherwise the block keeps an exception edge for an operation that
// is not in it any more.
if graph.blocks[block_idx].canraise() && new_ops.len() == count_before_last_operation {

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 Return before applying the last-operation check to empty blocks

When a block has no operations but retains ExitSwitch::LastException, this condition treats the initialized zero count as evidence that a nonexistent last operation was elided, then truncates its exception exits—or panics if it has none. Upstream jtransform.py:73-75 explicitly returns before processing every empty block, so port that guard before initializing this counter; the new condition otherwise introduces behavior precisely where upstream avoids reading count_before_last_operation.

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

Useful? React with 👍 / 👎.

@coderabbitai

coderabbitai Bot commented Aug 7, 2026

Copy link
Copy Markdown

Note

GitHub couldn't provide a complete incremental comparison for this pull request, so CodeRabbit is performing a full review instead. This review may take a little longer.

@youknowone youknowone changed the title codewriter parity: the nested-struct field index, and exception exits left behind by an elided operation codewriter parity, and the virtualizable array escape: the entry framestate was never copied Aug 7, 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: 270f8e1f25

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

// land. And a projection whose *address* was taken is not a read
// of the field at all; tracking it would drop the op and leave the
// address's consumer with an undefined operand.
let fresh_virtualizable = fresh_virtualizable || field.suppresses_virtualizable();

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 Preserve the field-slot address when suppressing vable lowering

When the generated JIT executes FrameLocalsRoot::new with the GC hook active, this suppression leaves addr_of_mut!((*frame_ptr).locals_cells_stack_w) represented as a plain FieldRead, whose result is the FixedObjectArray pointer rather than the address of the frame field. try_gc_add_root therefore registers the array body as a mutable root slot; a collection during frame setup can interpret or overwrite its header instead of forwarding frame.locals_cells_stack_w. Add a real place-address operation and pass that result through rather than merely disabling virtualizable lowering.

AGENTS.md reference: AGENTS.md:L252-L254

Useful? React with 👍 / 👎.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Actionable comments posted: 4

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

Inline comments:
In `@majit/majit-translate/src/codewriter/jtransform.rs`:
- Around line 7773-7833: Add three regression tests alongside
vable_array_escaping_the_block_on_a_link_arg_is_rejected, covering the fused
exitswitch operand, call argument, and setfield operand escape paths. Build
minimal graphs that cause a virtualizable array to escape through each route,
run transform_graph, and annotate each test with #[should_panic(expected =
"...")] using the exact route strings "fused exitswitch operand", "call
argument", and "setfield operand".
- Around line 7915-7981: The write-side coverage is missing for address-taken
virtualizable array fields. Add a test alongside
address_of_a_vable_array_field_is_not_tracked that constructs the same
FieldDescriptor with with_base_is_deref(true) and with_taken_by_address(true),
emits the corresponding SetField, and verifies no virtualizable rewrite occurs
while the ordinary write remains. Use the test result to update
rewrite_op_setfield’s suppression logic to the broader predicate if needed;
otherwise document the alternative reason near its current
base_is_local_aggregate() check.

In `@majit/majit-translate/src/front/mir.rs`:
- Around line 7607-7648: Add an ignored real-LLBC regression test for the
FixedObjectArray length retarget, following the existing anchors
set_ref_set_locals_w_real and vec_index_mut_fill_user_function_args_real. Target
the _check_stack_index graph, assert it contains zero residual calls matching
["object_array", "<Impl>", "len"], and assert it emits at least one
OpKind::ArrayLen.

In `@majit/majit-translate/src/model.rs`:
- Around line 485-550: Correct the aliased `Rvalue::Ref` field-access
classification so `let r = &q; (*r).f` is recognized as a non-dereferenced local
aggregate rather than relying on the `Field` projection kind. Update the
producer or `FieldDescriptor::suppresses_virtualizable()` to suppress
virtualizable lowering for this resolved shape while preserving true
pointer-based accesses. Add a regression comparing the generated behavior with
the PyPy oracle under `MAJIT_STATS=1`.
🪄 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: 15c7a369-9c0e-4178-83c6-83e964042e66

📥 Commits

Reviewing files that changed from the base of the PR and between 0b1dee0 and 270f8e1.

📒 Files selected for processing (36)
  • .github/workflows/pyre-ci.yml
  • majit/majit-metainterp/src/jit.rs
  • majit/majit-translate/src/codewriter/flatten.rs
  • majit/majit-translate/src/codewriter/format.rs
  • majit/majit-translate/src/codewriter/heaptracker.rs
  • majit/majit-translate/src/codewriter/jtransform.rs
  • majit/majit-translate/src/front/bool_then.rs
  • majit/majit-translate/src/front/from_size_align.rs
  • majit/majit-translate/src/front/mir.rs
  • majit/majit-translate/src/front/option_closure_select.rs
  • majit/majit-translate/src/front/option_expect.rs
  • majit/majit-translate/src/front/option_is_none.rs
  • majit/majit-translate/src/front/option_map_or.rs
  • majit/majit-translate/src/front/option_try.rs
  • majit/majit-translate/src/front/option_unwrap.rs
  • majit/majit-translate/src/front/option_unwrap_or.rs
  • majit/majit-translate/src/front/range_iter.rs
  • majit/majit-translate/src/front/result_exc.rs
  • majit/majit-translate/src/front/slice_index.rs
  • majit/majit-translate/src/model.rs
  • majit/majit-translate/src/translator/backendopt/mod.rs
  • majit/majit-translate/src/translator/rtyper/cutover.rs
  • majit/majit-translate/tests/test_vable_array_len.rs
  • pyre/pyre-interpreter/src/builtins.rs
  • pyre/pyre-interpreter/src/eval.rs
  • pyre/pyre-interpreter/src/pyframe.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/state.rs
  • pyre/pyre-jit-trace/src/trace.rs
  • pyre/pyre-jit-trace/src/trace_opcode.rs
  • pyre/pyre-jit/src/call_jit.rs
  • pyre/pyre-jit/src/eval.rs
  • pyre/scripts/vable-projection-census.py
  • rpython/jit/codewriter/heaptracker.py
  • rpython/jit/codewriter/test/test_heaptracker.py

Comment on lines +7773 to +7833
/// `jtransform.py:126-127` + `:145-168 _check_no_vable_array` — the
/// array a virtualizable field read produced may not leave the block
/// along a link argument; the block that would consume it has no
/// `vable_array_vars` entry for it, so no lowering could ever happen.
///
/// Drives the whole `transform_graph`, so it is evidence that the
/// transform enforces the rule and not only that the predicate is
/// correct. This is the same graph as
/// `fresh_virtualizable_suppresses_vable_array_tracking` minus the
/// hint: there the array never becomes a virtualizable array and the
/// link is fine, here it does and the link is not.
///
/// The expectation names the escape route, not just the upstream
/// header: all four call sites share one panic body, so without the
/// route the message cannot say which one fired.
#[test]
#[should_panic(expected = "Escaped via: link argument")]
fn vable_array_escaping_the_block_on_a_link_arg_is_rejected() {
use crate::model::Link;

let mut graph = FunctionGraph::new("vable_array_escape");
let frame_var = graph.alloc_value_var();
graph.push_inputarg_var(graph.startblock, frame_var.clone());
let array_var = graph
.push_op_var(
graph.startblock,
OpKind::FieldRead {
base: frame_var,
field: crate::model::FieldDescriptor::new(
"locals_stack_w",
Some("Frame".into()),
),
ty: ValueType::Ref(None),
pure: false,
},
true,
)
.unwrap();

// The consumer block receives the array as a link argument instead
// of reading it itself, so its `vable_array_vars` — rebuilt per
// block — has no entry to lower the access against.
let consumer = graph.create_block();
let phi = graph.alloc_value_var();
graph.push_inputarg_var(consumer, phi.clone());
graph.set_return(consumer, Some(phi));
let link = Link::from_variables(&graph, vec![array_var], consumer, None);
graph.set_control_flow_metadata(graph.startblock, None, vec![link]);

let config = GraphTransformConfig {
vable_arrays: vec![VirtualizableFieldDescriptor::new_with_arraydescr(
"locals_stack_w",
Some("Frame".into()),
0,
8,
true,
)],
..Default::default()
};
transform_graph(&graph, &config);
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Add regression coverage for the other three escape routes.

The doc comment on check_no_vable_array names four routes: "link argument", "fused exitswitch operand", "call argument", and "setfield operand". It also records that enumerating routes one at a time is what let _check_stack_index pass a green test while escaping by an unchecked route.

Only the link-argument route has a test. Add three #[should_panic] tests that pin the remaining route strings, so a future change that drops a call site fails a test instead of silently widening what the pass accepts.

🤖 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/codewriter/jtransform.rs` around lines 7773 - 7833,
Add three regression tests alongside
vable_array_escaping_the_block_on_a_link_arg_is_rejected, covering the fused
exitswitch operand, call argument, and setfield operand escape paths. Build
minimal graphs that cause a virtualizable array to escape through each route,
run transform_graph, and annotate each test with #[should_panic(expected =
"...")] using the exact route strings "fused exitswitch operand", "call
argument", and "setfield operand".

Comment thread majit/majit-translate/src/codewriter/jtransform.rs
Comment thread majit/majit-translate/src/front/mir.rs
Comment thread majit/majit-translate/src/model.rs
@github-actions

github-actions Bot commented Aug 7, 2026

Copy link
Copy Markdown

🤖 Codex parity review

Static analysis of this diff vs the local RPython/PyPy sources (commit 23e4152).
Updated: 2026-08-08T23:03:52.148Z

Files in the reviewed diff
.github/workflows/pyre-ci.yml
majit/majit-metainterp/src/jit.rs
majit/majit-translate/src/codewriter/flatten.rs
majit/majit-translate/src/codewriter/format.rs
majit/majit-translate/src/codewriter/heaptracker.rs
majit/majit-translate/src/codewriter/jtransform.rs
majit/majit-translate/src/front/bool_then.rs
majit/majit-translate/src/front/from_size_align.rs
majit/majit-translate/src/front/mir.rs
majit/majit-translate/src/front/option_closure_select.rs
majit/majit-translate/src/front/option_expect.rs
majit/majit-translate/src/front/option_is_none.rs
majit/majit-translate/src/front/option_map_or.rs
majit/majit-translate/src/front/option_try.rs
majit/majit-translate/src/front/option_unwrap.rs
majit/majit-translate/src/front/option_unwrap_or.rs
majit/majit-translate/src/front/range_iter.rs
majit/majit-translate/src/front/result_exc.rs
majit/majit-translate/src/front/slice_index.rs
majit/majit-translate/src/model.rs
majit/majit-translate/src/translator/backendopt/mod.rs
majit/majit-translate/src/translator/rtyper/cutover.rs
majit/majit-translate/tests/test_vable_array_len.rs
pyre/pyre-interpreter/src/builtins.rs
pyre/pyre-interpreter/src/eval.rs
pyre/pyre-interpreter/src/pyframe.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_dispatch/tests.rs
pyre/pyre-jit-trace/src/state.rs
pyre/pyre-jit-trace/src/trace.rs
pyre/pyre-jit-trace/src/trace_opcode.rs
pyre/pyre-jit/src/call_jit.rs
pyre/pyre-jit/src/eval.rs
pyre/scripts/vable-projection-census.py
rpython/jit/codewriter/heaptracker.py
rpython/jit/codewriter/test/test_heaptracker.py

1. Regressions to PyPy parity introduced by this patch

None.

2. Other mismatches introduced by this patch

  • majit/majit-metainterp/src/jit.rs:97 ↔ rpython/rlib/jit.py:311hint_fresh_virtualizable(x) is accepted as a standalone helper, while upstream rejects it: "lone fresh_virtualizable hint". The Rust helper permits a source shape that RPython refuses.

  • majit/majit-translate/src/codewriter/jtransform.rs:2606 ↔ rpython/rtyper/rvirtualizable.py:49 — fresh-virtualizable state is recorded only in the hint’s basic block; upstream emits jit_force_virtualizable before every redirected-field access. Thus a field access after a branch/loop can be lowered as virtualizable in Pyre but remains direct in PyPy.

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

  • pyre/pyre-interpreter/src/pyframe.rs:1251 ↔ pypy/interpreter/pyframe.py:513set_locals_w() hides the array access behind a separate Rust function ("set_ref(index, value)"), whereas PyPy writes self.locals_cells_stack_w[i] inline specifically to be "virtualizable-friendly". This remains a graph-boundary mismatch despite the new macros.

  • majit/majit-translate/src/codewriter/format.rs:90 ↔ rpython/jit/codewriter/format.py:17 — formatted operation operands use process-wide Variable.id() rather than the allocated register color/index. Output can therefore differ from upstream’s "%<kind><Register.index>".

4. Structural adaptations

  • pyre/pyre-interpreter/src/pyframe.rs:37 ↔ pypy/interpreter/pyframe.py:109locals_w!/locals_w_mut! expand Rust pointer dereferences at each caller so the field read and array operation remain in one lowered graph; this is the Rust-source equivalent of PyPy’s inline locals_cells_stack_w[...] accesses.

  • majit/majit-translate/src/model.rs:608 ↔ pypy/interpreter/pyframe.py:99 — Pyre uses base_is_local_aggregate to suppress virtualizable lowering during by-value FrameBox::new construction, instead of PyPy’s annotator-carried hint(self, access_directly=True, fresh_virtualizable=True). This is a Rust/MIR representation adaptation.

  • majit/majit-translate/src/front/mir.rs:2647 ↔ rpython/translator/simplify.py:1065 — Pyre runs additional graph cleanup and conditionally defers the pre-pass dead-phi sweep to preserve MIR/result-exception matching. Upstream applies its fixed all_passes sequence to flow graphs; the difference is required by Pyre’s distinct MIR graph ownership and unreachable-block representation.

@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.

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (2)
majit/majit-translate/src/front/option_closure_select.rs (1)

268-317: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Add unit test coverage for the niche-result Map/AndThen path.

The result_niche branches (lines 268-287 for the Some arm, 306-317 for the None arm) are new logic with no direct unit test. Every existing test uses the site() helper, which hardcodes result_niche: false (line 508). The niche path changes both the emitted shape (identity/null_mut() instead of an Option aggregate) and the owner keys used, so a regression here would only surface through the #[ignore]d real-LLBC tests in mir.rs, which do not run by default.

Add a test that builds a Map (and ideally AndThen) site with result_niche: true and asserts:

  • The Some arm emits no SyntheticTransparentCtor("Option", ..) and forwards call_once's result directly.
  • The None arm emits a null_mut() call instead of an Option ctor.
♻️ Suggested test skeleton
     fn site(kind: ClosureCombinator, result_var: Variable) -> ClosureSelectSite {
         ClosureSelectSite {
             kind,
             result_var,
             option_owner: "core::option::Option".into(),
             some_owner: "core::option::Option::Some".into(),
             call_once_owner: "test::closure".into(),
             payload_ty: ValueType::Int,
             call_result_ty: ValueType::Int,
             args_tuple_suffix: String::new(),
             niche: false,
             result_option_owner: "core::option::Option".into(),
             result_some_owner: "core::option::Option::Some".into(),
             result_niche: false,
         }
     }
+
+    fn niche_result_site(kind: ClosureCombinator, result_var: Variable) -> ClosureSelectSite {
+        ClosureSelectSite {
+            result_niche: true,
+            ..site(kind, result_var)
+        }
+    }
+
+    #[test]
+    fn map_with_niche_result_returns_closure_value_and_null_on_none() {
+        // Build result = opt.map(env) with niche_result_site(..) and assert:
+        // - no SyntheticTransparentCtor("Option", ..) in the Some arm
+        // - a null_mut() call in the None arm
+    }

Also applies to: 495-510

🤖 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/front/option_closure_select.rs` around lines 268 -
317, Extend the unit tests around the `site()` helper and combinator emission to
construct `Map` (and preferably `AndThen`) cases with `result_niche: true`.
Assert that the `Some` branch in the relevant emission function forwards the
`call_once` result without a `SyntheticTransparentCtor("Option", ..)`, while the
`None` branch emits `null_mut()` rather than an Option constructor.
majit/majit-translate/src/translator/rtyper/cutover.rs (1)

5110-5125: 🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win

Exercise the production blocked-block path.

The test removes the blocked block before calling transform_allocate. It then performs no assertion. The test passes even if production code stops excluding blocked blocks.

Call the production block-selection or specialization path, or extract the selection into a shared helper and test that helper. Assert that the blocked block is not transformed.

🤖 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/translator/rtyper/cutover.rs` around lines 5110 -
5125, Update the test around the blocked-block selection to exercise the
production block-selection or specialization path instead of filtering the block
locally before transform_allocate. Assert that the blocked block is excluded
from transformation, optionally extracting the selection logic into a shared
helper used by both production code and the test.
🤖 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.

Outside diff comments:
In `@majit/majit-translate/src/front/option_closure_select.rs`:
- Around line 268-317: Extend the unit tests around the `site()` helper and
combinator emission to construct `Map` (and preferably `AndThen`) cases with
`result_niche: true`. Assert that the `Some` branch in the relevant emission
function forwards the `call_once` result without a
`SyntheticTransparentCtor("Option", ..)`, while the `None` branch emits
`null_mut()` rather than an Option constructor.

In `@majit/majit-translate/src/translator/rtyper/cutover.rs`:
- Around line 5110-5125: Update the test around the blocked-block selection to
exercise the production block-selection or specialization path instead of
filtering the block locally before transform_allocate. Assert that the blocked
block is excluded from transformation, optionally extracting the selection logic
into a shared helper used by both production code and the test.

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: ASSERTIVE

Plan: Pro Plus

Run ID: f1e7ccfc-97c5-4d36-bb86-275b79b418d6

📥 Commits

Reviewing files that changed from the base of the PR and between 270f8e1 and 73e84c8.

📒 Files selected for processing (14)
  • majit/majit-translate/src/front/mir.rs
  • majit/majit-translate/src/front/option_closure_select.rs
  • majit/majit-translate/src/front/slice_index.rs
  • majit/majit-translate/src/translator/rtyper/cutover.rs
  • pyre/pyre-interpreter/src/builtins.rs
  • pyre/pyre-interpreter/src/eval.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_dispatch/tests.rs
  • pyre/pyre-jit-trace/src/state.rs
  • pyre/pyre-jit-trace/src/trace.rs
  • pyre/pyre-jit-trace/src/trace_opcode.rs
  • pyre/pyre-jit/src/call_jit.rs
  • pyre/pyre-jit/src/eval.rs

The recursion into an inlined sub-struct is handed the caller's own
cur_index, so the "not found" value it returns already counts the fields
walked before that sub-struct. Adding it back with `+=` counted them
twice, giving every field after a non-leading nested struct an index one
too high per preceding field.

That breaks the invariant field_pos_in() documents and
descr.rs derive_index_in_parent() repairs: all_fielddescrs_into() numbers
the same fields flat, so the two walkers disagreed. The stale number
survives wherever derive_index_in_parent() returns None -- no parent
descr, an empty parent all_fielddescrs(), or an unresolved field name.

Assisted-by: Claude
optimize_block() did not port jtransform.py:116-118: the exception exits
and the last_exception exitswitch belong to the block's last operation, so
when rewriting that operation contributes no operation at all they have to
go with it. Without this the block keeps an exception edge for an operation
that is no longer in it.

Ported in upstream order -- the count is taken right after
rewrite_operation() returns, and the exits are truncated before the
exitswitch/exits remap, matching _killed_exception_raising_operation()
at jtransform.py:176-179.

Latent today. A census over the pyre-object/pyre-interpreter/pyre-jit
corpus that build.rs analyzes counted 1590 canraise blocks and none whose
last operation rewrote to nothing, so no wrong exception edge is being
emitted; the test pins the behaviour with cast_int_to_uint, which rewrites
to RewriteResult::Identity.

Assisted-by: Claude
…ub-struct

Same defect as the majit port, on the RPython side it was ported from. The
recursion is given the caller's own cur_index, so the "not found" value it
returns already counts the fields walked before the inlined sub-struct;
adding it back with += counted them twice and gave every field after a
non-leading sub-struct an index one too high per preceding field.

all_fielddescrs() numbers the same fields flat, and descr.py:228 pairs the
two, so they disagreed for that shape.

New test file; three of its four cases fail without the fix (assert 4 == 3).
codewriter 524 passed, backend/llsupport/test/test_descr.py 20 passed,
metainterp/test/test_ajit.py 217 passed, backend/llgraph 138 passed.

Assisted-by: Claude
Addresses a review note on the elided-last-operation test: it proved one
non-exception edge remained but not that the edge targeted the
continuation block.

Assisted-by: Claude
`count_before_last_operation` was initialised to 0, so a block with no
operations satisfied `len(newoperations) == count_before_last_operation`
and took the `jtransform.py:117` branch: its `last_exception` exitswitch
and exception exits were dropped.  Upstream never binds the name when the
loop body does not run, so the comparison cannot evaluate there.  The
counter is now `Option<usize>`; `None` means there was no last operation
and the test does not apply.

Also ports `jtransform.py:74-75`, the early return for the graph's return
and except blocks.  Both are already inert for every remaining step of
`optimize_block`, so this changes no output today.

Assisted-by: Claude
`format_assembler`'s first pass assigned each label its ordinal as it
scanned jump targets, and the output loop's `FlatOp::Label` arm could only
look up a number a jump had already claimed.  `format.py:37-44` only marks
targets with a sentinel; `format.py:45-50 getlabelname` assigns the number
at print time, reached from both the jump operand (`format.py:24-25`) and
the label line (`format.py:61-63`).  Over a back edge the two orders
differ: the loop header's label line prints before the back edge that
targets it, so pyre numbered the forward exit first and inverted every
label in the loop.

The first pass now marks only, and numbering moves into the output loop.
Two tests cover it, one on a hand-built SSARepr and one end-to-end over
the back-edge graph.  `format_assembler` is read by the flatten parity
tests and by `JitCode::dump()`, which has no callers.

Assisted-by: Claude
…enced

`FieldDescriptor` gains `base_is_deref: Option<bool>`, recorded by the MIR
front in `resolve_place` / `emit_projection_write`.  `base_is_local_aggregate()`
(`== Some(false)`) joins `fresh_virtualizable` as a condition that keeps a
field access off the `getfield_vable_*` / `setfield_vable_*` path: a projection
whose base is a local aggregate rather than a pointer is not reaching a live
virtualizable.  `None` means "not recorded" and keeps the old lowering, so a
producer that does not set the flag cannot silently disable the protocol.
Measured over the three extracted crates (32181 fns, 46652 field projections),
the twelve non-dereferenced projections of a `PyFrame` virtualizable field are
all in `pyframe::<Impl>::new`, which takes its frame by value.

`majit_metainterp::jit` gains `hint_access_directly` / `hint_fresh_virtualizable`
(rlib/jit.py:88-93).  Placement differs from upstream: pyre dispatches one
helper per kwarg and has no `hook_access_field`, so the suppression covers only
the basic block the call sits in.

`simplify_lowered_graph` runs `eliminate_empty_blocks` + `clear_unreachable_blocks`
a second time after the dead-code sweeps.  `all_passes` (simplify.py:1065-1078)
orders `transform_dead_op_vars` before `eliminate_empty_blocks`; the existing
head call runs before every sweep, which inverts that.  The head call cannot
move: it collapses the MIR front's own empty blocks, rewiring a link on 998 of
1255 graphs.

`pyre_object::object_array::<Impl>::len` lowers to `OpKind::ArrayLen`.  It was
matched by `is_container_len` and lowered to `Call ["__len"]` with the array as
an argument.  `int_array` / `float_array` stay on `__len`; neither is
virtualizable.

`_check_no_vable_array` (jtransform.py:124-127, 421, 912) is ported, with a
route tag added to upstream's message.  It is not wired to its four call sites
in this commit.

Assisted-by: Claude
`PyFrame::locals_w` / `locals_w_mut` become `locals_w!` / `locals_w_mut!`
macros and their 108 call sites are converted.  The accessor form put the
field read in a separate graph from every consumer, so the array crossed a
call boundary as a return value; the macro emits the read and its consumer
into one basic block, which is what the codewriter's virtualizable-array
handling requires.  Charon output for the two forms:

    accessor:  bb0: _4 = locals_w<'7>(move _5) -> bb2
    macro:     bb0: _4 = &(*((*f_1)).locals_cells_stack_w); ... -> bb2

`FrameBox::new` taking its frame by value is now guarded at compile time
(`const _: fn(PyFrame) -> FrameBox = FrameBox::new;`).  Allocating first and
initialising through a pointer would turn its twelve field projections into
dereferences.

Assisted-by: Claude
`pyre/scripts/vable-projection-census.py` counts field projections over the
already-extracted Charon LLBC and fails unless every non-dereferenced
projection of a `PyFrame` virtualizable field is in `pyframe::<Impl>::new`.
The field list is parsed from `virtualizable_spec.rs` rather than duplicated,
and an unrecognised base shape fails rather than being assumed harmless.

The CI step goes in `&pyre-check-steps` after `Verify prepared Charon/LLBC`,
where both artefacts are already downloaded.  That block is aliased by the
macOS and Windows jobs, so the step is gated `if: runner.os == 'Linux'` —
charon resolves per-platform and has no Windows mapping.

The workflow's path filters gain `scripts/llbc_extract.py`, the module
`scripts/extract-llbc.py` imports and where most of the extraction logic
lives; a change to it alone did not trigger this workflow.

Assisted-by: Claude
`lower_framestate`'s first-edge arm handed a block its predecessor's exit
framestate verbatim, so its inputargs were the same `Variable` identities as
the link args feeding them — measured at 142598 of 153927 blocks.  Upstream's
`make_next_block` (flowcontext.py:464-471) is `newstate = state.copy()`, and
`FrameState.copy` (framestate.py:42) is "make a copy of this state in which
all Variables are fresh".  That distinctness is what makes positional
liveness meaningful in `transform_dead_op_vars`: with the identities shared,
a value read anywhere in the predecessor is in `read_vars`, so a link arg
naming it is never dropped however dead it is in the target.

`prune_dead_phis` loses its `if dirty` gate.  `transform_dead_op_vars` is the
first entry in `all_passes` (simplify.py:1067) and `simplify_graph`
(:1080-1086) applies every pass to every graph.  The gate was written while
the inputargs aliased, when the sweep found little on graphs no other pass
had touched.

Both are load-bearing, measured by reverting each in place against
`_check_stack_index`: with only the copy the array still escapes at link arg
8, with only the ungating at arg 5, with both it does not escape.

The four `_check_no_vable_array` call sites are wired
(jtransform.py:124-127, 421, 912).  `vable_array_escaping_the_block_on_a_link_arg_is_rejected`
now drives `transform_graph` rather than calling the check directly.

`FieldDescriptor` gains `taken_by_address`.  `&raw mut (*p).f` reaches the
codewriter as a `FieldRead`, because the front models a reference as an alias
of its referent.  For a virtualizable field that is not survivable: the read
is recorded in `vable_array_vars` and the op is dropped, leaving the address's
consumer with an undefined operand.  `pyframe::FrameLocalsRoot::new` is the
case — it registers `addr_of_mut!((*frame).locals_cells_stack_w)` with
`try_gc_add_root` — and it is what the newly wired check rejected.  Marking it
suppresses the virtualizable lowering only; the aliasing itself is unchanged,
here as at every other place-address in the corpus.

Only the outermost projection is marked, and only when the place is a
projection that emitted a read.  A local's Variable is the Variable of the op
that produced it, so matching on the last operation alone marks whatever came
before — and `self.method()` on `&mut self` is a `Rvalue::Ref` over a local at
every call site.

Assisted-by: Claude
`simplify_lowered_graph` takes `sweep_dead_vars`.  The end-of-lowering
call and the panic-chain call pass `true` and run `prune_dead_phis`
unconditionally; the result-exc pre-pass passes `false` and keeps the
`dirty` gate, so the `dirty` accumulation over `fuse_boxing_alloc`,
`prune_dead_boxing_remnants`, `remove_dead_aggregates`,
`remove_assertion_errors` and `fold_constant_exitswitch` is restored.

`rewire_result_exc_call_sites` follows one `Variable` by identity from a
link's args into the target block's inputargs.  A `Result` that is dead
downstream is what the sweep removes, and `follow_single_exit` then
fails the graph with "block N's exit does not carry the tracked value".
Sweeping in the pre-pass stopped three `_io` `__init__` graphs from
lowering and took the `reader_reset_buf` and `writer_reset_buf` callees
they queue: 2356 jitcodes -> 2350.  With the pre-pass gate kept, the
corpus is back to 2356 with no name-level difference.

Assisted-by: Claude
…by colour

`keyword_builtin_wrapper_finds_colored_argument_slice_item_descr` asserted
the argument slice is not coloured r0, then took the first `arraylen_gc`
on that register.  The colour is an allocation outcome: with the entry
framestate copied and the dead-var sweep running on every graph, the
`__pyre_wrap_getrandbits` wrapper loses 5 operations (153 -> 148) and the
allocator moves the slice from r11 to r0.

The wrapper is otherwise unchanged across the two arms -- same five array
operations in the same order, same length and item descriptor pool
entries, same array read throughout -- so the assertion pinned the
allocation and not a property of the wrapper.

Select the nearest `arraylen_gc` on the slice's register preceding the
extraction instead.  That also drops the reason the colour guard existed:
the first `arraylen_gc` can be the splitter's own `args.len()` read once
that inlines and the wrapper input is dead, and taking the nearest
preceding one names the slice's read under either colouring.

Assisted-by: Claude
@coderabbitai

coderabbitai Bot commented Aug 8, 2026

Copy link
Copy Markdown

Note

GitHub couldn't provide a complete incremental comparison for this pull request, so CodeRabbit is performing a full review instead. This review may take a little longer.

@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: 38501fdb28

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

macro_rules! locals_w_mut {
($frame:expr) => {
(unsafe { &mut *$frame.locals_cells_stack_w })
};

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 Require exclusive access in locals_w_mut!

Because the macro dereferences the copied raw field pointer inside its own unsafe block, safe callers can invoke locals_w_mut! through a shared &PyFrame or invoke it twice while the first result remains live, creating aliased mutable references and triggering undefined behavior. The removed accessor required &mut self, so preserve that exclusivity requirement while still expanding the field access at the call site.

Useful? React with 👍 / 👎.

Comment on lines +2611 to +2615
// wherever the access happens to sit. A single hint call
// cannot: it covers only the accesses in its own block.
// A redirected field access on the same instance in any
// other block — the far side of a branch, a loop body, an
// assertion guarded by a null test — still lowers to

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 Carry fresh-virtualizable state across basic blocks

When a constructor uses the new hint_fresh_virtualizable equivalent once and then accesses a redirected field after a branch or inside a loop, optimize_block clears vable_flags for that block, so the access is incorrectly lowered through the virtualizable protocol even though the object is still fresh. Requiring another hint in every block does not preserve upstream semantics; port the per-access flag propagation (hook_access_field/instance annotation) so the single source hint remains effective across the function.

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

Useful? React with 👍 / 👎.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Actionable comments posted: 4

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

Inline comments:
In @.github/workflows/pyre-ci.yml:
- Around line 408-410: Update the run command for the vable-projection census
step to invoke the pinned interpreter from steps.cpython.outputs.python-path
instead of bare python3, while preserving the existing script and arguments.

In `@majit/majit-translate/src/codewriter/jtransform.rs`:
- Around line 3074-3076: Update the comment above the write-side
fresh_virtualizable assignment to explicitly state that FieldWrite represents a
store rather than an address-taking projection, so the taken_by_address arm does
not apply. Keep the existing base_is_local_aggregate logic unchanged.

In `@majit/majit-translate/src/front/mir.rs`:
- Around line 5243-5298: Add a focused FunctionGraph-based unit test following
block_emptied_after_the_head_collapse_is_still_rewired_past that exercises
mark_place_address_of through Ref/RawPtr over a Projection(Field). Assert
taken_by_address is set only on the outermost FieldRead, while bare-local &x and
a no-op Deref collapse leave the relevant FieldRead unmarked.

In `@pyre/scripts/vable-projection-census.py`:
- Around line 93-97: Update the pretty-print subprocess invocation in the
surrounding function to surface stderr when check=True raises
CalledProcessError, preserving the existing stdout decoding on success. Ensure
the failure path logs or re-raises an error containing the captured stderr so CI
output includes Charon’s diagnostic.
🪄 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: ebcd1981-921c-42ae-aa27-c2d9ffbce8bc

📥 Commits

Reviewing files that changed from the base of the PR and between a7eb493 and 38501fd.

📒 Files selected for processing (37)
  • .github/workflows/pyre-ci.yml
  • majit/majit-metainterp/src/jit.rs
  • majit/majit-translate/src/codewriter/flatten.rs
  • majit/majit-translate/src/codewriter/format.rs
  • majit/majit-translate/src/codewriter/heaptracker.rs
  • majit/majit-translate/src/codewriter/jtransform.rs
  • majit/majit-translate/src/front/bool_then.rs
  • majit/majit-translate/src/front/from_size_align.rs
  • majit/majit-translate/src/front/mir.rs
  • majit/majit-translate/src/front/option_closure_select.rs
  • majit/majit-translate/src/front/option_expect.rs
  • majit/majit-translate/src/front/option_is_none.rs
  • majit/majit-translate/src/front/option_map_or.rs
  • majit/majit-translate/src/front/option_try.rs
  • majit/majit-translate/src/front/option_unwrap.rs
  • majit/majit-translate/src/front/option_unwrap_or.rs
  • majit/majit-translate/src/front/range_iter.rs
  • majit/majit-translate/src/front/result_exc.rs
  • majit/majit-translate/src/front/slice_index.rs
  • majit/majit-translate/src/model.rs
  • majit/majit-translate/src/translator/backendopt/mod.rs
  • majit/majit-translate/src/translator/rtyper/cutover.rs
  • majit/majit-translate/tests/test_vable_array_len.rs
  • pyre/pyre-interpreter/src/builtins.rs
  • pyre/pyre-interpreter/src/eval.rs
  • pyre/pyre-interpreter/src/pyframe.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_dispatch/tests.rs
  • pyre/pyre-jit-trace/src/state.rs
  • pyre/pyre-jit-trace/src/trace.rs
  • pyre/pyre-jit-trace/src/trace_opcode.rs
  • pyre/pyre-jit/src/call_jit.rs
  • pyre/pyre-jit/src/eval.rs
  • pyre/scripts/vable-projection-census.py
  • rpython/jit/codewriter/heaptracker.py
  • rpython/jit/codewriter/test/test_heaptracker.py

Comment on lines +408 to +410
if: runner.os == 'Linux'
shell: bash
run: python3 pyre/scripts/vable-projection-census.py build/llbc/pyre-object.ullbc build/llbc/pyre-interpreter.ullbc build/llbc/pyre-jit.ullbc

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

📐 Maintainability & Code Quality | 🔵 Trivial | 💤 Low value

Use the pinned interpreter instead of the system python3.

Every other Python step in this job runs ${{ steps.cpython.outputs.python-path }}. This step runs bare python3, so it binds to the runner image's system interpreter rather than the pinned 3.14. The script uses only the standard library today, so behavior does not change; the inconsistency is what invites drift when the image's default moves.

♻️ Proposed change
-      run: python3 pyre/scripts/vable-projection-census.py build/llbc/pyre-object.ullbc build/llbc/pyre-interpreter.ullbc build/llbc/pyre-jit.ullbc
+      run: ${{ steps.cpython.outputs.python-path }} pyre/scripts/vable-projection-census.py build/llbc/pyre-object.ullbc build/llbc/pyre-interpreter.ullbc build/llbc/pyre-jit.ullbc
📝 Committable suggestion

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

Suggested change
if: runner.os == 'Linux'
shell: bash
run: python3 pyre/scripts/vable-projection-census.py build/llbc/pyre-object.ullbc build/llbc/pyre-interpreter.ullbc build/llbc/pyre-jit.ullbc
if: runner.os == 'Linux'
shell: bash
run: ${{ steps.cpython.outputs.python-path }} pyre/scripts/vable-projection-census.py build/llbc/pyre-object.ullbc build/llbc/pyre-interpreter.ullbc build/llbc/pyre-jit.ullbc
🤖 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 @.github/workflows/pyre-ci.yml around lines 408 - 410, Update the run command
for the vable-projection census step to invoke the pinned interpreter from
steps.cpython.outputs.python-path instead of bare python3, while preserving the
existing script and arguments.

Comment thread majit/majit-translate/src/codewriter/jtransform.rs
Comment on lines +5243 to +5298
/// Record that the `Variable` just resolved for a place is the
/// **address** of a field, not its value.
///
/// The `Ref` / `RawPtr` arms alias the reference to its referent, so
/// `&raw mut (*p).f` leaves an ordinary `FieldRead` behind. A
/// virtualizable field read is not kept as a value — the codewriter
/// records the array in `vable_array_vars` and drops the op — so the
/// address's consumer would be handed an undefined operand. Flagging
/// the descriptor lets the codewriter decline the virtualizable path
/// for exactly this projection.
///
/// Only the **outermost** projection is marked: it is the one whose
/// result is the Variable the address stands for. An inner step of
/// `&(*p).a.b` is a genuine value read of `a` and keeps its lowering,
/// so a nested read of a virtualizable field is not collaterally
/// suppressed.
///
/// Both guards are load-bearing, because a local's Variable *is* the
/// Variable of whatever op produced it:
///
/// - `projection` — `&mut x` on a bare local resolves to that local's
/// Variable with nothing emitted. Matching on the last op's result
/// alone would then mark the op that produced `x`, which may be an
/// unrelated earlier field read. Autoref makes this the common
/// case, not a corner: `self.method()` on `&mut self` is a
/// `Rvalue::Ref` over a local at every call site.
/// - `before` — a projection that emits nothing (a bare `Deref`, or a
/// field the front resolves without a read) leaves the preceding op
/// as `last`, with the same false-marking risk.
///
/// A false mark here is not inert: on a virtualizable field it would
/// silently disable the protocol for a genuine access, which is the
/// failure the `Option` default on `base_is_deref` exists to prevent.
fn mark_place_address_of(
&mut self,
mir_bb: usize,
projection: bool,
before: usize,
v: &Variable,
) {
if !projection {
return;
}
let bb_id = self.block_id[mir_bb];
let block = self.graph.block_mut(bb_id);
if block.operations.len() <= before {
return;
}
if let Some(op) = block.operations.last_mut()
&& op.result.as_ref() == Some(v)
&& let OpKind::FieldRead { field, .. } = &mut op.kind
{
field.taken_by_address = true;
}
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🩺 Stability & Availability | 🔵 Trivial | ⚡ Quick win

Add a unit test for mark_place_address_of.

mark_place_address_of decides whether a FieldRead keeps a virtualizable field readable as a value or gets dropped by the codewriter's vable protocol. The function's own doc comment states the failure mode: a false mark on a virtualizable field silently disables the protocol for a genuine access, and this bug class is invisible until a specific address-taking shape appears.

The guard logic (projection, before, "only the last op", niche __pos_0 alias skip) has several interacting branches, but no direct unit test exercises it in this file. Add a small FunctionGraph-based test (following the pattern of block_emptied_after_the_head_collapse_is_still_rewired_past) that builds a Ref/RawPtr over a Projection(Field) place and asserts taken_by_address is set only on the outermost FieldRead, and stays unset for a bare-local &x and for a no-op Deref collapse.

🤖 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/front/mir.rs` around lines 5243 - 5298, Add a
focused FunctionGraph-based unit test following
block_emptied_after_the_head_collapse_is_still_rewired_past that exercises
mark_place_address_of through Ref/RawPtr over a Projection(Field). Assert
taken_by_address is set only on the outermost FieldRead, while bare-local &x and
a no-op Deref collapse leave the relevant FieldRead unmarked.

Comment on lines +93 to +97
return subprocess.run(
[str(charon_bin()), "pretty-print", str(path)],
capture_output=True,
check=True,
).stdout.decode(errors="replace")

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

📐 Maintainability & Code Quality | 🟡 Minor | ⚡ Quick win

Surface Charon's stderr when pretty-print fails.

capture_output=True with check=True discards stderr into the exception object. CalledProcessError renders only the command and the exit status, so a CI red gives no reason for the failure. This script exists to diagnose a CI failure, so the diagnostic must reach the log.

🛠️ Proposed fix
-    return subprocess.run(
-        [str(charon_bin()), "pretty-print", str(path)],
-        capture_output=True,
-        check=True,
-    ).stdout.decode(errors="replace")
+    proc = subprocess.run(
+        [str(charon_bin()), "pretty-print", str(path)],
+        capture_output=True,
+        check=False,
+    )
+    if proc.returncode != 0:
+        raise SystemExit(
+            f"charon pretty-print failed on {path} (exit {proc.returncode})\n"
+            + proc.stderr.decode(errors="replace")
+        )
+    return proc.stdout.decode(errors="replace")
📝 Committable suggestion

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

Suggested change
return subprocess.run(
[str(charon_bin()), "pretty-print", str(path)],
capture_output=True,
check=True,
).stdout.decode(errors="replace")
proc = subprocess.run(
[str(charon_bin()), "pretty-print", str(path)],
capture_output=True,
check=False,
)
if proc.returncode != 0:
raise SystemExit(
f"charon pretty-print failed on {path} (exit {proc.returncode})\n"
proc.stderr.decode(errors="replace")
)
return proc.stdout.decode(errors="replace")
🧰 Tools
🪛 Ruff (0.16.1)

[error] 93-93: subprocess call: check for execution of untrusted input

(S603)

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

In `@pyre/scripts/vable-projection-census.py` around lines 93 - 97, Update the
pretty-print subprocess invocation in the surrounding function to surface stderr
when check=True raises CalledProcessError, preserving the existing stdout
decoding on success. Ensure the failure path logs or re-raises an error
containing the captured stderr so CI output includes Charon’s diagnostic.

… a count

`EXPECTED` pinned `FrameBox::new` at 12 non-dereferenced projections.
Reworking that function's GC-root bracket took it to 10, so the step
failed on Linux against a number measured before that landed.  The count
tracks how often the constructor happens to touch a virtualizable field,
which moves with work unrelated to the protocol.

Gate on the set of functions instead.  `FrameBox::new` takes the frame by
value, so its `frame.<field>` projections are off a local aggregate; what
suppresses the virtualizable lowering is another function acquiring one,
and that is now what fails.

Also fail when the census matches nothing at all.  If the render or the
projection pattern stops resolving, every count goes to zero and a
set-membership check alone would report a clean tree while checking
nothing.

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