Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
146 changes: 131 additions & 15 deletions crates/synth-backend/src/arm_backend.rs
Original file line number Diff line number Diff line change
Expand Up @@ -150,8 +150,10 @@ fn compile_wasm_to_arm(
BoundsCheckConfig::None
};

// Instruction selection: optimized or direct
let arm_instrs = if config.no_optimize {
// The non-optimized (direct) instruction-selection path. Handles f32 via
// VFP/FPU. Used directly when `--no-optimize` is set, and as the fallback
// when the optimized path declines a module (see issue #120 below).
let select_direct = || -> Result<Vec<ArmInstruction>, String> {
let db = RuleDatabase::with_standard_rules();
let mut selector =
InstructionSelector::with_bounds_check(db.rules().to_vec(), bounds_config);
Expand All @@ -161,7 +163,12 @@ fn compile_wasm_to_arm(
}
selector
.select_with_stack(wasm_ops, num_params)
.map_err(|e| format!("instruction selection failed: {}", e))?
.map_err(|e| format!("instruction selection failed: {}", e))
};

// Instruction selection: optimized or direct
let arm_instrs = if config.no_optimize {
select_direct()?
} else {
let opt_config = if config.loom_compat {
OptimizationConfig::loom_compat()
Expand All @@ -170,18 +177,24 @@ fn compile_wasm_to_arm(
};

let bridge = OptimizerBridge::with_config(opt_config);
let (opt_ir, _cfg, _stats) = bridge
.optimize_full(wasm_ops)
.map_err(|e| format!("optimization failed: {}", e))?;

let arm_ops = bridge.ir_to_arm(&opt_ir, num_params as usize);
arm_ops
.into_iter()
.map(|op| ArmInstruction {
op,
source_line: None,
})
.collect()
match bridge.optimize_full(wasm_ops) {
Ok((opt_ir, _cfg, _stats)) => {
let arm_ops = bridge.ir_to_arm(&opt_ir, num_params as usize);
arm_ops
.into_iter()
.map(|op| ArmInstruction {
op,
source_line: None,
})
.collect()
}
// Issue #120: the optimized path declines modules it cannot lower
// (notably scalar f32/f64 ops — the IR has no float opcodes). Fall
// back to the direct instruction selector, which handles f32 via
// VFP/FPU. This is honest degradation: the function still compiles
// correctly, just without IR-level optimization.
Err(_) => select_direct()?,
}
};

// ISA feature gate: validate that all generated instructions are supported
Expand Down Expand Up @@ -405,6 +418,109 @@ mod tests {
);
}

// ========================================================================
// Issue #120 — f32 ops in the optimized lowering path
//
// `OptimizerBridge::wasm_to_ir` has no handlers for f32/f64 ops, so a
// value-producing float op fell through to `Opcode::Nop`, leaving a
// downstream consumer with an unmapped vreg and tripping the PR #101
// defensive panic in `ir_to_arm`. Customer reproducer: `compiler_builtins
// float::div` and `gale_compute_ipi_mask` in the `falcon-rate-component`
// module.
//
// Fix: `optimize_full` declines float modules with a typed `Err`;
// `compile_wasm_to_arm` falls back to the non-optimized `select_with_stack`
// path, which handles f32 via VFP/FPU. These tests use the *default*
// (optimized) config — `no_optimize` is NOT set — which is the exact
// configuration that panicked pre-fix.
// ========================================================================

/// Pre-fix: this panicked with "vreg vN has no assigned ARM register and
/// no spill slot" inside `ir_to_arm`. Post-fix: the optimized path declines
/// the module and the backend falls back to direct selection, producing a
/// non-empty f32.div lowering on a Cortex-M4F.
#[test]
fn test_issue120_f32_div_compiles_via_optimized_default() {
let backend = ArmBackend::new();
let ops = vec![WasmOp::LocalGet(0), WasmOp::LocalGet(1), WasmOp::F32Div];
let config = CompileConfig {
target: TargetSpec::cortex_m4f(),
// no_optimize NOT set — this exercises the optimized path that
// panicked in issue #120, then the fallback to direct selection.
..CompileConfig::default()
};

let result = backend.compile_function("fdiv", &ops, &config);
assert!(
result.is_ok(),
"f32.div must compile on Cortex-M4F via the optimized->direct \
fallback (issue #120), got: {:?}",
result.as_ref().err()
);
assert!(
!result.unwrap().code.is_empty(),
"f32.div must produce non-empty machine code"
);
}

/// A spread of f32 ops, all through the optimized (default) config, must
/// compile via the fallback on an FPU target without panicking.
#[test]
fn test_issue120_assorted_f32_ops_compile_via_optimized_default() {
let backend = ArmBackend::new();
let config = CompileConfig {
target: TargetSpec::cortex_m4f(),
..CompileConfig::default()
};

let cases: Vec<(&str, Vec<WasmOp>)> = vec![
(
"fadd",
vec![WasmOp::LocalGet(0), WasmOp::LocalGet(1), WasmOp::F32Add],
),
(
"fmul",
vec![WasmOp::LocalGet(0), WasmOp::LocalGet(1), WasmOp::F32Mul],
),
(
"fsub",
vec![WasmOp::LocalGet(0), WasmOp::LocalGet(1), WasmOp::F32Sub],
),
];

for (name, ops) in cases {
let result = backend.compile_function(name, &ops, &config);
assert!(
result.is_ok(),
"{name} must compile via the optimized->direct fallback \
(issue #120), got: {:?}",
result.as_ref().err()
);
assert!(
!result.unwrap().code.is_empty(),
"{name} must produce non-empty machine code"
);
}
}

/// The fallback must still honor the ISA feature gate: f32 on a no-FPU
/// target must fail cleanly (not panic) even on the optimized path.
#[test]
fn test_issue120_f32_div_rejected_on_no_fpu_via_optimized() {
let backend = ArmBackend::new();
let ops = vec![WasmOp::LocalGet(0), WasmOp::LocalGet(1), WasmOp::F32Div];
let config = CompileConfig {
target: TargetSpec::cortex_m3(),
..CompileConfig::default()
};

let result = backend.compile_function("fdiv", &ops, &config);
assert!(
result.is_err(),
"f32.div must be rejected on Cortex-M3 (no FPU), not panic"
);
}

/// Issue #94: end-to-end byte-size check for the canonical u64-packed
/// FFI-return hi32 extract pattern. Compiles two near-identical
/// functions — one with the optimized shift-by-32, one with a generic
Expand Down
115 changes: 114 additions & 1 deletion crates/synth-synthesis/src/optimizer_bridge.rs
Original file line number Diff line number Diff line change
Expand Up @@ -14,8 +14,8 @@
use crate::Condition;
use crate::rules::ArmOp;
use synth_cfg::{Cfg, CfgBuilder};
use synth_core::Result;
use synth_core::WasmOp;
use synth_core::{Error, Result};
use synth_opt::{
AlgebraicSimplification, CommonSubexpressionElimination, ConstantFolding, DeadCodeElimination,
Instruction, Opcode, OptResult, PassManager, PeepholeOptimization, Reg as OptReg,
Expand Down Expand Up @@ -364,6 +364,105 @@ impl OptimizerBridge {
)
}

/// Returns true if `op` is a scalar f32/f64 operation that the optimized
/// lowering path (`wasm_to_ir` → `ir_to_arm`) cannot handle.
///
/// Issue #120: `wasm_to_ir` has zero handlers for f32/f64 ops, so every
/// float op falls through the catch-all `_ => Opcode::Nop`. A value-producing
/// float op that emits `Nop` produces no vreg; any downstream consumer then
/// references an unmapped vreg and trips the defensive `get_arm_reg` panic
/// added by PR #101 (the #93 silent-drop class, for floats).
///
/// The IR `Opcode` enum (`synth-opt`) has no float opcodes at all, so the
/// optimized path cannot lower floats without a large feature addition.
/// Instead, `optimize_full` detects these ops up front and returns a typed
/// `Err`, letting the backend fall back to the non-optimized
/// `InstructionSelector::select_with_stack` path, which *does* handle f32
/// (VFP/FPU). This includes float→int / int→float conversion ops and float
/// loads/stores, since they too produce or consume float-typed values the
/// optimized path can neither map to a vreg nor lower to VFP.
fn is_unsupported_float_op(op: &WasmOp) -> bool {
matches!(
op,
// f32 arithmetic
WasmOp::F32Add
| WasmOp::F32Sub
| WasmOp::F32Mul
| WasmOp::F32Div
// f32 comparisons
| WasmOp::F32Eq
| WasmOp::F32Ne
| WasmOp::F32Lt
| WasmOp::F32Le
| WasmOp::F32Gt
| WasmOp::F32Ge
// f32 math functions
| WasmOp::F32Abs
| WasmOp::F32Neg
| WasmOp::F32Ceil
| WasmOp::F32Floor
| WasmOp::F32Trunc
| WasmOp::F32Nearest
| WasmOp::F32Sqrt
| WasmOp::F32Min
| WasmOp::F32Max
| WasmOp::F32Copysign
// f32 constants and memory
| WasmOp::F32Const(_)
| WasmOp::F32Load { .. }
| WasmOp::F32Store { .. }
// f32 conversions
| WasmOp::F32ConvertI32S
| WasmOp::F32ConvertI32U
| WasmOp::F32ConvertI64S
| WasmOp::F32ConvertI64U
| WasmOp::F32DemoteF64
| WasmOp::F32ReinterpretI32
| WasmOp::I32ReinterpretF32
| WasmOp::I32TruncF32S
| WasmOp::I32TruncF32U
// f64 arithmetic
| WasmOp::F64Add
| WasmOp::F64Sub
| WasmOp::F64Mul
| WasmOp::F64Div
// f64 comparisons
| WasmOp::F64Eq
| WasmOp::F64Ne
| WasmOp::F64Lt
| WasmOp::F64Le
| WasmOp::F64Gt
| WasmOp::F64Ge
// f64 math functions
| WasmOp::F64Abs
| WasmOp::F64Neg
| WasmOp::F64Ceil
| WasmOp::F64Floor
| WasmOp::F64Trunc
| WasmOp::F64Nearest
| WasmOp::F64Sqrt
| WasmOp::F64Min
| WasmOp::F64Max
| WasmOp::F64Copysign
// f64 constants and memory
| WasmOp::F64Const(_)
| WasmOp::F64Load { .. }
| WasmOp::F64Store { .. }
// f64 conversions
| WasmOp::F64ConvertI32S
| WasmOp::F64ConvertI32U
| WasmOp::F64ConvertI64S
| WasmOp::F64ConvertI64U
| WasmOp::F64PromoteF32
| WasmOp::F64ReinterpretI64
| WasmOp::I64ReinterpretF64
| WasmOp::I64TruncF64S
| WasmOp::I64TruncF64U
| WasmOp::I32TruncF64S
| WasmOp::I32TruncF64U
)
}

/// Returns how many i64 values a WASM op consumes (0 if not an i64-consuming op)
fn i64_operand_count(op: &WasmOp) -> usize {
match op {
Expand Down Expand Up @@ -1922,6 +2021,20 @@ impl OptimizerBridge {
// callers (fuzz harnesses, hand-built `Vec<WasmOp>`).
synth_core::wasm_stack_check::check_no_underflow(wasm_ops)?;

// Issue #120: the optimized path (`wasm_to_ir` → `ir_to_arm`) has no
// handlers for scalar f32/f64 ops — the IR `Opcode` enum has no float
// opcodes. Rather than silently emit `Opcode::Nop` for a value-producing
// float op (which leaves a downstream consumer with an unmapped vreg and
// trips the PR #101 defensive panic), decline the whole module here with
// a typed error. The backend falls back to `select_with_stack`, the
// non-optimized path, which handles f32 via VFP/FPU.
if let Some(float_op) = wasm_ops.iter().find(|op| Self::is_unsupported_float_op(op)) {
return Err(Error::UnsupportedInstruction(format!(
"optimized lowering path does not support float ops ({float_op:?}); \
the non-optimized instruction selector handles f32 — issue #120"
)));
}

// Preprocess: convert if-else patterns to select
let preprocessed = self.preprocess_wasm_ops(wasm_ops);

Expand Down
Loading
Loading