diff --git a/crates/synth-backend/src/arm_backend.rs b/crates/synth-backend/src/arm_backend.rs index d0223ed9..2baa7e8c 100644 --- a/crates/synth-backend/src/arm_backend.rs +++ b/crates/synth-backend/src/arm_backend.rs @@ -370,6 +370,10 @@ fn compile_wasm_to_arm( { selector.set_native_pointer_stack(sp_idx, sp_init); } + // #643: per-global slot widths — i64/f64 globals occupy 8-byte slots + // (register-pair store/load) and shift every later global's offset. + // Empty for i32-only modules ⇒ the legacy `idx * 4` layout, unchanged. + selector.set_global_widths(config.global_widths.clone()); 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 @@ -572,11 +576,25 @@ fn compile_wasm_to_arm( // pattern). Never fires without SYNTH_FACT_SPEC + facts + a discharged // obligation, so every existing compile keeps its path byte-identical. let has_fact_div_elide = !fact_div_zero_elide.is_empty() || !fact_div_ovf_elide.is_empty(); + // #643: the optimized path's global lowering is width-naive — `GlobalGet`/ + // `GlobalSet` are single-word `[R9, idx*4]` accesses, which (a) silently + // dropped the high word of every i64 global and (b) mis-address every + // global whose offset an earlier wide (i64/f64) slot shifted. When the + // module has any wide global, route every global-touching function to the + // direct selector, whose type-aware summed layout pairs the access (or + // declines loudly). Modules with only 4-byte globals — every existing + // fixture — keep the optimized path byte-identical. + let has_wide_global_module = config.global_widths.iter().any(|&w| w > 4); + let has_global_access = has_wide_global_module + && wasm_ops + .iter() + .any(|op| matches!(op, WasmOp::GlobalGet(_) | WasmOp::GlobalSet(_))); let arm_instrs = if config.no_optimize || config.relocatable || has_br_table || has_value_carry || has_wide_param + || has_global_access || has_fact_div_elide // #457: route read-before-write non-param locals to the direct // selector, whose prologue zero-init lands the wasm-mandated 0. diff --git a/crates/synth-cli/src/main.rs b/crates/synth-cli/src/main.rs index f44be620..fc3c2211 100644 --- a/crates/synth-cli/src/main.rs +++ b/crates/synth-cli/src/main.rs @@ -1251,6 +1251,9 @@ fn compile_command( let mut current_func_param_count: Option = None; let mut func_ret_i64: Vec = Vec::new(); // #311: call-result pair tagging let mut type_ret_i64: Vec = Vec::new(); // #311: call_indirect results + // #643: per-global slot widths (8 for i64/f64) — type-aware globals-table + // layout + register-pair global accesses. Empty for the demo path. + let mut global_widths: Vec = Vec::new(); let mut current_func_block_arity: Vec<(u8, u8)> = Vec::new(); // #509: value-carrying branches // VCR-PERF-002 Phase 1 (#494): loom `wsc.facts` premises — whole-module // table + this function's slice. Threaded to the CompileConfig; NOT yet @@ -1289,6 +1292,15 @@ fn compile_command( .context("Failed to decode WASM module (signature tables)")?; func_ret_i64 = module.func_ret_i64; type_ret_i64 = module.type_ret_i64; + // #643: capture the declared global slot widths (indexed by + // global index; gaps default to the 4-byte legacy width). + for g in &module.globals { + let i = g.index as usize; + if global_widths.len() <= i { + global_widths.resize(i + 1, 4); + } + global_widths[i] = g.slot_bytes; + } let module_func_params_i64 = module.func_params_i64; let module_func_arg_counts = module.func_arg_counts; // VCR-PERF-002 Phase 1 (#494): whatever facts loom forwarded @@ -1447,6 +1459,8 @@ fn compile_command( current_func_param_count, func_ret_i64, type_ret_i64, + // #643: type-aware globals-table layout (8-byte i64/f64 slots). + global_widths, current_func_block_arity, // VCR-PERF-002 Phase 1 (#494): threaded but not yet consumed (inert // plumbing, like volatile_segments was in #543 Phase 1). Phase 2 reads @@ -2134,10 +2148,11 @@ fn compile_all_exports( all_data_segments, // #237: active data segments, for --native-pointer-abi stack_pointer_global_opt, // #237: (index, init) of the SP global, if any all_globals, // #237: every defined global (index, init) — slot region under --native-pointer-abi - all_func_ret_i64, // #311: per-function returns-i64 (pair tagging) - all_type_ret_i64, // #311: per-type returns-i64 (call_indirect) + all_global_widths, // #643: per-global slot widths (8 for i64/f64) — type-aware R9 table layout + all_func_ret_i64, // #311: per-function returns-i64 (pair tagging) + all_type_ret_i64, // #311: per-type returns-i64 (call_indirect) all_func_params_i64, // #359: per-function declared param widths (stack-arg ABI) - all_wsc_facts, // VCR-PERF-002 Phase 1 (#494): loom wsc.facts premises + all_wsc_facts, // VCR-PERF-002 Phase 1 (#494): loom wsc.facts premises ) = if path.extension().is_some_and(|ext| ext == "wast") { info!("Parsing WAST (extracting all modules)..."); let contents = String::from_utf8(file_bytes).context("WAST file is not valid UTF-8")?; @@ -2231,6 +2246,7 @@ fn compile_all_exports( Vec::new(), // #237: data segments not threaded for WAST (single-module .wasm path covers it) None, // #237: SP-global promotion is single-module .wasm only Vec::new(), // #237: globals slot region is single-module .wasm only + Vec::new(), // #643: WAST fixture suite is i32-only — legacy 4-byte global slots Vec::new(), // #311: WAST runs the fixture suite; i32-only Vec::new(), Vec::new(), // #359: WAST fixture suite is i32-only — no stack params @@ -2270,6 +2286,32 @@ fn compile_all_exports( .iter() .map(|g| (g.index, g.init_i32.unwrap_or(0))) .collect(); + // #643: per-global slot widths (4 = i32/f32, 8 = i64/f64, 16 = v128), + // indexed by global index — the selector lays the R9 globals table out + // by summing these, giving i64 globals room for both words and + // shifting every later global's offset accordingly. + let global_widths: Vec = { + let mut widths = Vec::new(); + for g in &module.globals { + let i = g.index as usize; + if widths.len() <= i { + widths.resize(i + 1, 4); + } + widths[i] = g.slot_bytes; + } + widths + }; + // #643: the native-pointer ABI materializes `__synth_globals` as + // 4-byte i32 slots (`idx * 4`); a wide (i64/f64/v128) global has no + // consistent slot there. Refuse up front — an honest error beats the + // silent high-word truncation this replaced. + if native_pointer_abi && global_widths.iter().any(|&w| w > 4) { + anyhow::bail!( + "--native-pointer-abi does not support i64/f64/v128 globals \ + (the `__synth_globals` slot region is 4-byte i32 slots) — \ + refusing to truncate them to 32 bits (#643)" + ); + } // #235: compile not just the exports but every internal (non-imported) // function reachable from them via `call`. A loom-dissolved export can // retain a non-inlinable callee (e.g. a panic helper from an overflow @@ -2295,6 +2337,7 @@ fn compile_all_exports( data_segs, sp_global, globals, + global_widths, module.func_ret_i64, module.type_ret_i64, module.func_params_i64, @@ -2368,6 +2411,9 @@ fn compile_all_exports( stack_pointer_global: stack_pointer_global_opt, func_ret_i64: all_func_ret_i64.clone(), type_ret_i64: all_type_ret_i64.clone(), + // #643: per-global slot widths — i64/f64 globals get 8-byte slots and + // register-pair accesses; later globals' offsets shift accordingly. + global_widths: all_global_widths.clone(), // #359: indexed declared param widths (per full function index) — the // source of truth for the AAPCS stack-argument refusal. The per-function // `current_func_params_i64` is derived from this in the compile loop. diff --git a/crates/synth-core/src/backend.rs b/crates/synth-core/src/backend.rs index ab2a4dd9..80eaae7d 100644 --- a/crates/synth-core/src/backend.rs +++ b/crates/synth-core/src/backend.rs @@ -140,6 +140,14 @@ pub struct CompileConfig { /// invisible to liveness. pub func_ret_i64: Vec, pub type_ret_i64: Vec, + /// #643: byte width of each defined global's storage slot, indexed by + /// global index — 4 for i32/f32, 8 for i64/f64, 16 for v128 (from the + /// module's global section). The globals table is laid out by SUMMING + /// these widths: an i64 global needs a register-PAIR store/load at + /// `[R9, off]`/`[R9, off+4]`, and every later global's offset shifts. + /// Empty ⇒ every global assumed 4 bytes (the legacy `idx * 4` layout; + /// hand-built op streams and i32-only modules are byte-identical). + pub global_widths: Vec, /// #359: declared parameter widths per *function* (full index, imports /// first): `func_params_i64[f][k]` is true when param `k` of function `f` is /// i64/f64. The AAPCS stack-argument path needs the *declared* widths @@ -308,6 +316,8 @@ impl Default for CompileConfig { stack_pointer_global: None, func_ret_i64: Vec::new(), type_ret_i64: Vec::new(), + // #643: empty ⇒ legacy all-4-byte global slots (i32-only modules). + global_widths: Vec::new(), func_params_i64: Vec::new(), current_func_params_i64: Vec::new(), // #457: None ⇒ declared signature unknown ⇒ param-count inference diff --git a/crates/synth-core/src/wasm_decoder.rs b/crates/synth-core/src/wasm_decoder.rs index b01aad49..e7a4ac60 100644 --- a/crates/synth-core/src/wasm_decoder.rs +++ b/crates/synth-core/src/wasm_decoder.rs @@ -60,6 +60,11 @@ pub struct WasmGlobal { pub init_i32: Option, /// Whether the global is mutable. pub mutable: bool, + /// #643: byte width of the global's storage slot, from its declared value + /// type — 4 for i32/f32, 8 for i64/f64, 16 for v128. The globals table is + /// laid out by SUMMING these widths (not `index * 4`): an i64 global needs + /// room for both words, and every later global's offset shifts with it. + pub slot_bytes: u32, } impl WasmMemory { @@ -305,10 +310,20 @@ pub fn decode_wasm_module(wasm_bytes: &[u8]) -> Result { if let Ok(wasmparser::Operator::I32Const { value }) = ops.read() { init_i32 = Some(value); } + // #643: record the slot width from the DECLARED value type. + // i64/f64 globals occupy 8 bytes (a register pair on the + // 32-bit targets), v128 sixteen; laying every global out at + // `index * 4` silently dropped the high word of every i64. + let slot_bytes = match global.ty.content_type { + wasmparser::ValType::I64 | wasmparser::ValType::F64 => 8, + wasmparser::ValType::V128 => 16, + _ => 4, + }; globals.push(WasmGlobal { index: idx as u32, init_i32, mutable: global.ty.mutable, + slot_bytes, }); } } @@ -1774,6 +1789,30 @@ mod tests { let c = &module.globals[1]; assert_eq!(c.init_i32, Some(7)); assert!(!c.mutable, "second global is immutable"); + assert_eq!(sp.slot_bytes, 4, "i32 global occupies one 4-byte slot"); + assert_eq!(c.slot_bytes, 4); + } + + /// #643: the decoder records the DECLARED slot width per global — an i64 + /// (or f64) global occupies 8 bytes, so the globals-table layout can give + /// it room for both words and shift every later global's offset. + #[test] + fn test_decode_records_global_slot_widths_643() { + let wat = r#" + (module + (global $c (mut i64) (i64.const 0)) + (global $k (mut i32) (i32.const 0)) + (global $f (mut f64) (f64.const 0)) + (func (export "f") (result i32) global.get 1) + ) + "#; + let wasm = wat::parse_str(wat).expect("Failed to parse WAT"); + let module = decode_wasm_module(&wasm).expect("Failed to decode"); + + assert_eq!(module.globals.len(), 3); + assert_eq!(module.globals[0].slot_bytes, 8, "i64 global is 8 bytes"); + assert_eq!(module.globals[1].slot_bytes, 4, "i32 global is 4 bytes"); + assert_eq!(module.globals[2].slot_bytes, 8, "f64 global is 8 bytes"); } /// #509: the decoder records `(param_count, result_count)` for every diff --git a/crates/synth-synthesis/src/instruction_selector.rs b/crates/synth-synthesis/src/instruction_selector.rs index 22ad090a..6c6630a7 100644 --- a/crates/synth-synthesis/src/instruction_selector.rs +++ b/crates/synth-synthesis/src/instruction_selector.rs @@ -1889,6 +1889,14 @@ pub struct InstructionSelector { /// path refuses (Ok-or-Err) when any param is 64-bit. Empty ⇒ assume i32 /// (the legacy path; every function with <=4 i32 params is byte-identical). params_i64: Vec, + /// #643: byte width of each defined global's storage slot, indexed by + /// global index — 4 for i32/f32, 8 for i64/f64, 16 for v128. The globals + /// table (R9-relative) is laid out by SUMMING these widths, NOT `idx * 4`: + /// an i64 global's value is a register pair stored at `[R9, off]` / + /// `[R9, off+4]`, and every LATER global's offset shifts with the wider + /// slot. Empty ⇒ every global assumed 4 bytes (the legacy layout; + /// hand-built op streams and i32-only modules stay byte-identical). + global_widths: Vec, /// #509: blocktype-arity side-table of the function being compiled — /// `(param_count, result_count)` of the k-th `Block`/`Loop`/`If` in the op /// stream (ordinal-keyed: rewrites like the #539 memory.grow fold shift op @@ -2017,6 +2025,7 @@ impl InstructionSelector { func_ret_i64: Vec::new(), type_ret_i64: Vec::new(), params_i64: Vec::new(), + global_widths: Vec::new(), block_arity: Vec::new(), func_arg_counts: Vec::new(), type_arg_counts: Vec::new(), @@ -2052,6 +2061,7 @@ impl InstructionSelector { func_ret_i64: Vec::new(), type_ret_i64: Vec::new(), params_i64: Vec::new(), + global_widths: Vec::new(), block_arity: Vec::new(), func_arg_counts: Vec::new(), type_arg_counts: Vec::new(), @@ -2201,6 +2211,32 @@ impl InstructionSelector { self.block_arity = block_arity; } + /// #643: register the module's per-global slot widths (4 = i32/f32, + /// 8 = i64/f64, 16 = v128) so global accesses use the type-aware summed + /// layout and i64 globals get a register-PAIR store/load. Empty ⇒ every + /// global assumed 4 bytes (the legacy `idx * 4` layout). + pub fn set_global_widths(&mut self, global_widths: Vec) { + self.global_widths = global_widths; + } + + /// #643: byte width of global `idx`'s slot (4 when unknown — the legacy + /// assumption for hand-built op streams without a widths table). + fn global_slot_width(&self, idx: u32) -> u32 { + self.global_widths.get(idx as usize).copied().unwrap_or(4) + } + + /// #643: byte offset of global `idx` in the R9 globals table — the SUM of + /// all earlier globals' slot widths. Equals `idx * 4` exactly when no + /// earlier global is wider than 4 bytes (so i32-only modules and every + /// existing fixture keep their addresses bit-identical). Slots are laid + /// out densely; an i64 slot is two word-aligned words (`off`, `off+4`) — + /// word alignment is all the paired `LDR`/`STR` lowering requires. + fn global_slot_offset(&self, idx: u32) -> i32 { + (0..idx as usize) + .map(|i| self.global_widths.get(i).copied().unwrap_or(4) as i32) + .sum() + } + pub fn set_native_pointer_stack(&mut self, sp_index: u32, sp_init: i32) { self.sp_global = Some((sp_index, sp_init)); if sp_init >= 0 { @@ -2989,18 +3025,41 @@ impl InstructionSelector { GlobalGet(index) => { // WASM globals are stored in a globals table in memory. // R9 is the dedicated globals base register (set up by runtime startup). - // Each i32 global occupies 4 bytes: globals_base + index * 4. + // #643: slot offsets are the SUM of earlier globals' widths + // (i64/f64 slots are 8 bytes) — `idx * 4` only when every + // earlier global is i32/f32. This blind (0,1)-stack-effect + // path has no register-pair representation for the value, so + // a 64-bit global access is DECLINED loudly rather than + // truncated to one word (`select_with_stack` lowers the pair). + if self.global_slot_width(*index) != 4 { + return Err(synth_core::Error::synthesis(format!( + "global.get {index} reads a {}-byte (i64/f64/v128) global — \ + the single-register selector path cannot lower a pair; \ + refusing to truncate to 32 bits (#643)", + self.global_slot_width(*index) + ))); + } vec![ArmOp::Ldr { rd, - addr: MemAddr::imm(Reg::R9, (*index as i32) * 4), + addr: MemAddr::imm(Reg::R9, self.global_slot_offset(*index)), }] } GlobalSet(index) => { - // Store value from source register to globals_base + index * 4. + // Store value from source register to the global's slot. // R9 is the dedicated globals base register. + // #643: type-aware offset + loud decline for 64-bit globals + // (see GlobalGet above — storing one word dropped the hi half). + if self.global_slot_width(*index) != 4 { + return Err(synth_core::Error::synthesis(format!( + "global.set {index} writes a {}-byte (i64/f64/v128) global — \ + the single-register selector path cannot lower a pair; \ + refusing to drop the high word (#643)", + self.global_slot_width(*index) + ))); + } vec![ArmOp::Str { rd, - addr: MemAddr::imm(Reg::R9, (*index as i32) * 4), + addr: MemAddr::imm(Reg::R9, self.global_slot_offset(*index)), }] } Select => { @@ -9478,6 +9537,21 @@ impl InstructionSelector { } GlobalGet(global_idx) => { + // #643: type-aware slot addressing. The offset is the SUM + // of earlier globals' widths (an i64/f64 slot is 8 bytes), + // NOT `idx * 4` — and an i64 global's value is a register + // PAIR loaded from `[R9, off]` / `[R9, off+4]`. The old + // single-word `idx * 4` lowering silently dropped the high + // word of every i64 global. + let slot_off = self.global_slot_offset(*global_idx); + let slot_width = self.global_slot_width(*global_idx); + if slot_width > 8 { + return Err(synth_core::Error::synthesis(format!( + "global.get {global_idx} (op {idx}) reads a \ + {slot_width}-byte (v128) global — no lowering; \ + refusing to truncate (#643)" + ))); + } // #237 (gale, mutex-on-silicon): under the native-pointer ABI, // globals live in MATERIALIZED slots (`__synth_globals + idx*4`, // emitted into the object's .data with their wasm init values) @@ -9488,6 +9562,21 @@ impl InstructionSelector { // global is rebased to an absolute pointer on read so address // arithmetic and [r11=0 + addr] accesses see host pointers. if self.native_pointer_abi { + // #643: the materialized `__synth_globals` region is a + // 4-byte-slot layout (i32 inits, `idx * 4` addressing, + // emitted by the CLI). A wide global — or a 4-byte + // global whose offset an earlier wide global shifted — + // has no consistent slot there; decline loudly (the + // CLI refuses such modules before codegen; this guards + // direct `select_with_stack` drivers). + if slot_width != 4 || slot_off != (*global_idx as i32) * 4 { + return Err(synth_core::Error::synthesis(format!( + "global.get {global_idx} (op {idx}): i64/f64 \ + globals are unsupported under the native-pointer \ + ABI's 4-byte `__synth_globals` slot layout — \ + refusing to truncate (#643)" + ))); + } let dst = alloc_temp_or_spill( &mut next_temp, &mut stack, @@ -9538,8 +9627,41 @@ impl InstructionSelector { } continue; } + // #643: an i64/f64 global is a register PAIR — load both + // words from its 8-byte slot. The pair MUST be consecutive + // in ALLOCATABLE_REGS (i64_pair_hi recovers the high reg + // downstream, exactly like I64Load). + if slot_width == 8 { + 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::Ldr { + rd: dst_lo, + addr: MemAddr::imm(Reg::R9, slot_off), + }, + source_line: Some(idx), + }); + cf.add_instruction(); + instructions.push(ArmInstruction { + op: ArmOp::Ldr { + rd: dst_hi, + addr: MemAddr::imm(Reg::R9, slot_off + 4), + }, + source_line: Some(idx), + }); + cf.add_instruction(); + stack.push(StackVal::i64(dst_lo)); + continue; + } // Load global value from globals table (R9 = globals base). - // Each i32 global occupies 4 bytes at offset index * 4. + // i32/f32 globals occupy one 4-byte slot at `slot_off`. let dst = alloc_temp_or_spill( &mut next_temp, &mut stack, @@ -9551,7 +9673,7 @@ impl InstructionSelector { instructions.push(ArmInstruction { op: ArmOp::Ldr { rd: dst, - addr: MemAddr::imm(Reg::R9, (*global_idx as i32) * 4), + addr: MemAddr::imm(Reg::R9, slot_off), }, source_line: Some(idx), }); @@ -9560,6 +9682,68 @@ impl InstructionSelector { } GlobalSet(global_idx) => { + // #643: type-aware slot addressing (see GlobalGet above) — + // an i64/f64 global's value is a register PAIR stored to + // `[R9, off]` / `[R9, off+4]`. The old single-word store + // silently discarded the already-materialized high word. + let slot_off = self.global_slot_offset(*global_idx); + let slot_width = self.global_slot_width(*global_idx); + if slot_width > 8 { + return Err(synth_core::Error::synthesis(format!( + "global.set {global_idx} (op {idx}) writes a \ + {slot_width}-byte (v128) global — no lowering; \ + refusing to truncate (#643)" + ))); + } + if slot_width == 8 { + if self.native_pointer_abi { + // 4-byte `__synth_globals` slot layout — see the + // GlobalGet decline above. + return Err(synth_core::Error::synthesis(format!( + "global.set {global_idx} (op {idx}): i64/f64 \ + globals are unsupported under the native-pointer \ + ABI's 4-byte `__synth_globals` slot layout — \ + refusing to drop the high word (#643)" + ))); + } + // The operand-stack top must actually BE a pair — a + // width mismatch (invalid wasm / a producer we failed + // to tag) would make i64_pair_hi fabricate a high reg. + if !stack.last().map(StackVal::is_i64).unwrap_or(false) { + return Err(synth_core::Error::synthesis(format!( + "global.set {global_idx} (op {idx}) writes an \ + 8-byte global but the operand-stack top is not \ + an i64 pair — refusing to fabricate a high \ + word (#643)" + ))); + } + let val_lo = pop_operand( + &mut stack, + &mut next_temp, + &mut instructions, + &mut spill, + &live_params, + idx, + )?; + let val_hi = i64_pair_hi(val_lo)?; + instructions.push(ArmInstruction { + op: ArmOp::Str { + rd: val_lo, + addr: MemAddr::imm(Reg::R9, slot_off), + }, + source_line: Some(idx), + }); + cf.add_instruction(); + instructions.push(ArmInstruction { + op: ArmOp::Str { + rd: val_hi, + addr: MemAddr::imm(Reg::R9, slot_off + 4), + }, + source_line: Some(idx), + }); + cf.add_instruction(); + continue; + } // Pop value from stack and store to globals table (R9 = globals base). let val = pop_operand( &mut stack, @@ -9577,6 +9761,18 @@ impl InstructionSelector { // absolute pointer is rebased back to a wasm offset before the // store (slots hold offsets; no data relocation needed). if self.native_pointer_abi { + // #643: a 4-byte global whose offset an earlier wide + // global shifted has no consistent slot in the CLI's + // `idx * 4` `__synth_globals` layout — decline loudly + // (mirrors the GlobalGet guard above). + if slot_off != (*global_idx as i32) * 4 { + return Err(synth_core::Error::synthesis(format!( + "global.set {global_idx} (op {idx}): the module \ + mixes i64/f64 globals into the native-pointer \ + ABI's 4-byte `__synth_globals` slot layout — \ + refusing an inconsistent offset (#643)" + ))); + } let mut reserved = live_params.clone(); reserved.push(val); let stored = if let Some((sp_idx, _)) = self.sp_global @@ -9633,7 +9829,7 @@ impl InstructionSelector { instructions.push(ArmInstruction { op: ArmOp::Str { rd: val, - addr: MemAddr::imm(Reg::R9, (*global_idx as i32) * 4), + addr: MemAddr::imm(Reg::R9, slot_off), }, source_line: Some(idx), }); @@ -14977,6 +15173,168 @@ mod tests { assert!(has_add, "Should have ADD for i32.add"); } + // ========================================================================= + // #643: i64 globals — 8-byte slots, register-pair store/load, and the + // layout shift of every global behind a wide slot + // ========================================================================= + + /// `global.get` of an i64 global loads BOTH words — a consecutive register + /// pair from `[R9, #0]` and `[R9, #4]` (was: one word, hi silently lost). + #[test] + fn i64_global_get_loads_pair_643() { + let mut selector = fresh_selector(); + selector.set_global_widths(vec![8]); + + let wasm_ops = vec![WasmOp::GlobalGet(0), WasmOp::Drop]; + let instrs = selector.select_with_stack(&wasm_ops, 0).unwrap(); + + let r9_loads: Vec = instrs + .iter() + .filter_map(|i| match &i.op { + ArmOp::Ldr { addr, .. } if addr.base == Reg::R9 => Some(addr.offset), + _ => None, + }) + .collect(); + assert_eq!( + r9_loads, + vec![0, 4], + "i64 global.get must load lo from [R9,#0] and hi from [R9,#4]" + ); + } + + /// `global.set` of an i64 global stores BOTH words of the popped pair. + #[test] + fn i64_global_set_stores_pair_643() { + let mut selector = fresh_selector(); + selector.set_global_widths(vec![8]); + + let wasm_ops = vec![ + WasmOp::I64Const(0x1234_5678_9ABC_DEF0_u64 as i64), + WasmOp::GlobalSet(0), + ]; + let instrs = selector.select_with_stack(&wasm_ops, 0).unwrap(); + + let r9_stores: Vec = instrs + .iter() + .filter_map(|i| match &i.op { + ArmOp::Str { addr, .. } if addr.base == Reg::R9 => Some(addr.offset), + _ => None, + }) + .collect(); + assert_eq!( + r9_stores, + vec![0, 4], + "i64 global.set must store lo to [R9,#0] and hi to [R9,#4]" + ); + } + + /// The layout-shift canary: an i32 global declared AFTER an i64 one sits + /// at the SUM of earlier widths ([R9,#8]), not `idx * 4` ([R9,#4]) — a + /// pair fix without the offset shift would alias it with the i64's high + /// word. + #[test] + fn i32_global_after_i64_offset_shifts_643() { + let mut selector = fresh_selector(); + selector.set_global_widths(vec![8, 4]); + + let wasm_ops = vec![ + WasmOp::GlobalGet(1), + WasmOp::I32Const(1), + WasmOp::I32Add, + WasmOp::GlobalSet(1), + ]; + let instrs = selector.select_with_stack(&wasm_ops, 0).unwrap(); + + assert!( + instrs.iter().any(|i| matches!( + &i.op, + ArmOp::Ldr { addr, .. } if addr.base == Reg::R9 && addr.offset == 8 + )), + "i32 global 1 behind an i64 slot must load from [R9,#8]" + ); + assert!( + instrs.iter().any(|i| matches!( + &i.op, + ArmOp::Str { addr, .. } if addr.base == Reg::R9 && addr.offset == 8 + )), + "i32 global 1 behind an i64 slot must store to [R9,#8]" + ); + assert!( + !instrs.iter().any(|i| matches!( + &i.op, + ArmOp::Ldr { addr, .. } if addr.base == Reg::R9 && addr.offset == 4 + )), + "the legacy idx*4 offset (4) would alias the i64's high word" + ); + } + + /// An empty widths table keeps the legacy `idx * 4` layout bit-identical + /// (hand-built op streams; i32-only modules never shift). + #[test] + fn global_widths_empty_keeps_legacy_layout_643() { + let mut selector = fresh_selector(); + // no set_global_widths call + let wasm_ops = vec![WasmOp::GlobalGet(3), WasmOp::Drop]; + let instrs = selector.select_with_stack(&wasm_ops, 0).unwrap(); + assert!( + instrs.iter().any(|i| matches!( + &i.op, + ArmOp::Ldr { addr, .. } if addr.base == Reg::R9 && addr.offset == 12 + )), + "without a widths table, global 3 keeps the legacy [R9,#12]" + ); + } + + /// The blind single-register path (`select`) cannot represent a pair — + /// it must DECLINE an i64 global access loudly, never truncate. + #[test] + fn i64_global_declined_on_select_default_643() { + let db = RuleDatabase::new(); + let mut selector = InstructionSelector::new(db.rules().to_vec()); + selector.set_global_widths(vec![8]); + + let err = selector.select(&[WasmOp::GlobalGet(0)]).unwrap_err(); + assert!( + err.to_string().contains("#643"), + "expected the #643 pair-decline, got: {err}" + ); + + let mut selector2 = InstructionSelector::new(db.rules().to_vec()); + selector2.set_global_widths(vec![8]); + let err2 = selector2.select(&[WasmOp::GlobalSet(0)]).unwrap_err(); + assert!( + err2.to_string().contains("#643"), + "expected the #643 pair-decline, got: {err2}" + ); + } + + /// A v128 global has no lowering — decline loudly on the stack path too. + #[test] + fn v128_global_declined_643() { + let mut selector = fresh_selector(); + selector.set_global_widths(vec![16]); + let err = selector + .select_with_stack(&[WasmOp::GlobalGet(0), WasmOp::Drop], 0) + .unwrap_err(); + assert!(err.to_string().contains("#643"), "got: {err}"); + } + + /// Defensive width check: an 8-byte global.set whose operand-stack top is + /// NOT an i64 pair (invalid wasm / untagged producer) must decline rather + /// than fabricate a high register via i64_pair_hi. + #[test] + fn i64_global_set_width_mismatch_declined_643() { + let mut selector = fresh_selector(); + selector.set_global_widths(vec![8]); + let err = selector + .select_with_stack(&[WasmOp::I32Const(1), WasmOp::GlobalSet(0)], 0) + .unwrap_err(); + assert!( + err.to_string().contains("not an i64 pair"), + "expected the pair-width decline, got: {err}" + ); + } + // ========================================================================= // Select instruction tests // ========================================================================= diff --git a/crates/synth-synthesis/src/optimizer_bridge.rs b/crates/synth-synthesis/src/optimizer_bridge.rs index 7448264b..8fbf32dd 100644 --- a/crates/synth-synthesis/src/optimizer_bridge.rs +++ b/crates/synth-synthesis/src/optimizer_bridge.rs @@ -2434,6 +2434,14 @@ impl OptimizerBridge { // ===== Globals ===== // // GlobalGet pushes a fresh i32; GlobalSet pops one. + // + // #643: this i32-single-slot model (and ir_to_arm's + // `[R9, idx*4]` lowering) is WIDTH-NAIVE — it truncated i64 + // globals and mis-addressed globals behind a wide slot. The + // backend's #643 pre-gate routes every global-touching + // function of a module with any i64/f64/v128 global to the + // direct selector, so these opcodes only ever see modules + // whose globals are all 4-byte (`idx * 4` is then exact). WasmOp::GlobalGet(idx) => Opcode::GlobalGet { dest: OptReg(inst_id as u32), idx: *idx, diff --git a/scripts/repro/i64_globals_643.wat b/scripts/repro/i64_globals_643.wat new file mode 100644 index 00000000..7a5ee661 --- /dev/null +++ b/scripts/repro/i64_globals_643.wat @@ -0,0 +1,46 @@ +;; #643: i64 global.set/global.get on Thumb-2 truncated to 32 bits — the high +;; word was silently dropped (one str/ldr at [r9,#idx*4] instead of a pair, and +;; the 4-byte slot layout had no room for the second word). +;; +;; Fixture shape: +;; * $c (i64, index 0) — the truncation target. Values straddle 32 bits. +;; * $k (i32, index 1) — declared AFTER the i64 global: the LAYOUT-SHIFT +;; canary. Its slot must move from [r9,#4] to [r9,#8]; a fix that pairs the +;; i64 access but keeps idx*4 addressing makes set64 clobber $k (and +;; vice versa). +;; * set-then-get ACROSS calls (set64 / get_lo / get_hi) and within one call +;; (roundtrip_hi — gale's exact #643 repro shape). +;; * pure_add — global-free control: stays compilable everywhere (and is the +;; non-vacuity anchor for the RV32 loud-skip check). +(module + (global $c (mut i64) (i64.const 0)) + (global $k (mut i32) (i32.const 0)) + + ;; store an i64 assembled from two i32 halves + (func (export "set64") (param $lo i32) (param $hi i32) + (global.set $c + (i64.or + (i64.extend_i32_u (local.get $lo)) + (i64.shl (i64.extend_i32_u (local.get $hi)) (i64.const 32))))) + + (func (export "get_lo") (result i32) + (i32.wrap_i64 (global.get $c))) + + (func (export "get_hi") (result i32) + (i32.wrap_i64 (i64.shr_u (global.get $c) (i64.const 32)))) + + (func (export "set32") (param i32) + (global.set $k (local.get 0))) + + (func (export "get32") (result i32) + (global.get $k)) + + ;; gale's #643 reproducer: set + get of the i64 global in ONE function, + ;; returning the high word. Was 32 (the shift amount) instead of 0x12345678. + (func (export "roundtrip_hi") (param i32 i32) (result i32) + (global.set $c (i64.const 0x123456789ABCDEF0)) + (i32.wrap_i64 (i64.shr_u (global.get $c) (i64.const 32)))) + + ;; global-free control + (func (export "pure_add") (param i32 i32) (result i32) + (i32.add (local.get 0) (local.get 1)))) diff --git a/scripts/repro/i64_globals_643_differential.py b/scripts/repro/i64_globals_643_differential.py new file mode 100644 index 00000000..30223b8d --- /dev/null +++ b/scripts/repro/i64_globals_643_differential.py @@ -0,0 +1,210 @@ +#!/usr/bin/env python3 +"""#643 — i64 global.set/global.get pair correctness on BOTH ARM selectors. + +Differential oracle for issue #643: on Thumb-2, an i64 global was stored/loaded +as ONE 32-bit word at [r9,#idx*4] — the high word silently dropped — and the +4-byte slot layout left no room for the second word (so a fix must ALSO shift +the offsets of every later global: the i32-after-i64 canary). + +Runs the in-tree fixture `i64_globals_643.wat` on BOTH lowering paths: + + * OPTIMIZED (`synth compile ... --target cortex-m3`) — the IR path (the + #643 pre-gate must route global-touching functions of a wide-global module + to the direct selector). + * DIRECT (`... --relocatable`) — `select_with_stack` + (the pair store/load lowering itself). + +Globals are STATEFUL: one unicorn instance per path with R9 pointed at a +zeroed scratch region, exports called in sequence, results compared against +the SAME stateful sequence on one wasmtime instance: + + set32(canary); get32 + for v in {0x123456789ABCDEF0, -1, 0x8000000000000000}: set64; get_lo; get_hi + get32 # canary survived the i64 sets (layout-shift gate) + roundtrip_hi(0,0) # gale's exact repro: must be 0x12345678 + +Also checks the RV32 contract: the RISC-V selector has NO global lowering, so +every global-touching export must LOUD-SKIP (warn + absent from the symtab), +never silently truncate; the global-free control must still be emitted. + +Run (needs wasmtime + unicorn + pyelftools): + SYNTH=./target/debug/synth /tmp/synthvenv/bin/python \ + scripts/repro/i64_globals_643_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_R9, + UC_ARM_REG_R11, + UC_ARM_REG_SP, +) + +WAT = Path(__file__).with_name("i64_globals_643.wat") +SYNTH = os.environ.get("SYNTH", "./target/debug/synth") +CODE, STK, RET, R11, GLOB = 0x100000, 0x900000, 0x300000, 0x20000000, 0x400000 + +CANARY = 0x0C0FFEE1 +I64_VALUES = [0x123456789ABCDEF0, 0xFFFFFFFFFFFFFFFF, 0x8000000000000000] + +# every global-touching export (must all loud-skip on RV32) +GLOBAL_FNS = ["set64", "get_lo", "get_hi", "set32", "get32", "roundtrip_hi"] + + +def compile_synth(out, backend_args): + env = {"PATH": "/usr/bin:/bin"} + cmd = [SYNTH, "compile", str(WAT), "-o", out, "--all-exports"] + backend_args + r = subprocess.run(cmd, capture_output=True, text=True, env=env) + if r.returncode != 0: + sys.exit(f"compile failed ({backend_args}): {r.stderr}") + return 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 + + +class ArmRunner: + """One persistent unicorn instance per path — globals live at R9=GLOB and + must survive across calls (set-then-get ACROSS calls is the point).""" + + def __init__(self, code, base, syms): + self.base, self.syms = base, syms + self.mu = Uc(UC_ARCH_ARM, UC_MODE_THUMB) + self.mu.mem_map(CODE, 0x20000) + self.mu.mem_map(STK - 0x10000, 0x20000) + self.mu.mem_map(RET & ~0xFFF, 0x1000) + self.mu.mem_map(GLOB, 0x1000) # zeroed globals table (inits are 0) + self.mu.mem_map(R11, 0x10000) + self.mu.mem_write(CODE, code) + + def call(self, fn, args=()): + faddr = self.syms.get(fn) + if faddr is None: + return f"ERR:symbol {fn} missing" + foff = (faddr & ~1) - self.base + for reg, val in zip((UC_ARM_REG_R0, UC_ARM_REG_R1), args): + self.mu.reg_write(reg, val & 0xFFFFFFFF) + self.mu.reg_write(UC_ARM_REG_SP, STK) + self.mu.reg_write(UC_ARM_REG_R9, GLOB) + self.mu.reg_write(UC_ARM_REG_R11, R11) + self.mu.reg_write(UC_ARM_REG_LR, RET | 1) + try: + self.mu.emu_start((CODE + foff) | 1, RET, count=100000) + except UcError as e: + return f"ERR:{e}" + return self.mu.reg_read(UC_ARM_REG_R0) & 0xFFFFFFFF + + +class WasmtimeRunner: + """Stateful ground truth: ONE instance for the whole sequence.""" + + def __init__(self): + engine = wasmtime.Engine() + module = wasmtime.Module.from_file(engine, str(WAT)) + self.store = wasmtime.Store(engine) + self.inst = wasmtime.Instance(self.store, module, []) + + def call(self, fn, args=()): + signed = [a - (1 << 32) if a >= (1 << 31) else a for a in args] + r = self.inst.exports(self.store)[fn](self.store, *signed) + return (r if r is not None else 0) & 0xFFFFFFFF + + +def sequence(run): + """The stateful call sequence; yields (label, fn, args).""" + yield ("canary-set", "set32", (CANARY,)) + yield ("canary-get", "get32", ()) + for v in I64_VALUES: + lo, hi = v & 0xFFFFFFFF, (v >> 32) & 0xFFFFFFFF + yield (f"set64({v:#x})", "set64", (lo, hi)) + yield (f"get_lo({v:#x})", "get_lo", ()) + yield (f"get_hi({v:#x})", "get_hi", ()) + yield ("canary-survives-i64-sets", "get32", ()) + yield ("gale-roundtrip_hi", "roundtrip_hi", (0, 0)) + yield ("control-pure_add", "pure_add", (41, 1)) + + +def run_arm_path(label, relocatable): + out = f"/tmp/i64g643_{'rel' if relocatable else 'opt'}.o" + args = ["-b", "arm", "--target", "cortex-m3"] + if relocatable: + args.append("--relocatable") + compile_synth(out, args) + code, base, syms = load(out) + arm = ArmRunner(code, base, syms) + gt = WasmtimeRunner() + print(f"\n=== {label} path ===") + fails = 0 + for step, fn, fargs in sequence(arm): + exp = gt.call(fn, fargs) + got = arm.call(fn, fargs) + # setters return nothing; only compare value-producing steps + is_get = fn not in ("set64", "set32") + if not is_get: + if isinstance(got, str): + fails += 1 + print(f" [BUG] {step}: {got}") + continue + match = got == exp + if not match: + fails += 1 + print(f" [{'ok ' if match else 'BUG'}] {step}: {fn}{fargs} -> " + f"{got if isinstance(got, str) else hex(got)} (wasmtime {exp:#x})") + return fails + + +def check_rv32_loudskip(): + """RV32 has no global lowering: global-touching exports must loud-skip + (warn + absent symbol); the global-free control must be emitted.""" + out = "/tmp/i64g643_rv32.o" + stderr = compile_synth(out, ["-b", "riscv"]) + _, _, syms = load(out) + print("\n=== RV32 contract (loud-skip, not silent truncation) ===") + fails = 0 + for fn in GLOBAL_FNS: + skipped = fn not in syms + warned = fn in stderr + ok = skipped and warned + if not ok: + fails += 1 + print(f" [{'ok ' if ok else 'BUG'}] {fn}: " + f"{'loud-skipped' if skipped else 'EMITTED (silent gap?)'}" + f"{'' if warned else ' (no warning in stderr)'}") + if "pure_add" in syms: + print(" [ok ] pure_add emitted (non-vacuity)") + else: + fails += 1 + print(" [BUG] pure_add missing — skip contract is vacuous") + return fails + + +def main(): + total = 0 + total += run_arm_path("OPTIMIZED (default)", relocatable=False) + total += run_arm_path("DIRECT (--relocatable)", relocatable=True) + total += check_rv32_loudskip() + print(f"\nORACLE: {'PASS' if total == 0 else f'FAIL ({total} divergences)'}") + sys.exit(0 if total == 0 else 1) + + +if __name__ == "__main__": + main()