diff --git a/tests/integration/src/end_to_end/differential/cases/case_dyn_trait.rs b/tests/integration/src/end_to_end/differential/cases/case_dyn_trait.rs new file mode 100644 index 0000000000..9eac6871a5 --- /dev/null +++ b/tests/integration/src/end_to_end/differential/cases/case_dyn_trait.rs @@ -0,0 +1,50 @@ +// `dyn Trait` dispatch: the vtables live in `.rodata` as arrays of funcref +// table indices, and each method call loads its slot and dispatches via +// `call_indirect`. The concrete impl is picked through a runtime-indexed array +// of trait-object references so LLVM cannot devirtualize, and two methods per +// trait exercise two distinct vtable slots per object. + +trait Mix { + fn scale(&self, x: u32) -> u32; + fn fold(&self, x: u32, y: u32) -> u32; +} + +struct Affine(u32); +struct Xor(u32); + +impl Mix for Affine { + #[inline(never)] + fn scale(&self, x: u32) -> u32 { + x.wrapping_mul(self.0 | 1).wrapping_add(0x9e37) + } + + #[inline(never)] + fn fold(&self, x: u32, y: u32) -> u32 { + x.rotate_left(self.0 & 31) ^ y + } +} + +impl Mix for Xor { + #[inline(never)] + fn scale(&self, x: u32) -> u32 { + (x ^ self.0).wrapping_sub(x >> 5) + } + + #[inline(never)] + fn fold(&self, x: u32, y: u32) -> u32 { + (x | y).wrapping_mul(2654435761).wrapping_add(self.0) + } +} + +#[unsafe(no_mangle)] +pub extern "C" fn entrypoint(input1: u32, input2: u32) -> u32 { + let a = Affine(input2); + let b = Xor(input1.wrapping_add(input2)); + // Runtime-indexed fat-pointer loads defeat devirtualization, the same way + // the fn-pointer array idiom does. + let objs: [&dyn Mix; 2] = [&a, &b]; + let first = objs[(input1 & 1) as usize]; + let second = objs[((input2 >> 2) & 1) as usize]; + let scaled = first.scale(input1); + second.fold(scaled, input2).wrapping_add(first.fold(input2, input1)) +} diff --git a/tests/integration/src/end_to_end/differential/cases/case_fnptr_value.rs b/tests/integration/src/end_to_end/differential/cases/case_fnptr_value.rs new file mode 100644 index 0000000000..fca67e4bc3 --- /dev/null +++ b/tests/integration/src/end_to_end/differential/cases/case_fnptr_value.rs @@ -0,0 +1,62 @@ +// Function pointers as first-class VALUES: returned from and passed to +// `#[inline(never)]` helpers, mutated across loop iterations (state-machine +// style), coerced from a non-capturing closure, and compared with `==`. At the +// Wasm level a fn pointer is its funcref-table index (an i32), so all of this +// exercises table-index data flow between functions, across loop-carried +// locals, and through an integer comparison — while every actual dispatch +// stays a runtime-indexed `call_indirect`. + +type Op = fn(u32, u32) -> u32; + +#[inline(never)] +fn op_add(a: u32, b: u32) -> u32 { + a.wrapping_add(b) +} + +#[inline(never)] +fn op_shear(a: u32, b: u32) -> u32 { + (a ^ b).rotate_left(11) +} + +#[inline(never)] +fn op_scale(a: u32, b: u32) -> u32 { + a.wrapping_mul(b | 1) +} + +// Returns a fn pointer picked by runtime data: the caller receives a table +// index it cannot devirtualize through the noinline boundary. +#[inline(never)] +fn pick(sel: u32) -> Op { + match sel % 3 { + 0 => op_add, + 1 => op_shear, + _ => op_scale, + } +} + +// Takes a fn pointer as a parameter and dispatches through it. +#[inline(never)] +fn apply(f: Op, a: u32, b: u32) -> u32 { + f(a, b) +} + +#[unsafe(no_mangle)] +pub extern "C" fn entrypoint(input1: u32, input2: u32) -> u32 { + let mut f = pick(input1); + let g = pick(input2 >> 3); + // fn-pointer equality compares funcref-table indices in wasm and host + // addresses natively; both are injective over these distinct functions. + let same = (f == g) as u32; + // Non-capturing closure coerced to `fn`: an anonymous table entry. + let h: Op = |a, b| (a | 3).wrapping_sub(b >> 2); + let mut acc = apply(h, input2, input1); + // Loop-carried fn-pointer state machine: f changes each iteration based + // on data computed through the previous pointer. + let mut i = 0u32; + while i < 4 { + acc = apply(f, acc, input1.rotate_left(i)); + f = pick(acc ^ i); + i += 1; + } + acc.wrapping_add(g(acc, input2)).wrapping_add(same) +} diff --git a/tests/integration/src/end_to_end/differential/cases/case_indirect_chain.rs b/tests/integration/src/end_to_end/differential/cases/case_indirect_chain.rs new file mode 100644 index 0000000000..f1afb9b7bd --- /dev/null +++ b/tests/integration/src/end_to_end/differential/cases/case_indirect_chain.rs @@ -0,0 +1,52 @@ +// Chained indirect dispatch: the runtime-selected stage functions themselves +// dispatch through a second fn-pointer array, so a `dynexec` callee performs +// another `dynexec` (nested dispatch frames on the VM). The outer dispatch +// also sits inside a loop, and one arm of a conditional dispatches while the +// other computes directly — call_indirect in every control-flow position. + +type Leaf = fn(u32) -> u32; + +#[inline(never)] +fn leaf_gray(x: u32) -> u32 { + x ^ (x >> 1) +} + +#[inline(never)] +fn leaf_spread(x: u32) -> u32 { + x.wrapping_mul(0x8100_0101).rotate_right(3) +} + +static LEAVES: [Leaf; 2] = [leaf_gray, leaf_spread]; + +// Each stage dispatches through LEAVES with a runtime index: an indirect +// callee that itself calls indirectly. +#[inline(never)] +fn stage_mask(x: u32) -> u32 { + LEAVES[(x & 1) as usize](x).wrapping_add(0x5a5a) +} + +#[inline(never)] +fn stage_swap(x: u32) -> u32 { + LEAVES[((x >> 2) & 1) as usize](x.swap_bytes()) +} + +type Stage = fn(u32) -> u32; +static STAGES: [Stage; 2] = [stage_mask, stage_swap]; + +#[unsafe(no_mangle)] +pub extern "C" fn entrypoint(input1: u32, input2: u32) -> u32 { + let mut acc = input1; + // Indirect dispatch inside a loop, index depending on the loop-carried value + let mut k = 0u32; + while k < 3 { + acc = STAGES[((acc ^ k) & 1) as usize](acc.wrapping_add(input2)); + k += 1; + } + // Indirect dispatch in one branch arm only + if input2 & 4 == 0 { + acc = LEAVES[(acc & 1) as usize](acc); + } else { + acc = acc.wrapping_mul(3).wrapping_sub(input1); + } + acc +} diff --git a/tests/integration/src/end_to_end/differential/cases/case_indirect_collision.rs b/tests/integration/src/end_to_end/differential/cases/case_indirect_collision.rs new file mode 100644 index 0000000000..8947a1b3e0 --- /dev/null +++ b/tests/integration/src/end_to_end/differential/cases/case_indirect_collision.rs @@ -0,0 +1,31 @@ +// A user function deliberately named `__indirect_function_table_0` — the exact +// symbol the frontend generates for the lowered funcref table of table 0. Every +// module symbol is a producer-controlled string, so the table-lowering probes +// the symbol table and bumps a counter until the generated name is free; this +// case forces that collision-rename path while still dispatching indirectly. + +#[unsafe(no_mangle)] +pub extern "C" fn __indirect_function_table_0(x: u32) -> u32 { + x.wrapping_mul(0x0101_0101).rotate_left(7) +} + +#[inline(never)] +fn op_gray(a: u32, b: u32) -> u32 { + (a ^ (a >> 1)).wrapping_add(b) +} + +#[inline(never)] +fn op_lerp(a: u32, b: u32) -> u32 { + a.wrapping_add(b.wrapping_sub(a) >> 3) +} + +// Runtime-indexed fn-pointer load: survives as `call_indirect`, which lazily +// lowers the funcref table and hits the reserved-name collision. +static OPS: [fn(u32, u32) -> u32; 2] = [op_gray, op_lerp]; + +#[unsafe(no_mangle)] +pub extern "C" fn entrypoint(input1: u32, input2: u32) -> u32 { + let f = OPS[(input1 & 1) as usize]; + let mixed = f(input1, input2); + mixed.wrapping_add(__indirect_function_table_0(input2)) +} diff --git a/tests/integration/src/end_to_end/differential/cases/case_indirect_sigs.rs b/tests/integration/src/end_to_end/differential/cases/case_indirect_sigs.rs new file mode 100644 index 0000000000..3ad97f70c4 --- /dev/null +++ b/tests/integration/src/end_to_end/differential/cases/case_indirect_sigs.rs @@ -0,0 +1,41 @@ +// Two function-pointer tables with DIFFERENT function types. All address-taken +// functions land in the single wasm funcref table, but the two dispatch sites +// carry distinct signature type indices — so the lowered `builtin.function_table` +// holds entries with two different signature tags, and each `hir.exec_indirect` +// call site must skip (tag-filter) the other signature's entries while the +// runtime tag check accepts only its own. + +#[inline(never)] +fn un_not(a: u32) -> u32 { + !a +} + +#[inline(never)] +fn un_rev(a: u32) -> u32 { + a.swap_bytes().rotate_left(9) +} + +#[inline(never)] +fn wi_fold(a: u64, b: u32) -> u64 { + a.wrapping_mul(0x9e3779b97f4a7c15).wrapping_add(b as u64) +} + +#[inline(never)] +fn wi_shear(a: u64, b: u32) -> u64 { + (a ^ ((b as u64) << 17)).rotate_right(23) +} + +// Runtime-indexed loads of fn pointers from static arrays are not +// devirtualized by LLVM without PGO, so both dispatches survive as +// `call_indirect` with distinct type indices. +static UNARY: [fn(u32) -> u32; 2] = [un_not, un_rev]; +static WIDE: [fn(u64, u32) -> u64; 2] = [wi_fold, wi_shear]; + +#[unsafe(no_mangle)] +pub extern "C" fn entrypoint(input1: u32, input2: u32) -> u32 { + let u = UNARY[(input1 & 1) as usize]; + let w = WIDE[((input2 >> 1) & 1) as usize]; + let narrow = u(input1.wrapping_add(input2)); + let wide = w(((input1 as u64) << 32) | input2 as u64, narrow); + (wide as u32).wrapping_add((wide >> 32) as u32) +} diff --git a/tests/integration/src/end_to_end/differential/cases/case_indirect_wide.rs b/tests/integration/src/end_to_end/differential/cases/case_indirect_wide.rs new file mode 100644 index 0000000000..bc0b863ef9 --- /dev/null +++ b/tests/integration/src/end_to_end/differential/cases/case_indirect_wide.rs @@ -0,0 +1,44 @@ +// The widest indirect signature the lowering accepts: 7 u64 parameters are 14 +// stack felts, plus the table index = 15 of Miden's 16-element operand-stack +// window (16 argument felts + the index would be diagnosed at translation). +// Dispatching it exercises `dynexec` with a full argument window and u64 +// (two-felt) values crossing the dispatch boundary in both directions. + +type Wide = fn(u64, u64, u64, u64, u64, u64, u64) -> u64; + +#[inline(never)] +fn w_fold(a: u64, b: u64, c: u64, d: u64, e: u64, f: u64, g: u64) -> u64 { + a.wrapping_add(b) + .wrapping_mul(c | 1) + .wrapping_sub(d) + .rotate_left((e & 63) as u32) + ^ f.wrapping_add(g) +} + +#[inline(never)] +fn w_zip(a: u64, b: u64, c: u64, d: u64, e: u64, f: u64, g: u64) -> u64 { + (a ^ b.rotate_right(17)) + .wrapping_add(c.wrapping_mul(c)) + .wrapping_add(d >> 3) + .wrapping_add(e << 5) + .wrapping_add(f ^ g.swap_bytes()) +} + +static WIDES: [Wide; 2] = [w_fold, w_zip]; + +#[unsafe(no_mangle)] +pub extern "C" fn entrypoint(input1: u32, input2: u32) -> u32 { + let x = ((input1 as u64) << 32) | input2 as u64; + let y = ((input2 as u64) << 32) | input1 as u64; + let f = WIDES[(input1 & 1) as usize]; + let r = f( + x, + y, + x.wrapping_add(y), + x ^ 0x00ff_00ff_00ff_00ff, + y.wrapping_mul(3), + x.rotate_left(9), + y ^ x, + ); + (r as u32).wrapping_add((r >> 32) as u32) +} diff --git a/tests/integration/src/end_to_end/differential/cases/case_local_shapes.rs b/tests/integration/src/end_to_end/differential/cases/case_local_shapes.rs new file mode 100644 index 0000000000..411aabb0ab --- /dev/null +++ b/tests/integration/src/end_to_end/differential/cases/case_local_shapes.rs @@ -0,0 +1,41 @@ +// Local2Reg gap shapes. Every wasm function parameter gets an unconditional +// `hir.store_local` at entry (frontend/wasm `declare_parameters`), so: +// (1) `lsh_unused`'s ignored second parameter is a stored-but-never-loaded +// local, reaching the dead-store-erasure arm of the Local2Reg pass +// (`#[no_mangle]` gives the helper external linkage so LLVM's dead-arg +// elimination cannot drop the parameter; `#[inline(never)]` keeps the +// call); +// (2) `lsh_konst` has no parameters and no wasm locals at all, reaching the +// pass's `locals.is_empty()` early return; +// (3) `lsh_pick` takes a by-value array, which Rust passes indirectly — the +// incoming pointer travels through a single-use local (promotable), and +// with debug info the aggregate's `di.debug_declare` references that +// local, reaching the declare-conversion path of +// `convert_debug_references_for_local`. + +#[inline(never)] +#[unsafe(no_mangle)] +extern "C" fn lsh_unused(a: u32, _dead: u32) -> u32 { + a.wrapping_mul(2654435761).rotate_left(5) +} + +#[inline(never)] +#[unsafe(no_mangle)] +extern "C" fn lsh_konst() -> u32 { + 40507 +} + +#[inline(never)] +#[unsafe(no_mangle)] +fn lsh_pick(arr: [u32; 4], i: u32) -> u32 { + arr[(i & 3) as usize] +} + +#[unsafe(no_mangle)] +pub extern "C" fn entrypoint(input1: u32, input2: u32) -> u32 { + let arr = [input1, input2, input1 ^ input2, input1.wrapping_add(input2)]; + let a = lsh_unused(input1, input2); + let b = lsh_konst(); + let c = lsh_pick(arr, input2 >> 7); + a ^ b.wrapping_add(c) +} diff --git a/tests/integration/src/end_to_end/differential/cases/case_rotl_window.rs b/tests/integration/src/end_to_end/differential/cases/case_rotl_window.rs new file mode 100644 index 0000000000..1040aebbf6 --- /dev/null +++ b/tests/integration/src/end_to_end/differential/cases/case_rotl_window.rs @@ -0,0 +1,52 @@ +// Minimal reproducer for the arity-2 operand-scheduler NoSolution panic that +// does NOT involve invalid IR: the ten-count variant of case_spill_switch. +// Ten shared masked rotate-count bands keep 15 felts live at an +// `arith.rotl` whose count operand is Copy-constrained; the only tactic the +// solver tries for binary ops (TwoArgs) emits dup-then-movup, which needs a +// 17-felt stack access, so the solution is rejected by the MASM 16-felt +// window check and no fallback tactic exists. See the `rotl_window` test for +// the full finding notes; the six-count `spill_switch` twin passes. +#[unsafe(no_mangle)] +pub extern "C" fn entrypoint(input1: u32, input2: u32) -> u32 { + let m = (input1 | 1) as u64; + let n = ((input2 ^ 0x9e37_79b9) as u64) | 2; + // First uses of the ten shared counts. + let mut acc = (m ^ n) | 1; + acc ^= m.rotate_left(1); + acc = acc.wrapping_add(n.rotate_left(3)); + acc ^= m.rotate_left(5); + acc = acc.wrapping_sub(n.rotate_left(7)); + acc ^= m.rotate_left(9); + acc = acc.wrapping_add(n.rotate_left(11)); + acc ^= m.rotate_left(13); + acc = acc.wrapping_sub(n.rotate_left(15)); + acc ^= m.rotate_left(17); + acc = acc.wrapping_add(n.rotate_left(19)); + let iters = (input2 % 97) + 3; + let mut i: u32 = 0; + while i < iters { + // Dense 6-way dispatch with structurally-distinct arms. + acc = match (acc as u32) & 7 { + 0 => (acc ^ 0x9e37_79b9).wrapping_mul(129), + 1 => acc.rotate_left(2).wrapping_add(0x85eb_ca6b), + 2 => acc.wrapping_sub(0xc2b2_ae35) ^ (acc >> 5), + 3 => acc.rotate_left(6) ^ acc.wrapping_mul(65), + 4 => acc.wrapping_add(acc.rotate_left(10)) | 1, + _ => acc ^ (acc << 3) ^ 0x27d4_eb2f, + }; + i = i.wrapping_add(1); + } + // Post-loop partners of the ten crossing counts. + let mut r = acc; + r ^= acc.rotate_left(1); + r = r.wrapping_add(acc.rotate_left(3)); + r ^= acc.rotate_left(5); + r = r.wrapping_sub(acc.rotate_left(7)); + r ^= acc.rotate_left(9); + r = r.wrapping_add(acc.rotate_left(11)); + r ^= acc.rotate_left(13); + r = r.wrapping_sub(acc.rotate_left(15)); + r ^= acc.rotate_left(17); + r = r.wrapping_add(acc.rotate_left(19)); + (r as u32) ^ ((r >> 32) as u32) +} diff --git a/tests/integration/src/end_to_end/differential/cases/case_spill_loop_mix.rs b/tests/integration/src/end_to_end/differential/cases/case_spill_loop_mix.rs new file mode 100644 index 0000000000..540f0df295 --- /dev/null +++ b/tests/integration/src/end_to_end/differential/cases/case_spill_loop_mix.rs @@ -0,0 +1,76 @@ +// Loop-header W^entry pressure through shared masked rotate counts. The +// translator wraps every rotate count in `arith.band(count, mask)`; the +// folder dedups the constant operands function-wide and CSE merges the +// identical bands across blocks, so a count constant reused in two blocks +// becomes ONE u32 SSA value (one felt) crossing the edge between them. +// Loop 1 reuses SIXTEEN counts between the pre-loop code and rotates of the +// loop-carried `acc` (LICM cannot hoist those), so 18 felts are alive at +// the loop header with in-loop next-use distances — the loop-header W^entry +// computation takes its w_used >= K over-capacity arm (candidate sort + +// take_while fill), and the excluded values force spill/reload +// reconciliation (edge splits) on the preheader edge AND the loop backedge. +// Counts 28/30 are used in the entry block and again only after loop 2; +// their bands cross both loop headers (trace-verified), though liveness +// still reports them with in-loop distances, so they land in the candidate +// set rather than the live-through set (see the scratch/knowledge notes on +// the empty-live-through observation). +#[unsafe(no_mangle)] +pub extern "C" fn entrypoint(input1: u32, input2: u32) -> u32 { + let m = (input1 | 1) as u64; + let n = ((input2 ^ 0x9e37_79b9) as u64) | 2; + // Pre-loop partners of the sixteen in-loop counts (odd 1..31), plus the + // first uses of the live-through counts 28/30. + let mut acc = (m ^ n) | 1; + acc ^= m.rotate_left(1); + acc = acc.wrapping_add(n.rotate_left(3)); + acc ^= m.rotate_left(5); + acc = acc.wrapping_sub(n.rotate_left(7)); + acc ^= m.rotate_left(9); + acc = acc.wrapping_add(n.rotate_left(11)); + acc ^= m.rotate_left(13); + acc = acc.wrapping_sub(n.rotate_left(15)); + acc ^= m.rotate_left(17); + acc = acc.wrapping_add(n.rotate_left(19)); + acc ^= m.rotate_left(21); + acc = acc.wrapping_sub(n.rotate_left(23)); + acc ^= m.rotate_left(25); + acc = acc.wrapping_add(n.rotate_left(27)); + acc ^= m.rotate_left(29); + acc = acc.wrapping_sub(n.rotate_left(31)); + acc ^= m.rotate_left(28); + acc = acc.wrapping_add(n.rotate_left(30)); + let iters = (input2 % 97) + 3; + let mut i: u32 = 0; + while i < iters { + // Sixteen rotates of the loop-carried value with the shared counts. + acc ^= acc.rotate_left(1) | 1; + acc = acc.wrapping_add(acc.rotate_left(3)); + acc ^= acc.rotate_left(5); + acc = acc.wrapping_sub(acc.rotate_left(7)); + acc ^= acc.rotate_left(9); + acc = acc.wrapping_add(acc.rotate_left(11)); + acc ^= acc.rotate_left(13); + acc = acc.wrapping_sub(acc.rotate_left(15)); + acc ^= acc.rotate_left(17); + acc = acc.wrapping_add(acc.rotate_left(19)); + acc ^= acc.rotate_left(21); + acc = acc.wrapping_sub(acc.rotate_left(23)); + acc ^= acc.rotate_left(25); + acc = acc.wrapping_add(acc.rotate_left(27)); + acc ^= acc.rotate_left(29); + acc = acc.wrapping_sub(acc.rotate_left(31)); + i = i.wrapping_add(1); + } + // Loop 2: light body with fresh counts; 28/30 stay live through it. + let mut acc2 = acc | 1; + let iters2 = (input1 % 89) + 2; + let mut j: u32 = 0; + while j < iters2 { + acc2 ^= acc2.rotate_left(4); + acc2 = acc2.wrapping_add(acc2.rotate_left(6)); + j = j.wrapping_add(1); + } + // Post-loop-2 partners of the live-through counts 28/30. + let r = acc2 ^ acc2.rotate_left(28) ^ acc.rotate_left(30); + (r as u32) ^ ((r >> 32) as u32) +} diff --git a/tests/integration/src/end_to_end/differential/cases/case_spill_split.rs b/tests/integration/src/end_to_end/differential/cases/case_spill_split.rs new file mode 100644 index 0000000000..83ca633e5d --- /dev/null +++ b/tests/integration/src/end_to_end/differential/cases/case_spill_split.rs @@ -0,0 +1,60 @@ +// Asymmetric-pressure diamond around cross-edge SSA values. The pinned +// call keeps `vx`'s definition before the branch, and the rotate-count +// constants shared by the entry block, both arms, and the join become +// CSE-merged `arith.band` mask ops — SSA values live across BOTH arms +// (trace-verified; user values in locals never cross edges in W). The +// heavy arm's ten-u64 reload cluster (~20 felts) spills those cross-edge +// values inside that arm only; the cheap arm keeps them on the operand +// stack. At the join, W^entry selects them (candidates inherited from the +// cheap arm), so control-flow-edge reconciliation places reloads on the +// heavy edge and compensating spills on the cheap edge — both edges are +// unstructured (Predecessor::Block), so the analysis records edge SPLITS +// and the transform materializes split blocks, redirects the branches, and +// inserts the spills/reloads with Placement::Split — the i1289-gated +// edge-split cluster that symmetric-pressure shapes (spill_branch/ +// spill_edge) never reach. +use core::sync::atomic::{AtomicU32, Ordering}; + +/// Never written with a non-zero value: `fetch_add(0)` is an opaque no-op +/// whose memory side effect pins the call site (LLVM sinks pure calls to +/// their use, which would move `vx`'s definition past the branch). +static PIN: AtomicU32 = AtomicU32::new(0); + +#[inline(never)] +fn scramble(a: u64) -> u64 { + let p = PIN.fetch_add(0, Ordering::Relaxed) as u64; + (a ^ p).wrapping_mul(0x2545_f491_4f6c_dd1d) ^ a.rotate_left(13) +} + +#[unsafe(no_mangle)] +pub extern "C" fn entrypoint(input1: u32, input2: u32) -> u32 { + let m = (input1 | 1) as u64; + let n = ((input2 ^ 0x9e37_79b9) as u64) | 2; + let v0 = m.wrapping_mul(0x9e37_79b9_7f4a_7c15) ^ n; + let v1 = n.wrapping_mul(0xbf58_476d_1ce4_e5b9) ^ m.rotate_left(11); + let v2 = v0.rotate_left(17) ^ n.wrapping_mul(0x94d0_49bb_1331_11eb); + let v3 = v1.rotate_left(23) ^ m.wrapping_mul(0xd6e8_feb8_6659_fd93); + let v4 = v2.wrapping_add(v0.rotate_left(29)) ^ 0xa076_1d64_78bd_642f; + let v5 = v3.wrapping_sub(v1.rotate_left(31)) ^ 0xe703_7ed1_a0b4_28db; + let v6 = v4.rotate_left(9) ^ v2.wrapping_mul(0x8ebc_6af0_9c88_c6e3); + let v7 = v5.rotate_left(13) ^ v3.wrapping_mul(0x5895_58cb_3521_e49d); + let v8 = v6.wrapping_add(v4.rotate_left(19)) ^ n.rotate_left(3); + let v9 = v7.wrapping_sub(v5.rotate_left(21)) ^ m.rotate_left(5); + // Cross-edge value: exactly one use, after the join. + let vx = scramble(m ^ n.rotate_left(9)); + let t = if m % 97 < 48 { + // Heavy arm: wide tree over all ten u64s — the arm-top local reloads + // hold ~20 felts, so `vx` (furthest next use) is spilled here. + (v1 ^ v9.rotate_left(1)) + .wrapping_add(v2 ^ v8.rotate_left(3)) + .wrapping_mul(v3 | 1) + ^ v7.rotate_left(5) + ^ (v4 ^ v6.rotate_left(7)).wrapping_sub(v5 ^ v0.rotate_left(9)) + } else { + // Cheap arm: `vx` stays resident on the operand stack. + m ^ n.rotate_left(3) + }; + // Join: `vx` is consumed first. + let r = vx ^ t.rotate_left(21) ^ v0.rotate_left(31) ^ v9.rotate_left(35); + (r as u32) ^ ((r >> 32) as u32) +} diff --git a/tests/integration/src/end_to_end/differential/cases/case_spill_switch.rs b/tests/integration/src/end_to_end/differential/cases/case_spill_switch.rs new file mode 100644 index 0000000000..629460730a --- /dev/null +++ b/tests/integration/src/end_to_end/differential/cases/case_spill_switch.rs @@ -0,0 +1,44 @@ +// Spill pressure under a dense `match` inside a `% 97`-bounded loop. Ten +// masked rotate-count bands (CSE-merged cross-block SSA values, see +// case_spill_loop_mix) are used before the loop and after it, so they must +// cross the loop AND the 6-way dispatch inside it every iteration — spilled +// values crossing scf.index_switch arm edges, per-arm edge reconciliation +// on the in-loop switch (multi-region successor traversals in the spill +// analysis' liveness/DCA walks), with structurally-varied arm bodies so the +// dispatch survives as a br_table. +#[unsafe(no_mangle)] +pub extern "C" fn entrypoint(input1: u32, input2: u32) -> u32 { + let m = (input1 | 1) as u64; + let n = ((input2 ^ 0x9e37_79b9) as u64) | 2; + // First uses of the six shared counts. + let mut acc = (m ^ n) | 1; + acc ^= m.rotate_left(1); + acc = acc.wrapping_add(n.rotate_left(3)); + acc ^= m.rotate_left(5); + acc = acc.wrapping_sub(n.rotate_left(7)); + acc ^= m.rotate_left(9); + acc = acc.wrapping_add(n.rotate_left(11)); + let iters = (input2 % 97) + 3; + let mut i: u32 = 0; + while i < iters { + // Dense 6-way dispatch with structurally-distinct arms. + acc = match (acc as u32) & 7 { + 0 => (acc ^ 0x9e37_79b9).wrapping_mul(129), + 1 => acc.rotate_left(2).wrapping_add(0x85eb_ca6b), + 2 => acc.wrapping_sub(0xc2b2_ae35) ^ (acc >> 5), + 3 => acc.rotate_left(6) ^ acc.wrapping_mul(65), + 4 => acc.wrapping_add(acc.rotate_left(10)) | 1, + _ => acc ^ (acc << 3) ^ 0x27d4_eb2f, + }; + i = i.wrapping_add(1); + } + // Post-loop partners of the six crossing counts. + let mut r = acc; + r ^= acc.rotate_left(1); + r = r.wrapping_add(acc.rotate_left(3)); + r ^= acc.rotate_left(5); + r = r.wrapping_sub(acc.rotate_left(7)); + r ^= acc.rotate_left(9); + r = r.wrapping_add(acc.rotate_left(11)); + (r as u32) ^ ((r >> 32) as u32) +} diff --git a/tests/integration/src/end_to_end/differential/cases/case_spin_guard.rs b/tests/integration/src/end_to_end/differential/cases/case_spin_guard.rs new file mode 100644 index 0000000000..4cca219e00 --- /dev/null +++ b/tests/integration/src/end_to_end/differential/cases/case_spin_guard.rs @@ -0,0 +1,15 @@ +// Bare statically-infinite `loop {}` behind an impossible cross-modulus +// guard: the loop header is a block that contains only its own back-edge +// `cf.br`, so the passthrough-collapse canonicalizations that consider the +// guard's branch arms must take the collapse-into-self-loop bail instead of +// collapsing the branch (unreachable_exits keeps its infinite loop body +// non-empty on purpose, so this shape is not otherwise in the corpus). +#[unsafe(no_mangle)] +pub extern "C" fn entrypoint(input1: u32, input2: u32) -> u32 { + let h = input1 ^ input2.rotate_left(7); + // h % 6 == 5 implies h % 3 == 2, contradicting h % 3 == 0. + if h % 6 == 5 && h % 3 == 0 { + loop {} + } + h.wrapping_mul(input2 | 1) +} diff --git a/tests/integration/src/end_to_end/differential/cases/case_u64_ucmp.rs b/tests/integration/src/end_to_end/differential/cases/case_u64_ucmp.rs index e2f2b00380..2d1f56c231 100644 --- a/tests/integration/src/end_to_end/differential/cases/case_u64_ucmp.rs +++ b/tests/integration/src/end_to_end/differential/cases/case_u64_ucmp.rs @@ -1,10 +1,13 @@ -// Exercises the unsigned-u64 emitter arms in `codegen/masm/src/emit/int64.rs`: -// `lt_u64`/`lte_u64`/`gt_u64`/`gte_u64` (the `i64.lt_u`-family translators -// bitcast both operands to U64, the only way U64-typed values reach the -// comparison emitters), `rotr_u64` via a dynamic-count rotate (constant-count -// rotr is turned into rotl by LLVM), and the u64 arm of `clz`. Comparisons -// feed both branches and a select. u64 division/remainder lives in the -// separate `u64_udiv` case, which aborts in the VM (U64_DIV_EVENT). +// Exercises the STRICT unsigned-u64 comparison emitter arms in +// `codegen/masm/src/emit/int64.rs`: `lt_u64`/`gt_u64` (the `i64.lt_u`-family +// translators bitcast both operands to U64, the only way U64-typed values +// reach the comparison emitters), `rotr_u64` via a dynamic-count rotate +// (constant-count rotr is turned into rotl by LLVM), and the u64 arm of +// `clz`. Comparisons feed both branches and a select. NOTE: the `<=`/`>=` +// comparisons below are canonicalized by LLVM into strict compares with +// inverted arms (branch position) — the non-strict `lte_u64`/`gte_u64` arms +// are covered by the materialized-bool helpers in `case_ucmp_ge.rs`. +// u64 division/remainder lives in the separate `u64_udiv` case. #[unsafe(no_mangle)] pub extern "C" fn entrypoint(input1: u32, input2: u32) -> u32 { let a: u64 = ((input1 as u64) << 32) | input2 as u64; diff --git a/tests/integration/src/end_to_end/differential/cases/case_ucmp_ge.rs b/tests/integration/src/end_to_end/differential/cases/case_ucmp_ge.rs new file mode 100644 index 0000000000..637c9622e7 --- /dev/null +++ b/tests/integration/src/end_to_end/differential/cases/case_ucmp_ge.rs @@ -0,0 +1,39 @@ +// Exercises the NON-STRICT unsigned u64 comparison arm `gte_u64` +// (`::miden::intrinsics::i64` family in `codegen/masm/src/emit/int64.rs`) that +// no other case reaches: in branch/select position LLVM canonicalizes +// `>=`/`<=` into strict compares with inverted arms, and the u128 compare +// legalization only ever materializes an inline `i64.le_u` pair (which is what +// keeps `lte_u64` warm) — `i64.ge_u` appears only when the boolean is +// materialized as a VALUE inside an `#[inline(never)]` helper, exactly like +// the signed twins in `case_scmp_bool.rs`. Each helper uses a distinct operand +// pair so InstCombine cannot CSE the mirrored predicates. The u32 helper pins +// the `i32.ge_u` value form onto the (already warm) `U32Gte` arm so the +// non-strict unsigned boundary semantics (`x >= x` on forced-equal draws) are +// asserted differentially at both widths. +#[inline(never)] +fn ge64u(x: u64, y: u64) -> u32 { + (x >= y) as u32 +} + +#[inline(never)] +fn le64u(x: u64, y: u64) -> u32 { + (x <= y) as u32 +} + +#[inline(never)] +fn ge32u(x: u32, y: u32) -> u32 { + (x >= y) as u32 +} + +#[unsafe(no_mangle)] +pub extern "C" fn entrypoint(input1: u32, input2: u32) -> u32 { + let a: u64 = ((input1 as u64) << 32) | input2 as u64; + let b: u64 = ((input2 as u64) << 17) ^ (input1 as u64).wrapping_mul(0xA5A5_5A5B); + let c: u64 = a.rotate_left(13) ^ (input2 as u64); + + let t1 = ge64u(a, b); // i64.ge_u + let t2 = le64u(b, c); // i64.le_u (distinct pair, bounds the warm sibling) + let t3 = ge32u(input1, input2.wrapping_mul(0x85EB_CA77)); // i32.ge_u + + t1 ^ (t2 << 1) ^ (t3 << 2) ^ (input1 & 0xF8) +} diff --git a/tests/integration/src/end_to_end/differential/cases/case_unroll_chain.rs b/tests/integration/src/end_to_end/differential/cases/case_unroll_chain.rs new file mode 100644 index 0000000000..ec9fcf6cef --- /dev/null +++ b/tests/integration/src/end_to_end/differential/cases/case_unroll_chain.rs @@ -0,0 +1,19 @@ +// Minimal reproducer for an operand-scheduler NoSolution compile panic: +// LLVM runtime-unrolls this `% 97`-bounded loop 8x, producing one basic +// block whose body is the interleaved non-reassociable chain +// `((((acc*33)^i)*33)^(i+1))*33 ...` (eight mul-by-33 / xor-of-induction +// rounds plus eight induction increments). Scheduling that block's operands +// defeats every solver tactic. See the `unroll_chain` test for the full +// finding notes; the mul-only and xor-only bodies of the same loop compile +// and pass (their unrolled chains collapse). +#[unsafe(no_mangle)] +pub extern "C" fn entrypoint(input1: u32, input2: u32) -> u32 { + let x = input2; + let mut acc = input1; + let mut i = 0u32; + while i < x % 97 { + acc = acc.wrapping_mul(33) ^ i; + i = i.wrapping_add(1); + } + acc +} diff --git a/tests/integration/src/end_to_end/differential/cases/case_unroll_rotmix.rs b/tests/integration/src/end_to_end/differential/cases/case_unroll_rotmix.rs new file mode 100644 index 0000000000..0ef814ab28 --- /dev/null +++ b/tests/integration/src/end_to_end/differential/cases/case_unroll_rotmix.rs @@ -0,0 +1,17 @@ +// Minimal reproducer for a compile-time panic in the MASM operand-scheduler +// solution applier: LLVM runtime-unrolls this `% 97`-bounded +// mul-xor-rotate accumulator round (4x, vs 8x for the rotate-less +// unroll_chain round), and applying the solver's solution executes +// `Stack::movdn` with a position past the end of the model stack — +// `attempt to subtract with overflow` at codegen/masm/src/opt/operands/ +// stack.rs:80. See the ignored test for the bounding variants. +#[unsafe(no_mangle)] +pub extern "C" fn entrypoint(input1: u32, input2: u32) -> u32 { + let mut acc = input1 as u64; + let mut i: u32 = 0; + while i < input2 % 97 { + acc = (acc.wrapping_mul(33) ^ (i as u64)).rotate_left(5); + i = i.wrapping_add(1); + } + (acc as u32) ^ ((acc >> 32) as u32) +} diff --git a/tests/integration/src/end_to_end/differential/cases/case_unroll_u32.rs b/tests/integration/src/end_to_end/differential/cases/case_unroll_u32.rs new file mode 100644 index 0000000000..5456217409 --- /dev/null +++ b/tests/integration/src/end_to_end/differential/cases/case_unroll_u32.rs @@ -0,0 +1,16 @@ +// u32 variant of the unrolled mul-xor-rotate accumulator round: LLVM +// runtime-unrolls the `% 97`-bounded loop into one block of interleaved +// u32 mul/xor/rotl rounds with several `i+k` operands live at once. Unlike +// the u64 rounds (unroll_chain: NoSolution; unroll_rotmix: movdn +// out-of-range), the single-felt u32 chain SCHEDULES, pressing the operand +// scheduler's tactic interiors from just inside the solvable boundary. +#[unsafe(no_mangle)] +pub extern "C" fn entrypoint(input1: u32, input2: u32) -> u32 { + let mut acc = input1 | 1; + let mut i: u32 = 0; + while i < input2 % 97 { + acc = (acc.wrapping_mul(33) ^ i).rotate_left(5); + i = i.wrapping_add(1); + } + acc +} diff --git a/tests/integration/src/end_to_end/differential/tests/calls.rs b/tests/integration/src/end_to_end/differential/tests/calls.rs index 00b62f3f99..714d34526e 100644 --- a/tests/integration/src/end_to_end/differential/tests/calls.rs +++ b/tests/integration/src/end_to_end/differential/tests/calls.rs @@ -39,3 +39,56 @@ fn call_mix() { fn call_indirect() { run_case("call_indirect", include_str!("../cases/case_call_indirect.rs")); } + +/// Two fn-pointer arrays of different fn types dispatched at runtime — the one +/// funcref table holds entries with two distinct signature tags, so each +/// `hir.exec_indirect` call site must tag-filter the other signature's entries +/// (verifier/possible_callees skip arms) and the runtime tag check passes only +/// for its own; also the first u64-carrying indirect signature. +#[test] +fn indirect_sigs() { + run_case("indirect_sigs", include_str!("../cases/case_indirect_sigs.rs")); +} + +/// A user `#[no_mangle]` function named exactly `__indirect_function_table_0` +/// collides with the symbol the frontend generates for the lowered funcref +/// table, forcing the collision-rename (counter-bump) path in +/// `get_or_build_table` while dispatch still works through the renamed table. +#[test] +fn indirect_collision() { + run_case("indirect_collision", include_str!("../cases/case_indirect_collision.rs")); +} + +/// `dyn Trait` dispatch through runtime-selected trait objects: vtables are +/// `.rodata` arrays of funcref-table indices, each method call loads its +/// vtable slot and dispatches via `call_indirect` — a dispatch shape (vtable +/// slot load + receiver pointer argument) no fn-pointer-array sibling covers. +#[test] +fn dyn_trait() { + run_case("dyn_trait", include_str!("../cases/case_dyn_trait.rs")); +} + +/// Function pointers as first-class values: returned from / passed to +/// `#[inline(never)]` helpers, a loop-carried fn-pointer state machine, a +/// non-capturing closure coerced to `fn` (anonymous table entry), and fn-ptr +/// `==` (funcref-index comparison) — table-index data flow no sibling covers. +#[test] +fn fnptr_value() { + run_case("fnptr_value", include_str!("../cases/case_fnptr_value.rs")); +} + +/// Chained indirect dispatch — an indirect callee that itself dispatches +/// through a second fn-pointer array (nested `dynexec` frames) — plus +/// dispatch inside a loop and in a single branch arm. +#[test] +fn indirect_chain() { + run_case("indirect_chain", include_str!("../cases/case_indirect_chain.rs")); +} + +/// The widest accepted indirect signature — 7 u64 parameters (14 felts) plus +/// the table index fills 15 of the 16-element operand-stack window — dynexec +/// with a full argument window and u64 values crossing the dispatch boundary. +#[test] +fn indirect_wide() { + run_case("indirect_wide", include_str!("../cases/case_indirect_wide.rs")); +} diff --git a/tests/integration/src/end_to_end/differential/tests/control_flow.rs b/tests/integration/src/end_to_end/differential/tests/control_flow.rs index 66351257f3..9e058fb0fd 100644 --- a/tests/integration/src/end_to_end/differential/tests/control_flow.rs +++ b/tests/integration/src/end_to_end/differential/tests/control_flow.rs @@ -64,19 +64,24 @@ fn trap_branch() { /// Four-exit loop plus eq-chains that canonicalize into contiguous-at-7 and /// sparse cf.switch ops — exercises binary-search (interval guard) and /// linear-search switch lowering. +/// +/// Formerly `#[ignore]`d: the frontend re-typed the `br_table` selector with +/// a checked I32->U32 cast, but LLVM rebases contiguous switches by +/// wrapping-subtracting the smallest case, so a selector below the minimum +/// wrapped negative and the VM aborted with 'value does not fit in i32' +/// (issues #1235/#1243, fixed by PR #1245: `pop1_bitcasted` in +/// `translate_br_table`). Re-verified passing 2026-08-27. #[test] -#[ignore = "flaky native/MASM divergence: mismatch on inputs (1669775643, 1062584501); separate \ - run hit VM assert 'value does not fit in i32' at cycle 2474"] fn switch_shapes() { run_case("switch_shapes", include_str!("../cases/case_switch_shapes.rs")); } -/// Deterministic reproducer for the `switch_shapes` divergence: pins the -/// exact `(input1, input2)` pair the fuzzer flagged, so the bug fails -/// reliably on that input rather than only when proptest happens to draw it. +/// Pinned regression guard for the fixed #1235/#1243 `br_table` selector +/// wrap: input pair (1669775643, 1062584501) makes the rebased selector wrap +/// below the smallest case, which must dispatch to the default arm (random +/// draws hit an `h < 7` pair only ~7/251 of the time, so the pin keeps the +/// wrap path exercised on every run). #[test] -#[ignore = "MASM VM aborts on pinned input (1669775643, 1062584501): 'value does not fit in i32'; \ - deterministic reproducer for the switch_shapes divergence"] fn switch_shapes_repro() { run_case_with_inputs( "switch_shapes_repro", @@ -135,3 +140,40 @@ fn unreachable_exits() { fn switch_loop_mix() { run_case("switch_loop_mix", include_str!("../cases/case_switch_loop_mix.rs")); } + +/// Bare `loop {}` behind an impossible cross-modulus guard — the loop header +/// is a block containing only its own back-edge br, exercising the +/// collapse-into-self-loop bail of the passthrough-branch canonicalizations. +#[test] +fn spin_guard() { + run_case("spin_guard", include_str!("../cases/case_spin_guard.rs")); +} + +/// COMPILE-TIME COMPILER PANIC (safe Rust, 2026-08-27): building this case +/// panics in the MASM operand scheduler with `NoSolution` at +/// codegen/masm/src/lower/lowering.rs:109. Trigger: LLVM runtime-unrolls the +/// `% 97`-bounded loop 8x into a single block computing the interleaved +/// non-reassociable chain `((((acc*33)^i)*33)^(i+1))*33 ...`, whose live +/// state spills. Root cause (triage 2026-08-27): NOT a hard scheduling +/// problem — TransformSpills' `insert_required_phis` +/// (hir-transform/src/spill.rs) seeds EVERY predecessor edge of a +/// dominance-frontier join with the spilled value itself; on the loop-BYPASS +/// edge (this zero-trip-capable `while`) the definition does not dominate, +/// the seed is never rewritten (the pass warns "unused phi"; dead-phi +/// removal is an open TODO), no verifier checks SSA dominance, and +/// cfg-to-scf threads the dead phi args into a sibling-region `scf.yield` — +/// so the scheduler receives an UNSATISFIABLE problem (an operand that is +/// not on the operand stack). With yield arity 2 the TwoArgs-only tactic +/// list returns NotApplicable and NoSolution panics; the arity-3 twin of +/// the same defect is `unroll_rotmix` (spills.rs), and the independent +/// in-contract arity-2 solver gap is `rotl_window` (spills.rs). Bounded by: +/// the xor-only and mul-only bodies of the identical loop compile and pass, +/// and `case_chain300`'s ~400-op straight-line chain passes. Un-ignore when +/// this case compiles (after a spills fix it may still hit the rotl_window +/// gap — then re-triage). +#[test] +#[ignore = "compiler panic: 'with error: NoSolution' at codegen/masm/src/lower/lowering.rs:109 \ + while scheduling the 8x-unrolled mul-xor loop chain (compile-time, no inputs involved)"] +fn unroll_chain() { + run_case("unroll_chain", include_str!("../cases/case_unroll_chain.rs")); +} diff --git a/tests/integration/src/end_to_end/differential/tests/memory.rs b/tests/integration/src/end_to_end/differential/tests/memory.rs index 84c914aa73..5f970f331e 100644 --- a/tests/integration/src/end_to_end/differential/tests/memory.rs +++ b/tests/integration/src/end_to_end/differential/tests/memory.rs @@ -19,8 +19,11 @@ fn mem_copy() { /// Overlapping `copy_within` (dst > src) — wasm `memory.copy` memmove /// semantics vs forward-copying MASM lowering. #[test] -#[ignore = "native/MASM divergence: memory.copy with overlapping dst > src ranges (original repro: \ - inputs (91264998, 3811523388) in pre-split mem_copy)"] +#[ignore = "native/MASM divergence: memory.copy with overlapping dst > src ranges; the VM now \ + hard-aborts via the miden-core-lib memcopy overlap assert (hash-coded error \ + 14467508661128000855, re-verified 2026-08-27; e.g. inputs (4294967295, 194795201) — \ + original repro (91264998, 3811523388) in pre-split mem_copy). Un-ignore when the \ + lowering handles overlapping copies with memmove semantics"] fn mem_overlap() { run_case("mem_overlap", include_str!("../cases/case_mem_overlap.rs")); } @@ -86,3 +89,12 @@ fn loadwiden() { fn static_bss() { run_case("static_bss", include_str!("../cases/case_static_bss.rs")); } + +/// Wasm-local shapes for the `Local2Reg` pass: an unused parameter (its entry +/// `store_local` is a dead store to erase), a zero-parameter zero-local helper +/// (the no-locals early return), and a by-value array parameter whose +/// single-use pointer local is promoted. +#[test] +fn local_shapes() { + run_case("local_shapes", include_str!("../cases/case_local_shapes.rs")); +} diff --git a/tests/integration/src/end_to_end/differential/tests/spills.rs b/tests/integration/src/end_to_end/differential/tests/spills.rs index b148f28790..4b9e71d963 100644 --- a/tests/integration/src/end_to_end/differential/tests/spills.rs +++ b/tests/integration/src/end_to_end/differential/tests/spills.rs @@ -34,6 +34,89 @@ fn spill_loop() { run_case("spill_loop", include_str!("../cases/case_spill_loop.rs")); } +/// Sixteen masked rotate counts shared between pre-loop code and rotates of +/// the loop-carried accumulator (CSE merges the translator's count bands +/// into cross-block SSA values) — 18 felts alive at the loop header drive +/// the W^entry over-capacity arm, with edge splits on the preheader edge +/// and the loop backedge; a second light loop carries two more shared +/// counts across it. +#[test] +fn spill_loop_mix() { + run_case("spill_loop_mix", include_str!("../cases/case_spill_loop_mix.rs")); +} + +/// COMPILE-TIME COMPILER PANIC (safe Rust, 2026-08-27): building this case +/// panics with `attempt to subtract with overflow` in `Stack::movdn` at +/// codegen/masm/src/opt/operands/stack.rs:80. Trigger: LLVM runtime-unrolls +/// the `% 97`-bounded round `acc = (acc.wrapping_mul(33) ^ i).rotate_left(5)` +/// 4x, whose live state spills. Root cause (triage 2026-08-27): the SAME +/// TransformSpills defect as `unroll_chain` (control_flow.rs) — +/// `insert_required_phis` seeds the loop-bypass edge with non-dominating +/// spilled values, leaving SSA-invalid IR that cfg-to-scf threads into a +/// sibling-region `scf.yield %310, %309, %342` of which only `%342` is on +/// the operand stack; the scheduler is handed this UNSATISFIABLE arity-3 +/// problem and `MoveDownAndSwap` runs `movdn` past the end of its stack +/// model. Not a solver defect and provably not a miscompile risk (a live +/// use of the poisoned phi args would have been invalid SSA before the +/// pass, so they are always dead; the solver matches values by identity and +/// validates accepted solutions). Bounded by: the xor-rotl and mul-rotl +/// rounds of the identical loop compile and pass; the rotate-less round is +/// the arity-2 symptom (`unroll_chain`, NoSolution at lowering.rs:109). +/// Compile-time — no inputs involved. Un-ignore when this case compiles. +#[test] +#[ignore = "compiler panic: 'attempt to subtract with overflow' in Stack::movdn at \ + codegen/masm/src/opt/operands/stack.rs:80 while applying the scheduler solution for \ + the 4x-unrolled mul-xor-rotl loop chain (compile-time, no inputs involved)"] +fn unroll_rotmix() { + run_case("unroll_rotmix", include_str!("../cases/case_unroll_rotmix.rs")); +} + +/// Six shared masked rotate counts crossing a dense 6-way `match` inside a +/// `% 97`-bounded loop — spilled values crossing scf.index_switch arm +/// edges, per-arm edge reconciliation, and multi-region successor +/// traversals under TransformSpills' liveness walks. (The ten-count version +/// of this shape hits the known NoSolution panic on an arity-2 rotl — +/// see the scratch log; unroll_chain is the documented reproducer.) +#[test] +fn spill_switch() { + run_case("spill_switch", include_str!("../cases/case_spill_switch.rs")); +} + +/// COMPILE-TIME COMPILER PANIC (safe Rust, 2026-08-27): building this case +/// panics with `NoSolution` at codegen/masm/src/lower/lowering.rs:109 while +/// scheduling `arith.rotl %167, %397` (constraints `[Move, Copy]`) over a +/// 15-felt operand stack. Root cause (triage 2026-08-27, distinct from +/// `unroll_chain`): for arity-2 problems the solver pushes ONLY the +/// `TwoArgs` tactic (codegen/masm/src/opt/operands/solver.rs, the +/// `is_binary` branch); its fixed `dup(copy)` + `movup(move)` pattern here +/// requires a 17-felt stack access, the solution is rejected by the 16-felt +/// MASM window check, and there is no fallback tactic — although a valid +/// in-window schedule exists (`MoveUp(7), Copy(7), Swap(1)`, exactly what +/// `LinearStackWindow` + `Linear` produce for the same problem at other +/// arities). The problem is in-contract: spill analysis correctly capped +/// live pressure at 15 felts <= K=16. This is the ten-count variant of the +/// passing `spill_switch` (six counts) — the boundary is copy-constraint +/// depth, not unrolling; no unrolled chain and no invalid IR are involved +/// (unlike `unroll_chain`/`unroll_rotmix`, whose panics stem from +/// TransformSpills-produced non-dominating phi operands). Un-ignore when +/// this case compiles (binary problems get a window-aware fallback tactic). +#[test] +#[ignore = "compiler panic: 'with error: NoSolution' at codegen/masm/src/lower/lowering.rs:109 \ + scheduling an arity-2 arith.rotl with a Copy-constrained count at the bottom of a full \ + 15-felt window (TwoArgs-only tactic list, no window-aware fallback; compile-time, no \ + inputs involved)"] +fn rotl_window() { + run_case("rotl_window", include_str!("../cases/case_rotl_window.rs")); +} + +/// u32 variant of the unrolled mul-xor-rotate round — schedulable single- +/// felt interleaved chains pressing the scheduler tactic interiors (the u64 +/// twins are the ignored unroll_chain / unroll_rotmix panics). +#[test] +fn unroll_u32() { + run_case("unroll_u32", include_str!("../cases/case_unroll_u32.rs")); +} + /// Two sequential diamonds with wide mixed-width (u64/u32) arm trees over the /// same locals — spill uses inside two scf regions, sibling-arm reloads /// joined by phis at two joins, and size tie-breaking among spill candidates. @@ -42,13 +125,29 @@ fn spill_twin() { run_case("spill_twin", include_str!("../cases/case_spill_twin.rs")); } -/// Reproducer for a compile-time spill-transform panic: each arm calls a -/// non-inlinable helper, spills the call result under wide-tree pressure, -/// then yields it, so the spilled value crosses the control-flow edge as the -/// arm's result. A second shape (nested wide diamonds) hits the same panic. +/// Asymmetric-pressure diamond: cross-edge SSA values (CSE-merged masked +/// rotate-count bands shared by both arms and the join) are spilled in the +/// heavy arm only, so control-flow edge reconciliation records edge splits +/// — a reload split on the heavy edge and a compensating spill split on the +/// cheap edge — driving `SpillAnalysis::split` and the transform's +/// split-materialization loop (`Placement::Split` insertion and branch +/// redirection). +#[test] +fn spill_split() { + run_case("spill_split", include_str!("../cases/case_spill_split.rs")); +} + +/// Each arm calls a non-inlinable helper, spills the call result under +/// wide-tree pressure, then yields it, so the spilled value crosses the +/// control-flow edge as the arm's result. A second shape (nested wide +/// diamonds) exercises the same edge-split path. +/// +/// Formerly `#[ignore]`d as a compile-time panic reproducer (i1289: +/// TransformSpills `convert_reload_to_load` unwrapped None on a spilled +/// value crossing a CF edge as a successor arg / scf.yield operand); +/// re-verified compiling and passing differentially 2026-08-27 — kept as +/// the regression guard for the edge-split spill cluster. #[test] -#[ignore = "compile-time compiler panic: TransformSpills convert_reload_to_load unwraps None \ - (dialects/hir/src/transforms/spill.rs:157); gates the edge-split spill cluster"] fn spill_edge() { run_case("spill_edge", include_str!("../cases/case_spill_edge.rs")); } diff --git a/tests/integration/src/end_to_end/differential/tests/wide.rs b/tests/integration/src/end_to_end/differential/tests/wide.rs index 789ee2032d..5c4add6b70 100644 --- a/tests/integration/src/end_to_end/differential/tests/wide.rs +++ b/tests/integration/src/end_to_end/differential/tests/wide.rs @@ -24,6 +24,15 @@ fn u64_ucmp() { run_case("u64_ucmp", include_str!("../cases/case_u64_ucmp.rs")); } +/// Non-strict unsigned comparisons materialized as VALUES via +/// `#[inline(never)]` helpers — the only producer of `i64.ge_u` (the +/// `gte_u64` emitter arm); branch/select position is always canonicalized +/// to strict compares. +#[test] +fn ucmp_ge() { + run_case("ucmp_ge", include_str!("../cases/case_ucmp_ge.rs")); +} + /// Unsigned u64 division/remainder with dynamic non-zero divisors — /// `checked_div_u64`/`checked_mod_u64` emitter arms (miden-core-lib /// `u64::div`/`u64::mod`). diff --git a/tools/fuzza-agent/AGENT-PROMPT.md b/tools/fuzza-agent/AGENT-PROMPT.md index b4f4bbb3b1..9bf08a1524 100644 --- a/tools/fuzza-agent/AGENT-PROMPT.md +++ b/tools/fuzza-agent/AGENT-PROMPT.md @@ -87,9 +87,10 @@ Each new case is a single `.rs` file at determinism unless you restore it before returning. (Statics are still a useful tool: non-zero initializers are how you reach the data-segment code.) - Stay away from known compile-breakers unless they are your target: flat - signatures over 16 stack felts, function pointers, and recursion (see - `KNOWLEDGE.md`), plus the shapes behind the `#[ignore]`d compiler-panic - reproducers in the test modules. + signatures over 16 stack felts and recursion (see `KNOWLEDGE.md`), plus + the shapes behind the `#[ignore]`d compiler-panic reproducers in the test + modules. (Function pointers / `dyn` dispatch are supported and covered — + see KNOWLEDGE.md's indirect-calls section.) Wire the new case into the thematic module under `tests/integration/src/end_to_end/differential/tests/` that matches what the diff --git a/tools/fuzza-agent/KNOWLEDGE.md b/tools/fuzza-agent/KNOWLEDGE.md index 84bc10dc43..23bc893bb8 100644 --- a/tools/fuzza-agent/KNOWLEDGE.md +++ b/tools/fuzza-agent/KNOWLEDGE.md @@ -72,9 +72,16 @@ Maintenance rules: discriminators thread as SSA). - The five scf while/switch arg-and-result canonicalization interiors and cfg-to-scf undef/latch threading are structurally unproducible. - - Spill-analysis W at any block/region boundary holds at most one value → - proactive block-arg spilling, `spill_trailing_until_fits`, loop-header - `w_used >= K` arms, and region-branch spill arms are unproducible. + - Spill-analysis W at a block/region boundary carries no USER values + (locals are reloaded per block; Local2Reg promotes only same-block + store/load pairs with no control flow between them — local2reg.rs). The + 2026-07 corollary that W <= 1 at every boundary was WRONG (struck + 2026-08-27): midenc-CSE'd masked shift/rotate-count bands cross edges + as SSA and can carry 16+ felts — see "Spill analysis & the edge-split + cluster". `spill_trailing_until_fits` and proactive block-arg spilling + remain unproducible (>16-felt block params have no producer), but the + loop-header `w_used >= K` arm and the CFG edge-split machinery are + reachable. - The >16-felt pressure differential cases trigger is largely *self-inflicted*: the frontend batches `load_local`s at block tops and `SinkOperandDefs` sinks arithmetic but not loads (original wasm operand @@ -86,13 +93,20 @@ Maintenance rules: always turned into a strict compare with inverted arms. `le_s`/`ge_s` (and the `lte`/`gte` emitter arms) are reachable **only** by materializing the boolean as a value inside a `#[inline(never)]` helper (`case_scmp_bool.rs`). + The same holds for the UNSIGNED non-strict compares: `i64.ge_u` (the + `gte_u64` emitter arm) has no producer besides a materialized + `(a >= b) as u32` helper (`case_ucmp_ge.rs`, 2026-08-27) — the u128 + compare legalization only ever materializes an inline `i64.le_u` pair + (which is what keeps `lte_u64` warm), never `ge_u`. - The harness prepends a `loop {}` panic handler, so `panic!` **never** lowers to wasm `unreachable`. To get a genuine trap edge, plant `core::arch::wasm32::unreachable()` behind an impossible cross-modulus guard (`case_unreachable_exits.rs`). -- `Operator::CallIndirect` is `todo!()` in the frontend — function-pointer / - dyn-dispatch cases panic the compiler. Recursion (self or mutual) is a clean - "found a cycle in the call graph" linker error. Neither is testable. +- `Operator::CallIndirect` is fully supported (PR #1251 + signature-tag-check + follow-up, 2026-08): function-pointer and dyn-dispatch cases compile and run + — see the "Indirect calls / funcref tables" section for the verified facts. + Recursion (self or mutual) remains a clean "found a cycle in the call graph" + linker error and is untestable. - Flat function signatures are capped at **16 stack felts** (16×u32 or 8×u64 is the at-limit case, `case_wide_calls.rs`); one felt more currently fails the build inside the spill analysis — treat wider signatures as unwritable, not @@ -109,15 +123,37 @@ Maintenance rules: except `eq/lt/gt/lte_imm`, which switch lowering calls with **U32 selectors only**. `shr_imm_*` is dead: `arith::Shr` lowering always calls `shr()`; constant shift counts are materialized as pushed operands. -- Memory-op immediate/typed arms: the `load_imm` family has only unit-test - callers; `store_imm` non-u32 arms require GlobalVariables (only - `__stack_pointer` exists); felt load/store has no in-scope producer - (f32 bit transport is out of scope by decision — see "Out-of-scope - surfaces" — and LLVM int-ifies plain from_bits/to_bits memory traffic - anyway); `repr(packed)` / dynamically-unaligned access adds - nothing (dynamic-pointer load/store delegates wholesale to intrinsics — - alignment branching is imm-pointer-only); wasm `memory.copy` is always - u8-typed (typed memcpy arms dead). +- Memory-op immediate/typed arms (re-verified 2026-08-27 on the + element-address-space rewrite of emit/mem.rs): the `load_imm` family has + only unit-test callers; `store_imm`'s sole producer is the + GlobalVariable-initializer lowering (lower/component.rs), and the only + global in a plain no_std module is the element-aligned I32 + `__stack_pointer` — so every non-I32/unaligned `Some(imm)` arm + (`store_small_imm`, `store/load_double/quad_word_imm`, the felt `_imm`s, + `store_word_imm`'s unaligned else, `push_native_ptr`) is unreachable. + Constant-address user stores do NOT reach `store_imm`: no HIR + constant-address store/load canonicalization exists — the frontend always + materializes a pointer value through `prepare_addr`. Felt load/store has + no in-scope producer (f32 bit transport is out of scope by decision — see + "Out-of-scope surfaces" — and LLVM int-ifies plain from_bits/to_bits + memory traffic anyway); `repr(packed)` / dynamically-unaligned access + adds nothing (dynamic-pointer load/store delegates wholesale to + intrinsics — alignment branching is imm-pointer-only); wasm `memory.copy` + is always u8-typed, so the byte-`memcpy` runtime element-alignment split + (memcopy_elements fast path vs fallback loop) is the ONLY reachable + memcpy fork (both arms warm), and the word-sized `memcopy_words` fast + paths (pointee size 16 / multiple of 16), the other-size fallback call, + and `emit_word_aligned_element_addr_from_byte_ptr` (called only from + those paths) are dead; `realign_double_word`/`realign_quad_word` remain + zero-caller dead API; `OpEmitter::mem_stream` is dead in this pipeline + (HIR MemStream is built only by the MASM-frontend lifter); + `store_array`/`store_struct` are todo!() stubs with no producer (wasm + stores are scalar-only). `prepare_addr`/`enforce_alignment` are WARM via + the frontend `FunctionBuilderExt` monomorph (their cold remainder is the + assert message + `?` error edges); their two fully-cold report rows are + the `FunctionBuilder` instantiation (used only by the + aligned_memory.rs unit tests) plus a `<_, _>` phantom row — do not + re-read them as a coverage regression. - Wasm has no 128-bit memory ops: `[u128; N]` array (runtime-indexed, loads AND stores) and u128-static traffic all legalize to `i64.load`/ `i64.store` PAIRS (wat+masm probe-verified 2026-07-23, deleted `u128_arr` @@ -196,6 +232,144 @@ Maintenance rules: so a dead `end` never resumes at a following block WITH arguments (the next_block_args closure is unproducible). +## Module-structure payload closure (verified 2026-08-27, global mop-up) + +The cold remainder of `module_env.rs::parse_payload` and its section handlers +is toolchain-gated for cargo-miden no_std cdylib builds — not case-producible: + +- `import_section`/`declare_import` (0-cov): harness modules are import-less; + an undefined import is a clean link error, and intrinsic/stub imports are + the out-of-scope linker-stub surface. +- `start_section` (0-cov): rustc/wasm-ld never emit a wasm start section for + a no_std cdylib (no life-before-main in Rust). +- `dwarf_section` (0-cov): differential builds carry no DWARF (see the + Local2Reg section's synthesized-debug-info fact). +- `TagSection` is `unreachable!()` (exceptions feature disabled). +- Partials: `global_section` (the I32 `__stack_pointer` is the only wasm + global this toolchain emits), `data_section` (no passive-segment producer + without shared-memory init), `element_section`/`table_section` (multi-table + / passive / null-hole / PIC-base shapes — see the indirect-calls section), + `name_section` (subsections beyond function names are not emitted). + The remaining error arms (Encoding::Component, duplicate custom sections) + are diagnostics backstops. + +## Local2Reg & data-segment layout (verified 2026-08-27, memory gap-check) + +The pass lives at `dialects/hir/src/transforms/local2reg.rs` (NOT +hir-transform/) — scope FUZZA_AREA accordingly. + +- **Every function parameter gets an unconditional `hir.store_local` at + entry** (frontend/wasm func_translator.rs `declare_parameters`); wasm + local.get/set/tee are the only other load/store_local producers. + Consequences (`case_local_shapes.rs`): an UNUSED parameter of a kept + function (`#[no_mangle]` defeats dead-arg elimination, `#[inline(never)]` + keeps the call) is a stored-but-never-loaded local and reaches the pass's + dead-store-erasure arm; a zero-param/zero-local helper reaches the + no-locals early return; a by-value aggregate param (passed indirectly) + gives a promotable single-use pointer local. +- **Harness debug info is frontend-synthesized, not DWARF**: cargo-miden + differential builds carry no DWARF, so the frontend synthesizes plain + `[DW_OP_WASM_local(N)]` `di.debug_value` records itself (probe: HIR shows + `producer = midenc-frontend-wasm`, file "unknown"). `di.debug_declare` + and non-trivial expressions (Deref, FrameBase) are emitted only from real + DWARF location schedules (function_builder_ext.rs + `emit_scheduled_dbg_value`) — the declare-conversion loop, + `declares_are_safe`, the FrameBase matcher arm, and the + unsafe-expression preserve/return-false paths of + `convert_debug_references_for_local` are pipeline-gated. The + `di.debug_value` rewrite path is warm. +- Other closed Local2Reg arms: ExecFpi prefix-local pinning (SDK-only + producer); the loaded-but-never-stored "poison" arm (no safe-Rust + producer of a read-before-any-write wasm local — LLVM materializes + constants for known-zero and deletes unreachable-path merges); the + neither-loaded-nor-stored else (structurally dead — candidates come from + the load/store maps); `is_declaration` (import-less harness modules have + no function declarations); log bodies. +- **Data-segment layout arms are toolchain-bounded**: wasm-ld emits active + segments sorted by offset, unique, non-overlapping, so + `DataSegmentLayout::insert` always takes the push_back path + (middle-insert / same-offset-dedup / Mismatch / Overlapping arms + unproducible) and `validate_no_overlaps`' error interior is a backstop + behind it. The end-of-address-space edges (insert's OutOfBounds, + `next_available_offset`'s overflow Nones) need a segment ending at/past + 2^32 — covered by the linker.rs unit test + `link_fails_when_data_segments_fill_the_address_space`, and inherently a + link error, not differential material. `DataSegmentLayout::len`/ + `pop_front`/`Segment::alloc_default` are dead API (no pipeline callers). + +## Indirect calls / funcref tables (verified 2026-08-27) + +Corpus cases: `case_call_indirect`, `case_indirect_sigs`, +`case_indirect_collision`, `case_dyn_trait`, `case_fnptr_value`, +`case_indirect_chain`, `case_indirect_wide`; all probe- and/or +region-verified. + +- **Pipeline**: wasm funcref table → `builtin.function_table` (two words of + linear memory per slot: MAST-root digest word + signature-tag word) → + linker allocates the table word-aligned in the page after the globals; + component `init` fills initialized slots via `procref`; each + `call_indirect` becomes `hir.exec_indirect` → bounds check + signature-tag + check + `dynexec`. Tables are lowered lazily on the first dispatching + `call_indirect`. The runtime failure modes (OOB index, null slot, + tag-mismatched slot) are UB natively and are asserted NON-differentially in + `end_to_end/indirect_call_traps.rs` — differential cases must stay on safe + dispatches and never duplicate them. +- **Tag interning**: tag = structurally-interned wasm signature index + 1 + (`signature_type_tag`; 0 reserved for null slots). Structurally-equal fn + types share one tag; distinct fn-ptr types in one program produce distinct + tags inside the ONE shared table (`case_indirect_sigs`, entries tag 1 + 2). + A multi-tag table is also what reaches the tag-mismatch skip arms of + `ExecIndirect::verify` and `possible_callees`. +- **Toolchain shape**: rustc + wasm-ld emit exactly one funcref table + (`__indirect_function_table`), slot 0 = reserved null pointer, all live + address-taken functions contiguous from slot 1, initialized by a single + active element segment at offset 1 with no `ref.null` holes. Hence + `collect_table_image`'s FuncRef-whole-table-initializer, `precomputed` + Null-image, global-relative(PIC)-base, and null-hole arms, plus every + multi-table shape, are toolchain-unproducible. +- **Devirtualization** (the enemy): a provably-single-target fn ptr or a + constant table index is devirtualized to a direct call. What survives as + `call_indirect`: runtime-indexed loads from a `static` fn-ptr array + (`OPS[(x & 3) as usize]`), runtime-indexed `[&dyn Trait; N]` selection, and + fn-ptr values crossing `#[inline(never)]` boundaries (returned from or + passed to noinline helpers, incl. loop-carried fn-ptr state machines) — + LLVM does not do indirect-call promotion without PGO. +- **dyn Trait**: vtables are `.rodata` arrays of funcref-table indices; each + method dispatch loads its vtable slot and `call_indirect`s with the + method's own wasm signature (receiver pointer + args → its own tag) + (`case_dyn_trait`, 3 dispatch sites, tags 1/2). +- **Non-capturing closures** coerced to `fn` become anonymous + `FnOnce::call_once` shim entries in the table; **fn-ptr `==`** compiles to + `i32.eq` on table indices, agreeing with native address comparison for + distinct-bodied functions (`case_fnptr_value`; wasm-ld does no ICF, which + also closes `possible_callees`' duplicate-callee dedup arm). +- **Table symbol collisions**: the generated table symbol is + `__indirect_function_table_`, probed against the module symbol table + with a counter bump — a user `#[no_mangle]` fn named exactly that forces + the rename path (`case_indirect_collision`, table becomes `..._0_1`). +- **Width cap**: the lowering schedules the arguments plus the table index in + Miden's 16-felt operand-stack window ⇒ at most 15 argument felts. 7×u64 + (14 felts) dispatches end-to-end (`case_indirect_wide`); one felt more is a + clean translation-time diagnostic (code_translator/mod.rs `unsupported + call_indirect ... operand stack window`), not a panic. +- **Verified dead ends**: `add_table_entry`'s Intrinsic arm + (`CallableFunction::Intrinsic` is unconstructible today — the only + `register_linker_stub` caller pre-filters on `is_operation()`) and + Instruction arm (an intrinsic in a table = linker-stub surface, out of + scope); `live_entries`' empty-entries arm (a lazily-built table always + holds ≥1 entry — safe Rust cannot dispatch without an address-taken + function); `exec_indirect`'s argument-extension assert (indirect + signatures come from `sig_from_func_type`/`AbiParam::new`, which never set + extension attrs — extension attrs exist only on `Signature::new` canon-ABI + component paths); the legalization illegal arms for + FunctionTable/FunctionTableEntry/ExecIndirect (invalid-IR backstops; the + frontend pre-diagnoses the producible ones at translation). +- `OpEmitter::assert`/`assert_eq` have only felt-intrinsic (SDK) HIR + producers (`frontend/wasm/src/intrinsics/felt.rs`); `assert_eq_imm` has + only `#[cfg(test)]` callers; `assertz`'s harness producer is only the + `prepare_addr` align-hint — their cold arms are closed for plain-Rust + cases. + ## Rewrite-pass scope closures (CSE / SCCP / DCE / folder / scf patterns) Verified 2026-07-23 (region-level coverage + source audit of the pass @@ -232,6 +406,17 @@ pipeline in midenc-compile/src/stages/rewrite.rs): arms are dead (fresh folder per driver iteration, each op visited once), and `notify_removal`'s main body is dead (nothing erases a folder-owned constant while its folder lives). +- Greedy-driver region simplification runs at `RegionSimplificationLevel:: + Normal` everywhere (the driver default; the one pipeline config-setter, + midenc-compile backend.rs, also sets Normal). `merge_identical_blocks` and + `drop_redundant_arguments`/`drop_redundant_block_arguments` run only under + `Aggressive` — config-gated, no case producer (2026-08-27). +- The MASM legalization pass (codegen/masm/src/legalization.rs) runs + `apply_full_conversion` on every compile, but wasm-derived HIR arrives + already-legal, so `FullConversionDriver::legalize_operation` only verifies + legality: its pattern-rewrite/materialization interiors and + `reconcile_unrealized_conversion_casts` are invalid-IR backstops + (2026-08-27). - `DeadCodeAnalysis` has exactly two pipeline loaders — SCCP's solver (pre-lift) and `LivenessAnalysis` inside TransformSpills (pre- AND post-lift; the latter is what warms the scf region-branch/terminator arms) @@ -256,6 +441,88 @@ pipeline in midenc-compile/src/stages/rewrite.rs): stack-resident value used twice on one exit edge — killed by the locals argument. +## cf/scf canonicalization & cfg-to-scf closures (verified 2026-08-27) + +Region-level audit of everything still cold under +`dialects/cf/,dialects/scf/,hir-transform/src/cfg_to_scf` (control-flow +gap-check pass; wat probes `tail_funnel` (deleted), `spin_guard`): + +- **Returns are always per-site.** The LLVM wasm backend emits an explicit + (tail-duplicated) `return` at every return site and never branches to the + outermost frame; probe- and corpus-wat-verified, it also never emits + result-typed `block`/`loop` frames — no `br`/`br_if` in our pipeline ever + carries a value on the wasm stack (div-bearing two/three-arm tail merges + and early-return+loop shapes all come out as per-site `return`s). + Value-carrying HIR successor args therefore exist only in cfg-to-scf's own + synthesized dispatch (e.g. the residual `cf.cond_br .. ^ret(%v)` exit). +- Consequences, all structurally closed for wasm-derived IR: the function's + ret-only exit block (`^exit(%v): builtin.ret %v`, built by the final + reachable `End`) always has exactly ONE unconditional-br predecessor, and + `SimplifyBrToBlockWithSinglePred` (registered before `SimplifyBrToReturn` + on cf.br, equal MAX benefit) always claims it — `SimplifyBrToReturn`'s + interior, `collapse_branch`'s block-arg check/remap paths (a passthrough + block with arguments needs back-to-back result-typed frame ends), and the + branch-region entry-argument replacement in + `transform_to_structured_cf_branches` (transform.rs ~725) are all + unproducible. +- **`SimplifyPassthroughCondBr` can never rewrite**: collapsing an arm of a + multi-successor predecessor requires the passthrough's target to have a + UNIQUE predecessor (critical-edge guard), but a wasm frame is emitted only + because something branches to it — a target whose only pred is the + passthrough block would be a frame nothing branches to. The plain-br + variant (`SimplifyPassthroughBr`) fires routinely (1-successor preds skip + the guard). A frame-end passthrough to a *self-loop* is producible: a bare + `loop {}` behind an impossible guard leaves a header block containing only + its own back-edge `cf.br`, taking `collapse_branch`'s + collapse-into-self-loop bail (`case_spin_guard.rs`; `unreachable_exits` + deliberately keeps its infinite loop body non-empty, which hides this + shape). +- **`cf.Switch`/`cf.CondBr::get_successor_for_operands` interiors are + closed**: the only callers workspace-wide are DCA's + `visit_branch_operation` (dce.rs) and the spill analysis' + single-successor resolution (spills.rs), both passing SCCP-lattice + constants — a cf selector/condition is never a lattice constant (SCCP + cannot out-prove LLVM pre-lift; the post-lift residual dispatch selects on + scf results, which are runtime). +- **`cf.Select::fold` interior is closed**: it folds only on a constant + BoolAttr condition; wasm `select` conditions are LLVM-pre-folded and + `ConvertTrivialIfToSelect`-created discriminator selects have runtime + compare conditions; nothing post-lift constant-ifies an i1 (SCCP is + pre-lift only, and no cf constant-condition pattern exists). +- **`FoldConstantIndexSwitch` is closed**: an `scf.index_switch` selector is + either a user `br_table` selector (LLVM deletes constant-selector + br_tables) or a cfg-to-scf discriminator (multiplexer block-arg/op-result + by construction). `FoldRedundantYields` (the only use-replacer that could + constant-ify one) needs ALL regions to yield the same SSA value in the + selector column, but discriminator columns carry distinct per-continuation + constants by construction — an all-same column would mean a single + continuation, for which no dispatch switch is synthesized. Note + `builtin.ret_imm` has NO pipeline producer (frontend always emits + `builtin.ret`; ret_imm appears only in unit tests and global-variable + initializers), so exit kinds are exactly {ret, unreachable}, the combined + exit dispatch is at most one `cf.cond_br`, and a 3+-way residual exit + switch cannot exist. +- **cfg-to-scf transform cold interiors are logs/errors or recorded + closures**: `combine_exit` and `EdgeMultiplexer::redirect_edge` are fully + warm except `log::trace!` bodies and `?` error edges; `check_value`'s + nested-region grandparent walk needs an SSA value crossing sibling loops + (locals argument); the undef-threading arm and `loop_block_dominates` + cache need an escaping value not defined in the latch (latch-multiplexer + construction, recorded); the latch→header carried-values loop + (transform.rs ~891) and prior latch/header-arg checks need loop-header + block args (irreducible/multi-entry CFG, unproducible from wasm); the + reduce-time successor-swap arm is dead by the + `create_single_exiting_latch` invariant (recorded). The + `<_ as CFGToSCFInterface>` builder rows are duplicate unresolved-receiver + monomorphs of warm concrete impls. `LiftControlFlowToSCF`'s + World/Component/Module recursion arms never run (FUNCTION pass manager). +- The remaining cold cf/scf rows are OpParser/OpPrinter impls (textual HIR), + SwitchCase KeyedSuccessor rewrite-API, rewriter-instantiated scf builder + monomorphs of the closed canonicalization interiors, and the + `get_region_invocation_bounds`/`get_entry_successor_regions`/ + `get_successor_regions` region-analysis arms (liveness/DCA-adjacent — + candidates for a spill-focused area, not for CF cases). + ## Block-emitter operand-drop facts (codegen/masm emitter.rs / emit/mod.rs / stack.rs) Verified 2026-07-23 (three probed cases — fresh-valued multi-exit loops, @@ -297,6 +564,11 @@ corroborate each closure below): `OpEmitter::bnot` and its `emit_repeat`/`emit_template` 64/128-bit arms are unreachable; `emit_all::<[_;13]>/<[_;14]>` likewise (callers are the checked/overflowing `mul_u64` arms the frontend never builds). +- **Dead emit-helper API** (zero callers workspace-wide, 2026-08-27): + `dup_select_int32`/`mov_select_int32` (int32.rs); `zext_int64` and + `move_int64_up` (int64.rs) are called only from the dead cast/felt/i128 + paths. `LoopForest::verify`/`compare_loops`/`verify_loop` (hir ir/loops.rs) + are self-check API with no pipeline caller. - **`OperandStack::get` is SDK-only** (emit/events.rs, emit/merkle.rs); `IndexMut` remains closed with the same-value-operand-pair fact. Refinement: cfg-to-scf DOES synthesize repeated-operand lists (`scf.yield %v, %v, %v, @@ -310,33 +582,72 @@ linker.rs, cfg_to_scf); the corpus's scale cases are `case_chain300` (~400-op single-block chain), `case_match64`, `case_deep_nest`, `case_call_web`, `case_seg24`. -- **The solver has no fallback scheduler and no reachable failure arm.** - Production fuel is always the default 40, charged once per tactic tried - (cost 1 for the four pattern tactics, `max(num_copies,1)` for - CopyAll/Linear/LinearStackWindow; chains are ≤5 tactics). Exhausted fuel - only stops the search for a *better* solution — with no solution yet, the - remaining tactics run regardless (regression-test-pinned). Reaching the - exhaustion break needs ≥14 Copy-constrained operands in one problem; - all-tactics-fail (`NoSolution` → compile panic) needs >16 felts of live - operands below the problem. Both are forbidden by the locals argument + - SpillAnalysis (K=16) + block-entry dead-operand drops → closed from - wasm-derived IR (the solver's own unit tests cover them with fuel 0/10). +- **The solver has no fallback scheduler.** Production fuel is always the + default 40, charged once per tactic tried (cost 1 for the four pattern + tactics, `max(num_copies,1)` for CopyAll/Linear/LinearStackWindow; chains + are ≤5 tactics). Exhausted fuel only stops the search for a *better* + solution — with no solution yet, the remaining tactics run regardless + (regression-test-pinned). Reaching the exhaustion break needs ≥14 + Copy-constrained operands in one problem (still no known producer). The + 2026-07-23 claim that all-tactics-fail (`NoSolution` → compile panic) is + closed from wasm-derived IR was WRONG (struck 2026-08-27): LLVM + runtime-unrolls a `% 97`-bounded `acc = acc.wrapping_mul(33) ^ i` loop 8x + into a single block interleaving eight mul/xor rounds with eight distinct + `i+k` operands, and scheduling that block panics with `NoSolution` on safe + Rust (`unroll_chain`, kept `#[ignore]`d; specifics at the test). The + mul-only and xor-only bodies of the same loop collapse when unrolled and + pass, so the trigger is the unroll-produced *interleaved* chain — plain + chain length is fine (`case_chain300`). ROOT-CAUSED 2026-08-27: the + unroll-family panics (`unroll_chain`, `unroll_rotmix`) are NOT solver + limitations — the unroll forces spilling, and TransformSpills hands the + solver SSA-invalid IR (see the spill section's phi-insertion fact); the + scheduling problems are *unsatisfiable* (an expected operand absent from + the operand stack), not hard. Panic site depends only on arity: arity-2 → + `TwoArgs` NotApplicable → `NoSolution` at lowering.rs:109; arity≥3 → + `MoveDownAndSwap` walks the model past its end → subtract-with-overflow + in `Stack::movdn` (stack.rs:80). The solver never validates that expected + Move operands exist on the stack, so out-of-contract input surfaces as + these arbitrary panics. +- **Arity-2 problems are TwoArgs-only** (`solver.rs` `is_binary` branch): + no other tactic is pushed for binary ops, so when TwoArgs' fixed + dup/movup pattern needs a stack access past the 16-felt MASM window (a + Copy-constrained operand near the bottom of a full 15-felt window — copy + materialization adds transient depth the K=16 spill cap does not model), + the window check rejects the solution and there is no fallback → + `NoSolution` on an *in-contract, solvable* problem + (`LinearStackWindow`+`Linear` produce a valid in-window schedule for the + same shape at other arities). Reproducer: `rotl_window` (ten shared + count bands + u64 rotl; the six-count `spill_switch` passes) — a + root-cause distinct from the unroll-family panics above. - **No size-gated compiler path exists at single-block scale**: a ~400-op non-reassociable chain (139 spill locals, 267 stack-motion ops in MASM) compiles in about a second and passes differentially — no cliff, no fuel/scale arm. Scale DID warm `TwoArgs::move_copy`'s commutative sub-arms (non-strict scheduling of commutative binops under reload interleavings) — the only tactic interior that responded to scale. -- **MoveDownAndSwap's evict arms and MoveUpAndSwap's final NotApplicable - arm remain unproducible**: they need a live non-operand value on top of - the stack at an arity≥3 no-copy problem, but RegStackify moves every - single-use def to its use and SinkOperandDefs sinks the whole operand - cluster together, so operands stay adjacent to their op; the 400-op storm - never produced the shape. CopyAll's success loop and SwapAndMoveUp's real - arms likewise have no plain-Rust producer in this corpus (campaign-2 - verdict: no deterministic lever) — the four tactics' *precondition* arms - are structurally dead (each tactic is only pushed when its precondition - already holds). +- **MoveDownAndSwap's FIRST evict arm and MoveUpAndSwap's final + NotApplicable arm remain unproducible**: they need a live non-operand + value on top of the stack at an arity≥3 no-copy problem, but RegStackify + moves every single-use def to its use and SinkOperandDefs sinks the whole + operand cluster together, so operands stay adjacent to their op; the + 400-op storm never produced the shape. Refinement (2026-08-27): + MoveDownAndSwap's SECOND evict arm (the post-move eviction) IS warm in + the current corpus, so the old "evict arms unproducible" plural was too + strong. CopyAll's success loop and SwapAndMoveUp's real arms still have + no plain-Rust producer — the four tactics' *precondition* arms are + structurally dead (each tactic is only pushed when its precondition + already holds). The unroll-interleave lever (2026-08-27) DOES reopen the + solver interiors, but every u64 trigger found so far panics before + contributing coverage (NoSolution at lowering.rs:109 — also producible + WITHOUT unrolling by an arity-2 rotl with a copy-constrained shared + count band under ~10 felts of crossing-band freight — and a second + unroll-family panic in `Stack::movdn`; both live as ignored reproducers + in the spills test module). The schedulable u32 twin (`case_unroll_u32`) + adds no new interior regions. `preemptively_move_endangered_operands_to_ + top`'s interior is closed-in-practice: it needs missing-copy felts plus + a deep move operand in one problem, but exec args are always fresh + single-use loads (no aliases) and alias-bearing small ops have + SinkOperandDefs-adjacent operands. - **Switch lowering is width-insensitive past 8 arms**: a 64-arm dense `match` survives as one 65-target `br_table` (structurally-varied arm bodies defeat LLVM's lookup-table and arm-merging transforms), and adds @@ -355,11 +666,104 @@ single-block chain), `case_match64`, `case_deep_nest`, `case_call_web`, irreducible CFG (wasm is reducible by construction). The latch's successor 0 is always the loop header (create_single_exiting_latch construction invariant), so the reduce-time successor-swap arm is dead. -- **codegen/masm/src/linker.rs is data-layout only** (segments + globals — - call-graph/MAST ordering lives in the assembler, not here) and closed: - its cold surface is error paths, disabled log bodies, the multi-module - `__stack_pointer` dedup (the harness always links exactly one HIR - module), the page_size=0 arm, and dead accessors. +- **codegen/masm/src/linker.rs is data-layout only** (segments + globals + + function-table bases — call-graph/MAST ordering lives in the assembler, + not here) and closed: its cold surface is error paths, disabled log + bodies, the multi-module `__stack_pointer` dedup (the harness always links + exactly one HIR module), the page_size=0 arm, and dead accessors — + including `FunctionTableLayout::is_empty` (sole caller + `has_function_tables` sits behind `requires_init`'s `has_globals()` + short-circuit, and `__stack_pointer` makes has_globals always true) and + `element_addr_of`'s None edge (2026-08-27; the table layout loop itself is + warm from the call_indirect cases). + +## Spill analysis & the edge-split cluster (verified 2026-08-27) + +Corpus cases: `case_spill_split` (asymmetric diamond, both split flavors), +`case_spill_loop_mix` (loop-header over-capacity + backedge splits), +`case_spill_switch` (dispatch under crossing freight), plus the revived +`case_spill_edge`. All trace-verified with `MIDENC_TRACE= +'analysis:spills=trace,pass:spills=trace'` — the spill pass/analysis logs +(edge splits, W^entry sets, loop pressure) are the cheapest way to check a +spill shape BEFORE paying a coverage step. + +- **The only plain-Rust producer of cross-block W traffic is the + masked-count band**: the translator wraps every shift/rotate count in + `arith.band(count, mask)`; the canonicalizer's folder dedups the constant + operands function-wide and CSE merges the structurally-identical bands + into the dominating occurrence — so a count CONSTANT reused in two blocks + becomes ONE u32 SSA value (one felt) live across the edges between them. + User values never cross in W (locals are reloaded per block; Local2Reg is + same-block-only). N shared counts = N felts of freight across any chosen + edge; CSE needs the first use to dominate the later ones (e.g. a do-while + body dominates the post-loop code, a `while` body does not dominate its + exit). +- **Edge splits (`SpillAnalysis::split`, the transform's split loop, + `Placement::Split`) fire on ASYMMETRIC pressure**: a value in W^entry(B) + that is missing from one predecessor's W^exit gets a reload split on that + edge, and the compensating spill lands as a split on the other edge — + produced by a diamond whose arms differ in pressure while shared bands + cross both (`case_spill_split`); symmetric-pressure shapes (spill_branch/ + twin/edge) spill the value in BOTH arms and never trigger reconciliation + (that is why the cluster stayed cold until now). Loop preheader and + BACKEDGE splits come from over-capacity loop headers the same way. +- **Loop-header `w_used >= K`** (the over-capacity arm incl. its sort and + take_while closures) is reachable with 16+ shared counts used both before + the loop and on the loop-carried accumulator inside it (LICM cannot hoist + rotates of a loop-carried value; rotates of loop-invariant operands DO + get hoisted and defeat the shape). +- **Pre-lift spilling bounds the post-lift pass**: the first TransformSpills + caps SSA values crossing any CFG edge at <= K felts and rewrites spilled + values' downstream uses to reloads placed at those uses, so after + cfg-to-scf no scf op can have >16 felts of results and no post-lift + block boundary exceeds K. Consequently `spill_trailing_until_fits`, the + w_exit>K result-spill arm of `compute_w_exit_region_branch_op`, the + region-branch entry-spill arm of `visit_region_branch_operation` (min() + also caps W right before every op, and scf operands — if conditions, + switch selectors — are always freshly computed there), and the loop-LIKE + over-capacity closures are all unproducible. +- **Terminator operands are always fresh**: yield/condition/ret operands + are constants, local loads, or tail-computed values, never + spilled-and-unreloaded — MIN's terminator-reload interiors are closed. + Splits carrying successor ARGUMENTS are likewise unproducible (arg + sources are computed immediately before the terminator). +- **Pre-lift "live through loop" is always empty** (three shapes): the + loop-exit +LOOP_EXIT_DISTANCE increment never survives into the header's + next-use set, so post-loop-used values arrive classified as in-loop + candidates; the pre-lift live-through sort closure is out of reach (the + post-lift loop-LIKE counterpart does fire). +- **`max_block_pressure`'s region-branch arm is empirically unproducible**: + the loop-pressure walk only visits the scf.while's own region-graph + entries, and top-test, light-header, and bottom-test diamond-in-loop + variants never place the nested scf.if in a walked block. +- **`get_region_invocation_bounds` (and the entry-successor arms it feeds) + is pass-config-gated**: its sole caller is ControlFlowSink + (hir-transform/src/sink.rs), which is registered but never scheduled in + the pipeline. (This refutes the 2026-08 CF-iteration lead that + liveness/DCA under TransformSpills reach it.) +- **Test-only API**: `is_spilled_at`/`is_reloaded_at`/`is_spilled_in_split`/ + `is_reloaded_in_split`/`set_materialized_split`/`get_split` are called + only from the analysis' own unit tests. +- The spill freight has a scheduler ceiling: crossing-band freight around + 10 felts combined with an in-loop multi-arm dispatch currently fails to + schedule (see the ignored reproducers in the spills test module); keep + deliberate freight around 6-8 felts in cases that must pass. +- **`insert_required_phis` seeds every predecessor edge with the spilled + value itself** (hir-transform/src/spill.rs, phi-insertion for DF+ of the + reload blocks): for a join reachable via a path the definition does not + dominate (e.g. a loop-bypass edge when the spill lives in the loop body), + no reaching definition exists on that edge, so the seeded successor + argument is never rewritten and the function leaves the pass SSA-invalid; + the phi is provably dead on such edges (a real use would have been + invalid pre-pass), the pass itself warns "unused phi ... encountered + during rewrite phase" (removal is an open TODO in `rewrite_inserted_phi_ + uses`), and nothing downstream verifies dominance (the per-op verifier + has no SSA-dominance check). cfg-to-scf then threads the dead phi args + into sibling-region scf.yield operands, and codegen panics scheduling an + operand that is not on the operand stack — the mechanism behind the + ignored unroll-family reproducers (specifics at the test sites). Because + the poisoned phi can never feed a live use, this defect cannot silently + miscompile; it always surfaces as a compile-time panic. ## Case-writing tricks that work @@ -402,6 +806,14 @@ single-block chain), `case_match64`, `case_deep_nest`, `case_call_web`, (`u32mod`, `u64::mod`, `i32::wrapping_mod`) never execute. Give remainders a mirrored/rotated operand pair with no matching div (masm-verified both ways, `case_udiv_bounds.rs`, `case_sdiv_bounds.rs`). +- **Pure defs (and pure `#[inline(never)]` calls!) sink to their use**: + LLVM infers readnone on internal helpers and moves the computation into + the use's block, destroying any "defined before the branch, used after + the join" liveness you were counting on. Pin a call in place by giving + the helper an opaque atomic side effect that never changes state: + `PIN.fetch_add(0, Ordering::Relaxed)` folded into the result + (`case_spill_split`) — deterministic across the 16 reused native + invocations, unfoldable, and unsinkable. - An **opaquely-zero value** (impossible cross-modulus guard `as usize`, times an input-derived factor) keeps a copy alive that LLVM would elide when it can prove `len == 0` or `src == dst` — how a len-0 same-position @@ -459,6 +871,12 @@ at the test site. - The report's `Area delta` line inflates by a constant when duplicate monomorphized `(file, name)` rows exist — judge productivity by the difference of the area *headline* between steps. +- Generic compiler functions can appear as SEVERAL rows: the pipeline's + live instantiation, unit-test-only instantiations, and `<_, _>` + unresolved-receiver phantom rows. A "fully untouched" row does not mean + the function is cold — check the partially-covered table (and + report.json) for a warm sibling monomorph before treating it as a target + (2026-08-27: prepare_addr/enforce_alignment read as untouched this way). - A `fuzza-cov-step` launched immediately after a backgrounded `fuzza-cov` can produce an empty report (0 tests, 0 regions) — rerun the step; note the `report.prev.json` delta chain is polluted for that step. @@ -475,6 +893,11 @@ at the test site. cold (that is how translate_unreachable_operator's warm End-of-Loop arm read as a target). Before betting a case on a specific arm, verify at region level — `report.json` carries exact `line:col` spans per region. + Even REGION-level cold on a dispatch arm can be attribution noise: the + `OpEmitter::shr` U64 arm's region reads count-0 while its unique callee + `shr_u64` is 6/6 warm and the corpus HIR provably contains u64 `arith.shr` + (2026-08-27). When a cold arm has a dedicated callee, check the callee's + coverage before treating the arm as a gap. - `MIDENC_EMIT` paths must be ABSOLUTE `kind=DIR` specs: bare kinds dump into the test process CWD (that is how stray `.masm`/`.hir` files end up in the source tree), and *relative* dirs silently vanish into the ephemeral diff --git a/tools/fuzza-agent/OUTER-LOOP.md b/tools/fuzza-agent/OUTER-LOOP.md index f65aaf6a1d..fa9be24e5c 100644 --- a/tools/fuzza-agent/OUTER-LOOP.md +++ b/tools/fuzza-agent/OUTER-LOOP.md @@ -48,6 +48,12 @@ closed by an unreachability argument). case budget and a bias toward the unreachability exit. - An area blocked by a known bug is a *re-run candidate*, not a dead area — the blocking test's comment names the unblock condition. +- When composing a brief from the report's cold tables, read the untouched + AND partially-covered tables together: an untouched row for a generic + function may be a unit-test-only or phantom `<_, _>` monomorph whose real + instantiation sits warm in the partial table, and a cold dispatch ARM can + be llvm-cov attribution noise — verify via its dedicated callee's coverage + before making it an iteration's headline target. ## Per-iteration subagent prompt skeleton