Skip to content

majit: split state-field resume frames by liveness and give up on multi-frame bridges - #773

Merged
youknowone merged 5 commits into
mainfrom
aheui
Jul 25, 2026
Merged

majit: split state-field resume frames by liveness and give up on multi-frame bridges#773
youknowone merged 5 commits into
mainfrom
aheui

Conversation

@youknowone

@youknowone youknowone commented Jul 25, 2026

Copy link
Copy Markdown
Owner

Splits state-field resume data into per-frame sections by liveness on the bridge
read path, and gives up on bridges whose resume data spans more than one frame.

Background

build_state_field_snapshot (pyre/pyre-jit-trace/src/state.rs, mirroring
resume.py's capture_resumedata) writes one section per MIFrame, outer to
inner, each section sized by its own jitcode's -live- liveness at its own pc.
The reader in majit-macros' rebuild_from_resumedata passed
frame_value_count = None, so it treated the whole stream as a single frame:
frame 0 swallowed the callee section's [jitcode_index, pc, py_pc] header plus
its values.

On the aheui pi.jinseo.aheui guard at pc=613 this decoded 14 values against 6
live root registers. setup_bridge_sym then declined to seed the bridge, the
bridge was compiled with create_sym's loop-namespace InputArgs anyway, and
OptIntBounds::propagate_forward panicked with
getintbound_handle: expected 'i'-typed operand, got Ref
(← compile_bridgeclose_bridge).

Changes

  • majit-metainterp/src/resume_box_reader.rs (new) — bridge-entry virtual
    materialization moved out of pyre-jit-trace/src/state.rs so the macro path
    and the pyre path share one reader. state.rs loses ~870 lines
    (emit_stroruni_oopspec_call, BridgeVirtualCache, decode_fieldnum,
    materialize_bridge_virtual); origin/main's profiler op counting is carried
    into the moved copy (15 count_ops sites, OPS + RECORDED_OPS).
  • majit-macros/src/jit_interp/codegen_state.rsrebuild_from_resumedata
    now decodes through the registered liveness splitter
    (majit_ir::resumedata::get_frame_value_count_fn()) instead of None, so
    each frame section is sized by its own jitcode/pc liveness
    (jitcode.py:147 enumerate_vars, resume.py:1049-1055).
  • majit-metainterp/src/jitdriver.rs — after rebuild_from_resumedata, a
    resume record with more than one frame gives up on the bridge
    (compile.py:725-729 compile.giveup()) using the existing tracing-session
    cleanup idiom, rather than compiling a bridge that cannot be seeded.
  • majit-metainterp/src/optimizeopt/optimizer.rs — the bridge-path preview
    virtual-state mismatch is no longer fatal.
  • MAJIT_BRIDGE_ONLY=<fail_index,...> — new env filter restricting bridge
    formation to the listed guard fail indices, applied at both should_bridge
    sites. Used to bisect which guard's bridge miscompiles; unset means "all",
    so there is no behaviour change by default.
  • majit-metainterp/src/jitdriver.rs, pyjitpl.rs, pyjitpl/dispatch.rs,
    trace_ctx.rs — a bridge closing on a merge point took its JUMP target from
    current_trace_green_key().unwrap_or(bridge_key), the green key of the loop
    the failing guard belonged to. When the trace closed on an inner merge
    point (the cross-loop cut) that key names a different loop than the one
    reached, and since pc is a green baked into each compiled loop as a
    constant, the JUMP entered a loop compiled for another pc while carrying the
    reached pc's reds. pyjitpl.py:3005-3007 reads
    ptoken = self.get_procedure_token(greenboxes) off the greens of the merge
    point just reached, and pyjitpl.py:3012-3060 keeps tracing (or compiles a
    loop) when that greenkey has no procedure token — it never substitutes
    another loop's token. Both close paths now record the closing merge point's
    greens on the trace ctx, each compiled loop registers the greens it closed
    on, and the bridge inverts those greens back to a key; when no compiled loop
    holds them, has_targets is false and the existing compile_loop
    fall-through (pyjitpl.py:3014-3017) runs. update_tracing_green_key,
    named by the previous comment as the retargeting mechanism, has no call
    sites.

Verification

  • python3 ./pyre/check.py — dynasm 308/308, cranelift 308/308, wasm 305/305.
  • aheui corpus (logo, 99bottles, 99dan, quine) — --jit output
    byte-identical to --no-jit.
  • The getintbound_handle panic is gone; bridge seed declines on
    pi.jinseo.aheui go 2 → 0, with 2 giveups in their place.

pi.jinseo.aheui was isolated to two independent defects, measured as a 2×2
matrix over bridges × collection. The bridge half is what this PR fixes:

bridges GC before after
on off diverges at byte 806 byte-identical (12288 of 15001 bytes)
on on SIGSEGV SIGSEGV (unchanged — GC defect)
off on diverges at byte 12 unchanged — GC defect
off off correct correct

Per-guard, with collection disabled: MAJIT_BRIDGE_ONLY=50 diverged at byte
806 and =17 at byte 2195; both are now byte-identical, as is the full
bridge set. {2,3,6,7}, {9} and {12} were already correct and remain so.

Still open

pi.jinseo.aheui --jit is still not correct with collection enabled — a
separate GC-layer defect, unchanged by this PR and tracked apart from it.
Quarantining from-space instead of releasing it removes the SIGSEGV but not
the wrong output, which points at a JIT-held Node reference missing from
the copying collector's root walk rather than at bridge compilation.

🤖 Generated with Claude Code

Summary by CodeRabbit

  • New Features

    • Improved bridge and loop handling for resumed execution, including accurate loop-target selection and multi-frame safeguards.
    • Added support for rebuilding virtual values and replaying deferred heap updates when entering bridges.
    • Enhanced state tracking for loop-header values and field descriptors.
  • Bug Fixes

    • Improved bridge optimization resilience when virtual-state previews cannot be matched.
    • Preserved pending field updates so subsequent operations observe correct values.
    • Added clearer diagnostics for missing or unseeded bridge state.

@coderabbitai

coderabbitai Bot commented Jul 25, 2026

Copy link
Copy Markdown

Review Change Stack

Warning

Review limit reached

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

Next review available in: 54 minutes

Enable usage-based reviews in Billing to review now. Otherwise, wait until the next included review is available.
You're only billed for reviews past your plan's rate limits ($0.25/file).

How can I continue?

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

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

How do review limits work?

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

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

Please refer docs for additional details.

Review details
⚙️ Run configuration

Configuration used: Organization UI

Review profile: ASSERTIVE

Plan: Pro Plus

Run ID: bf740212-2234-44c4-aefa-f9a26a5c7621

📥 Commits

Reviewing files that changed from the base of the PR and between 03db062 and 9884ea5.

📒 Files selected for processing (11)
  • majit/majit-ir/src/descr.rs
  • majit/majit-macros/src/jit_interp/codegen_state.rs
  • majit/majit-metainterp/src/jitdriver.rs
  • majit/majit-metainterp/src/lib.rs
  • majit/majit-metainterp/src/optimizeopt/optimizer.rs
  • majit/majit-metainterp/src/pyjitpl.rs
  • majit/majit-metainterp/src/pyjitpl/dispatch.rs
  • majit/majit-metainterp/src/resume_box_reader.rs
  • majit/majit-metainterp/src/trace_ctx.rs
  • pyre/pyre-jit-trace/src/descr.rs
  • pyre/pyre-jit-trace/src/state.rs

Walkthrough

Bridge resume reconstruction is centralized in majit-metainterp, then wired into bridge setup and state seeding. Loop-close greens are recorded for compiled-loop retargeting, while bridge eligibility and optimizer handling are adjusted for filtered and multi-frame bridge flows.

Changes

JIT bridge pipeline

Layer / File(s) Summary
Shared bridge reconstruction primitives
majit/majit-ir/src/descr.rs, majit/majit-metainterp/src/resume_box_reader.rs, majit/majit-metainterp/src/lib.rs, pyre/pyre-jit-trace/src/descr.rs
Adds canonical field-descriptor lookup and shared helpers for decoding resume values, materializing virtuals, and replaying pending heap writes.
Bridge entry state wiring
majit/majit-macros/src/jit_interp/codegen_state.rs, pyre/pyre-jit-trace/src/state.rs
Uses frame-aware resume decoding and shared bridge helpers for pending writes, virtual replay, and scalar/reference state seeding.
Close-green loop retargeting
majit/majit-metainterp/src/trace_ctx.rs, majit/majit-metainterp/src/pyjitpl/dispatch.rs, majit/majit-metainterp/src/pyjitpl.rs, majit/majit-metainterp/src/jitdriver.rs
Captures close-point greens, records them for compiled loops, and resolves bridge targets from matching compiled green tuples.
Bridge eligibility and optimization
majit/majit-metainterp/src/jitdriver.rs, majit/majit-metainterp/src/optimizeopt/optimizer.rs
Adds diagnostic bridge filtering, rejects unsupported multi-frame state-field bridges, and suppresses preview virtual-state mismatches during bridge optimization.

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

Possibly related PRs

Poem

A rabbit hops through bridge-state snow,
Rebuilding fields in rows below.
Greens point the loop-path bright,
Virtuals bloom in trace-light.
Pending writes now find their way—
Hop, compile, and bridge today!

🚥 Pre-merge checks | ✅ 5
✅ Passed checks (5 passed)
Check name Status Explanation
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.
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title clearly summarizes the main change: liveness-based frame splitting and rejecting multi-frame state-field bridges.
✨ Finishing Touches
🧪 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.

@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: 18e772a46b

ℹ️ 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 +5328 to +5331
if self.dispatch_jitcode.is_some()
&& resume_data_result
.as_ref()
.is_some_and(|r| r.frames.len() > 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 Preserve multi-frame bridges instead of rejecting them

For a state-field #[jit_interp] driver whose guard fails while a #[jit_inline] callee is active, this condition aborts every bridge attempt solely because the resume record contains multiple frames. The guard therefore continues deoptimizing through the blackhole and can repeatedly retrigger tracing without ever compiling the hot failure path. Restore and seed each frame—including the innermost resume coordinate—rather than permanently disabling bridges for this common inlining case.

AGENTS.md reference: AGENTS.md:L32-L42

Useful? React with 👍 / 👎.

@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 9884ea5).
Updated: 2026-07-25T09:07:19.861Z

Files in the reviewed diff
majit/majit-ir/src/descr.rs
majit/majit-macros/src/jit_interp/codegen_state.rs
majit/majit-metainterp/src/jitdriver.rs
majit/majit-metainterp/src/lib.rs
majit/majit-metainterp/src/optimizeopt/optimizer.rs
majit/majit-metainterp/src/pyjitpl.rs
majit/majit-metainterp/src/pyjitpl/dispatch.rs
majit/majit-metainterp/src/resume_box_reader.rs
majit/majit-metainterp/src/trace_ctx.rs
pyre/pyre-jit-trace/src/descr.rs
pyre/pyre-jit-trace/src/state.rs

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

sections 1 and 2 MUST cite our-side files from that list; a divergence in any
file NOT in the list is by definition not introduced by this patch — report
it under section 3 instead, or omit it. Verify every section-1/2 citation
against the list before finalizing the report.

---

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

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

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

Authoritative changed-file list for this patch (git diff upstream/main --name-only):
majit/majit-ir/src/descr.rs
majit/majit-macros/src/jit_interp/codegen_state.rs
majit/majit-metainterp/src/jitdriver.rs
majit/majit-metainterp/src/lib.rs
majit/majit-metainterp/src/optimizeopt/optimizer.rs
majit/majit-metainterp/src/pyjitpl.rs
majit/majit-metainterp/src/pyjitpl/dispatch.rs
majit/majit-metainterp/src/resume_box_reader.rs
majit/majit-metainterp/src/trace_ctx.rs
pyre/pyre-jit-trace/src/descr.rs
pyre/pyre-jit-trace/src/state.rs
warning: Codex could not find bubblewrap on PATH. Install bubblewrap with your OS package manager. See the sandbox prerequisites: https://developers.openai.com/codex/concepts/sandboxing#prerequisites. Codex will use the bundled bubblewrap in the meantime.
2026-07-25T09:06:39.823698Z ERROR rmcp::transport::worker: worker quit with fatal: Transport channel closed, when UnexpectedServerResponse("HTTP 500: {\n  \"error\": {\n    \"message\": \"The server had an error processing your request. Sorry about that! You can retry your request, or contact us through our help center at help.openai.com if you keep seeing this error. (Please include the request ID 3cc94a41-d919-4c6f-8e18-69c1d7da7eee in your email.)\",\n    \"type\": \"server_error\",\n    \"param\": null,\n    \"code\": null\n  }\n}")
2026-07-25T09:06:40.162138Z ERROR rmcp::transport::worker: worker quit with fatal: Transport channel closed, when UnexpectedServerResponse("HTTP 503: {\n  \"error\": {\n    \"message\": \"Service Unavailable\",\n    \"type\": null,\n    \"code\": \"biscuit_baker_service_me_circuit_open\",\n    \"param\": null\n  },\n  \"status\": 503\n}")
2026-07-25T09:06:41.254545Z ERROR rmcp::transport::worker: worker quit with fatal: Transport channel closed, when UnexpectedServerResponse("HTTP 503: {\n  \"error\": {\n    \"message\": \"Service Unavailable\",\n    \"type\": null,\n    \"code\": \"biscuit_baker_service_me_circuit_open\",\n    \"param\": null\n  },\n  \"status\": 503\n}")
2026-07-25T09:06:41.346007Z ERROR rmcp::transport::worker: worker quit with fatal: Transport channel closed, when UnexpectedServerResponse("HTTP 503: {\n  \"error\": {\n    \"message\": \"Service Unavailable\",\n    \"type\": null,\n    \"code\": \"biscuit_baker_service_me_circuit_open\",\n    \"param\": null\n  },\n  \"status\": 503\n}")
2026-07-25T09:06:41.581817Z ERROR codex_api::endpoint::responses_websocket: failed to connect to websocket: HTTP error: 503 Service Unavailable, url: wss://chatgpt.com/backend-api/codex/responses
2026-07-25T09:06:42.314092Z ERROR codex_api::endpoint::responses_websocket: failed to connect to websocket: HTTP error: 503 Service Unavailable, url: wss://chatgpt.com/backend-api/codex/responses
2026-07-25T09:06:42.577972Z ERROR rmcp::transport::worker: worker quit with fatal: Transport channel closed, when UnexpectedServerResponse("HTTP 503: {\n  \"error\": {\n    \"message\": \"Service Unavailable\",\n    \"type\": null,\n    \"code\": \"biscuit_baker_service_me_circuit_open\",\n    \"param\": null\n  },\n  \"status\": 503\n}")
2026-07-25T09:06:42.666918Z ERROR codex_api::endpoint::responses_websocket: failed to connect to websocket: HTTP error: 503 Service Unavailable, url: wss://chatgpt.com/backend-api/codex/responses
ERROR: Reconnecting... 2/5
2026-07-25T09:06:43.230934Z ERROR codex_api::endpoint::responses_websocket: failed to connect to websocket: HTTP error: 503 Service Unavailable, url: wss://chatgpt.com/backend-api/codex/responses
ERROR: Reconnecting... 3/5
2026-07-25T09:06:43.657922Z ERROR rmcp::transport::worker: worker quit with fatal: Transport channel closed, when UnexpectedServerResponse("HTTP 503: {\n  \"error\": {\n    \"message\": \"Service Unavailable\",\n    \"type\": null,\n    \"code\": \"biscuit_baker_service_me_circuit_open\",\n    \"param\": null\n  },\n  \"status\": 503\n}")
2026-07-25T09:06:44.166375Z ERROR codex_api::endpoint::responses_websocket: failed to connect to websocket: HTTP error: 503 Service Unavailable, url: wss://chatgpt.com/backend-api/codex/responses
ERROR: Reconnecting... 4/5
2026-07-25T09:06:45.882550Z ERROR codex_api::endpoint::responses_websocket: failed to connect to websocket: HTTP error: 503 Service Unavailable, url: wss://chatgpt.com/backend-api/codex/responses
ERROR: Reconnecting... 5/5
2026-07-25T09:06:49.595090Z ERROR codex_api::endpoint::responses_websocket: failed to connect to websocket: HTTP error: 503 Service Unavailable, url: wss://chatgpt.com/backend-api/codex/responses
warning: Falling back from WebSockets to HTTPS transport. unexpected status 503 Service Unavailable: Service Unavailable, url: wss://chatgpt.com/backend-api/codex/responses, cf-ray: a20a02c2ee49b9f2-SEA, auth error: 503, auth error code: biscuit_baker_service_me_circuit_open
ERROR: Reconnecting... 1/5
ERROR: Reconnecting... 2/5
ERROR: Reconnecting... 3/5
ERROR: Reconnecting... 4/5
ERROR: Reconnecting... 5/5
ERROR: unexpected status 503 Service Unavailable: Service Unavailable, url: https://chatgpt.com/backend-api/codex/responses, cf-ray: a20a037f9941a377-SEA, auth error: 503, auth error code: biscuit_baker_service_me_circuit_open
ERROR: unexpected status 503 Service Unavailable: Service Unavailable, url: https://chatgpt.com/backend-api/codex/responses, cf-ray: a20a037f9941a377-SEA, auth error: 503, auth error code: biscuit_baker_service_me_circuit_open

@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: 03db0628ad

ℹ️ 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 +2389 to +2391
let __fvc = majit_ir::resumedata::get_frame_value_count_fn();
let __fvc_ref: ::std::option::Option<&dyn Fn(i32, i32) -> usize> =
__fvc.as_ref().map(|f| f as &dyn Fn(i32, i32) -> usize);

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P1 Badge Keep frame liveness scoped to the owning JIT driver

When two independent #[jit_interp] drivers are installed on the same thread, register_dispatch_jitcode replaces the single STATE_FIELD_FVC TLS payload with the most recently installed driver's registry and liveness. This newly fetched global callback therefore decodes an earlier driver's rd_numb using the later driver's JitCodes, producing incorrect frame lengths that can turn values into frame headers and either reject or mis-seed the bridge. Pass the owning driver's registry/liveness into this decode instead of consulting shared TLS state.

AGENTS.md reference: AGENTS.md:L148-L162

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

🤖 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/jitdriver.rs`:
- Around line 2035-2047: Prune loop-header green entries whenever their compiled
loop is removed or invalidated: update the cleanup paths remove_compiled_loop,
invalidate_loop, and mark_all_loops_for_release to remove the corresponding keys
from the loop_header_greens map, mirroring loop_header_pcs management. Also
replace the unnecessary loop_close_greens.clone() in the close-loop recording
block with a move, since the binding is not used afterward.
- Around line 392-412: Remove the temporary bridge_only_allows diagnostic and
its gating from should_bridge, restoring bridge formation to the existing
behavior. If the helper is intentionally retained instead, update its parsing so
an all-invalid MAJIT_BRIDGE_ONLY value fails loudly or falls back to allowing
all bridges rather than producing an empty allowlist; remove the temporary
diagnostic labeling before merge.

In `@majit/majit-metainterp/src/optimizeopt/optimizer.rs`:
- Around line 3173-3188: Add regression tests covering the optimizer branch
around `building_bridge`: assert bridge optimization succeeds with
`exported_loop_state == None`, while normal-loop optimization still returns
`InvalidLoop`. In both successful and failing paths, also assert that
`building_bridge` is restored to its original value after optimization.

In `@majit/majit-metainterp/src/pyjitpl.rs`:
- Around line 8206-8227: Evict stale loop metadata when compiled loops are
retired: update remove_compiled_loop and the memmgr eviction path in
try_to_free_some_loops to remove the owning key from both loop_header_greens and
the existing loop_header_pcs map, alongside the other compiled-loop cleanup.
Preserve compiled_key_for_greens behavior while ensuring each retired loop’s
entries are removed from both maps.

In `@majit/majit-metainterp/src/resume_box_reader.rs`:
- Around line 747-753: Replace the debug-only arity checks with unconditional
assert_eq! checks in both VStr/VUniConcatInfo at
majit/majit-metainterp/src/resume_box_reader.rs#L747-L753 and VStr/VUniSliceInfo
at majit/majit-metainterp/src/resume_box_reader.rs#L795-L802, preserving the
expected lengths of 2 and 3 respectively before indexing fieldnums.
🪄 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: 4caa61cb-a31d-4a21-aa80-11698f2079fe

📥 Commits

Reviewing files that changed from the base of the PR and between c38f0f2 and 03db062.

📒 Files selected for processing (11)
  • majit/majit-ir/src/descr.rs
  • majit/majit-macros/src/jit_interp/codegen_state.rs
  • majit/majit-metainterp/src/jitdriver.rs
  • majit/majit-metainterp/src/lib.rs
  • majit/majit-metainterp/src/optimizeopt/optimizer.rs
  • majit/majit-metainterp/src/pyjitpl.rs
  • majit/majit-metainterp/src/pyjitpl/dispatch.rs
  • majit/majit-metainterp/src/resume_box_reader.rs
  • majit/majit-metainterp/src/trace_ctx.rs
  • pyre/pyre-jit-trace/src/descr.rs
  • pyre/pyre-jit-trace/src/state.rs

Comment on lines +392 to +412
/// TEMP DIAGNOSTIC (remove before commit): `MAJIT_BRIDGE_ONLY=a,b,c` restricts
/// bridge formation to the listed guard `fail_index` values, so a miscompiling
/// bridge can be bisected out of a run. Unset = allow all.
fn bridge_only_allows(fail_index: u32) -> bool {
static LIST: std::sync::OnceLock<Option<Vec<u32>>> = std::sync::OnceLock::new();
let list = LIST.get_or_init(|| {
std::env::var("MAJIT_BRIDGE_ONLY").ok().map(|v| {
v.split(',')
.filter_map(|t| t.trim().parse::<u32>().ok())
.collect()
})
});
let ok = match list {
None => true,
Some(allowed) => allowed.contains(&fail_index),
};
if crate::bridge_debug_enabled() {
eprintln!("[bridgeONLY] fail_index={fail_index} allowed={ok}");
}
ok
}

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

Self-declared "remove before commit" diagnostic is still in the diff.

Two concerns:

  1. The doc comment marks this as temporary, but bridge_only_allows is wired into should_bridge at Lines 3481-3482 and 5815-5816, so it ships as a live gate on bridge formation.
  2. filter_map(... .ok()) silently drops unparsable tokens, so MAJIT_BRIDGE_ONLY=oops yields Some(vec![]) and suppresses every bridge — the inverse of the documented "empty/unset = allow all". A typo therefore silently disables bridge formation for the whole run.

Either drop the helper before merge, or keep it and make an all-invalid list fail loud / fall back to allow-all. Want me to open an issue to track removal?

🛡️ Fail loud instead of silently suppressing all bridges
     let list = LIST.get_or_init(|| {
         std::env::var("MAJIT_BRIDGE_ONLY").ok().map(|v| {
-            v.split(',')
-                .filter_map(|t| t.trim().parse::<u32>().ok())
-                .collect()
+            v.split(',')
+                .map(|t| t.trim())
+                .filter(|t| !t.is_empty())
+                .map(|t| {
+                    t.parse::<u32>().unwrap_or_else(|_| {
+                        panic!("MAJIT_BRIDGE_ONLY: {t:?} is not a valid fail_index")
+                    })
+                })
+                .collect()
         })
     });
📝 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
/// TEMP DIAGNOSTIC (remove before commit): `MAJIT_BRIDGE_ONLY=a,b,c` restricts
/// bridge formation to the listed guard `fail_index` values, so a miscompiling
/// bridge can be bisected out of a run. Unset = allow all.
fn bridge_only_allows(fail_index: u32) -> bool {
static LIST: std::sync::OnceLock<Option<Vec<u32>>> = std::sync::OnceLock::new();
let list = LIST.get_or_init(|| {
std::env::var("MAJIT_BRIDGE_ONLY").ok().map(|v| {
v.split(',')
.filter_map(|t| t.trim().parse::<u32>().ok())
.collect()
})
});
let ok = match list {
None => true,
Some(allowed) => allowed.contains(&fail_index),
};
if crate::bridge_debug_enabled() {
eprintln!("[bridgeONLY] fail_index={fail_index} allowed={ok}");
}
ok
}
/// TEMP DIAGNOSTIC (remove before commit): `MAJIT_BRIDGE_ONLY=a,b,c` restricts
/// bridge formation to the listed guard `fail_index` values, so a miscompiling
/// bridge can be bisected out of a run. Unset = allow all.
fn bridge_only_allows(fail_index: u32) -> bool {
static LIST: std::sync::OnceLock<Option<Vec<u32>>> = std::sync::OnceLock::new();
let list = LIST.get_or_init(|| {
std::env::var("MAJIT_BRIDGE_ONLY").ok().map(|v| {
v.split(',')
.map(|t| t.trim())
.filter(|t| !t.is_empty())
.map(|t| {
t.parse::<u32>().unwrap_or_else(|_| {
panic!("MAJIT_BRIDGE_ONLY: {t:?} is not a valid fail_index")
})
})
.collect()
})
});
let ok = match list {
None => true,
Some(allowed) => allowed.contains(&fail_index),
};
if crate::bridge_debug_enabled() {
eprintln!("[bridgeONLY] fail_index={fail_index} allowed={ok}");
}
ok
}
🤖 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 392 - 412, Remove the
temporary bridge_only_allows diagnostic and its gating from should_bridge,
restoring bridge formation to the existing behavior. If the helper is
intentionally retained instead, update its parsing so an all-invalid
MAJIT_BRIDGE_ONLY value fails loudly or falls back to allowing all bridges
rather than producing an empty allowlist; remove the temporary diagnostic
labeling before merge.

Comment on lines +2035 to +2047
// Key the loop by the greens it closed on, so a bridge reaching
// this merge point resolves THIS loop's procedure token. Only
// `last_compiled_key` is registered: on a cross-loop cut that
// is the inner loop the close point belongs to, whereas
// `loop_green_key` is the outer trace-start key.
if let Some(greens) = loop_close_greens.clone() {
if crate::closedbg_enabled() {
eprintln!("@@@CLOSE LOOP-GREENS key={} greens={greens:?}", k as i64);
}
self.meta.record_loop_header_greens(k, greens);
} else if crate::closedbg_enabled() {
eprintln!("@@@CLOSE LOOP-GREENS key={} greens=NONE", k 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.

🚀 Performance & Scalability | 🔵 Trivial | 💤 Low value

loop_header_greens is never pruned, and the reverse lookup scans it linearly.

record_loop_header_greens inserts one (Vec, Vec, Vec) per compiled key, but none of remove_compiled_loop, invalidate_loop, or mark_all_loops_for_release clear this map — they only touch compiled_loops. Entries therefore accumulate for the process lifetime, and compiled_key_for_greens (pyjitpl.rs:8221-8226) walks every one of them on each bridge close, filtering dead keys via has_compiled_targets at read time. Lookups stay correct; the cost is steady growth plus an O(loops) scan on the bridge close path.

Consider evicting alongside the compiled_loops removal paths, mirroring how loop_header_pcs is managed.

Minor: loop_close_greens.clone() at Line 2040 can move instead — the binding is not read afterwards.

🤖 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 2035 - 2047, Prune
loop-header green entries whenever their compiled loop is removed or
invalidated: update the cleanup paths remove_compiled_loop, invalidate_loop, and
mark_all_loops_for_release to remove the corresponding keys from the
loop_header_greens map, mirroring loop_header_pcs management. Also replace the
unnecessary loop_close_greens.clone() in the close-loop recording block with a
move, since the binding is not used afterward.

Comment thread majit/majit-metainterp/src/optimizeopt/optimizer.rs
Comment on lines +8206 to +8227
/// Record the merge-point green constants a compiled loop was traced
/// under, so `compiled_key_for_greens` can invert them back to its key.
pub fn record_loop_header_greens(
&mut self,
green_key: u64,
greens: (Vec<i64>, Vec<i64>, Vec<i64>),
) {
self.loop_header_greens.insert(green_key, greens);
}

/// pyjitpl.py:3005-3006 `ptoken = self.get_procedure_token(greenboxes)` /
/// `has_compiled_targets(ptoken)`: the green key of the compiled loop
/// whose header greens equal `greens`, or `None` when no loop lives at
/// those greens. Compared element-wise like pyjitpl.py:3912
/// `same_greenkey`, over every green rather than the pc alone.
pub fn compiled_key_for_greens(&self, greens: &(Vec<i64>, Vec<i64>, Vec<i64>)) -> Option<u64> {
self.loop_header_greens
.iter()
.find(|(key, header)| *header == greens && self.has_compiled_targets(**key))
.map(|(key, _)| *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.

🩺 Stability & Availability | 🔵 Trivial | ⚡ Quick win

loop_header_greens entries are never evicted.

record_loop_header_greens only ever inserts; nothing removes an entry when its owning loop is retired. remove_compiled_loop clears compiled_loops/pending_preamble_tokens but not loop_header_greens (or the pre-existing loop_header_pcs), and try_to_free_some_loops's memmgr eviction path likewise never prunes either map. compiled_key_for_greens masks the functional symptom via the has_compiled_targets filter, but the map itself grows without bound across the process lifetime as loops recompile/evict — a slow leak in long-running JIT sessions.

Consider clearing the corresponding entry in remove_compiled_loop and in the memmgr eviction path in try_to_free_some_loops (same fix applies to the pre-existing loop_header_pcs).

♻️ Proposed fix
 pub fn remove_compiled_loop(&mut self, green_key: u64) {
     self.compiled_loops.swap_remove(&green_key);
     self.pending_preamble_tokens.swap_remove(&green_key);
+    self.loop_header_pcs.swap_remove(&green_key);
+    self.loop_header_greens.swap_remove(&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 8206 - 8227, Evict stale
loop metadata when compiled loops are retired: update remove_compiled_loop and
the memmgr eviction path in try_to_free_some_loops to remove the owning key from
both loop_header_greens and the existing loop_header_pcs map, alongside the
other compiled-loop cleanup. Preserve compiled_key_for_greens behavior while
ensuring each retired loop’s entries are removed from both maps.

Comment on lines +747 to +753
debug_assert_eq!(
fieldnums.len(),
2,
"VStr/VUniConcatInfo must have exactly 2 fieldnums (left, right)"
);
let left = decode_fieldnum(ctx, fieldnums[0], rd_virtuals, resume_data, cache);
let right = decode_fieldnum(ctx, fieldnums[1], rd_virtuals, resume_data, cache);

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

Arity preconditions on fieldnums use debug_assert_eq!, so release builds index out of bounds instead of reporting the violation. Both sites assert the expected fieldnums length and then index it directly; with debug_assert_eq! compiled out, a malformed resume stream panics with a bare slice-index message. Every other arity check in this module fails loud unconditionally — VRawSliceInfo at Line 640 uses assert!, matching upstream's active assert len(self.fieldnums) == 1 (resume.py:724).

  • majit/majit-metainterp/src/resume_box_reader.rs#L747-L753: change the VStr/VUniConcatInfo check to assert_eq! so the 2-fieldnum precondition holds before fieldnums[0] / fieldnums[1].
  • majit/majit-metainterp/src/resume_box_reader.rs#L795-L802: change the VStr/VUniSliceInfo check to assert_eq! so the 3-fieldnum precondition holds before fieldnums[0..2].
📍 Affects 1 file
  • majit/majit-metainterp/src/resume_box_reader.rs#L747-L753 (this comment)
  • majit/majit-metainterp/src/resume_box_reader.rs#L795-L802
🤖 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/resume_box_reader.rs` around lines 747 - 753,
Replace the debug-only arity checks with unconditional assert_eq! checks in both
VStr/VUniConcatInfo at majit/majit-metainterp/src/resume_box_reader.rs#L747-L753
and VStr/VUniSliceInfo at
majit/majit-metainterp/src/resume_box_reader.rs#L795-L802, preserving the
expected lengths of 2 and 3 respectively before indexing fieldnums.

Move the trace-emit (box-reader) flavour of the resume-data reader out of
pyre-jit-trace/state.rs into a new majit-metainterp/resume_box_reader.rs so
every #[jit_interp] consumer can materialize a guard's virtuals and replay its
deferred heap writes at bridge entry, not only the hand-written PyreJitState.

- Add majit-metainterp/src/resume_box_reader.rs (BridgeVirtualCache,
  materialize_bridge_virtual, replay_pending_fields, emit_pending_field_op,
  decode_fieldnum, rebuilt_value_to_opref, default_bridge_array_descr) and
  re-export it from majit-metainterp/src/lib.rs.
- pyre-jit-trace/src/state.rs: drop the local BridgeVirtualCache /
  materialize_virtual_from_rd / emit_stroruni_oopspec_call definitions and use
  the shared ones.
- jit_interp macro setup_bridge_sym (codegen_state.rs): call
  replay_pending_fields once, and materialize_bridge_virtual for
  RebuiltValue::Virtual int/ref scalars that previously fell through unseeded
  (bridge read size>chain and dereferenced a NULL node head). Add env-gated
  (AHEUI_BRIDGE_DIAG) bridge-seed diagnostics.
- Hoist field_descr_from_parent_by_offset into majit-ir/src/descr.rs;
  pyre-jit-trace make_field_descr_with_parent delegates to it.

Assisted-by: Claude
optimize_bridge (unroll.py:193) runs propagate_all_forward without building
the short-preamble export preview; virtual-state matching is deferred to
jump_to_existing_trace (unroll.py:207), which catches VirtualStatesCantMatch
and falls back to jump_to_preamble (unroll.py:209-210, 238-242). pyre folds
the preview export into the shared optimize_with_constants_and_inputs_at, so
a preview mismatch escaped as InvalidLoop and discarded the bridge.

Add an Optimizer.building_bridge flag, set around the bridge call in
optimize_bridge. When it is set, a preview make_inputargs_and_virtuals
mismatch leaves exported_loop_state = None instead of returning InvalidLoop;
the loop and peeled-loop paths keep the fatal behavior.

Assisted-by: Claude
…ti-frame bridges

`rebuild_from_resumedata` in the `#[jit_interp]` codegen passed
`frame_value_count = None` to `rebuild_from_numbering`, so frame 0
consumed every remaining item in `rd_numb`. The writer side
(`build_state_field_snapshot`) emits one section per MIFrame, outermost
to innermost, each stamping its own absolute jitcode index and sized by
its own liveness, so a guard failing inside a `#[jit_inline]` callee
publishes a multi-frame stream whose second section's
[jitcode_index, pc, py_pc] header and values were folded into frame 0.

`setup_bridge_sym` then saw `reg_indices.total_len() != frame.values.len()`
and returned without seeding, while the bridge was still compiled with
`create_sym`'s loop-namespace InputArgs. On aheui pi.jinseo the guard at
pc=613 decoded 14 values against 6 live root registers, and the resulting
bridge reached the optimizer with a Ref operand on an int operation
(`getintbound_handle: expected 'i'-typed operand, got Ref` in
OptIntBounds::propagate_forward via close_bridge).

Decode through the liveness splitter that `register_dispatch_jitcode`
already installs (`install_state_field_fvc`), matching the other
compile-time decoders of the same `rd_numb`, and give up on bridge
formation when the decoded stream carries more than one frame: only the
root frame's identity registers are seeded and the bridge re-enters at
the root dispatch coordinate, which would re-run an inlined helper that
already committed part of its effect. The giveup is scoped to state-field
drivers by the `dispatch_jitcode` probe.

aheui pi.jinseo: length-mismatch declines 2 -> 0, two multi-frame giveups,
`getintbound_handle` panic no longer reached. logo/99bottles/99dan/quine
stay byte-identical between --no-jit and --jit.

Assisted-by: Claude
Restricts bridge formation to a comma-separated list of guard `fail_index`
values, so a run that miscompiles only when some bridge is built can be
bisected down to the individual guard. Unset keeps every bridge, so the
knob is inert by default; it sits next to the existing MAJIT_NO_BRIDGE
diagnostic and gates the same `should_bridge` predicate at both of its
sites. Logs each decision under MAJIT_BRIDGE_DEBUG.

On aheui pi.jinseo with GC suppressed this isolates two independent
single-bridge miscompiles: fail_index=50 alone diverges from the
interpreter at output byte 806, and fail_index=17 alone at byte 2195,
while {2,3,6,7} and {9,12} stay byte-identical.

Assisted-by: Claude
… greens

A bridge closing on a merge point took its JUMP target from
`current_trace_green_key().unwrap_or(bridge_key)`, i.e. the green key of
the loop the failing guard belonged to. When the trace closed on an inner
merge point (the cross-loop cut) that key names a different loop than the
one reached. `pc` is a green and is baked into each compiled loop as a
constant, so the JUMP entered a loop compiled for another pc while
carrying the reached pc's reds.

pyjitpl.py:3005-3007 reads `ptoken = self.get_procedure_token(greenboxes)`
off the greens of the merge point just reached, and pyjitpl.py:3012-3060
keeps tracing (or compiles a loop) when that greenkey has no procedure
token — it never substitutes another loop's token.

The greens of the closing merge point are now recorded on the trace ctx by
both close paths, each compiled loop registers the greens it closed on, and
the bridge resolves its target by inverting those greens back to a key.
When no compiled loop holds them, `has_targets` is false and the existing
`compile_loop` fall-through (pyjitpl.py:3014-3017) runs.

`update_tracing_green_key`, which the previous comment named as the
retargeting mechanism, has no call sites.

On aheui pi.jinseo.aheui with collection disabled, the guards whose bridges
miscompiled (fail_index 50 at output byte 806, 17 at 2195) are now
byte-identical to the naive interpreter, as is the full bridge set;
previously it diverged at byte 806.

check.py: dynasm 308/308, cranelift 308/308, wasm 305/305.

Assisted-by: Claude
@youknowone
youknowone merged commit 52c2280 into main Jul 25, 2026
4 of 8 checks passed
@youknowone
youknowone deleted the aheui branch July 25, 2026 09:05
youknowone added a commit that referenced this pull request Jul 26, 2026
…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
youknowone added a commit that referenced this pull request Jul 26, 2026
…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
youknowone added a commit that referenced this pull request Jul 27, 2026
…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
youknowone added a commit that referenced this pull request Jul 27, 2026
…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
youknowone added a commit that referenced this pull request Jul 28, 2026
…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
youknowone added a commit that referenced this pull request Jul 28, 2026
…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
youknowone added a commit that referenced this pull request Jul 28, 2026
…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
youknowone added a commit that referenced this pull request Jul 28, 2026
…GE_ONLY parsing (#787)

* majit: evict per-loop side tables on retirement and harden MAJIT_BRIDGE_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

* majit: allocate a headerless jitcode `new` from the interpreter's own 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

* majit: drop the loop side tables on the bulk eviction paths too

`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

* majit: log the bridge-path preview virtual-state mismatch

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

* jit: name the portal driver's own frame_value_count decoder

`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

* majit: own the jitcode table and the portal jitcode from the static data

`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

* majit: pin the export preview's self-match, which keeps building_bridge 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

* jit: resume the build-time liveness buffer instead of forking the pool

`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

* jit: seed the runtime assembler's insns from the build-time opcode table

`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

* jit: bind the list-append store helpers and their jitcode shells as fnaddrs

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

* majit: drop the bridge_only_parse tests orphaned by the MAJIT_BRIDGE_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

* jit: skip the finish_setup republish when neither writer buffer grew

`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

* jit: census how many build-time Field descrs converge with the get_field_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

* jit: classify the field-descr identity census by miss reason and by reader

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

* jit: mint every field descr through GcCache::get_field_descr

`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

* jit: back metainterp_sd.all_descrs with the process-wide descr registry

`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

* jit: ignore shrinking take_back_all_descrs write-backs

`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

* jit: carry EffectInfo raw descr sets across descrs.bin as gccache keys

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

* jit: list PyFrame.w_globals once and hold get_field_descr cache hits 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

* jit: allocate BC_NEW / bh_new structs through the GC and barrier the 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

* jit: build the global build-time descr pool inside the OnceLock initializer

`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

* jit: resolve SizeDescr::w_class_obj through a frontend-registered decoder

`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

* jit: append in the orthodox list-append fold only when the sub-walk did 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

* jit: resolve W_ListObject field descrs to the canonical group before 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

* majit: register the headerless nursery-alloc hook in the cranelift and 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

* majit(cranelift): lower CallMallocNurseryHeaderless to an inline nursery 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

* majit-translate: stride pointer array items by the target word in the 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

* majit-backend-wasm: implement bh_arraylen_gc, zero the old-gen JitFrame

`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

* majit: run rustfmt over resolve_w_class_obj and the metainterp re-export list

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

Assisted-by: Claude

* majit-ir: keep the caller's struct-qualified field name in GcCache::get_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

* majit-metainterp: serialize the finish_setup_descrs publish across threads

`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

* jit: record the measured gap behind SetMemberLookup::AbsentContainer

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