From 2477d3fd77cce327849d70b9ced7778e8f51105c Mon Sep 17 00:00:00 2001 From: Ralf Anton Beier Date: Fri, 3 Jul 2026 13:00:30 +0200 Subject: [PATCH] =?UTF-8?q?feat(selector):=20i64=20stack=20params=20+=20pa?= =?UTF-8?q?ir=20spill-slot=20growth=20=E2=80=94=20direct-path=20completene?= =?UTF-8?q?ss=20(#503,=20#587)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Closes the two remaining honest-skip classes in the direct selector's i64 handling: #503-i64 — 64-bit params AAPCS-passed on the STACK. New aapcs_param_layout walks NCRN+NSAA with declared widths: a wide param past R3 (or even-align-spilled, e.g. p3 of (i32 i32 i32 i64)) gets an 8-byte-aligned caller-frame slot read/written via I64Ldr/I64Str at [sp, frame+24+nsaa]; per AAPCS C.5 a narrow param AFTER a stack spill is itself stack-passed. That C.5 rule also fixes a latent SILENT miscompile found along the way: p3 of (i64 i32 i32 i32) was read from R3 (= p2) by the index_to_reg fallback — on BOTH paths (the optimized path now routes wide-param signatures to the direct selector; its own homing is width-naive and its #518 decline only caught i64-param READS). Wide STACK params in has-call functions lower too (the caller frame survives the BL); the #518 decline is narrowed to REGISTER- resident i64 params in frame-backing functions, which remain the one loud skip. param_slots now backs only register-resident params. #587 — i64 spill-slot pool exhaustion (falcon func_60/func_73). The pool (8 slots) is growable: a new pool-grow recovery retry reruns the ENTIRE existing ladder (every rung + the #474 promotion-off fallback — deliberately last, so anything that compiled yesterday is produced by exactly yesterday's path) with the pool sized from a conservative operand-stack-depth bound (new synth_core::wasm_stack_check:: max_depth_bound; clamped to 120 slots). SpillState is Vec-backed; compute_local_layout reserves the matching area. Freeze: frozen byte gates + promotion_never_causes_compile_failure_474 bit-identical (pool-grow and the wide-param machinery only fire on previously-skipped/miscompiled shapes; all-i32 signatures produce the exact legacy offsets by construction). Oracles (wasmtime ground truth vs unicorn, both paths, CI-wired): scripts/repro/i64_stack_param_503_differential.py (7 shapes + control) scripts/repro/i64_spill_pool_587_differential.py (20-live-i64 fold) i64_param_518_differential.py updated: d_past_r3 moved from the decline contract to emitted+executes-correctly. Closes #503 Co-Authored-By: Claude Opus 4.8 --- .github/workflows/ci.yml | 44 ++ crates/synth-backend/src/arm_backend.rs | 105 +++- .../tests/i64_completeness_503_587.rs | 92 +++ crates/synth-core/src/wasm_stack_check.rs | 53 ++ .../src/instruction_selector.rs | 578 +++++++++++++----- scripts/repro/i64_param_518_decline.wat | 34 +- scripts/repro/i64_param_518_differential.py | 55 +- scripts/repro/i64_spill_pool_587.wat | 67 ++ .../repro/i64_spill_pool_587_differential.py | 134 ++++ scripts/repro/i64_stack_param_503.wat | 66 ++ .../repro/i64_stack_param_503_differential.py | 226 +++++++ scripts/repro/stack_args_503_differential.py | 5 +- 12 files changed, 1250 insertions(+), 209 deletions(-) create mode 100644 crates/synth-cli/tests/i64_completeness_503_587.rs create mode 100644 scripts/repro/i64_spill_pool_587.wat create mode 100644 scripts/repro/i64_spill_pool_587_differential.py create mode 100644 scripts/repro/i64_stack_param_503.wat create mode 100644 scripts/repro/i64_stack_param_503_differential.py diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 7e1dcfa5..991835a6 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -621,6 +621,50 @@ jobs: SYNTH: ./target/debug/synth run: python scripts/repro/stack_args_503_differential.py + i64-completeness-503-587-oracle: + name: i64 stack-param + spill-pool-grow oracle + # VCR-ORACLE-001 (#242, #503-i64, #587): EXECUTE the two previously + # loud-skipped direct-selector i64 classes under unicorn and diff vs + # wasmtime, on BOTH paths (--relocatable = falcon's, and the default): + # * #503-i64 — 64-bit params AAPCS-passed on the STACK (past R3 / + # even-align-spilled), incl. the narrow-after-wide shape that was + # silently MIScompiled (p3 of `(i64 i32 i32 i32)` read from R3 = p2), + # a write-back shape, and a has-call shape. Falcon func_58/func_163. + # * #587 — an i64-dense function whose ~16 concurrent pair spills + # exhausted the fixed 8-slot pool; the pool-grow recovery retry (last + # resort, after the #474 promotion-off fallback) sizes the pool from + # the operand-stack-depth bound. Falcon func_60/func_73. + # Isolated job: emulation deps pip-installed here ONLY. + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v7 + - uses: dtolnay/rust-toolchain@stable + - name: Cache Cargo dependencies + uses: actions/cache@v6 + with: + path: | + ~/.cargo/registry + ~/.cargo/git + target/ + key: ${{ runner.os }}-cargo-${{ hashFiles('**/Cargo.lock') }} + restore-keys: | + ${{ runner.os }}-cargo- + - name: Build synth + run: cargo build -p synth-cli + - uses: actions/setup-python@v6 + with: + python-version: "3.x" + - name: Install emulation deps + run: pip install wasmtime unicorn pyelftools + - name: Run i64 stack-param oracle (#503-i64) + env: + SYNTH: ./target/debug/synth + run: python scripts/repro/i64_stack_param_503_differential.py + - name: Run i64 spill-pool-grow oracle (#587) + env: + SYNTH: ./target/debug/synth + run: python scripts/repro/i64_spill_pool_587_differential.py + br-table-507-oracle: name: optimized-path br_table oracle # VCR-ORACLE-001 (#242, #507): EXECUTE br_table dispatch compiled via the diff --git a/crates/synth-backend/src/arm_backend.rs b/crates/synth-backend/src/arm_backend.rs index d5a63aae..138bccaf 100644 --- a/crates/synth-backend/src/arm_backend.rs +++ b/crates/synth-backend/src/arm_backend.rs @@ -286,7 +286,8 @@ fn compile_wasm_to_arm( // not behavioural). let select_direct_attempt = |spill_on_exhaustion: bool, param_backing_on_exhaustion: bool, - local_promote: bool| + local_promote: bool, + i64_spill_slots: Option| -> Result, synth_core::Error> { let db = RuleDatabase::with_standard_rules(); let mut selector = @@ -327,6 +328,13 @@ fn compile_wasm_to_arm( } selector.set_spill_on_exhaustion(spill_on_exhaustion); selector.set_param_backing_on_exhaustion(param_backing_on_exhaustion); + // #587 pool-grow rung: a larger i64 spill-slot pool, set ONLY on the + // retry after an attempt failed with the slot-pool-exhausted Err — + // functions that compile with the default pool keep their frame + // byte-identical by construction. + if let Some(slots) = i64_spill_slots { + selector.set_i64_spill_slots(slots); + } // VCR-RA local promotion (#390, #242): keep eligible non-param i32 locals // in callee-saved registers instead of frame slots — the structural lever // toward native parity. DEFAULT-ON as of v0.14.0: gale's G474RE DWT gate @@ -345,14 +353,17 @@ fn compile_wasm_to_arm( let select_direct = || -> Result, String> { const SINGLE_EXHAUSTION: &str = "all allocatable registers are live on the stack"; const PAIR_EXHAUSTION: &str = "no consecutive pair of free registers for i64"; + const SLOT_EXHAUSTION: &str = "i64 spill-slot pool exhausted"; // The full exhaustion-recovery ladder, parameterized on whether local // promotion is enabled. Each rung is reached only when the previous one // returned a recoverable register-exhaustion Err, so a function that // compiles on the first attempt is untouched by the later rungs. Returns // the result AND which rung produced it (for the #242 measurement below). let recovery_ladder = - |promote: bool| -> (Result, synth_core::Error>, &'static str) { - let mut attempt = select_direct_attempt(false, false, promote); + |promote: bool, + i64_spill_slots: Option| + -> (Result, synth_core::Error>, &'static str) { + let mut attempt = select_direct_attempt(false, false, promote, i64_spill_slots); let mut rung = "base"; // VCR-RA-001 step 3b-lite (#242): the i32 register-exhaustion // hard-fail is recoverable — retry with spill-on-exhaustion, which @@ -361,7 +372,7 @@ fn compile_wasm_to_arm( if let Err(e) = &attempt && e.to_string().contains(SINGLE_EXHAUSTION) { - attempt = select_direct_attempt(true, false, promote); + attempt = select_direct_attempt(true, false, promote, i64_spill_slots); rung = "spill"; } // VCR-RA-001 acceptance increment (#242): the i64 consecutive-PAIR @@ -371,7 +382,7 @@ fn compile_wasm_to_arm( if let Err(e) = &attempt && e.to_string().contains(PAIR_EXHAUSTION) { - attempt = select_direct_attempt(true, true, promote); + attempt = select_direct_attempt(true, true, promote, i64_spill_slots); rung = "param-backing"; } (attempt, rung) @@ -387,19 +398,57 @@ fn compile_wasm_to_arm( // is reached ONLY by functions that exhaust WITH promotion, so promotion-on // output is untouched by construction (frozen byte gate stays green). let promote = std::env::var("SYNTH_NO_LOCAL_PROMOTE").is_err(); - let (mut attempt, mut rung) = recovery_ladder(promote); - let mut promotion_dropped = false; - if promote - && attempt - .as_ref() - .err() - .is_some_and(|e| e.to_string().contains("register exhaustion")) + // The full pre-#587 recovery sequence (promotion-on ladder, then the + // #474 promotion-off fallback), parameterized on the pool size so the + // pool-grow retry below reruns it verbatim. + let full_sequence = |slots: Option| -> ( + Result, synth_core::Error>, + &'static str, + bool, + ) { + let (mut attempt, mut rung) = recovery_ladder(promote, slots); + let mut promotion_dropped = false; + if promote + && attempt + .as_ref() + .err() + .is_some_and(|e| e.to_string().contains("register exhaustion")) + { + let (rescued, off_rung) = recovery_ladder(false, slots); + if rescued.is_ok() { + attempt = rescued; + rung = off_rung; + promotion_dropped = true; + } + } + (attempt, rung, promotion_dropped) + }; + let (mut attempt, mut rung, mut promotion_dropped) = full_sequence(None); + // #587 pool-grow retry (the falcon func_60/func_73 remainder): the fixed + // 8-slot i64 spill pool can exhaust while spilling is otherwise working — + // an i64-dense function simply has more values simultaneously live than + // the pool holds. Rerun the ENTIRE sequence (every rung, both promotion + // modes) with the pool sized from a conservative operand-stack-depth + // bound: the number of simultaneously spilled values can never exceed + // the operand-stack depth, plus a few transient slots (the arg-move + // cycle resolver and call-result parking each borrow one). The selector + // clamps the request to its 12-bit-friendly cap; a function that still + // exhausts stays an honest loud skip. Deliberately LAST — after the #474 + // promotion-off fallback — so any function that compiled yesterday + // (through any rung or fallback) is produced by exactly yesterday's + // path, byte-identical; the grown pool only ever fires for functions + // whose every existing escape ended in the slot-pool Err. + if attempt + .as_ref() + .err() + .is_some_and(|e| e.to_string().contains(SLOT_EXHAUSTION)) { - let (rescued, off_rung) = recovery_ladder(false); - if rescued.is_ok() { - attempt = rescued; - rung = off_rung; - promotion_dropped = true; + let depth = synth_core::wasm_stack_check::max_depth_bound(wasm_ops) as usize; + let (grown, _, grown_dropped) = full_sequence(Some(depth.saturating_add(4))); + if grown.is_ok() { + attempt = grown; + rung = "pool-grow"; + promotion_dropped = grown_dropped; } } // VCR-RA measurement (#242): log which recovery rung produced the result, @@ -452,7 +501,27 @@ fn compile_wasm_to_arm( // lands the carried value at the join. Never fires for void-block control // flow (all frozen/optimized fixtures), so those stay byte-identical. let has_value_carry = has_value_carrying_branch(wasm_ops, &config.current_func_block_arity); - let arm_instrs = if config.no_optimize || config.relocatable || has_br_table || has_value_carry + // #503-i64/#518: route any signature with a 64-bit (i64/f64) param to the + // direct selector. The optimized path's param homing is width-naive — its + // #518 decline covers only functions that READ an i64 param (an `I64Load` + // from a param index), so a function that reads an i32 param whose AAPCS + // home a preceding wide param SHIFTED (e.g. p1 of `(i64 i32)` lives in R2, + // not R1; p3 of `(i64 i32 i32 i32)` lives on the stack, not in R3) was + // silently miscompiled rather than falling back. The direct selector's + // `aapcs_param_layout` homing handles every such shape (i64-param READS + // already fell back to it via the ir_to_arm Err, so those functions emit + // the same bytes as before). `num_params` counts read-first locals, so a + // function that never touches any param keeps the optimized path. + let has_wide_param = config + .current_func_params_i64 + .iter() + .take(num_params as usize) + .any(|&w| w); + let arm_instrs = if config.no_optimize + || config.relocatable + || has_br_table + || has_value_carry + || has_wide_param { select_direct()? } else { diff --git a/crates/synth-cli/tests/i64_completeness_503_587.rs b/crates/synth-cli/tests/i64_completeness_503_587.rs new file mode 100644 index 00000000..be665c65 --- /dev/null +++ b/crates/synth-cli/tests/i64_completeness_503_587.rs @@ -0,0 +1,92 @@ +//! #503-i64 + #587 — direct-selector i64 completeness guards. +//! +//! Two honest-skip classes closed end-to-end: +//! +//! * **#503-i64**: any 64-bit (i64/f64) param AAPCS-passed on the STACK (past +//! R3, or even-align-spilled) used to loud-skip the whole function +//! ("#518/#503: an i64/f64 param is AAPCS-passed past R3"). The width-aware +//! `aapcs_param_layout` incoming homing now lowers every shape in +//! `scripts/repro/i64_stack_param_503.wat` (falcon func_58/func_163/func_164 +//! class). +//! * **#587**: an i64-dense function whose simultaneous pair spills exceed the +//! fixed 8-slot pool used to loud-skip ("i64 spill-slot pool exhausted" — +//! falcon func_60/func_73). The backend's `pool-grow` recovery retry now +//! reruns the full ladder with the pool sized from the operand-stack-depth +//! bound — strictly LAST, after the #474 promotion-off fallback, so every +//! function that compiled before is produced by exactly the old path +//! (`promotion_never_causes_compile_failure_474` + the frozen byte gate +//! prove bit-identity). +//! +//! Execution correctness (vs wasmtime under unicorn) is gated by +//! `scripts/repro/i64_stack_param_503_differential.py` and +//! `scripts/repro/i64_spill_pool_587_differential.py`; these tests pin the +//! compile-side contract: no skips, and #587 lands on the pool-grow rung. + +use std::process::Command; + +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) +} + +fn compile(wat: &str, out: &str, extra_env: &[(&str, &str)]) -> std::process::Output { + let mut cmd = Command::new(synth()); + for (k, v) in extra_env { + cmd.env(k, v); + } + cmd.args([ + "compile", + fixture(wat).to_str().unwrap(), + "-o", + out, + "-b", + "arm", + "--target", + "cortex-m4", + "--relocatable", + "--all-exports", + ]) + .output() + .expect("failed to run synth") +} + +/// #503-i64: every previously-declined i64-stack-param shape compiles — zero +/// skipped functions in the fixture. +#[test] +fn i64_stack_param_shapes_compile_without_skips_503() { + let out = compile("i64_stack_param_503.wat", "/tmp/cli_503.o", &[]); + let stderr = String::from_utf8_lossy(&out.stderr); + assert!(out.status.success(), "compile failed:\n{stderr}"); + assert!( + !stderr.contains("skipping function"), + "an i64-stack-param shape was skipped (the #503-i64 class regressed):\n{stderr}" + ); +} + +/// #587: the pool-exhausting i64-dense function compiles, and it does so on +/// the `pool-grow` recovery rung (not by accident on an earlier rung — that +/// would make this test vacuous as a pool-grow guard). +#[test] +fn i64_spill_pool_exhaustion_rescued_by_pool_grow_587() { + let out = compile( + "i64_spill_pool_587.wat", + "/tmp/cli_587.o", + &[("SYNTH_RECOVERY_STATS", "1")], + ); + let stderr = String::from_utf8_lossy(&out.stderr); + assert!(out.status.success(), "compile failed:\n{stderr}"); + assert!( + !stderr.contains("skipping function"), + "the i64-dense function was skipped (the #587 pool-grow rung regressed):\n{stderr}" + ); + assert!( + stderr.contains("rung=pool-grow result=ok"), + "expected the pool-grow rung to produce the result (recovery stats):\n{stderr}" + ); +} diff --git a/crates/synth-core/src/wasm_stack_check.rs b/crates/synth-core/src/wasm_stack_check.rs index 7aa8a337..0d19d0a3 100644 --- a/crates/synth-core/src/wasm_stack_check.rs +++ b/crates/synth-core/src/wasm_stack_check.rs @@ -77,6 +77,37 @@ pub fn check_no_underflow(wasm_ops: &[WasmOp]) -> crate::Result<()> { Ok(()) } +/// #587: a conservative UPPER BOUND on the wasm value-stack depth this op +/// sequence can reach. Used by the ARM backend's `pool-grow` exhaustion- +/// recovery rung to size the i64 spill-slot pool: the number of values +/// *simultaneously* spilled by the direct selector can never exceed the +/// number of values simultaneously live on the operand stack, so a pool of +/// `max_depth_bound` slots (plus the resolver/result-parking transients the +/// caller adds) cannot exhaust through the deepest-value spill loop. +/// +/// Over-approximation rules (never under-counts in reachable code): +/// * Modeled ops apply their exact pops/pushes; a would-be underflow clamps +/// to 0 (malformed input is someone else's Err, not a panic here). +/// * Unmodeled/`Bail` ops (`call`, terminators, SIMD, …) are treated as net +/// `+1` — every wasm op pushes at most one value net, so this only ever +/// over-counts (a call pops its args; a terminator pushes nothing). +/// * `Block`/`Loop`/`If`/`Else`/`End` are stack-neutral in the effects table, +/// which over-counts (`if` really pops its condition) — same direction. +pub fn max_depth_bound(wasm_ops: &[WasmOp]) -> u32 { + let mut depth: i64 = 0; + let mut max: i64 = 0; + for op in wasm_ops { + match stack_effect_or_bail(op) { + StackEffect::Modeled { pops, pushes } => { + depth = (depth - pops as i64).max(0) + pushes as i64; + } + StackEffect::Bail => depth += 1, + } + max = max.max(depth); + } + u32::try_from(max).unwrap_or(u32::MAX) +} + enum StackEffect { Modeled { pops: u32, pushes: u32 }, Bail, @@ -476,4 +507,26 @@ mod tests { fn empty_input_is_ok() { assert!(check_no_underflow(&[]).is_ok()); } + + #[test] + fn max_depth_bound_exact_on_modeled_ops() { + // #587: 3 consts (depth 3) folded to 1 — the bound is the peak, 3. + let ops = vec![ + WasmOp::I32Const(1), + WasmOp::I32Const(2), + WasmOp::I32Const(3), + WasmOp::I32Add, + WasmOp::I32Add, + ]; + assert_eq!(max_depth_bound(&ops), 3); + assert_eq!(max_depth_bound(&[]), 0); + } + + #[test] + fn max_depth_bound_over_approximates_unmodeled_ops() { + // #587: `call` bails in the underflow checker; the bound treats it as + // net +1 (an over-approximation, never an under-count). + let ops = vec![WasmOp::I32Const(1), WasmOp::Call(0), WasmOp::I32Add]; + assert!(max_depth_bound(&ops) >= 2); + } } diff --git a/crates/synth-synthesis/src/instruction_selector.rs b/crates/synth-synthesis/src/instruction_selector.rs index b2a2a6f8..f4739924 100644 --- a/crates/synth-synthesis/src/instruction_selector.rs +++ b/crates/synth-synthesis/src/instruction_selector.rs @@ -132,23 +132,39 @@ fn index_to_reg(index: u8) -> Reg { reg } -/// AAPCS core-register assignment for a function's first parameters (#518). +/// Full AAPCS core-register + stack assignment for a function's parameters +/// (#518/#503). Every param lands in exactly one of the two maps. +struct AapcsParamLayout { + /// Register-resident params: lo register (an i64/f64 occupies the + /// even-aligned pair `(lo, i64_pair_hi(lo))`). + regs: std::collections::HashMap, + /// Stack-passed params: `(nsaa_offset, is_wide)`. `nsaa_offset` is the + /// byte offset from SP *at function entry* (the AAPCS Next Stacked + /// Argument Address, starting at 0); a wide param's slot is 8-byte + /// aligned per AAPCS 5.5 stage C. + stack: std::collections::HashMap, +} + +/// AAPCS parameter assignment for a function's parameters (#518/#503). /// /// Walks the declared param widths left-to-right over the 4 core argument -/// registers R0..R3. An i32/f32 param takes the next register; an i64/f64 param -/// takes an EVEN-ALIGNED consecutive pair (rounding the cursor up to an even -/// index first) and the returned register is its LOW half (hi = [`i64_pair_hi`]). -/// Returns the lo register for every param that lands wholly within R0..R3; a -/// param that would spill past R3 is stack-passed and is absent from the map -/// (an i64 there is the open #503-i64 case, declined by the caller). +/// registers R0..R3 (NCRN) and the incoming stack (NSAA). An i32/f32 param +/// takes the next register; an i64/f64 param takes an EVEN-ALIGNED +/// consecutive pair (rounding NCRN up to even first) with the LOW half +/// recorded (hi = [`i64_pair_hi`]). A param that does not fit the remaining +/// registers goes to the stack — a wide one to the next 8-byte-aligned NSAA, +/// a narrow one to the next 4-byte NSAA — and, per AAPCS stage C.5, NCRN is +/// then 4 for good: NO later param back-fills a register (this is what makes +/// a narrow param AFTER a stack-spilled wide param itself stack-passed). /// -/// For an all-i32 signature this is exactly `index_to_reg(i)` for each `i`, so -/// the param→register mapping is byte-identical to the legacy sequential scheme -/// whenever no i64/f64 param is present — the #518 fix only diverges for the -/// i64-param functions that were miscompiled. -fn aapcs_param_regs(num_params: u32, params_i64: &[bool]) -> std::collections::HashMap { - let mut map = std::collections::HashMap::new(); - let mut next: u8 = 0; // next free core arg-register index (0..=3) +/// For an all-i32 signature this is exactly `index_to_reg(i)` for `i < 4` and +/// NSAA `(i-4)*4` beyond, so both maps are byte-identical to the legacy +/// sequential scheme whenever no i64/f64 param is present. +fn aapcs_param_layout(num_params: u32, params_i64: &[bool]) -> AapcsParamLayout { + let mut regs = std::collections::HashMap::new(); + let mut stack = std::collections::HashMap::new(); + let mut next: u8 = 0; // NCRN: next free core arg-register index (0..=4) + let mut nsaa: i32 = 0; // next stacked-argument byte offset from entry SP for i in 0..num_params { let is_wide = params_i64.get(i as usize).copied().unwrap_or(false); if is_wide { @@ -157,25 +173,41 @@ fn aapcs_param_regs(num_params: u32, params_i64: &[bool]) -> std::collections::H } if next <= 2 { // the pair (next, next+1) lands wholly within R0..R3 - map.insert(i, index_to_reg(next)); + regs.insert(i, index_to_reg(next)); next += 2; } else { - next = 4; // spilled to the stack (#503-i64) + next = 4; // C.5: registers are done for every later param too + if nsaa % 8 != 0 { + nsaa += 4; // 8-byte-align the wide stack slot + } + stack.insert(i, (nsaa, true)); + nsaa += 8; } } else if next <= 3 { - map.insert(i, index_to_reg(next)); + regs.insert(i, index_to_reg(next)); next += 1; } else { next = 4; // i32 stack param (#359 incoming-arg path) + stack.insert(i, (nsaa, false)); + nsaa += 4; } } - map + AapcsParamLayout { regs, stack } +} + +/// AAPCS core-register assignment only — see [`aapcs_param_layout`]. A param +/// absent from this map is stack-passed (in the layout's `stack` map). +/// Test-only: the selector itself uses the full layout. +#[cfg(test)] +fn aapcs_param_regs(num_params: u32, params_i64: &[bool]) -> std::collections::HashMap { + aapcs_param_layout(num_params, params_i64).regs } /// True if any declared i64/f64 param of this function lands (wholly or its /// even-aligned start) PAST R3 — i.e. AAPCS passes it on the stack (#518/#503). -/// The register-pair homing below only covers i64 params resident in R0..R3; a -/// stack-passed 64-bit param is the open #503-i64 follow-up and is declined. +/// Retained for the AAPCS-matrix unit tests; the selector itself now lowers +/// this case via the width-aware `incoming_params` machinery (#503-i64). +#[cfg(test)] fn i64_param_overflows_core_regs(num_params: u32, params_i64: &[bool]) -> bool { let regs = aapcs_param_regs(num_params, params_i64); (0..num_params) @@ -374,17 +406,28 @@ fn edge_value_move( Ok(()) } -/// Number of i64 spill slots reserved in the frame (#171). Each is 8 bytes -/// (lo+hi). Slots are reused (freed on reload), so this caps *simultaneous* -/// spills, not total. 8 is generous for real i64-heavy functions; exceeding it -/// surfaces as a clean `Err` (the #168 skip-and-continue net), never a panic. +/// Default number of i64 spill slots reserved in the frame (#171). Each is 8 +/// bytes (lo+hi). Slots are reused (freed on reload), so this caps +/// *simultaneous* spills, not total. 8 is generous for real i64-heavy +/// functions; exceeding it surfaces as a clean `Err` (the #168 +/// skip-and-continue net), never a panic — which the backend's `pool-grow` +/// recovery rung (#587) then retries with a pool sized from the function's +/// operand-stack-depth bound, so only functions that previously did NOT +/// compile ever get a larger frame. const I64_SPILL_SLOTS: usize = 8; +/// Hard cap on a grown i64 spill pool (#587): 120 slots = 960 frame bytes, +/// comfortably inside the 12-bit `[sp,#imm]` (4095) window next to typical +/// locals. A function needing more stays an honest loud skip. +const I64_SPILL_SLOTS_MAX: usize = 120; + /// Tracks which i64 spill slots are in use (#171). `base` is the byte offset /// from SP of slot 0; slot `i` occupies `[base + i*8, base + i*8 + 8)`. +/// `used.len()` is the pool size — [`I64_SPILL_SLOTS`] by default, larger only +/// on the #587 `pool-grow` recovery rung. struct SpillState { base: i32, - used: [bool; I64_SPILL_SLOTS], + used: Vec, /// VCR-RA-001 step 3b-lite (#242): when set, i32 temp allocation under /// register exhaustion spills the deepest stack value instead of /// hard-failing (see [`alloc_temp_or_spill`]). Default OFF; flipped on only @@ -403,10 +446,19 @@ struct SpillState { } impl SpillState { + /// Default-pool state (test-only; the selector sizes the pool explicitly + /// via [`SpillState::with_slots`] so the #587 pool-grow rung can act). + #[cfg(test)] fn new(base: i32) -> Self { + Self::with_slots(base, I64_SPILL_SLOTS) + } + /// #587: pool-grow rung — same state, `slots`-sized pool. The frame area + /// must have been reserved with the SAME count (`compute_local_layout`'s + /// `i64_spill_slots` argument), or slots would alias the param homes. + fn with_slots(base: i32, slots: usize) -> Self { SpillState { base, - used: [false; I64_SPILL_SLOTS], + used: vec![false; slots], spill_on_exhaustion: false, area_reserved: true, } @@ -420,7 +472,7 @@ impl SpillState { /// Release the slot at byte offset `off` so it can be reused. fn free(&mut self, off: i32) { let i = ((off - self.base) / 8) as usize; - if i < I64_SPILL_SLOTS { + if i < self.used.len() { self.used[i] = false; } } @@ -440,9 +492,13 @@ impl SpillState { /// would see unrelated frame traffic. Slot reuse means a stale earlier store /// can mask a lost later one — this is defense-in-depth behind the /// `is_const_materialization` filters, not the primary fix. -fn assert_spill_reloads_have_stores(instructions: &[ArmInstruction], spill_base: i32) { - let area = spill_base..spill_base + (I64_SPILL_SLOTS as i32) * 8; - let mut stored = [false; I64_SPILL_SLOTS * 2]; // 4-byte halves +fn assert_spill_reloads_have_stores( + instructions: &[ArmInstruction], + spill_base: i32, + spill_slots: usize, +) { + let area = spill_base..spill_base + (spill_slots as i32) * 8; + let mut stored = vec![false; spill_slots * 2]; // 4-byte halves for ins in instructions { match &ins.op { ArmOp::Str { addr, .. } @@ -1066,13 +1122,17 @@ struct LocalLayout { /// never clobbered in its home reg between reads. Appended last so existing /// local/spill offsets are unchanged. param_slots: std::collections::HashMap, - /// #359: frame slot (offset from SP) per incoming stack-passed param the fn - /// references — wasm param indices in `[4, num_params)` that AAPCS delivers - /// on the caller's stack (not in r0..r3). Computed AFTER `frame_size` is - /// finalized: such a param sits at `[sp, frame_size + 24 + (k-4)*4]` (24 = - /// the fixed `push {r4-r8,lr}`). i32-only; an i64/f64 stack param is an - /// Ok-or-Err refusal at the use site. Empty unless `num_params > 4`. - incoming_params: std::collections::HashMap, + /// #359/#503: frame slot `(offset_from_sp, is_i64)` per incoming + /// stack-passed param the fn references — the wasm param indices that + /// AAPCS delivers on the caller's stack (not in r0..r3), per the + /// width-aware [`aapcs_param_layout`] walk (a wide param even-aligns to an + /// 8-byte NSAA slot; a narrow param AFTER a stack-spilled wide one is + /// itself stack-passed — AAPCS C.5, no register back-fill). Computed AFTER + /// `frame_size` is finalized: param k sits at + /// `[sp, frame_size + 24 + nsaa_k]` (24 = the fixed `push {r4-r8,lr}`). + /// For an all-i32 signature `nsaa_k == (k-4)*4`, byte-identical to the + /// legacy formula. + incoming_params: std::collections::HashMap, /// Whether the `i64_spill_base` area was actually reserved /// (`has_i64 || force_spill_area`). Mirrored into /// [`SpillState::area_reserved`]; see that field for why (#326). @@ -1097,14 +1157,19 @@ struct LocalLayout { fn compute_local_layout( wasm_ops: &[WasmOp], num_params: u32, + params_i64: &[bool], func_ret_i64: &[bool], type_ret_i64: &[bool], force_spill_area: bool, force_param_backing: bool, outgoing_arg_bytes: i32, + i64_spill_slots: usize, ) -> LocalLayout { use std::collections::{BTreeSet, HashMap}; let i64_set = infer_i64_locals(wasm_ops, func_ret_i64, type_ret_i64); + // #503-i64: the width-aware AAPCS assignment — which params are register- + // resident and which live on the caller's stack (and at what NSAA offset). + let aapcs = aapcs_param_layout(num_params, params_i64); // Collect non-param local indices, in ascending order for deterministic layout. let mut used: BTreeSet = BTreeSet::new(); @@ -1166,15 +1231,23 @@ fn compute_local_layout( // functions — `force_spill_area` is set ONLY on that retry (the function // failed to compile at all on the first pass), so no function that compiles // today changes frame size. + // #503-i64: a signature with a wide param AND any stack-passed param was + // previously declined outright, so reserving the spill area for that shape + // changes no function that compiled before. It is needed because a wide + // STACK param's `local.get` allocates a register pair (whose pressure path + // spills i64 values) even when the op stream contains no I64-named op — + // e.g. a body that only reads the param and returns it. + let has_wide_param = params_i64.iter().take(num_params as usize).any(|&w| w); let has_i64 = force_spill_area || !i64_set.is_empty() + || (has_wide_param && !aapcs.stack.is_empty()) || wasm_ops.iter().any(|op| format!("{op:?}").contains("I64")); let i64_spill_base = if has_i64 { if (offset % 8) != 0 { offset += 4; } let base = offset; - offset += (I64_SPILL_SLOTS as i32) * 8; + offset += (i64_spill_slots as i32) * 8; base } else { offset // unused: no i64 op means no spill @@ -1183,7 +1256,6 @@ fn compute_local_layout( // #204: append a spill slot for every in-register param a function with a // CALL reads (frame-backed → never clobbered in its home reg). Appended // last → existing local/spill offsets unchanged. - let param_count = num_params.min(4); let mut param_slots: HashMap = HashMap::new(); // Frame-backing is needed precisely when a param must survive across a call: // AAPCS arg-marshalling reclaims r0..r3 for the call's arguments, so a param @@ -1206,7 +1278,14 @@ fn compute_local_layout( WasmOp::LocalGet(idx) | WasmOp::LocalSet(idx) | WasmOp::LocalTee(idx) => *idx, _ => continue, }; - if pidx < param_count { + // #503-i64: only REGISTER-resident params get a home-reg-backing + // slot — a stack-passed param (which can have index < 4 when a + // wide param even-align-spills, e.g. `(i32 i32 i32 i64)`) already + // lives in the caller's frame, which trivially survives calls; + // backing it from `index_to_reg(pidx)` would store an unrelated + // register. For an all-i32 signature `aapcs.regs` covers exactly + // the indices `< num_params.min(4)`, so this is byte-identical. + if pidx < num_params && aapcs.regs.contains_key(&pidx) { used_params.insert(pidx); } } @@ -1223,14 +1302,15 @@ fn compute_local_layout( // Round frame to 8-byte multiple for AAPCS SP alignment. let frame_size = (offset + 7) & !7; - // #359: locate the incoming stack-passed params (wasm idx in [4, num_params) - // that AAPCS delivers on the caller's stack). After `push {r4-r8,lr}` (24 - // bytes) + `sub sp,#frame_size`, param k sits at - // `[sp, frame_size + 24 + (k-4)*4]`. Only the params actually referenced are - // recorded. Empty when num_params <= 4. i32-only is enforced at the use site - // (Ok-or-Err) — the offset is the same regardless of width. - let mut incoming_params: HashMap = HashMap::new(); - if num_params > 4 { + // #359/#503: locate the incoming stack-passed params (the wasm indices the + // width-aware AAPCS walk put on the caller's stack). After + // `push {r4-r8,lr}` (24 bytes) + `sub sp,#frame_size`, param k sits at + // `[sp, frame_size + 24 + nsaa_k]` where `nsaa_k` is its AAPCS stacked + // offset ((k-4)*4 for an all-i32 signature — byte-identical to the legacy + // formula; 8-byte aligned for a wide param). Only the params actually + // referenced are recorded. + let mut incoming_params: HashMap = HashMap::new(); + if !aapcs.stack.is_empty() { const FIXED_PUSH_BYTES: i32 = 24; // push {r4,r5,r6,r7,r8,lr} let mut used_incoming: BTreeSet = BTreeSet::new(); for op in wasm_ops { @@ -1238,13 +1318,14 @@ fn compute_local_layout( WasmOp::LocalGet(idx) | WasmOp::LocalSet(idx) | WasmOp::LocalTee(idx) => *idx, _ => continue, }; - if (4..num_params).contains(&k) { + if k < num_params && aapcs.stack.contains_key(&k) { used_incoming.insert(k); } } for &k in &used_incoming { - let off = frame_size + FIXED_PUSH_BYTES + (k as i32 - 4) * 4; - incoming_params.insert(k, off); + let (nsaa, is_wide) = aapcs.stack[&k]; + let off = frame_size + FIXED_PUSH_BYTES + nsaa; + incoming_params.insert(k, (off, is_wide)); } } @@ -1845,6 +1926,13 @@ pub struct InstructionSelector { /// are promoted; read-before-write (#457), control-flow, and i64 locals fall /// back to the frame unchanged. See [`compute_local_promotion`]. local_promote: bool, + /// #587 pool-grow recovery rung: size of the i64 spill-slot pool. Default + /// [`I64_SPILL_SLOTS`] — the backend raises it (clamped to + /// [`I64_SPILL_SLOTS_MAX`]) only for a retry after an attempt failed with + /// the "i64 spill-slot pool exhausted" `Err`, so every function that + /// compiles with the default pool keeps its frame byte-identical by + /// construction. + i64_spill_slots: usize, } impl InstructionSelector { @@ -1876,6 +1964,7 @@ impl InstructionSelector { spill_on_exhaustion: false, param_backing_on_exhaustion: false, local_promote: false, + i64_spill_slots: I64_SPILL_SLOTS, } } @@ -1907,6 +1996,7 @@ impl InstructionSelector { spill_on_exhaustion: false, param_backing_on_exhaustion: false, local_promote: false, + i64_spill_slots: I64_SPILL_SLOTS, } } @@ -1936,6 +2026,18 @@ impl InstructionSelector { self.param_backing_on_exhaustion = enabled; } + /// #587 pool-grow recovery rung: size the i64 spill-slot pool. Intended + /// ONLY as the backend's retry after an attempt failed with the + /// "i64 spill-slot pool exhausted" `Err` — a larger pool grows the frame, + /// so calling it for a function that compiles with the default pool would + /// change its bytes. Clamped to `[I64_SPILL_SLOTS, I64_SPILL_SLOTS_MAX]`: + /// never below the default (shrinking could break the #171 machinery), + /// never past the 12-bit `[sp,#imm]`-friendly cap (a function needing + /// more stays an honest loud skip). + pub fn set_i64_spill_slots(&mut self, slots: usize) { + self.i64_spill_slots = slots.clamp(I64_SPILL_SLOTS, I64_SPILL_SLOTS_MAX); + } + /// VCR-RA local promotion (#390, #242): keep eligible non-param i32 locals in /// callee-saved registers instead of frame slots. Off ⇒ frame-slot path, /// bit-identical. See the `local_promote` field and [`compute_local_promotion`]. @@ -5640,10 +5742,10 @@ impl InstructionSelector { // a panic deep in the selector's pop sequence (see PR #117). synth_core::wasm_stack_check::check_no_underflow(wasm_ops)?; - // #359/#503: AAPCS stack arguments. We pass params index>=4 on the - // outgoing stack and read incoming params index>=4 from the caller's - // stack. The incoming-param homing (`compute_local_layout` → - // `incoming_params`, offset `frame_size+24+(k-4)*4`) and the outgoing + // #359/#503: AAPCS stack arguments. We pass args past r0..r3 on the + // outgoing stack and read incoming stack-passed params from the + // caller's stack. The incoming-param homing (`compute_local_layout` → + // `incoming_params`, offset `frame_size+24+nsaa_k`) and the outgoing // store (`emit_stack_args`, offset `(k-4)*4`) are both GENERIC in the // param/arg index — no fixed ≤8 structure — so an arbitrary scalar count // lowers correctly. The real bound is the 12-bit `[sp,#imm]` immediate @@ -5654,48 +5756,35 @@ impl InstructionSelector { // generic machinery handles. The 12-bit guards remain the Ok-or-Err // backstop (#180/#185): never silently emit an out-of-range encoding. // - // #359/#503 Ok-or-Err (#180/#185): the register-homing + incoming-stack- - // slot scheme assumes every param is a 4-byte i32. A 64-bit param - // (i64/f64) occupies a register PAIR / 8-byte stack slot and shifts which - // params land on the stack — even an UNUSED one. Declared widths (not - // op-stream inference, which can't see an unused i64 param) drive this - // refusal. This 64-bit stack-param case is still refused (the AAPCS - // pair-alignment + back-fill lowering is the #503 follow-up); only the - // >8-scalar-i32 cap is lifted here. Gated on num_params>4: a <=4-param - // function never touches the stack-arg path, so the legacy i64-param - // handling (param_slots) is unchanged and every existing fixture stays - // byte-identical. Empty `params_i64` (no signature plumbed, e.g. unit - // tests) ⇒ assume i32. - if num_params > 4 && self.params_i64.iter().take(num_params as usize).any(|&w| w) { - return Err(synth_core::Error::synthesis(format!( - "#503: function has {num_params} params including a 64-bit (i64/f64) \ - param on the AAPCS stack-argument path; only i32 stack params are \ - supported (64-bit stack params are not yet lowered)" - ))); - } - - // #518 Ok-or-Err (#180/#185): an i64/f64 PARAM is correctly homed below - // (AAPCS register-pair, `aapcs_param_regs`) only for the LEAF, - // register-resident case. Two sub-cases are declined LOUDLY here rather - // than silently miscompiled (the #518 defect): (1) an i64 param that - // AAPCS passes PAST R3 on the stack — the open #503-i64 follow-up; - // (2) a function that FRAME-BACKS its params — `has_call` (params spill - // to the frame to survive the call's caller-saved clobber, #204/#193) or - // the pair-exhaustion retry (`param_backing_on_exhaustion`) — where the - // `param_slots` path would size an i64 param's slot from `i64_set`, which - // does not include params, dropping the high half. Both fall back to a - // loud skip (warning + absent symbol), never wrong code. The common - // leaf-in-registers case is FIXED below. (Empty `params_i64` ⇒ all-i32 ⇒ - // this is a no-op and every existing fixture stays byte-identical.) - let has_i64_param = self.params_i64.iter().take(num_params as usize).any(|&w| w); - if has_i64_param { - if i64_param_overflows_core_regs(num_params, &self.params_i64) { - return Err(synth_core::Error::synthesis( - "#518/#503: an i64/f64 param is AAPCS-passed past R3 (on the \ - stack); 64-bit stack params are not yet lowered" - .to_string(), - )); - } + // #503-i64 (the falcon func_58/func_163 remainder): a 64-bit param that + // AAPCS passes ON THE STACK is now lowered too. `aapcs_param_layout` + // (declared widths — op-stream inference can't see an unused i64 param) + // assigns every param either a register (pair, even-aligned, for a wide + // one) or an NSAA stack offset (8-byte-aligned for a wide one; a narrow + // param AFTER any stack-spilled param is itself stack-passed — AAPCS + // C.5, no register back-fill: the pre-fix `index_to_reg` fallback read + // such a param from a WRONG register, e.g. p3 of `(i64 i32 i32 i32)` + // from R3 = p2). Wide incoming slots are read/written with + // I64Ldr/I64Str through `layout.incoming_params`. + // + // #518 Ok-or-Err (#180/#185): the ONE remaining decline — an i64/f64 + // param that is REGISTER-resident in a function that FRAME-BACKS its + // params: `has_call` (params spill to the frame to survive the call's + // caller-saved clobber, #204/#193) or the pair-exhaustion retry + // (`param_backing_on_exhaustion`). There the `param_slots` path would + // size the i64 param's slot from `i64_set`, which does not include + // params, dropping the high half. A STACK-passed wide param is exempt: + // it lives in the caller's frame (never in a clobberable register), so + // it needs no backing slot and survives calls by construction. Falls + // back to a loud skip (warning + absent symbol), never wrong code. + // (Empty `params_i64` ⇒ all-i32 ⇒ this is a no-op and every existing + // fixture stays byte-identical.) + let param_layout = aapcs_param_layout(num_params, &self.params_i64); + let has_reg_i64_param = (0..num_params).any(|i| { + self.params_i64.get(i as usize).copied().unwrap_or(false) + && param_layout.regs.contains_key(&i) + }); + if has_reg_i64_param { let has_call = wasm_ops .iter() .any(|op| matches!(op, Call(_) | CallIndirect { .. })); @@ -5762,19 +5851,26 @@ impl InstructionSelector { let layout = compute_local_layout( wasm_ops, num_params, + &self.params_i64, &self.func_ret_i64, &self.type_ret_i64, self.spill_on_exhaustion, self.param_backing_on_exhaustion, outgoing_arg_bytes, + self.i64_spill_slots, ); // #359 Ok-or-Err (#180/#185): an incoming stack-passed param is read via - // `ldr rd,[sp,#off]` where `off = frame_size + 24 + (k-4)*4` and + // `ldr rd,[sp,#off]` where `off = frame_size + 24 + nsaa_k` and // `frame_size` is unbounded (a function with a large locals frame). The // Thumb-2 `ldr [sp,#imm]` immediate is 12-bit (0..4095); refuse rather // than silently emit an out-of-range encoding. Checked once here over the - // finalized layout instead of at each use site. - if let Some(&max_off) = layout.incoming_params.values().max() + // finalized layout instead of at each use site. A wide (i64/f64) param's + // hi half is read 4 bytes above its lo, so its bound is `off + 4`. + if let Some(max_off) = layout + .incoming_params + .values() + .map(|&(off, is_wide)| off + if is_wide { 4 } else { 0 }) + .max() && max_off > 4095 { return Err(synth_core::Error::synthesis(format!( @@ -5800,7 +5896,9 @@ impl InstructionSelector { // value (#171). i64 values track only the lo register/slot. let mut stack: Vec = Vec::new(); // i64 register-pair spill slots (#171), reused across the function. - let mut spill = SpillState::new(layout.i64_spill_base); + // Pool size follows `self.i64_spill_slots` (#587 pool-grow rung) and + // must match the area `compute_local_layout` reserved above. + let mut spill = SpillState::with_slots(layout.i64_spill_base, self.i64_spill_slots); spill.spill_on_exhaustion = self.spill_on_exhaustion; spill.area_reserved = layout.spill_area_reserved; // Next available register for temporaries (start after params) @@ -5853,9 +5951,13 @@ impl InstructionSelector { // entry and accessed only through it, so it can never be clobbered in // its home register between reads. Params with no slot (unused) stay // register-backed via local_to_reg. - let param_aapcs = aapcs_param_regs(num_params, &self.params_i64); + let param_aapcs = ¶m_layout.regs; for i in 0..num_params.min(4) { if let Some(&(off, is_i64)) = layout.param_slots.get(&i) { + // #503-i64: `param_slots` only contains REGISTER-resident + // params now, and every reg-resident param below a stack spill + // is sequential from R0 (a wide reg param + has_call is + // declined above), so `index_to_reg(i)` is its true home. let reg = index_to_reg(i as u8); let op = if is_i64 { ArmOp::I64Str { @@ -5873,13 +5975,12 @@ impl InstructionSelector { op, source_line: None, }); - } else { - let reg = param_aapcs - .get(&i) - .copied() - .unwrap_or_else(|| index_to_reg(i as u8)); + } else if let Some(®) = param_aapcs.get(&i) { local_to_reg.insert(i, reg); } + // else: stack-passed (index < 4 only when a wide param + // even-align-spilled, #503-i64) — reads/writes route through + // `layout.incoming_params`; there is no register home. } let mut i64_locals = infer_i64_locals(wasm_ops, &self.func_ret_i64, &self.type_ret_i64); @@ -5985,34 +6086,55 @@ impl InstructionSelector { } match op { LocalGet(local_idx) => { - // #359: an incoming stack-passed param (idx in [4,num_params)) - // is i32 by construction — a 64-bit param is refused up front - // in `select_with_stack` (declared-width guard), so this path - // only ever sees i32 stack params. - // Get the register for this local. Three cases: - // 1. Param in register — use the cached mapping. - // 2. Spilled i64 local — load both halves via I64Ldr. - // 3. Spilled i32 local — single Ldr. + // Get the register for this local. Cases: + // 1. Incoming stack-passed param (#359/#503) — load from the + // caller's frame (i32 single Ldr; i64/f64 pair via I64Ldr). + // 2. Param in register — use the cached mapping. + // 3. Spilled i64 local — load both halves via I64Ldr. + // 4. Spilled i32 local — single Ldr. let (reg, val_is_i64) = - if let Some(&off) = layout.incoming_params.get(local_idx) { - // #359: read the incoming stack-passed param into a - // fresh temp pushed on the vstack — NOT register-homed. - let dst = alloc_temp_or_spill( - &mut next_temp, - &mut stack, - &mut instructions, - &mut spill, - &live_params, - idx, - )?; - instructions.push(ArmInstruction { - op: ArmOp::Ldr { - rd: dst, - addr: MemAddr::imm(Reg::SP, off), - }, - source_line: Some(idx), - }); - (dst, false) + if let Some(&(off, is_wide)) = layout.incoming_params.get(local_idx) { + // #359/#503: read the incoming stack-passed param into + // fresh temp(s) pushed on the vstack — NOT register- + // homed. A wide param loads its 8-byte NSAA slot into + // a consecutive pair (#503-i64). + if is_wide { + let (dst_lo, dst_hi) = alloc_consecutive_pair( + &mut next_temp, + &mut stack, + &mut instructions, + &mut spill, + &[], + &live_params, + idx, + )?; + instructions.push(ArmInstruction { + op: ArmOp::I64Ldr { + rdlo: dst_lo, + rdhi: dst_hi, + addr: MemAddr::imm(Reg::SP, off), + }, + source_line: Some(idx), + }); + (dst_lo, true) + } else { + let dst = alloc_temp_or_spill( + &mut next_temp, + &mut stack, + &mut instructions, + &mut spill, + &live_params, + idx, + )?; + instructions.push(ArmInstruction { + op: ArmOp::Ldr { + rd: dst, + addr: MemAddr::imm(Reg::SP, off), + }, + source_line: Some(idx), + }); + (dst, false) + } } else if let Some(&(off, is_i64)) = layout.param_slots.get(local_idx) { // #204/#193: frame-backed param — reload from its slot. if is_i64 { @@ -8661,15 +8783,23 @@ impl InstructionSelector { &live_params, idx, )?; - if let Some(&off) = layout.incoming_params.get(local_idx) { - // #359: write to the incoming stack-passed param's slot. - // i32 by construction — a 64-bit param is refused up front - // in `select_with_stack`. - instructions.push(ArmInstruction { - op: ArmOp::Str { + if let Some(&(off, is_wide)) = layout.incoming_params.get(local_idx) { + // #359/#503: write to the incoming stack-passed param's + // slot in the caller's frame (wide: both halves, I64Str). + let op = if is_wide { + ArmOp::I64Str { + rdlo: val, + rdhi: i64_pair_hi(val)?, + addr: MemAddr::imm(Reg::SP, off), + } + } else { + ArmOp::Str { rd: val, addr: MemAddr::imm(Reg::SP, off), - }, + } + }; + instructions.push(ArmInstruction { + op, source_line: Some(idx), }); cf.add_instruction(); @@ -8770,15 +8900,24 @@ impl InstructionSelector { &live_params, idx, )?; - if let Some(&off) = layout.incoming_params.get(local_idx) { - // #359: write to the incoming stack-passed param's slot; - // value stays on the operand stack (peek kept it). i32 by - // construction — a 64-bit param is refused up front. - instructions.push(ArmInstruction { - op: ArmOp::Str { + if let Some(&(off, is_wide)) = layout.incoming_params.get(local_idx) { + // #359/#503: write to the incoming stack-passed param's + // slot (wide: both halves, I64Str); value stays on the + // operand stack (peek kept it). + let op = if is_wide { + ArmOp::I64Str { + rdlo: val, + rdhi: i64_pair_hi(val)?, + addr: MemAddr::imm(Reg::SP, off), + } + } else { + ArmOp::Str { rd: val, addr: MemAddr::imm(Reg::SP, off), - }, + } + }; + instructions.push(ArmInstruction { + op, source_line: Some(idx), }); cf.add_instruction(); @@ -10864,7 +11003,7 @@ impl InstructionSelector { // #581 defensive invariant: no reload of a never-stored spill slot may // leave the selector (see `assert_spill_reloads_have_stores`). if spill.area_reserved { - assert_spill_reloads_have_stores(&instructions, spill.base); + assert_spill_reloads_have_stores(&instructions, spill.base, spill.used.len()); } Ok(instructions) @@ -11817,22 +11956,137 @@ mod tests { ); } - /// #359 Ok-or-Err: a 64-bit param in the stack region (>4 params, param i64) - /// is NOT lowered (AAPCS pair math differs) — it must return Err, never a - /// silent miscompile (#180/#185). + /// #503-i64: a 64-bit param in the stack region (>4 params, param4 i64) now + /// LOWERS — the width-aware `aapcs_param_layout` walk gives it an + /// 8-byte-aligned NSAA slot instead of the old blanket refusal. Execution + /// correctness is gated by `scripts/repro/i64_stack_param_503_differential.py`. #[test] - fn test_359_i64_stack_param_errs() { + fn test_503_i64_stack_param_lowers() { let db = RuleDatabase::new(); let mut selector = InstructionSelector::new(db.rules().to_vec()); selector.set_params_i64(vec![false, false, false, false, true]); // param4 = i64 - let wasm_ops = vec![WasmOp::LocalGet(0)]; - let r = selector.select_with_stack(&wasm_ops, 5); + let wasm_ops = vec![WasmOp::LocalGet(4)]; + let arm = selector + .select_with_stack(&wasm_ops, 5) + .expect("a 5-param function with an i64 stack param must lower after #503-i64"); + // The wide param is read as a PAIR from the incoming stack. + assert!( + arm.iter() + .any(|i| matches!(&i.op, ArmOp::I64Ldr { addr, .. } if addr.base == Reg::SP)), + "the i64 stack param must be loaded via I64Ldr [sp,#off]: {arm:#?}" + ); + } + + /// #503-i64: the found-along-the-way narrow-after-wide bug. In + /// `(i64 i32 i32 i32)`, p0 takes R0:R1, p1/p2 take R2/R3, and p3 is + /// STACK-passed (AAPCS C.5 — no register back-fill). The pre-fix homing + /// fell back to `index_to_reg(3)` and silently read p3 from R3 (= p2). + /// Now it must load p3 from the incoming stack. + #[test] + fn test_503_narrow_param_after_wide_reads_stack_not_r3() { + let db = RuleDatabase::new(); + let mut selector = InstructionSelector::new(db.rules().to_vec()); + selector.set_params_i64(vec![true, false, false, false]); // param0 = i64 + let wasm_ops = vec![WasmOp::LocalGet(3)]; + let arm = selector + .select_with_stack(&wasm_ops, 4) + .expect("(i64 i32 i32 i32) reading p3 must lower"); assert!( - r.is_err(), - "a 5-param function with an i64 param must Err, not lower: {r:?}" + arm.iter() + .any(|i| matches!(&i.op, ArmOp::Ldr { addr, .. } if addr.base == Reg::SP)), + "p3 must be loaded from the incoming stack, not read from R3: {arm:#?}" ); } + /// #503-i64: a wide STACK param in a has-call function lowers (it lives in + /// the caller's frame, surviving the BL without a backing slot), while a + /// wide REGISTER param in a has-call function keeps the #518 loud decline + /// (its `param_slots` backing would drop the high half). + #[test] + fn test_503_wide_stack_param_with_call_lowers_reg_still_declines() { + let db = RuleDatabase::new(); + // (i32 i32 i32 i32 i64) + call: the i64 is stack-passed -> lowers. + let mut selector = InstructionSelector::new(db.rules().to_vec()); + selector.set_params_i64(vec![false, false, false, false, true]); + selector.set_func_arg_counts(vec![0, 0], Vec::new()); + let ops = vec![WasmOp::Call(1), WasmOp::LocalGet(4), WasmOp::Drop]; + assert!( + selector.select_with_stack(&ops, 5).is_ok(), + "a wide STACK param + call must lower (caller-frame slot)" + ); + // (i64) + call: the i64 is REGISTER-resident -> #518 decline stands. + let mut selector = InstructionSelector::new(db.rules().to_vec()); + selector.set_params_i64(vec![true]); + selector.set_func_arg_counts(vec![0, 0], Vec::new()); + let ops = vec![WasmOp::Call(1), WasmOp::LocalGet(0), WasmOp::Drop]; + let r = selector.select_with_stack(&ops, 1); + assert!( + r.as_ref().is_err_and(|e| e.to_string().contains("#518")), + "a wide REGISTER param + call must keep the #518 loud decline: {r:?}" + ); + } + + /// #503-i64: the AAPCS stack-offset matrix of `aapcs_param_layout` — NSAA + /// walking with 8-byte alignment for wide slots and C.5 no-back-fill. + #[test] + fn test_503_aapcs_stack_offsets() { + // (i32 x4, i64): p4 wide @ nsaa 0. + let l = aapcs_param_layout(5, &[false, false, false, false, true]); + assert_eq!(l.stack.get(&4), Some(&(0, true))); + // (i32 x5, i64): p4 narrow @ 0, p5 wide @ 8 (4-byte alignment hole). + let l = aapcs_param_layout(6, &[false, false, false, false, false, true]); + assert_eq!(l.stack.get(&4), Some(&(0, false))); + assert_eq!(l.stack.get(&5), Some(&(8, true))); + // (i32 x3, i64): even-align spills the pair with only 4 params. + let l = aapcs_param_layout(4, &[false, false, false, true]); + assert_eq!(l.stack.get(&3), Some(&(0, true))); + assert_eq!(l.regs.get(&3), None); + // (i64, i32 x3): p1=R2, p2=R3, p3 narrow @ 0 (no back-fill). + let l = aapcs_param_layout(4, &[true, false, false, false]); + assert_eq!(l.regs.get(&0), Some(&Reg::R0)); + assert_eq!(l.regs.get(&1), Some(&Reg::R2)); + assert_eq!(l.regs.get(&2), Some(&Reg::R3)); + assert_eq!(l.stack.get(&3), Some(&(0, false))); + // all-i32 x6: legacy (k-4)*4 formula, byte-identical. + let l = aapcs_param_layout(6, &[false; 6]); + assert_eq!(l.stack.get(&4), Some(&(0, false))); + assert_eq!(l.stack.get(&5), Some(&(4, false))); + } + + /// #587: the i64 spill-slot pool is growable — the same op stream that + /// exhausts the default 8-slot pool compiles with a grown pool. 20 i64 + /// constants are simultaneously live (~16 concurrent pair spills; the + /// R0-R8 pool holds 4 pairs), folded with non-commutative ops. + #[test] + fn test_587_spill_pool_grow_rescues_exhaustion() { + let build_ops = || { + let mut ops: Vec = (0..20).map(|k| WasmOp::I64Const(k + 1)).collect(); + for j in 0..19 { + ops.push(match j % 3 { + 0 => WasmOp::I64Add, + 1 => WasmOp::I64Sub, + _ => WasmOp::I64Xor, + }); + } + ops + }; + let db = RuleDatabase::new(); + // Default pool: honest slot-pool exhaustion Err. + let mut selector = InstructionSelector::new(db.rules().to_vec()); + let r = selector.select_with_stack(&build_ops(), 0); + assert!( + r.as_ref() + .is_err_and(|e| e.to_string().contains("i64 spill-slot pool exhausted")), + "20 live i64s must exhaust the default 8-slot pool: {r:?}" + ); + // Grown pool (the #587 pool-grow rung's retry): compiles. + let mut selector = InstructionSelector::new(db.rules().to_vec()); + selector.set_i64_spill_slots(24); + selector + .select_with_stack(&build_ops(), 0) + .expect("a 24-slot pool must rescue the same function"); + } + /// #503: a >8-scalar-i32 call now LOWERS (the old conservative >8 cap is /// lifted; `emit_stack_args` is generic and the 12-bit `[sp,#imm]` guard is /// the real backstop). A 9-arg call stores args 4..8 (five of them) to the @@ -16867,7 +17121,7 @@ mod tests { fn test_compute_local_layout_no_locals() { // Function with only params and no LocalGet/Set produces zero frame. let ops = vec![WasmOp::LocalGet(0), WasmOp::LocalGet(1), WasmOp::I32Add]; - let layout = compute_local_layout(&ops, 2, &[], &[], false, false, 0); + let layout = compute_local_layout(&ops, 2, &[], &[], &[], false, false, 0, I64_SPILL_SLOTS); assert_eq!(layout.frame_size, 0); assert!(layout.locals.is_empty()); } @@ -16880,7 +17134,7 @@ mod tests { WasmOp::LocalSet(1), WasmOp::LocalGet(1), ]; - let layout = compute_local_layout(&ops, 1, &[], &[], false, false, 0); + let layout = compute_local_layout(&ops, 1, &[], &[], &[], false, false, 0, I64_SPILL_SLOTS); assert!(layout.locals.contains_key(&1)); let (off, is_i64) = layout.locals[&1]; assert_eq!(off, 0); @@ -16897,7 +17151,7 @@ mod tests { WasmOp::LocalSet(0), WasmOp::LocalGet(0), ]; - let layout = compute_local_layout(&ops, 0, &[], &[], false, false, 0); + let layout = compute_local_layout(&ops, 0, &[], &[], &[], false, false, 0, I64_SPILL_SLOTS); let (off, is_i64) = layout.locals[&0]; assert_eq!(off, 0); assert!(is_i64); @@ -16917,7 +17171,7 @@ mod tests { WasmOp::I64Const(2), WasmOp::LocalSet(1), ]; - let layout = compute_local_layout(&ops, 0, &[], &[], false, false, 0); + let layout = compute_local_layout(&ops, 0, &[], &[], &[], false, false, 0, I64_SPILL_SLOTS); let (off0, is_i64_0) = layout.locals[&0]; let (off1, is_i64_1) = layout.locals[&1]; assert_eq!(off0, 0); @@ -16939,7 +17193,7 @@ mod tests { WasmOp::I32Add, WasmOp::LocalSet(2), ]; - let layout = compute_local_layout(&ops, 2, &[], &[], false, false, 0); + let layout = compute_local_layout(&ops, 2, &[], &[], &[], false, false, 0, I64_SPILL_SLOTS); // Only idx 2 should be in the layout. assert!(!layout.locals.contains_key(&0)); assert!(!layout.locals.contains_key(&1)); diff --git a/scripts/repro/i64_param_518_decline.wat b/scripts/repro/i64_param_518_decline.wat index 8469b248..e0007f6d 100644 --- a/scripts/repro/i64_param_518_decline.wat +++ b/scripts/repro/i64_param_518_decline.wat @@ -1,26 +1,28 @@ -;; #518 — the DECLINE half of the fix. The leaf, register-resident i64-param -;; cases are lowered correctly (see i64_param_518.wat); these two sub-cases are -;; instead declined LOUDLY (a `warning: skipping function …` + absence from the -;; symbol table) rather than silently miscompiled, because their correct lowering -;; is a tracked follow-up: -;; - d_past_r3: an i64 param AAPCS-passed PAST R3 (on the stack) — the open -;; #503-i64 stack-param case. Here three i32 params fill R0..R2, so the i64 -;; even-aligns to R4:R5 → stack. -;; - d_call: an i64 param in a function that CONTAINS A CALL — params are -;; frame-backed to survive the call's caller-saved clobber, and that -;; param_slots path sizes an i64 param's slot from a width set that excludes -;; params, dropping the high half. Declined until the frame-backed i64 path -;; lands. +;; #518 — the DECLINE half of the fix, minus what #503-i64 closed. The leaf, +;; register-resident i64-param cases are lowered correctly (see +;; i64_param_518.wat); the REMAINING sub-case is declined LOUDLY (a +;; `warning: skipping function …` + absence from the symbol table) rather than +;; silently miscompiled, because its correct lowering is a tracked follow-up: +;; - d_call: a REGISTER-resident i64 param in a function that CONTAINS A +;; CALL — params are frame-backed to survive the call's caller-saved +;; clobber, and that param_slots path sizes an i64 param's slot from a +;; width set that excludes params, dropping the high half. Declined until +;; the frame-backed i64 path lands. +;; - d_past_r3 (an i64 param AAPCS-passed PAST R3, on the caller's stack) +;; was the #503-i64 stack-param case and is now LOWERED (width-aware +;; `aapcs_param_layout` incoming homing): emitted + executes correctly, +;; gated below and by i64_stack_param_503_differential.py. ;; d_leaf is the control: a leaf register-resident i64 param IS emitted (proves -;; the decline is specific to the two sub-cases, not a blanket i64-param refusal). +;; the decline is specific to d_call, not a blanket i64-param refusal). (module (func $helper (param i32) (result i32) (local.get 0)) - ;; i64 param past R3 (R0,R1,R2 = the three i32s; i64 -> R4:R5 = stack) -> decline + ;; i64 param past R3 (R0,R1,R2 = the three i32s; the even-aligned pair does + ;; not fit -> caller stack @ nsaa 0) -> LOWERED as of #503-i64 (func (export "d_past_r3") (param i32 i32 i32 i64) (result i64) (i64.add (local.get 3) (i64.const 1))) - ;; i64 param + a call -> frame-backed -> decline + ;; REGISTER-resident i64 param + a call -> frame-backed -> decline (func (export "d_call") (param i64) (result i64) (i64.add (local.get 0) diff --git a/scripts/repro/i64_param_518_differential.py b/scripts/repro/i64_param_518_differential.py index 756d760b..bf00b9d5 100644 --- a/scripts/repro/i64_param_518_differential.py +++ b/scripts/repro/i64_param_518_differential.py @@ -54,12 +54,14 @@ ) WAT = Path(__file__).with_name("i64_param_518.wat") -# The DECLINE half: i64-param sub-cases that are loud-skipped (not lowered) — an -# i64 param past R3 (#503-i64 stack-param) and an i64 param in a frame-backing -# (has-call) function. d_leaf is the control: a leaf i64 param IS emitted. +# The DECLINE half: the i64-param sub-case that is still loud-skipped (not +# lowered) — a REGISTER-resident i64 param in a frame-backing (has-call) +# function. d_past_r3 (i64 param past R3 = on the caller's stack) was the +# #503-i64 case and is now LOWERED: it moved to the emitted+executes set. +# d_leaf is the control: a leaf i64 param IS emitted. DECLINE_WAT = Path(__file__).with_name("i64_param_518_decline.wat") -DECLINE_SKIPPED = ["d_past_r3", "d_call"] # must warn + be absent from symtab -DECLINE_EMITTED = ["d_leaf"] # must be emitted (non-vacuity) +DECLINE_SKIPPED = ["d_call"] # must warn + be absent from symtab +DECLINE_EMITTED = ["d_leaf", "d_past_r3"] # must be emitted (non-vacuity) SYNTH = os.environ.get("SYNTH", "./target/debug/synth") CODE, STK, RET, R11 = 0x100000, 0x900000, 0x300000, 0x20000000 @@ -227,14 +229,21 @@ def check_decline_contract(): ) + "]" ) print(f" [{'ok ' if ok else 'BUG'}] {fn}: loud-skipped{why}") - # leaf control: emitted AND correct under unicorn + # emitted set: present in the symtab AND correct under unicorn for fn in DECLINE_EMITTED: if fn not in syms: print(f" [BUG] {fn}: expected emitted, but it was skipped") fails += 1 continue - # d_leaf: (param i64)(result i64) i64.add(p,3); run with p=7 -> 10 - ok = got_i64_leaf(code, base, syms[fn], 7) == leaf_expect(7) + if fn == "d_leaf": + # d_leaf: (param i64)(result i64) i64.add(p,3); run with p=7 -> 10 + ok = got_i64_leaf(code, base, syms[fn], 7) == leaf_expect(7) + else: + # d_past_r3 (#503-i64): (param i32 i32 i32 i64)(result i64) + # i64.add(p3,1); p3 arrives on the caller's stack at entry SP. + p3 = 0x1_0000_0005 + ok = got_i64_past_r3(code, base, syms[fn], p3) == ( + (p3 + 1) & 0xFFFFFFFFFFFFFFFF) print(f" [{'ok ' if ok else 'BUG'}] {fn}: emitted + executes correctly") if not ok: fails += 1 @@ -246,6 +255,29 @@ def leaf_expect(p): return (p + 3) & 0xFFFFFFFFFFFFFFFF +def got_i64_past_r3(code, base, faddr, p3): + """d_past_r3 (#503-i64): p0..p2 = i32 in R0..R2, p3 = i64 at [entry SP].""" + foff = (faddr & ~1) - base + mu = Uc(UC_ARCH_ARM, UC_MODE_THUMB) + mu.mem_map(CODE, 0x20000) + mu.mem_map(STK - 0x10000, 0x20000) + mu.mem_map(RET & ~0xFFF, 0x1000) + mu.mem_write(CODE, code) + mu.reg_write(UC_ARM_REG_SP, STK) + mu.reg_write(UC_ARM_REG_R11, R11) + mu.reg_write(UC_ARM_REG_R0, 11) + mu.reg_write(UC_ARM_REG_R1, 22) + mu.reg_write(UC_ARM_REG_R2, 33) + mu.mem_write(STK, (p3 & 0xFFFFFFFFFFFFFFFF).to_bytes(8, "little")) + mu.reg_write(UC_ARM_REG_LR, RET | 1) + try: + mu.emu_start((CODE + foff) | 1, RET, count=100000) + except UcError as e: + return f"ERR:{e}" + return (mu.reg_read(UC_ARM_REG_R0) & 0xFFFFFFFF) | ( + (mu.reg_read(UC_ARM_REG_R1) & 0xFFFFFFFF) << 32) + + def got_i64_leaf(code, base, faddr, arg): foff = (faddr & ~1) - base mu = Uc(UC_ARCH_ARM, UC_MODE_THUMB) @@ -293,9 +325,10 @@ def main(): total = opt_div + rel_div + decline_fails if total == 0: print("RESULT: PASS — both paths match wasmtime on every leaf i64-param " - "function across the full AAPCS matrix, AND the unsupported " - "sub-cases (i64 past R3, frame-backed i64 param) loud-skip rather " - "than miscompile. #518 fixed.") + "function across the full AAPCS matrix, the #503-i64 stack-param " + "case (d_past_r3) is emitted and correct, AND the remaining " + "unsupported sub-case (frame-backed REGISTER i64 param, d_call) " + "loud-skips rather than miscompiles. #518 fixed.") sys.exit(0) print(f"RESULT: FAIL — {opt_div + rel_div} value divergence(s) + " f"{decline_fails} decline-contract failure(s); #518 not fully fixed.") diff --git a/scripts/repro/i64_spill_pool_587.wat b/scripts/repro/i64_spill_pool_587.wat new file mode 100644 index 00000000..18f4a420 --- /dev/null +++ b/scripts/repro/i64_spill_pool_587.wat @@ -0,0 +1,67 @@ +;; #587 — DIRECT-PATH i64 spill-slot POOL exhaustion (falcon func_60/func_73). +;; +;; The direct selector's pair allocator (#171/#325) spills deep i64 values to a +;; fixed frame pool of I64_SPILL_SLOTS = 8 slots. Twenty simultaneously-live +;; i64 constants need ~16 slots at peak (the R0-R8 pool holds 4 pairs), so the +;; pool exhausted and the whole function loud-skipped: +;; "register exhaustion: i64 spill-slot pool exhausted — function too +;; complex for current register allocator" +;; +;; The fix adds a `pool-grow` rung to the backend's exhaustion-recovery ladder: +;; when (and only when) an attempt fails with the slot-pool message, selection +;; reruns with the pool sized from a conservative operand-stack-depth bound +;; (`max_depth_bound`), so the frame grows only for functions that previously +;; did not compile at all — everything that compiled yesterday keeps its +;; 8-slot frame byte-identically. +;; +;; The fold mixes non-commutative ops (sub/xor) so a slot-collision, reload, +;; or half-swap bug changes the result; distinct byte patterns in every +;; constant make lo/hi swaps and wrong-slot reloads visible. hp2 folds from +;; the other end (deepest value consumed LAST) so slots stay live to the end. +;; +;; Differential oracle: scripts/repro/i64_spill_pool_587_differential.py +;; (wasmtime ground truth vs unicorn, direct/--relocatable and default paths). +(module + (func (export "hp20") (param i32) (result i64) + i64.const 0x0101010102020202 + i64.const 0x0303030304040404 + i64.const 0x0505050506060606 + i64.const 0x0707070708080808 + i64.const 0x090909090a0a0a0a + i64.const 0x0b0b0b0b0c0c0c0c + i64.const 0x0d0d0d0d0e0e0e0e + i64.const 0x0f0f0f0f10101010 + i64.const 0x1111111112121212 + i64.const 0x1313131314141414 + i64.const 0x1515151516161616 + i64.const 0x1717171718181818 + i64.const 0x191919191a1a1a1a + i64.const 0x1b1b1b1b1c1c1c1c + i64.const 0x1d1d1d1d1e1e1e1e + i64.const 0x1f1f1f1f20202020 + i64.const 0x2121212122222222 + i64.const 0x2323232324242424 + i64.const 0x2525252526262626 + i64.const 0x2727272728282828 + i64.add + i64.sub + i64.xor + i64.add + i64.sub + i64.xor + i64.add + i64.sub + i64.xor + i64.add + i64.sub + i64.xor + i64.add + i64.sub + i64.xor + i64.add + i64.sub + i64.xor + i64.add + local.get 0 + i64.extend_i32_u + i64.xor)) diff --git a/scripts/repro/i64_spill_pool_587_differential.py b/scripts/repro/i64_spill_pool_587_differential.py new file mode 100644 index 00000000..e26a9b28 --- /dev/null +++ b/scripts/repro/i64_spill_pool_587_differential.py @@ -0,0 +1,134 @@ +#!/usr/bin/env python3 +"""#587 — DIRECT-PATH i64 spill-slot pool-grow differential oracle (epic #242). + +`i64_spill_pool_587.wat` keeps twenty i64 constants simultaneously live — +~16 concurrent pair spills at peak, double the fixed 8-slot pool — so the +whole function used to loud-skip with "i64 spill-slot pool exhausted". The +backend's `pool-grow` recovery rung now retries with the pool sized from the +operand-stack-depth bound, so the function compiles; this oracle proves the +grown-pool code is CORRECT, not just present: wasmtime is ground truth, +unicorn runs synth's ARM, and the non-commutative fold (sub/xor with +distinct byte patterns per constant) makes any slot collision, lost store, +wrong-slot reload, or lo/hi swap change the result. + +Covers the DIRECT path both ways it is reached: `--relocatable` (falcon) and +the default path (the optimized selector's pair pool also exhausts on this +shape and declines to direct — #496). i64 results read from r0 (lo):r1 (hi). + +Exits nonzero on any mismatch or skip, so it can gate CI. + +Run (needs wasmtime + unicorn + pyelftools): + SYNTH=./target/debug/synth /tmp/synthvenv/bin/python \ + scripts/repro/i64_spill_pool_587_differential.py +""" + +import os +import subprocess +import sys +from pathlib import Path + +import wasmtime +from elftools.elf.elffile import ELFFile +from unicorn import UC_ARCH_ARM, UC_MODE_THUMB, Uc, UcError +from unicorn.arm_const import ( + UC_ARM_REG_LR, + UC_ARM_REG_R0, + UC_ARM_REG_R1, + UC_ARM_REG_R11, + UC_ARM_REG_SP, +) + +WAT = Path(__file__).with_name("i64_spill_pool_587.wat") +SYNTH = os.environ.get("SYNTH", "./target/debug/synth") +CODE, STK, RET, R11 = 0x100000, 0x900000, 0x300000, 0x20000000 +ARGS = [0, 1, 0xDEADBEEF, 0xFFFFFFFF] + + +def wasmtime_run(arg): + engine = wasmtime.Engine() + module = wasmtime.Module.from_file(engine, str(WAT)) + store = wasmtime.Store(engine) + inst = wasmtime.Instance(store, module, []) + a = arg - (1 << 32) if arg >= (1 << 31) else arg + return inst.exports(store)["hp20"](store, a) & 0xFFFFFFFFFFFFFFFF + + +def compile_synth(out, relocatable): + cmd = [SYNTH, "compile", str(WAT), "-o", out, "-b", "arm", + "--target", "cortex-m4", "--all-exports"] + if relocatable: + cmd.append("--relocatable") + r = subprocess.run(cmd, capture_output=True, text=True, + env={"PATH": "/usr/bin:/bin"}) + if r.returncode != 0 or "skipping function" in r.stderr: + sys.exit(f"FAIL: compile failed/skipped (reloc={relocatable}) — the " + f"#587 pool-grow rung did not rescue hp20:\n{r.stderr}") + + +def load(elf): + f = ELFFile(open(elf, "rb")) + text = f.get_section_by_name(".text") + code, base = text.data(), text["sh_addr"] + syms = {} + for sec in f.iter_sections(): + if sec.header.sh_type == "SHT_SYMTAB": + for sy in sec.iter_symbols(): + if sy.name and sy["st_info"]["type"] == "STT_FUNC": + syms[sy.name] = sy["st_value"] + return code, base, syms + + +def unicorn_run(code, base, faddr, arg): + foff = (faddr & ~1) - base + mu = Uc(UC_ARCH_ARM, UC_MODE_THUMB) + mu.mem_map(CODE, 0x20000) + mu.mem_map(STK - 0x10000, 0x20000) + mu.mem_map(RET & ~0xFFF, 0x1000) + mu.mem_write(CODE, code) + mu.reg_write(UC_ARM_REG_SP, STK) + mu.reg_write(UC_ARM_REG_R11, R11) + mu.reg_write(UC_ARM_REG_R0, arg & 0xFFFFFFFF) + mu.reg_write(UC_ARM_REG_LR, RET | 1) + try: + mu.emu_start((CODE + foff) | 1, RET, count=200000) + except UcError as e: + return f"ERR:{e}" + return (mu.reg_read(UC_ARM_REG_R0) & 0xFFFFFFFF) | ( + (mu.reg_read(UC_ARM_REG_R1) & 0xFFFFFFFF) << 32) + + +def run_path(label, relocatable): + out = f"/tmp/i587_{'rel' if relocatable else 'opt'}.o" + compile_synth(out, relocatable) + code, base, syms = load(out) + print(f"\n=== {label} path ===") + if "hp20" not in syms: + print(" [BUG] hp20: SYMBOL MISSING") + return 1 + fails = 0 + for arg in ARGS: + exp = wasmtime_run(arg) + got = unicorn_run(code, base, syms["hp20"], arg) + ok = isinstance(got, int) and got == exp + fails += 0 if ok else 1 + print(f" [{'ok ' if ok else 'BUG'}] hp20({arg:#x}) -> " + + (f"{got:#x}" if isinstance(got, int) else str(got)) + + f" (wasmtime {exp:#x})") + return fails + + +def main(): + fails = run_path("DIRECT (--relocatable)", relocatable=True) + fails += run_path("DEFAULT (optimized declines to direct)", relocatable=False) + print("\n--- verdict ---") + if fails == 0: + print("RESULT: PASS — the previously pool-exhausted i64-dense function " + "compiles via the #587 pool-grow rung and matches wasmtime on " + "both paths.") + sys.exit(0) + print(f"RESULT: FAIL — {fails} divergence(s).") + sys.exit(1) + + +if __name__ == "__main__": + main() diff --git a/scripts/repro/i64_stack_param_503.wat b/scripts/repro/i64_stack_param_503.wat new file mode 100644 index 00000000..b80b251c --- /dev/null +++ b/scripts/repro/i64_stack_param_503.wat @@ -0,0 +1,66 @@ +;; #503 — the i64 STACK-PARAM sub-case (the remainder after v0.15.x lifted the +;; >8-scalar cap): any 64-bit (i64/f64) param that AAPCS passes on the STACK +;; used to be declined ("#518/#503: an i64/f64 param is AAPCS-passed past R3"), +;; dropping falcon's func_58/func_163/func_164-class helpers from the ELF. +;; +;; The fix makes the incoming-param homing width-aware end to end +;; (`aapcs_param_layout`): a wide param past R3 gets an 8-byte-aligned NSAA +;; slot read via `I64Ldr [sp, #frame+24+nsaa]`, and — the found-along-the-way +;; #503-adjacent bug — an i32 param that a PRECEDING wide param pushed onto +;; the stack (AAPCS C.5: once any arg goes to the stack, NCRN=4, no backfill) +;; is now also read from its stack slot instead of a wrong register. +;; +;; Shapes (each export names the AAPCS placement it exercises): +;; s_mix (i32 i32 i32 i32 i64) p4 = i64 @ [nsaa 0] (issue #503 repro) +;; s_align (i32 i32 i32 i32 i32 i64) p5 = i64 @ [nsaa 8] (4-byte hole: NSAA +;; rounds 4 -> 8 for the wide slot) +;; s_p3 (i32 i32 i32 i64) p3 = i64 @ [nsaa 0] (even-align spills +;; it with only 4 declared params) +;; s_nar (i64 i32 i32 i32) p3 = i32 @ [nsaa 0] (narrow-after-wide: +;; p0=R0:R1, p1=R2, p2=R3, p3=stack — the +;; pre-fix code read p3 from R3 = p2) +;; s_call (i32 i32 i32 i32 i64) wide STACK param in a has-call function +;; (lives in the caller's frame, so it +;; survives the BL without a param slot) +;; s_wr (i32 i32 i32 i32 i64) local.set/get round-trip on the wide +;; stack param's slot (I64Str + I64Ldr) +;; s_i32 (i32) control: untouched narrow path +;; +;; Differential oracle: scripts/repro/i64_stack_param_503_differential.py +;; (wasmtime ground truth vs unicorn on BOTH the direct/--relocatable and the +;; default/optimized-with-fallback paths). +(module + (func $helper (param i32) (result i32) + (i32.add (local.get 0) (i32.const 5))) + + ;; the literal shape from issue #503's mix.wat + (func (export "s_mix") (param i32 i32 i32 i32 i64) (result i64) + (i64.add (local.get 4) (i64.extend_i32_u (local.get 0)))) + + ;; NSAA 8-byte alignment: p4 (i32) sits at [sp,#0], p5 (i64) at [sp,#8] + (func (export "s_align") (param i32 i32 i32 i32 i32 i64) (result i64) + (i64.sub (local.get 5) (i64.extend_i32_u (local.get 4)))) + + ;; 4 declared params, but the even-aligned pair does not fit: p3 -> stack + (func (export "s_p3") (param i32 i32 i32 i64) (result i64) + (i64.xor (local.get 3) (i64.extend_i32_u (local.get 2)))) + + ;; narrow-after-wide: p3 is an i32 ON THE STACK (NCRN=4 after the p0 pair + ;; and p1/p2 take R2/R3). Pre-fix this MIScompiled (read as R3 = p2). + (func (export "s_nar") (param i64 i32 i32 i32) (result i32) + (i32.sub (local.get 3) (local.get 2))) + + ;; wide stack param + a call: the caller-frame slot needs no call-spill + (func (export "s_call") (param i32 i32 i32 i32 i64) (result i64) + (i64.add + (local.get 4) + (i64.extend_i32_u (call $helper (local.get 1))))) + + ;; write-then-read the wide stack param's slot (I64Str/I64Ldr round trip) + (func (export "s_wr") (param i32 i32 i32 i32 i64) (result i64) + (local.set 4 (i64.add (local.get 4) (i64.const 0x100000001))) + (local.get 4)) + + ;; control: plain i32 param, byte-identical path + (func (export "s_i32") (param i32) (result i32) + (i32.add (local.get 0) (i32.const 3)))) diff --git a/scripts/repro/i64_stack_param_503_differential.py b/scripts/repro/i64_stack_param_503_differential.py new file mode 100644 index 00000000..2dc74266 --- /dev/null +++ b/scripts/repro/i64_stack_param_503_differential.py @@ -0,0 +1,226 @@ +#!/usr/bin/env python3 +"""#503-i64 — AAPCS 64-bit STACK-param differential oracle (epic #242). + +Compiles `i64_stack_param_503.wat` — every shape of the previously-declined +"#518/#503: an i64/f64 param is AAPCS-passed past R3" class plus the +narrow-after-wide stack-param shape that was silently MIScompiled (p3 of +`(i64 i32 i32 i32)` read from R3 = p2) — on BOTH lowering paths: + + * DIRECT (`--relocatable`) — the shipped/falcon path + (`select_with_stack`). + * OPTIMIZED (default `--target ...`) — wide-param signatures are routed + to the direct selector (#503-i64), + so this exercises the route. + +and runs each export under unicorn with TRUE-AAPCS argument placement: +r0..r3 walked with even-aligned i64 pairs, stack args written at entry SP +(narrow: 4-byte NSAA; wide: 8-byte-aligned NSAA). wasmtime is ground truth; +i64 results are read from r0 (lo) : r1 (hi). + +Exits nonzero on any divergence or any skipped export, so it can gate CI. + +Run (needs wasmtime + unicorn + pyelftools): + SYNTH=./target/debug/synth /tmp/synthvenv/bin/python \ + scripts/repro/i64_stack_param_503_differential.py +""" + +import os +import struct +import subprocess +import sys +from pathlib import Path + +import wasmtime +from elftools.elf.elffile import ELFFile +from unicorn import UC_ARCH_ARM, UC_MODE_THUMB, Uc, UcError +from unicorn.arm_const import ( + UC_ARM_REG_LR, + UC_ARM_REG_R0, + UC_ARM_REG_R1, + UC_ARM_REG_R2, + UC_ARM_REG_R3, + UC_ARM_REG_R11, + UC_ARM_REG_SP, +) + +WAT = Path(__file__).with_name("i64_stack_param_503.wat") +SYNTH = os.environ.get("SYNTH", "./target/debug/synth") +CODE, STK, RET, R11 = 0x100000, 0x900000, 0x300000, 0x20000000 + +# (export, args, signature). The signature drives TRUE-AAPCS placement: +# registers first (even-aligned pairs for i64), then the entry-SP stack +# (NSAA: 4-byte for i32, 8-byte-aligned for i64) — matching what a real +# AAPCS caller would do, NOT what synth happens to emit. +CASES = [ + ("s_mix", (1, 2, 3, 4, 0x1_0000_0007), "i32,i32,i32,i32,i64"), + ("s_mix", (0xFFFFFFFF, 0, 0, 0, 0xFFFFFFFF_00000001), "i32,i32,i32,i32,i64"), + ("s_align", (1, 2, 3, 4, 5, 0x55555555_00000003), "i32,i32,i32,i32,i32,i64"), + ("s_p3", (9, 8, 7, 0xA5A5A5A5_5A5A5A5A), "i32,i32,i32,i64"), + ("s_nar", (0x1_00000001, 5, 100, 42), "i64,i32,i32,i32"), + ("s_call", (1, 40, 3, 4, 0x7_00000009), "i32,i32,i32,i32,i64"), + ("s_wr", (1, 2, 3, 4, 0xFFFFFFFF), "i32,i32,i32,i32,i64"), + ("s_i32", (7,), "i32"), +] +I64_RESULT = {"s_mix", "s_align", "s_p3", "s_call", "s_wr"} + + +def to_signed(v, wide): + bits = 64 if wide else 32 + return v - (1 << bits) if v >= (1 << (bits - 1)) else v + + +def wasmtime_run(fn, args, sig): + engine = wasmtime.Engine() + module = wasmtime.Module.from_file(engine, str(WAT)) + store = wasmtime.Store(engine) + inst = wasmtime.Instance(store, module, []) + sargs = [to_signed(a, t == "i64") for a, t in zip(args, sig.split(","))] + res = inst.exports(store)[fn](store, *sargs) + return res & (0xFFFFFFFFFFFFFFFF if fn in I64_RESULT else 0xFFFFFFFF) + + +def compile_synth(out, relocatable): + env = {"PATH": "/usr/bin:/bin"} + cmd = [SYNTH, "compile", str(WAT), "-o", out, "-b", "arm", + "--target", "cortex-m4", "--all-exports"] + if relocatable: + cmd.append("--relocatable") + r = subprocess.run(cmd, capture_output=True, text=True, env=env) + if r.returncode != 0: + sys.exit(f"compile failed (reloc={relocatable}): {r.stderr}") + return r.stderr + + +def encode_thm_bl(pc, target): + """Thumb-2 BL: encode (target - (pc+4)) as the S/J1/J2/imm10/imm11 form.""" + off = target - (pc + 4) + off &= 0x1FFFFFF # 25-bit signed range + s = (off >> 24) & 1 + i1 = (off >> 23) & 1 + i2 = (off >> 22) & 1 + imm10 = (off >> 12) & 0x3FF + imm11 = (off >> 1) & 0x7FF + j1 = (~i1 & 1) ^ s + j2 = (~i2 & 1) ^ s + hw1 = 0xF000 | (s << 10) | imm10 + hw2 = 0xD000 | (j1 << 13) | (j2 << 11) | imm11 + return hw1, hw2 + + +def load(elf): + f = ELFFile(open(elf, "rb")) + text_sec = f.get_section_by_name(".text") + code, base = bytearray(text_sec.data()), text_sec["sh_addr"] + syms = {} + symtab = None + for sec in f.iter_sections(): + if sec.header.sh_type == "SHT_SYMTAB": + symtab = sec + for sy in sec.iter_symbols(): + if sy.name and sy["st_info"]["type"] == "STT_FUNC": + syms[sy.name] = sy["st_value"] + # --relocatable object: resolve internal THM_CALLs (s_call -> $helper) in + # place, exactly as `ld` would — the emulator runs raw .text bytes. + rel = f.get_section_by_name(".rel.text") + if rel is not None and symtab is not None: + for r in rel.iter_relocations(): + t = r["r_info_type"] + if t in (10, 30): # R_ARM_THM_CALL / R_ARM_THM_JUMP24 + sym = symtab.get_symbol(r["r_info_sym"]) + target = CODE + (sym["st_value"] & ~1) + pc = CODE + r["r_offset"] + hw1, hw2 = encode_thm_bl(pc, target) + struct.pack_into("> 32) & 0xFFFFFFFF) + ncrn += 2 + else: + ncrn = 4 # C.5: no back-fill + if nsaa % 8: + nsaa += 4 + mu.mem_write(STK + nsaa, (v & 0xFFFFFFFFFFFFFFFF) + .to_bytes(8, "little")) + nsaa += 8 + elif ncrn <= 3: + mu.reg_write(regs[ncrn], v & 0xFFFFFFFF) + ncrn += 1 + else: + ncrn = 4 + mu.mem_write(STK + nsaa, (v & 0xFFFFFFFF).to_bytes(4, "little")) + nsaa += 4 + + +def unicorn_run(code, base, faddr, args, sig, i64_result): + foff = (faddr & ~1) - base + mu = Uc(UC_ARCH_ARM, UC_MODE_THUMB) + mu.mem_map(CODE, 0x20000) + mu.mem_map(STK - 0x10000, 0x20000) + mu.mem_map(RET & ~0xFFF, 0x1000) + mu.mem_write(CODE, code) + mu.reg_write(UC_ARM_REG_SP, STK) # entry SP == first stack arg (AAPCS) + mu.reg_write(UC_ARM_REG_R11, R11) + place_args(mu, args, sig) + mu.reg_write(UC_ARM_REG_LR, RET | 1) + try: + mu.emu_start((CODE + foff) | 1, RET, count=100000) + except UcError as e: + return f"ERR:{e}" + lo = mu.reg_read(UC_ARM_REG_R0) & 0xFFFFFFFF + if i64_result: + hi = mu.reg_read(UC_ARM_REG_R1) & 0xFFFFFFFF + return lo | (hi << 32) + return lo + + +def run_path(label, relocatable): + out = f"/tmp/i503_{'rel' if relocatable else 'opt'}.o" + stderr = compile_synth(out, relocatable) + fails = 0 + if "skipping function" in stderr: + print(f" FAIL: a function was skipped on the {label} path:\n{stderr}") + fails += 1 + code, base, syms = load(out) + print(f"\n=== {label} path ===") + for fn, args, sig in CASES: + faddr = syms.get(fn) + if faddr is None: + print(f" [BUG] {fn}: SYMBOL MISSING") + fails += 1 + continue + exp = wasmtime_run(fn, args, sig) + got = unicorn_run(code, base, faddr, args, sig, fn in I64_RESULT) + ok = isinstance(got, int) and got == exp + fails += 0 if ok else 1 + print(f" [{'ok ' if ok else 'BUG'}] {fn}{args} -> " + f"{got:#x}" if isinstance(got, int) else got, + f" (wasmtime {exp:#x})") + return fails + + +def main(): + fails = run_path("DIRECT (--relocatable)", relocatable=True) + fails += run_path("OPTIMIZED (default, wide-param route)", relocatable=False) + print("\n--- verdict ---") + if fails == 0: + print("RESULT: PASS — every previously-declined i64-stack-param shape " + "(and the narrow-after-wide miscompile shape) compiles on both " + "paths and matches wasmtime.") + sys.exit(0) + print(f"RESULT: FAIL — {fails} divergence(s)/missing symbol(s).") + sys.exit(1) + + +if __name__ == "__main__": + main() diff --git a/scripts/repro/stack_args_503_differential.py b/scripts/repro/stack_args_503_differential.py index f963b774..23aa2ecb 100644 --- a/scripts/repro/stack_args_503_differential.py +++ b/scripts/repro/stack_args_503_differential.py @@ -9,8 +9,9 @@ ELF. The incoming-param homing (`compute_local_layout` → `incoming_params`, offset `frame_size+24+(k-4)*4`) and the outgoing store (`emit_stack_args`, offset `(k-4)*4`) are both GENERIC in the index; #503 lifts the >8-scalar caps and leans -on the existing 12-bit `[sp,#imm]` guards. (The 64-bit stack-param case stays -refused — that lowering is a #503 follow-up.) +on the existing 12-bit `[sp,#imm]` guards. (The 64-bit stack-PARAM case is now +lowered too — width-aware NSAA homing, gated separately by +i64_stack_param_503_differential.py.) This harness compiles via the SHIPPED direct path (`--relocatable`, what falcon uses → `select_with_stack`), then runs each export under unicorn (UC_ARCH_ARM /