Skip to content

jit(aarch64): close the loop gap vs PyPy in the backend; fix NaN float compares on both dynasm arches - #859

Merged
youknowone merged 15 commits into
mainfrom
perf-loop
Jul 29, 2026
Merged

jit(aarch64): close the loop gap vs PyPy in the backend; fix NaN float compares on both dynasm arches#859
youknowone merged 15 commits into
mainfrom
perf-loop

Conversation

@youknowone

@youknowone youknowone commented Jul 28, 2026

Copy link
Copy Markdown
Owner

Two independent lines of work sit on this branch: the aarch64 backend's steady-loop code quality (which is where the loop-bench gap against PyPy actually was), a float-comparison miscompile on both dynasm architectures, and the earlier #841 review follow-ups + vref-producer hardening.


1. The loop gap vs PyPy was the guard veneer

Wall-clock A/B on the dev box is not usable for this: running the same binary under two labels put nested_loop at 2.11x and 1.96x — opposite sides of the 2x gate — and the absolute level drifts ~40% between runs minutes apart. So the work was driven by disassembling pyre's steady loop and PyPy's steady loop for the same program and diffing them, which is deterministic and load-immune.

For while j < 30000: s = s + i*j; j = j + 1: pyre 24 instructions, PyPy 20.

Root cause. emit_bcond_to_label emitted every guard as

b.<inv_cc> skip ; b =>label ; skip:

