From 1ac90dc74b3260e2376bd0a4f78cf5ad9d1a337e Mon Sep 17 00:00:00 2001 From: Ralf Anton Beier Date: Fri, 3 Jul 2026 18:39:53 +0200 Subject: [PATCH] =?UTF-8?q?fix(selector):=20i64=20rotl/rotr/div=5Fu/rem=5F?= =?UTF-8?q?u=20compute=20real=20results=20=E2=80=94=20never=20silent=200?= =?UTF-8?q?=20(#610)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit All four ops (plus div_s/rem_s, same disease) compiled without error and returned 0 for every input on the ARM Cortex-M path. Root cause was in the Thumb-2 encoder's multi-instruction expansions, one disease in two forms: * I64Rotl/I64Rotr: the expansion used hardcoded R3/R4 scratch that collided with selector-assigned registers, then its own `POP {R4}` restored the saved scratch OVER the computed result (rd_lo == R4 in the repro) — the op returned the caller's stale R4: 0 under qemu/unicorn reset state. * I64DivU/I64RemU/I64DivS/I64RemS: the expansion IGNORED its register fields outright (`rdlo: _, ...` — hardcoded R0:R1 dividend, R2:R3 divisor, result to R0:R1) while the selector allocated rd elsewhere (R4:R5), which the core's own POP then clobbered with stale values. Fix: a fixed-ABI wrapper around each core — save R0-R3, marshal the operand registers into the core's fixed input regs via the stack (permutation-safe: every source is read before any fixed reg is written), run the core (self-preserving for R4+; R12 is encoder scratch, never allocatable #212), MOV the result pair into the selector's rd (loud Err on the impossible swapped pair), restore R0-R3 skipping the result registers. The rot cores are rewritten to fixed regs (R0:R1 value, R2 amount, R3+R12 scratch); the div/rem shift-subtract cores are byte-identical inside the wrapper. Divide-by-zero now traps (`ORRS R12,R2,R3; BNE +0; UDF #0`), matching WASM semantics and the i32 guard — previously div/0 silently returned 0. Estimator kept in exact agreement (#498/#511 oracle): rot 74→102 bytes, div_u/rem_u/div_s/rem_s 74/78/126/124 → 120/124/172/170; all sizes are register-independent by construction. Frozen fixture hashes bit-identical (these ops appear in no frozen anchor). Red→green: scripts/repro/i64_rot_div_610_differential.py (55 vectors — rot identity/32/63/>=64 + hi-half twins, div by 1/self/0-trap, high-bit patterns, signed variants, shl control) vs wasmtime under unicorn: 40/55 MISMATCH on v0.30.0, 55/55 OK after. Wired as an isolated CI oracle job. New encoder unit tests pin the rd-landing tail, the zero-divisor guard, the rd∈R0-R3 skip-restore, and the swapped-pair loud reject. Closes #610 Co-Authored-By: Claude Opus 4.8 --- .github/workflows/ci.yml | 36 + crates/synth-backend/src/arm_encoder.rs | 657 ++++++++++-------- .../tests/estimator_encoder_agreement.rs | 4 +- .../synth-synthesis/src/optimizer_bridge.rs | 19 +- scripts/repro/i64_rot_div_610.wat | 55 ++ scripts/repro/i64_rot_div_610_differential.py | 216 ++++++ 6 files changed, 685 insertions(+), 302 deletions(-) create mode 100644 scripts/repro/i64_rot_div_610.wat create mode 100644 scripts/repro/i64_rot_div_610_differential.py diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 991835a6..1e134e62 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -665,6 +665,42 @@ jobs: SYNTH: ./target/debug/synth run: python scripts/repro/i64_spill_pool_587_differential.py + i64-rot-div-610-oracle: + name: i64 rotl/rotr/div/rem expansion oracle + # VCR-ORACLE-001 (#242, #610): EXECUTE the i64 rotl/rotr/div_u/rem_u (+ + # div_s/rem_s) encoder expansions under unicorn and diff vs wasmtime. + # Pre-#610 these compiled without error and returned 0 for EVERY input: + # the rot expansions restored saved scratch OVER the result (`POP {R4}` + # with rd_lo == R4) and the div/rem expansions ignored their register + # operands outright (hardcoded R0:R1/R2:R3). Now wrapped in the fixed-ABI + # marshal/restore; divide-by-zero traps (UDF #0) like the i32 guard. + # Vectors: rot-by-0 identity, rot 32/63/>=64, div by 1/self/0(trap), + # high-bit patterns, _hi twins for the upper result half. + # Isolated job: emulation deps pip-installed here ONLY. + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v7 + - uses: dtolnay/rust-toolchain@stable + - name: Cache Cargo dependencies + uses: actions/cache@v6 + with: + path: | + ~/.cargo/registry + ~/.cargo/git + target/ + key: ${{ runner.os }}-cargo-${{ hashFiles('**/Cargo.lock') }} + restore-keys: | + ${{ runner.os }}-cargo- + - name: Build synth + run: cargo build -p synth-cli + - uses: actions/setup-python@v6 + with: + python-version: "3.x" + - name: Install emulation deps + run: pip install wasmtime unicorn pyelftools + - name: Run i64 rot/div/rem expansion oracle (#610) + run: python scripts/repro/i64_rot_div_610_differential.py target/debug/synth + br-table-507-oracle: name: optimized-path br_table oracle # VCR-ORACLE-001 (#242, #507): EXECUTE br_table dispatch compiled via the diff --git a/crates/synth-backend/src/arm_encoder.rs b/crates/synth-backend/src/arm_encoder.rs index f5f9b28e..31bcb1b2 100644 --- a/crates/synth-backend/src/arm_encoder.rs +++ b/crates/synth-backend/src/arm_encoder.rs @@ -3551,10 +3551,16 @@ impl ArmEncoder { Ok(bytes) // Total: 40 bytes } - // I64Rotl: 64-bit rotate left + // I64Rotl: 64-bit rotate left (#610 rewrite). // For n < 32: new_hi = (hi << n) | (lo >> (32-n)), new_lo = (lo << n) | (hi >> (32-n)) - // For n >= 32: same formula but with lo/hi conceptually swapped, shift by (n-32) - // Uses R4 (saved/restored) and R12 as scratch + // For n >= 32: same formula with lo/hi swapped, shift by m = n-32. + // + // Fixed-reg core: value in R0:R1, amount in R2, scratch R3 + R12 + // (all four saved/marshaled by the #610 fixed-ABI wrapper; the + // pre-#610 expansion wrote through the selector's registers with + // colliding R3/R4 scratch and restored the saved R4 OVER the + // result). Relies on ARM register-shift semantics: amounts >= 32 + // yield 0 for LSL/LSR, which makes n = 0 and n = 32 exact. ArmOp::I64Rotl { rdlo, rdhi, @@ -3562,142 +3568,47 @@ impl ArmEncoder { rnhi, shift, } => { - let rd_lo_bits = reg_to_bits(rdlo); - let rd_hi_bits = reg_to_bits(rdhi); - let rn_lo_bits = reg_to_bits(rnlo); - let rn_hi_bits = reg_to_bits(rnhi); - let shift_bits = reg_to_bits(shift); - let r12: u32 = 12; // IP scratch - let r3: u32 = 3; // Scratch (high word of shift amount, unused) - let r4: u32 = 4; // Scratch (saved/restored) let mut bytes = Vec::new(); + emit_i64_fixed_abi_entry(&mut bytes, &[rnlo, rnhi, shift]); + + let core: [u16; 35] = [ + 0xF002, 0x023F, // AND.W R2, R2, #63 (mask amount mod 64) + 0xF1B2, 0x0320, // SUBS.W R3, R2, #32 (R3 = n-32, sets N) + 0xD50E, // BPL .large (n >= 32) + // --- small rotation (n < 32) --- + 0xF1C2, 0x0320, // RSB.W R3, R2, #32 (R3 = 32-n) + 0xFA20, 0xFC03, // LSR.W R12, R0, R3 (lo >> (32-n)) + 0xFA21, 0xF303, // LSR.W R3, R1, R3 (hi >> (32-n)) + 0xFA01, 0xF102, // LSL.W R1, R1, R2 (hi << n) + 0xEA41, 0x010C, // ORR.W R1, R1, R12 (new_hi) + 0xFA00, 0xF002, // LSL.W R0, R0, R2 (lo << n) + 0xEA40, 0x0003, // ORR.W R0, R0, R3 (new_lo) + 0xE00E, // B .done + // --- large rotation (n >= 32), R3 = m = n-32 --- + 0xF1C3, 0x0220, // RSB.W R2, R3, #32 (R2 = 32-m = 64-n) + 0xFA21, 0xFC02, // LSR.W R12, R1, R2 (hi >> (64-n)) + 0xFA20, 0xF202, // LSR.W R2, R0, R2 (lo >> (64-n)) + 0xFA00, 0xF003, // LSL.W R0, R0, R3 (lo << m) + 0xFA01, 0xF103, // LSL.W R1, R1, R3 (hi << m) + 0xEA40, 0x0C0C, // ORR.W R12, R0, R12 (new_hi = (lo<>(64-n))) + 0xEA41, 0x0002, // ORR.W R0, R1, R2 (new_lo = (hi<>(64-n))) + 0x4661, // MOV R1, R12 (new_hi into place) + // .done: result in R0:R1 + ]; + for hw in core { + bytes.extend_from_slice(&hw.to_le_bytes()); + } - // PUSH {R4} - bytes.extend_from_slice(&0xB410u16.to_le_bytes()); - - // AND.W shift, shift, #63 (mask to 6 bits) - let hw1: u16 = (0xF000 | shift_bits) as u16; - let hw2: u16 = ((shift_bits << 8) | 0x3F) as u16; - bytes.extend_from_slice(&hw1.to_le_bytes()); - bytes.extend_from_slice(&hw2.to_le_bytes()); - - // SUBS.W R3, shift, #32 (R3 = n-32, sets flags) - let hw1: u16 = (0xF1B0 | shift_bits) as u16; - let hw2: u16 = ((r3 << 8) | 0x20) as u16; - bytes.extend_from_slice(&hw1.to_le_bytes()); - bytes.extend_from_slice(&hw2.to_le_bytes()); - - // BPL .large (branch if n >= 32, offset = +14 halfwords) - let bpl: u16 = 0xD50E; - bytes.extend_from_slice(&bpl.to_le_bytes()); - - // === Small rotation (n < 32) === - // RSB.W R3, shift, #32 (R3 = 32-n) - let hw1: u16 = (0xF1C0 | shift_bits) as u16; - let hw2: u16 = ((r3 << 8) | 0x20) as u16; - bytes.extend_from_slice(&hw1.to_le_bytes()); - bytes.extend_from_slice(&hw2.to_le_bytes()); - - // LSR.W R4, rn_lo, R3 (R4 = lo >> (32-n), will go to new_hi) - let hw1: u16 = (0xFA20 | rn_lo_bits) as u16; - let hw2: u16 = (0xF000 | (r4 << 8) | r3) as u16; - bytes.extend_from_slice(&hw1.to_le_bytes()); - bytes.extend_from_slice(&hw2.to_le_bytes()); - - // LSR.W R12, rn_hi, R3 (R12 = hi >> (32-n), will go to new_lo) - let hw1: u16 = (0xFA20 | rn_hi_bits) as u16; - let hw2: u16 = (0xF000 | (r12 << 8) | r3) as u16; - bytes.extend_from_slice(&hw1.to_le_bytes()); - bytes.extend_from_slice(&hw2.to_le_bytes()); - - // LSL.W rd_hi, rn_hi, shift (rd_hi = hi << n) - let hw1: u16 = (0xFA00 | rn_hi_bits) as u16; - let hw2: u16 = (0xF000 | (rd_hi_bits << 8) | shift_bits) as u16; - bytes.extend_from_slice(&hw1.to_le_bytes()); - bytes.extend_from_slice(&hw2.to_le_bytes()); - - // ORR.W rd_hi, rd_hi, R4 (rd_hi = (hi << n) | (lo >> (32-n))) - let hw1: u16 = (0xEA40 | rd_hi_bits) as u16; - let hw2: u16 = ((rd_hi_bits << 8) | r4) as u16; - bytes.extend_from_slice(&hw1.to_le_bytes()); - bytes.extend_from_slice(&hw2.to_le_bytes()); - - // LSL.W rd_lo, rn_lo, shift (rd_lo = lo << n) - let hw1: u16 = (0xFA00 | rn_lo_bits) as u16; - let hw2: u16 = (0xF000 | (rd_lo_bits << 8) | shift_bits) as u16; - bytes.extend_from_slice(&hw1.to_le_bytes()); - bytes.extend_from_slice(&hw2.to_le_bytes()); - - // ORR.W rd_lo, rd_lo, R12 (rd_lo = (lo << n) | (hi >> (32-n))) - let hw1: u16 = (0xEA40 | rd_lo_bits) as u16; - let hw2: u16 = ((rd_lo_bits << 8) | r12) as u16; - bytes.extend_from_slice(&hw1.to_le_bytes()); - bytes.extend_from_slice(&hw2.to_le_bytes()); - - // B .done (skip large block, offset = +14 halfwords) - let b_done: u16 = 0xE00E; - bytes.extend_from_slice(&b_done.to_le_bytes()); - - // === Large rotation (n >= 32) === - // R3 already has n-32 from the SUBS - // RSB.W R4, R3, #32 (R4 = 32-(n-32) = 64-n) - let hw1: u16 = (0xF1C0 | r3) as u16; - let hw2: u16 = ((r4 << 8) | 0x20) as u16; - bytes.extend_from_slice(&hw1.to_le_bytes()); - bytes.extend_from_slice(&hw2.to_le_bytes()); - - // LSR.W R12, rn_hi, R4 (R12 = hi >> (64-n), goes to new_hi low bits) - let hw1: u16 = (0xFA20 | rn_hi_bits) as u16; - let hw2: u16 = (0xF000 | (r12 << 8) | r4) as u16; - bytes.extend_from_slice(&hw1.to_le_bytes()); - bytes.extend_from_slice(&hw2.to_le_bytes()); - - // LSR.W R4, rn_lo, R4 (R4 = lo >> (64-n), goes to new_lo low bits) - let hw1: u16 = (0xFA20 | rn_lo_bits) as u16; - let hw2: u16 = (0xF000 | (r4 << 8) | r4) as u16; - bytes.extend_from_slice(&hw1.to_le_bytes()); - bytes.extend_from_slice(&hw2.to_le_bytes()); - - // LSL.W shift, rn_lo, R3 (shift = lo << (n-32), new_hi high bits) - let hw1: u16 = (0xFA00 | rn_lo_bits) as u16; - let hw2: u16 = (0xF000 | (shift_bits << 8) | r3) as u16; - bytes.extend_from_slice(&hw1.to_le_bytes()); - bytes.extend_from_slice(&hw2.to_le_bytes()); - - // ORR.W shift, shift, R12 (shift = (lo << (n-32)) | (hi >> (64-n)) = new_hi) - let hw1: u16 = (0xEA40 | shift_bits) as u16; - let hw2: u16 = ((shift_bits << 8) | r12) as u16; - bytes.extend_from_slice(&hw1.to_le_bytes()); - bytes.extend_from_slice(&hw2.to_le_bytes()); - - // LSL.W rd_lo, rn_hi, R3 (rd_lo = hi << (n-32), new_lo high bits) - let hw1: u16 = (0xFA00 | rn_hi_bits) as u16; - let hw2: u16 = (0xF000 | (rd_lo_bits << 8) | r3) as u16; - bytes.extend_from_slice(&hw1.to_le_bytes()); - bytes.extend_from_slice(&hw2.to_le_bytes()); - - // ORR.W rd_lo, rd_lo, R4 (rd_lo = (hi << (n-32)) | (lo >> (64-n)) = new_lo) - let hw1: u16 = (0xEA40 | rd_lo_bits) as u16; - let hw2: u16 = ((rd_lo_bits << 8) | r4) as u16; - bytes.extend_from_slice(&hw1.to_le_bytes()); - bytes.extend_from_slice(&hw2.to_le_bytes()); - - // MOV rd_hi, shift (rd_hi = new_hi) - let d_bit = (rd_hi_bits >> 3) & 1; - let mov_instr: u16 = - (0x4600 | (d_bit << 7) | (shift_bits << 3) | (rd_hi_bits & 0x7)) as u16; - bytes.extend_from_slice(&mov_instr.to_le_bytes()); - - // POP {R4} - bytes.extend_from_slice(&0xBC10u16.to_le_bytes()); - - Ok(bytes) // Total: 74 bytes + emit_i64_fixed_abi_exit(&mut bytes, rdlo, rdhi)?; + Ok(bytes) // Total: 102 bytes } - // I64Rotr: 64-bit rotate right - // rotr(x, n) = rotl(x, 64-n) + // I64Rotr: 64-bit rotate right (#610 rewrite). // For n < 32: new_lo = (lo >> n) | (hi << (32-n)), new_hi = (hi >> n) | (lo << (32-n)) - // For n >= 32: same formula but with lo/hi swapped, shift by (n-32) + // For n >= 32: same formula with lo/hi swapped, shift by m = n-32. + // + // Same fixed-reg core contract as I64Rotl: value in R0:R1, amount + // in R2, scratch R3 + R12, all covered by the fixed-ABI wrapper. ArmOp::I64Rotr { rdlo, rdhi, @@ -3705,135 +3616,39 @@ impl ArmEncoder { rnhi, shift, } => { - let rd_lo_bits = reg_to_bits(rdlo); - let rd_hi_bits = reg_to_bits(rdhi); - let rn_lo_bits = reg_to_bits(rnlo); - let rn_hi_bits = reg_to_bits(rnhi); - let shift_bits = reg_to_bits(shift); - let r12: u32 = 12; - let r3: u32 = 3; - let r4: u32 = 4; let mut bytes = Vec::new(); + emit_i64_fixed_abi_entry(&mut bytes, &[rnlo, rnhi, shift]); + + let core: [u16; 35] = [ + 0xF002, 0x023F, // AND.W R2, R2, #63 (mask amount mod 64) + 0xF1B2, 0x0320, // SUBS.W R3, R2, #32 (R3 = n-32, sets N) + 0xD50E, // BPL .large (n >= 32) + // --- small rotation (n < 32) --- + 0xF1C2, 0x0320, // RSB.W R3, R2, #32 (R3 = 32-n) + 0xFA01, 0xFC03, // LSL.W R12, R1, R3 (hi << (32-n)) + 0xFA00, 0xF303, // LSL.W R3, R0, R3 (lo << (32-n)) + 0xFA20, 0xF002, // LSR.W R0, R0, R2 (lo >> n) + 0xEA40, 0x000C, // ORR.W R0, R0, R12 (new_lo) + 0xFA21, 0xF102, // LSR.W R1, R1, R2 (hi >> n) + 0xEA41, 0x0103, // ORR.W R1, R1, R3 (new_hi) + 0xE00E, // B .done + // --- large rotation (n >= 32), R3 = m = n-32 --- + 0xF1C3, 0x0220, // RSB.W R2, R3, #32 (R2 = 32-m = 64-n) + 0xFA00, 0xFC02, // LSL.W R12, R0, R2 (lo << (64-n)) + 0xFA01, 0xF202, // LSL.W R2, R1, R2 (hi << (64-n)) + 0xFA21, 0xF103, // LSR.W R1, R1, R3 (hi >> m) + 0xEA41, 0x0C0C, // ORR.W R12, R1, R12 (new_lo = (hi>>m)|(lo<<(64-n))) + 0xFA20, 0xF103, // LSR.W R1, R0, R3 (lo >> m) + 0xEA41, 0x0102, // ORR.W R1, R1, R2 (new_hi = (lo>>m)|(hi<<(64-n))) + 0x4660, // MOV R0, R12 (new_lo into place) + // .done: result in R0:R1 + ]; + for hw in core { + bytes.extend_from_slice(&hw.to_le_bytes()); + } - // PUSH {R4} - bytes.extend_from_slice(&0xB410u16.to_le_bytes()); - - // AND.W shift, shift, #63 - let hw1: u16 = (0xF000 | shift_bits) as u16; - let hw2: u16 = ((shift_bits << 8) | 0x3F) as u16; - bytes.extend_from_slice(&hw1.to_le_bytes()); - bytes.extend_from_slice(&hw2.to_le_bytes()); - - // SUBS.W R3, shift, #32 - let hw1: u16 = (0xF1B0 | shift_bits) as u16; - let hw2: u16 = ((r3 << 8) | 0x20) as u16; - bytes.extend_from_slice(&hw1.to_le_bytes()); - bytes.extend_from_slice(&hw2.to_le_bytes()); - - // BPL .large (+14 halfwords) - let bpl: u16 = 0xD50E; - bytes.extend_from_slice(&bpl.to_le_bytes()); - - // === Small rotation (n < 32) === - // RSB.W R3, shift, #32 (R3 = 32-n) - let hw1: u16 = (0xF1C0 | shift_bits) as u16; - let hw2: u16 = ((r3 << 8) | 0x20) as u16; - bytes.extend_from_slice(&hw1.to_le_bytes()); - bytes.extend_from_slice(&hw2.to_le_bytes()); - - // LSL.W R4, rn_hi, R3 (R4 = hi << (32-n), will go to new_lo) - let hw1: u16 = (0xFA00 | rn_hi_bits) as u16; - let hw2: u16 = (0xF000 | (r4 << 8) | r3) as u16; - bytes.extend_from_slice(&hw1.to_le_bytes()); - bytes.extend_from_slice(&hw2.to_le_bytes()); - - // LSL.W R12, rn_lo, R3 (R12 = lo << (32-n), will go to new_hi) - let hw1: u16 = (0xFA00 | rn_lo_bits) as u16; - let hw2: u16 = (0xF000 | (r12 << 8) | r3) as u16; - bytes.extend_from_slice(&hw1.to_le_bytes()); - bytes.extend_from_slice(&hw2.to_le_bytes()); - - // LSR.W rd_lo, rn_lo, shift (rd_lo = lo >> n) - let hw1: u16 = (0xFA20 | rn_lo_bits) as u16; - let hw2: u16 = (0xF000 | (rd_lo_bits << 8) | shift_bits) as u16; - bytes.extend_from_slice(&hw1.to_le_bytes()); - bytes.extend_from_slice(&hw2.to_le_bytes()); - - // ORR.W rd_lo, rd_lo, R4 (rd_lo = (lo >> n) | (hi << (32-n))) - let hw1: u16 = (0xEA40 | rd_lo_bits) as u16; - let hw2: u16 = ((rd_lo_bits << 8) | r4) as u16; - bytes.extend_from_slice(&hw1.to_le_bytes()); - bytes.extend_from_slice(&hw2.to_le_bytes()); - - // LSR.W rd_hi, rn_hi, shift (rd_hi = hi >> n) - let hw1: u16 = (0xFA20 | rn_hi_bits) as u16; - let hw2: u16 = (0xF000 | (rd_hi_bits << 8) | shift_bits) as u16; - bytes.extend_from_slice(&hw1.to_le_bytes()); - bytes.extend_from_slice(&hw2.to_le_bytes()); - - // ORR.W rd_hi, rd_hi, R12 (rd_hi = (hi >> n) | (lo << (32-n))) - let hw1: u16 = (0xEA40 | rd_hi_bits) as u16; - let hw2: u16 = ((rd_hi_bits << 8) | r12) as u16; - bytes.extend_from_slice(&hw1.to_le_bytes()); - bytes.extend_from_slice(&hw2.to_le_bytes()); - - // B .done (+14 halfwords) - let b_done: u16 = 0xE00E; - bytes.extend_from_slice(&b_done.to_le_bytes()); - - // === Large rotation (n >= 32) === - // RSB.W R4, R3, #32 (R4 = 64-n) - let hw1: u16 = (0xF1C0 | r3) as u16; - let hw2: u16 = ((r4 << 8) | 0x20) as u16; - bytes.extend_from_slice(&hw1.to_le_bytes()); - bytes.extend_from_slice(&hw2.to_le_bytes()); - - // LSL.W R12, rn_lo, R4 (R12 = lo << (64-n), goes to new_lo low bits) - let hw1: u16 = (0xFA00 | rn_lo_bits) as u16; - let hw2: u16 = (0xF000 | (r12 << 8) | r4) as u16; - bytes.extend_from_slice(&hw1.to_le_bytes()); - bytes.extend_from_slice(&hw2.to_le_bytes()); - - // LSL.W R4, rn_hi, R4 (R4 = hi << (64-n), goes to new_hi low bits) - let hw1: u16 = (0xFA00 | rn_hi_bits) as u16; - let hw2: u16 = (0xF000 | (r4 << 8) | r4) as u16; - bytes.extend_from_slice(&hw1.to_le_bytes()); - bytes.extend_from_slice(&hw2.to_le_bytes()); - - // LSR.W shift, rn_hi, R3 (shift = hi >> (n-32), new_lo high bits) - let hw1: u16 = (0xFA20 | rn_hi_bits) as u16; - let hw2: u16 = (0xF000 | (shift_bits << 8) | r3) as u16; - bytes.extend_from_slice(&hw1.to_le_bytes()); - bytes.extend_from_slice(&hw2.to_le_bytes()); - - // ORR.W shift, shift, R12 (shift = (hi >> (n-32)) | (lo << (64-n)) = new_lo) - let hw1: u16 = (0xEA40 | shift_bits) as u16; - let hw2: u16 = ((shift_bits << 8) | r12) as u16; - bytes.extend_from_slice(&hw1.to_le_bytes()); - bytes.extend_from_slice(&hw2.to_le_bytes()); - - // LSR.W rd_hi, rn_lo, R3 (rd_hi = lo >> (n-32), new_hi high bits) - let hw1: u16 = (0xFA20 | rn_lo_bits) as u16; - let hw2: u16 = (0xF000 | (rd_hi_bits << 8) | r3) as u16; - bytes.extend_from_slice(&hw1.to_le_bytes()); - bytes.extend_from_slice(&hw2.to_le_bytes()); - - // ORR.W rd_hi, rd_hi, R4 (rd_hi = (lo >> (n-32)) | (hi << (64-n)) = new_hi) - let hw1: u16 = (0xEA40 | rd_hi_bits) as u16; - let hw2: u16 = ((rd_hi_bits << 8) | r4) as u16; - bytes.extend_from_slice(&hw1.to_le_bytes()); - bytes.extend_from_slice(&hw2.to_le_bytes()); - - // MOV rd_lo, shift (rd_lo = new_lo) - let d_bit = (rd_lo_bits >> 3) & 1; - let mov_instr: u16 = - (0x4600 | (d_bit << 7) | (shift_bits << 3) | (rd_lo_bits & 0x7)) as u16; - bytes.extend_from_slice(&mov_instr.to_le_bytes()); - - // POP {R4} - bytes.extend_from_slice(&0xBC10u16.to_le_bytes()); - - Ok(bytes) // Total: 74 bytes + emit_i64_fixed_abi_exit(&mut bytes, rdlo, rdhi)?; + Ok(bytes) // Total: 102 bytes } // I64Clz: Count leading zeros in 64-bit value @@ -4487,18 +4302,26 @@ impl ArmEncoder { } // I64DivU: 64-bit unsigned division using binary long division - // Input: R0:R1 = dividend, R2:R3 = divisor - // Output: R0:R1 = quotient + // Core: R0:R1 = dividend, R2:R3 = divisor -> R0:R1 = quotient // Uses: R4-R7, R12 as loop counter (avoid R8 for Renode compatibility) + // + // #610: the fixed-ABI wrapper marshals the selector-assigned + // operand registers into the core's fixed regs and lands the + // result in rd — pre-#610 this arm IGNORED its register fields, + // so the selector read its rd pair (e.g. R4:R5) after the core's + // own POP restored the stale caller values over it: 0 for every + // input. A zero divisor now traps (UDF #0), per WASM semantics. ArmOp::I64DivU { - rdlo: _, - rdhi: _, - rnlo: _, - rnhi: _, - rmlo: _, - rmhi: _, + rdlo, + rdhi, + rnlo, + rnhi, + rmlo, + rmhi, } => { let mut bytes = Vec::new(); + emit_i64_fixed_abi_entry(&mut bytes, &[rnlo, rnhi, rmlo, rmhi]); + emit_i64_divisor_zero_trap(&mut bytes); // PUSH {R4-R7} - save scratch registers (NO LR — this is inline code) // 16-bit PUSH: 1011 010 M rrrrrrrr where M=0 (no LR), r=R4-R7 = 0xF0 @@ -4611,22 +4434,26 @@ impl ArmEncoder { // Encoding: 1011 1100 1111 0000 = 0xBCF0 bytes.extend_from_slice(&0xBCF0u16.to_le_bytes()); + emit_i64_fixed_abi_exit(&mut bytes, rdlo, rdhi)?; Ok(bytes) } // I64DivS: 64-bit signed division // Converts to unsigned, divides, then applies sign - // Input: R0:R1 = dividend (signed), R2:R3 = divisor (signed) - // Output: R0:R1 = quotient (signed) + // Core: R0:R1 = dividend (signed), R2:R3 = divisor (signed) + // -> R0:R1 = quotient (signed) + // #610: fixed-ABI wrapper + zero-divisor trap (see I64DivU). ArmOp::I64DivS { - rdlo: _, - rdhi: _, - rnlo: _, - rnhi: _, - rmlo: _, - rmhi: _, + rdlo, + rdhi, + rnlo, + rnhi, + rmlo, + rmhi, } => { let mut bytes = Vec::new(); + emit_i64_fixed_abi_entry(&mut bytes, &[rnlo, rnhi, rmlo, rmhi]); + emit_i64_divisor_zero_trap(&mut bytes); // PUSH {R4-R11} - save scratch registers (NO LR — inline code) bytes.extend_from_slice(&0xE92Du16.to_le_bytes()); @@ -4738,22 +4565,25 @@ impl ArmEncoder { bytes.extend_from_slice(&0xE8BDu16.to_le_bytes()); bytes.extend_from_slice(&0x0FF0u16.to_le_bytes()); + emit_i64_fixed_abi_exit(&mut bytes, rdlo, rdhi)?; Ok(bytes) } // I64RemU: 64-bit unsigned remainder using binary long division // Same algorithm as I64DivU but returns remainder instead of quotient - // Input: R0:R1 = dividend, R2:R3 = divisor - // Output: R0:R1 = remainder + // Core: R0:R1 = dividend, R2:R3 = divisor -> R0:R1 = remainder + // #610: fixed-ABI wrapper + zero-divisor trap (see I64DivU). ArmOp::I64RemU { - rdlo: _, - rdhi: _, - rnlo: _, - rnhi: _, - rmlo: _, - rmhi: _, + rdlo, + rdhi, + rnlo, + rnhi, + rmlo, + rmhi, } => { let mut bytes = Vec::new(); + emit_i64_fixed_abi_entry(&mut bytes, &[rnlo, rnhi, rmlo, rmhi]); + emit_i64_divisor_zero_trap(&mut bytes); // PUSH {R4-R8} - save scratch registers (NO LR — inline code) bytes.extend_from_slice(&0xE92Du16.to_le_bytes()); @@ -4822,22 +4652,26 @@ impl ArmEncoder { bytes.extend_from_slice(&0xE8BDu16.to_le_bytes()); bytes.extend_from_slice(&0x01F0u16.to_le_bytes()); + emit_i64_fixed_abi_exit(&mut bytes, rdlo, rdhi)?; Ok(bytes) } // I64RemS: 64-bit signed remainder // Remainder sign follows dividend sign (not quotient rule) - // Input: R0:R1 = dividend (signed), R2:R3 = divisor (signed) - // Output: R0:R1 = remainder (signed, same sign as dividend) + // Core: R0:R1 = dividend (signed), R2:R3 = divisor (signed) + // -> R0:R1 = remainder (signed, same sign as dividend) + // #610: fixed-ABI wrapper + zero-divisor trap (see I64DivU). ArmOp::I64RemS { - rdlo: _, - rdhi: _, - rnlo: _, - rnhi: _, - rmlo: _, - rmhi: _, + rdlo, + rdhi, + rnlo, + rnhi, + rmlo, + rmhi, } => { let mut bytes = Vec::new(); + emit_i64_fixed_abi_entry(&mut bytes, &[rnlo, rnhi, rmlo, rmhi]); + emit_i64_divisor_zero_trap(&mut bytes); // PUSH {R4-R11} - save scratch registers (NO LR — inline code) bytes.extend_from_slice(&0xE92Du16.to_le_bytes()); @@ -4945,6 +4779,7 @@ impl ArmEncoder { bytes.extend_from_slice(&0xE8BDu16.to_le_bytes()); bytes.extend_from_slice(&0x0FF0u16.to_le_bytes()); + emit_i64_fixed_abi_exit(&mut bytes, rdlo, rdhi)?; Ok(bytes) } @@ -7031,6 +6866,101 @@ fn reg_to_bits(reg: &Reg) -> u32 { } } +// ====================================================================== +// #610 — i64 fixed-ABI expansion wrappers. +// +// The hand-written multi-instruction i64 cores (rotl/rotr and the div/rem +// shift-subtract loops) compute in FIXED low registers. Before #610 the +// div/rem arms ignored their operand fields outright (hardcoded R0:R1 / +// R2:R3 in, result to R0:R1) and the rot arms used R3/R4 scratch that +// collided with selector-assigned registers — then restored the saved +// scratch OVER the result (`POP {R4}` with rd_lo == R4), so the op +// returned the caller's stale register: 0 for every input under qemu. +// +// These wrappers make each core honor its register parameters: +// 1. save R0-R3, +// 2. marshal the operand registers into the core's fixed input regs via +// the stack (permutation-safe: every source is read before any fixed +// register is written), +// 3. run the fixed-reg core (self-preserving for R4+; R12 is encoder +// scratch and never allocatable, #212), +// 4. MOV the result pair from R0:R1 into the selector's rd pair, +// 5. restore R0-R3, skipping any register the result now occupies. +// +// All emitted lengths are register-independent so the optimized path's +// byte-size estimator (`estimate_arm_byte_size`, pinned by the +// estimator↔encoder agreement oracle #498/#511) stays a constant per op. +// ====================================================================== + +/// Steps 1+2: `PUSH {R0-R3}`, then marshal `srcs` (operand registers, any of +/// R0-R12) into `R0..R` via individual stack pushes. Sources are all read +/// before any destination register is written, so arbitrary source/target +/// permutations (including operands living in R0-R3) are safe. +fn emit_i64_fixed_abi_entry(bytes: &mut Vec, srcs: &[&Reg]) { + debug_assert!(srcs.len() <= 4); + // PUSH {R0-R3} — save the caller-visible low registers. + bytes.extend_from_slice(&0xB40Fu16.to_le_bytes()); + // STR src, [SP, #-4]! — push in reverse so srcs[0] ends up on top. + for src in srcs.iter().rev() { + let rt = reg_to_bits(src) as u16; + bytes.extend_from_slice(&0xF84Du16.to_le_bytes()); + bytes.extend_from_slice(&((rt << 12) | 0x0D04).to_le_bytes()); + } + // POP {Ri} — Ri := srcs[i]. + for i in 0..srcs.len() as u16 { + bytes.extend_from_slice(&(0xBC00u16 | (1u16 << i)).to_le_bytes()); + } +} + +/// Steps 4+5: move the core's R0:R1 result into the selector's rd pair, then +/// restore the R0-R3 saved by [`emit_i64_fixed_abi_entry`], skipping any +/// register the result now lives in (its saved caller word is discarded). +fn emit_i64_fixed_abi_exit(bytes: &mut Vec, rdlo: &Reg, rdhi: &Reg) -> Result<()> { + let lo = reg_to_bits(rdlo); + let hi = reg_to_bits(rdhi); + if lo == 1 && hi == 0 { + // A fully swapped pair would clobber one half in either MOV order. + // Selector pairs are consecutive (lo, lo+1), so this cannot occur. + return Err(synth_core::Error::synthesis( + "i64 expansion: swapped result pair (rd_lo=R1, rd_hi=R0) is unsupported (#610)", + )); + } + let mov16 = |bytes: &mut Vec, rd: u32, rm: u32| { + let d = ((rd >> 3) & 1) as u16; + bytes.extend_from_slice( + &(0x4600u16 | (d << 7) | ((rm as u16) << 3) | ((rd & 7) as u16)).to_le_bytes(), + ); + }; + if hi == 0 { + // rd_hi is R0: read R0 into rd_lo BEFORE overwriting R0 with R1. + mov16(bytes, lo, 0); + mov16(bytes, hi, 1); + } else { + // rd_lo may be R1: read R1 into rd_hi BEFORE overwriting R1 with R0. + mov16(bytes, hi, 1); + mov16(bytes, lo, 0); + } + for i in 0..4u32 { + if i == lo || i == hi { + // The result lives here — drop the saved caller word. + bytes.extend_from_slice(&0xB001u16.to_le_bytes()); // ADD SP, #4 + } else { + bytes.extend_from_slice(&(0xBC00u16 | (1u16 << i)).to_le_bytes()); // POP {Ri} + } + } + Ok(()) +} + +/// WASM `i64.div_*` / `i64.rem_*` by zero must trap, matching the i32 path's +/// cmp/bne/udf guard. Emitted after marshaling, when the divisor pair is in +/// R2:R3: `ORRS R12, R2, R3` — `BNE` over a `UDF #0` when nonzero. +fn emit_i64_divisor_zero_trap(bytes: &mut Vec) { + bytes.extend_from_slice(&0xEA52u16.to_le_bytes()); // ORRS.W R12, R2, R3 + bytes.extend_from_slice(&0x0C03u16.to_le_bytes()); + bytes.extend_from_slice(&0xD100u16.to_le_bytes()); // BNE.N +0 (skip the UDF) + bytes.extend_from_slice(&0xDE00u16.to_le_bytes()); // UDF #0 — divide by zero +} + /// Fallible form of the `verify_reg_bits` contract. PC (R15) is not a valid /// data operand for the Thumb-2 encodings that use this guard (SDIV/UDIV/MLS/… /// are UNPREDICTABLE with PC). Synth's own codegen never emits PC there, but @@ -8824,6 +8754,147 @@ mod tests { assert_eq!(code, vec![0x00, 0xDE]); } + /// #610: the i64 rot/div/rem expansions must land the result in the + /// selector-assigned rd pair and leave R0-R3 preserved (restored from the + /// fixed-ABI wrapper's save area) — pre-#610 the rot expansion's own + /// `POP {R4}` restored stale scratch OVER the result (rd_lo == R4) and + /// the div/rem expansions ignored their register fields outright. + #[test] + fn test_610_i64_rot_expansion_ends_with_rd_movs_and_restore() { + let encoder = ArmEncoder::new_thumb2(); + for op in [ + ArmOp::I64Rotl { + rdlo: Reg::R4, + rdhi: Reg::R5, + rnlo: Reg::R0, + rnhi: Reg::R1, + shift: Reg::R2, + }, + ArmOp::I64Rotr { + rdlo: Reg::R4, + rdhi: Reg::R5, + rnlo: Reg::R0, + rnhi: Reg::R1, + shift: Reg::R2, + }, + ] { + let code = encoder.encode(&op).unwrap(); + assert_eq!(code.len(), 102, "register-independent size (estimator pin)"); + // Tail: MOV r5, r1 (0x460D); MOV r4, r0 (0x4604); POP {r0..r3} + // (rd pair r4:r5 does not overlap the save area — all 4 restored). + let tail: Vec = code[code.len() - 12..] + .chunks(2) + .map(|c| u16::from_le_bytes([c[0], c[1]])) + .collect(); + assert_eq!(tail, vec![0x460D, 0x4604, 0xBC01, 0xBC02, 0xBC04, 0xBC08]); + } + } + + /// #610: div/rem expansions honor rd and carry the divide-by-zero trap + /// guard (`ORRS R12, R2, R3; BNE +0; UDF #0`) after operand marshaling. + #[test] + fn test_610_i64_div_rem_expansion_guard_and_rd() { + let encoder = ArmEncoder::new_thumb2(); + let mk = |which: u8| { + let (rdlo, rdhi, rnlo, rnhi, rmlo, rmhi) = + (Reg::R4, Reg::R5, Reg::R0, Reg::R1, Reg::R2, Reg::R3); + match which { + 0 => ArmOp::I64DivU { + rdlo, + rdhi, + rnlo, + rnhi, + rmlo, + rmhi, + }, + 1 => ArmOp::I64RemU { + rdlo, + rdhi, + rnlo, + rnhi, + rmlo, + rmhi, + }, + 2 => ArmOp::I64DivS { + rdlo, + rdhi, + rnlo, + rnhi, + rmlo, + rmhi, + }, + _ => ArmOp::I64RemS { + rdlo, + rdhi, + rnlo, + rnhi, + rmlo, + rmhi, + }, + } + }; + for which in 0..4u8 { + let code = encoder.encode(&mk(which)).unwrap(); + // Zero-divisor trap guard right after the 26-byte marshal prologue. + let guard: Vec = code[26..34] + .chunks(2) + .map(|c| u16::from_le_bytes([c[0], c[1]])) + .collect(); + assert_eq!( + guard, + vec![0xEA52, 0x0C03, 0xD100, 0xDE00], + "ORRS R12,R2,R3; BNE +0; UDF #0" + ); + // Tail: result into rd pair (r5:r4), then restore all of R0-R3. + let tail: Vec = code[code.len() - 12..] + .chunks(2) + .map(|c| u16::from_le_bytes([c[0], c[1]])) + .collect(); + assert_eq!(tail, vec![0x460D, 0x4604, 0xBC01, 0xBC02, 0xBC04, 0xBC08]); + } + } + + /// #610: when rd overlaps R0-R3 the restore must SKIP the result + /// registers (drop the saved caller word) instead of popping over them. + #[test] + fn test_610_i64_divu_rd_in_r0_r1_skips_restore() { + let encoder = ArmEncoder::new_thumb2(); + let code = encoder + .encode(&ArmOp::I64DivU { + rdlo: Reg::R0, + rdhi: Reg::R1, + rnlo: Reg::R0, + rnhi: Reg::R1, + rmlo: Reg::R2, + rmhi: Reg::R3, + }) + .unwrap(); + let tail: Vec = code[code.len() - 12..] + .chunks(2) + .map(|c| u16::from_le_bytes([c[0], c[1]])) + .collect(); + // MOV r1,r1 / MOV r0,r0 (no-ops, size-stable), ADD SP,#4 twice + // (discard saved r0/r1 — the result lives there), POP {r2}, POP {r3}. + assert_eq!(tail, vec![0x4609, 0x4600, 0xB001, 0xB001, 0xBC04, 0xBC08]); + } + + /// #610: a fully swapped rd pair (rd_lo=R1, rd_hi=R0) cannot be + /// materialized by two MOVs in either order — must be a loud Err, never + /// silent corruption. (Selector pairs are consecutive, so unreachable.) + #[test] + fn test_610_i64_swapped_rd_pair_rejected() { + let encoder = ArmEncoder::new_thumb2(); + let result = encoder.encode(&ArmOp::I64RemU { + rdlo: Reg::R1, + rdhi: Reg::R0, + rnlo: Reg::R2, + rnhi: Reg::R3, + rmlo: Reg::R4, + rmhi: Reg::R5, + }); + assert!(result.is_err(), "swapped rd pair must be rejected loudly"); + } + #[test] fn test_encode_nop_thumb2() { let encoder = ArmEncoder::new_thumb2(); diff --git a/crates/synth-backend/tests/estimator_encoder_agreement.rs b/crates/synth-backend/tests/estimator_encoder_agreement.rs index 4efa4091..f8270eb8 100644 --- a/crates/synth-backend/tests/estimator_encoder_agreement.rs +++ b/crates/synth-backend/tests/estimator_encoder_agreement.rs @@ -43,7 +43,9 @@ //! { 84 }`. //! - The i64 long-sequence estimates `I64DivU/RemU/DivS/RemS` (100/100/150/150), //! `I64Popcnt` (200) and `I64Extend32S` (8) OVER-estimated. CLOSED: pinned to -//! the exact encoder lengths (74/78/126/124, 172, 6). +//! the exact encoder lengths (74/78/126/124, 172, 6). (#610 later wrapped the +//! div/rem and rot expansions in the fixed-ABI marshal/restore + zero-divisor +//! trap — now 120/124/172/170 and 102, still exact-pinned here.) //! - SURVIVOR (structural): far branches (`BOffset`/`BCondOffset` with a large //! displacement) need the 4-byte `.w` form but the estimator runs BEFORE //! displacements are known, on a 0-offset placeholder, so it sizes them 2 — diff --git a/crates/synth-synthesis/src/optimizer_bridge.rs b/crates/synth-synthesis/src/optimizer_bridge.rs index 5d538ad4..a0b5f1ff 100644 --- a/crates/synth-synthesis/src/optimizer_bridge.rs +++ b/crates/synth-synthesis/src/optimizer_bridge.rs @@ -454,8 +454,10 @@ pub fn estimate_arm_byte_size(op: &ArmOp) -> usize { ArmOp::I64Shl { .. } | ArmOp::I64ShrU { .. } => 38, // I64ShrS: same as ShrU but large block is 8 bytes (ASR+ASR vs LSR+MOV) = 40 ArmOp::I64ShrS { .. } => 40, - // I64Rotl/Rotr: PUSH(2) + AND(4) + SUBS(4) + BPL(2) + small_block(30) + B(2) + large_block(30) + POP(2) = 74 bytes - ArmOp::I64Rotl { .. } | ArmOp::I64Rotr { .. } => 74, + // I64Rotl/Rotr (#610): fixed-ABI wrapper (PUSH r0-r3(2) + 3×STR(12) + + // 3×POP(6) + 2×MOV(4) + 4×restore(8) = 32) + core (AND(4) + SUBS(4) + + // BPL(2) + small(30) + B(2) + large(30) = 70) = 102 bytes. + ArmOp::I64Rotl { .. } | ArmOp::I64Rotr { .. } => 102, // I64Clz: CMP.W(4) + BEQ(2) + CLZ.W(4) + B(2) + NOP(2) + CLZ.W(4) + ADD.W(4) + MOV(2) = 24 bytes ArmOp::I64Clz { .. } => 24, // I64Ctz: CMP.W(4) + BEQ(2) + RBIT.W(4) + CLZ.W(4) + B(2) + NOP(2) + RBIT.W(4) + CLZ.W(4) + ADD.W(4) + MOV(2) = 32 bytes @@ -469,13 +471,14 @@ pub fn estimate_arm_byte_size(op: &ArmOp) -> usize { // I64Extend32S: MOV (lo passthrough) + ASR (sign-fill hi) = 6 bytes // (#498: measured 6, was an 8-byte over-estimate). ArmOp::I64Extend32S { .. } => 6, - // I64 division: PUSH + init + binary long-division loop + MOV + POP. - // #498: exact measured sizes (were ~80-150 guesses). - ArmOp::I64DivU { .. } => 74, - ArmOp::I64RemU { .. } => 78, + // I64 division: #610 fixed-ABI wrapper (PUSH r0-r3(2) + 4×STR(16) + + // 4×POP(8) + zero-trap(8) + 2×MOV(4) + 4×restore(8) = 46) around the + // pre-#610 cores (74/78/126/124, the #498 exact measurements). + ArmOp::I64DivU { .. } => 120, + ArmOp::I64RemU { .. } => 124, // Signed versions have additional negation logic. - ArmOp::I64DivS { .. } => 126, - ArmOp::I64RemS { .. } => 124, + ArmOp::I64DivS { .. } => 172, + ArmOp::I64RemS { .. } => 170, // AND/OR/XOR: encoder always uses 32-bit Thumb-2 (.W) encoding ArmOp::And { .. } | ArmOp::Orr { .. } | ArmOp::Eor { .. } => 4, // LDR/STR with high base register or large offset need 32-bit diff --git a/scripts/repro/i64_rot_div_610.wat b/scripts/repro/i64_rot_div_610.wat new file mode 100644 index 00000000..ae812997 --- /dev/null +++ b/scripts/repro/i64_rot_div_610.wat @@ -0,0 +1,55 @@ +;; #610: i64.rotl / i64.rotr / i64.div_u / i64.rem_u silently compiled to code +;; returning 0 for EVERY input on the ARM Cortex-M path (self-contained, no +;; relocations — not a missing libcall). Root cause was in the encoder's +;; multi-instruction expansions, one disease in two forms: +;; - rotl/rotr: the expansion used hardcoded R3/R4 scratch that collided +;; with the selector-assigned registers, then `POP {R4}` restored the +;; saved scratch OVER the computed result (rd_lo == R4) — the op returned +;; the caller's stale R4 (0 under qemu/unicorn reset state). +;; - div_u/rem_u (and div_s/rem_s): the expansion IGNORED its register +;; fields outright (hardcoded R0:R1 / R2:R3 in, result to R0:R1) while the +;; selector allocated rd elsewhere (e.g. R4:R5) — and the core's own POP +;; restored stale values over that pair too. +;; Fixed by the #610 fixed-ABI wrapper: save R0-R3, marshal operands via the +;; stack (permutation-safe), run the fixed-reg core, MOV the result into the +;; selector's rd pair, restore R0-R3 skipping the result registers. Divide by +;; zero now traps (UDF #0), matching WASM semantics and the i32 guard. +;; +;; lo/hi function pairs let the harness check BOTH halves of every 64-bit +;; result through the i32 return register. wasmtime oracle per vector. +(module + ;; --- variable-amount rotates --- + (func (export "rotl") (param i64 i64) (result i32) + (i32.wrap_i64 (i64.rotl (local.get 0) (local.get 1)))) + (func (export "rotl_hi") (param i64 i64) (result i32) + (i32.wrap_i64 (i64.shr_u (i64.rotl (local.get 0) (local.get 1)) (i64.const 32)))) + (func (export "rotr") (param i64 i64) (result i32) + (i32.wrap_i64 (i64.rotr (local.get 0) (local.get 1)))) + (func (export "rotr_hi") (param i64 i64) (result i32) + (i32.wrap_i64 (i64.shr_u (i64.rotr (local.get 0) (local.get 1)) (i64.const 32)))) + ;; --- const-amount rotates (the issue's exact repro shape) --- + (func (export "rotl8") (param i64) (result i32) + (i32.wrap_i64 (i64.rotl (local.get 0) (i64.const 8)))) + (func (export "rotl0") (param i64) (result i32) + (i32.wrap_i64 (i64.rotl (local.get 0) (i64.const 0)))) + (func (export "rotr8") (param i64) (result i32) + (i32.wrap_i64 (i64.rotr (local.get 0) (i64.const 8)))) + ;; --- unsigned divide / remainder --- + (func (export "div_u") (param i64 i64) (result i32) + (i32.wrap_i64 (i64.div_u (local.get 0) (local.get 1)))) + (func (export "div_u_hi") (param i64 i64) (result i32) + (i32.wrap_i64 (i64.shr_u (i64.div_u (local.get 0) (local.get 1)) (i64.const 32)))) + (func (export "rem_u") (param i64 i64) (result i32) + (i32.wrap_i64 (i64.rem_u (local.get 0) (local.get 1)))) + (func (export "rem_u_hi") (param i64 i64) (result i32) + (i32.wrap_i64 (i64.shr_u (i64.rem_u (local.get 0) (local.get 1)) (i64.const 32)))) + (func (export "divu7") (param i64) (result i32) + (i32.wrap_i64 (i64.div_u (local.get 0) (i64.const 7)))) + ;; --- signed divide / remainder (same fixed-ABI disease, fixed together) --- + (func (export "div_s") (param i64 i64) (result i32) + (i32.wrap_i64 (i64.div_s (local.get 0) (local.get 1)))) + (func (export "rem_s") (param i64 i64) (result i32) + (i32.wrap_i64 (i64.rem_s (local.get 0) (local.get 1)))) + ;; --- control: correct before the fix (issue table) --- + (func (export "shl4") (param i64) (result i32) + (i32.wrap_i64 (i64.shl (local.get 0) (i64.const 4))))) diff --git a/scripts/repro/i64_rot_div_610_differential.py b/scripts/repro/i64_rot_div_610_differential.py new file mode 100644 index 00000000..5d966b87 --- /dev/null +++ b/scripts/repro/i64_rot_div_610_differential.py @@ -0,0 +1,216 @@ +# #610: i64.rotl / i64.rotr / i64.div_u / i64.rem_u compiled to code returning +# 0 for every input on the ARM Cortex-M path. The encoder expansions either +# used colliding hardcoded scratch and restored it OVER the result (rot: `POP +# {R4}` with rd_lo == R4) or ignored their register operands entirely (div/rem: +# hardcoded R0:R1/R2:R3, result to R0:R1, while the selector read rd = R4:R5). +# Pre-fix, every vector below (except the shl4 control) returned the stale +# caller register — 0. +# +# Differential: compile EACH export with the issue's exact config +# (`-t cortex-m3 -n --relocatable`), emulate under unicorn +# (UC_MODE_THUMB), and difference against the wasmtime oracle. Divide-by-zero +# vectors assert BOTH sides trap (synth now emits a UDF #0 guard; wasmtime +# reports "integer divide by zero"). +# +# Edge vectors per the #610 gate: rot-by-0 identity, rot by 32 (half swap), +# rot >= 64 (mod-64 mask), div by 1 / by self / by zero (trap), high-bit +# patterns, >32-bit divisors, and _hi twins so both halves of the 64-bit +# result are checked through the i32 return register. +# +# Usage: python3 i64_rot_div_610_differential.py [wat] +import os +import shutil +import struct +import subprocess +import sys +import tempfile + +from elftools.elf.elffile import ELFFile +from unicorn import Uc, UcError, UC_ARCH_ARM, UC_MODE_THUMB +from unicorn.arm_const import ( + UC_ARM_REG_LR, + UC_ARM_REG_R0, + UC_ARM_REG_R1, + UC_ARM_REG_R2, + UC_ARM_REG_R3, + UC_ARM_REG_PC, + UC_ARM_REG_SP, +) + +SYNTH = sys.argv[1] if len(sys.argv) > 1 else "target/debug/synth" +WAT = ( + sys.argv[2] + if len(sys.argv) > 2 + else os.path.join(os.path.dirname(__file__), "i64_rot_div_610.wat") +) +CODE, STK = 0x1000000, 0x6000000 +RET = CODE + 0xFFF0 +M64 = (1 << 64) - 1 +M32 = (1 << 32) - 1 +TRAP = "TRAP" + + +def sext64(v): + return v - (1 << 64) if v & (1 << 63) else v + + +def rotl(x, n): + n &= 63 + return ((x << n) | (x >> (64 - n))) & M64 if n else x + + +PAT = 0x123456789ABCDEF0 + +# (function, args-as-u64 tuple, wasm-semantics expected i32-as-u32 or TRAP) +VECTORS = [ + # --- rotl: identity, issue repro, cross-word, mod-64, high-bit --- + ("rotl", (123, 0), 123), # identity — the issue's clearest tell + ("rotl", (1, 8), 256), # issue table row 1 + ("rotl", (PAT, 4), rotl(PAT, 4) & M32), + ("rotl_hi", (PAT, 4), rotl(PAT, 4) >> 32), + ("rotl", (PAT, 32), rotl(PAT, 32) & M32), # exact half swap + ("rotl_hi", (PAT, 32), rotl(PAT, 32) >> 32), + ("rotl", (PAT, 33), rotl(PAT, 33) & M32), # large-branch path + ("rotl_hi", (PAT, 33), rotl(PAT, 33) >> 32), + ("rotl", (PAT, 63), rotl(PAT, 63) & M32), + ("rotl_hi", (PAT, 63), rotl(PAT, 63) >> 32), + ("rotl", (PAT, 64), PAT & M32), # rot >= 64: mod-64 identity + ("rotl_hi", (PAT, 64), PAT >> 32), + ("rotl", (PAT, 100), rotl(PAT, 100) & M32), + ("rotl", (0x8000000000000001, 1), 3), # both edge bits cross + # --- rotr --- + ("rotr", (256, 8), 1), # issue table row 3 + ("rotr", (PAT, 0), PAT & M32), # identity + ("rotr_hi", (PAT, 0), PAT >> 32), + ("rotr", (1, 1), 0), # lo bit 0 -> hi bit 31 + ("rotr_hi", (1, 1), 0x80000000), + ("rotr", (PAT, 32), rotl(PAT, 32) & M32), # rotr 32 == rotl 32 + ("rotr_hi", (PAT, 32), rotl(PAT, 32) >> 32), + ("rotr", (PAT, 63), rotl(PAT, 1) & M32), # rotr 63 == rotl 1 + ("rotr_hi", (PAT, 63), rotl(PAT, 1) >> 32), + ("rotr", (PAT, 64), PAT & M32), + # --- const-amount shapes (the issue's exact repro) --- + ("rotl8", (1,), 256), + ("rotl0", (123,), 123), + ("rotr8", (256,), 1), + # --- div_u: issue rows, by 1, by self, big, >32-bit divisor --- + ("div_u", (100, 7), 14), + ("div_u", (700, 7), 100), + ("divu7", (100,), 14), + ("div_u", (PAT, 1), PAT & M32), # by 1: identity + ("div_u_hi", (PAT, 1), PAT >> 32), + ("div_u", (PAT, PAT), 1), # by self + ("div_u", (M64, 3), (M64 // 3) & M32), + ("div_u_hi", (M64, 3), (M64 // 3) >> 32), + ("div_u", (1 << 63, 10), ((1 << 63) // 10) & M32), + ("div_u_hi", (1 << 63, 10), ((1 << 63) // 10) >> 32), + ("div_u", (PAT, 1 << 32), (PAT >> 32) & M32), # 64-bit divisor + ("div_u", (7, 100), 0), # divisor > dividend + # --- rem_u --- + ("rem_u", (100, 7), 2), # issue table row 6 + ("rem_u", (PAT, 1), 0), # by 1: zero + ("rem_u", (M64, 1 << 32), M32), + ("rem_u_hi", (M64, (1 << 32) + 1), 0), + ("rem_u", (7, 100), 7), + # --- div_s / rem_s (same fixed-ABI disease, fixed together) --- + ("div_s", ((-100) & M64, 7), (-14) & M32), + ("div_s", (100, (-7) & M64), (-14) & M32), + ("div_s", ((-100) & M64, (-7) & M64), 14), + ("rem_s", ((-100) & M64, 7), (-2) & M32), + ("rem_s", (100, (-7) & M64), 2), + # --- divide by zero: BOTH sides must trap --- + ("div_u", (100, 0), TRAP), + ("rem_u", (100, 0), TRAP), + ("div_s", (100, 0), TRAP), + ("rem_s", (100, 0), TRAP), + # --- control: correct before the fix (issue table) --- + ("shl4", (256,), 4096), +] + + +def wasmtime_oracle(func, args): + if shutil.which("wasmtime") is None: + return None + argv = ["wasmtime", "run", "--invoke", func, WAT] + [str(sext64(a)) for a in args] + out = subprocess.run(argv, capture_output=True, text=True) + if out.returncode != 0: + if "divide by zero" in out.stderr: + return TRAP + return None + return int(out.stdout.strip()) & M32 + + +def compile_func(func, out_o): + argv = [ + SYNTH, "compile", WAT, + "-t", "cortex-m3", + "-n", func, + "--relocatable", + "-o", out_o, + ] + r = subprocess.run(argv, capture_output=True, text=True) + if r.returncode != 0: + print(f"COMPILE FAILED for {func}:\n{r.stderr}") + sys.exit(2) + + +def emulate(elf_path, func, args): + """Returns the u32 result, or TRAP if execution hit a UDF/fault.""" + e = ELFFile(open(elf_path, "rb")) + text = e.get_section_by_name(".text").data() + # Symbols from the symtab, never from disasm text (host-dependent). + st = [s for s in e.iter_sections() if s["sh_type"] == "SHT_SYMTAB"][0] + syms = {s.name: s["st_value"] & ~1 for s in st.iter_symbols() if s.name} + if func not in syms: + print(f"SYMBOL MISSING: {func}") + sys.exit(2) + + mu = Uc(UC_ARCH_ARM, UC_MODE_THUMB) + mu.mem_map(CODE, 0x100000) + mu.mem_map(STK, 0x100000) + mu.mem_write(CODE, text) + mu.mem_write(RET, struct.pack("> 32) & M32) + ri += 2 + mu.reg_write(UC_ARM_REG_SP, STK + 0x80000) + mu.reg_write(UC_ARM_REG_LR, RET | 1) + try: + # div/rem expansions loop 64 times (~1.1k insns) — generous budget. + mu.emu_start((CODE + syms[func]) | 1, RET, count=50000) + except UcError: + return TRAP # UDF #0 (divide-by-zero guard) or a genuine fault + if mu.reg_read(UC_ARM_REG_PC) != RET & ~1: + return TRAP # never reached the return pad + return mu.reg_read(UC_ARM_REG_R0) & M32 + + +tmp = tempfile.mkdtemp(prefix="i64rotdiv610_") +fails = 0 +for func, args, want in VECTORS: + oracle = wasmtime_oracle(func, args) + if oracle is not None and oracle != want: + print(f"HARNESS BUG: builtin expect {want} != wasmtime {oracle} for {func}{args}") + sys.exit(2) + want = oracle if oracle is not None else want + obj = os.path.join(tmp, f"{func}.o") + if not os.path.exists(obj): + compile_func(func, obj) + got = emulate(obj, func, args) + ok = got == want + fails += 0 if ok else 1 + argstr = ", ".join(hex(a) for a in args) + gots = got if got == TRAP else hex(got) + wants = want if want == TRAP else hex(want) + print(f"{func}({argstr}) = {gots} (oracle: {wants}) {'OK' if ok else 'MISMATCH'}") + +if fails == 0: + print("ORACLE: PASS") + sys.exit(0) +print(f"ORACLE: FAIL — {fails} vector(s) wrong (#610)") +sys.exit(1)