From aa83581585bd374775be37d456febe3537514499 Mon Sep 17 00:00:00 2001 From: Ralf Anton Beier Date: Fri, 10 Jul 2026 18:22:32 +0200 Subject: [PATCH] perf(codegen): elide the #682 mod-32 shift mask when the amount is provably < 32 (#686) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit gale measured #682's unconditional AND R12,#31 mask at ~12% cyc/call (+14 B) on gust_mix, whose Q8 fixed-point shifts are all CONSTANTS — the mask can never fire there, yet every register-controlled i32 shift pays it. New peephole liveness::elide_shift_masks removes the mask ONLY when the amount is statically provable < 32; anything unproven keeps it — elision is an optimization, the mask stays the sound default. Two proven shapes, keyed on the #682 idiom (and r12,rK,#31 ; shift by r12 — emitted adjacently by both direct selectors, the Rocq-proved DSL rules, and the optimized bridge; the DSL rules stay MASKED, no proof changes): - Const amount: nearest preceding def of rK is a pure materialization (movw, or the bridge's mov #imm) over a fully-modeled redefinition- free window -> fold the whole triple to the immediate shift, REDUCED MOD 32 (so >= 32 amounts shrink too — the fold computes exactly the WASM semantics the mask enforces); C mod 32 == 0 lowers to MOV (imm5 == 0 encodes shift-by-32, the LSR/ASR pitfall). The movw drops when rK has no other reader (reg_dead_by_redef). This supersedes fold_immediate_shifts for the masked idiom — its movw->shift window is intercepted by the #682 and, so post-#682 it declined every const-amount register shift (the gust_mix regression's second half). - Range-carried amount: nearest def is and rK,rX,#c with c < 32 -> rK < 32 at the shift, the re-mask is a no-op; shift by rK directly. Covers the wasm-level x&31 / x&15 idioms. A #494 fact-spec value- range premise is the documented follow-up. Soundness scaffolding as the sibling folds: backward scan aborts on any reg_effect-unmodeled op (call/branch/LABEL = merge point); dropping the and r12 write is safe by the #212 R12-scratch convention; rotr is never masked (#682 exemption) so the pattern cannot match it; removal/rewrite-only before branch resolution (offset-neutral). FLAG-OFF (opt-in SYNTH_SHIFT_MASK_ELIDE=1) per the flag-then-flip protocol: the elision MOVES the frozen anchors — measured on this commit, per-function, no function grows, every const-shift function shrinks (bytes ON vs OFF, identical on both paths): control_step_decide 313 -> 293 (-20) flight_seam flight_algo 376 -> 296 (-80), controller_step 325 -> 241 (-84) flight_seam_flat flight_algo 520 -> 436 (-84), controller_step -84 gust_mix (the #686 fixture) 65 -> 55 (-10) — recovers the measured regression (the +14 B was mask + retained movw + wide shift) i32_shift_mask_682 shl32/shl33/shl300/shr300/sar300 -> 8-12 B each signed_div_const untouched Default (flag unset) is byte-identical to v0.37.1 everywhere: the frozen byte-gate passes unchanged, and shift_mask_elide_686.rs pins unset == opt-out. The default-on flip is the maintainer's separate refreeze (all differentials green on the new bytes, goldens re-pinned). Oracles on this commit: - i32_shift_mask_682_differential.py: all green DEFAULT and ELIDE-ON, both paths, including every >= 32 row; now also CI-gated with the flag ON. RED-TESTED: a temporary force-elide of const >= 32 (bare register shift, no mask) turned 10 rows red on both paths — the oracle catches unsound elision; hack removed. - Execution differentials with the flag ON over the moved fixtures: flight_seam flight_algo 0x07FDF307 MATCH, control_step 13/13, frame_slot_dce PASS, const_cse PASS. - shift_mask_elide_686.rs: per-function no-grow table over the corpus (both paths) + strict shrink on gust_mix + default==opt-out pin. - 10 new unit tests: mod-32 fold (< 32, >= 32, == 32 -> MOV), bridge mov-imm form, range-carried and-mask (#15 folds, #63 declines), unproven def declines, label-in-window declines, movw kept when the amount register has other readers, non-R12 pattern untouched. Closes #686. Refs #682 #683. Co-Authored-By: Claude Fable 5 --- .github/workflows/ci.yml | 6 + crates/synth-backend/src/arm_backend.rs | 33 ++ .../synth-cli/tests/shift_mask_elide_686.rs | 193 ++++++++ crates/synth-synthesis/src/liveness.rs | 464 ++++++++++++++++++ scripts/repro/gust_mix_686.wat | 35 ++ 5 files changed, 731 insertions(+) create mode 100644 crates/synth-cli/tests/shift_mask_elide_686.rs create mode 100644 scripts/repro/gust_mix_686.wat diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index fca630e4..94f62cda 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -340,6 +340,12 @@ jobs: run: SYNTH=./target/debug/synth python scripts/repro/i32_shift_mask_682_differential.py - name: Run ADDW static-offset oracle (#681, incl. software-bounds bypass) run: SYNTH=./target/debug/synth python scripts/repro/addw_offset_681_differential.py + # #686: the same oracle under the mask-elision lever (opt-in, + # SYNTH_SHIFT_MASK_ELIDE=1) — const amounts fold to the immediate form + # mod 32, unproven amounts keep the mask; the >= 32 rows pin that the + # elision never fires unsoundly (red-tested at land time). + - name: Run i32 shift-mask oracle with mask elision ON (#686) + run: SYNTH_SHIFT_MASK_ELIDE=1 SYNTH=./target/debug/synth python scripts/repro/i32_shift_mask_682_differential.py fact-spec-oracle: name: fact-spec elision oracle (#494 phases 2 + 2b) diff --git a/crates/synth-backend/src/arm_backend.rs b/crates/synth-backend/src/arm_backend.rs index 9569bc9f..2a176e9b 100644 --- a/crates/synth-backend/src/arm_backend.rs +++ b/crates/synth-backend/src/arm_backend.rs @@ -1051,6 +1051,39 @@ fn compile_wasm_to_arm( arm_instrs }; + // #686: elide the #682 mod-32 shift-amount mask (`and r12,rK,#31` before + // every register-controlled i32 shl/shr) when the amount is STATICALLY + // provable < 32 — a const amount folds to the immediate-shift form + // (reduced mod 32, so >= 32 shrinks too), and an already-masked amount + // (`rK = rX & c`, c < 32) drops the redundant re-mask. gale measured the + // unconditional mask at ~12% cyc/call (+14 B) on gust_mix, whose Q8 + // fixed-point shifts are all constants (#686). The mask stays wherever + // the bound is unproven — elision is an optimization, the mask is the + // sound default (`liveness::elide_shift_masks` has the proof + // obligations). Runs after `fold_immediate_shifts` (whose movw→shift + // window the #682 mask intercepts, so it declines every masked const + // shift) and before branch resolution (removal/rewrite-only ⇒ + // offset-neutral). + // + // FLAG-OFF (opt-in via `SYNTH_SHIFT_MASK_ELIDE=1`) because the elision + // moves the frozen anchors: const-amount shifts in control_step (−20 B), + // flight_seam (−164 B) and flight_seam_flat (−168 B) fold back to the + // immediate form — byte-shapes the corpus had BEFORE the #682 mask, now + // with the mask soundly kept for every unproven amount. Flipping + // default-on is a deliberate byte-changing refreeze (all differentials + // re-run on the new bytes, goldens re-pinned) owned by the maintainer. + let arm_instrs = if std::env::var("SYNTH_SHIFT_MASK_ELIDE").is_ok_and(|v| v != "0") { + let (out, elisions) = synth_synthesis::liveness::elide_shift_masks(&arm_instrs); + if std::env::var("SYNTH_FUSE_STATS").is_ok() { + eprintln!( + "[shift-mask-elide] {elisions} provably-<32 shift-amount mask(s) elided (#686)" + ); + } + out + } else { + arm_instrs + }; + // VCR-RA uxth/uxtb fold (#428, #242): `movw rM,#0xffff; and rD,rN,rM` → // `uxth rD,rN` (and the 0xff/uxtb form), removing the dead `movw` — −1 // instruction, −1 live register per 16/8-bit mask. 0xffff/0xff are not Thumb-2 diff --git a/crates/synth-cli/tests/shift_mask_elide_686.rs b/crates/synth-cli/tests/shift_mask_elide_686.rs new file mode 100644 index 00000000..efe830cf --- /dev/null +++ b/crates/synth-cli/tests/shift_mask_elide_686.rs @@ -0,0 +1,193 @@ +//! #686 — `SYNTH_SHIFT_MASK_ELIDE`: elide the #682 mod-32 shift-amount mask +//! when the amount is statically provable < 32. +//! +//! Three gates, per the flag-then-flip protocol: +//! +//! 1. **Default is OFF and byte-identical to baseline** — the elision moves +//! the frozen anchors (const-amount shifts fold back to the immediate +//! form), so it lands opt-in. `frozen_codegen_bytes.rs` locks the shipped +//! bytes; this test additionally pins unset ≡ explicit opt-out, so the +//! default can only change via a deliberate flip (which updates this). +//! 2. **Per-function no-grow table** — with the flag ON, no function in the +//! corpus gets BIGGER on either path (relocatable/direct and default/ +//! optimized). Elision is removal/rewrite-only; growth would mean the +//! pass leaked somewhere it doesn't understand. +//! 3. **gust_mix recovers the #682 size regression** — the gale-measured +//! fixture (`gust_mix_686.wat`, constant Q8 shift) must strictly shrink: +//! the `movw + and r12 + shift.w` triple folds to the immediate shift. +//! (The 12% is cycles on silicon; bytes are the buildable proxy — the +//! 10 B here is exactly the dead mask + dead materialization.) +//! +//! Result-correctness for the elision (including amounts >= 32, where it +//! must never fire) is owned by `scripts/repro/i32_shift_mask_682_differential.py` +//! — re-run green with the flag ON at land time, and red-tested against a +//! force-elide of a >= 32 case (10 rows red, both paths). + +use std::collections::BTreeMap; +use std::process::Command; + +use object::{Object, ObjectSection, ObjectSymbol, SymbolKind}; + +fn synth() -> &'static str { + env!("CARGO_BIN_EXE_synth") +} + +fn fixture(name: &str) -> std::path::PathBuf { + std::path::Path::new(env!("CARGO_MANIFEST_DIR")) + .join("../..") + .join("scripts/repro") + .join(name) +} + +/// The ARM corpus the frozen byte-gate pins, plus the two shift fixtures. +const CORPUS: &[&str] = &[ + "control_step.wasm", + "flight_seam.wasm", + "flight_seam_flat.wasm", + "signed_div_const.wasm", + "i32_shift_mask_682.wat", + "gust_mix_686.wat", +]; + +/// Both codegen paths: `--relocatable` forces the direct stack selector; the +/// default is the optimized bridge. The #682 mask (and therefore the #686 +/// elision) exists on both. +const VARIANTS: &[(&str, bool)] = &[("relocatable", true), ("default", false)]; + +/// Compile `wasm` and return (.text bytes, per-function sizes by symbol name). +/// Sizes are derived from sorted symbol addresses (next symbol / section end), +/// the same symtab the `.py` differentials read — `st_size` is not populated. +fn compile(wasm: &str, relocatable: bool, elide: Option<&str>) -> (Vec, BTreeMap) { + let path = fixture(wasm); + let elf = format!( + "/tmp/shift_mask_elide_686_{}_{}_{}.o", + wasm.replace('.', "_"), + relocatable, + elide.unwrap_or("unset") + ); + let mut cmd = Command::new(synth()); + cmd.env_remove("SYNTH_SHIFT_MASK_ELIDE"); + if let Some(v) = elide { + cmd.env("SYNTH_SHIFT_MASK_ELIDE", v); + } + cmd.args([ + "compile", + path.to_str().unwrap(), + "-o", + &elf, + "-b", + "arm", + "--target", + "cortex-m4", + "--all-exports", + ]); + if relocatable { + cmd.arg("--relocatable"); + } + let out = cmd.output().expect("run synth"); + assert!( + out.status.success(), + "synth compile failed for {wasm} (relocatable={relocatable}): {}", + String::from_utf8_lossy(&out.stderr) + ); + let bytes = std::fs::read(&elf).expect("read elf"); + let obj = object::File::parse(&*bytes).expect("parse elf"); + let text = obj.section_by_name(".text").expect(".text"); + let data = text.data().expect("read .text").to_vec(); + let end = text.address() + data.len() as u64; + + // Function starts: named symbols inside .text, sorted by address. + let mut starts: Vec<(u64, String)> = obj + .symbols() + .filter(|s| { + !s.name().unwrap_or("").is_empty() + && matches!( + s.kind(), + SymbolKind::Text | SymbolKind::Label | SymbolKind::Unknown + ) + && s.address() >= text.address() + && s.address() < end + }) + .map(|s| (s.address(), s.name().unwrap().to_string())) + .collect(); + starts.sort(); + starts.dedup_by(|a, b| a.0 == b.0); // aliases (func_N + export name) — keep one + let mut sizes = BTreeMap::new(); + for (i, (addr, name)) in starts.iter().enumerate() { + let next = starts.get(i + 1).map(|(a, _)| *a).unwrap_or(end); + sizes.insert(name.clone(), next - addr); + } + (data, sizes) +} + +/// Gate 1: unset ≡ `SYNTH_SHIFT_MASK_ELIDE=0`, byte-for-byte — the flag is +/// opt-in. A deliberate default-on flip re-freezes the anchors and updates +/// this assertion (flag-then-flip protocol; the maintainer owns the flip). +#[test] +fn shift_mask_elide_686_default_is_off_and_byte_identical() { + for &(vname, reloc) in VARIANTS { + for &wasm in CORPUS { + let (unset, _) = compile(wasm, reloc, None); + let (off, _) = compile(wasm, reloc, Some("0")); + assert_eq!( + unset, off, + "{wasm} [{vname}]: default must equal explicit opt-out (flag is opt-in)" + ); + } + } +} + +/// Gates 2+3: per-function no-grow across the corpus, strict shrink on the +/// gale-measured gust_mix shape (and the #682 const-amount repro functions). +#[test] +fn shift_mask_elide_686_per_function_no_grow_and_gust_mix_recovers() { + for &(vname, reloc) in VARIANTS { + for &wasm in CORPUS { + let (off_bytes, off) = compile(wasm, reloc, Some("0")); + let (on_bytes, on) = compile(wasm, reloc, Some("1")); + assert_eq!( + off.keys().collect::>(), + on.keys().collect::>(), + "{wasm} [{vname}]: the flag must not add/drop functions" + ); + for (name, off_size) in &off { + let on_size = on[name]; + assert!( + on_size <= *off_size, + "{wasm} [{vname}] {name}: GREW under elision ({off_size} -> {on_size} B) \ + — the pass is removal/rewrite-only, growth is a leak" + ); + } + assert!( + on_bytes.len() <= off_bytes.len(), + "{wasm} [{vname}]: .text grew under elision" + ); + } + } + + // gust_mix: the Q8 constant shift's masked triple must fold — this is the + // fixture whose +14 B / +12% gale measured on #682's unconditional mask. + for &(vname, reloc) in VARIANTS { + let (off_bytes, _) = compile("gust_mix_686.wat", reloc, Some("0")); + let (on_bytes, _) = compile("gust_mix_686.wat", reloc, Some("1")); + assert!( + on_bytes.len() < off_bytes.len(), + "gust_mix [{vname}]: elision must strictly shrink the constant-shift \ + function ({} -> {} B)", + off_bytes.len(), + on_bytes.len() + ); + } + + // The #682 repro's const-amount functions (shl32/shl33/shl300/shr300/ + // sar300) all fold mod 32 on the direct path — strict shrink there too. + let (off_bytes, _) = compile("i32_shift_mask_682.wat", true, Some("0")); + let (on_bytes, _) = compile("i32_shift_mask_682.wat", true, Some("1")); + assert!( + on_bytes.len() < off_bytes.len(), + "i32_shift_mask_682 [relocatable]: const >= 32 amounts must now fold mod 32 \ + ({} -> {} B)", + off_bytes.len(), + on_bytes.len() + ); +} diff --git a/crates/synth-synthesis/src/liveness.rs b/crates/synth-synthesis/src/liveness.rs index a0fd63b6..8768ca5d 100644 --- a/crates/synth-synthesis/src/liveness.rs +++ b/crates/synth-synthesis/src/liveness.rs @@ -1038,6 +1038,223 @@ pub fn fold_immediate_shifts(instrs: &[ArmInstruction]) -> (Vec, (folded, folds) } +/// #686 — elide the #682 mod-32 shift-amount mask when the amount is +/// STATICALLY provably `< 32` at the shift. +/// +/// Since #682/#683 every i32 register-controlled `shl`/`shr_s`/`shr_u` +/// lowering (both direct selectors, the Rocq-proved DSL rules, and the +/// optimized bridge) emits the WASM mod-32 mask as an adjacent pair: +/// +/// ```text +/// and r12, rK, #31 ; {lsl|lsr|asr}.w rD, rN, r12 +/// ``` +/// +/// The mask is the SOUND DEFAULT — this pass removes it only when the bound +/// is proven, in two shapes: +/// +/// - **Const amount (pattern A):** the nearest preceding def of `rK` is +/// `movw rK, #C`, with `rK` not redefined in between over a fully-modeled +/// straight-line window. The whole idiom folds to the immediate form +/// `{lsl|lsr|asr} rD, rN, #(C mod 32)`; `C mod 32 == 0` becomes +/// `mov rD, rN` (immediate `lsr/asr #0` encodes shift-by-**32**, the imm5 +/// pitfall). The `movw` is dropped when `rK` has no other reader +/// ([`reg_dead_by_redef`]). This SUPERSEDES [`fold_immediate_shifts`] for +/// the masked idiom — that fold's `movw→shift` window is intercepted by +/// the `and`, so post-#682 it declines every const-amount register shift — +/// and, unlike it, folds `C >= 32` too by reducing mod 32 (exactly WASM +/// §4.3.2, the semantics the mask enforces; the mask made those cases +/// correct, the fold now also makes them small). +/// - **Range-carried amount (pattern B):** the nearest preceding def of `rK` +/// is `and rK, rX, #c` with `c ∈ [0, 31]` — then `rK <= c < 32` unsigned +/// at the shift (no redefinition in the window), so the #682 re-mask is a +/// no-op: the shift consumes `rK` directly and the `and r12` is dropped. +/// Covers the wasm-level `x & 31` / `x & 15` amount idioms. +/// +/// A fact-spec value-range premise (`hi < 32` carried through CompileConfig +/// via the #494 machinery) is the documented follow-up; anything unproven +/// keeps the mask. +/// +/// SOUNDNESS: +/// - The backward scan from the mask walks only [`reg_effect`]-modeled ops +/// and aborts on `None` (call / branch / **label** — a label is a +/// control-flow merge where another def of `rK` could arrive). +/// - Reads of `rK` inside the window don't change its value (the fold stays +/// valid) but do keep the `movw` alive in pattern A. +/// - Dropping the `and r12` (a write to R12) is safe by the R12 convention +/// (#212): R12 is encoder scratch, never allocatable, never live across +/// instructions — nothing downstream reads the masked value except the +/// adjacent shift being rewritten. +/// - `rotr` is exempt from #682 (ROR is cyclic — never masked), so the +/// pattern cannot match it. +/// +/// Branch-offset safety: removal/rewrite-only, run BEFORE +/// `resolve_label_branches` like [`fold_immediate_shifts`]. Pure function; +/// the wiring is flag-gated in the backend (`SYNTH_SHIFT_MASK_ELIDE`). +pub fn elide_shift_masks(instrs: &[ArmInstruction]) -> (Vec, usize) { + #[derive(Clone, Copy)] + enum ShiftKind { + Lsl, + Lsr, + Asr, + } + let n = instrs.len(); + let mut out = instrs.to_vec(); + let mut drop: Vec = vec![false; n]; + let mut elisions = 0usize; + + for j in 0..n.saturating_sub(1) { + // [j] must be the #682 mask: `and r12, rK, #31` … + let k = match &out[j].op { + ArmOp::And { + rd: Reg::R12, + rn, + op2: Operand2::Imm(31), + } if *rn != Reg::R12 => *rn, + _ => continue, + }; + // … and [j+1] the adjacent shift consuming R12 as its AMOUNT (the + // pair is emitted as a unit by every #682 lowering site). + let (kind, rd, rn_val) = match &out[j + 1].op { + ArmOp::LslReg { + rd, + rn, + rm: Reg::R12, + } if *rn != Reg::R12 => (ShiftKind::Lsl, *rd, *rn), + ArmOp::LsrReg { + rd, + rn, + rm: Reg::R12, + } if *rn != Reg::R12 => (ShiftKind::Lsr, *rd, *rn), + ArmOp::AsrReg { + rd, + rn, + rm: Reg::R12, + } if *rn != Reg::R12 => (ShiftKind::Asr, *rd, *rn), + _ => continue, + }; + // Nearest preceding def of `rK` over a fully-modeled window. Reads of + // `rK` are value-preserving (fold stays valid) but recorded — they + // block the movw removal in pattern A. + let mut def_site: Option = None; + let mut k_read_between = false; + for i in (0..j).rev() { + if drop[i] { + continue; // already elided (a dead movw or an `and r12` mask) + } + let Some(eff) = reg_effect(&out[i].op) else { + break; // unmodeled: call/branch/label ⇒ can't prove, mask stays + }; + if eff.defs.contains(&k) { + def_site = Some(i); + break; + } + if eff.uses.contains(&k) { + k_read_between = true; + } + } + let Some(d) = def_site else { continue }; + + // Pattern A: the const the amount register holds at the mask, if its + // def is a pure materialization — `movw` (direct paths) or `mov #imm` + // (the bridge's small-const form; `movw+movt` large consts land on + // the RMW `movt` and decline, as does `mvn`). + let const_amount = match &out[d].op { + ArmOp::Movw { rd: mrd, imm16 } if *mrd == k => Some(u32::from(*imm16)), + ArmOp::Mov { + rd: mrd, + op2: Operand2::Imm(c), + } if *mrd == k && *c >= 0 => Some(*c as u32), + _ => None, + }; + match (const_amount, &out[d].op) { + // Pattern A: const amount — fold to the immediate shift, mod 32. + // (Red-tested at land time: a force-elide of `c >= 32` via the + // bare register shift turned 10 rows of + // `i32_shift_mask_682_differential.py` red on both paths — the + // #682 oracle guards this pass's mod-32 obligation.) + (Some(c), _) => { + let s = c & 31; + out[j + 1].op = match (kind, s) { + // shift-by-0 ⇒ identity move (imm5==0 means shift-by-32 + // for LSR/ASR; LSL #0 is representable but MOV is the + // single uniform, encoder-clean identity). + (_, 0) => ArmOp::Mov { + rd, + op2: Operand2::Reg(rn_val), + }, + (ShiftKind::Lsl, s) => ArmOp::Lsl { + rd, + rn: rn_val, + shift: s, + }, + (ShiftKind::Lsr, s) => ArmOp::Lsr { + rd, + rn: rn_val, + shift: s, + }, + (ShiftKind::Asr, s) => ArmOp::Asr { + rd, + rn: rn_val, + shift: s, + }, + }; + drop[j] = true; + // The movw is a dead store only if the shift (now immediate) + // was `rK`'s sole reader: nothing read it inside the window, + // and it is redefined-before-read after the pair. + if !k_read_between && reg_dead_by_redef(k, &instrs[j + 2..]) { + drop[d] = true; + } + elisions += 1; + } + // Pattern B: range-carried amount — `rK = rX & c` with `c < 32` + // proves `rK < 32`; the #682 re-mask is a no-op. Shift by `rK`. + ( + None, + ArmOp::And { + rd: ard, + op2: Operand2::Imm(c), + .. + }, + ) if *ard == k && (0..=31).contains(c) => { + out[j + 1].op = match kind { + ShiftKind::Lsl => ArmOp::LslReg { + rd, + rn: rn_val, + rm: k, + }, + ShiftKind::Lsr => ArmOp::LsrReg { + rd, + rn: rn_val, + rm: k, + }, + ShiftKind::Asr => ArmOp::AsrReg { + rd, + rn: rn_val, + rm: k, + }, + }; + drop[j] = true; + elisions += 1; + } + // Any other def (arithmetic, load, `movt`, `mvn`, …): bound + // unproven, mask stays. + _ => {} + } + } + + if elisions == 0 { + return (out, 0); + } + let kept: Vec = out + .into_iter() + .enumerate() + .filter(|(i, _)| !drop[*i]) + .map(|(_, ins)| ins) + .collect(); + (kept, elisions) +} + /// VCR-RA peephole (#428, #242): fold a 16/8-bit mask materialized into a scratch /// register and consumed by `AND` into the dedicated zero-extend instruction. /// @@ -6375,6 +6592,253 @@ mod tests { assert_eq!(n, 0); } + // ---- elide_shift_masks (#686) ---- + + /// The #682 masked idiom for register `k`: `and r12, k, #31 ; rd, rn, r12`. + fn mask_pair(k: Reg, shift: fn(Reg, Reg, Reg) -> ArmOp) -> [ArmInstruction; 2] { + [ + ins(ArmOp::And { + rd: Reg::R12, + rn: k, + op2: Operand2::Imm(31), + }), + ins(shift(Reg::R4, Reg::R1, Reg::R12)), + ] + } + fn lslreg(rd: Reg, rn: Reg, rm: Reg) -> ArmOp { + ArmOp::LslReg { rd, rn, rm } + } + fn lsrreg(rd: Reg, rn: Reg, rm: Reg) -> ArmOp { + ArmOp::LsrReg { rd, rn, rm } + } + fn asrreg(rd: Reg, rn: Reg, rm: Reg) -> ArmOp { + ArmOp::AsrReg { rd, rn, rm } + } + fn ret() -> ArmInstruction { + ins(ArmOp::Bx { rm: Reg::LR }) + } + + #[test] + fn elide_686_const_lt32_folds_masked_triple_to_imm_shift() { + // movw r3,#8 ; and r12,r3,#31 ; asr r4,r1,r12 ; bx lr + // ⇒ asr r4,r1,#8 (movw + and both removed) — the gust_mix shape. + let mut seq = vec![ins(ArmOp::Movw { + rd: Reg::R3, + imm16: 8, + })]; + seq.extend(mask_pair(Reg::R3, asrreg)); + seq.push(ret()); + let (out, n) = elide_shift_masks(&seq); + assert_eq!(n, 1); + assert_eq!(out.len(), 2, "movw and the #682 mask are both removed"); + assert!(matches!( + out[0].op, + ArmOp::Asr { + rd: Reg::R4, + rn: Reg::R1, + shift: 8 + } + )); + } + + #[test] + fn elide_686_const_ge32_folds_mod_32() { + // The #682 semantics the mask enforces, now folded: #33 ⇒ lsl #1. + let mut seq = vec![ins(ArmOp::Movw { + rd: Reg::R3, + imm16: 33, + })]; + seq.extend(mask_pair(Reg::R3, lslreg)); + seq.push(ret()); + let (out, n) = elide_shift_masks(&seq); + assert_eq!(n, 1); + assert!(matches!(out[0].op, ArmOp::Lsl { shift: 1, .. })); + } + + #[test] + fn elide_686_const_mod32_zero_is_mov_not_imm5_zero() { + // #32 ≡ 0 mod 32 ⇒ identity. MUST be `mov` — immediate `lsr/asr #0` + // encodes shift-by-32 (the imm5 pitfall), the exact #682 bug shape. + let mut seq = vec![ins(ArmOp::Movw { + rd: Reg::R3, + imm16: 32, + })]; + seq.extend(mask_pair(Reg::R3, lsrreg)); + seq.push(ret()); + let (out, n) = elide_shift_masks(&seq); + assert_eq!(n, 1); + assert!( + matches!( + out[0].op, + ArmOp::Mov { + rd: Reg::R4, + op2: Operand2::Reg(Reg::R1) + } + ), + "shift-by-0 must lower to MOV, got {:?}", + out[0].op + ); + } + + #[test] + fn elide_686_bridge_mov_imm_const_form_folds_too() { + // The optimized bridge materializes small consts as `mov rd,#imm`. + let mut seq = vec![ins(ArmOp::Mov { + rd: Reg::R3, + op2: Operand2::Imm(24), + })]; + seq.extend(mask_pair(Reg::R3, lslreg)); + seq.push(ret()); + let (out, n) = elide_shift_masks(&seq); + assert_eq!(n, 1); + assert!(matches!(out[0].op, ArmOp::Lsl { shift: 24, .. })); + } + + #[test] + fn elide_686_range_carried_and_mask_drops_the_re_mask() { + // and r3,r2,#15 ; and r12,r3,#31 ; lsl r4,r1,r12 + // ⇒ and r3,r2,#15 ; lsl r4,r1,r3 — r3 ≤ 15 < 32, re-mask is a no-op. + let mut seq = vec![ins(ArmOp::And { + rd: Reg::R3, + rn: Reg::R2, + op2: Operand2::Imm(15), + })]; + seq.extend(mask_pair(Reg::R3, lslreg)); + seq.push(ret()); + let (out, n) = elide_shift_masks(&seq); + assert_eq!(n, 1); + assert_eq!(out.len(), 3, "only the #682 re-mask is removed"); + assert!(matches!( + out[0].op, + ArmOp::And { + rd: Reg::R3, + op2: Operand2::Imm(15), + .. + } + )); + assert!( + matches!( + out[1].op, + ArmOp::LslReg { + rd: Reg::R4, + rn: Reg::R1, + rm: Reg::R3 + } + ), + "shift consumes the range-carried register directly" + ); + } + + #[test] + fn elide_686_and_mask_ge32_keeps_the_mask() { + // `x & 63` proves only < 64 — NOT < 32. The #682 mask must stay. + let mut seq = vec![ins(ArmOp::And { + rd: Reg::R3, + rn: Reg::R2, + op2: Operand2::Imm(63), + })]; + seq.extend(mask_pair(Reg::R3, lslreg)); + seq.push(ret()); + let (out, n) = elide_shift_masks(&seq); + assert_eq!(n, 0); + assert_eq!(out.len(), seq.len()); + } + + #[test] + fn elide_686_unproven_def_keeps_the_mask() { + // The amount comes from an ADD — no bound, mask stays (sound default). + let mut seq = vec![ins(ArmOp::Add { + rd: Reg::R3, + rn: Reg::R2, + op2: Operand2::Imm(1), + })]; + seq.extend(mask_pair(Reg::R3, lslreg)); + seq.push(ret()); + let (_, n) = elide_shift_masks(&seq); + assert_eq!(n, 0); + } + + #[test] + fn elide_686_unmodeled_op_in_window_keeps_the_mask() { + // A label between the def and the mask is a control-flow merge — + // another def of r3 could arrive there. Mask stays. + let mut seq = vec![ + ins(ArmOp::Movw { + rd: Reg::R3, + imm16: 8, + }), + ins(ArmOp::Label { + name: "L1".to_string(), + }), + ]; + seq.extend(mask_pair(Reg::R3, lslreg)); + seq.push(ret()); + let (_, n) = elide_shift_masks(&seq); + assert_eq!(n, 0); + } + + #[test] + fn elide_686_movw_kept_when_amount_reg_has_other_readers() { + // r3 is also stored between the movw and the shift — the fold still + // fires (value unchanged) but the movw must survive for the store. + let mut seq = vec![ + ins(ArmOp::Movw { + rd: Reg::R3, + imm16: 8, + }), + ins(ArmOp::Str { + rd: Reg::R3, + addr: crate::rules::MemAddr { + base: Reg::SP, + offset: 0, + offset_reg: None, + }, + }), + ]; + seq.extend(mask_pair(Reg::R3, lslreg)); + seq.push(ret()); + let (out, n) = elide_shift_masks(&seq); + assert_eq!(n, 1); + assert!( + out.iter() + .any(|i| matches!(i.op, ArmOp::Movw { rd: Reg::R3, .. })), + "movw survives — r3 has another reader" + ); + assert!( + out.iter() + .any(|i| matches!(i.op, ArmOp::Lsl { shift: 8, .. })) + ); + assert!( + !out.iter().any(|i| matches!(i.op, ArmOp::And { .. })), + "the #682 mask itself is still elided" + ); + } + + #[test] + fn elide_686_non_adjacent_or_non_r12_pattern_untouched() { + // A mask into a NON-scratch register, or a shift not consuming R12, + // is not the #682 idiom — never touched. + let seq = vec![ + ins(ArmOp::Movw { + rd: Reg::R3, + imm16: 8, + }), + ins(ArmOp::And { + rd: Reg::R5, + rn: Reg::R3, + op2: Operand2::Imm(31), + }), + ins(ArmOp::LslReg { + rd: Reg::R4, + rn: Reg::R1, + rm: Reg::R5, + }), + ret(), + ]; + let (_, n) = elide_shift_masks(&seq); + assert_eq!(n, 0); + } + // ---- fold_uxth (#428) ---- #[test] diff --git a/scripts/repro/gust_mix_686.wat b/scripts/repro/gust_mix_686.wat new file mode 100644 index 00000000..a8f6d6c7 --- /dev/null +++ b/scripts/repro/gust_mix_686.wat @@ -0,0 +1,35 @@ +;; #686 — gale gust_mix shape: clamp(1500 + ((ch - 1024) * 205 >> 8), 1000, 2000). +;; A Q8 fixed-point scale whose shift amount is a CONSTANT < 32 — the case where +;; the #682 mod-32 mask (`and r12,rK,#31`) is provably dead. gale measured the +;; unconditional mask at ~12% cyc/call (+14 B) on this shape (68 B at v0.37.0 → +;; 82 B at v0.37.1); SYNTH_SHIFT_MASK_ELIDE=1 folds the masked triple back to +;; the immediate shift (`asrs #8`). Pinned by shift_mask_elide_686.rs (per- +;; function no-grow/shrink) and i32_shift_mask_682_differential.py (semantics). +(module + (func (export "gust_mix") (param i32) (result i32) + (local i32) + local.get 0 + i32.const 1024 + i32.sub + i32.const 205 + i32.mul + i32.const 8 + i32.shr_s + i32.const 1500 + i32.add + local.set 1 + local.get 1 + i32.const 1000 + local.get 1 + i32.const 1000 + i32.lt_s + select + local.set 1 + local.get 1 + i32.const 2000 + local.get 1 + i32.const 2000 + i32.gt_s + select + ) +)