so the branch to the failure stub got the unconditional b's 26-bit range. That was a blanket workaround for a single case — a trace larger than b.cond's +1MB forward reach (logo's 70000-op trace), which cannot use one conditional branch at all. PyPy emits a single patched b.cond (it appears as brk #0 in jit-backend-dump before patching).

Every guard therefore cost two instructions instead of one, plus an extra branch in the fetch stream — and traces are guard-dense, so it scaled with everything. It is now decided per trace in _assemble (ops.len() * 1024 >= 1<<20 keeps the long form). The estimate is not taken on faith: _assemble asserts the emitted body plus a stub allowance fits the reach whenever the short form was used, and all 337 pyre/bench + pyre/bench/synth fixtures run clean against a release build with -C debug-assertions=yes.

Three smaller parity gaps in the same sweep, each a real divergence from opassembler.py:

before upstream
int_mul_ovf asr x14, dst, 63 + cmp x15, x14 CMP_rr_shifted(ip0, res, 63) = cmp Xn, Xm, asr #63
compare vs immediate materialize into x17, then cmp reg,reg emit_int_comp_op takes CMP_ri for a 12-bit operand
array base offset mov x16, index + add x16, x16, #ofs a single ADD_ri(ip0, index, ofs)

The last two are encoded as raw words, because dynasm's cmp/add Xd|SP, Xn|SP, #uimm forms reject a dynamic register operand — it cannot prove the register isn't SP. Both encodings are codebuilder.py's own (CMP_ri, ADD_ri) and were checked against llvm-mc at the range endpoints.

Also on this branch: the compare→guard condition-code fold, which is two commits. The first records an adjacent comparison's cc so the guard branches on the live NZCV instead of re-testing a materialized boolean. The second completes the frame-register sentinel that x86 already had — force_allocate_reg_or_cc hands a comparison the frame register when the next op consumes the flags, and the emit side publishes guard_success_cc and emits nothing. It was #[cfg(target_arch = "x86_64")]-gated precisely because aarch64 had no flush_cc (cset x29, cc would destroy the frame pointer). aarch64 now has one, the gate is lifted, consider_int_is_true_j2 routes through it (regalloc.py:469 prepare_comp_unary allocates no destination for the folded form), and genop_discard_cond_call consumes a published condition the way _emit_op_cond_call skips its CMP when arglocs[0] is None.

Result

Steady-loop instruction counts, pyre vs PyPy — pyre is now shorter on five of six:

pyre pypy
nested_loop 19 20
inv 14 15
int_loop 15 17
raise_catch 31 33
fannkuch 50 102
lst (list-heavy) 32 21

Wall clock, taken on a quiet box with an interleaved, order-alternated min-of-11 harness, and reproduced on a reversed-order repeat (the after-column landed within ±0.05x both times):

bench before after gate
int_loop 1.02x 0.79x 2.0
nested_loop 1.54x 1.02x 2.0
raise_catch 1.18x 0.91x 1.5
spectral_norm 1.82x 1.48x 5.0
fannkuch 3.20x 2.92x 5.0
float_loop 0.64x 0.57x 1.5
fib_loop 1.30x 1.29x 3.0
inline_helper 1.08x 1.09x 1.5
nbody 1.90x 1.95x 5.0

nbody is float-heavy and flat within the noise floor; nothing here touches float codegen paths. lst at 32 vs 21 is the remaining per-loop gap — list indexing carries two bounds checks, a class guard and a storage-kind guard, plus three instructions to materialize a constant class-pointer address each iteration. Upstream also has prepare_comp_op_float_* / emit_comp_op_float_* (float compare folded into the following guard), which pyre still lacks. Both are follow-ups, not in this PR.

Where the residual gap now lives (measured after these commits)

Worth recording because it inverts the reading this branch started from. With the veneer gone, wall clock tracks instruction count again:

loop pyre insns pypy insns wall vs pypy cost per instruction
nested_loop 19 20 1.02x 1.07x
list read (acc + q[i] + q[j]) 50 35 1.67x 1.17x
list read+write (fannkuch swap) 56 38 2.0x 1.36x

Per-instruction cost is 1.1–1.4x, not the ~2.3x measured before the veneer fix, so instruction count is the dominant term and "the gap is per-op cost, not IR" no longer holds. The 15 extra instructions in the list loops are exactly the per-iteration class guard (6), storage-kind guard (3), length/items reload (2) and bounds checks (5) that PyPy hoists into its preamble and pyre re-executes; PyPy's steady body carries none of them.

That hoisting is the short-preamble heap import, which is separately known to be dead in pyre and to miscompile bridges when revived — an optimizer epic, not something reachable from the backend. The aarch64 backend itself is now at parity for these loops: the constant-first mov+cmp and the four-instruction scaled array access that remain are byte-for-byte what PyPy emits (prepare_int_cmp only tests arg1; load_supported_factors = (1,) forces the explicit int_lshift).

Ratios below ~0.15s of PyPy time are startup noise and must not be quoted — the same list loop reads as 4x at the fixture's own N and 1.67x once scaled.


2. Float comparisons miscompiled NaN on both dynasm architectures

Found while reviewing the above. The live float-comparison arm (regalloc_perform) emitted a bare SETcc/cset from a condition table with no unordered handling. One x86 table had been copied to aarch64, so each architecture got wrong exactly the half the other handles correctly:

FLOAT_LT FLOAT_LE FLOAT_EQ FLOAT_NE FLOAT_GT FLOAT_GE
aarch64 — fcmp sets NZCV=0b0011 ok lo ok ls ok ok hi → True hs → True
x86 — ucomisd sets ZF=PF=CF=1 setb → True setbe → True sete → True setne → False ok seta ok setae

In a compiled loop with x = float('nan'), 30000 iterations: aarch64 evaluated nan > 1.0 as True 28558 times; x86 evaluated nan < 1.0 True 27358 times, nan == 1.0 True 26958 times, and nan != 1.0 True only 3242 times (the interpreter prefix before the trace compiled).

  • aarch64: gt/ge test N == V and are false when unordered, which is what opassembler.py:310-315 uses. hi/hs test C, which fcmp sets for NaN.
  • x86: ported assembler.py:1322 _cmpop_float — FLOAT_LT/LE compare in the reverse operand order so they can use the A/AE forms, and FLOAT_EQ/NE get _if_parity_clear_zero_and_carry (jnp skip; cmp rbp, 0; skip:).

Both verified against CPython over 15 operand pairs covering ±0.0, ±inf and NaN in either position; x86 verified under Rosetta on x86_64-apple-darwin.

cranelift and wasm were never affectedFloatCC::LessThan and f64.lt are IEEE-ordered by construction.

Worth flagging for future backend audits: the dead legacy genop_float_cmp in both files gets this right (it swaps for lt/le and uses sete+setnp). It has no callers. A reading-only review that lands on the dead spelling will conclude the backend is fine.


3. #841 review follow-ups and vref-producer hardening

Unchanged from the previous revision of this PR; restated in brief.

force_all_frames forced nothing. Flagged by both reviewers; the finding is real, the attribution was not. Upstream gets the force out of the walkpyframe.py declares f_backref = jit.vref_None, so getnextframe_nohidden's frame.f_backref() is a jit_force_virtual. Making pyre's walkers force-free was correct; what is missing is the vref producer, so force_vref is the identity. Until it exists, this consumer states the force directly. Still unobservable — a regression bench was built, did not discriminate (same count under PYRE_NO_JIT=1), and was deleted rather than committed; sys.settrace still fires zero events under pyre, before and after #791.

The other seven #841 findings were refuted, each verified against current code and re-attacked by an independent skeptic: _warnings STATE_NS is rooted via walk_process_import_roots; hash("") cannot disagree between entry points; the wasm nursery-hook disarm is reachable only from a #[test]; the _warnings stale-category local is always a W_TypeObject and heap classes allocate old-gen; specialised tuples cannot reach the wrappeditems path; the FnDef constant is a synthetic 0-arg Call whose result_ty is the return value; the string-hash memo belongs to the threading epic as one change.

Three vref hardening changes, each independently correct and a no-op today, each a crash the moment a producer exists: the frame-chain re-entry test resolved through vref_referent rather than by forcing (forcing would clear TOKEN_TRACING_RESCALL and make a reader report its own read as a callee escape); the displaced topframeref rooted through majit_gc::shadow_stack at the two sites that parked it raw across collectable code; and the vref given its own materializer header, since materialize_virtual_object was dereferencing the JIT_VIRTUAL_REF_VTABLE magic as a type object.

The producer itself is deliberately not herealloc_virtual_ref is Box::into_raw (host heap, not GC-registered), the normal non-escaping leave leaves forced == NULL which is exactly what eval.rs's expect aborts on, and the walker never calls the vref bracket. Those are design changes that cannot be exercised without the producer and vice versa. There is also a fork to decide first: stop_tracking_virtualref must record VIRTUAL_REF_FINISH before the CALL, but pyre's walker records the call op first and concrete-executes after, in all four dispatchers.

Five comments corrected across trace.rs, jitcode_dispatch/mod.rs and mir.rs, including one that framed an orthodox ValueType::Int stamp as a deviation and invited a future "fix" to Ref that would be the actual deviation.


Verification

  • python3 pyre/check.py --backend dynasm,cranelift re-run after every commit in section 1 and 2: 331 passed on both backends each time. The single failure is synth/ast_compile_roundtrip, which check.py itself marks BASEFAIL and which fails identically on cranelift — a backend none of these changes touch.
  • cargo test -p majit-backend-dynasm and -p majit-metainterp pass.
  • All 337 pyre/bench + pyre/bench/synth fixtures swept against a release build with -C debug-assertions=yes: neither the guard-branch range assumption nor flush_cc's precondition ever fires.
  • Hand-encoded CMP_ri / ADD_ri words checked against llvm-mc --disassemble --triple=aarch64 at both range endpoints.
  • Output compared to CPython for float/NaN (15 pairs), int_mul_ovf boundary values, and every loop bench.

A note for whoever reads CI: a perf-gate failure on this suite is not by itself evidence of a regression. The same tree failed dynasm nested_loop ratio 2.3x > gate 2x and then passed minutes later with no code change; the noise floor on a shared box exceeds the gate margin. Re-run before believing it.

commented by Claude

Summary by CodeRabbit

  • Bug Fixes
    • Improved JIT guard branching and comparison handling on AArch64 after moves/spills, with safer NZCV-based guard evaluation.
    • Fixed x86 floating-point compare handling for unordered/NaN cases to prevent misclassification.
    • Improved tracing/residual-call and frame/virtual-reference behavior, including more reliable frame rooting during multi-frame “blackhole” handling.
  • Performance
    • Reduced emitted instruction sequences for AArch64 indexed addressing and selected compare/arithmetic paths.
  • Documentation
    • Updated comments covering funcptr constant materialization, residual-call parity behavior, and Unicode/list layout details.
  • Chores
    • Removed an internal list helper used for in-place append GC-block querying.

@coderabbitai

coderabbitai Bot commented Jul 28, 2026

Copy link
Copy Markdown

Review Change Stack

Walkthrough

The PR adds AArch64 condition-code-aware guard lowering and immediate arithmetic paths, updates cross-target comparison allocation and x86 floating comparison handling, and strengthens Pyre virtual-reference frame rooting, restoration, and forcing. It also changes residual barrier recording and clarifies representation documentation.

Changes

Backend condition-code flow

Layer / File(s) Summary
AArch64 condition-aware lowering
majit/majit-backend-dynasm/src/aarch64/regalloc.rs, majit/majit-backend-dynasm/src/regalloc.rs
Comparisons and truth operations can publish condition codes for adjacent consumers through the register-allocation sentinel path.
Immediate arithmetic lowering
majit/majit-backend-dynasm/src/aarch64/opassembler.rs
Indexed loads use direct ADD-immediate encoding for in-range offsets.

Virtual-reference frame safety

Layer / File(s) Summary
Frame referent resolution and forcing
pyre/pyre-interpreter/src/executioncontext.rs, pyre/pyre-interpreter/src/eval.rs
Frame walking uses a shared virtual-reference resolver, and full-frame traversal forces each visited frame.
Rooted residual-call frame guard
pyre/pyre-jit-trace/src/jitcode_dispatch/residual_call.rs, pyre/pyre-object/src/listobject.rs
Residual frame state is rooted and conditionally restored; Object-strategy residual calls always record the in-place list write barrier, and the obsolete query helper is removed.
Rooted top-frame state
pyre/pyre-jit-trace/src/trace.rs
Multi-frame blackhole adoption roots and restores topframeref across the drive.

Documentation clarifications

Layer / File(s) Summary
Representation and runtime documentation
majit/majit-translate/src/front/mir.rs, pyre/pyre-interpreter/src/executioncontext.rs, pyre/pyre-interpreter/src/jit_fnaddr.rs, pyre/pyre-object/src/unicodeobject.rs
Comments clarify funcptr slot mapping, execution-context references, barrier behavior, and Unicode object layout.

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

Possibly related PRs

Poem

A rabbit watched the flags take flight,
While rooted frames held on tight.
Guards hopped through branches bright,
Barriers kept the heap just right.
“A fine code burrow!” squeaked the hare.

🚥 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 matches the PR’s main themes: AArch64 backend work and NaN float-compare fixes on both dynasm arches.
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 perf-loop

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: 83e8db8f76

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

// takes precedence for that case (heaptracker.py:66-style
// "typeptr" filter does NOT apply to w_class in pyre).
(*ptr).w_class = get_instantiate(&*(vtable as *const PyType));
if vtable as u64 == majit_metainterp::virtualref::JIT_VIRTUAL_REF_VTABLE {

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 Preserve the vref tag on 32-bit targets

When a guard-resume materializes a virtual reference on wasm32, VRefSizeDescr::vtable() has already narrowed the 64-bit JIT_VIRTUAL_REF_VTABLE constant to usize; casting it back to u64 cannot recover the high bits, so this comparison is always false. The code consequently enters the ordinary-object branch and dereferences the truncated magic value as a PyType *, crashing instead of materializing the vref. Recognize the vref descriptor without passing its tag through usize, or make the runtime tag word-sized consistently.

Useful? React with 👍 / 👎.

@github-actions

github-actions Bot commented Jul 28, 2026

Copy link
Copy Markdown

🤖 Codex parity review

Static analysis of this diff vs the local RPython/PyPy sources (commit 79cea09).
Updated: 2026-07-29T08:10:23.071Z

Files in the reviewed diff
majit/majit-backend-dynasm/src/aarch64/assembler.rs
majit/majit-backend-dynasm/src/aarch64/opassembler.rs
majit/majit-backend-dynasm/src/aarch64/regalloc.rs
majit/majit-backend-dynasm/src/regalloc.rs
majit/majit-backend-dynasm/src/x86/assembler.rs
majit/majit-translate/src/front/mir.rs
pyre/pyre-interpreter/src/eval.rs
pyre/pyre-interpreter/src/executioncontext.rs
pyre/pyre-interpreter/src/jit_fnaddr.rs
pyre/pyre-jit-trace/src/jitcode_dispatch/mod.rs
pyre/pyre-jit-trace/src/jitcode_dispatch/residual_call.rs
pyre/pyre-jit-trace/src/jitcode_dispatch/specialize.rs
pyre/pyre-jit-trace/src/state.rs
pyre/pyre-jit-trace/src/trace.rs
pyre/pyre-object/src/listobject.rs
pyre/pyre-object/src/unicodeobject.rs

1. Regressions to PyPy parity introduced by this patch

  • pyre/pyre-interpreter/src/executioncontext.rs:1003 ↔ pypy/interpreter/executioncontext.py:323-336force_all_frames() now calls force_frame(frame) for every frame merely visited. Upstream obtains the required JIT-frame forcing only by reading each frame’s f_backref; it does not force the virtualizable fields of every visited frame. This can make otherwise-private virtualizable state escape and abort tracing, exactly contrary to the Rust file’s own force_frame contract at lines 13-26.

2. Other mismatches introduced by this patch

None.

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

  • pyre/pyre-jit-trace/src/jitcode_dispatch/residual_call.rs:2524-2532 ↔ rpython/jit/metainterp/pyjitpl.py:3341-3371 — the walker still does not call vrefs_before_residual_call() / vrefs_after_residual_call(). Upstream brackets every may-force residual call and records VIRTUAL_REF_FINISH when a vref escapes; pyre only has the helpers on TraceCtx.

  • pyre/pyre-jit-trace/src/jitcode_dispatch/specialize.rs:93-111 ↔ rpython/jit/metainterp/pyjitpl.py:2007-2083OS_JIT_FORCE_VIRTUAL remains fail-loud instead of implementing PyPy’s _do_jit_force_virtual fast path and may-force fallback.

  • pyre/pyre-jit-trace/src/jitcode_dispatch/specialize.rs:116-120 ↔ rpython/jit/metainterp/pyjitpl.py:2053-2082direct_libffi_call, direct_assembler_call, and the resulting KEEPALIVE handling remain absent from the production walker.

  • pyre/pyre-jit-trace/src/state.rs:10044-10045 ↔ rpython/jit/metainterp/resume.py:1111-1122 — virtual-object resume materialization uses std::alloc::alloc_zeroed, whereas upstream allocates through the metainterp/GC allocation path. Such materialized objects are not automatically GC-owned/rooted by the normal allocator protocol.

  • majit/majit-metainterp/src/virtualref.rs:68-87 ↔ rpython/jit/metainterp/virtualref.py:17-20,100-120 — only the forced field is GC-traced; upstream’s virtual_token and forced are both GC-reference fields. This is documented as a known limitation and predates this patch.

4. Structural adaptations

  • pyre/pyre-jit-trace/src/state.rs:10050-10057 ↔ rpython/jit/metainterp/virtualref.py:17-23 — pyre represents the JIT-vref vtable as a u64 type tag, not an RPython OBJECT_VTABLE*. The new special case correctly avoids treating that tag as a PyType*; this is a Rust/runtime-layout adaptation, not a semantic mismatch.

  • majit/majit-backend-dynasm/src/aarch64/assembler.rs:1106-1138 ↔ rpython/jit/backend/aarch64/opassembler.py:129-133 — direct AArch64 instruction-word emission substitutes for RPython’s codebuilder helpers because dynasm cannot encode these forms with dynamic register operands. The immediate-range behavior matches upstream.

  • majit/majit-backend-dynasm/src/aarch64/assembler.rs:4662-4713 ↔ rpython/jit/backend/aarch64/assembler.py:1174-1200 — choosing short versus long conditional branches is a dynasm relocation/range adaptation. It preserves the guard’s success/failure control flow while accounting for AArch64 branch reach.

@youknowone youknowone changed the title jit: #841 review follow-ups; harden the frame chain for a vref producer jit(aarch64): close the loop gap vs PyPy in the backend; fix NaN float compares on both dynasm arches Jul 29, 2026

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

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

💡 Codex Review

https://github.com/youknowone/pyre/blob/6cc8dbdd12cdec5a5e16658bc9658c0c0feb995b/pyre-interpreter/src/executioncontext.rs#L999
P1 Badge Restore the frame-chain vref producer instead of forcing frames

The fresh code now calls force_frame even though the preceding comment explicitly says this only compensates for the missing VIRTUAL_REF producer. On every sys.settrace/setprofile call made while the JIT is active, this forces the virtualizable through a different hook instead of reproducing upstream's f_backref vref force/finish lifecycle; it also leaves this consumer coupled to the missing producer and risks double or incorrectly scoped forcing once that producer is added. Implement the producer/root protocol and retain the upstream frame-chain walk rather than this acknowledged workaround.

AGENTS.md reference: AGENTS.md:L205-L207

ℹ️ 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 inputargs: &'a [InputArg] = self.inputargs;
let ops: &'a [Op] = self.operations;
self.trace_start_offset = self.mc.offset().0;
self.long_guard_branch = ops.len().saturating_mul(MAX_BYTES_PER_OP) >= BCOND_FORWARD_RANGE;

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 Base guard reach on the emitted recovery distance

On AArch64, a trace with fewer than 1024 operations always selects short b.cond branches, but operation count does not bound the distance to the recovery labels: every guard's later generate_quick_failure can emit one store sequence per const_stores entry, so guards with wide failure states can place an early stub beyond the 1 MiB range. The subsequent debug assertion assumes a fixed 64 bytes per stub, does not measure those sequences, and is absent in release builds; such a valid trace therefore reaches dynasm finalization with an impossible relocation, whereas the previous unconditional veneer compiled it. Choose the form from an actual conservative bound that includes recovery code, or preserve/patch the long form when the finalized displacement requires it.

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

🤖 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-backend-dynasm/src/aarch64/assembler.rs`:
- Around line 2237-2252: Increase the per-guard stub estimate used in the
debug_assert around pending_guard_tokens so it covers the full minimal recovery
stub, including pointer saves, stores, and the callee-save footer, rather than
the current 64-byte charge. Ensure traces near MAX_BYTES_PER_OP select
long_guard_branch before finalize when their body plus guard stubs could exceed
BCOND_FORWARD_RANGE; use emitted guard-token byte accounting if the fixed
estimate cannot safely cover optional exception or const-store paths.

In `@majit/majit-backend-dynasm/src/x86/assembler.rs`:
- Around line 3339-3342: Update the result-location handling around emit_setcc
so comparisons targeting the rbp condition-code sentinel are routed through
flush_cc instead of materializing directly into rbp. Preserve the existing
direct emit_setcc path only for ordinary register destinations, ensuring the
live JIT-frame pointer and interpreter semantics remain intact.
🪄 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: 6e716ab9-aab8-4cf8-9e09-9e3b7a3664f5

📥 Commits

Reviewing files that changed from the base of the PR and between 31a4f20 and 6cc8dbd.

📒 Files selected for processing (14)
  • majit/majit-backend-dynasm/src/aarch64/assembler.rs
  • majit/majit-backend-dynasm/src/aarch64/opassembler.rs
  • majit/majit-backend-dynasm/src/aarch64/regalloc.rs
  • majit/majit-backend-dynasm/src/regalloc.rs
  • majit/majit-backend-dynasm/src/x86/assembler.rs
  • majit/majit-translate/src/front/mir.rs
  • pyre/pyre-interpreter/src/eval.rs
  • pyre/pyre-interpreter/src/executioncontext.rs
  • pyre/pyre-jit-trace/src/jitcode_dispatch/mod.rs
  • pyre/pyre-jit-trace/src/jitcode_dispatch/residual_call.rs
  • pyre/pyre-jit-trace/src/jitcode_dispatch/specialize.rs
  • pyre/pyre-jit-trace/src/state.rs
  • pyre/pyre-jit-trace/src/trace.rs
  • pyre/pyre-object/src/unicodeobject.rs

Comment thread majit/majit-backend-dynasm/src/aarch64/assembler.rs
Comment thread majit/majit-backend-dynasm/src/x86/assembler.rs Outdated
@youknowone

Copy link
Copy Markdown
Owner Author

CI triage — pyre/check.py (ubuntu-24.04)

Two failures, neither attributable to this branch. Evidence for both, so this does not have to be re-derived at merge time.

1. dynasm synth/exception_traceback_frame_linenoGC BUG: invalid type_id ... site=copy_nursery_object

Pre-existing and intermittent. It has a deterministic amplifier:

PYPY_GC_NURSERY=131072 ./target/release/pyre-dynasm \
    pyre/bench/synth/exception_traceback_frame_lineno.py

A/B under that amplifier, 8 runs each:

binary panics
this branch (6cc8dbdd12) 8/8
pre-change baseline (this branch's merge-base build) 8/8

Identical, so the commits here neither introduce nor aggravate it. The nursery-size band is narrow — 32768 and 1048576 are both 0/8 — so a single small value is not enough to call it unreproducible.

Being worked separately on fix-dynasm-gc-nested-listcomp (#872); deliberately not touched here.

2. cranelift int_loop exec 0.73s > pypy 0.36s ratio 2.0x > gate 2x

This branch touches no cranelift file (git diff --name-only <merge-base>..HEAD has zero matches for cranelift), and check.py's own bench table names int_loop as one of the "known slow-runner flakes" left deliberately at low headroom.

It is also already red on main itself for the same job/bench/backend — run 30378431710 at b8a00beb9. It is x86_64-only: pyre's absolute time on that runner is unchanged across recent main runs (0.71 / 0.80 / 0.72 / 0.79 s, all of which passed); the ratio crossed because pypy got faster on the runner. Local arm64 measurement on this tree, interleaved against pypy over 5 rounds, is 0.33s vs 0.30s — under 1.2x raw and below 1.0x once startup is subtracted the way check.py does.

The underlying cause is cranelift x86_64 register pressure (preserve_frame_pointers + enable_pinned_reg cost two of sixteen GPRs), not this branch's IR.

Local verification of this tree

python3 pyre/check.py --backend dynasm,cranelift was re-run after every commit here: 331 passed on both backends each time, the only failure being synth/ast_compile_roundtrip, which check.py marks BASEFAIL and which fails identically on cranelift.

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

Here are some automated review suggestions for this pull request.

Reviewed commit: 6f9a3d44e2

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

pub fn force_all_frames(&mut self, is_being_profiled: bool) {
let mut frame = self.gettopframe_nohidden();
while !frame.is_null() {
force_frame(frame);

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 Restore the vref producer instead of forcing frames directly

When settrace or setprofile walks active JIT frames, this call bypasses PyPy's virtual-reference protocol: upstream executioncontext.py:323 force_all_frames forces frames solely by reading the f_backref vrefs, while the new comment here explicitly acknowledges that the actual VIRTUAL_REF producer is still missing and installs this direct virtualizable force only as an interim substitute. Fresh evidence in this revision is that admission at lines 991-999; implement the missing producer and preserve the upstream walk rather than shipping the shortcut, especially because adding the producer later would leave two independently maintained forcing paths.

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

Useful? React with 👍 / 👎.

`gettopframe_nohidden` / `getnextframe_nohidden` are force-free, so
`force_all_frames` no longer marked any visited frame as forced.  Upstream gets
that force out of the walk itself: `f_backref` holds a `jit.virtual_ref`, so
reading it is a `jit_force_virtual` and `virtualref.force_virtual` runs
`ResumeGuardForcedDescr.force_now`.  Pyre's walk calls `force_vref` at the same
points, but nothing stores a `JitVirtualRef` in the chain yet, so it is the
identity; this consumer now states the force directly.

Records `index_storage` in the `W_UnicodeObject` layout comment.

Assisted-by: Claude
…orcing

`ResidualFrameChainGuard::enter` compared `ec.topframeref` against the inline
callee frame with a raw `ptr::eq`.  `topframeref` holds a `jit.virtual_ref`, so
once the tracer stores a `JitVirtualRef` there the comparison misses even when
the vref names that very frame, and the guard writes `frame.f_backref =
vref(frame)` — a self-loop in the chain for the duration of the residual.

Resolve the referent instead, without forcing.  Forcing would be wrong rather
than merely expensive: a live vref carries `TOKEN_TRACING_RESCALL` across a
residual and `force_virtual` clears it, which is the marker
`tracing_after_residual_call` reads as "the callee forced this vref", so a
reader that forced would report its own read as a callee escape.
`vref_referent` is the non-forcing read; `chain_next_frame` in the root walker
already had this shape and now shares it.

The re-entry case no longer returns early.  `PUBLISHED_INLINE_FRAME` was written
only on the path past that check, and its consumer `flush_active_frame_escape`
is the sub-walk escape redirect, so an early return leaves it null for the whole
sub-walk.  The chain write is now conditional on `entered` and the publish is
unconditional; `Drop` restores only what it wrote.

Corrects four comments stating that `vrefs_before_residual_call` /
`vrefs_after_residual_call` / `stop_tracking_virtualref` are unported.  They are
ported on `TraceCtx` and wired on the metainterp leg; the walker does not call
them.  The comments now also record that `TraceCtx` and `PyreSym` carry separate
`virtualref_boxes`, and that only `TraceCtx`'s is read by the bracket and the
guard snapshots.

Assisted-by: Claude
…er header

Two sites parked a raw `ec.topframeref` across code that can collect and
restored it afterwards: `ResidualFrameChainGuard`, across the concrete residual,
and the multi-frame blackhole drive.  Frames themselves never move — `FrameBox`
allocates old-gen — so the raw copy is sound while the slot only ever holds a
`PyFrame`.  Once the tracer stores a `JitVirtualRef` there the displaced value
is a nursery object, and a collection inside the guarded region would leave the
restore writing back a pre-move pointer with no crash at the store.  Both now
push it onto `majit_gc::shadow_stack` and read it back before popping, so the
collector forwards it in place — the same treatment `CurrentFrameGuard` already
gives this field.

`materialize_virtual_object` seeded every allocation as a `PyObject`, reading
`get_instantiate` off `descr.vtable()`.  A `JitVirtualRef` is a `GcStruct` whose
`('super', rclass.OBJECT)` slot carries the type-id constant itself, so that arm
would dereference the `JIT_VIRTUAL_REF_VTABLE` magic as a type object on the
first guard exit carrying a vref.  Write the typeptr directly for that vtable
and leave the field replay to fill `virtual_token` / `forced`.

Assisted-by: Claude
`history.py getkind` returns `"int"` for a `Ptr` whose `TO._gckind` is
`raw`, and `FuncType._gckind` is `raw`, so `Ptr(FuncType)` maps to `int`.
The comment stated the upstream kind was `r` and described the `Int`
slot as a departure from it.

Assisted-by: Claude
…testing the boolean

The regalloc emit path for INT_LT/LE/GT/GE/EQ/NE, UINT_*, PTR_EQ/NE,
INSTANCE_PTR_EQ/NE, INT_IS_TRUE and INT_IS_ZERO emitted `cmp` + `cset` and
left `guard_success_cc` unset, so the following GUARD_TRUE / GUARD_FALSE ran
`emit_test_loc` over the materialized boolean before branching.  Record the
comparison's condition code and result register; a guard whose operand is
exactly that register now takes the recorded condition (inverted for
GUARD_FALSE) and emits only the conditional branch.

`assembler.py:1186-1198 _walk_operations` folds the same pair by passing
`prevop` into `guard_operations[...]` -> `regalloc.py:780 guard_impl` ->
`dispatch_comparison(prevop)`.  The record is cleared by `regalloc_perform`
and by a register move, so only an adjacent pair folds, matching upstream's
`operations[i + 1]` window.

nested_loop's steady loop drops two `tst` instructions per iteration
(IntLt->GuardTrue and IntIsTrue->GuardFalse) and both branches now consume
the `cmp` flags directly.

Assisted-by: Claude
…ializing a boolean

`force_allocate_reg_or_cc` hands a comparison the frame register as its
result when the next op consumes the flags directly, and the emit side
recognises that sentinel, publishes `guard_success_cc` and emits nothing.
The path was `#[cfg(target_arch = "x86_64")]`-gated because aarch64 had no
`flush_cc`: `cset x29, cc` would have destroyed the frame pointer.

Add `flush_cc` to the aarch64 assembler and lift the gate.  The comparison
arms (INT_LT/LE/GT/GE/EQ/NE, UINT_*, PTR_EQ/NE, INSTANCE_PTR_EQ/NE,
INT_IS_TRUE, INT_IS_ZERO) route through it, and the guard arms take the
`load_condition_into_cc` shape the x86 assembler already uses, so a
published condition is honoured instead of being overwritten by the
`emit_test_loc` fallback.  `genop_discard_cond_call` consumes a published
condition too — `opassembler.py:864 _emit_op_cond_call` skips its CMP when
`arglocs[0] is None` for the same reason.

`consider_int_is_true_j2` now allocates its result through
`force_allocate_reg_or_cc`.  `regalloc.py:469 prepare_comp_unary` allocates
no destination at all for the folded form and `opassembler.py:210
emit_comp_op_int_is_true` emits the `cmp` alone, returning the condition.

A guard emits its own flag-setting code, so `regalloc_perform_guard` clears
the adjacent-comparison record on the way out.

Steady-loop machine code, counted from `MAJIT_DUMP` output over equal
numbers of compiled traces:

    nested_loop   cset 160 -> 0     tst 240 -> 80
    int_loop      cset   4 -> 0     tst   6 -> 2
    raise_catch   cset 1207 -> 0    tst 2011 -> 804
    fannkuch      cset 157851 -> 14028   tst 183327 -> 39504

Each folded pair goes from `cmp; cset xN, lt; tst xN, xN; b.ne` to
`cmp; b.lt`.

Assisted-by: Claude
`float_opcode_to_cc` mapped FLOAT_GT to `hi` and FLOAT_GE to `hs`, the
`seta` / `setae` spelling that is correct after x86 `ucomisd` because that
sets CF on an unordered compare.  `fcmp` instead sets NZCV = 0b0011, so C is
set and `hi` (C set, Z clear) and `hs` (C set) are both TRUE when either
operand is NaN.  `opassembler.py:314-315` uses `gt` and `ge`, which test
N == V and are false in that state.

Compiled loop over `x = float('nan')`, 30000 iterations:

    before   nan > 1.0 -> True 28558x   nan >= 1.0 -> True 28358x
    after    nan > 1.0 -> False         nan >= 1.0 -> False

FLOAT_LT / FLOAT_LE / FLOAT_EQ / FLOAT_NE already used `lo` / `ls` / `eq` /
`ne`, which match `opassembler.py:310-313` and are false (true for `ne`) on
an unordered compare.

Assisted-by: Claude
The regalloc float-comparison arm emitted `ucomisd a, b` followed by a
`SETcc` taken straight from a condition table: FLOAT_LT -> `setb`,
FLOAT_LE -> `setbe`, FLOAT_EQ -> `sete`, FLOAT_NE -> `setne`.  UCOMISD sets
ZF = PF = CF = 1 when either operand is NaN, so all four report the wrong
answer for an unordered compare.

`assembler.py:1322 _cmpop_float` avoids it two ways, both ported here:
FLOAT_LT / FLOAT_LE compare in the reverse order so they can use the
`A` / `AE` forms, which are already false on unordered; FLOAT_EQ / FLOAT_NE
have no such form and get `assembler.py:1314
_if_parity_clear_zero_and_carry`, which clears ZF and CF via `cmp rbp, 0`
when PF is set.

`float_opcode_to_cc` had no other caller and is removed.

Compiled loop over `x = float('nan')`, 20000 iterations, `(lt, le, gt, ge,
eq, ne)` against CPython:

    nan vs 1.0   before (19599, 19599, 0, 0, 19599,   601)
                 after  (    0,     0, 0, 0,     0, 20000)
    1.0 vs nan   before (19999, 19999, 0, 0, 19999,     1)
                 after  (    0,     0, 0, 0,     0, 20000)

Verified on x86_64-apple-darwin under Rosetta over 15 operand pairs
covering signed zeros, infinities and NaN in either position; all now match
CPython.  FLOAT_GT / FLOAT_GE were already correct here — they are the two
the aarch64 table got wrong.

Assisted-by: Claude
`assembler.py:1293 flush_cc` opens with
`assert self.guard_success_cc == rx86.cond_none`, so the invariant covers
every call.  Both backends checked it only inside the frame-register
sentinel branch, leaving the materialize path unchecked.

A condition still pending on entry was published by an earlier op and never
consumed, which would make the following guard branch on it instead of on
its own operand.  Verified not to fire: the whole of pyre/bench and
pyre/bench/synth (337 files) run clean against a release build with
`-C debug-assertions=yes`.

Assisted-by: Claude
…reach

Every guard emitted `b.<inv_cc> skip; b =>label; skip:` so that the branch
to the failure stub carried the unconditional `b`'s 26-bit displacement.
That was a blanket workaround for one case: a trace larger than `b.cond`'s
+1MB forward reach, which cannot be reached by a single conditional branch
at all (logo's 70000-op trace).

Decide it per trace instead.  The failure-recovery stubs are written
directly after the body, so a guard reaches its stub whenever the trace's
own code stays inside that window; bound it by the operation count against
a 1KB-per-operation ceiling.  Traces over the bound keep the long form.

The estimate is checked: `_assemble` asserts the emitted body plus a stub
allowance fits the reach whenever the short form was used, and the whole of
pyre/bench and pyre/bench/synth (337 files) runs clean against a release
build with `-C debug-assertions=yes`.

nested_loop's steady loop, which carries four guards, goes from 24 to 20
instructions.

Assisted-by: Claude
`opassembler.py:94 emit_comp_op_int_mul_ovf` checks the product for
overflow with `CMP_rr_shifted(ip0, res, 63)` — `cmp Xn, Xm, asr #63`, which
takes the shift as part of the compare.  The emit here did the shift into a
scratch register first (`asr x14, dst, 63; cmp x15, x14`).

nested_loop's steady loop goes from 20 to 19 instructions.

Assisted-by: Claude
…gister

`emit_cmp_loc_loc` materialized every immediate operand into x17 and then
compared register-to-register.  `opassembler.py:129 emit_int_comp_op` takes
`CMP_ri` when the right-hand side is an immediate, and
`codebuilder.py:389 CMP_ri` encodes a 12-bit unsigned field; wider values
still need the register.

dynasm's `cmp Xn|SP, #uimm` form rejects a dynamic register operand — it
cannot tell whether the register is SP — so the instruction is encoded
directly, as SUBS with Rd = xzr, the same word `CMP_ri` writes.  Checked
against llvm-mc for the range endpoints.

A list-indexing loop's steady body (bounds check, class guard, storage-kind
check, second bounds check, indexed load, add-overflow) goes from 34 to 33
instructions; the folded compare is the storage-kind check `cmp x11, #1`.

Assisted-by: Claude
The two sites that fold a base offset into an array index emitted
`mov x16, index; add x16, x16, #ofs`.  `opassembler.py:403` does it with a
single `ADD_ri(ip0, index, ofs)`; both sites already gate on the same
`check_imm_arg` range, so the move is pure overhead.

Encoded directly for the same reason as the compare immediate: dynasm's
`add Xd|SP, Xn|SP, #uimm` form rejects a dynamic register operand.  Checked
against llvm-mc at both range endpoints.

A list-indexing loop's steady body goes from 33 to 32 instructions.

Assisted-by: Claude
`consider_float_cmp` and `consider_float_cmp_j2` allocated the result with
`force_allocate_reg`. `x86/regalloc.py:682 _consider_float_cmp` uses
`force_allocate_reg_or_cc`, so a comparison whose only consumer is the next
guard gets the frame-register sentinel instead of a general-purpose register.

Both emit sides now end in `flush_cc` rather than a bare `cset` / `SETcc`,
matching `opassembler.py:138 emit_comp_op_float_*` and
`assembler.py:1345 genop_cmp_float`.

x86's `flush_cc` no longer clears the destination first. `assembler.py:1300`
needs its `MOV imm0` because `SET_ir` writes only the low byte; `emit_setcc`
here ends in `movzx r32, r8`, which zeroes bits 8..63 on its own, so the MOV
was dead. Dropping it takes every comparison that does materialize a boolean
back to two instructions.

Every `fcmp` in the nbody and spectral_norm traces is now followed directly by
a conditional branch where it was followed by `cset` (nbody 47768 sites,
spectral_norm 1040). The largest emitted loop goes from 809 to 753
instructions on nbody and from 54 to 52 on spectral_norm. Wall clock is
unchanged: interleaved, order-alternated min-of-9 over nbody, spectral_norm,
float_loop, int_loop and fannkuch moves every bench by at most 1.1%, which is
inside this box's noise floor.

Verified against CPython on arm64 and on x86_64 under Rosetta, including a new
fixture whose float booleans are consumed as values rather than by an adjacent
guard. Output is identical between the two architectures across all 336
bench + synth fixtures except nbody, whose cross-architecture float drift
reproduces byte-for-byte on an x86_64 build of the parent commit. check.py
--backend dynasm is 331 passed, and all 341 fixtures run clean against a
build with -C debug-assertions=yes.

Assisted-by: Claude
…end arm

Remove the append_inplace_wb_covered optimization that dropped the recorded
list_write_barrier(W_ListObject) on w_list_append's Object-strategy in-place
arm in favour of the backend COND_CALL_GC_WB_ARRAY on the block's setarrayitem.
A guard-failure bridge re-materializes the items block and appends into it
without that array barrier firing, so an old->young element store never enters
the remembered set; a later minor collection frees the still-referenced young
element and the collector reads a freed header (validate_type_id /
object_total_size panic; nested_list_comprehension_hot sized up, dynasm only).

Removes FbwWalkMode::append_inplace_wb_covered_receiver (field, setter in the
w_list_append fold sub-walk) and the w_list_append_stores_into_gc_block_in_place
predicate.

(cherry picked from commit b301a3c)
@youknowone

Copy link
Copy Markdown
Owner Author

Rebased onto current origin/main and force-pushed (--force-with-lease). One commit added beyond the previous push.

Added: cherry-pick of b301a3c21b from #872

jit(fbw): always record list_write_barrier on the Object in-place append arm, recorded with -x.

A 337-fixture x 4-config panic census on this branch found that shrinking PYPY_GC_NURSERY exposes four fixtures that abort at GC BUG: invalid type_id — across two sites, not one:

fixture site before after
nested_list_comprehension_hot object_total_size 5/5 abort 0/56
comprehension_object_append_hot object_total_size 5/5 abort 0/56
const_arg_call_resume object_total_size 5/5 abort 0/56
exception_traceback_frame_lineno copy_nursery_object 5/5 abort 0/56

(after = 7 nursery sizes x 8 reps each). #872's commit clears both sites, including const_arg_call_resume and the copy_nursery_object fixture, which its title does not name — the two sites shared one producer, a missing remembered-set entry for an appended old->young element.

The census also confirms the config CI actually runs (PYRE_STDLIB + MAJIT_STRICT=1 + MAJIT_STATS=1, default nursery) is 0 panics / 337 fixtures on arm64 and on x86_64 under Rosetta, both before and after.

Verification

python3 pyre/check.py --backend dynasm,cranelift: 331 passed on both backends. The sole failure is synth/ast_compile_roundtrip, which check.py itself marks BASEFAIL and which fails identically on cranelift.

Not addressed here

synth/attr_instance_shadows_class is 148x slower than pypy at its shipped N=30000 (14490 ms vs 98 ms) and scales superlinearly — doubling N roughly triples pyre's time while pypy stays flat. It is not a panic and not JIT-related (PYRE_NO_JIT=1 is slower). Profile: mapdict_root_walker_area 31.1%, OldGen::contains 25.3%. walk_mapdict_roots_area clones the entire INSTANCE_DICT side table on every minor collection with a per-entry mutex re-lock, so minor GC costs O(live instances) rather than O(nursery); there is no remembered set for that table. That is mapdict + collector work, a different subsystem from this branch's dynasm backend changes, so it is left for a separate change.

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

Here are some automated review suggestions for this pull request.

Reviewed commit: 79cea09137

ℹ️ 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 +3440 to +3442
let recorded = ctx
.trace_ctx
.record_op_with_descr(call_opcode, &allboxes, descr.clone());

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 Fix the bridge barrier instead of recording the helper

For Object-strategy in-place appends, this records list_write_barrier as an ordinary residual call in every steady-state iteration even though the backend already emits COND_CALL_GC_WB_ARRAY; the stated failure occurs specifically when a guard-failure bridge re-materializes the items block without emitting that array barrier. Keeping an extra owner-barrier call in all normal traces masks that bridge-generation defect and imposes a duplicate call on this hot path, so restore the upstream recording shape and add the missing write barrier to the bridge/rematerialization path instead.

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

🤖 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-backend-dynasm/src/regalloc.rs`:
- Around line 4135-4138: Preserve unordered-NaN semantics when fusing
FloatGt/FloatGe comparisons into condition-code results: update the
force_allocate_reg_or_cc handling at regalloc.rs lines 4135-4138 to materialize
or branch with an unordered-aware condition instead of publishing raw CC_G/CC_GE
from AArch64 FCMP, and apply the same correction to the J2 path at lines
4156-4158.

In `@pyre/pyre-interpreter/src/jit_fnaddr.rs`:
- Around line 1482-1485: Clarify the comment describing the residual barrier and
enclosing W_ListObject: state that the list slot keeps the appended element
reachable, while the barrier records the old W_ListObject so minor GC traces the
old-to-young edge. Preserve the existing inline sub-walk behavior and
reachability context.

In `@pyre/pyre-jit-trace/src/jitcode_dispatch/residual_call.rs`:
- Around line 490-496: After assigning frame.f_backref in the entered branch,
invoke the existing GC write-barrier mechanism for the old-generation frame and
the newly stored saved_topframeref before publishing or using the shadow-stack
root. Keep the update scoped to this f_backref publication so minor GC preserves
the residual frame chain.
🪄 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: d2ef540b-eea2-4b81-b288-48bfbb4ba33c

📥 Commits

Reviewing files that changed from the base of the PR and between 6f9a3d4 and 79cea09.

📒 Files selected for processing (16)
  • majit/majit-backend-dynasm/src/aarch64/assembler.rs
  • majit/majit-backend-dynasm/src/aarch64/opassembler.rs
  • majit/majit-backend-dynasm/src/aarch64/regalloc.rs
  • majit/majit-backend-dynasm/src/regalloc.rs
  • majit/majit-backend-dynasm/src/x86/assembler.rs
  • majit/majit-translate/src/front/mir.rs
  • pyre/pyre-interpreter/src/eval.rs
  • pyre/pyre-interpreter/src/executioncontext.rs
  • pyre/pyre-interpreter/src/jit_fnaddr.rs
  • pyre/pyre-jit-trace/src/jitcode_dispatch/mod.rs
  • pyre/pyre-jit-trace/src/jitcode_dispatch/residual_call.rs
  • pyre/pyre-jit-trace/src/jitcode_dispatch/specialize.rs
  • pyre/pyre-jit-trace/src/state.rs
  • pyre/pyre-jit-trace/src/trace.rs
  • pyre/pyre-object/src/listobject.rs
  • pyre/pyre-object/src/unicodeobject.rs
💤 Files with no reviewable changes (1)
  • pyre/pyre-object/src/listobject.rs

Comment on lines +4135 to +4138
// x86/regalloc.py:682 — a float comparison whose only consumer is the
// next guard leaves its answer in the flags, like the integer one.
let ops_ref: &[Op] = self.operations;
let result_loc = self.force_allocate_reg_or_cc(op.pos.get(), ops_ref, i);

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🎯 Functional Correctness | 🟠 Major | 🏗️ Heavy lift

Preserve unordered-NaN semantics before fusing FloatGt/FloatGe.

AArch64 FCMP on NaN leaves N=0, Z=0, C=1, V=1; raw b.gt and b.ge both succeed because N == V. Publishing CC_G/CC_GE through the sentinel therefore makes a following guard incorrectly pass for NaN. Add unordered-aware flag handling, or exclude these operations from CC fusion until that handling exists.

  • majit/majit-backend-dynasm/src/regalloc.rs#L4135-L4138: retain a NaN-correct materialization path or use an unordered-aware fused branch.
  • majit/majit-backend-dynasm/src/regalloc.rs#L4156-L4158: apply the same handling to the J2 path.
📍 Affects 1 file
  • majit/majit-backend-dynasm/src/regalloc.rs#L4135-L4138 (this comment)
  • majit/majit-backend-dynasm/src/regalloc.rs#L4156-L4158
🤖 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-backend-dynasm/src/regalloc.rs` around lines 4135 - 4138,
Preserve unordered-NaN semantics when fusing FloatGt/FloatGe comparisons into
condition-code results: update the force_allocate_reg_or_cc handling at
regalloc.rs lines 4135-4138 to materialize or branch with an unordered-aware
condition instead of publishing raw CC_G/CC_GE from AArch64 FCMP, and apply the
same correction to the J2 path at lines 4156-4158.

Comment on lines +1482 to +1485
// the inline sub-walk must decline. The residual barrier remembers the
// enclosing `W_ListObject`, whose trace reaches every item slot, and is
// the only thing keeping an appended `old -> young` element reachable
// across a minor collection.

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

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

Clarify the barrier’s role in reachability.

The write barrier does not itself keep the appended element reachable; the list slot does. Its role is to record the old W_ListObject so minor GC traces that old-to-young edge.

Proposed wording
-    // the inline sub-walk must decline. The residual barrier remembers the
-    // enclosing `W_ListObject`, whose trace reaches every item slot, and is
-    // the only thing keeping an appended `old -> young` element reachable
-    // across a minor collection.
+    // the inline sub-walk must decline. The residual barrier remembers the
+    // enclosing `W_ListObject`, whose trace reaches every item slot, so the
+    // collector scans an appended `old -> young` edge during minor collection.
📝 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
// the inline sub-walk must decline. The residual barrier remembers the
// enclosing `W_ListObject`, whose trace reaches every item slot, and is
// the only thing keeping an appended `old -> young` element reachable
// across a minor collection.
// the inline sub-walk must decline. The residual barrier remembers the
// enclosing `W_ListObject`, whose trace reaches every item slot, so the
// collector scans an appended `old -> young` edge during minor collection.
🤖 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 1482 - 1485, Clarify
the comment describing the residual barrier and enclosing W_ListObject: state
that the list slot keeps the appended element reachable, while the barrier
records the old W_ListObject so minor GC traces the old-to-young edge. Preserve
the existing inline sub-walk behavior and reachability context.

Comment on lines +490 to +496
if entered {
unsafe {
(*frame).f_backref = saved_topframeref;
(*ec).topframeref = frame;
}
}
let saved_root = majit_gc::shadow_stack::push(majit_ir::GcRef(saved_topframeref as 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.

🩺 Stability & Availability | 🔴 Critical | ⚡ Quick win

Add a write barrier after publishing f_backref.

frame is old-gen while saved_topframeref can be a nursery JitVirtualRef. Rooting saved_root forwards only the shadow-stack slot; without remembering frame.f_backref, a minor GC leaves that field stale while residual code can still walk the chain.

Proposed fix
         if entered {
             unsafe {
                 (*frame).f_backref = saved_topframeref;
+                if pyre_object::gc_hook::try_gc_owns_object(frame as *mut u8) {
+                    pyre_object::gc_hook::try_gc_write_barrier(frame as *mut u8);
+                }
                 (*ec).topframeref = frame;
             }
         }
📝 Committable suggestion

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

Suggested change
if entered {
unsafe {
(*frame).f_backref = saved_topframeref;
(*ec).topframeref = frame;
}
}
let saved_root = majit_gc::shadow_stack::push(majit_ir::GcRef(saved_topframeref as usize));
if entered {
unsafe {
(*frame).f_backref = saved_topframeref;
if pyre_object::gc_hook::try_gc_owns_object(frame as *mut u8) {
pyre_object::gc_hook::try_gc_write_barrier(frame as *mut u8);
}
(*ec).topframeref = frame;
}
}
let saved_root = majit_gc::shadow_stack::push(majit_ir::GcRef(saved_topframeref as usize));
🤖 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-trace/src/jitcode_dispatch/residual_call.rs` around lines 490 -
496, After assigning frame.f_backref in the entered branch, invoke the existing
GC write-barrier mechanism for the old-generation frame and the newly stored
saved_topframeref before publishing or using the shadow-stack root. Keep the
update scoped to this f_backref publication so minor GC preserves the residual
frame chain.

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