Skip to content

majit: evict per-loop side tables on retirement and harden MAJIT_BRIDGE_ONLY parsing - #787

Merged
youknowone merged 32 commits into
mainfrom
aheui
Jul 28, 2026
Merged

majit: evict per-loop side tables on retirement and harden MAJIT_BRIDGE_ONLY parsing#787
youknowone merged 32 commits into
mainfrom
aheui

Conversation

@youknowone

@youknowone youknowone commented Jul 25, 2026

Copy link
Copy Markdown
Owner

Follow-up to #773, which was merged before its review comments were addressed.
Four findings from CodeRabbit and Codex; each is described with what it does and
does not affect.

Changes

  • pyjitpl.rs — per-loop side tables outlived the loops they describe.
    loop_header_pcs (pre-existing) and loop_header_greens (added by majit: split state-field resume frames by liveness and give up on multi-frame bridges #773) were
    inserted on compile and never removed, so both kept growing as loops were
    retired. remove_compiled_loop and the memmgr eviction path in
    try_to_free_some_loops now drop the retired key from both, through a shared
    forget_loop_side_tables.

    This is a growth fix, not a targeting fix: compiled_key_for_greens already
    gates its match on has_compiled_targets, so a stale entry could never have
    resolved a bridge onto a retired loop.

  • jitdriver.rsMAJIT_BRIDGE_ONLY silently swallowed unparsable entries.
    The list was parsed with filter_map(|t| t.parse().ok()), so
    MAJIT_BRIDGE_ONLY=oops produced an empty allowlist — which suppresses every
    bridge, the exact inverse of the documented "unset means all" default, with no
    diagnostic. Since this knob exists to bisect a miscompiling bridge out of a
    run, a typo would have produced a confidently wrong result. An unparsable entry
    now panics.

  • resume_box_reader.rs — the two fieldnums arity checks are unconditional
    assert_eq!.
    As debug_assert_eq! they compiled out of release builds,
    which then indexed past the end of the slice and panicked without the message
    the check exists to print.

  • jitdriver.rs — dropped a redundant clone() of the close greens; the
    value is not used again.

Not addressed here, with reasons

  • Multi-frame bridge giveup (jitdriver.rs, Codex P1). majit: split state-field resume frames by liveness and give up on multi-frame bridges #773 gave up on
    bridges whose resume data spans more than one frame, in the same commit that
    fixed the liveness splitter those bridges decode through. The giveup may
    therefore now be unnecessary conservatism costing bridge coverage. That is a
    behaviour change needing its own measurement, not a review fixup, so it is
    left for a separate change together with CodeRabbit's request for
    building_bridge regression tests in optimizeopt/optimizer.rs.

  • Shared FRAME_VALUE_COUNT_FN (majit-ir/src/resumedata.rs:20, Codex P1).
    Reported as thread-local state; it is a process-global AtomicUsize that
    predates majit: split state-field resume frames by liveness and give up on multi-frame bridges #773 and is already consumed by the cranelift backend. It is only
    hazardous with two #[jit_interp] drivers registering different splitters in
    one process, which no current configuration does. Making it per-driver is a
    cross-crate signature change through rebuild_from_numbering.

Verification

cargo test -p majit-metainterp --features dynasm — 1501 passed, 0 failed.

🤖 Generated with Claude Code

Summary by CodeRabbit

  • Bug Fixes
    • Improved cleanup and invalidation for retired compiled loops, preventing stale per-loop side data from lingering.
    • Added stricter release-mode validation for virtual string/unicode rematerialization inputs.
    • Fixed resume/decoding liveness handling to consistently use the runtime snapshot.
    • Updated headerless allocation behavior to support a non-collecting path for headerless nursery allocations.
    • Improved descriptor field-key stability/compatibility to avoid mismatched descriptor resolutions.
  • New Features
    • Added support for reporting field-descriptor identity census on demand via an environment setting.
  • Tests
    • Added unit tests for compiled-loop removal and compiled-trace invalidation side-table cleanup.

@coderabbitai

coderabbitai Bot commented Jul 25, 2026

Copy link
Copy Markdown

Review Change Stack

Caution

Review failed

The pull request is closed.

ℹ️ Recent review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: ASSERTIVE

Plan: Pro Plus

Run ID: 08ad175f-64b1-46da-91f2-636aa6561e0e

📥 Commits

Reviewing files that changed from the base of the PR and between 34ddb53 and 1806815.

📒 Files selected for processing (38)
  • majit/majit-backend-cranelift/src/compiler.rs
  • majit/majit-backend-dynasm/src/runner.rs
  • majit/majit-backend-wasm/src/lib.rs
  • majit/majit-gc/src/lib.rs
  • majit/majit-ir/src/descr.rs
  • majit/majit-ir/src/descr_registry.rs
  • majit/majit-ir/src/effectinfo.rs
  • majit/majit-macros/src/jit_struct.rs
  • majit/majit-metainterp/src/blackhole.rs
  • majit/majit-metainterp/src/call_descr.rs
  • majit/majit-metainterp/src/jitcode/assembler.rs
  • majit/majit-metainterp/src/jitcode/mod.rs
  • majit/majit-metainterp/src/jitdriver.rs
  • majit/majit-metainterp/src/lib.rs
  • majit/majit-metainterp/src/opencoder.rs
  • majit/majit-metainterp/src/optimizeopt/optimizer.rs
  • majit/majit-metainterp/src/optimizeopt/pure.rs
  • majit/majit-metainterp/src/optimizeopt/virtualize.rs
  • majit/majit-metainterp/src/optimizeopt/virtualstate.rs
  • majit/majit-metainterp/src/pyjitpl.rs
  • majit/majit-metainterp/src/pyjitpl/dispatch.rs
  • majit/majit-metainterp/src/resume_box_reader.rs
  • majit/majit-translate/src/codewriter/assembler.rs
  • majit/majit-translate/src/codewriter/call.rs
  • majit/majit-translate/src/codewriter/jitcode.rs
  • pyre/pyre-interpreter/src/jit_fnaddr.rs
  • pyre/pyre-jit-trace/build.rs
  • pyre/pyre-jit-trace/src/assembler.rs
  • pyre/pyre-jit-trace/src/descr.rs
  • pyre/pyre-jit-trace/src/jitcode_dispatch/specialize.rs
  • pyre/pyre-jit-trace/src/jitcode_runtime.rs
  • pyre/pyre-jit-trace/src/state.rs
  • pyre/pyre-jit/src/call_jit.rs
  • pyre/pyre-jit/src/jit/assembler.rs
  • pyre/pyre-jit/src/jit/codewriter.rs
  • pyre/pyre-jit/src/lib.rs
  • pyre/pyre-object/src/listobject.rs
  • pyre/pyrex/src/lib.rs

Walkthrough

The changes unify descriptor identity and effect metadata, add headerless non-collecting GC allocation, centralize metainterpreter JitCode and loop state, and synchronize liveness and callable-address handling across resumed Pyre JIT execution.

Changes

Descriptor identity and effect metadata

Layer / File(s) Summary
Descriptor contracts and caches
majit/majit-ir/*, majit/majit-translate/*, majit/majit-metainterp/*
Field keys, parent descriptor links, descriptor caches, global descriptor storage, and descriptor-group construction are updated.
Keyed effect-set construction
majit/majit-translate/src/codewriter/call.rs, majit/majit-ir/src/effectinfo.rs
Read/write effect analysis carries stable field, array, and interior-field keys into serialized effect metadata.
Runtime descriptor reconstruction
pyre/pyre-jit-trace/src/descr.rs, pyre/pyre-jit-trace/src/jitcode_runtime.rs
Runtime descriptor groups, keyed lookups, call-descriptor rehydration, and build descriptor pool initialization are revised.

Headerless non-collecting allocation

Layer / File(s) Summary
GC allocation contract and hooks
majit/majit-gc/src/lib.rs
Adds the allocator trait method, global hook, trampoline, and coverage tests.
Backend allocation wiring
majit/majit-backend-cranelift/src/compiler.rs, majit/majit-backend-dynasm/src/runner.rs, majit/majit-backend-wasm/src/lib.rs
Registers backend allocation hooks and implements active-runtime, inline-bump, fallback, and blackhole allocation paths.
Tracer allocation and write barriers
majit/majit-metainterp/src/pyjitpl/dispatch.rs, majit/majit-backend-wasm/src/lib.rs
Headerless bytecode allocation uses the non-collecting nursery path, reference stores issue write barriers, and wasm frames are initialized before execution.

Metainterpreter state and loop lifecycle

Layer / File(s) Summary
Shared JitCode and descriptor state
majit/majit-metainterp/src/jitdriver.rs, majit/majit-metainterp/src/pyjitpl.rs
Portal JitCode and flat JitCode tables are stored through MetaInterp, while descriptor access and optimizer snapshots use shared accessors.
Compiled-loop retirement
majit/majit-metainterp/src/pyjitpl.rs, majit/majit-metainterp/src/jitdriver.rs
Loop removal, invalidation, and clearing now retire loop-header side tables consistently.
Resume validation and diagnostics
majit/majit-metainterp/src/resume_box_reader.rs, majit/majit-metainterp/src/optimizeopt/*, majit/majit-metainterp/src/blackhole.rs
Rematerialization checks are enforced, bridge mismatch logging is added, and virtual-state matching is tested.

Pyre resumed JIT assembly

Layer / File(s) Summary
Liveness and assembler synchronization
pyre/pyre-jit-trace/src/{assembler,state}.rs, pyre/pyre-jit/src/jit/{assembler,codewriter}.rs, pyre/pyre-jit/src/call_jit.rs
Assembler and resume paths use shared runtime liveness and opcode state with republish tracking.
Resume call wiring
pyre/pyre-interpreter/src/jit_fnaddr.rs, pyre/pyre-object/src/listobject.rs
List append resume targets are registered and object_push is publicly callable with a safety contract.
List append specialization
pyre/pyre-jit-trace/src/jitcode_dispatch/specialize.rs
Commit logic checks list length before applying a concrete append.

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

Possibly related issues

  • youknowone/pyre#205: The portal driver changes implement the indexed shared-driver state described by this issue.

Possibly related PRs

Poem

A rabbit hops through descriptor dew,
With nursery paths and liveness too.
Old loop tables fade away,
While JitCodes find their home to stay.
Keys align and bridges sing—
Thump, thump, goes the testing spring!

🚥 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 reflects the main retirement-side-table cleanup and also mentions a related parsing hardening change.
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 aheui

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

❤️ Share

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

@github-actions

github-actions Bot commented Jul 25, 2026

Copy link
Copy Markdown

🤖 Codex parity review

Static analysis of this diff vs the local RPython/PyPy sources (commit 1806815).
Updated: 2026-07-28T09:42:43.482Z

Files in the reviewed diff
majit/majit-backend-cranelift/src/compiler.rs
majit/majit-backend-dynasm/src/runner.rs
majit/majit-backend-wasm/src/lib.rs
majit/majit-gc/src/lib.rs
majit/majit-ir/src/descr.rs
majit/majit-ir/src/descr_registry.rs
majit/majit-ir/src/effectinfo.rs
majit/majit-macros/src/jit_struct.rs
majit/majit-metainterp/src/blackhole.rs
majit/majit-metainterp/src/call_descr.rs
majit/majit-metainterp/src/jitcode/assembler.rs
majit/majit-metainterp/src/jitcode/mod.rs
majit/majit-metainterp/src/jitdriver.rs
majit/majit-metainterp/src/lib.rs
majit/majit-metainterp/src/opencoder.rs
majit/majit-metainterp/src/optimizeopt/optimizer.rs
majit/majit-metainterp/src/optimizeopt/pure.rs
majit/majit-metainterp/src/optimizeopt/virtualize.rs
majit/majit-metainterp/src/optimizeopt/virtualstate.rs
majit/majit-metainterp/src/pyjitpl.rs
majit/majit-metainterp/src/pyjitpl/dispatch.rs
majit/majit-metainterp/src/resume_box_reader.rs
majit/majit-translate/src/codewriter/assembler.rs
majit/majit-translate/src/codewriter/call.rs
majit/majit-translate/src/codewriter/jitcode.rs
pyre/pyre-interpreter/src/jit_fnaddr.rs
pyre/pyre-jit-trace/build.rs
pyre/pyre-jit-trace/src/assembler.rs
pyre/pyre-jit-trace/src/descr.rs
pyre/pyre-jit-trace/src/jitcode_dispatch/specialize.rs
pyre/pyre-jit-trace/src/jitcode_runtime.rs
pyre/pyre-jit-trace/src/state.rs
pyre/pyre-jit/src/call_jit.rs
pyre/pyre-jit/src/jit/assembler.rs
pyre/pyre-jit/src/jit/codewriter.rs
pyre/pyre-jit/src/lib.rs
pyre/pyre-object/src/listobject.rs
pyre/pyrex/src/lib.rs

1. Regressions to PyPy parity introduced by this patch

None.

2. Other mismatches introduced by this patch

None.

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

None.

4. Structural adaptations

  • pyre/pyre-jit-trace/src/assembler.rs:45 ↔ rpython/jit/codewriter/assembler.py:21 — PyPy creates one assembler and one liveness buffer; pyre must seed a runtime assembler from build-time serialized liveness because code generation is split across build and runtime phases.

  • majit/majit-ir/src/effectinfo.rs:28 ↔ rpython/jit/codewriter/effectinfo.py:266 — PyPy retains descriptor-object frozensets in-process; pyre serializes cache-key projections (DescrSetMember) across descrs.bin, then rehydrates canonical descriptor objects.

  • majit/majit-ir/src/descr.rs:898 ↔ rpython/jit/backend/llsupport/descr.py:218 — PyPy derives field metadata directly from an already-complete lltype.Struct; pyre accepts extracted layout metadata and can upgrade a cached partial size layout, including rebinding field-parent references at majit/majit-ir/src/descr.rs:1225.

  • majit/majit-translate/src/codewriter/jitcode.rs:1150 ↔ rpython/jit/backend/llsupport/llmodel.py:775 — PyPy’s GC descriptor/allocation pipeline carries object-layout facts directly; pyre’s serialized BhDescr::Size wire format has no headerless flag, so it uses an otherwise-unused owner sentinel to preserve headerless allocation semantics.

  • majit/majit-ir/src/descr_registry.rs:154 ↔ rpython/jit/metainterp/pyjitpl.py:2287 — PyPy has one MetaInterpStaticData descriptor table; pyre has separate runtime and tracing static-data owners but process-global descriptor indices, requiring one process-global all_descrs table.

  • pyre/pyre-interpreter/src/jit_fnaddr.rs:1394 ↔ rpython/jit/codewriter/call.py:174 — PyPy resolves every translated graph through getfunctionptr; pyre’s build-time source translation needs explicit runtime address bindings for residual calls that can be resumed in blackhole execution.

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

ℹ️ 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 +9013 to +9015
fn forget_loop_side_tables(&mut self, green_key: u64) {
self.loop_header_pcs.swap_remove(&green_key);
self.loop_header_greens.swap_remove(&green_key);

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 Clear side tables on every loop-retirement path

When a long-running process compiles distinct loop keys and then uses clear_compiled_loops, mark_all_loops_for_release, or invalidate_compiled_trace, those paths delete compiled_loops entries directly and never call this helper. Consequently loop_header_pcs and loop_header_greens still retain one entry per retired key, so the unbounded growth this change is intended to prevent remains for guard-recovery resets, jit_hooks.releaseall, and trace invalidation. Route every compiled-entry removal through the cleanup or clear/retain the side tables alongside it.

Useful? React with 👍 / 👎.

Comment thread majit/majit-metainterp/src/jitdriver.rs Outdated
Comment on lines +421 to +423
v.split(',')
.filter_map(|t| t.trim().parse::<u32>().ok())
.map(|t| t.trim())
.filter(|t| !t.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 an empty bridge allowlist

When MAJIT_BRIDGE_ONLY is empty, whitespace-only, or comma-only, filtering empty tokens produces Some(Vec::new()) rather than triggering the new validation. allowed.contains then rejects every guard without a diagnostic, preserving exactly the confidently wrong bridge-bisection result this hardening is meant to eliminate. Reject the value when no fail indices remain, or explicitly treat it as unset.

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

Caution

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

⚠️ Outside diff range comments (1)
majit/majit-metainterp/src/jitdriver.rs (1)

4909-4924: 📐 Maintainability & Code Quality | 🟠 Major | ⚡ Quick win

Same side-table leak as remove_compiled_loop/try_to_free_some_loops.

mark_all_loops_for_release clears self.meta.compiled_loops but leaves loop_header_pcs / loop_header_greens populated for every green key that was ever compiled. Since compiled_key_for_greens filters on has_compiled_targets, this is not a correctness bug today, but it defeats the intent of the PR's new forget_loop_side_tables cleanup at exactly the same class of eviction site.

🤖 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-metainterp/src/jitdriver.rs` around lines 4909 - 4924, Update
mark_all_loops_for_release to invoke the existing forget_loop_side_tables
cleanup for every compiled loop before clearing self.meta.compiled_loops, so
loop_header_pcs and loop_header_greens are removed during bulk eviction.
Preserve the existing release_all_loops behavior and ensure cleanup covers all
entries being discarded.
🤖 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-metainterp/src/pyjitpl.rs`:
- Around line 9002-9016: Extend side-table cleanup to every compiled-loop
eviction path: update clear_compiled_loops and the
mark_all_loops_for_release/invalidate_compiled_trace flow to remove
corresponding loop_header_pcs and loop_header_greens entries, reusing
forget_loop_side_tables where applicable. Ensure bulk clearing leaves no
per-loop side-table entries behind.

---

Outside diff comments:
In `@majit/majit-metainterp/src/jitdriver.rs`:
- Around line 4909-4924: Update mark_all_loops_for_release to invoke the
existing forget_loop_side_tables cleanup for every compiled loop before clearing
self.meta.compiled_loops, so loop_header_pcs and loop_header_greens are removed
during bulk eviction. Preserve the existing release_all_loops behavior and
ensure cleanup covers all entries being discarded.
🪄 Autofix (Beta)

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: e5e0c3d5-4343-48d7-9b16-45794bda927e

📥 Commits

Reviewing files that changed from the base of the PR and between b59700c and b5de0da.

📒 Files selected for processing (3)
  • majit/majit-metainterp/src/jitdriver.rs
  • majit/majit-metainterp/src/pyjitpl.rs
  • majit/majit-metainterp/src/resume_box_reader.rs

Comment thread majit/majit-metainterp/src/pyjitpl.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: 0826090bf1

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

let headerless = descr.as_size_descr().is_some_and(|sd| sd.headerless());
let ptr = Some(size.max(1))
.filter(|_| headerless)
.map(|n| majit_gc::alloc_nursery_headerless_no_collect(n).0 as i64)

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 Register headerless trace allocation for Cranelift

When a headerless_structs interpreter runs with the Cranelift backend, majit-backend-cranelift::register_active_hooks never installs set_active_alloc_nursery_headerless_no_collect, even though Cranelift supports CallMallocNurseryHeaderless. This call therefore returns null and the fallback allocates the traced object on the host heap, outside the interpreter collector; after a collection, references reachable through that object can be lost rather than forwarded. Install the new callback for every backend supporting headerless allocation, or fail loudly instead of using an untracked allocation.

Useful? React with 👍 / 👎.

Comment thread majit/majit-gc/src/lib.rs
Comment on lines +177 to +178
fn alloc_nursery_headerless_no_collect(&mut self, size: usize) -> GcRef {
self.alloc_nursery_headerless(size)

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 Do not forward no-collect allocation to a collecting method

When an existing moving, headerless-aware allocator implements only alloc_nursery_headerless, it silently inherits this default and may evacuate the nursery on overflow. That directly violates the preceding invariant: the jitcode tracer has raw pointers in an unrooted register bank, so such a collection can leave stale pointers and corrupt the trace. Make moving allocators provide an actual grow/no-collect implementation rather than giving them a collecting default.

Useful? React with 👍 / 👎.

@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: 34ddb53a04

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

insns,
all_liveness,
all_liveness_length,
..Self::default()

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 Rebuild the dedup map for seeded liveness entries

When a runtime liveness triple already exists in the build-time prefix, this constructor copies the bytes but leaves all_liveness_positions empty via Self::default(), so the first runtime occurrence appends a duplicate rather than reusing its existing offset. Since each -live- operand is limited to a 16-bit offset, workloads that lazily compile many distinct frame layouts can exhaust the 64 KiB pool earlier than the single-Assembler upstream design; serialize or reconstruct the build-time dedup map when resuming the buffer (and seed the reader-side mirror consistently).

AGENTS.md reference: AGENTS.md:L194-L196

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

Caution

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

⚠️ Outside diff range comments (5)
majit/majit-metainterp/src/pyjitpl/dispatch.rs (1)

10469-10484: 📐 Maintainability & Code Quality | 🟡 Minor | ⚡ Quick win

Assert the new exc_value on the one test that exercises it.

This is the test for the handler-less raise path, which is exactly where Line 1789-1803 now snapshots last_exception_value and hands it to the caller before clearing the channel. The .. added here skips that field, so the new handoff has no coverage. exc_raw is already in scope.

💚 Bind and assert `exc_value`
-        let (finish_args, finish_arg_types, exit_with_exception) = match action {
+        let (finish_args, finish_arg_types, exit_with_exception, exc_value) = match action {
             TraceAction::Finish {
                 finish_args,
                 finish_arg_types,
                 exit_with_exception,
-                ..
-            } => (finish_args, finish_arg_types, exit_with_exception),
+                exc_value,
+            } => (
+                finish_args,
+                finish_arg_types,
+                exit_with_exception,
+                exc_value,
+            ),
             other => panic!(
                 "expected TraceAction::Finish for handler-less raise, got {:?}",
                 other
             ),
         };
         assert_eq!(finish_arg_types, vec![majit_ir::Type::Ref]);
         assert_eq!(finish_args.len(), 1);
         assert!(exit_with_exception);
+        assert_eq!(
+            exc_value, exc_raw,
+            "the drained-stack exit must hand the exception value to the caller",
+        );
🤖 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-metainterp/src/pyjitpl/dispatch.rs` around lines 10469 - 10484,
Update the handler-less raise test’s TraceAction::Finish destructuring to bind
the new exc_value field instead of skipping it, then assert it matches the
in-scope exc_raw value before the existing finish argument and exception checks.
pyre/pyre-jit/src/jit/codewriter.rs (1)

5322-5334: 🩺 Stability & Availability | 🟠 Major | 🏗️ Heavy lift

Seed the metainterp shared_asm before building build-time jitcodes.

JitDriver::with_options still creates shared_asm with Assembler::new(), but sync_liveness_info_from_shared_asm() / the macro-emitted install path reads it during driver setup. Use Assembler::resuming_build_time_liveness() here so any build-time jitcodes have the same liveness prefix before decoding.

🤖 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/pyre-jit/src/jit/codewriter.rs` around lines 5322 - 5334, Update
JitDriver::with_options to initialize shared_asm with
Assembler::resuming_build_time_liveness() instead of Assembler::new(). Preserve
the existing shared_asm setup and ensure sync_liveness_info_from_shared_asm()
and the macro-emitted install path observe the seeded liveness prefix during
driver initialization.
pyre/pyre-interpreter/src/jit_fnaddr.rs (1)

409-437: 🩺 Stability & Availability | 🟡 Minor | ⚡ Quick win

Add the missing bare-name push_fnaddr for w_list_new_object.

w_list_new_object is residualized (#[dont_look_inside]) but the entry only registers the qualified/aliased paths. Add a bare "w_list_new_object" binding so residual call sites that spell it directly resolve to the real funcptr instead of a fallback symbolic fnaddr hash.

🛡️ Proposed fix
     let w_list_new_object: fn(Vec<pyre_object::PyObjectRef>) -> pyre_object::PyObjectRef =
         pyre_object::listobject::w_list_new_object;
     push_alias_pair(
         &mut entries,
         "pyre_object::listobject::w_list_new_object",
         "pyre_object::w_list_new_object",
         w_list_new_object as *const (),
     );
+    push_fnaddr(
+        &mut entries,
+        "w_list_new_object",
+        w_list_new_object as *const (),
+    );
🤖 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/pyre-interpreter/src/jit_fnaddr.rs` around lines 409 - 437, Add a
bare-name push_fnaddr registration for w_list_new_object, using the same
function pointer already used by its push_alias_pair entry. Place it alongside
the existing w_list_new_object registrations in the JIT function-address setup,
without changing the alias mappings or neighboring drain_collect_items handling.
majit/majit-metainterp/src/jitdriver.rs (2)

2837-2856: 🎯 Functional Correctness | 🔴 Critical | 🏗️ Heavy lift

Keep the escaping exception rooted while compiling.

exc_value is a GC reference encoded as i64; this path stores it only in BH_LAST_EXC_VALUE and then calls the allocating compile path. Unlike the explicit shadow-stack rooting earlier in this file, the TLS cell is not registered as a GC root, so a moving collection can leave the published exception stale. Root it for the whole compile call, write the forwarded value back to the continuation channel afterward, and pop the root on every exit.

As per coding guidelines, “Do not use TLS for process-global, interpreter-owned, identity-sensitive, semantic, registry, cache, or GC-relevant runtime state.”

🤖 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-metainterp/src/jitdriver.rs` around lines 2837 - 2856, Update the
exit-with-exception path surrounding the compile call to explicitly shadow-root
the GC reference encoded by exc_value for the entire allocating compilation,
rather than relying on BH_LAST_EXC_VALUE as a root. After compilation, write the
forwarded value back to BH_LAST_EXC_VALUE for the continuation and ensure the
shadow root is popped on every exit, including error paths; avoid using TLS as
the GC root.

Source: Coding guidelines


763-786: 🗄️ Data Integrity & Integration | 🟠 Major | 🏗️ Heavy lift

Remove the process-global/TLS frame-value-count fallback.

None retains the global callback path, but install_state_field_fvc overwrites STATE_FIELD_FVC on every dispatch installation. Multiple drivers on one thread can therefore decode a frame using another driver's JitCode/liveness registry, silently producing the wrong box count. Keep this state on the owning MetaInterp/driver and route every decode through that owner rather than a process-global callback plus TLS payload.

As per coding guidelines, “Do not use TLS for process-global, interpreter-owned, identity-sensitive, semantic, registry, cache, or GC-relevant runtime state.”

🤖 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-metainterp/src/jitdriver.rs` around lines 763 - 786, Remove the
process-global/TLS fallback used by frame-value-count decoding and make the
owning MetaInterp/driver authoritative. Update frame_value_count_fn and the
decode path around install_state_field_fvc so each dispatch uses its owner’s
JitCode/liveness registry directly, without consulting STATE_FIELD_FVC or TLS
payload; preserve correct per-driver decoding when multiple drivers share a
thread.

Source: Coding guidelines

🤖 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-metainterp/src/pyjitpl.rs`:
- Around line 9097-9110: Update invalidate_compiled_trace to clear the
pending_preamble_tokens associated with each retired green_key, matching the
cleanup performed by remove_compiled_loop. Preserve the existing compiled_loops
removal and forget_loop_side_tables calls while ensuring no pending tokens
remain for invalidated keys.

In `@majit/majit-metainterp/src/pyjitpl/dispatch.rs`:
- Around line 2906-2916: Update the headerless allocation path in the BC_NEW
dispatch logic so a successful alloc_nursery_headerless_no_collect result is
zero-initialized before being returned, preserving the alloc_zeroed behavior of
the fallback path and the surrounding malloc-plus-zero contract.

---

Outside diff comments:
In `@majit/majit-metainterp/src/jitdriver.rs`:
- Around line 2837-2856: Update the exit-with-exception path surrounding the
compile call to explicitly shadow-root the GC reference encoded by exc_value for
the entire allocating compilation, rather than relying on BH_LAST_EXC_VALUE as a
root. After compilation, write the forwarded value back to BH_LAST_EXC_VALUE for
the continuation and ensure the shadow root is popped on every exit, including
error paths; avoid using TLS as the GC root.
- Around line 763-786: Remove the process-global/TLS fallback used by
frame-value-count decoding and make the owning MetaInterp/driver authoritative.
Update frame_value_count_fn and the decode path around install_state_field_fvc
so each dispatch uses its owner’s JitCode/liveness registry directly, without
consulting STATE_FIELD_FVC or TLS payload; preserve correct per-driver decoding
when multiple drivers share a thread.

In `@majit/majit-metainterp/src/pyjitpl/dispatch.rs`:
- Around line 10469-10484: Update the handler-less raise test’s
TraceAction::Finish destructuring to bind the new exc_value field instead of
skipping it, then assert it matches the in-scope exc_raw value before the
existing finish argument and exception checks.

In `@pyre/pyre-interpreter/src/jit_fnaddr.rs`:
- Around line 409-437: Add a bare-name push_fnaddr registration for
w_list_new_object, using the same function pointer already used by its
push_alias_pair entry. Place it alongside the existing w_list_new_object
registrations in the JIT function-address setup, without changing the alias
mappings or neighboring drain_collect_items handling.

In `@pyre/pyre-jit/src/jit/codewriter.rs`:
- Around line 5322-5334: Update JitDriver::with_options to initialize shared_asm
with Assembler::resuming_build_time_liveness() instead of Assembler::new().
Preserve the existing shared_asm setup and ensure
sync_liveness_info_from_shared_asm() and the macro-emitted install path observe
the seeded liveness prefix during driver initialization.
🪄 Autofix (Beta)

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: b884d8fd-34b8-42de-b7f9-e230781dc7f7

📥 Commits

Reviewing files that changed from the base of the PR and between b5de0da and 34ddb53.

📒 Files selected for processing (16)
  • majit/majit-backend-dynasm/src/runner.rs
  • majit/majit-gc/src/lib.rs
  • majit/majit-metainterp/src/blackhole.rs
  • majit/majit-metainterp/src/jitdriver.rs
  • majit/majit-metainterp/src/optimizeopt/optimizer.rs
  • majit/majit-metainterp/src/optimizeopt/virtualstate.rs
  • majit/majit-metainterp/src/pyjitpl.rs
  • majit/majit-metainterp/src/pyjitpl/dispatch.rs
  • majit/majit-metainterp/src/resume_box_reader.rs
  • pyre/pyre-interpreter/src/jit_fnaddr.rs
  • pyre/pyre-jit-trace/src/assembler.rs
  • pyre/pyre-jit-trace/src/state.rs
  • pyre/pyre-jit/src/call_jit.rs
  • pyre/pyre-jit/src/jit/assembler.rs
  • pyre/pyre-jit/src/jit/codewriter.rs
  • pyre/pyre-object/src/listobject.rs

Comment on lines +9097 to +9110
/// Drop every compiled loop whose root trace is `trace_id`, with the
/// per-loop side tables that belong to it.
pub fn invalidate_compiled_trace(&mut self, trace_id: u64) {
let stale: Vec<u64> = self
.compiled_loops
.iter()
.filter(|(_, entry)| entry.root_trace_id == trace_id)
.map(|(green_key, _)| *green_key)
.collect();
for green_key in stale {
self.compiled_loops.swap_remove(&green_key);
self.forget_loop_side_tables(green_key);
}
}

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 | 🟡 Minor | ⚡ Quick win

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
# Find callers of invalidate_compiled_trace and any related pending_preamble_tokens handling.
rg -n 'invalidate_compiled_trace' --type=rust -C3
rg -n 'pending_preamble_tokens' --type=rust -C3

Repository: youknowone/pyre

Length of output: 153


🏁 Script executed:

#!/bin/bash
set -u

printf 'Tracked rust files around pyjitpl/compile/jitdriver:\n'
git ls-files | rg '(^|/)(pyjitpl|compile|jitdriver)\.rs$|majit-metainterp|jitrpy' | sed -n '1,120p' || true

printf '\nSearch for invalidate_compiled_trace with all files, context=all:\n'
rg -n --hidden --glob '!target/**' 'invalidate_compiled_compile|invalidate_compiled_trace|invalidate.*trace|compiled_loops|pending_preamble_tokens|loop_header_pcs|loop_header_greens|clear_compiled_loops|forget_loop_side_tables|compile_loop|InvalidLoop|Invalid.*Loop' . -S || true

printf '\nFile list names:\n'
fd -i 'pyjitpl\.rs|compile\.rs|jitdriver\.rs|metainterp' . -t f | sed -n '1,200p'

Repository: youknowone/pyre

Length of output: 50372


🏁 Script executed:

#!/bin/bash
set -u

printf 'locate pyjitpl.rs candidates:\n'
git ls-files | rg 'majit/majit-metainterp/src/pyjitpl\.rs|pyre/pyre-jit-trace/src/pyjitpl\.rs'

printf '\npyjitpl compile/state definitions:\n'
python3 - <<'PY'
from pathlib import Path
p=Path('majit/majit-metainterp/src/pyjitpl.rs')
lines=p.read_text().splitlines()
for needle in ['struct .*CompiledLoop', 'pub struct .*', 'pending_preamble_tokens', 'compiled_loops', 'loop_header_pcs', 'loop_header_greens', 'remove_compiled_loop', 'clear_compiled_loops', 'invalidate_compiled_trace', 'forget_loop_side_tables']:
    print(f'\n--- {needle} ---')
    count=0
    for i,l in enumerate(lines,1):
        if needle in l:
            start=max(1, i-8); end=min(len(lines), i+18)
            print('\n'.join(f'{j}: {lines[j-1]}' for j in range(start,end+1)))
            count+=1
            if count>=4: break
PY

printf '\nsimilar state references:\n'
rg -n 'pending_preamble_tokens|loop_header_pcs|loop_header_greens|compiled_loops|pending_compile|compile_loop|remove_compiled_loop|clear_compiled_loops|invalidate_compiled_trace|forget_loop_side_tables' majit/majit-metainterp/src pyre/pyre-jit-trace/src/pypy -g '*.rs' -g '*.py' -S || false

Repository: youknowone/pyre

Length of output: 50371


🏁 Script executed:

#!/bin/bash
set -u

printf 'compile.rs around pending_preamble_tokens entry:\n'
rg -n 'pending_preamble_tokens|InvalidLoop|CompileStatus|compile_loop|compile_retrace|make_jitcell_token' majit/majit-metainterp/src/compile.rs -C4 --max-count 200

printf '\npyjitpl around compile_retry_start_retrace / compile_loop wrapper:\n'
sed -n '5575,5640p' majit/majit-metainterp/src/pyjitpl.rs | nl -ba -v5575

printf '\ncompile.rs line ranges summary for stale search:\n'
python3 - <<'PY'
from pathlib import Path
text=Path('majit/majit-metainterp/src/compile.rs').read_text().splitlines()
keys=['pending_preamble_tokens','CompileStatus','compile_loop','compile_retrace','make_jitcell_token','token']
for i,line in enumerate(text,1):
    if any(k in line for k in keys):
        start=max(1, min(i, len(text)-50)); end=max(1, min(i+50, len(text)))
        # crude around first matches
PY

Repository: youknowone/pyre

Length of output: 7578


🏁 Script executed:

#!/bin/bash
set -u

printf 'compile.py upstream pending_preamble_tokens/InvalidLoop references:\n'
rg -n 'pending_preamble_tokens|InvalidLoop|compile_loop|compile_retrace|make_jitcell_token|compile.*target' majit/majit-metainterp/src/compile.py majit/majit-metainterp src/pypy -S -C3 || true

printf '\nexact pyjitpl validate_retrace_start and retry area:\n'
awk '
/validate_retrace_start|must_compile_with_values|prior_retraced_count|pending_preamble_tokens|compile_retrace/ {
  s=NR>5545?NR-5545:1; e=NR+50; if(e>5660) e=5660;
  print "--- lines "s"-"e" ---";
  for(i=s;i<=e;i++) printf "%6d:%s\n", i, lines[i]
}
{lines[NR]=$0}
' majit/majit-metainterp/src/pyjitpl.rs

printf '\nexact compile.py invalid-loop/compile retry references with line numbers:\n'
rg -n 'pending_preamble_tokens|InvalidLoop|compile_loop|compile_retrace|last_compile_status|retrace' majit/majit-metainterp/src/compile.py -C3 || true

Repository: youknowone/pyre

Length of output: 50374


🏁 Script executed:

#!/bin/bash
set -u

printf 'pyjitpl.rs compile_retrace body range:\n'
python3 <<'PY'
from pathlib import Path
p=Path('majit/majit-metainterp/src/pyjitpl.rs')
lines=p.read_text().splitlines()
start=end=0
for i,l in enumerate(lines,1):
    if 'pub(crate) fn compile_retrace' in l or 'fn compile_retrace' in l:
        start=max(1, i-10); end=i+80
        break
print('\n'.join(f'{j}: {lines[j-1]}' for j in range(start,end+1)))
PY

printf '\npyjitpl.rs compile_retrace exact token handling and retry:\n'
rg -n 'compile_retrace|pending_preamble_tokens|prior_front_target_tokens|must_compile_with_values' majit/majit-metainterp/src/pyjitpl.rs -C4 --max-count 200

printf '\ncompile.py exact compile/compile_retrace token/PreambleCompileData area:\n'
python3 <<'PY'
from pathlib import Path
p=Path('majit/majit-metainterp/src/compile.py')
lines=p.read_text().splitlines()
for needle in ['def compile_retrace(', 'def compile_loop(', 'pending_preamble_tokens', 'if invalid', 'PreambleCompileData']:
    print(f'\n--- {needle} ---')
    count=0
    for i,l in enumerate(lines,1):
        if needle in l:
            start=max(1, i-20); end=min(len(lines), i+80)
            print('\n'.join(f'{j}: {lines[j-1]}' for j in range(start,end+1)))
            count+=1
            if count>=2:
                break
PY

Repository: youknowone/pyre

Length of output: 27293


Clear pending_preamble_tokens in invalidate_compiled_trace.

A key can retain preamble target tokens after an InvalidLoop/panic before it is ever recompiled. When this path retires matching green keys, it should consume the same pending tokens that remove_compiled_loop clears, in addition to dropping compiled_loops and the loop-header side tables.

♻️ Possible fix
     pub fn invalidate_compiled_trace(&mut self, trace_id: u64) {
         let stale: Vec<u64> = self
             .compiled_loops
             .iter()
             .filter(|(_, entry)| entry.root_trace_id == trace_id)
             .map(|(green_key, _)| *green_key)
             .collect();
         for green_key in stale {
             self.compiled_loops.swap_remove(&green_key);
+            self.pending_preamble_tokens.swap_remove(&green_key);
             self.forget_loop_side_tables(green_key);
         }
     }
📝 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
/// Drop every compiled loop whose root trace is `trace_id`, with the
/// per-loop side tables that belong to it.
pub fn invalidate_compiled_trace(&mut self, trace_id: u64) {
let stale: Vec<u64> = self
.compiled_loops
.iter()
.filter(|(_, entry)| entry.root_trace_id == trace_id)
.map(|(green_key, _)| *green_key)
.collect();
for green_key in stale {
self.compiled_loops.swap_remove(&green_key);
self.forget_loop_side_tables(green_key);
}
}
/// Drop every compiled loop whose root trace is `trace_id`, with the
/// per-loop side tables that belong to it.
pub fn invalidate_compiled_trace(&mut self, trace_id: u64) {
let stale: Vec<u64> = self
.compiled_loops
.iter()
.filter(|(_, entry)| entry.root_trace_id == trace_id)
.map(|(green_key, _)| *green_key)
.collect();
for green_key in stale {
self.compiled_loops.swap_remove(&green_key);
self.pending_preamble_tokens.swap_remove(&green_key);
self.forget_loop_side_tables(green_key);
}
}
🤖 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-metainterp/src/pyjitpl.rs` around lines 9097 - 9110, Update
invalidate_compiled_trace to clear the pending_preamble_tokens associated with
each retired green_key, matching the cleanup performed by remove_compiled_loop.
Preserve the existing compiled_loops removal and forget_loop_side_tables calls
while ensuring no pending tokens remain for invalidated keys.

Comment on lines +2906 to +2916
let headerless = descr.as_size_descr().is_some_and(|sd| sd.headerless());
let ptr = Some(size.max(1))
.filter(|_| headerless)
.map(|n| majit_gc::alloc_nursery_headerless_no_collect(n).0 as i64)
.filter(|p| *p != 0)
.unwrap_or_else(|| {
let layout = std::alloc::Layout::from_size_align(size.max(1), 8)
.expect("BC_NEW: invalid struct layout");
let raw = unsafe { std::alloc::alloc_zeroed(layout) };
raw as i64
});

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 | 🟠 Major | ⚡ Quick win

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
# Does any headerless nursery allocation path zero the returned block?
ast-grep run --pattern 'fn alloc_nursery_headerless($$$) -> GcRef { $$$ }' --lang rust majit/majit-gc/src
ast-grep run --pattern 'fn alloc_nursery_headerless_no_collect($$$) -> GcRef { $$$ }' --lang rust majit/majit-gc/src majit/majit-backend-dynasm/src
rg -n --type=rust -C5 'write_bytes|zeroed|memset' majit/majit-gc/src/nursery.rs

Repository: youknowone/pyre

Length of output: 2382


🏁 Script executed:

#!/bin/bash
set -euo pipefail

echo "== Relevant files =="
git ls-files | rg 'majit/majit-gc/src/lib.rs|majit/majit-metainterp/src/pyjitpl/dispatch.rs' || true

echo
echo "== GC allocation implementations =="
sed -n '160,185p;1735,1770p' majit/majit-gc/src/lib.rs

echo
echo "== dispatch allocation context =="
sed -n '2893,2922p' majit/majit-metainterp/src/pyjitpl/dispatch.rs

echo
echo "== Headerless allocator references/config =="
rg -n --type=rust -C3 'headerless|alloc_nursery_headerless_no_collect|alloc_nursery_headerless' majit/majit-gc/src majit/majit-metainterp/src/pyjitpl/dispatch.rs

Repository: youknowone/pyre

Length of output: 24195


🏁 Script executed:

#!/bin/bash
set -euo pipefail

echo "Search for headerless allocator implementations / hooks:"
rg -n --type=rust -C4 'add_alloc_nursery_headerless|alloc_nursery_headerless|set_active_alloc_nursery_headerless_no_collect|call_malloc_nursery_headerless|call_malloc.*headerless' .

echo
echo "Search for headerless zeroing / write_bytes nearby:"
rg -n --type=rust -C3 'headerless|zeroed|write_bytes|memset' majit/majit-gc/src majit/majit-backend-dynasm/src majit/majit-metainterp/src/pyjitpl/dispatch.rs | sed -n '1,220p'

Repository: youknowone/pyre

Length of output: 39918


Zero the block returned by the headerless nursery allocator.

The active headerless path returns a raw bump-pointer nursery block with no zeroing, while the fallback host path uses alloc_zeroed and the surrounding comment still describes bh_new as “malloc + zero”. Zero the returned nursery object after alloc_nursery_headerless_no_collect succeeds, or tighten these sites' guards with an explicit zeroing invariant at the allocator boundary.

🤖 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-metainterp/src/pyjitpl/dispatch.rs` around lines 2906 - 2916,
Update the headerless allocation path in the BC_NEW dispatch logic so a
successful alloc_nursery_headerless_no_collect result is zero-initialized before
being returned, preserving the alloc_zeroed behavior of the fallback path and
the surrounding malloc-plus-zero contract.

@youknowone

Copy link
Copy Markdown
Owner Author

Rebased onto origin/main (948340d19e) and added one commit.

jit: skip the finish_setup republish when neither writer buffer grewensure_finish_setup runs on every jitcode_for and built its arguments by cloning Assembler.insns and Assembler.all_liveness whole. Since jit: seed the runtime assembler's insns from the build-time opcode table and jit: resume the build-time liveness buffer instead of forking the pool seed both buffers from the build-time tables, that copy became proportional to the whole opcode and liveness universe, once per blackhole resume. assembler.py:29-31 only appends to them, so equal lengths mean equal contents and the snapshot is now skipped when neither grew.

Measured on bench/synth/depth{2,3,7}_inline_chain_typeflip, which run 119873 guard failures with identical trace structure on both sides:

depth2 depth3 depth7
origin/main 1.78s 2.47s 4.50s
this branch, before the fix timed out
this branch, after 1.65s 1.85s 3.61s

(user+sys CPU, min of 3. Before the fix, wall clock was 5.56s / 7.38s on depth2 / depth3 against 1.95s / 2.54s for origin/main; depth3 and depth7 timed out under check.py.)

python3 ./pyre/check.py --backend dynasm: 2 failed / 329 passed. Both failures are independent of this branch — synth/getframe_force_cancel_journal (pre-existing wrong output) and synth/const_arg_call_resume, whose ratio gate divides by a 0.01s pypy denominator; its CPU time is at parity with origin/main (0.27–0.29s here vs 0.26–0.34s there).

commented by Claude

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

💡 Codex Review

https://github.com/youknowone/pyre/blob/14e33b1be05dc433cf0f616ab4ff81a9843eba61/pyre-interpreter/src/jit_fnaddr.rs#L1342-L1346
P1 Badge Register list methods under their defining modules

When the default-enabled jd1 unpack loop exits a guard inside w_list_append, these bindings do not resolve the resumed store: Charon identifies the methods by their defining paths (listobject::W_ListObject::object_push, int_array::IntArray::push, and float_array::FloatArray::push), while the new entries register only the crate-root re-export spellings. Because target_to_path finds the module-qualified function graph first, fnaddr_for_target misses these entries and retains a symbolic hash; the blackhole then aborts instead of appending the already-fetched item. Register both the defining-module and root aliases, as the nearby PyFrame method bindings do.

AGENTS.md reference: AGENTS.md:L194-L196

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

@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

https://github.com/youknowone/pyre/blob/a439550d6966d7b0150e7437fc9a47cd4d6a15a6/pyre-interpreter/src/jit_fnaddr.rs#L1342-L1345
P1 Badge Register module-qualified paths for the push methods

When a resumed append resolves these extracted inherent methods through target_to_path, the path is module-qualified (listobject::W_ListObject::object_push, and likewise int_array::IntArray::push / float_array::FloatArray::push); fnaddr_for_target returns immediately for that resolved path and never tries the graph-less two-segment fallback. These root-only registrations therefore miss, leaving the symbolic address that the blackhole rejects and still dropping the in-flight append item. Register both root and module-qualified spellings with push_alias_pair, as the nearby PyFrame method bindings do.

AGENTS.md reference: AGENTS.md:L14-L20

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

@youknowone
youknowone force-pushed the aheui branch 2 times, most recently from 90e298c to 88992d0 Compare July 28, 2026 07:21

@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: 88992d03a4

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

for m in members {
match descr_from_set_member(m) {
SetMemberLookup::Resolved(d) => out.push(d),
SetMemberLookup::AbsentContainer => {}

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 effects for lazily registered descriptors

When a build-time call's effect set references a container that is absent during rehydrate_build_descr_raw_sets but is registered lazily later, this arm permanently drops the member from the frozen raw set. compute_bitstrings can then classify the call as not writing that descriptor, allowing the heap optimizer to reuse a stale field or array read across a call that actually mutates it and silently miscompile the program. Retain and re-resolve these keys as the descriptor universe grows, or conservatively degrade the effect to random effects instead of treating absence as an empty effect.

AGENTS.md reference: AGENTS.md:L194-L196

Useful? React with 👍 / 👎.

Comment on lines +2965 to +2966
} else if type_id != 0 {
majit_gc::alloc_oldgen_typed(type_id, size).0

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 Root GC objects held only in tracer registers

When BC_NEW creates a headered GC-managed object and a collecting residual call or user gc.collect() runs before that object escapes the machine register bank, the only reference is the raw pointer stored by set_ref_reg. Allocating it in old generation prevents movement during a minor collection but does not make it a GC root, so a major collection can sweep the object and leave subsequent SETFIELD or GETFIELD operations dereferencing freed memory. Expose the live reference registers to the collector or keep the new object temporarily rooted across collecting operations.

Useful? React with 👍 / 👎.

…GE_ONLY parsing

Review findings from PR #773.

`loop_header_pcs` and the new `loop_header_greens` were inserted on compile but
never removed, so both outlived `compiled_loops` and grew without bound.
`remove_compiled_loop` and the memmgr eviction path in `try_to_free_some_loops`
now drop the retired key from both. `compiled_key_for_greens` already required
`has_compiled_targets`, so a leftover entry could not resolve a bridge onto a
retired loop; this is the growth fix, not a targeting fix.

`MAJIT_BRIDGE_ONLY` parsed its list with `filter_map(.. .ok())`, so a single
unparsable entry was dropped and `MAJIT_BRIDGE_ONLY=oops` yielded an empty
allowlist — which suppresses every bridge, the inverse of the documented
unset-means-all default, with no diagnostic. An unparsable entry now panics.

The two `fieldnums` arity checks in `resume_box_reader.rs` are unconditional
`assert_eq!` rather than `debug_assert_eq!`; release builds indexed past the
end and panicked without the message.

cargo test -p majit-metainterp --features dynasm: 1501 passed, 0 failed.

Assisted-by: Claude
… pool

`JitCodeMachine::run_one_step`'s `BC_NEW` arm allocated every struct with
`std::alloc::alloc_zeroed`, bypassing the GC. blackhole.py:1301-1310
`bhimpl_new` reaches the allocation through `cpu.bh_new(descr)`, so it always
lands in the pool the collector that owns the object manages.

A descr flagged `headerless` says the interpreter owns the struct in its own
collected pool: that is what `headerless_structs` declares, and what compiled
code allocates it from, through `call_malloc_nursery_headerless`. A host-heap
block there is invisible to that collector. aheui's copying collector
range-checks its nursery chunks in `forward_root`, so it neither traces
through such an object nor forwards the references hanging off it, and the
graph below it is left in from-space for the next collection to reuse.

The allocation must not collect. `BC_NEW` runs mid-jitcode with raw object
pointers live in the machine's own register bank -- the `getfield` result that
the `setfield` after the `new` consumes -- and that bank belongs to no root
set; unlike an interpreter-side allocation there is no successor to hand over
as a keep root. `GcAllocator::alloc_nursery_headerless_no_collect` carries
that requirement, defaulting to the collecting form, which is what a
non-moving collector wants.

Non-headerless descrs keep `alloc_zeroed` unchanged and `BC_NEW_WITH_VTABLE`
is untouched. aheui is the only consumer in the tree that declares
`headerless_structs`.

python ./pyre/check.py: dynasm 6 failed / 315 passed, cranelift 6 / 315,
wasm 3 / 315 -- the same failure set, test for test, as the commit this is
built on, confirmed by rerunning it with these three files reverted. The one
difference between the two runs is the measured ratio of the pre-existing
`const_arg_call_resume` perf-gate failure.

Assisted-by: Claude
`clear_compiled_loops`, `mark_all_loops_for_release` and
`invalidate_compiled_trace` removed `compiled_loops` entries without
touching `loop_header_pcs` / `loop_header_greens`. `clear_compiled_loops`
now clears both maps and `mark_all_loops_for_release` routes through it;
`invalidate_compiled_trace` moves to `MetaInterp`, where it drops the
side tables of each removed green key.

`MAJIT_BRIDGE_ONLY` values naming no index (empty, whitespace, bare
commas) produced an empty allowlist that rejected every guard without a
diagnostic. Parsing moves to `parse_bridge_only`, which panics in that
case.

Adds unit tests for the three eviction paths and the four parse cases.

Assisted-by: Claude
The `building_bridge` branch that leaves the export empty instead of
raising InvalidLoop had no trace. Log it under MAJIT_BRIDGE_DEBUG next to
the other `[bridgeB]` lines.

Probed with it: the branch does not fire on the aheui corpus
(logo/99bottles/99dan/quine/pi.jinseo) or on pyre/bench + pyre/extra_tests.

Assisted-by: Claude
`pypyjit_driver_descriptor` left `frame_value_count_fn` at None, so jd0's
`-live-` decode fell back to the process-global slot in
`majit_ir::resumedata`. That slot has two unarbitrated writers — this
crate's `ensure_finish_setup` and majit-metainterp's
`install_state_field_fvc`, each behind its own `Once` — so the last
registration wins, and a decode against the wrong store returns a
mistyped count rather than failing.

Only `ensure_finish_setup` runs today: pyre's jd1 dispatch body does not
lower, so `register_dispatch_jitcode` is skipped and
`install_state_field_fvc` is never reached (measured on
pyre/bench/{nbody,fib_recursive,int_loop} and an unpackiterable drain).

Set the field to `frame_value_count_at`, the same shape jd1 already uses
in `unpackiterable_driver_descriptor`, so `active_frame_value_count_fn`
resolves both drivers off the driver rather than the global.

check.py: dynasm 5/316, cranelift 6/315, wasm 3/315 — the same
correctness failures as HEAD, differing only in the const_arg_call_resume
perf ratio.

Assisted-by: Claude
`MetaInterpStaticData` gains `jitcodes`, the flat table `resume.py:1051`
indexes (`warmspot.py:281-282` installs it there). `register_dispatch_jitcode`
publishes its drained worklist into it through `MetaInterp::install_jitcodes`,
and the two `resolve_jitcode` closures read it, so `JitDriver`'s own
`jitcode_registry` copy is gone.

The portal JitCode moves to `JitDriverStaticData::mainjitcode`, which had no
writer (`call.py:147`), at the driver's own registered slot
(`call.py:46-47 jd.index`). `JitDriver` keeps only that slot index and
`dispatch_jitcode()` reads through it, replacing the driver-local
`Option<Arc<JitCode>>`. `call.py:148`'s back-pointer has no counterpart: the
metainterp-side `JitCode` carries no `jitdriver_sd` slot, only the
translate-side one does.

aheui logo/99bottles/99dan/quine byte-identical between --jit and --no-jit
(logo md5 7fcdbfff0af449c4283c008e3ca317ce); pi.jinseo prefix-identical at
12288 B with 0 FREE/ALLOC-OUTSIDE-CHUNKS; majit-metainterp 1418 passed;
aheui-runtime 18 passed.

Assisted-by: Claude
…ge dead

The preview in `optimize_with_constants_and_inputs_at` exports its virtual
state from `post_force_args` and re-matches that same list, so every
`state[i]` derives from `args[i]` and `make_inputargs_and_virtuals` cannot
raise VirtualStatesCantMatch there. Both arms of the `building_bridge` branch
are therefore unreachable.

Measured: five virtual-carrying fixtures (escaping tuple, escaping instance,
aliased list, varying-length array, nested virtual), two of which compile
bridges, produce zero hits — as do the aheui corpus and pyre/bench +
pyre/extra_tests.

Adds `export_state_re_matched_against_its_own_args_cannot_fail`, which fails
if the preview stops being a self-match. Upstream matches against a different
loop's stored state in `jump_to_existing_trace` (unroll.py:207), so moving to
that shape trips the test and flags the branch as newly live.

Assisted-by: Claude
`Assembler::resuming_build_time_liveness` seeds the runtime codewriter's
`all_liveness` with `jitcode_runtime::all_liveness()`, and
`AssemblerState::new` seeds the reader-side mirror the same way, so a
`publish_state` wholesale replace cannot rewind past the prefix. Every
production `publish_state` caller now publishes a buffer carrying it
(`Assembler::finished`, `encode_liveness_info`); the hand-built-buffer
callers are all `#[cfg(test)]`.

With the build-time bytes addressable from `metainterp_sd.liveness_info`,
`blackhole_resume_via_rd_numb` drops its `novable` pick between the two
pools and reads the one `resume.py:1022` reads, and
`build_time_frame_value_count_at` reads it too — only its jitcode-table
lookup stays per-driver.

The `-live-` operand is 2 bytes, so the pool is capped at 64 KiB; the
build-time prefix is 6952 of those bytes and the overflow assert now
reports both halves.

Adds `assembler_state_resumes_the_build_time_liveness_prefix`: losing the
prefix does not fail loudly, it lands a baked offset inside an unrelated
runtime triple and returns a mistyped value count.

Assisted-by: Claude
`AssemblerState::new` and `Assembler::resuming_build_time_liveness` resumed
the build-time `all_liveness` buffer but started `insns` empty.
`blackhole.py:55-61` recovers `op_live` as `asm.insns['live/']`, so
`MetaInterpStaticData.op_live` stayed at its unset sentinel,
`blackhole_control_opcodes()` returned -1, and `can_decode_live_vars` looked
for 255 as a marker byte and declined every resume against a build-time
jitcode.

Both sides seed from `jitcode_runtime::insns_opname_to_byte()`;
`publish_state` replaces `asm.insns` wholesale, so seeding one side alone
does not hold.

Assisted-by: Claude
…naddrs

The #171 append fold descends `w_list_append` as a sub-jitcode walk, so a
guard exit inside that body is numbered against `w_list_append`'s own jitcode
and resumed there. The resumed body reaches its per-strategy store —
`W_ListObject::object_push`, `IntArray::push`, `FloatArray::push` — each a
`residual_call` whose funcptr the codewriter left as a
`symbolic_fnaddr_for_path` hash, so the blackhole aborted the frame. The jd1
drain then fell back to the interpreter after `next()` had already produced
an item, losing one element per compiled-loop entry
(`bench/synth/unpack_drain_star_raise.py` printed 47941 instead of 48000).

`fnaddr_for_target`'s `CallTarget::Method` fallback keys on
`CallPath::for_impl_method(receiver, name)`, which
`register_macro_helper_trace_fnaddr` derives by stripping the leading crate
segment, hence the `pyre_object::<Type>::<method>` spelling. `object_push`
becomes `pub` because the binding takes its address.

`bhimpl_inline_call_*` calls `cpu.bh_call_*(adr2int(jitcode.fnaddr))`, so the
`w_list_append` and `w_list_len` jitcode shells are bound too.

The symbolic-funcptr decline now names the jitcode and position.

Assisted-by: Claude
…ONLY removal

The rebase onto origin/main takes upstream's deletion of `bridge_only_allows`
/ `parse_bridge_only`; the unit tests for the parser came along with this
branch's hardening commits and no longer name anything.

Assisted-by: Claude
`ensure_finish_setup` runs on every `jitcode_for`, and it built its
arguments by cloning `Assembler.insns` and `Assembler.all_liveness`
whole. Both are now seeded from the build-time tables, so every call
copied the entire opcode and liveness universe and handed it to
`finish_setup_if_needed`, which rewrote the same `op_*` ids and rebuilt
the same `liveness_info` Arc from it.

`assembler.py:29-31` only appends to those two buffers, so equal lengths
mean equal contents. `MetaInterpStaticData` now records the `insns`
length its cached opcode ids were read off, and `ensure_finish_setup`
compares both lengths before taking the snapshot.

bench/synth/depth{2,3}_inline_chain_typeflip run 119873 guard failures
with identical trace structure on both sides, so the copies landed once
per blackhole resume: wall clock was 5.56s/7.38s before this change
against 1.95s/2.54s for the same fixtures at origin/main. After it,
user+sys CPU over depth{2,3,7} (min of 3) is 1.65s/1.85s/3.61s here
against 1.78s/2.47s/4.50s at origin/main.

Assisted-by: Claude
…eld_descr cache

`PYRE_FIELD_IDENTITY_CENSUS=1` walks `all_descrs()` at process exit and reports,
per `BhDescr::Field`, whether the `DescrRef` `make_descr_from_bh` produces is the
same `Arc` `descr.py:218-239 get_field_descr` holds for that
`(STRUCT, fieldname)` key.

`effectinfo.py:465-547 compute_bitstrings` partitions descrs by object identity,
so carrying `EffectInfo`'s raw `_*_descrs_*` sets across `descrs.bin` only means
anything if each rehydrated member lands on the descr the trace itself caches.
Today the census reports 422 Field slots, 325 keyed, 0 converging: the
`_cache_size[key].all_fielddescrs()` list and `_cache_field[key][name]` are
separate mints, and `W_ListObject`'s fields carry dot-qualified build-time names
against bare runtime keys.

Also drops the trailing blank line rustfmt flagged in jitdriver.rs.

Assisted-by: Claude
…eader

The census compared one resolution against `_cache_field` and reported a
single converged count.  Two resolutions exist: `field_descr_ref_from_bh`
(`pyjitpl/dispatch.rs`), which reads `_cache_field[STRUCT][fieldname]` and is
the Arc baked into recorded getfield/setfield ops, and
`field_descr_from_bh_field` (`pyre-jit-trace/src/descr.rs`), which walks
`_cache_size[STRUCT].all_fielddescrs()` and fills the build-time descr pool.
Export the former and report both against `_cache_field`, plus their agreement
with each other and how often the pool Arc came from `all_fielddescrs()`.

Misses are split into `no _cache_size[STRUCT]` / `no _cache_field[STRUCT]` /
`name not in _cache_field[STRUCT]` / `different Arc`, and the samples carry the
owner, `index_in_parent`, the parent's `all_fielddescrs` length and the
`_cache_field` key set.

On `append_hot.py` this reports 422 Field slots, 325 keyed: pool converges 124,
walker 274, pool==walker 124/325, with 42 name misses whose `_cache_field` keys
are inner-struct field names (`block`, `len`) registered under the outer struct
key.

Assisted-by: Claude
`SimpleFieldDescr`, `SimpleFieldDescrSpec` and `BhFieldSpec` gain
`field_key` — `descr.py:227`'s `fieldname` cache key, kept separate from
the display `name` (`'%s.%s' % (STRUCT._name, fieldname)`). The key was
previously recovered by `rsplit_once('.')` on the concatenated name,
which turned `int_items.len` into `len`.

`make_simple_descr_group_keyed_with_headerless` and
`build_object_descr_group_with_def_path` now obtain their fields from
`GcCache::get_field_descr` instead of minting fresh Arcs inside
`Arc::new_cyclic`, and the walker's `field_descr_ref_from_bh` name-miss
branch routes through the same cache-or-mint. `get_field_descr` takes
`index` / `virtualizable`; `SimpleFieldDescr::parent_descr` becomes
interior-mutable so a later `register_keyed_size` can re-point it.
`register_keyed_field` is first-write-wins.

`PyreObjectDescrGroup` carries its own field list instead of indexing
`size_descr.all_fielddescrs()`, which is positional by
`index_in_parent` (`heaptracker.py:76-101 get_fielddescr_index_in`) and
need not agree with the pyre static table's order.

`bh_all_field_specs_for_struct_into` flattens inline sub-structs with
the root owner, a dotted `field_key` and an absolute offset.

Field-identity census on a list-append workload: pool 124/325 -> 320/323
resolving to the `_cache_field` Arc, walker 274/325 -> 319/323.

Assisted-by: Claude
`descr_index` is stamped off the process-global `GcCache`
(`descr.py:28 v.descr_index = len(all_descrs)`), but `all_descrs` was a
per-`MetaInterpStaticData` field. pyre carries two of those objects —
the tracing walker's thread-local one and the one `JitDriver`'s
`MetaInterp` owns — and only the former ran `finish_setup_descrs`, so
the numbering was assigned off a list nothing consumes while the
consumed list stayed empty. `ensure_descr_index` then returned the
already-assigned global index without appending, and
`bridgeopt.py:155 metainterp_sd.all_descrs[descr_index]` indexed a
zero-length vec (`index out of bounds: the len is 0 but the index is 8`
on bridge_branchy_callee, inline_multiframe_drain_journaled_store,
inline_multiframe_module_branch_deopt, fannkuch).

The storage moves to `descr_registry::ALL_DESCRS`;
`MetaInterpStaticData::all_descrs()` is the accessor. Upstream keeps the
list on `metainterp_sd` because there is one `metainterp_sd` built from
one `cpu.setup_descrs()`.

The six optimizer seeds change from `std::mem::take` of the slot to a
clone: emptying it for the duration of an optimize left any reader
inside that window with a zero-length universe.

Assisted-by: Claude
`descr.py:25-47 setup_descrs` numbers `all_descrs` once and
`descr.py:28 v.descr_index = len(all_descrs); all_descrs.append(v)` only
ever appends, so a write-back shorter than the published list is never a
new universe. `unroll.rs` hands the list to each phase with
`std::mem::take` and restores it on the way out; an early exit between
the two leaves the outer `UnrollOptimizer` holding an empty vector, which
`compile_loop` then publishes, invalidating every `descr_index` already
serialized into a compiled bridge (`index out of bounds: the len is 0 but
the index is 203` from `deserialize_optimizer_knowledge` on fannkuch).

Assisted-by: Claude
The six raw sets of `effectinfo.py:128-145 frozenset_or_none`
(`_readonly_descrs_fields`, `_write_descrs_fields` and the array and
interiorfield pairs) hold `Arc<dyn Descr>` and were `#[serde(skip)]`, so
every call descr read back from `descrs.bin` came up with them `None` —
the shape `effectinfo.py:149-162` reserves for `EF_RANDOM_EFFECTS`.
`compute_bitstrings` reads the two shapes oppositely, so a deserialized
concrete EI had its bitstrings cleared instead of classified.

Each member is now serialized as the gccache key the analyzer minted it
through: `DescrSetMember::{Field, Array, InteriorField}` carries the
`(struct_id, field_name)` / `(array_id)` / `(array_id, name)` tuple that
`descr.py:218-239 get_field_descr`, `descr.py:348-378 get_array_descr`
and `descr.py:404-437 get_interiorfield_descr` key their caches on. Both
halves of the split agree on those tuples by construction.

`rehydrate_build_descr_raw_sets` resolves them before
`finish_setup_descrs` and re-derives `single_write_descr_array`
(`effectinfo.py:201-206`, also serde-skipped and read by
`heap.rs force_from_effectinfo`). It first materializes every non-call
pool slot, so each parent publishes its full
`heaptracker.all_fielddescrs(STRUCT)` list before any member is looked
up.

Resolution is lookup-only. Minting through a member would publish a
parent `SizeDescr` with an empty field list and win `_cache_field` by
first-write, breaking the `heaptracker.py:76-101 get_fielddescr_index_in`
positional invariant that `optimizeopt/info.rs force_box` asserts. A
member whose container is absent from this process's descr universe is
dropped — no recorded operation can carry a descr for it; a member whose
container is published but whose key misses degrades the EI to the
wildcard instead.

Measured on the append/loop corpus: 92 EIs rehydrated, 2 degraded.

Assisted-by: Claude
…to the caller's field

`PYFRAME_DESCR_GROUP` named `"PyFrame.w_globals"` twice at
`PYFRAME_W_GLOBALS_OFFSET`, at positions 4 and 12 of the field list, and
`pyframe_w_globals_obj_descr` read position 12. `index_in_parent` is the
position, so the two entries described the same slot under two different
`heaptracker.py:76-101 get_fielddescr_index_in` answers; routing field
descrs through `GcCache::get_field_descr` then collapsed them onto one
cached `Arc` whose `index_in_parent` was whichever minted first. The
duplicate was the last entry, so dropping it shifts nothing; the accessor
moves to position 4.

`descr.py:218-239` derives offset, size, flag, `_immutable_fields_` rank
and `index_in_parent` from `(STRUCT, fieldname)` itself, so a cache hit
upstream cannot describe a different field than the caller means. Pyre
passes them in, so two call sites can disagree and the cache silently
keeps the first mint. `SimpleFieldDescr::describes_same_field` states the
invariant and a `debug_assert` in the cache-hit path enforces it; `index`
is excluded because it is the per-trace codewriter slot id the analyzer
legitimately restamps.

`check.py --backend dynasm` built with `-C debug-assertions=on` reports
no violation over the whole corpus: 2 failed / 329 passed, both failures
pre-existing.

Assisted-by: Claude
…tracer's ref setfield

`runner.rs bh_new` allocated every struct with `libc::malloc`, ignoring the
descr's `type_id` that its `bh_new_with_vtable` sibling already honours. The
two now share `bh_alloc_struct`, which routes a headered GC-managed descr to
the non-moving old generation, a headerless one to the interpreter's own
headerless nursery, and keeps the zeroed malloc for `type_id == 0` and for a
runtime with no allocator hook installed.

`pyjitpl/dispatch.rs BC_NEW` took the same shape one layer up: only the
headerless case reached the GC, and everything else went to
`std::alloc::alloc_zeroed`. A headered GC-managed descr now allocates in the
old generation there too; both GC paths are no-collect and old-gen is
mark-sweep, so the pointer the tracer keeps in its register bank stays valid.

`BC_SETFIELD_GC_R` wrote the field with a raw store and no write barrier,
unlike the `BC_SETARRAYITEM_GC_R` arm next to it and unlike
`bh_setfield_gc_r`. It now notifies the GC on the container.

`BhDescr::is_headerless` replaces the `owner == "__majit_headerless_size__"`
comparison open-coded in `jitcode/assembler.rs` and twice in `dispatch.rs`;
the marker constant moves next to the enum it tags.

Assisted-by: Claude
…alizer

`install_global_build_descr_pool` materialized the whole pool — one clone per
`BhDescr` in the binary, each call descr carrying its `EffectInfo` raw descr
sets, plus a `JitCode::from_canonical` per jitcode entry — and then handed it
to `OnceLock::set`, which drops it once a pool is installed.

`drive_unpack_iterable_trace` calls it before every
`_unpackiterable_unknown_length` walk, so on an unpack-heavy program that
build-and-drop dominated: on `bench/synth/exception_subclass_attrs.py` a
`sample` run put 233 of 2465 main-thread samples in
`install_global_build_descr_pool`, 128 of them in the `Arc<JitCode>` drop of
the discarded pool. Measured CPU (user+sys, min of 3) goes 7.26s -> 4.48s.

`set_global_build_descr_pool(pool)` becomes
`init_global_build_descr_pool(build)`, which runs the closure from inside
`OnceLock::get_or_init`.

Assisted-by: Claude
…oder

`build_object_descr_group_with_def_path` used to build a `PyreSizeDescr`,
whose `w_class_obj` reads `get_instantiate(vtable)` live. Routing it through
`make_simple_descr_group_keyed_with_headerless` made every runtime PyObject
group a `SimpleSizeDescr`, which inherits the trait default `None`.

`OptVirtualize`'s `w_class` getfield arm (virtualize.rs:860-894) folds the
header read off a `new_with_vtable` virtual to that constant, and takes the
"class identity unresolved -> force the virtual" exit when it is `None`. The
forced `W_IntObject` then reads `w_class` out of its own freshly allocated,
uninitialised memory and guards on the `PtrEq`, so the guard fails on most
iterations. On `bench/synth/exception_subclass_attrs.py`: guard failures
71654, bridges 331, CPU 4.5s.

`SimpleSizeDescr::w_class_obj` now goes through
`majit_ir::descr::set_w_class_obj_resolver`, which pyre registers in
`install_jit_call_bridge` alongside the `str`/`unicode` green resolvers, and
`PyreSizeDescr::w_class_obj` calls the same decoder. The hook also covers the
size descrs `size_descr_ref_from_bh` mints inside majit-metainterp, which
could not carry a pyre override at all.

Same corpus entry after: guard failures 42, bridges 0, CPU 0.65s — equal to
the branch base on all three.

Assisted-by: Claude
…id not

`orthodox_list_append_commit` ended with an unconditional `w_list_append` on
the premise that the descended sub-walk records the store as IR without
touching the concrete list. The per-strategy store the arm reaches
(`W_ListObject::object_push`, `IntArray::push`, `FloatArray::push`) is a
`residual_call`, and `try_execute_residual_call_via_executor` executes a
residual whose funcptr resolves to a real address rather than only recording
it, so on a target where the arm keeps them as residuals the sub-walk has
already appended and the fold appends the value a second time.

Re-read the receiver's length and append only when it is unchanged. The rewind
journal entry stays unconditional, so an aborted walk rewinds to `len_before`
whichever side grew the list.

Assisted-by: Claude
…the parent group

`make_descr_from_bh` bridged the codewriter's `W_ListObject` field names to the
canonical `W_LIST_DESCR_GROUP` entries only after the parent-struct lookup, so
whenever the codewriter modeled the parent the field ended up with two descrs:
the parent group's entry for a codewriter-lowered body and the canonical entry
for the walker-native list specializations. `MAJIT_LOG` shows both for
`int_items.len` at offset 48 — index 5 (`index_in_parent` 5) and index
268436224 (`index_in_parent` 3).

The heapcache and the optimizer's heap pass key on descr identity, so the
`w_list_append` sub-walk's `SetfieldGc(int_items.len)` did not invalidate the
`len(xs)` read that followed it, and the read folded to the pre-append length:
one skipped `list.pop(0)` in the first compiled iteration, after which the
steady-state length stays one too high.

Run the bridge before the parent-group lookup. Fixes `list_ops`,
`delete_negative_open_slice_hot`, `exception_residual_raise_caught_in_frame`,
`sre_pattern_methods` on all three backends and wasm
`comprehension_object_append_hot`'s output.

Assisted-by: Claude
…d wasm backends

`register_active_hooks` installed `alloc_nursery_typed` but left
`alloc_nursery_headerless_no_collect` unset on these two backends, so
`majit_gc::alloc_nursery_headerless_no_collect` returned `GcRef(0)` and the
jitcode tracer's `NEW` on a `headerless` descr (`pyjitpl/dispatch.rs` BC_NEW)
fell through to `std::alloc::alloc_zeroed` on the host heap, where the
interpreter's collector cannot see it. The dynasm backend already registers it
(`runner.rs`).

Assisted-by: Claude
…ery bump

The arm called `gc_alloc_nursery_headerless_shim` out of line for every
allocation, spilling the ref roots and installing a gcmap each time. Both
dynasm backends already emit an inline bump for this opcode
(`genop_call_malloc_nursery_headerless`), and the cranelift `CallMallocNursery`
arm right below already emits one for the headered case.

Emit the same shape here, with the headerless deltas: bump by `size` alone (no
`GcHeader::SIZE` reservation), no header word zeroed, result is the old nursery
base. The slow path keeps the existing shim call with its spill / gcmap /
reload. A runtime reporting no bump surface (`nursery_free` / `nursery_top` at
0) stays on the helper.

aheui logo under cranelift, CPU time, 16 interleaved rounds over the runs that
produce the reference output: min 20.66s -> 9.08s, median 21.47s -> 11.18s.
`pyre/check.py --backend cranelift` 334/334; pyre declares no
`headerless_structs`, so the opcode does not occur there.

Assisted-by: Claude
… identity-less arraydescr fallback

`arraydescrof_concrete`'s branch for an array with no `array_type_id`
returned a fixed item size of 8. The named-element path
(`get_type_flag`) and the codewriter-less fallback in `assembler.rs`
already use `target_word_size()` for pointer elements; this branch now
matches them.

The list / tuple items-block `getarrayitem` / `setarrayitem` /
`arraylen` ops the #171 append fold emits carry no `array_type_id`, so
on wasm32 the descr placed items at `block+8` with an 8-byte stride
while the runtime `ItemsBlock` holds 4-byte items at `block+4`.

Assisted-by: Claude
`WasmBackend` inherited the `bh_arraylen_gc` trait stub, which returns
0, so every array length reached at trace time read as 0. The override
reads the word-width length prefix at `ArrayDescr.lendescr`, the same
offset and width `bh_new_array` stores.

`execute_token` allocates its `JitFrame` from the old-gen arena, whose
`ArenaCollection::malloc` returns recycled bytes, while `JitFrame::init`
requires zero-filled storage (the native `execute_token` uses `calloc`;
the wasm nursery zeroes on reset). Zero the allocation before `init`, so
a Ref home the trace has not defined when a collection lands reads as
null rather than as a stale word.

Drop the `!cfg!(target_arch = "wasm32")` gate on the `arraylen_gc`
constant fold in `opimpl_arraylen_gc`; it was there because the stub
made the fold bake `ConstInt(0)`.

Measured on `bench/synth/comprehension_object_append_hot` under wasm:
the 0 length made the #171 append fold bake the at-capacity arm, whose
guard then failed on most appends — 5926 compiles over 1.2M guard
failures, 98s. Now 24 compiles / 3610 guard failures, 0.35s. wasm
`check.py` 322/323 -> 323/323; dynasm and cranelift stay 326/326.

Assisted-by: Claude
…ort list

The two hunks `cargo fmt --all -- --check` reports on this branch.

Assisted-by: Claude
…et_field_descr

`get_field_descr` always minted the display name as `T<type_id>.<field>`,
so every field descr routed through the keyed group builder lost the
`Owner.field` spelling the caller already held. The non-keyed builder
(`make_simple_descr_group_inner`) writes `spec.name` verbatim, and
`PyreFieldDescr` stores `STRUCT.field`, so the keyed path was the odd one
out; `descr.py:227` names a field `'%s.%s' % (STRUCT._name, fieldname)`.

Add a `display_name: Option<&str>` argument. The two callers that carry a
qualified name — `make_simple_descr_group_keyed_with_headerless` (from
`SimpleFieldDescrSpec.name`) and `field_descr_from_bh_field` (from
`BhFieldSpec.name`) — pass it; the mint sites that only hold a bare field
key pass `None` and keep the `T<type_id>.` stand-in.

Fixes `descr::tests::make_descr_from_bh_field_preserves_parent_name_index`
and `descr::tests::make_descr_from_bh_struct_array_preserves_type_and_interior_fields`,
which have been red on this branch since field descrs started minting
through `GcCache::get_field_descr`.

Assisted-by: Claude
…reads

`MetaInterpStaticData::finish_setup_descrs` writes `set_descr_index`,
`set_ei_index` and `set_effect_bitstrings` onto descrs owned by the
process-global `GcCache`, but pyre holds `MetaInterpStaticData` in a
thread-local, so its `finish_setup_done` guard is per-thread. Two threads
reaching the publish together are two writers over one `EffectInfoCell`,
whose `set_bitstrings` is documented as single-writer: each drops the
`Vec<u8>` the other just installed.

`warmspot.py:289` has one writer by construction — `finish_setup` runs
once, in one process, before tracing. Take a process-global mutex for the
publish so that holds here too.

The crash it fixes is the `cargo test -p pyre-jit-trace --lib` abort
(`pointer being freed was not allocated` on macOS, `double free or
corruption (fasttop)` on Linux) whose faulting stack is
`ensure_finish_setup -> finish_setup_descrs -> set_effect_bitstrings ->
EffectInfoCell::set_bitstrings -> drop of Option<Vec<u8>>`. 9/15 runs of
the test binary aborted before, 0/25 after; single-threaded runs never
reproduced it.

Assisted-by: Claude
Codex parity review flagged the `AbsentContainer => {}` arm as an
unsound drop of a serialized write-set member. The premise the arm rests
on — "the container is absent, so no recorded operation can carry a descr
for it" — is evaluated once, when the `BhCallDescr` is materialized, while
the runtime descr universe keeps growing after that, so a container
registered later leaves the EI claiming "not written" for a field the
callee writes.

Document that, plus why the conservative repair is not taken here: a probe
over `bench/synth/comprehension_object_append_hot` counts 211 drops across
~40 distinct containers, so degrading each to `EF_RANDOM_EFFECTS` would
turn most residual calls into whole-heap barriers. Name the convergence
path (re-resolve from the retained `descr_set_keys` as the universe grows)
and its blockers.

No behavior change.

Assisted-by: Claude
@youknowone
youknowone merged commit c78f463 into main Jul 28, 2026
7 of 8 checks passed
@youknowone
youknowone deleted the aheui branch July 28, 2026 09:26
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