CratonVM

JIT round 10, lane arm — proposals

Findings from an encoder-and-gate audit of the aarch64 backend (jit/src/aarch64.rs, jit/src/aarch64_backend.rs) and of jit/src/backend_parity.rs (x86-64 single-pass-vs-IR body comparison, owned by this lane in this round though it has no aarch64 content). None of the three directions below is a bug fix — the audit did not find a live encoding bug worth fixing or filing (see the session report for what was checked and refuted) — each is net-new mechanism or a small consistency cleanup.

1. A minimal AArch64 pseudo-op interpreter, for SEMANTIC differential tests

What's missing today: every aarch64 test in jit/src/aarch64.rs asserts an exact instruction WORD. That is the right check for "did the encoder put this field in the right bit position" (FOCUS item 1 of this round), and it caught real bugs in earlier rounds (the register-31 aliasing, the load/store immediate truncations). It cannot catch a bug where two fields are each correctly encoded but the wrong VALUE was fed to the wrong field — e.g. emit_int_div_rem handing MSUB's ra/rn/rm operands in a permuted but still-well-formed order, which would still assert as "a valid MSUB encoding" under an exact-word test that only checks against a hand-computed expected word using the same permutation. aarch64_backend's own test suite already works around this one instruction at a time by decoding fields back out of the emitted word and asserting on the field VALUES against the source Arm64Instruction (see r9w11_div_rem_guard_the_divisor_and_branch_to_the_throw_stub), which is exactly the right idea but is re-invented per test.

Mechanism: a #[cfg(test)]-only, ~300-line interpreter in aarch64_backend.rs's test module (or a new jit/tests/r10_arm_interp.rs — this lane's naming convention) that executes the Arm64Instruction pseudo-op stream directly (not the encoded bytes — no ARM host or emulator needed), against a [i64; 31] GPR file, an [f64; 32] FP file, and a Vec<u8> stack scratchpad for Ldr/Str frame slots. It needs to cover only the ~45 pseudo-ops the backend actually emits (the match in emit_machine_code_inner is the exhaustive list). Then EVERY existing compile_with(...) + eval_int_method/eval_... test in aarch64_backend.rs's suite — which today runs the real encoded bytes on... nothing, because CI is x86-64, so those tests currently only check Arm64CompileResult::success and inspect the instruction list by pattern-matching — gains a SECOND, independent oracle: interpret the pseudo-ops and compare against the interpreter tier's actual result for the same bytecode (already computed in-process for every compiled method). A mismatch means either backend's LOWERING is wrong, not just its encoding — the class of bug this round's audit could not rule out by inspection alone, because verifying emit_lcmp's CMP/CSET/CNEG sequence or emit_int_div_rem's guard-then-divide shape by hand-tracing values is exactly the kind of check a human reviewer is worst at and a 50-line interpreter loop is best at.

Cost: the interpreter itself is a big match on Arm64Instruction variants (already an exhaustive, closed enum — no guessing needed) executing each one's obvious arithmetic; a day, single-lane, no coordination. The integration (running it from every existing arithmetic/branch/conversion test instead of only checking success) is bigger — call it a full round — because it means rewriting assertions test-by-test rather than adding one new test file, and it is the only piece of this proposal that touches test files outside this lane's normal review scope (needs its own round with this lane holding aarch64_backend.rs alone, per the round's file-locking rule).

Recommendation: worth doing before the shared-memory lowerings (getfield/putfield/bastore/the throw path) land for real, because that work is exactly where a permuted-operand bug would hide — an NPE guard that compares the wrong register, or a bounds check that adds instead of subtracts, would still encode as "a valid CMP" and pass every exact-word test in the file.

2. Extend backend_parity's capability-loss detector to aarch64 bodies

What's missing today: backend_parity.rs's whole mechanism — decode a finished body, report a capability the OPTIMIZING tier lost relative to the baseline — is architecture-general in design (BodyProfile, regressions, the asymmetric-loss ParityVerdict) but hard-wired to x86-64 encoding in decode_one/decode_vector/one_byte_shape/two_byte_shape. It exists because a lowering that quietly stops emitting a capability the JIT relies on is invisible until a benchmark regresses (the module header's own motivating incident: bastore lowering directly, sieve silently losing its 6.4x SWAR path with no failing test). The aarch64 backend is heading toward exactly that failure shape: the shared-memory gate's whole design is "one pseudo-op arm now orders its own memory access (getstatic's LDAR), more will follow, and each is admitted to opcode_has_ordered_lowering as it lands" — which is a sequence of lowerings, several backend contributors, and several rounds, i.e. precisely the conditions under which "a body quietly stopped emitting LDAR/STLR/DMB" is a plausible regression. Round 9 waves 12-13 made it concrete: arraylength and the fourteen primitive array element accesses now lower, so real array/field bodies exist. (The ARM64_CAN_ORDER_MEMORY this paragraph named was removed in wave 14 — a single bool in front of the per-opcode list was both too weak and, for its exclusive-access half, unreachable; see ARM64_LOWERS_ACQUIRE_RELEASE.) Today there is nothing that would notice; the only check is the exact-word tests pinning each finished lowering, which say nothing about whether the NEXT lowering that touches the same field keeps emitting the barrier.

Mechanism: an Arm64BodyCapability enum (Ldar, Stlr, Dmb, Ldaxr/Stlxr/Casal as one Exclusive bucket) and an aarch64 profile_body, MUCH simpler than the x86-64 one because AArch64 is fixed-width: no length decode is needed at all, just code.chunks_exact(4) and a match on each word's fixed top bits (LDAR/STLR are already fully pinned bit patterns per ldst_ordered's doc). This is a few hours, not a redesign — the hard part of backend_parity.rs (variable-length x86 decode, fail-closed on desync) doesn't exist on this architecture. The comparison baseline is not another backend (aarch64 has no second tier to compare against) but the PREVIOUS release's compiled body for the same method, i.e. a golden-body regression test: compile a small fixed corpus of volatile-field-touching methods once getfield/putfield land, record their Arm64BodyCapability sets in a checked-in golden file, and fail if a future change compiles the same method with fewer barriers. This is the aarch64 counterpart of the ratchet tests already in this tree (jit/tests/process_global_statics_ratchet.rs) — same idea (a number that is only allowed to move in the safe direction), applied to barrier presence instead of static-declaration count.

Cost: small (the decoder) plus one golden-file corpus to build and maintain as new opcodes get ordered lowerings; grows with the backend, not with this change.

Recommendation: this was "stage it alongside step 1 of aarch64-backend-has-ordering-encoders-but-no-shared-memory-lowerings-20260918.md rather than after, while the lowering set is still small (one opcode: getstatic)". That window has closed: round 9 waves 12-13 took the set to sixteen opcodes (getstatic, arraylength, and the fourteen primitive array element accesses), and that page is RETIRED (docs/internal/retired/...-RETIRED-20260921.md). The proposal stands and is now worth MORE, for exactly the reason it gave — it is scaffolding that is expensive to retrofit once ten opcodes order memory and nobody remembers which one lost its barrier. Start the golden corpus from the sixteen that exist.

3. Fold ldr_imm_w/str_imm_w into the A8 register-field type split

What: the A8 change (4b83cd26a) split every load/store base register into impl Into<RegSp> and every zero-extending data register into impl Into<RegZr>, and did so for ldr_imm/str_imm (64-bit), ldrb_imm/ldrh_imm/strb_imm/strh_imm (the byte/halfword forms added in round 9 wave 10) and the FP forms — but not for ldr_imm_w/str_imm_w (the 32-bit unsigned-offset forms), which still take a plain Reg for both rt and rn. This is not a live bug: Reg cannot BE encoding 31 any more (A8 removed SP from the enum entirely), so there is no way to pass SP through these two functions today, and their only production caller (aarch64_backend's MemLoad/MemStore W32 arm) always passes an allocator-assigned scratch register — never SP — as both rt and rn, so the narrower type costs nothing today. But it is the exact shape of inconsistency the A8 commit message itself flags as the dangerous half-finished state ("mixed SP-reading immediate forms with XZR-reading shifted forms"): if a future 32-bit lowering ever needs [SP, #imm] (a spilled int local addressed directly off SP rather than FP, say), the fix has no home to go to — the caller would have to either widen these two functions first (discovering the gap by a compile error, the safe direction) or, worse, round-trip through RegSp::enc()/a raw u32 and re-derive the encoding by hand next to the two functions that already do it correctly.

Mechanism: widen the two signatures to ldr_imm_w(&mut self, rt: impl Into<RegZr>, rn: impl Into<RegSp>, imm: u16) and the str_imm_w equivalent, exactly mirroring ldr_imm/str_imm's already-widened signatures three lines above them in the same file. Every current call site passes a Reg, which already implements Into<RegZr> and Into<RegSp>, so this is a pure widening with zero call-site changes.

Cost: minutes; it is a two-function signature change with no behavior change on any existing caller. Not done in this round because HARD RULE 5 ("keep edits compile-safe in isolation... do not change the signature... of any public item other files use") makes it out of scope for a lane that cannot build: ldr_imm_w/str_imm_w are pub fn on a type (Aarch64Emitter) that aarch64_backend.rs also touches heavily in this same round, and confirming no other in-flight lane's uncommitted edit takes a narrower view of these two functions needs a build this lane is not allowed to run.

Recommendation: low priority, but free — bundle it into whichever future change first needs [SP, #imm] on a 32-bit access, rather than filing it as its own round.