From dd29e1a0f244219b3014b02c563c7dc70cb3dbc4 Mon Sep 17 00:00:00 2001 From: Yona Appletree Date: Fri, 31 Jul 2026 18:07:32 -0700 Subject: [PATCH 01/15] feat(lpvm-native): rv32 soft-float f32 lowering + the float-capability seam MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `IsaTarget::f32_lowering` names how a target does binary32: soft-float library calls, a hardware FPU, or not at all. `Rv32imac` answers SoftFloatCalls; nothing answers HardwareFpu, and a test keeps it that way — that is what makes it impossible to emit `fadd.s` for a C6 that would trap on it. `lower_f32` lowers f32 ops to direct calls at the platform soft-float symbols (`__addsf3`, `__ltsf2`, `__floatsisf`, …) with no LightPlayer wrapper in between (roadmap D1). Values stay in integer registers, so the empty Float register class stays empty and no emitter work is needed. `neg`/`abs`/`const`/bitcast are pure bit ops with no call at all; the ops compiler-rt does not define (sqrt, floor/ceil/trunc/nearest, min/max, float→int, unorm lanes) call the M5 native-f32 builtin family, which is the only implementation and therefore not a wrapper either. Also fixes the wasm B1 defect class here before it can bite: import → builtin id resolution was Q32-hardcoded, which in f32 mode silently resolves a pointer-taking builtin to its Q16.16 twin with no type error. Behind `float-f32`, off by default, for the measured D2 reason. Plan: 2026-07-30-1745-f32-native-math (M9) --- lp-shader/lpvm-native/Cargo.toml | 14 + lp-shader/lpvm-native/src/isa/mod.rs | 124 ++++ lp-shader/lpvm-native/src/lib.rs | 6 + lp-shader/lpvm-native/src/lower.rs | 131 ++-- lp-shader/lpvm-native/src/lower_f32.rs | 839 +++++++++++++++++++++++++ 5 files changed, 1078 insertions(+), 36 deletions(-) create mode 100644 lp-shader/lpvm-native/src/lower_f32.rs diff --git a/lp-shader/lpvm-native/Cargo.toml b/lp-shader/lpvm-native/Cargo.toml index be173fc11..dfedfefd0 100644 --- a/lp-shader/lpvm-native/Cargo.toml +++ b/lp-shader/lpvm-native/Cargo.toml @@ -22,6 +22,16 @@ isa-rv32 = ["dep:lp-riscv-inst"] # Xtensa backend (ESP32-S3 / classic ESP32): `isa/xt`, its emitter and the # per-opcode immediate table. isa-xt = ["dep:lp-xt-inst"] +# Native-f32 (`FloatMode::F32`) lowering: `lower_f32`, and on a JIT-capable +# target the soft-float symbol entries in the `rt_jit` builtin table. +# +# Off by default, and for the same measured reason as the `isa-*` gates above: +# `FloatMode` is matched on a runtime value, so LTO cannot drop the f32 arms on +# its own. The shipping ESP32-C6 image runs Fixed-mode shaders only and must not +# grow by an f32 backend it never enters (f32 roadmap D2). Pair it with +# `lps-builtins/float-f32`, which gates the `__lp_lpir_*_f32` implementations +# this lowering calls. +float-f32 = [] # Enable debug info generation (increases binary size, for development only) debug = [] # The Xtensa hardware-risk corpus (`xt_corpus`), shared by the ESP32-S3 JIT @@ -33,6 +43,10 @@ emu = [ # The emulation path's reference target is rv32 (lp-riscv-emu); Xtensa is # additive on top of it via `emu-xt`. "isa-rv32", + # Host emulation is where the f32 filetest targets run, and the builtins + # image it links is always built with the f32 family. Nothing on the host is + # flash-constrained, so the gate has no job here. + "float-f32", "dep:cranelift-codegen", "dep:lpvm-cranelift", "dep:lpvm-emu", diff --git a/lp-shader/lpvm-native/src/isa/mod.rs b/lp-shader/lpvm-native/src/isa/mod.rs index 5b972aafa..e3052c272 100644 --- a/lp-shader/lpvm-native/src/isa/mod.rs +++ b/lp-shader/lpvm-native/src/isa/mod.rs @@ -32,6 +32,10 @@ pub(crate) use shared::IsaEmitOutput; /// emitter currently produces only base RV32IM instructions. The A and C /// extensions appear in the target name because the firmware runtime uses /// them, not because we emit them. +/// +/// Because the name is the *hardware*, an rv32 part that **does** have the F +/// extension (ESP32-S31, ESP32-P4, any RV32IMAFC core) is a **new variant**, +/// not a flag on this one. See [`IsaTarget::f32_lowering`]. #[derive(Copy, Clone, PartialEq, Eq, Debug)] pub enum IsaTarget { #[cfg(feature = "isa-rv32")] @@ -43,6 +47,39 @@ pub enum IsaTarget { Xtensa, } +/// How a target performs IEEE-754 binary32 arithmetic in +/// [`lpir::FloatMode::F32`]. +/// +/// This is the **float-capability seam**. It exists because "rv32" is not one +/// float story: the ESP32-C6 and RP2350's Hazard3 are RV32IMAC with no F +/// extension at all, while the ESP32-S31 and ESP32-P4 are RV32IMAFC with a +/// per-core single-precision FPU. Emitting `fadd.s` for a C6 does not produce a +/// wrong number — it takes an illegal-instruction trap on the first frame. So +/// the choice has to be a *named property of the target*, checked in one place, +/// rather than an assumption baked into shared lowering. +/// +/// Note what makes this safe today: **no variant of [`IsaTarget`] answers +/// [`F32Lowering::HardwareFpu`]**, so no code path in this crate can emit an +/// FP-register instruction. The variant is not speculative padding — it is the +/// thing a new `Rv32imafc` (or the Xtensa FPU backend, roadmap M7) flips, and +/// having it here means that change is one arm of one match instead of a search +/// for every place float lowering assumed soft calls. +#[derive(Copy, Clone, PartialEq, Eq, Debug)] +pub enum F32Lowering { + /// This target cannot execute f32 shaders at all; `FloatMode::F32` is a + /// compile error naming the target. + Unsupported, + /// Float ops lower to calls into the platform soft-float library. Values + /// live in **integer** registers ([`RegClass::Int`]) — the soft-float ABI + /// passes and returns a `float` in `a0`-class registers — so this path + /// needs no float register class, no float argument bank, and no new + /// emitter instructions. + SoftFloatCalls, + /// Float ops lower to native FP instructions operating on the float + /// register file ([`RegClass::Float`]). + HardwareFpu, +} + impl IsaTarget { /// Pool-init order for the register allocator's LRU, for one register class. /// @@ -234,6 +271,37 @@ impl IsaTarget { } } + /// How this target implements `float` when the shader is compiled in + /// [`lpir::FloatMode::F32`] — the float-capability seam. + /// + /// `Rv32imac` names a part **without** the F extension (ESP32-C6, Hazard3), + /// so it answers [`F32Lowering::SoftFloatCalls`]: float ops become direct + /// calls to the platform soft-float symbols (`__addsf3`, `__mulsf3`, …). + /// Those symbols are present in every rv32 image we build — on the C6 the + /// linker resolves them to the chip's **ROM** `rvfplib` routines + /// (`esp32c6.rom.rvfp.ld`), and in the host emulator's builtins image to + /// Rust's `compiler_builtins`. See + /// `docs/adr/2026-07-31-soft-float-via-compiler-builtins.md`. + /// + /// The Xtensa arm is [`F32Lowering::Unsupported`] until the hardware-FPU + /// emitter lands (roadmap M7). It is deliberately *not* + /// [`F32Lowering::SoftFloatCalls`]: the S3 has a real FPU, and quietly + /// giving it the slow path would hide the missing backend behind working + /// output. + /// + /// **Nothing answers [`F32Lowering::HardwareFpu`] yet**, which is what + /// makes it impossible for this crate to emit an F-extension instruction + /// for a part that would trap on it. `f32_lowering_never_claims_hardware` + /// in this module is the guard on that. + pub fn f32_lowering(self) -> F32Lowering { + match self { + #[cfg(feature = "isa-rv32")] + IsaTarget::Rv32imac => F32Lowering::SoftFloatCalls, + #[cfg(feature = "isa-xt")] + IsaTarget::Xtensa => F32Lowering::Unsupported, + } + } + /// `object` crate Architecture for ELF emission. pub fn elf_architecture(self) -> Architecture { match self { @@ -632,3 +700,59 @@ impl IsaTarget { } } } + +#[cfg(test)] +mod tests { + use super::*; + + fn every_isa() -> alloc::vec::Vec { + alloc::vec![ + #[cfg(feature = "isa-rv32")] + IsaTarget::Rv32imac, + #[cfg(feature = "isa-xt")] + IsaTarget::Xtensa, + ] + } + + /// The float-capability seam's whole job: no target may claim a hardware + /// FPU while no emitter in this crate can encode one. A backend that gains + /// FP instructions flips its [`IsaTarget::f32_lowering`] arm and updates + /// this assertion in the same change — which is exactly the review moment + /// we want, because that is the change that can start emitting `fadd.s`. + #[test] + fn f32_lowering_never_claims_hardware() { + for isa in every_isa() { + assert_ne!( + isa.f32_lowering(), + F32Lowering::HardwareFpu, + "{isa:?} claims a hardware FPU, but no emitter here can encode \ + an FP instruction — and a part without the extension traps on one" + ); + } + } + + /// The C6 is the reference rv32 part and has no F extension; soft calls are + /// the only correct answer for it. + #[cfg(feature = "isa-rv32")] + #[test] + fn rv32imac_is_soft_float() { + assert_eq!( + IsaTarget::Rv32imac.f32_lowering(), + F32Lowering::SoftFloatCalls + ); + } + + /// Soft float keeps every value integer-class, so the float pools stay + /// empty. A float vreg reaching the allocator would then fail loudly with + /// `OutOfRegisters` rather than quietly landing an f32 bit pattern in a GPR + /// that some later pass treats as an integer. + #[test] + fn no_target_has_a_float_register_pool() { + for isa in every_isa() { + assert!(isa.allocatable_pool_order(RegClass::Float).is_empty()); + assert!(isa.caller_saved_pool_hw(RegClass::Float).is_empty()); + assert_eq!(isa.direct_ret_reg_count(RegClass::Float), 0); + assert_eq!(isa.call_arg_reg_count(RegClass::Float), 0); + } + } +} diff --git a/lp-shader/lpvm-native/src/lib.rs b/lp-shader/lpvm-native/src/lib.rs index 0711d9dea..46b92b490 100644 --- a/lp-shader/lpvm-native/src/lib.rs +++ b/lp-shader/lpvm-native/src/lib.rs @@ -54,6 +54,12 @@ mod exec_addr; mod jit_symbol_sizes; pub mod link; pub mod lower; +// Native-f32 lowering. Behind `float-f32` for the same reason the ISAs are +// behind `isa-*`: `FloatMode` is matched on a *runtime* value, so LTO cannot +// drop this on its own, and a Fixed-only device image must not pay for it +// (f32 roadmap D2). +#[cfg(feature = "float-f32")] +pub mod lower_f32; pub mod lower_opts; pub mod native_options; pub mod opt; diff --git a/lp-shader/lpvm-native/src/lower.rs b/lp-shader/lpvm-native/src/lower.rs index 0bcaaf966..fef6fc594 100644 --- a/lp-shader/lpvm-native/src/lower.rs +++ b/lp-shader/lpvm-native/src/lower.rs @@ -57,8 +57,8 @@ use alloc::vec::Vec; use lpir::{CalleeRef, FloatMode, IrFunction, LpirModule, LpirOp}; use lps_builtin_ids::{ - BuiltinId, GlslParamKind, glsl_lpfn_q32_builtin_id, glsl_q32_math_builtin_id, - lpir_q32_builtin_id, texture_q32_builtin_id, vm_q32_builtin_id, + BuiltinId, GlslParamKind, Mode as BuiltinMode, glsl_lpfn_builtin_id, glsl_math_builtin_id, + lpir_builtin_id, texture_builtin_id, vm_builtin_id, }; use crate::LowerOpts; @@ -73,13 +73,16 @@ use crate::vinst::{ use lps_q32::q32_encode; #[inline] -fn fa_vreg(v: lpir::VReg) -> VReg { +pub(crate) fn fa_vreg(v: lpir::VReg) -> VReg { VReg(v.0 as u16) } /// Map LPIR vregs into the shared operand pool without an intermediate Vec /// (one call per Call VInst; Q32 code is call-dense). -fn push_vregs_slice(pool: &mut Vec, ir: &[lpir::VReg]) -> Result { +pub(crate) fn push_vregs_slice( + pool: &mut Vec, + ir: &[lpir::VReg], +) -> Result { if ir.len() > u8::MAX as usize { return Err(LowerError::UnsupportedOp { description: String::from("vreg slice too long for FA backend"), @@ -1460,6 +1463,8 @@ pub fn lower_lpir_op( Ok(()) } + // Every float arm above carries an `opts.float_mode == FloatMode::Q32` + // guard, so reaching this arm means the shader asked for native f32. LpirOp::Fadd { .. } | LpirOp::Fsub { .. } | LpirOp::Fmul { .. } @@ -1489,9 +1494,26 @@ pub fn lower_lpir_op( | LpirOp::FtoUnorm16 { .. } | LpirOp::FtoUnorm8 { .. } | LpirOp::Unorm16toF { .. } - | LpirOp::Unorm8toF { .. } => Err(LowerError::UnsupportedOp { - description: String::from("float op requires Q32 mode (F32 not supported on rv32c)"), - }), + | LpirOp::Unorm8toF { .. } => { + #[cfg(feature = "float-f32")] + { + crate::lower_f32::lower_f32_op(out, op, abi.isa(), src_op, symbols, vreg_pool, temps) + } + // Without the feature the f32 lowering is not linked at all, which + // is the point of the gate (roadmap D2: `FloatMode` is matched on a + // runtime value, so LTO cannot drop it on its own). Name the feature + // — "unsupported op" alone sends people looking for a missing + // backend that is right there behind a flag. + #[cfg(not(feature = "float-f32"))] + { + let _ = (out, symbols, vreg_pool, temps); + Err(LowerError::UnsupportedOp { + description: String::from( + "float_mode=f32 needs the `float-f32` feature on lpvm-native", + ), + }) + } + } LpirOp::IfStart { .. } | LpirOp::Else @@ -1516,10 +1538,11 @@ pub fn lower_lpir_op( args, results, } => { - let name = - resolve_callee_name(ir, *callee).ok_or_else(|| LowerError::UnsupportedOp { + let name = resolve_callee_name(ir, *callee, opts.float_mode).ok_or_else(|| { + LowerError::UnsupportedOp { description: format!("Call: callee index out of range ({callee:?})"), - })?; + } + })?; let args_slice = func.pool_slice(*args); if args_slice.len() != args.count as usize { return Err(LowerError::UnsupportedOp { @@ -2124,10 +2147,10 @@ pub fn lower_ops( /// Resolve a callee to its symbol name, borrowed from the module (or /// `'static` for builtins) — no per-call-site String; interning copies only /// on first sight of a symbol. -fn resolve_callee_name(ir: &LpirModule, callee: CalleeRef) -> Option<&str> { +fn resolve_callee_name(ir: &LpirModule, callee: CalleeRef, mode: FloatMode) -> Option<&str> { if let Some(idx) = ir.callee_as_import(callee) { ir.imports.get(idx).map(|imp| { - if let Some(bid) = resolve_import_to_builtin(imp) { + if let Some(bid) = resolve_import_to_builtin(imp, mode) { bid.name() } else { imp.func_name.as_str() @@ -2142,31 +2165,46 @@ fn resolve_callee_name(ir: &LpirModule, callee: CalleeRef) -> Option<&str> { /// Map an LPIR import declaration to a BuiltinId to get the C ABI symbol name. /// Mirrors Cranelift's resolve_import in lpvm-cranelift/src/builtins.rs -fn resolve_import_to_builtin(decl: &lpir::lpir_module::ImportDecl) -> Option { +/// +/// **`mode` is load-bearing, not decoration.** Resolving an import without it +/// was the wasm backend's B1 defect (M1 corpus findings §3): in f32 mode the +/// Q32 ids came back, and a builtin taking a *pointer* has an `i32` signature +/// in both modes, so the call linked and ran while reinterpreting f32 bit +/// patterns as Q16.16. There is no type error to catch it — only wrong colors. +/// `lps-builtin-ids`' resolvers never fall back across modes, so an unmapped +/// name surfaces as a named "unknown builtin symbol" relocation failure. +fn resolve_import_to_builtin( + decl: &lpir::lpir_module::ImportDecl, + mode: FloatMode, +) -> Option { + let bmode = match mode { + FloatMode::Q32 => BuiltinMode::Q32, + FloatMode::F32 => BuiltinMode::F32, + }; match decl.module_name.as_str() { "glsl" => { let ac = decl.param_types.len(); - glsl_q32_math_builtin_id(&decl.func_name, ac) + glsl_math_builtin_id(&decl.func_name, ac, bmode) } "lpir" => { let ac = decl.param_types.len(); - lpir_q32_builtin_id(&decl.func_name, ac) + lpir_builtin_id(&decl.func_name, ac, bmode) } "lpfn" => { // LPFX builtins are named like "lpfn_psrdnoise_34" - strip the suffix let base = lpfn_strip_suffix(&decl.func_name)?; // Get GLSL kinds from lpfn_glsl_params CSV or fall back to IR types let kinds = lpfn_glsl_kinds_from_decl(decl); - glsl_lpfn_q32_builtin_id(base, &kinds) + glsl_lpfn_builtin_id(base, &kinds, bmode) } "vm" => { let ac = decl.param_types.len(); - vm_q32_builtin_id(&decl.func_name, ac) + vm_builtin_id(&decl.func_name, ac, bmode) } "texture" => { let base = texture_strip_suffix(&decl.func_name); let ac = decl.param_types.len(); - texture_q32_builtin_id(base, ac) + texture_builtin_id(base, ac, bmode) } _ => None, } @@ -3308,8 +3346,41 @@ mod tests { } } + /// F32 mode leaves the Q32 arms and reaches the soft-float lowering; the + /// per-op shape is asserted in [`crate::lower_f32`]'s own tests. What is + /// worth pinning *here* is that the hand-off happens at all — the fallthrough + /// arm is easy to leave behind when a new float op is added. + #[cfg(feature = "float-f32")] #[test] - fn lower_f32_float_unsupported() { + fn lower_f32_hands_off_to_the_soft_float_path() { + let f = empty_func(); + let (ir, abi) = empty_ir_abi(); + for op in [ + LpirOp::Fadd { + dst: v(0), + lhs: v(1), + rhs: v(2), + }, + LpirOp::Fdiv { + dst: v(0), + lhs: v(1), + rhs: v(2), + }, + ] { + let vinsts = + call_lower_op(&op, FloatMode::F32, None, &f, &ir, &abi).expect("f32 lowers"); + assert!( + vinsts.iter().any(|i| matches!(i, VInst::Call { .. })), + "expected a soft-float call for {op:?}, got {vinsts:?}" + ); + } + } + + /// With the gate off there is no f32 backend linked, and the error must say + /// so by name rather than looking like a missing feature of the compiler. + #[cfg(not(feature = "float-f32"))] + #[test] + fn lower_f32_without_the_feature_names_the_feature() { let op = LpirOp::Fadd { dst: v(0), lhs: v(1), @@ -3318,23 +3389,11 @@ mod tests { let f = empty_func(); let (ir, abi) = empty_ir_abi(); let err = call_lower_op(&op, FloatMode::F32, None, &f, &ir, &abi).expect_err("F32 float"); - match err { - LowerError::UnsupportedOp { description } => { - assert!( - description.contains("Q32"), - "expected Q32 hint in {description:?}" - ); - } - } - let div = LpirOp::Fdiv { - dst: v(0), - lhs: v(1), - rhs: v(2), - }; - assert!(matches!( - call_lower_op(&div, FloatMode::F32, None, &f, &ir, &abi), - Err(LowerError::UnsupportedOp { .. }) - )); + let LowerError::UnsupportedOp { description } = err; + assert!( + description.contains("float-f32"), + "expected the feature name in {description:?}" + ); } #[test] diff --git a/lp-shader/lpvm-native/src/lower_f32.rs b/lp-shader/lpvm-native/src/lower_f32.rs new file mode 100644 index 000000000..87e030e03 --- /dev/null +++ b/lp-shader/lpvm-native/src/lower_f32.rs @@ -0,0 +1,839 @@ +//! Native-f32 ([`FloatMode::F32`]) LPIR → [`VInst`] lowering. +//! +//! The Q32 arms live in [`crate::lower`]; this module owns everything the +//! backend does when the shader asked for real IEEE-754 binary32. +//! +//! # Soft float: direct calls to the platform library, no wrapper +//! +//! On an rv32 part **without** the F extension — the ESP32-C6, RP2350's +//! Hazard3, every Cortex-M0+-class core we might target next — arithmetic +//! lowers to a call at the standard soft-float symbol names: +//! +//! ```text +//! __addsf3 __subsf3 __mulsf3 __divsf3 +//! __eqsf2 __nesf2 __ltsf2 __lesf2 __gtsf2 __gesf2 +//! __floatsisf __floatunsisf +//! ``` +//! +//! These are **not** LightPlayer symbols and there is no LightPlayer wrapper in +//! front of them. They are already present in every rv32 image we build: the +//! host emulator's builtins image gets them from Rust's `compiler_builtins`, +//! and the ESP32-C6 firmware's linker script resolves them to the chip's **ROM** +//! `rvfplib` routines (`esp-rom-sys`, `ld/esp32c6/rom/esp32c6.rom.rvfp.ld` — +//! `__addsf3 = 0x400009f8`, and `__divsf3 = 0x400008dc` from the libgcc group). +//! So the call costs one `auipc`+`jalr` and nothing else, and on the C6 it does +//! not consume a byte of app flash. See +//! `docs/adr/2026-07-31-soft-float-via-compiler-builtins.md`. +//! +//! **f32 values live in integer registers on this path.** That is the +//! soft-float ABI (`float` is passed and returned in `a0`-class registers), and +//! it is why this whole path needs no [`RegClass::Float`] pool, no float +//! argument bank, and no new emitter instruction: every [`VInst`] emitted here +//! is an ordinary integer one. +//! +//! # The comparison return convention +//! +//! `__ltsf2` and friends do not return a boolean. They return a signed integer +//! whose *sign* answers the question, with the unordered (NaN) case deliberately +//! biased so that the natural test is false: +//! +//! | LPIR op | call | test | NaN operand | +//! |---|---|---|---| +//! | `Feq` | `__eqsf2` | `== 0` | returns non-zero → false | +//! | `Fne` | `__nesf2` | `!= 0` | returns non-zero → **true** | +//! | `Flt` | `__ltsf2` | `< 0` | returns positive → false | +//! | `Fle` | `__lesf2` | `<= 0` | returns positive → false | +//! | `Fgt` | `__gtsf2` | `> 0` | returns negative → false | +//! | `Fge` | `__gesf2` | `>= 0` | returns negative → false | +//! +//! Every row lands on IEEE-754's rule that an unordered comparison is false +//! except for `!=`. The bias direction is the reason `Flt` cannot be spelled as +//! `__gtsf2(b, a) > 0` and similar rewrites: the two differ exactly on NaN. +//! +//! # What compiler-rt does *not* provide +//! +//! `sqrt`, `floor`, `ceil`, `trunc`, `nearest`, `min`, `max`, the float→int +//! conversions, and the unorm lane conversions have no soft-float ABI symbol — +//! they are libm or LightPlayer-specific. Those call the native-f32 builtin +//! family (`__lp_lpir_*_f32`, roadmap M5), which is the *only* implementation +//! and therefore not a wrapper either. `float→int` is the interesting one: see +//! [`f32_ftoi_sat_s_symbol`]. +//! +//! # Ops with no call at all +//! +//! `Fneg`, `Fabs`, `FconstF32`, and `FfromI32Bits` are pure bit manipulation on +//! the IEEE encoding and lower to one or two integer instructions. `Fabs` in +//! particular **must** be the sign-bit mask rather than a comparison, so it +//! stays exact on NaN and `-0.0` (`docs/design/float.md` §3). + +use alloc::string::String; +use alloc::vec::Vec; + +use lpir::LpirOp; + +use crate::abi::RegClass; +use crate::error::LowerError; +use crate::isa::{F32Lowering, IsaTarget}; +use crate::lower::{fa_vreg, push_vregs_slice}; +use crate::vinst::{ + AluOp, IcmpCond, ModuleSymbols, TempVRegs, VInst, VReg, VRegSlice, pack_src_op, +}; + +/// IEEE-754 binary32 sign bit — the mask `Fneg` flips and `Fabs` clears. +const SIGN_BIT: i32 = i32::MIN; +/// Everything but the sign bit. +const SIGN_MASK_OFF: i32 = i32::MAX; + +/// Lower one float [`LpirOp`] in [`lpir::FloatMode::F32`]. +/// +/// Called from [`crate::lower::lower_lpir_op`]'s float fallthrough, i.e. only +/// after every Q32 arm has declined. `isa` selects the strategy through +/// [`IsaTarget::f32_lowering`] — the float-capability seam. +#[allow( + clippy::too_many_arguments, + reason = "mirrors lower_lpir_op's threading of the same lowering context" +)] +pub fn lower_f32_op( + out: &mut Vec, + op: &LpirOp, + isa: IsaTarget, + src_op: Option, + symbols: &mut ModuleSymbols, + vreg_pool: &mut Vec, + temps: &mut TempVRegs, +) -> Result<(), LowerError> { + match isa.f32_lowering() { + F32Lowering::SoftFloatCalls => { + lower_soft_float_op(out, op, src_op, symbols, vreg_pool, temps) + } + F32Lowering::Unsupported => Err(LowerError::UnsupportedOp { + description: alloc::format!( + "float_mode=f32 is not implemented for {isa:?}: no soft-float library \ + and no hardware-FPU emitter for this target" + ), + }), + // No `IsaTarget` answers this today, and `isa::tests:: + // f32_lowering_never_claims_hardware` keeps it that way. Erroring rather + // than falling through to soft float is deliberate: a target that claims + // an FPU and silently gets library calls would pass its tests and be + // ~30x slower than it was supposed to be, with nothing pointing at why. + F32Lowering::HardwareFpu => Err(LowerError::UnsupportedOp { + description: alloc::format!( + "{isa:?} claims a hardware FPU but this crate has no FP emitter" + ), + }), + } +} + +/// The soft-float arm: every float op becomes integer instructions and calls. +fn lower_soft_float_op( + out: &mut Vec, + op: &LpirOp, + src_op: Option, + symbols: &mut ModuleSymbols, + vreg_pool: &mut Vec, + temps: &mut TempVRegs, +) -> Result<(), LowerError> { + let po = pack_src_op(src_op); + match op { + // ── compiler-rt arithmetic ─────────────────────────────────────────── + LpirOp::Fadd { dst, lhs, rhs } => { + soft_call(out, symbols, vreg_pool, "__addsf3", &[*lhs, *rhs], &[*dst], po) + } + LpirOp::Fsub { dst, lhs, rhs } => { + soft_call(out, symbols, vreg_pool, "__subsf3", &[*lhs, *rhs], &[*dst], po) + } + LpirOp::Fmul { dst, lhs, rhs } => { + soft_call(out, symbols, vreg_pool, "__mulsf3", &[*lhs, *rhs], &[*dst], po) + } + LpirOp::Fdiv { dst, lhs, rhs } => { + soft_call(out, symbols, vreg_pool, "__divsf3", &[*lhs, *rhs], &[*dst], po) + } + // A real divide, not the Q32 path's multiply-by-reciprocal. The Q32 + // rewrite is legitimate there because Q16.16 division is already + // approximate; in f32 it would silently lose the last bits of an + // operation `docs/design/float.md` §3 marks correctly rounded. + LpirOp::FdivConstF32 { dst, lhs, rhs } => { + let divisor = temps.mint(); + out.push(VInst::IConst32 { + dst: divisor, + val: rhs.to_bits() as i32, + src_op: po, + }); + call_native( + out, + symbols, + vreg_pool, + "__divsf3", + &[fa_vreg(*lhs), divisor], + &[fa_vreg(*dst)], + po, + ) + } + + // ── compiler-rt comparisons ────────────────────────────────────────── + LpirOp::Feq { dst, lhs, rhs } => { + soft_compare(out, symbols, vreg_pool, temps, "__eqsf2", IcmpCond::Eq, *dst, *lhs, *rhs, po) + } + LpirOp::Fne { dst, lhs, rhs } => { + soft_compare(out, symbols, vreg_pool, temps, "__nesf2", IcmpCond::Ne, *dst, *lhs, *rhs, po) + } + LpirOp::Flt { dst, lhs, rhs } => { + soft_compare(out, symbols, vreg_pool, temps, "__ltsf2", IcmpCond::LtS, *dst, *lhs, *rhs, po) + } + LpirOp::Fle { dst, lhs, rhs } => { + soft_compare(out, symbols, vreg_pool, temps, "__lesf2", IcmpCond::LeS, *dst, *lhs, *rhs, po) + } + LpirOp::Fgt { dst, lhs, rhs } => { + soft_compare(out, symbols, vreg_pool, temps, "__gtsf2", IcmpCond::GtS, *dst, *lhs, *rhs, po) + } + LpirOp::Fge { dst, lhs, rhs } => { + soft_compare(out, symbols, vreg_pool, temps, "__gesf2", IcmpCond::GeS, *dst, *lhs, *rhs, po) + } + + // ── compiler-rt int → float ────────────────────────────────────────── + LpirOp::ItofS { dst, src } => { + soft_call(out, symbols, vreg_pool, "__floatsisf", &[*src], &[*dst], po) + } + LpirOp::ItofU { dst, src } => { + soft_call(out, symbols, vreg_pool, "__floatunsisf", &[*src], &[*dst], po) + } + + // ── bit manipulation, no call ──────────────────────────────────────── + LpirOp::FconstF32 { dst, value } => { + out.push(VInst::IConst32 { + dst: fa_vreg(*dst), + val: value.to_bits() as i32, + src_op: po, + }); + Ok(()) + } + // Flip the sign bit. Not `0.0 - x`: that turns `-0.0` into `+0.0` and + // quiets a signaling NaN, both of which `float.md` §3 forbids for `-x`. + LpirOp::Fneg { dst, src } => { + mask_op(out, temps, AluOp::Xor, SIGN_BIT, fa_vreg(*dst), fa_vreg(*src), po); + Ok(()) + } + // Clear the sign bit; exact on NaN and ±0 for the same reason. + LpirOp::Fabs { dst, src } => { + mask_op(out, temps, AluOp::And, SIGN_MASK_OFF, fa_vreg(*dst), fa_vreg(*src), po); + Ok(()) + } + // Reinterpret: the value is already the bit pattern, in an integer + // register, on this ABI. A hardware-FPU path needs a real `fmv.w.x` + // here — which is why this arm is inside the soft-float function and + // not shared. + LpirOp::FfromI32Bits { dst, src } => { + out.push(VInst::Mov { + dst: fa_vreg(*dst), + src: fa_vreg(*src), + src_op: po, + }); + Ok(()) + } + + // ── native-f32 builtin family (no compiler-rt equivalent) ──────────── + LpirOp::Fsqrt { dst, src } => { + soft_call(out, symbols, vreg_pool, "__lp_lpir_fsqrt_f32", &[*src], &[*dst], po) + } + LpirOp::Ffloor { dst, src } => { + soft_call(out, symbols, vreg_pool, "__lp_lpir_ffloor_f32", &[*src], &[*dst], po) + } + LpirOp::Fceil { dst, src } => { + soft_call(out, symbols, vreg_pool, "__lp_lpir_fceil_f32", &[*src], &[*dst], po) + } + LpirOp::Ftrunc { dst, src } => { + soft_call(out, symbols, vreg_pool, "__lp_lpir_ftrunc_f32", &[*src], &[*dst], po) + } + LpirOp::Fnearest { dst, src } => { + soft_call(out, symbols, vreg_pool, "__lp_lpir_fnearest_f32", &[*src], &[*dst], po) + } + LpirOp::Fmin { dst, lhs, rhs } => soft_call( + out, + symbols, + vreg_pool, + "__lp_lpir_fmin_f32", + &[*lhs, *rhs], + &[*dst], + po, + ), + LpirOp::Fmax { dst, lhs, rhs } => soft_call( + out, + symbols, + vreg_pool, + "__lp_lpir_fmax_f32", + &[*lhs, *rhs], + &[*dst], + po, + ), + LpirOp::FtoiSatS { dst, src } => { + soft_call(out, symbols, vreg_pool, f32_ftoi_sat_s_symbol(), &[*src], &[*dst], po) + } + LpirOp::FtoiSatU { dst, src } => { + soft_call(out, symbols, vreg_pool, f32_ftoi_sat_u_symbol(), &[*src], &[*dst], po) + } + LpirOp::FtoUnorm16 { dst, src } => soft_call( + out, + symbols, + vreg_pool, + "__lp_lpir_fto_unorm16_f32", + &[*src], + &[*dst], + po, + ), + LpirOp::FtoUnorm8 { dst, src } => soft_call( + out, + symbols, + vreg_pool, + "__lp_lpir_fto_unorm8_f32", + &[*src], + &[*dst], + po, + ), + LpirOp::Unorm16toF { dst, src } => soft_call( + out, + symbols, + vreg_pool, + "__lp_lpir_unorm16_to_f_f32", + &[*src], + &[*dst], + po, + ), + LpirOp::Unorm8toF { dst, src } => soft_call( + out, + symbols, + vreg_pool, + "__lp_lpir_unorm8_to_f_f32", + &[*src], + &[*dst], + po, + ), + + other => Err(LowerError::UnsupportedOp { + description: alloc::format!("not a float op, or unhandled in f32 mode: {other:?}"), + }), + } +} + +/// Symbol for `LpirOp::FtoiSatS` in f32 mode, and why it is not `__fixsfsi`. +/// +/// `__fixsfsi` **is** in the soft-float ABI, and the direct-call rule would +/// otherwise apply. It is skipped because the ABI does not pin the answers LPIR +/// needs: libgcc documents float→int conversion as undefined for out-of-range +/// inputs, and `docs/design/float.md` §3 requires finite out-of-range values to +/// saturate. Rust's `compiler_builtins` happens to saturate (and map NaN to 0), +/// but the ESP32-C6 resolves this symbol to the chip's ROM `rvfplib`, which is a +/// *different implementation* — so calling it would mean the emulator and the +/// silicon could legally disagree at exactly the edges the corpus tests. +/// +/// `__lp_lpir_ftoi_sat_s_f32` is one implementation everywhere, and it is the +/// same rule wasm's `i32.trunc_sat_f32_s` follows, so the three f32 targets +/// agree by construction. The C6 harness measures the ROM's behavior separately +/// (`fw-esp32c6` `test_f32_softfloat`) so this can be revisited with data. +fn f32_ftoi_sat_s_symbol() -> &'static str { + "__lp_lpir_ftoi_sat_s_f32" +} + +/// Unsigned sibling of [`f32_ftoi_sat_s_symbol`]; same reasoning, `__fixunssfsi`. +fn f32_ftoi_sat_u_symbol() -> &'static str { + "__lp_lpir_ftoi_sat_u_f32" +} + +/// `dst = src OP imm` where `imm` is a full 32-bit mask (never a legal +/// 12-bit immediate on rv32, so it always materializes). +fn mask_op( + out: &mut Vec, + temps: &mut TempVRegs, + op: AluOp, + mask: i32, + dst: VReg, + src: VReg, + po: u16, +) { + let m = temps.mint(); + out.push(VInst::IConst32 { + dst: m, + val: mask, + src_op: po, + }); + out.push(VInst::AluRRR { + op, + dst, + src1: src, + src2: m, + src_op: po, + }); +} + +/// `dst = (call(lhs, rhs) COND 0)` — the comparison shape from the module docs. +#[allow( + clippy::too_many_arguments, + reason = "one call site per comparison op; splitting it would only move the arguments" +)] +fn soft_compare( + out: &mut Vec, + symbols: &mut ModuleSymbols, + vreg_pool: &mut Vec, + temps: &mut TempVRegs, + symbol: &'static str, + cond: IcmpCond, + dst: lpir::VReg, + lhs: lpir::VReg, + rhs: lpir::VReg, + po: u16, +) -> Result<(), LowerError> { + let raw = temps.mint(); + call_native( + out, + symbols, + vreg_pool, + symbol, + &[fa_vreg(lhs), fa_vreg(rhs)], + &[raw], + po, + )?; + let zero = temps.mint(); + out.push(VInst::IConst32 { + dst: zero, + val: 0, + src_op: po, + }); + out.push(VInst::Icmp { + dst: fa_vreg(dst), + lhs: raw, + rhs: zero, + cond, + src_op: po, + }); + Ok(()) +} + +/// [`crate::lower`]'s `sym_call` with the packed `src_op` already computed. +fn soft_call( + out: &mut Vec, + symbols: &mut ModuleSymbols, + vreg_pool: &mut Vec, + symbol: &'static str, + args: &[lpir::VReg], + rets: &[lpir::VReg], + po: u16, +) -> Result<(), LowerError> { + out.push(VInst::Call { + target: symbols.intern(symbol), + args: push_vregs_slice(vreg_pool, args)?, + rets: push_vregs_slice(vreg_pool, rets)?, + callee_uses_sret: false, + caller_passes_sret_ptr: false, + caller_sret_vm_abi_swap: false, + src_op: po, + }); + Ok(()) +} + +/// [`soft_call`] for operands that are already backend vregs (temps), not LPIR +/// ones. +fn call_native( + out: &mut Vec, + symbols: &mut ModuleSymbols, + vreg_pool: &mut Vec, + symbol: &'static str, + args: &[VReg], + rets: &[VReg], + po: u16, +) -> Result<(), LowerError> { + out.push(VInst::Call { + target: symbols.intern(symbol), + args: push_native_vregs(vreg_pool, args)?, + rets: push_native_vregs(vreg_pool, rets)?, + callee_uses_sret: false, + caller_passes_sret_ptr: false, + caller_sret_vm_abi_swap: false, + src_op: po, + }); + Ok(()) +} + +fn push_native_vregs(pool: &mut Vec, vregs: &[VReg]) -> Result { + if vregs.len() > u8::MAX as usize { + return Err(LowerError::UnsupportedOp { + description: String::from("vreg slice too long for FA backend"), + }); + } + let start = u16::try_from(pool.len()).map_err(|_| LowerError::UnsupportedOp { + description: String::from("vreg pool exhausted (u16)"), + })?; + pool.extend_from_slice(vregs); + Ok(VRegSlice { + start, + count: vregs.len() as u8, + }) +} + +/// Compile-time assertion that soft float stays integer-class. +/// +/// Not a runtime check — a note where a reader will look. Every `VInst` this +/// module emits is `Call`, `IConst32`, `AluRRR`, `Icmp`, or `Mov`, and +/// [`crate::abi::classify`] answers [`RegClass::Int`] for all of them. That is +/// the property that keeps the empty float register pool from ever being +/// consulted on this path. +const _: () = { + assert!(matches!(RegClass::Int, RegClass::Int)); +}; + +#[cfg(test)] +mod tests { + use super::*; + + fn lower(op: LpirOp, isa: IsaTarget) -> (Vec, ModuleSymbols, Vec) { + let mut out = Vec::new(); + let mut symbols = ModuleSymbols::default(); + let mut pool = Vec::new(); + let mut temps = TempVRegs::new(64); + lower_f32_op( + &mut out, + &op, + isa, + Some(0), + &mut symbols, + &mut pool, + &mut temps, + ) + .expect("lowering should succeed"); + (out, symbols, pool) + } + + fn called_symbols(insts: &[VInst], symbols: &ModuleSymbols) -> Vec { + insts + .iter() + .filter_map(|i| match i { + VInst::Call { target, .. } => Some(String::from(symbols.name(*target))), + _ => None, + }) + .collect() + } + + fn v(n: u32) -> lpir::VReg { + lpir::VReg(n) + } + + #[cfg(feature = "isa-rv32")] + const RV32: IsaTarget = IsaTarget::Rv32imac; + + /// The D1 claim, asserted: arithmetic is one call at the platform symbol, + /// with no LightPlayer name in between. + #[cfg(feature = "isa-rv32")] + #[test] + fn arithmetic_calls_compiler_rt_directly() { + for (op, want) in [ + ( + LpirOp::Fadd { + dst: v(0), + lhs: v(1), + rhs: v(2), + }, + "__addsf3", + ), + ( + LpirOp::Fsub { + dst: v(0), + lhs: v(1), + rhs: v(2), + }, + "__subsf3", + ), + ( + LpirOp::Fmul { + dst: v(0), + lhs: v(1), + rhs: v(2), + }, + "__mulsf3", + ), + ( + LpirOp::Fdiv { + dst: v(0), + lhs: v(1), + rhs: v(2), + }, + "__divsf3", + ), + ] { + let (insts, symbols, _) = lower(op, RV32); + assert_eq!(insts.len(), 1, "one Call and nothing else"); + assert_eq!(called_symbols(&insts, &symbols), alloc::vec![want]); + } + } + + /// Each comparison must use its *own* symbol: `__ltsf2` and `__gtsf2` are + /// biased in opposite directions for NaN, so substituting one for the other + /// with swapped operands is wrong exactly where it matters. + #[cfg(feature = "isa-rv32")] + #[test] + fn each_comparison_uses_its_own_symbol_and_tests_the_sign() { + for (op, want_sym, want_cond) in [ + ( + LpirOp::Feq { + dst: v(0), + lhs: v(1), + rhs: v(2), + }, + "__eqsf2", + IcmpCond::Eq, + ), + ( + LpirOp::Fne { + dst: v(0), + lhs: v(1), + rhs: v(2), + }, + "__nesf2", + IcmpCond::Ne, + ), + ( + LpirOp::Flt { + dst: v(0), + lhs: v(1), + rhs: v(2), + }, + "__ltsf2", + IcmpCond::LtS, + ), + ( + LpirOp::Fle { + dst: v(0), + lhs: v(1), + rhs: v(2), + }, + "__lesf2", + IcmpCond::LeS, + ), + ( + LpirOp::Fgt { + dst: v(0), + lhs: v(1), + rhs: v(2), + }, + "__gtsf2", + IcmpCond::GtS, + ), + ( + LpirOp::Fge { + dst: v(0), + lhs: v(1), + rhs: v(2), + }, + "__gesf2", + IcmpCond::GeS, + ), + ] { + let (insts, symbols, _) = lower(op, RV32); + assert_eq!(called_symbols(&insts, &symbols), alloc::vec![want_sym]); + let cmp = insts + .iter() + .find_map(|i| match i { + VInst::Icmp { cond, .. } => Some(*cond), + _ => None, + }) + .expect("comparison lowers through Icmp against zero"); + assert_eq!(cmp, want_cond, "{want_sym} tested with the wrong condition"); + } + } + + /// `abs`/`neg` are sign-bit masks, not arithmetic — the property that keeps + /// them exact on NaN and `-0.0` (`float.md` §3). + #[cfg(feature = "isa-rv32")] + #[test] + fn neg_and_abs_are_bit_masks_with_no_call() { + let (neg, symbols, _) = lower( + LpirOp::Fneg { + dst: v(0), + src: v(1), + }, + RV32, + ); + assert!(called_symbols(&neg, &symbols).is_empty()); + assert!(matches!( + neg.as_slice(), + [ + VInst::IConst32 { val: SIGN_BIT, .. }, + VInst::AluRRR { + op: AluOp::Xor, + .. + } + ] + )); + + let (abs, symbols, _) = lower( + LpirOp::Fabs { + dst: v(0), + src: v(1), + }, + RV32, + ); + assert!(called_symbols(&abs, &symbols).is_empty()); + assert!(matches!( + abs.as_slice(), + [ + VInst::IConst32 { + val: SIGN_MASK_OFF, + .. + }, + VInst::AluRRR { + op: AluOp::And, + .. + } + ] + )); + } + + /// A float constant is its IEEE bit pattern in an integer register — the + /// whole soft-float representation in one instruction. + #[cfg(feature = "isa-rv32")] + #[test] + fn fconst_materializes_the_ieee_bit_pattern() { + let (insts, _, _) = lower( + LpirOp::FconstF32 { + dst: v(0), + value: 1.0, + }, + RV32, + ); + assert!(matches!( + insts.as_slice(), + [VInst::IConst32 { val, .. }] if *val == 0x3f80_0000u32 as i32 + )); + } + + /// `x / c` divides. The Q32 path multiplies by a precomputed reciprocal, + /// which is a legitimate approximation there and an incorrect one here. + #[cfg(feature = "isa-rv32")] + #[test] + fn div_by_constant_divides_rather_than_multiplying_by_a_reciprocal() { + let (insts, symbols, _) = lower( + LpirOp::FdivConstF32 { + dst: v(0), + lhs: v(1), + rhs: 3.0, + }, + RV32, + ); + assert_eq!(called_symbols(&insts, &symbols), alloc::vec!["__divsf3"]); + assert!( + insts.iter().any(|i| matches!( + i, + VInst::IConst32 { val, .. } if *val == 3.0f32.to_bits() as i32 + )), + "the divisor is materialized as its own bit pattern, not a reciprocal" + ); + } + + /// Ops with no soft-float ABI symbol go to the native-f32 builtin family. + /// Nothing in this set may reach for a `_q32` name — that was the wasm B1 + /// defect, and it produces plausible wrong numbers rather than an error. + #[cfg(feature = "isa-rv32")] + #[test] + fn libm_shaped_ops_use_the_f32_builtin_family() { + for (op, want) in [ + ( + LpirOp::Fsqrt { + dst: v(0), + src: v(1), + }, + "__lp_lpir_fsqrt_f32", + ), + ( + LpirOp::Ffloor { + dst: v(0), + src: v(1), + }, + "__lp_lpir_ffloor_f32", + ), + ( + LpirOp::Fceil { + dst: v(0), + src: v(1), + }, + "__lp_lpir_fceil_f32", + ), + ( + LpirOp::Ftrunc { + dst: v(0), + src: v(1), + }, + "__lp_lpir_ftrunc_f32", + ), + ( + LpirOp::Fnearest { + dst: v(0), + src: v(1), + }, + "__lp_lpir_fnearest_f32", + ), + ( + LpirOp::FtoiSatS { + dst: v(0), + src: v(1), + }, + "__lp_lpir_ftoi_sat_s_f32", + ), + ( + LpirOp::FtoiSatU { + dst: v(0), + src: v(1), + }, + "__lp_lpir_ftoi_sat_u_f32", + ), + ( + LpirOp::FtoUnorm16 { + dst: v(0), + src: v(1), + }, + "__lp_lpir_fto_unorm16_f32", + ), + ( + LpirOp::Unorm8toF { + dst: v(0), + src: v(1), + }, + "__lp_lpir_unorm8_to_f_f32", + ), + ] { + let (insts, symbols, _) = lower(op, RV32); + let called = called_symbols(&insts, &symbols); + assert_eq!(called, alloc::vec![want]); + assert!( + !called[0].ends_with("_q32"), + "f32 mode resolved a Q32 builtin — the wasm B1 defect class" + ); + } + } + + /// The seam refuses rather than silently giving an FPU part the slow path. + #[cfg(feature = "isa-xt")] + #[test] + fn xtensa_f32_is_a_named_error_not_a_silent_soft_float_fallback() { + let mut out = Vec::new(); + let mut symbols = ModuleSymbols::default(); + let mut pool = Vec::new(); + let mut temps = TempVRegs::new(64); + let err = lower_f32_op( + &mut out, + &LpirOp::Fadd { + dst: v(0), + lhs: v(1), + rhs: v(2), + }, + IsaTarget::Xtensa, + Some(0), + &mut symbols, + &mut pool, + &mut temps, + ) + .expect_err("Xtensa has no f32 backend yet"); + let LowerError::UnsupportedOp { description } = err else { + panic!("expected UnsupportedOp"); + }; + assert!(description.contains("Xtensa"), "{description}"); + assert!(out.is_empty(), "a refused op must emit nothing"); + } +} From 60cbf43cb9f1b8fbf24ccab2102f37a0bfae3b04 Mon Sep 17 00:00:00 2001 From: Yona Appletree Date: Fri, 31 Jul 2026 18:31:25 -0700 Subject: [PATCH 02/15] =?UTF-8?q?feat(lpvm-native):=20execute=20f32=20shad?= =?UTF-8?q?ers=20=E2=80=94=20marshalling,=20entry=20ABI,=20rv32=20f32=20ta?= =?UTF-8?q?rgets?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Three things stood between soft-float lowering and a running f32 shader: 1. `LpvmInstance::call` refused any float mode but Q32. Argument and return marshalling is now mode-aware through one `FloatLaneAbi` — the aggregate layout is identical in both modes, only the `float` lane differs, so the struct/array/matrix traversal is shared rather than forked. 2. The emulator's *entry* signature asked for `types::F32` in f32 mode and died with "Unsupported return type". `lpvm-native` targets the soft-float ABI, where an f32 argument lives in an integer register — the entry signature is Q32-shaped whatever the shader's float mode is. 3. Void returns were an error rather than 0.0, unlike every other f32-capable runtime. That asymmetry cost `wasm.f32` 26 files in the M1 corpus run. `rv32n.f32` / `rv32lpn.f32` are registered but deliberately out of DEFAULT_TARGETS (Q6: on demand) — every float op is a call, so their cost belongs to a deliberate run. `scalar/float`: 131/131 tests, 15/15 files on rv32lpn.f32. Plan: 2026-07-30-1745-f32-native-math (M9) --- Cargo.lock | 2 + ...-07-31-soft-float-via-compiler-builtins.md | 158 +++ docs/adr/README.md | 3 + justfile | 39 +- lp-fw/fw-esp32c6/Cargo.toml | 11 + lp-fw/fw-esp32c6/src/main.rs | 8 + .../src/tests/f32_softfloat/abi_probe.rs | 252 ++++ .../fw-esp32c6/src/tests/f32_softfloat/mod.rs | 68 + .../src/tests/f32_softfloat/report.rs | 35 + .../src/tests/f32_softfloat/shader_cases.rs | 220 +++ .../src/emu/emulator/execution.rs | 16 +- .../src/emu/emulator/registers.rs | 25 + .../src/emu/emulator/run_loops.rs | 18 +- .../lp-riscv-emu/src/emu/emulator/state.rs | 8 + .../lp-riscv-emu/src/emu/executor/float.rs | 1239 +++++++++++++++++ lp-riscv/lp-riscv-emu/src/emu/executor/mod.rs | 22 +- .../lp-riscv-emu/src/emu/executor/system.rs | 448 +++--- lp-riscv/lp-riscv-emu/src/emu/fp_regs.rs | 351 +++++ lp-riscv/lp-riscv-emu/src/emu/mod.rs | 2 + .../src/parse/parse_annotation.rs | 17 +- .../lps-filetests/src/targets/display.rs | 31 +- lp-shader/lps-filetests/src/targets/mod.rs | 23 + lp-shader/lps-shared/src/lib.rs | 3 +- lp-shader/lps-shared/src/lps_value_q32.rs | 120 +- lp-shader/lpvm-native/src/rt_emu/instance.rs | 54 +- lp-shader/lpvm-native/src/rt_jit/builtins.rs | 56 + lp-shader/lpvm-native/src/rt_jit/instance.rs | 26 +- lp-shader/lpvm/src/lib.rs | 3 +- lp-shader/lpvm/src/lpvm_abi.rs | 53 +- 29 files changed, 2957 insertions(+), 354 deletions(-) create mode 100644 docs/adr/2026-07-31-soft-float-via-compiler-builtins.md create mode 100644 lp-fw/fw-esp32c6/src/tests/f32_softfloat/abi_probe.rs create mode 100644 lp-fw/fw-esp32c6/src/tests/f32_softfloat/mod.rs create mode 100644 lp-fw/fw-esp32c6/src/tests/f32_softfloat/report.rs create mode 100644 lp-fw/fw-esp32c6/src/tests/f32_softfloat/shader_cases.rs create mode 100644 lp-riscv/lp-riscv-emu/src/emu/executor/float.rs create mode 100644 lp-riscv/lp-riscv-emu/src/emu/fp_regs.rs diff --git a/Cargo.lock b/Cargo.lock index 4e8551bac..84a7f3d76 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -3921,8 +3921,10 @@ dependencies = [ "lpfs", "lpir", "lps-builtins", + "lps-glsl", "lps-q32", "lps-shared", + "lpvm", "lpvm-native", "ser-write-json", "serde", diff --git a/docs/adr/2026-07-31-soft-float-via-compiler-builtins.md b/docs/adr/2026-07-31-soft-float-via-compiler-builtins.md new file mode 100644 index 000000000..86c36224a --- /dev/null +++ b/docs/adr/2026-07-31-soft-float-via-compiler-builtins.md @@ -0,0 +1,158 @@ +# Soft float calls the platform library directly, with no LightPlayer wrapper + +- **Status:** accepted +- **Date:** 2026-07-31 +- **Deciders:** Yona, f32 roadmap M9 +- **Supersedes:** nothing +- **Related:** `docs/design/float.md`, `docs/adr/2026-07-28-esp32c6-flash-budget.md`, + `docs/adr/2026-07-30-isa-parameterized-host-emu-engine.md` + +## Context + +The f32 roadmap adds IEEE-754 binary32 as a second numeric mode beside Q16.16. +Some targets have a single-precision FPU (ESP32-S3's Xtensa LX7, the announced +ESP32-S31 and the ESP32-P4 with RV32IMAFC). Many do not: the **ESP32-C6** +(RV32IMAC) is the reference device today, and the RP2350's Hazard3 cores, every +Cortex-M0+, and any future value part are the same shape. + +Those parts must still be able to *execute f32 semantics*, for two reasons: + +1. It is the only rv32 **hardware** oracle for f32 available before F-bearing + silicon reaches the desk. +2. A shader authored in Float mode should not simply fail to run on a board + without an FPU. Slow is a product decision; "does not exist" is a support + burden. + +So float ops on a non-FPU target lower to library calls. The question this ADR +answers is **which library, reached how**. + +Three options were on the table: + +- **A. LightPlayer wrapper.** Add `__lp_lpir_fadd_f32` and friends to + `lps-builtins`, lower to those, and have them call the platform routines. + This is the shape the Q32 path already has (`__lp_lpir_fdiv_recip_q32`). +- **B. Direct calls to the platform soft-float ABI.** Lower straight to + `__addsf3`, `__ltsf2`, `__floatsisf` — the symbol names every C toolchain, + `compiler_builtins`, and Espressif's ROM already publish. +- **C. Our own soft-float implementation** in `lps-builtins`, so one + implementation runs everywhere including the host emulator. + +## Decision + +**B: lower to the platform soft-float ABI symbols directly.** No wrapper layer. + +`IsaTarget::f32_lowering` names the strategy per target +(`Unsupported` / `SoftFloatCalls` / `HardwareFpu`), and the `SoftFloatCalls` arm +emits a plain `Call` VInst at the ABI symbol name — mechanically the same thing +today's Q32 lowering does, one string different. + +Ops the soft-float ABI **does not define** (`sqrt`, `floor`/`ceil`/`trunc`/ +`nearest`, `min`/`max`, the unorm lane conversions) call the native-f32 builtin +family instead. That is not a wrapper: those routines have no ABI symbol, so the +builtin *is* the implementation. + +**Float→int is a deliberate exception.** `__fixsfsi`/`__fixunssfsi` exist in the +ABI, and we do not call them — see Consequences. + +## Rationale + +**The symbols are already in every image, for free.** Verified before any +lowering was written: + +| Image | Where `__addsf3` resolves | +|---|---| +| `fw-esp32c6` (`riscv32imac-unknown-none-elf`) | **ROM**, `0x400009f8`, via `esp-rom-sys`'s `ld/esp32c6/rom/esp32c6.rom.rvfp.ld` | +| `lps-builtins-emu-app` (host emulator's guest image) | Rust `compiler_builtins`, linked in | + +On the C6 the implementation lives in mask ROM, so the call costs an +`auipc`+`jalr` and **zero bytes of the 3 MB app partition** — which matters, +because that partition is the repo's tightest resource. A wrapper layer would +have added a second call frame per float op *and* real flash, to accomplish +nothing. + +**Option A's cost is per-op, forever.** Yona, on first hearing the design: +*"it's a bit annoying if we have to double-call, first to lp-builtins, then to +rust-builtins. So we might want to see if we can directly call the rust +builtins."* We can. Soft float is already the slowest numeric path in the +system; doubling its call overhead to gain a naming convention is the wrong +trade. + +**Option C loses more than it gains.** Owning a soft-float library would buy +bit-identical behavior between emulator and silicon — genuinely valuable — at +the cost of maintaining correctly-rounded add/sub/mul/div forever, and of +*giving up the ROM implementation*, which is both free and faster than anything +we would write. The correct place to spend effort on emulator-vs-silicon +agreement is a conformance harness, not a reimplementation. + +**The ABI's return convention is a feature, not an obstacle.** `__ltsf2` and +friends return a signed integer whose sign answers the comparison, biased so +that the unordered (NaN) case makes the natural test false — matching IEEE-754, +where every comparison but `!=` is false on NaN. Lowering therefore emits the +call plus one compare-against-zero, and the NaN semantics come out right without +a special case. It also means each comparison must use **its own** symbol: +`a < b` is not `__gtsf2(b, a) > 0`, because the two are biased in opposite +directions and differ exactly on NaN. + +**Values stay in integer registers.** The soft-float ABI passes and returns a +`float` in the integer argument bank, which is what the emitter already does. So +this path needs no `RegClass::Float` pool, no float argument registers, and no +new emitter instructions — the entire f32 backend for a non-FPU target is a +lowering table. + +## Consequences + +**This is the standing answer for every future non-FPU target.** Hazard3 +(RP2350), RP2040, Cortex-M0+ — each needs its `IsaTarget` variant and its +`f32_lowering` arm, and nothing else. That is the point of writing this down. + +**The float-capability seam is load-bearing.** `IsaTarget::Rv32imac` names +hardware *without* the F extension, so an F-bearing rv32 part is a **new +variant**, never a flag on this one. Nothing currently answers +`F32Lowering::HardwareFpu`, and a unit test keeps it that way, because emitting +`fadd.s` for a C6 is not a wrong number — it is an illegal-instruction trap on +the first frame. + +**The emulator and the silicon run different code.** The host emulator executes +Rust's `compiler_builtins`; the C6 executes Espressif's ROM `rvfplib`. Both +implement the same ABI, but they are separate implementations, so agreement is a +fact to *measure*. The `test_f32_softfloat` harness on `fw-esp32c6` is that +measurement: it probes the ROM routines against IEEE reference bit patterns +computed off-device (computing them on-device would compare the routines to +themselves), and then runs a GLSL shader compiled on the C6 in Float mode +end to end. + +**Float→int does not use the ABI symbol.** `__fixsfsi` is documented as +*undefined* for out-of-range and NaN inputs, while `docs/design/float.md` §3 +requires finite out-of-range values to saturate. `compiler_builtins` happens to +saturate; the C6 ROM is a different implementation and need not. Rather than let +the emulator and the silicon legally disagree at exactly the edges the corpus +tests, both conversions go through `__lp_lpir_ftoi_sat_{s,u}_f32`, which is one +implementation everywhere and follows the same rule as wasm's +`i32.trunc_sat_f32_s`. The harness still *probes* the ROM's `__fixsfsi` and +prints what it does, so this can be revisited against data. + +**Nothing is bit-identical across float modes, and that is expected.** Q32 and +f32 are different numeric systems; the corpus's `run[q32]:` / `run[f32]:` +channels already carry that. + +**Cost is gated.** The f32 lowering sits behind `lpvm-native`'s `float-f32` + +feature and the builtin family behind `lps-builtins`' `float-f32`, both off by +default (roadmap D2): `FloatMode` is matched on a runtime value, so LTO cannot +drop the arms on its own, and the shipping C6 image runs Fixed-mode shaders +only. `test_f32_softfloat` is the one configuration in `fw-esp32c6` that turns +them on, which is what keeps `just fw-esp32c6-size-check` measuring an unchanged +product image. + +## Follow-ups + +- **Reconsider `__fixsfsi`/`__fixunssfsi`** once the C6 harness's probe data says + whether the ROM's out-of-range and NaN behavior matches `compiler_builtins`. If + it does, the two float→int ops can join the direct-call set and drop a builtin. +- **Xtensa's `f32_lowering` arm** stays `Unsupported` until the hardware-FPU + emitter and emitter land (roadmap M6/M7). Soft float would be the wrong answer + for a part that has an FPU, so this is a decision, not a gap to fill by + default. +- **A soft-float performance number.** Nothing here measures how slow Float mode + is on a C6. That belongs with the perf surface the roadmap defers (D3's + "42.5fps, soft-float" line), not with this decision. diff --git a/docs/adr/README.md b/docs/adr/README.md index 2189e5e85..328983143 100644 --- a/docs/adr/README.md +++ b/docs/adr/README.md @@ -124,6 +124,9 @@ holds the full context. | Emitter peephole for the Xtensa integer-div-by-zero guard (`Movi`+`BranchRr` → a single `BranchZ(Beqz)`; `MOVEQZ`/`MOVNEZ` fusion, both already encoded/decoded/emulated in `lp-xt-inst`/`lp-xt-emu`) | `2026-07-30-integer-division-never-traps` | Xtensa code-size or instruction-count pressure makes trimming the guard worth it | | C6 migration to `lp-ws281x` (`docs/debt/c6-on-legacy-ws281x-driver.md`) | `2026-07-31-lp-ws281x-multi-channel-driver-adoption` | A second C6 channel is wanted, a `lp-ws281x` fix needs to reach the C6, or maintaining two drivers becomes its own tax | | `MAX_LEDS` silent truncation and duplication (`docs/debt/output-channel-led-cap-silent-truncation.md`) | `2026-07-31-lp-ws281x-multi-channel-driver-adoption` | A long strip is authored and the cap bites with no diagnostic | +| Float→int through the soft-float ABI (`__fixsfsi`/`__fixunssfsi` are skipped because the ABI leaves out-of-range and NaN undefined) | `2026-07-31-soft-float-via-compiler-builtins` | The C6 harness's probe data shows the ROM matching `compiler_builtins` at those edges | +| Xtensa `F32Lowering` arm (`Unsupported` today — the S3 has an FPU, so soft float would be the wrong default) | `2026-07-31-soft-float-via-compiler-builtins` | The Xtensa FPU emulator and emitter land (roadmap M6/M7) | +| Soft-float performance measurement on the C6 (nothing here says how slow Float mode is) | `2026-07-31-soft-float-via-compiler-builtins` | A perf surface exists to show it (roadmap D3) | ## Relationship To Shared Planning diff --git a/justfile b/justfile index df45bb858..27c3dcd08 100644 --- a/justfile +++ b/justfile @@ -968,11 +968,14 @@ clippy-fw-esp32c6-harnesses: install-rv32-target cargo clippy --target {{ rv32_target }} --profile {{ fw_esp32c6_profile }} \ --features "$feature,esp32c6" -- --no-deps -D warnings done - # test_espnow is the one harness built without default features: it wants - # the radio capability alone, not the server stack. - echo "==> fw-esp32c6 harness: test_espnow (--no-default-features)" - cargo clippy --target {{ rv32_target }} --profile {{ fw_esp32c6_profile }} \ - --no-default-features --features test_espnow,esp32c6 -- --no-deps -D warnings + # Two harnesses build without default features: test_espnow wants the radio + # capability alone, and test_f32_softfloat wants the compiler alone (plus + # `float-f32`, which no other configuration in this crate turns on). + for feature in test_espnow test_f32_softfloat; do + echo "==> fw-esp32c6 harness: $feature (--no-default-features)" + cargo clippy --target {{ rv32_target }} --profile {{ fw_esp32c6_profile }} \ + --no-default-features --features "$feature,esp32c6" -- --no-deps -D warnings + done # Every lpc-engine node gate, one build per gate turned off. # @@ -1409,6 +1412,32 @@ fwtest-shader-compile-stress-trace-esp32c6: install-rv32-target fwtest-espnow-esp32c6: install-rv32-target cd lp-fw/fw-esp32c6 && cargo run --no-default-features --features test_espnow,esp32c6 --target {{ rv32_target }} --profile {{ fw_esp32c6_profile }} +# Run firmware with test_f32_softfloat: IEEE f32 semantics on the C6's soft-float +# path — the ROM `rvfplib` routines probed directly, plus a GLSL shader compiled +# on-device in FloatMode::F32 and executed. +# +# The port is NOT auto-detected. Several ESP32 boards are usually attached and +# picking the first one has flashed the wrong board before; pass the C6's port +# explicitly, e.g. `just fwtest-f32-softfloat-esp32c6 /dev/cu.usbmodem1301`. +fwtest-f32-softfloat-esp32c6 port="": install-rv32-target + #!/usr/bin/env bash + set -euo pipefail + port="{{ port }}" + if [[ -z "$port" ]]; then + port="${ESPFLASH_PORT:-}" + fi + if [[ -z "$port" ]]; then + echo "Pass the ESP32-C6 port explicitly (or set ESPFLASH_PORT):" >&2 + echo " just fwtest-f32-softfloat-esp32c6 /dev/cu.usbmodemXXXX" >&2 + echo "Available:" >&2 + ls /dev/cu.usbmodem* /dev/cu.usbserial* 2>/dev/null >&2 || true + exit 1 + fi + echo "Using ESPFLASH_PORT=$port" + cd lp-fw/fw-esp32c6 && ESPFLASH_PORT="$port" cargo run --no-default-features \ + --features test_f32_softfloat,esp32c6 --target {{ rv32_target }} \ + --profile {{ fw_esp32c6_profile }} + cargo-update: cargo update -p regalloc2 \ -p cranelift-codegen \ diff --git a/lp-fw/fw-esp32c6/Cargo.toml b/lp-fw/fw-esp32c6/Cargo.toml index 7e8bb7d5e..6c936bba2 100644 --- a/lp-fw/fw-esp32c6/Cargo.toml +++ b/lp-fw/fw-esp32c6/Cargo.toml @@ -48,6 +48,11 @@ test_msafluid = [] # MSAFluid perf harness (solver + cycle timing; phase 02 wir test_fluid_demo = [] # RGB MSAFluid demo on the basic ring fixture (gpio4, 241 leds, DisplayPipeline) test_jit_math_perf = ["lps-builtins"] # JIT Q32 math perf harness (cycle timing; no pipeline) test_shader_compile_incremental = ["dep:fw-checks", "dep:lp-shader", "dep:lpvm-native", "dep:lpir", "dep:lps-shared", "lps-builtins"] # Incremental native shader compile harness on ESP32-C6 +# f32 soft-float semantics harness (roadmap M9). The ONLY configuration in this +# crate that turns `float-f32` on: the C6 has no FPU, ships Fixed-mode shaders, +# and must not carry an f32 backend it never enters. Enabling it here and nowhere +# else is what keeps `just fw-esp32c6-size-check` measuring an unchanged image. +test_f32_softfloat = ["dep:lpvm-native", "dep:lpvm", "dep:lps-glsl", "dep:lpir", "dep:lps-shared", "lps-builtins", "lpc-shared", "lpvm-native/float-f32", "lps-builtins/float-f32"] test_espnow = ["radio"] # ESP-NOW broadcast/receive smoke test with simulated 1Hz button events [dependencies] @@ -75,6 +80,12 @@ lpvm-native = { path = "../../lp-shader/lpvm-native", default-features = false, ], optional = true } lpir = { path = "../../lp-shader/lpir", default-features = false, optional = true } lps-shared = { path = "../../lp-shader/lps-shared", default-features = false, optional = true } +# `test_f32_softfloat` only: the GLSL frontend and the LPVM engine/instance +# traits, so the harness can go source → machine code → call without the +# `LpsPxShader` render plumbing (it needs to call arbitrary functions, not +# render a frame). +lps-glsl = { path = "../../lp-shader/lps-glsl", default-features = false, optional = true } +lpvm = { path = "../../lp-shader/lpvm", default-features = false, optional = true } libm = "0.2" # Graphics backend is selected automatically by target architecture # (RV32 → lpvm-native::rt_jit on this firmware). No Cargo feature. diff --git a/lp-fw/fw-esp32c6/src/main.rs b/lp-fw/fw-esp32c6/src/main.rs index 301e86b80..473460fef 100644 --- a/lp-fw/fw-esp32c6/src/main.rs +++ b/lp-fw/fw-esp32c6/src/main.rs @@ -274,6 +274,8 @@ use { #[cfg(fw_harness)] mod tests { + #[cfg(feature = "test_f32_softfloat")] + pub mod f32_softfloat; #[cfg(feature = "test_fluid_demo")] pub mod fluid_demo; #[cfg(feature = "test_shader_compile_incremental")] @@ -628,6 +630,12 @@ async fn main(spawner: embassy_executor::Spawner) { run_espnow_test(spawner).await; } + #[cfg(feature = "test_f32_softfloat")] + { + use tests::f32_softfloat::run_f32_softfloat_test; + run_f32_softfloat_test(spawner).await; + } + #[cfg(not(fw_harness))] { let app = boot_firmware(spawner); diff --git a/lp-fw/fw-esp32c6/src/tests/f32_softfloat/abi_probe.rs b/lp-fw/fw-esp32c6/src/tests/f32_softfloat/abi_probe.rs new file mode 100644 index 000000000..25b7ef384 --- /dev/null +++ b/lp-fw/fw-esp32c6/src/tests/f32_softfloat/abi_probe.rs @@ -0,0 +1,252 @@ +//! Direct probes of the soft-float ABI symbols the f32 lowering calls. +//! +//! This half of the harness bypasses the compiler entirely: it calls +//! `__addsf3`, `__ltsf2`, … the same way the JIT-generated code does, and +//! compares the raw result words to IEEE-754 binary32 reference values +//! **computed off-device and written here as bit patterns**. +//! +//! Baking the references in as constants is not laziness — it is the only way +//! the test means anything. On this chip a Rust `a + b` on two `f32`s *is* a +//! call to `__addsf3`, so computing the expected value on-device would compare +//! the routine to itself and pass no matter how wrong it was. +//! +//! What is actually under test here is **the ESP32-C6 mask ROM**. The linker +//! resolves these names through `esp-rom-sys`'s `esp32c6.rom.rvfp.ld` to +//! Espressif's ROM-resident `rvfplib` (`__addsf3 = 0x400009f8`), not to Rust's +//! `compiler_builtins` — so this is a different implementation from the one the +//! host emulator runs, and the two agreeing is a fact to establish rather than +//! assume. + +use esp_println::println; + +use super::report::Report; + +// SAFETY (declaration only): the standard soft-float ABI entry points. On this +// target the linker binds them to ROM addresses; the harness calls them through +// the same C ABI the JIT-generated code uses. +unsafe extern "C" { + fn __addsf3(a: f32, b: f32) -> f32; + fn __subsf3(a: f32, b: f32) -> f32; + fn __mulsf3(a: f32, b: f32) -> f32; + fn __divsf3(a: f32, b: f32) -> f32; + fn __eqsf2(a: f32, b: f32) -> i32; + fn __nesf2(a: f32, b: f32) -> i32; + fn __ltsf2(a: f32, b: f32) -> i32; + fn __lesf2(a: f32, b: f32) -> i32; + fn __gtsf2(a: f32, b: f32) -> i32; + fn __gesf2(a: f32, b: f32) -> i32; + fn __floatsisf(a: i32) -> f32; + fn __floatunsisf(a: u32) -> f32; + // Not called by our lowering — probed for data only. See + // `lpvm_native::lower_f32::f32_ftoi_sat_s_symbol` for why float→int goes + // through a LightPlayer builtin instead. + fn __fixsfsi(a: f32) -> i32; + fn __fixunssfsi(a: f32) -> u32; +} + +const F_1_0: u32 = 0x3F80_0000; +const F_2_0: u32 = 0x4000_0000; +const F_3_0: u32 = 0x4040_0000; +const F_0_1: u32 = 0x3DCC_CCCD; +const F_0_2: u32 = 0x3E4C_CCCD; +const F_0_3_SUM: u32 = 0x3E99_999A; +const F_1_3RD: u32 = 0x3EAA_AAAB; +const F_1E30: u32 = 0x7149_F2CA; +const F_1E_M40: u32 = 0x0001_16C2; +const F_2E_M40: u32 = 0x0002_2D84; +const F_POS_INF: u32 = 0x7F80_0000; +const F_NEG_INF: u32 = 0xFF80_0000; +const F_QNAN: u32 = 0x7FC0_0000; +const F_ZERO: u32 = 0x0000_0000; +const F_NEG_ZERO: u32 = 0x8000_0000; +const F_2_POW_24: u32 = 0x4B80_0000; +const F_MIN_SUBNORMAL: u32 = 0x0000_0001; + +fn bits(x: f32) -> u32 { + x.to_bits() +} + +fn of(b: u32) -> f32 { + // `black_box` keeps the constant opaque to LLVM. Without it the optimizer is + // free to constant-fold an operand into a call it can evaluate at compile + // time on the *host*, which would silently move the test off the ROM. + core::hint::black_box(f32::from_bits(b)) +} + +/// Run every ABI probe, appending to `report`. +pub fn run(report: &mut Report) { + println!("[f32-soft] --- soft-float ABI probes (ESP32-C6 ROM rvfplib) ---"); + println!( + "[f32-soft] symbol addresses: __addsf3={:#010x} __mulsf3={:#010x} __divsf3={:#010x} \ + __ltsf2={:#010x}", + __addsf3 as *const () as usize, + __mulsf3 as *const () as usize, + __divsf3 as *const () as usize, + __ltsf2 as *const () as usize, + ); + + arithmetic(report); + edge_values(report); + subnormals(report); + comparisons(report); + conversions(report); + float_to_int_data_only(); +} + +fn arithmetic(report: &mut Report) { + check(report, "__addsf3(1,2)", bits(unsafe { __addsf3(of(F_1_0), of(F_2_0)) }), F_3_0); + check(report, "__subsf3(3,1)", bits(unsafe { __subsf3(of(F_3_0), of(F_1_0)) }), F_2_0); + check(report, "__mulsf3(3,2)", bits(unsafe { __mulsf3(of(F_3_0), of(F_2_0)) }), 0x40C0_0000); + // The classic: 0.1 + 0.2 must round to 0.30000001192092896, not 0.3. + // Anything else means the routine is not round-to-nearest-even. + check(report, "__addsf3(0.1,0.2)", bits(unsafe { __addsf3(of(F_0_1), of(F_0_2)) }), F_0_3_SUM); + // 1/3 is the one-bit-wrong detector for a reciprocal-multiply shortcut. + check(report, "__divsf3(1,3)", bits(unsafe { __divsf3(of(F_1_0), of(F_3_0)) }), F_1_3RD); +} + +fn edge_values(report: &mut Report) { + check( + report, + "__mulsf3(1e30,1e30) overflows to +inf", + bits(unsafe { __mulsf3(of(F_1E30), of(F_1E30)) }), + F_POS_INF, + ); + check( + report, + "__divsf3(1,0) = +inf", + bits(unsafe { __divsf3(of(F_1_0), of(F_ZERO)) }), + F_POS_INF, + ); + check( + report, + "__divsf3(1,-0) = -inf", + bits(unsafe { __divsf3(of(F_1_0), of(F_NEG_ZERO)) }), + F_NEG_INF, + ); + // 0/0 is a NaN; the *payload* is not something we pin (float.md §5), so + // this only asserts NaN-ness. + let zero_over_zero = bits(unsafe { __divsf3(of(F_ZERO), of(F_ZERO)) }); + report.record( + "__divsf3(0,0) is NaN", + is_nan_bits(zero_over_zero), + zero_over_zero, + F_QNAN, + ); +} + +fn subnormals(report: &mut Report) { + // The reason this is here: flush-to-zero would make both of these come back + // as 0x00000000. `docs/design/float.md` marks denormal FTZ target-defined, + // so a `0` here is data rather than a defect — but it is data we must have + // before the C6 can be trusted as an f32 oracle. + check( + report, + "__addsf3(1e-40,1e-40) keeps the subnormal", + bits(unsafe { __addsf3(of(F_1E_M40), of(F_1E_M40)) }), + F_2E_M40, + ); + check( + report, + "__mulsf3(min-subnormal,2) doubles it", + bits(unsafe { __mulsf3(of(F_MIN_SUBNORMAL), of(F_2_0)) }), + 0x0000_0002, + ); +} + +fn comparisons(report: &mut Report) { + // The lowering tests each result's SIGN. What matters is that the sign is on + // the correct side of zero, including for NaN, where every comparison but + // `!=` must come out false. + let lt = unsafe { __ltsf2(of(F_1_0), of(F_2_0)) }; + report.record("__ltsf2(1,2) < 0", lt < 0, lt as u32, 0); + let lt_nan = unsafe { __ltsf2(of(F_QNAN), of(F_1_0)) }; + report.record("__ltsf2(NaN,1) not < 0", !(lt_nan < 0), lt_nan as u32, 0); + + let le = unsafe { __lesf2(of(F_2_0), of(F_2_0)) }; + report.record("__lesf2(2,2) <= 0", le <= 0, le as u32, 0); + let le_nan = unsafe { __lesf2(of(F_QNAN), of(F_1_0)) }; + report.record("__lesf2(NaN,1) not <= 0", !(le_nan <= 0), le_nan as u32, 0); + + let gt = unsafe { __gtsf2(of(F_2_0), of(F_1_0)) }; + report.record("__gtsf2(2,1) > 0", gt > 0, gt as u32, 0); + let gt_nan = unsafe { __gtsf2(of(F_QNAN), of(F_1_0)) }; + report.record("__gtsf2(NaN,1) not > 0", !(gt_nan > 0), gt_nan as u32, 0); + + let ge = unsafe { __gesf2(of(F_2_0), of(F_2_0)) }; + report.record("__gesf2(2,2) >= 0", ge >= 0, ge as u32, 0); + let ge_nan = unsafe { __gesf2(of(F_QNAN), of(F_1_0)) }; + report.record("__gesf2(NaN,1) not >= 0", !(ge_nan >= 0), ge_nan as u32, 0); + + let eq = unsafe { __eqsf2(of(F_2_0), of(F_2_0)) }; + report.record("__eqsf2(2,2) == 0", eq == 0, eq as u32, 0); + let eq_nan = unsafe { __eqsf2(of(F_QNAN), of(F_QNAN)) }; + report.record("__eqsf2(NaN,NaN) != 0", eq_nan != 0, eq_nan as u32, 0); + // IEEE: `-0.0 == 0.0` is true even though the bit patterns differ. + let eq_zeros = unsafe { __eqsf2(of(F_NEG_ZERO), of(F_ZERO)) }; + report.record("__eqsf2(-0,+0) == 0", eq_zeros == 0, eq_zeros as u32, 0); + + // `!=` is the one comparison that is TRUE for unordered operands. + let ne_nan = unsafe { __nesf2(of(F_QNAN), of(F_QNAN)) }; + report.record("__nesf2(NaN,NaN) != 0", ne_nan != 0, ne_nan as u32, 0); +} + +fn conversions(report: &mut Report) { + check( + report, + "__floatsisf(1)", + bits(unsafe { __floatsisf(core::hint::black_box(1)) }), + F_1_0, + ); + // 2^24 + 1 is not representable; round-to-nearest-even gives 2^24. + check( + report, + "__floatsisf(2^24+1) rounds to 2^24", + bits(unsafe { __floatsisf(core::hint::black_box(16_777_217)) }), + F_2_POW_24, + ); + check( + report, + "__floatsisf(i32::MIN)", + bits(unsafe { __floatsisf(core::hint::black_box(i32::MIN)) }), + 0xCF00_0000, + ); + check( + report, + "__floatunsisf(u32::MAX)", + bits(unsafe { __floatunsisf(core::hint::black_box(u32::MAX)) }), + 0x4F80_0000, + ); +} + +/// Probe `__fixsfsi`/`__fixunssfsi` and **print** what the ROM does, without +/// asserting anything. +/// +/// The lowering deliberately does not call these: the soft-float ABI leaves +/// out-of-range and NaN conversion undefined, so the ROM is free to differ from +/// `compiler_builtins` (which the host emulator uses) at exactly the edges the +/// corpus tests. This exists so that decision can be revisited against measured +/// behavior instead of a guess. +fn float_to_int_data_only() { + let nan = unsafe { __fixsfsi(of(F_QNAN)) }; + let huge = unsafe { __fixsfsi(of(F_1E30)) }; + let neg_huge = unsafe { __fixsfsi(of(F_1E30 | 0x8000_0000)) }; + let u_neg = unsafe { __fixunssfsi(of(F_1_0 | 0x8000_0000)) }; + println!( + "[f32-soft] DATA (not asserted) __fixsfsi: NaN->{nan} 1e30->{huge} -1e30->{neg_huge} \ + __fixunssfsi(-1.0)->{u_neg}" + ); + println!( + "[f32-soft] DATA compiler_builtins/Rust-`as` would give: NaN->0 1e30->{} -1e30->{} \ + __fixunssfsi(-1.0)->0", + i32::MAX, + i32::MIN, + ); +} + +fn check(report: &mut Report, what: &str, got: u32, want: u32) { + report.record(what, got == want, got, want); +} + +fn is_nan_bits(b: u32) -> bool { + (b & 0x7F80_0000) == 0x7F80_0000 && (b & 0x007F_FFFF) != 0 +} diff --git a/lp-fw/fw-esp32c6/src/tests/f32_softfloat/mod.rs b/lp-fw/fw-esp32c6/src/tests/f32_softfloat/mod.rs new file mode 100644 index 000000000..87a4a725c --- /dev/null +++ b/lp-fw/fw-esp32c6/src/tests/f32_softfloat/mod.rs @@ -0,0 +1,68 @@ +//! ESP32-C6 f32 soft-float semantics harness. +//! +//! The C6 has no F extension, but it can still execute **IEEE-754 binary32 +//! semantics** through soft-float calls. That makes it the only rv32 *hardware* +//! oracle for f32 available until an F-bearing part (ESP32-S31, RV32IMAFC) is on +//! the desk — and the thing it is an oracle for is semantics, not FPU behavior. +//! +//! Two halves, in increasing scope: +//! +//! 1. [`abi_probe`] calls the soft-float ABI symbols directly and compares raw +//! result words to IEEE reference bit patterns. This measures the **ESP32-C6 +//! mask ROM** (`rvfplib`), which is a different implementation from the +//! `compiler_builtins` the host emulator links — so their agreement is +//! something to establish, not assume. +//! 2. [`shader_cases`] compiles a GLSL shader **on the device** in +//! `FloatMode::F32`, JITs it, and calls it, checking returned bit patterns. +//! This exercises lowering, relocation against the ROM addresses, and the +//! f32 argument/return marshalling together. +//! +//! **This is a test configuration, never product firmware.** `float-f32` is off +//! by default on `lpvm-native` and `lps-builtins` precisely so the shipping C6 +//! image does not carry an f32 backend it never enters; see +//! `docs/adr/2026-07-28-esp32c6-flash-budget.md` and roadmap decision D2. Report +//! this harness's image size separately from the shipping one. +//! +//! Run with: `just fwtest-f32-softfloat-esp32c6` + +use esp_println::println; + +use crate::board::esp32c6::init::{init_board, start_runtime}; + +mod abi_probe; +mod report; +mod shader_cases; + +pub async fn run_f32_softfloat_test(_: embassy_executor::Spawner) -> ! { + let (sw_int, timg0, _rmt, _usb_device, _gpio18, _flash, _gpio4, _gpio20, _wifi, _rwdt) = + init_board(); + start_runtime(timg0, sw_int); + + // No GPIO is configured anywhere in this harness. On the C6, GPIO12/13 are + // the USB D-/D+ lines, and driving them costs a physical replug to recover + // — a compute-only harness has no reason to go near them. + embassy_time::Timer::after(embassy_time::Duration::from_millis(200)).await; + + println!("[f32-soft] === ESP32-C6 f32 soft-float harness ==="); + + let mut abi = report::Report::default(); + abi_probe::run(&mut abi); + abi.summary("soft-float-abi"); + + let mut shader = report::Report::default(); + shader_cases::run(&mut shader); + shader.summary("f32-shader"); + + let failed = abi.failed + shader.failed; + let passed = abi.passed + shader.passed; + println!("[f32-soft] TOTAL {passed} passed, {failed} failed"); + if failed == 0 { + println!("[f32-soft] === DONE: OK ==="); + } else { + println!("[f32-soft] === DONE: {failed} FAILURES ==="); + } + + loop { + embassy_time::Timer::after(embassy_time::Duration::from_secs(60)).await; + } +} diff --git a/lp-fw/fw-esp32c6/src/tests/f32_softfloat/report.rs b/lp-fw/fw-esp32c6/src/tests/f32_softfloat/report.rs new file mode 100644 index 000000000..355646333 --- /dev/null +++ b/lp-fw/fw-esp32c6/src/tests/f32_softfloat/report.rs @@ -0,0 +1,35 @@ +//! Pass/fail tally for the f32 soft-float harness. +//! +//! Prints one line per case as it runs — a hung or rebooting board still leaves +//! evidence of how far it got — and a single machine-greppable summary at the +//! end. + +use esp_println::println; + +#[derive(Default)] +pub struct Report { + pub passed: u32, + pub failed: u32, +} + +impl Report { + /// Record one case. `got`/`want` are printed as raw words because that is + /// the only representation that survives the question being asked: a + /// decimal rendering of an f32 goes through the very routines under test. + pub fn record(&mut self, what: &str, ok: bool, got: u32, want: u32) { + if ok { + self.passed += 1; + println!("[f32-soft] PASS {what}"); + } else { + self.failed += 1; + println!("[f32-soft] FAIL {what}: got {got:#010x}, want {want:#010x}"); + } + } + + pub fn summary(&self, label: &str) { + println!( + "[f32-soft] SUMMARY {label}: {} passed, {} failed", + self.passed, self.failed + ); + } +} diff --git a/lp-fw/fw-esp32c6/src/tests/f32_softfloat/shader_cases.rs b/lp-fw/fw-esp32c6/src/tests/f32_softfloat/shader_cases.rs new file mode 100644 index 000000000..d0d647321 --- /dev/null +++ b/lp-fw/fw-esp32c6/src/tests/f32_softfloat/shader_cases.rs @@ -0,0 +1,220 @@ +//! End-to-end f32 shader cases: GLSL → LPIR → RV32 machine code → execution, +//! all on the C6, in `FloatMode::F32`. +//! +//! Where [`super::abi_probe`] measures the ROM library on its own, this measures +//! the whole stack the product would use: our `lps-glsl` frontend, `lpvm-native` +//! lowering the float ops to `__addsf3`-class calls, the JIT relocating those +//! calls against the ROM addresses, and the argument/return marshalling that +//! carries an f32 as its bit pattern in an integer register. +//! +//! Expected values are IEEE bit patterns computed off-device, for the same +//! reason as in `abi_probe`. + +use alloc::sync::Arc; + +use esp_println::println; +use lps_shared::LpsValueF32; +use lpvm::{LpvmEngine, LpvmInstance, LpvmModule}; +use lpvm_native::{BuiltinTable, NativeCompileOptions, NativeJitEngine}; + +use super::report::Report; + +/// One `f(args) -> f32` case, checked by result bit pattern. +struct Case { + name: &'static str, + func: &'static str, + args: &'static [f32], + want_bits: u32, +} + +/// The shader. Every function is deliberately small: a failure should point at +/// one lowering arm, not at a composition of five. +const SOURCE: &str = r#" +// Plain arithmetic — __addsf3 / __mulsf3 / __divsf3. +float add(float a, float b) { return a + b; } +float mul(float a, float b) { return a * b; } +float div(float a, float b) { return a / b; } + +// The capability f32 is being added for: Q16.16 tops out near 32768, so this +// result is unrepresentable in the fixed-point mode and would wrap. +float big(float x) { return x * 1000000.0; } + +// Comparisons go through __ltsf2/__gtsf2 and a sign test. +float pick_smaller(float a, float b) { return a < b ? a : b; } + +// int -> float (__floatsisf) and back (a LightPlayer builtin, not compiler-rt). +float from_int(int n) { return float(n); } +int to_int(float x) { return int(x); } + +// Ops with no soft-float ABI symbol: these reach the native-f32 builtin family. +float root(float x) { return sqrt(x); } +float down(float x) { return floor(x); } + +// Sign-bit ops that lower to integer masks, no call at all. +float magnitude(float x) { return abs(x); } +float flip(float x) { return -x; } +"#; + +const CASES: &[Case] = &[ + Case { + name: "add(1,2) == 3", + func: "add", + args: &[1.0, 2.0], + want_bits: 0x4040_0000, + }, + Case { + name: "add(0.1,0.2) rounds to 0.30000001", + func: "add", + args: &[0.1, 0.2], + want_bits: 0x3E99_999A, + }, + Case { + name: "mul(3,2) == 6", + func: "mul", + args: &[3.0, 2.0], + want_bits: 0x40C0_0000, + }, + Case { + name: "div(1,3) == 0.33333334", + func: "div", + args: &[1.0, 3.0], + want_bits: 0x3EAA_AAAB, + }, + // 12345.678 * 1e6 = 1.2345678e10 — four orders of magnitude past Q16.16's + // ceiling. In Fixed mode this shader is meaningless; that is the point. + Case { + name: "big(12345.678) reaches 1.2345678e10", + func: "big", + args: &[12345.678], + want_bits: 0x5037_F706, + }, + Case { + name: "pick_smaller(2,1) == 1", + func: "pick_smaller", + args: &[2.0, 1.0], + want_bits: 0x3F80_0000, + }, + Case { + name: "pick_smaller(-1.5,1) == -1.5", + func: "pick_smaller", + args: &[-1.5, 1.0], + want_bits: 0xBFC0_0000, + }, + Case { + name: "root(7) == 2.6457514", + func: "root", + args: &[7.0], + want_bits: 0x4029_53FD, + }, + Case { + name: "down(-1.5) == -2", + func: "down", + args: &[-1.5], + want_bits: 0xC000_0000, + }, + Case { + name: "magnitude(-2.5) == 2.5", + func: "magnitude", + args: &[-2.5], + want_bits: 0x4020_0000, + }, + // The sign-bit mask must be exact on -0.0: `0.0 - x` would answer +0.0. + Case { + name: "flip(0.0) == -0.0 (sign bit, not subtraction)", + func: "flip", + args: &[0.0], + want_bits: 0x8000_0000, + }, +]; + +/// Compile [`SOURCE`] in f32 mode on device and run [`CASES`]. +pub fn run(report: &mut Report) { + println!("[f32-soft] --- end-to-end f32 shader (on-device JIT) ---"); + + let mut table = BuiltinTable::new(); + table.populate(); + println!("[f32-soft] builtin table: {} symbols", table.len()); + + let options = NativeCompileOptions { + float_mode: lpir::FloatMode::F32, + ..Default::default() + }; + let engine = NativeJitEngine::new(Arc::new(table), options); + + let output = match lps_glsl::compile(SOURCE, &lps_glsl::CompileOptions::default()) { + Ok(o) => o, + Err(d) => { + report.record("glsl compiles", false, 0, 0); + println!("[f32-soft] frontend error: {}", d.render(SOURCE)); + return; + } + }; + report.record("glsl compiles", true, 0, 0); + + let module = match engine.compile(&output.ir, &output.meta) { + Ok(m) => m, + Err(e) => { + report.record("f32 backend compiles", false, 0, 0); + println!("[f32-soft] backend error: {e}"); + return; + } + }; + report.record("f32 backend compiles", true, 0, 0); + if let Some(size) = module.code_size_bytes() { + println!("[f32-soft] jit code size: {size} bytes"); + } + + let mut inst = match module.instantiate() { + Ok(i) => i, + Err(e) => { + report.record("instantiates", false, 0, 0); + println!("[f32-soft] instantiate error: {e}"); + return; + } + }; + report.record("instantiates", true, 0, 0); + + for case in CASES { + let args: alloc::vec::Vec = + case.args.iter().map(|a| LpsValueF32::F32(*a)).collect(); + match inst.call(case.func, &args) { + Ok(LpsValueF32::F32(got)) => { + report.record(case.name, got.to_bits() == case.want_bits, got.to_bits(), case.want_bits); + } + Ok(other) => { + println!("[f32-soft] FAIL {}: unexpected return shape {other:?}", case.name); + report.record(case.name, false, 0, case.want_bits); + } + Err(e) => { + println!("[f32-soft] FAIL {}: call error: {e}", case.name); + report.record(case.name, false, 0, case.want_bits); + } + } + } + + // int <-> float round trips, whose returns are not `float`. + match inst.call("from_int", &[LpsValueF32::I32(16_777_217)]) { + Ok(LpsValueF32::F32(got)) => report.record( + "from_int(2^24+1) rounds to 2^24", + got.to_bits() == 0x4B80_0000, + got.to_bits(), + 0x4B80_0000, + ), + other => { + println!("[f32-soft] FAIL from_int: {other:?}"); + report.record("from_int(2^24+1) rounds to 2^24", false, 0, 0x4B80_0000); + } + } + match inst.call("to_int", &[LpsValueF32::F32(1_000_000.5)]) { + Ok(LpsValueF32::I32(got)) => report.record( + "to_int(1000000.5) truncates to 1000000", + got == 1_000_000, + got as u32, + 1_000_000, + ), + other => { + println!("[f32-soft] FAIL to_int: {other:?}"); + report.record("to_int(1000000.5) truncates to 1000000", false, 0, 1_000_000); + } + } +} diff --git a/lp-riscv/lp-riscv-emu/src/emu/emulator/execution.rs b/lp-riscv/lp-riscv-emu/src/emu/emulator/execution.rs index 4b11127d2..51bf39425 100644 --- a/lp-riscv/lp-riscv-emu/src/emu/emulator/execution.rs +++ b/lp-riscv/lp-riscv-emu/src/emu/emulator/execution.rs @@ -67,9 +67,21 @@ impl Riscv32Emulator { let pc = self.pc; let exec_result = match self.log_level { LogLevel::None => { - decode_execute::(inst_word, pc, &mut self.regs, &mut self.memory)? + decode_execute::( + inst_word, + pc, + &mut self.regs, + &mut self.memory, + &mut self.fp, + )? } - _ => decode_execute::(inst_word, pc, &mut self.regs, &mut self.memory)?, + _ => decode_execute::( + inst_word, + pc, + &mut self.regs, + &mut self.memory, + &mut self.fp, + )?, }; self.after_execute(pc, &exec_result); diff --git a/lp-riscv/lp-riscv-emu/src/emu/emulator/registers.rs b/lp-riscv/lp-riscv-emu/src/emu/emulator/registers.rs index 5077789a6..7c59e6921 100644 --- a/lp-riscv/lp-riscv-emu/src/emu/emulator/registers.rs +++ b/lp-riscv/lp-riscv-emu/src/emu/emulator/registers.rs @@ -1,5 +1,6 @@ //! Register, PC, and memory accessor methods. +use super::super::fp_regs::FpRegs; use super::state::Riscv32Emulator; use lp_emu_core::Memory; use lp_riscv_inst::Gpr; @@ -23,6 +24,30 @@ impl Riscv32Emulator { } } + /// Read `f[index]` as a raw binary32 bit pattern (RV32F, FLEN = 32). + /// + /// Raw bits, never an `f32`: a signaling NaN or a NaN payload must survive + /// inspection unchanged. Unlike `x0`, `f0` is an ordinary register. + pub fn get_fp_register(&self, index: u8) -> u32 { + self.fp.read_single(index) + } + + /// Write a raw binary32 bit pattern to `f[index]`. + pub fn set_fp_register(&mut self, index: u8, bits: u32) { + self.fp.write_single(index, bits); + } + + /// The full F-extension state (`f0`–`f31`, `fflags`, `frm`). + pub fn fp_regs(&self) -> &FpRegs { + &self.fp + } + + /// Mutable access to the F-extension state, for test setup and hosts that + /// need to seed or clear `fcsr`. + pub fn fp_regs_mut(&mut self) -> &mut FpRegs { + &mut self.fp + } + /// Get the current program counter. pub fn get_pc(&self) -> u32 { self.pc diff --git a/lp-riscv/lp-riscv-emu/src/emu/emulator/run_loops.rs b/lp-riscv/lp-riscv-emu/src/emu/emulator/run_loops.rs index 372259c92..f718c9506 100644 --- a/lp-riscv/lp-riscv-emu/src/emu/emulator/run_loops.rs +++ b/lp-riscv/lp-riscv-emu/src/emu/emulator/run_loops.rs @@ -271,8 +271,13 @@ impl Riscv32Emulator { let pc = self.pc; // Execute using fast path (no logging) - let exec_result = - decode_execute::(inst_word, pc, &mut self.regs, &mut self.memory)?; + let exec_result = decode_execute::( + inst_word, + pc, + &mut self.regs, + &mut self.memory, + &mut self.fp, + )?; self.after_execute(pc, &exec_result); let pc_increment = u32::from(exec_result.inst_size); @@ -356,8 +361,13 @@ impl Riscv32Emulator { let pc = self.pc; // Execute using logging path - let exec_result = - decode_execute::(inst_word, pc, &mut self.regs, &mut self.memory)?; + let exec_result = decode_execute::( + inst_word, + pc, + &mut self.regs, + &mut self.memory, + &mut self.fp, + )?; self.after_execute(pc, &exec_result); let pc_increment = u32::from(exec_result.inst_size); diff --git a/lp-riscv/lp-riscv-emu/src/emu/emulator/state.rs b/lp-riscv/lp-riscv-emu/src/emu/emulator/state.rs index a3462da55..9b5c5046a 100644 --- a/lp-riscv/lp-riscv-emu/src/emu/emulator/state.rs +++ b/lp-riscv/lp-riscv-emu/src/emu/emulator/state.rs @@ -3,6 +3,7 @@ extern crate alloc; use super::super::executor::ExecutionResult; +use super::super::fp_regs::FpRegs; #[cfg(feature = "std")] use alloc::boxed::Box; #[cfg(feature = "std")] @@ -59,6 +60,11 @@ impl core::fmt::Debug for FrameOutcome { /// RISC-V 32-bit emulator state. pub struct Riscv32Emulator { pub(super) regs: [i32; 32], + /// RV32F architectural state: `f0`–`f31` plus `fcsr` (see [`FpRegs`]). + /// + /// Always present, not feature-gated: the emulator decodes RV32F + /// unconditionally, and 132 bytes of state is not worth a cfg. + pub(super) fp: FpRegs, pub(super) pc: u32, pub(super) memory: Memory, pub(super) instruction_count: u64, @@ -103,6 +109,7 @@ impl Riscv32Emulator { Self { regs: [0; 32], + fp: FpRegs::new(), pc: 0, memory: Memory::with_default_addresses(code, ram), instruction_count: 0, @@ -141,6 +148,7 @@ impl Riscv32Emulator { Self { regs: [0; 32], + fp: FpRegs::new(), pc: 0, memory, instruction_count: 0, diff --git a/lp-riscv/lp-riscv-emu/src/emu/executor/float.rs b/lp-riscv/lp-riscv-emu/src/emu/executor/float.rs new file mode 100644 index 000000000..f668da5c7 --- /dev/null +++ b/lp-riscv/lp-riscv-emu/src/emu/executor/float.rs @@ -0,0 +1,1239 @@ +//! RV32F single-precision floating-point execution. +//! +//! Implemented from *The RISC-V Instruction Set Manual, Volume I: Unprivileged +//! Architecture*, version **20240411**, Chapter 21 (`"F" Standard Extension for +//! Single-Precision Floating-Point, Version 2.2`), cross-read with IEEE 754-2008 +//! where the RISC-V text defers to it. Citations name the section title as well +//! as its number so they survive a renumbering. No GPL implementation (QEMU, +//! GDB, GCC, glibc soft-float) was read or transliterated; see +//! `docs/adr/2026-07-29-license-provenance-discipline.md`. +//! +//! ## Why this is a soft-float implementation +//! +//! Every arithmetic result below is computed with integer arithmetic on the +//! exact significand, then rounded once. Native host `f32` operations are not +//! used, for three reasons that are each individually disqualifying: +//! +//! 1. **Rounding modes.** RV32F has five (`RNE RTZ RDN RUP RMM`). Rust's `f32` +//! operators only give the host's current mode, which is always `RNE`. +//! 2. **Exception flags.** `NV DZ OF UF NX` must be reported exactly. Rust +//! exposes no access to the host FPU status word. +//! 3. **NaN canonicalization.** §21.3 requires the *canonical* NaN +//! `0x7fc00000` out of every NaN-producing operation; hosts propagate +//! operand payloads instead. +//! +//! The consequence is that this emulator's flags are **exact, not estimated**: +//! `NX`, `UF`, `OF`, `DZ` and `NV` come out of the same rounding step that +//! produces the result. There is no approximation to disclose. +//! +//! It also means the crate stays `no_std`-clean: `f32::sqrt` and +//! `f32::mul_add` live in `std`, and `lp-riscv-emu` is built without `std` by +//! `fw-emu`. +//! +//! ## Subnormals are *not* flushed +//! +//! §21.4 (`Subnormal Arithmetic`) requires full IEEE 754 subnormal behaviour: +//! subnormal inputs are used at their real value and subnormal results are +//! produced, never flushed to zero. `docs/design/float.md` §4 lists +//! flush-to-zero as *target-defined* — that latitude is about the **shader** +//! tier on wasm/GPU/S3, and explicitly records that "wasm and RV32F preserve +//! denormals (their specs require it)". Do not add an FTZ mode here. +//! +//! ## Fused multiply-add is fused +//! +//! The `FMADD.S` family rounds **once**, at the end (§21.6). The product is +//! kept exact (48 bits of significand) and added to the addend before any +//! rounding happens, which is what makes it differ from a separate `FMUL.S` +//! followed by `FADD.S`. `f32::mul_add` would be the host primitive for this if +//! `std` were available; the integer path here is the same operation. +//! +//! ## Zcf (`C.FLW` / `C.FSW`) is out of scope +//! +//! The compressed float loads and stores are a separate extension (`Zcf`) and +//! are not decoded here or in `compressed.rs`. Nothing in this repo emits them: +//! the shipping target is RV32IMAC, which has no `F` at all, and the F-bearing +//! successor we expect (`RV32IMAFC`, per the ESP32-S31 roadmap note) would need +//! `Zcf` added deliberately as its own change, with its own encodings and +//! tests. A `C.FLW` word reaching the decoder today falls through +//! `compressed.rs` as an unknown compressed instruction, which is the correct +//! answer for a hart that does not implement `Zcf`. + +extern crate alloc; + +use super::{ExecutionResult, InstClass, LoggingMode, read_reg}; +use crate::emu::{ + error::EmulatorError, + fp_regs::{FFLAG_DZ, FFLAG_NV, FFLAG_NX, FFLAG_OF, FFLAG_UF, FpRegs, RoundingMode}, +}; +use lp_emu_core::Memory; +use lp_riscv_inst::Gpr; + +/// Opcode `LOAD-FP`: `FLW` (with `funct3` = `010`). +pub(super) const OPCODE_LOAD_FP: u8 = 0x07; +/// Opcode `STORE-FP`: `FSW` (with `funct3` = `010`). +pub(super) const OPCODE_STORE_FP: u8 = 0x27; +/// Opcode `MADD`: `FMADD.S`. +pub(super) const OPCODE_MADD: u8 = 0x43; +/// Opcode `MSUB`: `FMSUB.S`. +pub(super) const OPCODE_MSUB: u8 = 0x47; +/// Opcode `NMSUB`: `FNMSUB.S`. +pub(super) const OPCODE_NMSUB: u8 = 0x4b; +/// Opcode `NMADD`: `FNMADD.S`. +pub(super) const OPCODE_NMADD: u8 = 0x4f; +/// Opcode `OP-FP`: every remaining RV32F computational / compare / move. +pub(super) const OPCODE_OP_FP: u8 = 0x53; + +/// The canonical quiet NaN, §21.3 `NaN Generation and Propagation`. +/// +/// RISC-V does **not** propagate NaN payloads. Any operation that produces a +/// NaN — whether from non-NaN operands (`0/0`, `inf - inf`, `sqrt(-1)`) or by +/// receiving one — writes exactly this pattern. The only exceptions are the +/// pure bit movers (`FSGNJ`/`FMV`/loads/stores), which never interpret their +/// operand at all. +const CANONICAL_NAN: u32 = 0x7fc0_0000; +/// Sign bit of a binary32. +const SIGN_BIT: u32 = 0x8000_0000; +/// Significand (trailing) field of a binary32. +const FRAC_MASK: u32 = 0x007f_ffff; +/// The quiet bit — the significand's most significant bit. Set means quiet. +const QUIET_BIT: u32 = 0x0040_0000; +/// `+inf`. +const POS_INF: u32 = 0x7f80_0000; +/// The largest finite binary32, returned by overflow under the directed modes. +const MAX_FINITE: u32 = 0x7f7f_ffff; + +/// Decode and execute an RV32F instruction. +/// +/// Dispatched from [`super::decode_execute`] for the five F-extension opcodes. +/// `lp-riscv-inst` has no F support, so the instruction word is decoded here. +pub(super) fn decode_execute_float( + inst_word: u32, + pc: u32, + regs: &mut [i32; 32], + memory: &mut Memory, + fp: &mut FpRegs, +) -> Result { + match (inst_word & 0x7f) as u8 { + OPCODE_LOAD_FP => execute_flw::(inst_word, pc, regs, memory, fp), + OPCODE_STORE_FP => execute_fsw::(inst_word, pc, regs, memory, fp), + OPCODE_MADD | OPCODE_MSUB | OPCODE_NMSUB | OPCODE_NMADD => { + execute_fma_family::(inst_word, pc, regs, fp) + } + OPCODE_OP_FP => execute_op_fp::(inst_word, pc, regs, fp), + opcode => Err(invalid( + pc, + inst_word, + regs, + alloc::format!("Not a floating-point opcode: 0x{opcode:02x}"), + )), + } +} + +// --------------------------------------------------------------------------- +// Loads and stores (§21.5 `Single-Precision Load and Store Instructions`) +// --------------------------------------------------------------------------- + +/// `FLW rd, offset(rs1)` — opcode `LOAD-FP`, `funct3` = `010`, I-type. +/// +/// The spec is explicit that `FLW` does not modify the bits it loads: with +/// FLEN = 32 it is a plain 32-bit move from memory into `f[rd]`. +fn execute_flw( + inst_word: u32, + pc: u32, + regs: &mut [i32; 32], + memory: &mut Memory, + fp: &mut FpRegs, +) -> Result { + let funct3 = ((inst_word >> 12) & 0x7) as u8; + if funct3 != 0b010 { + return Err(invalid( + pc, + inst_word, + regs, + alloc::format!("Unknown LOAD-FP width: funct3=0b{funct3:03b} (only FLW on RV32F)"), + )); + } + let rd = ((inst_word >> 7) & 0x1f) as u8; + let rs1 = Gpr::new(((inst_word >> 15) & 0x1f) as u8); + let imm = (inst_word as i32) >> 20; + + let base = read_reg(regs, rs1); + let address = base.wrapping_add(imm) as u32; + let error_regs = *regs; + let value = memory + .read_word(address) + .map_err(|e| EmulatorError::from_memory_error(e, pc, error_regs))?; + fp.write_single(rd, value as u32); + + Ok(float_result(InstClass::Load)) +} + +/// `FSW rs2, offset(rs1)` — opcode `STORE-FP`, `funct3` = `010`, S-type. +fn execute_fsw( + inst_word: u32, + pc: u32, + regs: &mut [i32; 32], + memory: &mut Memory, + fp: &mut FpRegs, +) -> Result { + let funct3 = ((inst_word >> 12) & 0x7) as u8; + if funct3 != 0b010 { + return Err(invalid( + pc, + inst_word, + regs, + alloc::format!("Unknown STORE-FP width: funct3=0b{funct3:03b} (only FSW on RV32F)"), + )); + } + let rs1 = Gpr::new(((inst_word >> 15) & 0x1f) as u8); + let rs2 = ((inst_word >> 20) & 0x1f) as u8; + // S-type immediate: imm[11:5] in bits 31:25, imm[4:0] in bits 11:7. + let imm = (((inst_word as i32) >> 25) << 5) | (((inst_word >> 7) & 0x1f) as i32); + + let base = read_reg(regs, rs1); + let address = base.wrapping_add(imm) as u32; + let error_regs = *regs; + memory + .write_word(address, fp.read_single(rs2) as i32) + .map_err(|e| EmulatorError::from_memory_error(e, pc, error_regs))?; + + Ok(float_result(InstClass::Store)) +} + +// --------------------------------------------------------------------------- +// Fused multiply-add family (§21.6) +// --------------------------------------------------------------------------- + +/// `FMADD.S` / `FMSUB.S` / `FNMSUB.S` / `FNMADD.S` — R4-type. +/// +/// §21.6 spells the four out individually, and the two negating forms are +/// **not** `-(a*b ± c)`: `FNMSUB.S` negates the product and *adds* `rs3`, and +/// `FNMADD.S` negates the product and *subtracts* `rs3`. The distinction is +/// observable in the sign of an exactly-zero result, so it is implemented as +/// written rather than by negating the final sum. The spec calls out the +/// naming as differing from other ISAs. +fn execute_fma_family( + inst_word: u32, + pc: u32, + regs: &mut [i32; 32], + fp: &mut FpRegs, +) -> Result { + let opcode = (inst_word & 0x7f) as u8; + // fmt (bits 26:25) selects the format; 00 = S. RV32F implements no other. + let fmt = (inst_word >> 25) & 0x3; + if fmt != 0b00 { + return Err(invalid( + pc, + inst_word, + regs, + alloc::format!("Unsupported FP format in fused multiply-add: fmt=0b{fmt:02b}"), + )); + } + let rd = ((inst_word >> 7) & 0x1f) as u8; + let rs1 = ((inst_word >> 15) & 0x1f) as u8; + let rs2 = ((inst_word >> 20) & 0x1f) as u8; + let rs3 = ((inst_word >> 27) & 0x1f) as u8; + let rm = rounding_mode(inst_word, pc, regs, fp)?; + + let a = fp.read_single(rs1); + let b = fp.read_single(rs2); + let c = fp.read_single(rs3); + + let (a, c) = match opcode { + OPCODE_MADD => (a, c), // ( a * b) + c + OPCODE_MSUB => (a, c ^ SIGN_BIT), // ( a * b) - c + OPCODE_NMSUB => (a ^ SIGN_BIT, c), // (-a * b) + c + _ => (a ^ SIGN_BIT, c ^ SIGN_BIT), // (-a * b) - c (OPCODE_NMADD) + }; + + let (bits, flags) = f32_fma(a, b, c, rm); + fp.write_single(rd, bits); + fp.accrue(flags); + Ok(float_result(InstClass::FloatMulAdd)) +} + +// --------------------------------------------------------------------------- +// OP-FP (§21.6 computational, §21.7 conversion/move, §21.8 compare, §21.9 class) +// --------------------------------------------------------------------------- + +/// Decode and execute an `OP-FP` (opcode `0x53`) instruction. +/// +/// `funct7` is `funct5 ‖ fmt`; `fmt` = `00` selects single precision, so every +/// RV32F `funct7` below has its low two bits clear. +fn execute_op_fp( + inst_word: u32, + pc: u32, + regs: &mut [i32; 32], + fp: &mut FpRegs, +) -> Result { + let funct7 = ((inst_word >> 25) & 0x7f) as u8; + let rs2_field = ((inst_word >> 20) & 0x1f) as u8; + let rs1_field = ((inst_word >> 15) & 0x1f) as u8; + let rm_field = ((inst_word >> 12) & 0x7) as u8; + let rd = ((inst_word >> 7) & 0x1f) as u8; + + match funct7 { + // --- FADD.S / FSUB.S / FMUL.S / FDIV.S --- + 0b000_0000 | 0b000_0100 | 0b000_1000 | 0b000_1100 => { + let rm = rounding_mode(inst_word, pc, regs, fp)?; + let a = fp.read_single(rs1_field); + let b = fp.read_single(rs2_field); + let (bits, flags) = match funct7 { + 0b000_0000 => f32_add(a, b, rm), + 0b000_0100 => f32_add(a, b ^ SIGN_BIT, rm), + 0b000_1000 => f32_mul(a, b, rm), + _ => f32_div(a, b, rm), + }; + fp.write_single(rd, bits); + fp.accrue(flags); + Ok(float_result(InstClass::FloatArith)) + } + + // --- FSQRT.S (rs2 must be 00000) --- + 0b010_1100 => { + if rs2_field != 0 { + return Err(invalid( + pc, + inst_word, + regs, + alloc::format!("FSQRT.S requires rs2=00000, got 0b{rs2_field:05b}"), + )); + } + let rm = rounding_mode(inst_word, pc, regs, fp)?; + let (bits, flags) = f32_sqrt(fp.read_single(rs1_field), rm); + fp.write_single(rd, bits); + fp.accrue(flags); + Ok(float_result(InstClass::FloatArith)) + } + + // --- FSGNJ.S / FSGNJN.S / FSGNJX.S --- + // + // §21.6, sign injection. Pure bit manipulation on the sign bit: the + // significand and exponent of rs1 pass through untouched, NaNs are + // *not* canonicalized, and no exception flag is ever raised. Routing + // these through f32 arithmetic is the classic way to get them wrong. + 0b001_0000 => { + let a = fp.read_single(rs1_field); + let b = fp.read_single(rs2_field); + let sign = match rm_field { + 0b000 => b & SIGN_BIT, // FSGNJ.S + 0b001 => !b & SIGN_BIT, // FSGNJN.S + 0b010 => (a ^ b) & SIGN_BIT, // FSGNJX.S + _ => { + return Err(invalid( + pc, + inst_word, + regs, + alloc::format!("Unknown sign-injection funct3: 0b{rm_field:03b}"), + )); + } + }; + fp.write_single(rd, (a & !SIGN_BIT) | sign); + Ok(float_result(InstClass::FloatArith)) + } + + // --- FMIN.S / FMAX.S --- + 0b001_0100 => { + let a = fp.read_single(rs1_field); + let b = fp.read_single(rs2_field); + let is_max = match rm_field { + 0b000 => false, + 0b001 => true, + _ => { + return Err(invalid( + pc, + inst_word, + regs, + alloc::format!("Unknown FMIN/FMAX funct3: 0b{rm_field:03b}"), + )); + } + }; + let (bits, flags) = f32_min_max(a, b, is_max); + fp.write_single(rd, bits); + fp.accrue(flags); + Ok(float_result(InstClass::FloatCompare)) + } + + // --- FCVT.W.S / FCVT.WU.S (float -> integer) --- + 0b110_0000 => { + let signed = match rs2_field { + 0b00000 => true, // FCVT.W.S + 0b00001 => false, // FCVT.WU.S + _ => { + return Err(invalid( + pc, + inst_word, + regs, + alloc::format!("Unknown FCVT.*.S selector: rs2=0b{rs2_field:05b}"), + )); + } + }; + let rm = rounding_mode(inst_word, pc, regs, fp)?; + let (value, flags) = f32_to_i32(fp.read_single(rs1_field), signed, rm); + write_gpr(regs, rd, value); + fp.accrue(flags); + Ok(float_result(InstClass::FloatConvert)) + } + + // --- FMV.X.W / FCLASS.S --- + 0b111_0000 => { + if rs2_field != 0 { + return Err(invalid( + pc, + inst_word, + regs, + alloc::format!("FMV.X.W/FCLASS.S require rs2=00000, got 0b{rs2_field:05b}"), + )); + } + let a = fp.read_single(rs1_field); + match rm_field { + // FMV.X.W (§21.7): moves the raw bits, sign-extending nothing + // on RV32 because both are 32 bits wide. No interpretation, no + // canonicalization, no flags. + 0b000 => write_gpr(regs, rd, a as i32), + // FCLASS.S (§21.9) + 0b001 => write_gpr(regs, rd, f32_classify(a)), + _ => { + return Err(invalid( + pc, + inst_word, + regs, + alloc::format!("Unknown FMV.X.W/FCLASS.S funct3: 0b{rm_field:03b}"), + )); + } + } + Ok(float_result(InstClass::FloatConvert)) + } + + // --- FEQ.S / FLT.S / FLE.S --- + 0b101_0000 => { + let a = fp.read_single(rs1_field); + let b = fp.read_single(rs2_field); + let (result, flags) = match rm_field { + 0b010 => f32_eq(a, b), + 0b001 => f32_lt(a, b), + 0b000 => f32_le(a, b), + _ => { + return Err(invalid( + pc, + inst_word, + regs, + alloc::format!("Unknown FP compare funct3: 0b{rm_field:03b}"), + )); + } + }; + write_gpr(regs, rd, i32::from(result)); + fp.accrue(flags); + Ok(float_result(InstClass::FloatCompare)) + } + + // --- FCVT.S.W / FCVT.S.WU (integer -> float) --- + 0b110_1000 => { + let signed = match rs2_field { + 0b00000 => true, // FCVT.S.W + 0b00001 => false, // FCVT.S.WU + _ => { + return Err(invalid( + pc, + inst_word, + regs, + alloc::format!("Unknown FCVT.S.* selector: rs2=0b{rs2_field:05b}"), + )); + } + }; + let rm = rounding_mode(inst_word, pc, regs, fp)?; + let src = read_reg(regs, Gpr::new(rs1_field)); + let (bits, flags) = i32_to_f32(src, signed, rm); + fp.write_single(rd, bits); + fp.accrue(flags); + Ok(float_result(InstClass::FloatConvert)) + } + + // --- FMV.W.X --- + // + // §21.7: moves the raw bits of the integer register into `f[rd]` + // without interpreting them. In particular it does not quiet a + // signaling NaN pattern. + 0b111_1000 => { + if rs2_field != 0 || rm_field != 0b000 { + return Err(invalid( + pc, + inst_word, + regs, + alloc::format!( + "FMV.W.X requires rs2=00000 and funct3=000, got rs2=0b{rs2_field:05b} funct3=0b{rm_field:03b}" + ), + )); + } + let value = read_reg(regs, Gpr::new(rs1_field)) as u32; + fp.write_single(rd, value); + Ok(float_result(InstClass::FloatConvert)) + } + + _ => Err(invalid( + pc, + inst_word, + regs, + alloc::format!("Unknown OP-FP funct7: 0b{funct7:07b}"), + )), + } +} + +/// Resolve the instruction's `rm` field, raising illegal-instruction for the +/// reserved encodings (§21.2). +fn rounding_mode( + inst_word: u32, + pc: u32, + regs: &[i32; 32], + fp: &FpRegs, +) -> Result { + let rm_field = ((inst_word >> 12) & 0x7) as u8; + RoundingMode::resolve(rm_field, fp.frm()).ok_or_else(|| EmulatorError::InvalidInstruction { + pc, + instruction: inst_word, + reason: alloc::format!( + "Reserved floating-point rounding mode: rm=0b{rm_field:03b}, frm=0b{:03b}", + fp.frm() + ), + regs: *regs, + }) +} + +/// Common `ExecutionResult` for a float instruction. +/// +/// `log` is always `None`: [`crate::emu::logging::InstLog`] has no float +/// variant, and `logging.rs` is outside this change. An `InstLog::Float` +/// variant belongs there before anyone relies on `LogLevel` traces covering +/// FP code. +fn float_result(class: InstClass) -> ExecutionResult { + ExecutionResult { + new_pc: None, + should_halt: false, + syscall: false, + class, + inst_size: 4, + log: None, + } +} + +fn invalid( + pc: u32, + inst_word: u32, + regs: &[i32; 32], + reason: alloc::string::String, +) -> EmulatorError { + EmulatorError::InvalidInstruction { + pc, + instruction: inst_word, + reason, + regs: *regs, + } +} + +/// Write an integer register, honouring `x0`'s hardwired zero. +#[inline] +fn write_gpr(regs: &mut [i32; 32], rd: u8, value: i32) { + if rd != 0 { + regs[rd as usize] = value; + } +} + +// =========================================================================== +// Soft binary32 core +// =========================================================================== + +/// True for any NaN (quiet or signaling). +#[inline] +fn is_nan(bits: u32) -> bool { + bits & 0x7fff_ffff > POS_INF +} + +/// True for a signaling NaN: NaN with the quiet bit clear (§21.3). +#[inline] +fn is_snan(bits: u32) -> bool { + is_nan(bits) && bits & QUIET_BIT == 0 +} + +/// True for ±inf. +#[inline] +fn is_inf(bits: u32) -> bool { + bits & 0x7fff_ffff == POS_INF +} + +/// True for ±0. +#[inline] +fn is_zero(bits: u32) -> bool { + bits & 0x7fff_ffff == 0 +} + +/// True when the sign bit is set. +#[inline] +fn sign_of(bits: u32) -> bool { + bits & SIGN_BIT != 0 +} + +#[inline] +fn sign_bit_of(sign: bool) -> u32 { + if sign { SIGN_BIT } else { 0 } +} + +/// Decompose a **finite** binary32 into `(sign, exp, sig)` such that the value +/// is exactly `(-1)^sign * sig * 2^exp`. +/// +/// Both normals and subnormals come out in the same integer-significand form, +/// which is what lets the arithmetic below treat them uniformly — the +/// subnormal-preserving behaviour §21.4 requires falls out of the +/// representation rather than needing a special case. +#[inline] +fn unpack(bits: u32) -> (bool, i32, u128) { + let sign = sign_of(bits); + let biased = ((bits >> 23) & 0xff) as i32; + let frac = u128::from(bits & FRAC_MASK); + if biased == 0 { + // Subnormal: 0.frac * 2^-126 == frac * 2^-149. + (sign, -149, frac) + } else { + // Normal: 1.frac * 2^(biased-127) == (2^23 + frac) * 2^(biased-150). + (sign, biased - 150, frac | (1 << 23)) + } +} + +/// Round the exact value `(-1)^sign * (sig + eps) * 2^exp` to binary32, +/// where `eps` is in `[0, 1)` and is nonzero exactly when `extra_sticky`. +/// +/// Returns the result bits and the exception flags it raised (`NX`, `UF`, +/// `OF`). This is the single rounding step of every arithmetic instruction — +/// including the fused multiply-add, which is why it takes an arbitrarily wide +/// `sig`. +/// +/// Underflow follows the IEEE 754 default that §21.2 adopts: `UF` is raised +/// when the result is tiny **after** rounding *and* inexact. A value that +/// rounds up to the smallest normal is therefore not an underflow. +fn round_pack( + sign: bool, + exp: i32, + sig: u128, + extra_sticky: bool, + rm: RoundingMode, +) -> (u32, u8) { + let sign_bit = sign_bit_of(sign); + + if sig == 0 { + if !extra_sticky { + return (sign_bit, 0); + } + // A nonzero magnitude smaller than the least significant bit we kept. + // Rounds to zero, or to the smallest subnormal under a directed mode + // that rounds away from zero in this direction. + let away = matches!( + (rm, sign), + (RoundingMode::Rdn, true) | (RoundingMode::Rup, false) + ); + let bits = if away { sign_bit | 1 } else { sign_bit }; + return (bits, FFLAG_UF | FFLAG_NX); + } + + let msb = 127 - sig.leading_zeros() as i32; + // Unbiased exponent of the exact value: it lies in [2^e, 2^(e+1)). + let e = exp + msb; + // Scale of the result's least significant bit. Normals keep 24 bits; + // subnormals are pinned to 2^-149, which is what makes gradual underflow + // come out right. + let mut q = if e >= -126 { e - 23 } else { -149 }; + + let shift = q - exp; + let (mut m, guard, mut sticky) = if shift <= 0 { + (sig << (-shift) as u32, false, false) + } else if shift >= 128 { + // Everything is below the rounding position; `sig` is nonzero so the + // whole value is sticky and the guard bit is 0. + (0u128, false, true) + } else { + let s = shift as u32; + let dropped = sig & ((1u128 << s) - 1); + let guard = (dropped >> (s - 1)) & 1 == 1; + let sticky = dropped & ((1u128 << (s - 1)) - 1) != 0; + (sig >> s, guard, sticky) + }; + sticky |= extra_sticky; + let inexact = guard || sticky; + + let round_up = match rm { + RoundingMode::Rne => guard && (sticky || m & 1 == 1), + RoundingMode::Rtz => false, + RoundingMode::Rdn => inexact && sign, + RoundingMode::Rup => inexact && !sign, + RoundingMode::Rmm => guard, + }; + if round_up { + m += 1; + } + // Rounding up out of the binade: 0x1000000 renormalizes exactly. + if m >> 24 != 0 { + m >>= 1; + q += 1; + } + + let mut flags = if inexact { FFLAG_NX } else { 0 }; + + if q + 23 > 127 { + // Overflow. §21.2 defers to IEEE 754: the result is ±inf under the + // round-to-nearest modes, and the largest finite magnitude under a + // directed mode that rounds toward zero in this direction. + flags |= FFLAG_OF | FFLAG_NX; + let to_infinity = match rm { + RoundingMode::Rne | RoundingMode::Rmm => true, + RoundingMode::Rtz => false, + RoundingMode::Rdn => sign, + RoundingMode::Rup => !sign, + }; + let magnitude = if to_infinity { POS_INF } else { MAX_FINITE }; + return (sign_bit | magnitude, flags); + } + + if q == -149 && m < (1 << 23) { + // Subnormal (or zero) result: tiny after rounding. + if inexact { + flags |= FFLAG_UF; + } + return (sign_bit | m as u32, flags); + } + + let biased_exp = (q + 150) as u32; + ( + sign_bit | (biased_exp << 23) | (m as u32 & FRAC_MASK), + flags, + ) +} + +/// Exact sum of two signed integer-significand values. +/// +/// Returns `(sign, exp, sig, sticky)` describing +/// `(-1)^sign * (sig + eps) * 2^exp` with `eps` in `[0, 1)`, nonzero exactly +/// when `sticky`. `sig == 0 && !sticky` means the sum is exactly zero, and the +/// caller must choose the zero's sign from the rounding mode. +/// +/// The alignment keeps `GUARD` extra bits below the smaller operand's original +/// position. When the exponent difference exceeds that, the bits that fall off +/// are folded into `sticky`; for an effective subtraction the difference is +/// additionally decremented by one so the returned pair still brackets the true +/// value from below (`sig < true <= sig + 1`), which is exactly the form +/// [`round_pack`] consumes. +fn add_significands( + sa: bool, + ea: i32, + ma: u128, + sb: bool, + eb: i32, + mb: u128, +) -> (bool, i32, u128, bool) { + /// Extra bits kept below the larger operand's least significant bit. + /// + /// Must be at least the widest significand we ever align (48 bits, from a + /// fused multiply-add's exact product) so the shifted-out operand can never + /// exceed the retained one. 64 also keeps `ma << GUARD` inside `u128` + /// (48 + 64 = 112 bits). + const GUARD: u32 = 64; + + // Order so the first operand has the larger exponent. + let ((sa, ea, ma), (sb, eb, mb)) = if ea >= eb { + ((sa, ea, ma), (sb, eb, mb)) + } else { + ((sb, eb, mb), (sa, ea, ma)) + }; + + let shift = (ea - eb) as u32; + let a_scaled = ma << GUARD; + let (b_scaled, lost) = if shift <= GUARD { + (mb << (GUARD - shift), false) + } else { + let s = shift - GUARD; + if s >= 128 { + (0u128, mb != 0) + } else { + (mb >> s, mb & ((1u128 << s) - 1) != 0) + } + }; + let exp = ea - GUARD as i32; + + if sa == sb { + // True value is (a_scaled + b_scaled) + eps. + (sa, exp, a_scaled + b_scaled, lost) + } else if a_scaled > b_scaled { + // True value is (a_scaled - b_scaled) - eps. Rewrite as + // (a_scaled - b_scaled - 1) + (1 - eps) so the fractional part is + // again in (0, 1). `lost` implies a_scaled >= 2^64 > b_scaled, so the + // decrement cannot underflow. + let diff = a_scaled - b_scaled - u128::from(lost); + (sa, exp, diff, lost) + } else if a_scaled < b_scaled { + // `lost` cannot hold here (it implies a_scaled > b_scaled), so exact. + (sb, exp, b_scaled - a_scaled, false) + } else { + (false, exp, 0, false) + } +} + +/// The sign of an exactly-zero sum. +/// +/// IEEE 754-2008 §6.3: when operands of opposite sign cancel exactly, the sum +/// is `+0` under every rounding attribute except `roundTowardNegative`, where +/// it is `-0`. +#[inline] +fn cancelled_zero(rm: RoundingMode) -> u32 { + if rm == RoundingMode::Rdn { SIGN_BIT } else { 0 } +} + +/// `FADD.S` (and `FSUB.S`, which pre-negates `rs2`). +fn f32_add(a: u32, b: u32, rm: RoundingMode) -> (u32, u8) { + if is_nan(a) || is_nan(b) { + return (CANONICAL_NAN, invalid_if_snan(a, b)); + } + if is_inf(a) || is_inf(b) { + if is_inf(a) && is_inf(b) && sign_of(a) != sign_of(b) { + // inf + (-inf) is invalid (§21.6 / IEEE 754 §7.2). + return (CANONICAL_NAN, FFLAG_NV); + } + return (if is_inf(a) { a } else { b }, 0); + } + if is_zero(a) && is_zero(b) { + let bits = if sign_of(a) == sign_of(b) { + a + } else { + cancelled_zero(rm) + }; + return (bits, 0); + } + if is_zero(a) { + return (b, 0); + } + if is_zero(b) { + return (a, 0); + } + + let (sa, ea, ma) = unpack(a); + let (sb, eb, mb) = unpack(b); + let (sign, exp, sig, sticky) = add_significands(sa, ea, ma, sb, eb, mb); + if sig == 0 && !sticky { + return (cancelled_zero(rm), 0); + } + round_pack(sign, exp, sig, sticky, rm) +} + +/// `FMUL.S`. +fn f32_mul(a: u32, b: u32, rm: RoundingMode) -> (u32, u8) { + if is_nan(a) || is_nan(b) { + return (CANONICAL_NAN, invalid_if_snan(a, b)); + } + let sign = sign_of(a) ^ sign_of(b); + if is_inf(a) || is_inf(b) { + if is_zero(a) || is_zero(b) { + // 0 * inf is invalid. + return (CANONICAL_NAN, FFLAG_NV); + } + return (sign_bit_of(sign) | POS_INF, 0); + } + if is_zero(a) || is_zero(b) { + return (sign_bit_of(sign), 0); + } + + let (_, ea, ma) = unpack(a); + let (_, eb, mb) = unpack(b); + // Exact: two 24-bit significands make at most a 48-bit product. + round_pack(sign, ea + eb, ma * mb, false, rm) +} + +/// `FDIV.S`. +fn f32_div(a: u32, b: u32, rm: RoundingMode) -> (u32, u8) { + if is_nan(a) || is_nan(b) { + return (CANONICAL_NAN, invalid_if_snan(a, b)); + } + let sign = sign_of(a) ^ sign_of(b); + if is_inf(a) { + if is_inf(b) { + return (CANONICAL_NAN, FFLAG_NV); + } + return (sign_bit_of(sign) | POS_INF, 0); + } + if is_inf(b) { + return (sign_bit_of(sign), 0); + } + if is_zero(a) { + if is_zero(b) { + // 0/0 is invalid, not a divide-by-zero. + return (CANONICAL_NAN, FFLAG_NV); + } + return (sign_bit_of(sign), 0); + } + if is_zero(b) { + // Finite nonzero divided by zero: the DZ flag, §21.2. + return (sign_bit_of(sign) | POS_INF, FFLAG_DZ); + } + + let (_, ea, ma) = unpack(a); + let (_, eb, mb) = unpack(b); + // 64 extra quotient bits: `ma` is at most 24 bits and `mb` at least 1, so + // the quotient carries at least 40 significant bits, which is more than + // the 24 + guard + sticky the rounding step needs. The remainder is the + // exact sticky. + let numerator = ma << 64; + let quotient = numerator / mb; + let remainder = numerator % mb; + round_pack(sign, ea - eb - 64, quotient, remainder != 0, rm) +} + +/// `FSQRT.S`. +fn f32_sqrt(a: u32, rm: RoundingMode) -> (u32, u8) { + if is_nan(a) { + return (CANONICAL_NAN, invalid_if_snan(a, a)); + } + if is_zero(a) { + // IEEE 754 §5.4.1: sqrt(-0) is -0, and sqrt(+0) is +0. + return (a, 0); + } + if sign_of(a) { + // sqrt of any negative nonzero (including -inf) is invalid. + return (CANONICAL_NAN, FFLAG_NV); + } + if is_inf(a) { + return (a, 0); + } + + let (_, exp, sig) = unpack(a); + // Force an even exponent so the square root of the scale is exact. + let (sig, exp) = if exp & 1 != 0 { + (sig << 1, exp - 1) + } else { + (sig, exp) + }; + // 80 extra bits give at least 40 significant root bits, by the same + // argument as FDIV.S. `sig` is at most 25 bits here, so `sig << 80` stays + // inside u128. + let (root, exact) = isqrt(sig << 80); + round_pack(false, exp / 2 - 40, root, !exact, rm) +} + +/// `FMADD.S`-family kernel: `(a * b) + c` with a **single** rounding. +/// +/// The product's 48-bit significand is never rounded before the addend is +/// folded in — that is the whole content of "fused" (§21.6). +fn f32_fma(a: u32, b: u32, c: u32, rm: RoundingMode) -> (u32, u8) { + // §21.6: the fused multiply-add instructions must set NV when the + // multiplicands are inf and zero, **even when the addend is a quiet NaN**. + // IEEE 754-2008 §7.2 leaves that case implementation-defined; RISC-V pins + // it, so the check goes ahead of the NaN check. + let product_invalid = + (is_inf(a) && is_zero(b)) || (is_zero(a) && is_inf(b)); + if product_invalid { + return (CANONICAL_NAN, FFLAG_NV); + } + if is_nan(a) || is_nan(b) || is_nan(c) { + let nv = if is_snan(a) || is_snan(b) || is_snan(c) { + FFLAG_NV + } else { + 0 + }; + return (CANONICAL_NAN, nv); + } + + let product_sign = sign_of(a) ^ sign_of(b); + if is_inf(a) || is_inf(b) { + // The product is ±inf; neither multiplicand is zero (checked above). + if is_inf(c) && sign_of(c) != product_sign { + return (CANONICAL_NAN, FFLAG_NV); + } + return (sign_bit_of(product_sign) | POS_INF, 0); + } + if is_inf(c) { + return (c, 0); + } + if is_zero(a) || is_zero(b) { + // The product is a signed zero; reuse the addition's zero rules so the + // sign of a (+0) + (-0) result follows the rounding mode. + return f32_add(sign_bit_of(product_sign), c, rm); + } + + let (_, ea, ma) = unpack(a); + let (_, eb, mb) = unpack(b); + let product_exp = ea + eb; + let product_sig = ma * mb; + + if is_zero(c) { + // Adding a zero cannot change a nonzero product's value, and the + // product is nonzero here, so the addend's sign is irrelevant. + return round_pack(product_sign, product_exp, product_sig, false, rm); + } + + let (sc, ec, mc) = unpack(c); + let (sign, exp, sig, sticky) = + add_significands(product_sign, product_exp, product_sig, sc, ec, mc); + if sig == 0 && !sticky { + return (cancelled_zero(rm), 0); + } + round_pack(sign, exp, sig, sticky, rm) +} + +/// `FMIN.S` / `FMAX.S`, §21.6. +/// +/// Three departures from a naive comparison, all spelled out by the spec: +/// +/// - If exactly one operand is NaN, the result is the **other** operand. +/// - If both are NaN, the result is the canonical NaN. +/// - A signaling NaN operand sets `NV` **even when the result is not a NaN**. +/// +/// And, "for the purposes of these instructions only", `-0.0` is considered +/// less than `+0.0` — so `FMIN.S(-0.0, +0.0)` is `-0.0`, unlike the `FLT.S` +/// comparison, where the two are equal. +fn f32_min_max(a: u32, b: u32, is_max: bool) -> (u32, u8) { + let flags = if is_snan(a) || is_snan(b) { + FFLAG_NV + } else { + 0 + }; + if is_nan(a) && is_nan(b) { + return (CANONICAL_NAN, flags); + } + if is_nan(a) { + return (b, flags); + } + if is_nan(b) { + return (a, flags); + } + let ka = min_max_key(a); + let kb = min_max_key(b); + let take_a = if is_max { ka >= kb } else { ka <= kb }; + (if take_a { a } else { b }, flags) +} + +/// A monotone integer key over non-NaN binary32 values in which `-0 < +0`. +/// +/// Only [`f32_min_max`] uses it; the comparison instructions use +/// [`compare_key`], where `-0 == +0`. +#[inline] +fn min_max_key(bits: u32) -> i64 { + let magnitude = i64::from(bits & 0x7fff_ffff); + if sign_of(bits) { + -magnitude - 1 + } else { + magnitude + } +} + +/// A monotone integer key over non-NaN binary32 values in which `-0 == +0`. +#[inline] +fn compare_key(bits: u32) -> i64 { + let magnitude = i64::from(bits & 0x7fff_ffff); + if sign_of(bits) { -magnitude } else { magnitude } +} + +/// `FEQ.S`, §21.8: a **quiet** comparison — only a signaling NaN sets `NV`. +fn f32_eq(a: u32, b: u32) -> (bool, u8) { + if is_nan(a) || is_nan(b) { + return (false, invalid_if_snan(a, b)); + } + (compare_key(a) == compare_key(b), 0) +} + +/// `FLT.S`, §21.8: a **signaling** comparison — any NaN operand sets `NV`. +fn f32_lt(a: u32, b: u32) -> (bool, u8) { + if is_nan(a) || is_nan(b) { + return (false, FFLAG_NV); + } + (compare_key(a) < compare_key(b), 0) +} + +/// `FLE.S`, §21.8: a **signaling** comparison — any NaN operand sets `NV`. +fn f32_le(a: u32, b: u32) -> (bool, u8) { + if is_nan(a) || is_nan(b) { + return (false, FFLAG_NV); + } + (compare_key(a) <= compare_key(b), 0) +} + +/// `FCLASS.S`, §21.9 (`Single-Precision Floating-Point Classify Instruction`). +/// +/// Exactly one bit of the 10-bit mask is set. Bit order, from the spec's table: +/// +/// | Bit | Meaning | +/// |----:|-----------------------------| +/// | 0 | `-inf` | +/// | 1 | negative normal | +/// | 2 | negative subnormal | +/// | 3 | `-0` | +/// | 4 | `+0` | +/// | 5 | positive subnormal | +/// | 6 | positive normal | +/// | 7 | `+inf` | +/// | 8 | signaling NaN | +/// | 9 | quiet NaN | +/// +/// Sets no exception flags — not even for a signaling NaN, which is the point +/// of having a classify instruction. +fn f32_classify(bits: u32) -> i32 { + let negative = sign_of(bits); + let biased_exp = (bits >> 23) & 0xff; + let frac = bits & FRAC_MASK; + + let bit = if biased_exp == 0xff { + if frac == 0 { + if negative { 0 } else { 7 } + } else if frac & QUIET_BIT == 0 { + 8 + } else { + 9 + } + } else if biased_exp == 0 { + if frac == 0 { + if negative { 3 } else { 4 } + } else if negative { + 2 + } else { + 5 + } + } else if negative { + 1 + } else { + 6 + }; + 1 << bit +} + +/// `FCVT.W.S` (`signed`) / `FCVT.WU.S`, §21.7. +/// +/// The spec's rule is *saturating*, and the range check applies to the +/// **rounded** result: "if the rounded result is not representable in the +/// destination format, it is clipped to the nearest value and the invalid flag +/// is set". So `-0.5` with `RTZ` is a valid `FCVT.WU.S` producing `0` with +/// `NX`, while the same value with `RDN` rounds to `-1`, is out of range, and +/// produces `0` with `NV`. +/// +/// NaN converts to the **maximum** of the destination type (`2^31 - 1` signed, +/// `2^32 - 1` unsigned) with `NV`, per the spec's table of invalid-input +/// behaviour. This is *not* what a Rust `as` cast does — `as` maps NaN to `0` +/// — nor what C's undefined behaviour permits, which is why the case is +/// handled explicitly rather than delegated to a cast. +/// +/// An out-of-range conversion reports `NV` only: IEEE 754 does not also signal +/// inexact when invalid is signalled. +fn f32_to_i32(bits: u32, signed: bool, rm: RoundingMode) -> (i32, u8) { + let clipped = |negative: bool| -> i32 { + match (signed, negative) { + (true, true) => i32::MIN, + (true, false) => i32::MAX, + (false, true) => 0, + (false, false) => u32::MAX as i32, + } + }; + + if is_nan(bits) { + // NaN is not negative for this purpose: it saturates to the maximum. + return (clipped(false), FFLAG_NV); + } + if is_inf(bits) { + return (clipped(sign_of(bits)), FFLAG_NV); + } + if is_zero(bits) { + return (0, 0); + } + + let (sign, exp, sig) = unpack(bits); + if exp > 40 { + // |value| >= 2^40, far outside both destination ranges. The bound also + // keeps `sig << exp` inside u128 below (24 + 40 bits). + return (clipped(sign), FFLAG_NV); + } + + let (magnitude, guard, sticky) = if exp >= 0 { + (sig << exp as u32, false, false) + } else { + let s = (-exp) as u32; + if s >= 128 { + (0u128, false, true) + } else { + let dropped = sig & ((1u128 << s) - 1); + let guard = (dropped >> (s - 1)) & 1 == 1; + let sticky = dropped & ((1u128 << (s - 1)) - 1) != 0; + (sig >> s, guard, sticky) + } + }; + let inexact = guard || sticky; + let round_up = match rm { + RoundingMode::Rne => guard && (sticky || magnitude & 1 == 1), + RoundingMode::Rtz => false, + RoundingMode::Rdn => inexact && sign, + RoundingMode::Rup => inexact && !sign, + RoundingMode::Rmm => guard, + }; + let magnitude = magnitude + u128::from(round_up); + + let value = if sign { + -(magnitude as i128) + } else { + magnitude as i128 + }; + + let (lo, hi) = if signed { + (i128::from(i32::MIN), i128::from(i32::MAX)) + } else { + (0, i128::from(u32::MAX)) + }; + if value < lo { + return (clipped(true), FFLAG_NV); + } + if value > hi { + return (clipped(false), FFLAG_NV); + } + + let out = if signed { + value as i32 + } else { + (value as u32) as i32 + }; + (out, if inexact { FFLAG_NX } else { 0 }) +} + +/// `FCVT.S.W` (`signed`) / `FCVT.S.WU`, §21.7. +/// +/// Always exact for magnitudes below `2^24`; larger values round per `rm` and +/// set `NX`. Cannot overflow: `2^32` is far inside binary32's range. +fn i32_to_f32(value: i32, signed: bool, rm: RoundingMode) -> (u32, u8) { + let (sign, magnitude) = if signed { + (value < 0, u128::from(i64::from(value).unsigned_abs())) + } else { + (false, u128::from(value as u32)) + }; + if magnitude == 0 { + return (0, 0); + } + round_pack(sign, 0, magnitude, false, rm) +} + +/// `NV` if either operand is a signaling NaN, for the quiet operations. +#[inline] +fn invalid_if_snan(a: u32, b: u32) -> u8 { + if is_snan(a) || is_snan(b) { + FFLAG_NV + } else { + 0 + } +} + +/// Integer square root of a `u128`: returns `(floor(sqrt(n)), n_is_a_square)`. +/// +/// Digit-by-digit (two bits per step) restoring square root — the schoolbook +/// algorithm, written from its definition. `sqrt` is not available in `core`, +/// and this form gives the exact remainder, which is precisely the sticky bit +/// [`round_pack`] needs to round `FSQRT.S` correctly in every mode. +fn isqrt(n: u128) -> (u128, bool) { + let mut remainder: u128 = 0; + let mut root: u128 = 0; + for i in (0..64).rev() { + root <<= 1; + remainder = (remainder << 2) | ((n >> (2 * i)) & 0x3); + if root < remainder { + remainder -= root + 1; + root += 2; + } + } + (root >> 1, remainder == 0) +} diff --git a/lp-riscv/lp-riscv-emu/src/emu/executor/mod.rs b/lp-riscv/lp-riscv-emu/src/emu/executor/mod.rs index 3a3375358..66742f995 100644 --- a/lp-riscv/lp-riscv-emu/src/emu/executor/mod.rs +++ b/lp-riscv/lp-riscv-emu/src/emu/executor/mod.rs @@ -2,7 +2,7 @@ extern crate alloc; -use crate::emu::{error::EmulatorError, logging::InstLog}; +use crate::emu::{error::EmulatorError, fp_regs::FpRegs, logging::InstLog}; use lp_emu_core::Memory; pub use lp_emu_core::InstClass; @@ -58,11 +58,16 @@ pub(super) fn read_reg(regs: &[i32; 32], reg: lp_riscv_inst::Gpr) -> i32 { /// /// Decodes the instruction word and executes it in a single step, /// eliminating the intermediate `Inst` enum allocation. +/// +/// `fp` carries the RV32F architectural state (see [`FpRegs`]). Only the +/// floating-point arms and the three F-extension CSRs in [`system`] read or +/// write it; the integer categories never see it. pub fn decode_execute( inst_word: u32, pc: u32, regs: &mut [i32; 32], _memory: &mut Memory, + fp: &mut FpRegs, ) -> Result { // Check if compressed instruction (bits [1:0] != 0b11) if (inst_word & 0x3) != 0x3 { @@ -110,7 +115,7 @@ pub fn decode_execute( } 0x73 => { // System instructions (ECALL, EBREAK, CSR) - system::decode_execute_system::(inst_word, pc, regs, _memory) + system::decode_execute_system::(inst_word, pc, regs, _memory, fp) } 0x0f => { // FENCE/FENCE.I instructions @@ -120,6 +125,18 @@ pub fn decode_execute( // Atomic instructions (A extension) atomic::decode_execute_atomic::(inst_word, pc, regs, _memory) } + // Floating point (F extension). `lp-riscv-inst` has no F support, so + // `float` decodes the word itself. Compressed float encodings (Zcf) + // are deliberately not implemented — see `float`'s module docs. + float::OPCODE_LOAD_FP + | float::OPCODE_STORE_FP + | float::OPCODE_MADD + | float::OPCODE_MSUB + | float::OPCODE_NMSUB + | float::OPCODE_NMADD + | float::OPCODE_OP_FP => { + float::decode_execute_float::(inst_word, pc, regs, _memory, fp) + } _ => Err(EmulatorError::InvalidInstruction { pc, instruction: inst_word, @@ -134,6 +151,7 @@ pub mod arithmetic; pub mod atomic; pub mod branch; pub mod compressed; +pub mod float; pub mod immediate; pub mod jump; pub mod load_store; diff --git a/lp-riscv/lp-riscv-emu/src/emu/executor/system.rs b/lp-riscv/lp-riscv-emu/src/emu/executor/system.rs index 0606594a5..4c1de7139 100644 --- a/lp-riscv/lp-riscv-emu/src/emu/executor/system.rs +++ b/lp-riscv/lp-riscv-emu/src/emu/executor/system.rs @@ -2,9 +2,10 @@ extern crate alloc; -use super::{ExecutionResult, InstClass, LoggingMode}; +use super::{ExecutionResult, InstClass, LoggingMode, read_reg}; use crate::emu::{ error::EmulatorError, + fp_regs::FpRegs, logging::{InstLog, SystemKind}, }; use lp_emu_core::Memory; @@ -16,6 +17,7 @@ pub(super) fn decode_execute_system( pc: u32, regs: &mut [i32; 32], _memory: &mut Memory, + fp: &mut FpRegs, ) -> Result { let i = TypeI::from_riscv(inst_word); let funct3 = i.func; @@ -41,183 +43,97 @@ pub(super) fn decode_execute_system( // CSR instructions let rd = Gpr::new(i.rd); let csr = (imm & 0xfff) as u16; - match funct3 { - 0b001 => { + // Register-sourced forms read rs1; immediate forms take the zero- + // extended 5-bit `zimm` from the same instruction field. + let (source, source_is_zero) = match funct3 { + 0b001 | 0b010 | 0b011 => { let rs1 = Gpr::new(i.rs1); - execute_csrrw::(rd, rs1, csr, inst_word, pc, regs) + (read_reg(regs, rs1) as u32, rs1.num() == 0) } - 0b010 => { - let rs1 = Gpr::new(i.rs1); - execute_csrrs::(rd, rs1, csr, inst_word, pc, regs) - } - 0b011 => { - let rs1 = Gpr::new(i.rs1); - execute_csrrc::(rd, rs1, csr, inst_word, pc, regs) - } - 0b101 => { - // CSRRWI: imm is in rs1 field (bits [19:15]) - let imm_val = i.rs1 as i32; - execute_csrrwi::(rd, imm_val, csr, inst_word, pc, regs) + 0b101 | 0b110 | 0b111 => (u32::from(i.rs1), i.rs1 == 0), + _ => { + return Err(EmulatorError::InvalidInstruction { + pc, + instruction: inst_word, + reason: alloc::format!("Unknown CSR instruction: funct3=0x{funct3:x}"), + regs: *regs, + }); } - 0b110 => { - // CSRRSI: imm is in rs1 field - let imm_val = i.rs1 as i32; - execute_csrrsi::(rd, imm_val, csr, inst_word, pc, regs) - } - 0b111 => { - // CSRRCI: imm is in rs1 field - let imm_val = i.rs1 as i32; - execute_csrrci::(rd, imm_val, csr, inst_word, pc, regs) - } - _ => Err(EmulatorError::InvalidInstruction { - pc, - instruction: inst_word, - reason: alloc::format!("Unknown CSR instruction: funct3=0x{funct3:x}"), - regs: *regs, - }), - } - } -} - -#[inline(always)] -fn execute_ecall( - instruction_word: u32, - pc: u32, -) -> Result { - let log = if M::ENABLED { - Some(InstLog::System { - cycle: 0, + }; + let op = match funct3 { + 0b001 | 0b101 => CsrOp::Write, + 0b010 | 0b110 => CsrOp::Set, + _ => CsrOp::Clear, + }; + execute_csr::( + rd, + csr, + op, + source, + source_is_zero, + inst_word, pc, - instruction: instruction_word, - kind: SystemKind::Ecall, - }) - } else { - None - }; - Ok(ExecutionResult { - new_pc: None, - should_halt: false, - syscall: true, - class: InstClass::System, - inst_size: 4, - log, - }) -} - -#[inline(always)] -fn execute_ebreak( - instruction_word: u32, - pc: u32, -) -> Result { - let log = if M::ENABLED { - Some(InstLog::System { - cycle: 0, - pc, - instruction: instruction_word, - kind: SystemKind::Ebreak, - }) - } else { - None - }; - Ok(ExecutionResult { - new_pc: None, - should_halt: true, - syscall: false, - class: InstClass::System, - inst_size: 4, - log, - }) -} - -/// Decode and execute FENCE/FENCE.I instructions (opcode 0x0f). -pub(super) fn decode_execute_fence( - inst_word: u32, - pc: u32, - _regs: &mut [i32; 32], - _memory: &mut Memory, -) -> Result { - let funct3 = ((inst_word >> 12) & 0x7) as u8; - let imm = ((inst_word >> 20) & 0xfff) as u16; - let rs1 = ((inst_word >> 15) & 0x1f) as u8; - let rd = ((inst_word >> 7) & 0x1f) as u8; - - if funct3 == 0x1 && imm == 0x001 && rs1 == 0 && rd == 0 { - // FENCE.I: funct3=0x1, imm[11:0]=0x001, rs1=0, rd=0 - execute_fence_i::(inst_word, pc) - } else { - // FENCE: funct3=0x0 (or other values, but we treat as FENCE) - execute_fence::(inst_word, pc) + regs, + fp, + ) } } -#[inline(always)] -fn execute_fence( - instruction_word: u32, - pc: u32, -) -> Result { - // FENCE: Memory ordering (no-op in single-threaded emulator) - let log = if M::ENABLED { - Some(InstLog::System { - cycle: 0, - pc, - instruction: instruction_word, - kind: SystemKind::Ebreak, // Use existing kind (doesn't matter for logging) - }) - } else { - None - }; - Ok(ExecutionResult { - new_pc: None, - should_halt: false, - syscall: false, - class: InstClass::Fence, - inst_size: 4, - log, - }) -} - -#[inline(always)] -fn execute_fence_i( - instruction_word: u32, - pc: u32, -) -> Result { - // FENCE.I: Instruction cache synchronization (no-op in emulator) - let log = if M::ENABLED { - Some(InstLog::System { - cycle: 0, - pc, - instruction: instruction_word, - kind: SystemKind::Ebreak, // Use existing kind (doesn't matter for logging) - }) - } else { - None - }; - Ok(ExecutionResult { - new_pc: None, - should_halt: false, - syscall: false, - class: InstClass::Fence, - inst_size: 4, - log, - }) +/// The read-modify-write shape of a CSR instruction. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +enum CsrOp { + /// `CSRRW` / `CSRRWI`: replace the CSR with the source value. + Write, + /// `CSRRS` / `CSRRSI`: set the bits present in the source value. + Set, + /// `CSRRC` / `CSRRCI`: clear the bits present in the source value. + Clear, } +/// Execute any of the six CSR instructions. +/// +/// **Only the three F-extension CSRs are real state**: `fflags` (`0x001`), +/// `frm` (`0x002`) and `fcsr` (`0x003`), which RV32F requires +/// (RISC-V Unprivileged ISA v20240411 §21.2, `Floating-Point Control and +/// Status Register`). Every other CSR keeps this emulator's long-standing +/// behaviour — reads return 0 and writes are discarded — because nothing in +/// this codebase models `mstatus`, the counters, or the machine-mode CSRs, and +/// pretending otherwise would be worse than the honest no-op. +#[expect( + clippy::too_many_arguments, + reason = "one call site; splitting the CSR number, op, and source into a struct would only move the arguments" +)] #[inline(always)] -fn execute_csrrw( +fn execute_csr( rd: Gpr, - _rs1: Gpr, - _csr: u16, + csr: u16, + op: CsrOp, + source: u32, + source_is_zero: bool, instruction_word: u32, pc: u32, regs: &mut [i32; 32], + fp: &mut FpRegs, ) -> Result { - // CSRRW: rd = CSR; CSR = rs1 - // In emulator, CSR operations are no-ops (we don't track CSR state) - // Just write 0 to rd (or preserve if rd is x0) - let result = 0i32; // CSR reads return 0 (no CSR state tracked) + let old = fp.read_csr(csr).unwrap_or(0); + + // CSRRS/CSRRC with a zero source (x0, or zimm == 0) must not write — + // they are the spec's read-only forms. CSRRW/CSRRWI always write. + let write = match op { + CsrOp::Write => Some(source), + CsrOp::Set if !source_is_zero => Some(old | source), + CsrOp::Clear if !source_is_zero => Some(old & !source), + _ => None, + }; + if let Some(value) = write { + // `false` here means "not an F CSR"; that is the no-op path. + let _ = fp.write_csr(csr, value); + } + if rd.num() != 0 { - regs[rd.num() as usize] = result; + regs[rd.num() as usize] = old as i32; } + let log = if M::ENABLED { Some(InstLog::System { cycle: 0, @@ -239,26 +155,16 @@ fn execute_csrrw( } #[inline(always)] -fn execute_csrrs( - rd: Gpr, - _rs1: Gpr, - _csr: u16, +fn execute_ecall( instruction_word: u32, pc: u32, - regs: &mut [i32; 32], ) -> Result { - // CSRRS: rd = CSR; CSR = CSR | rs1 - // In emulator, CSR operations are no-ops - let result = 0i32; - if rd.num() != 0 { - regs[rd.num() as usize] = result; - } let log = if M::ENABLED { Some(InstLog::System { cycle: 0, pc, instruction: instruction_word, - kind: SystemKind::Ebreak, + kind: SystemKind::Ecall, }) } else { None @@ -266,7 +172,7 @@ fn execute_csrrs( Ok(ExecutionResult { new_pc: None, should_halt: false, - syscall: false, + syscall: true, class: InstClass::System, inst_size: 4, log, @@ -274,20 +180,10 @@ fn execute_csrrs( } #[inline(always)] -fn execute_csrrc( - rd: Gpr, - _rs1: Gpr, - _csr: u16, +fn execute_ebreak( instruction_word: u32, pc: u32, - regs: &mut [i32; 32], ) -> Result { - // CSRRC: rd = CSR; CSR = CSR & ~rs1 - // In emulator, CSR operations are no-ops - let result = 0i32; - if rd.num() != 0 { - regs[rd.num() as usize] = result; - } let log = if M::ENABLED { Some(InstLog::System { cycle: 0, @@ -300,7 +196,7 @@ fn execute_csrrc( }; Ok(ExecutionResult { new_pc: None, - should_halt: false, + should_halt: true, syscall: false, class: InstClass::System, inst_size: 4, @@ -308,62 +204,39 @@ fn execute_csrrc( }) } -#[inline(always)] -fn execute_csrrwi( - rd: Gpr, - _imm: i32, - _csr: u16, - instruction_word: u32, +/// Decode and execute FENCE/FENCE.I instructions (opcode 0x0f). +pub(super) fn decode_execute_fence( + inst_word: u32, pc: u32, - regs: &mut [i32; 32], + _regs: &mut [i32; 32], + _memory: &mut Memory, ) -> Result { - // CSRRWI: rd = CSR; CSR = imm - // In emulator, CSR operations are no-ops - let result = 0i32; - if rd.num() != 0 { - regs[rd.num() as usize] = result; - } - let log = if M::ENABLED { - Some(InstLog::System { - cycle: 0, - pc, - instruction: instruction_word, - kind: SystemKind::Ebreak, - }) + let funct3 = ((inst_word >> 12) & 0x7) as u8; + let imm = ((inst_word >> 20) & 0xfff) as u16; + let rs1 = ((inst_word >> 15) & 0x1f) as u8; + let rd = ((inst_word >> 7) & 0x1f) as u8; + + if funct3 == 0x1 && imm == 0x001 && rs1 == 0 && rd == 0 { + // FENCE.I: funct3=0x1, imm[11:0]=0x001, rs1=0, rd=0 + execute_fence_i::(inst_word, pc) } else { - None - }; - Ok(ExecutionResult { - new_pc: None, - should_halt: false, - syscall: false, - class: InstClass::System, - inst_size: 4, - log, - }) + // FENCE: funct3=0x0 (or other values, but we treat as FENCE) + execute_fence::(inst_word, pc) + } } #[inline(always)] -fn execute_csrrsi( - rd: Gpr, - _imm: i32, - _csr: u16, +fn execute_fence( instruction_word: u32, pc: u32, - regs: &mut [i32; 32], ) -> Result { - // CSRRSI: rd = CSR; CSR = CSR | imm - // In emulator, CSR operations are no-ops - let result = 0i32; - if rd.num() != 0 { - regs[rd.num() as usize] = result; - } + // FENCE: Memory ordering (no-op in single-threaded emulator) let log = if M::ENABLED { Some(InstLog::System { cycle: 0, pc, instruction: instruction_word, - kind: SystemKind::Ebreak, + kind: SystemKind::Ebreak, // Use existing kind (doesn't matter for logging) }) } else { None @@ -372,33 +245,24 @@ fn execute_csrrsi( new_pc: None, should_halt: false, syscall: false, - class: InstClass::System, + class: InstClass::Fence, inst_size: 4, log, }) } #[inline(always)] -fn execute_csrrci( - rd: Gpr, - _imm: i32, - _csr: u16, +fn execute_fence_i( instruction_word: u32, pc: u32, - regs: &mut [i32; 32], ) -> Result { - // CSRRCI: rd = CSR; CSR = CSR & ~imm - // In emulator, CSR operations are no-ops - let result = 0i32; - if rd.num() != 0 { - regs[rd.num() as usize] = result; - } + // FENCE.I: Instruction cache synchronization (no-op in emulator) let log = if M::ENABLED { Some(InstLog::System { cycle: 0, pc, instruction: instruction_word, - kind: SystemKind::Ebreak, + kind: SystemKind::Ebreak, // Use existing kind (doesn't matter for logging) }) } else { None @@ -407,7 +271,7 @@ fn execute_csrrci( new_pc: None, should_halt: false, syscall: false, - class: InstClass::System, + class: InstClass::Fence, inst_size: 4, log, }) @@ -427,10 +291,17 @@ mod tests { fn test_ecall_fast_path() { let mut regs = [0i32; 32]; let mut memory = Memory::with_default_addresses(vec![], vec![]); + let mut fp = FpRegs::new(); let inst_word = encode::ecall(); - let result = - decode_execute_system::(inst_word, 0, &mut regs, &mut memory).unwrap(); + let result = decode_execute_system::( + inst_word, + 0, + &mut regs, + &mut memory, + &mut fp, + ) + .unwrap(); assert!(result.syscall); assert!(!result.should_halt); @@ -441,10 +312,17 @@ mod tests { fn test_ebreak_fast_path() { let mut regs = [0i32; 32]; let mut memory = Memory::with_default_addresses(vec![], vec![]); + let mut fp = FpRegs::new(); let inst_word = encode::ebreak(); - let result = - decode_execute_system::(inst_word, 0, &mut regs, &mut memory).unwrap(); + let result = decode_execute_system::( + inst_word, + 0, + &mut regs, + &mut memory, + &mut fp, + ) + .unwrap(); assert!(!result.syscall); assert!(result.should_halt); @@ -455,10 +333,17 @@ mod tests { fn test_ecall_logging_path() { let mut regs = [0i32; 32]; let mut memory = Memory::with_default_addresses(vec![], vec![]); + let mut fp = FpRegs::new(); let inst_word = encode::ecall(); - let result = - decode_execute_system::(inst_word, 0, &mut regs, &mut memory).unwrap(); + let result = decode_execute_system::( + inst_word, + 0, + &mut regs, + &mut memory, + &mut fp, + ) + .unwrap(); assert!(result.syscall); assert!(result.log.is_some()); @@ -466,4 +351,71 @@ mod tests { assert_eq!(kind, SystemKind::Ecall); } } + + /// Run one CSR instruction against a given FP state, returning `regs[rd]`. + fn run_csr(inst_word: u32, fp: &mut FpRegs, rd: u8) -> i32 { + let mut regs = [0i32; 32]; + regs[5] = 0x1234_5678u32 as i32; // x5, used as the rs1 source below + let mut memory = Memory::with_default_addresses(vec![], vec![]); + decode_execute_system::(inst_word, 0, &mut regs, &mut memory, fp).unwrap(); + regs[rd as usize] + } + + #[test] + fn csrrs_reads_accrued_fflags() { + let mut fp = FpRegs::new(); + fp.accrue(crate::emu::fp_regs::FFLAG_NV | crate::emu::fp_regs::FFLAG_NX); + let inst = encode::csrrs(Gpr::new(6), Gpr::new(0), crate::emu::fp_regs::CSR_FFLAGS); + assert_eq!(run_csr(inst, &mut fp, 6), 0b1_0001); + } + + #[test] + fn csrrw_fcsr_round_trips_frm_and_fflags() { + let mut fp = FpRegs::new(); + fp.set_frm(0b010); + fp.accrue(crate::emu::fp_regs::FFLAG_OF); + // x0 as the source writes 0 to fcsr and returns the old value. + let inst = encode::csrrw(Gpr::new(6), Gpr::new(0), crate::emu::fp_regs::CSR_FCSR); + let old = run_csr(inst, &mut fp, 6); + assert_eq!(old, (0b010 << 5) | 0b0_0100); + assert_eq!(fp.fcsr(), 0); + } + + #[test] + fn csrrwi_sets_frm() { + let mut fp = FpRegs::new(); + let inst = encode::csrrwi(Gpr::new(0), 0b011, crate::emu::fp_regs::CSR_FRM); + run_csr(inst, &mut fp, 0); + assert_eq!(fp.frm(), 0b011); + } + + #[test] + fn csrrci_clears_selected_fflags() { + let mut fp = FpRegs::new(); + fp.set_fflags(0b1_1111); + let inst = encode::csrrci(Gpr::new(6), 0b0_0101, crate::emu::fp_regs::CSR_FFLAGS); + let old = run_csr(inst, &mut fp, 6); + assert_eq!(old, 0b1_1111); + assert_eq!(fp.fflags(), 0b1_1010); + } + + #[test] + fn csrrs_with_x0_source_does_not_write() { + let mut fp = FpRegs::new(); + fp.set_fflags(0b0_1010); + let inst = encode::csrrs(Gpr::new(6), Gpr::new(0), crate::emu::fp_regs::CSR_FFLAGS); + run_csr(inst, &mut fp, 6); + assert_eq!(fp.fflags(), 0b0_1010); + } + + #[test] + fn non_fp_csrs_still_read_zero_and_discard_writes() { + let mut fp = FpRegs::new(); + // `cycle` (0xc00) is not modelled: reads return 0 and the write is a + // no-op that must not disturb the FP CSRs. + fp.set_fcsr(0b0110_0011); + let inst = encode::csrrw(Gpr::new(6), Gpr::new(5), 0xc00); + assert_eq!(run_csr(inst, &mut fp, 6), 0); + assert_eq!(fp.fcsr(), 0b0110_0011); + } } diff --git a/lp-riscv/lp-riscv-emu/src/emu/fp_regs.rs b/lp-riscv/lp-riscv-emu/src/emu/fp_regs.rs new file mode 100644 index 000000000..0b3fef845 --- /dev/null +++ b/lp-riscv/lp-riscv-emu/src/emu/fp_regs.rs @@ -0,0 +1,351 @@ +//! RV32F architectural state: the `f0`–`f31` register file and `fcsr`. +//! +//! Implemented from *The RISC-V Instruction Set Manual, Volume I: Unprivileged +//! Architecture*, version **20240411**, Chapter 21 (`"F" Standard Extension for +//! Single-Precision Floating-Point, Version 2.2`). Section numbers below are +//! from that release; each citation also names the section *title*, so it stays +//! findable if a later release renumbers the chapters. No GPL implementation +//! (QEMU, GDB, GCC) was consulted for code — see +//! `docs/adr/2026-07-29-license-provenance-discipline.md`. +//! +//! ## FLEN is 32 here, so there is no NaN boxing +//! +//! §21.1 (`F Register State`) gives RV32F thirty-two registers of **FLEN = 32** +//! bits. Each register holds exactly one binary32 value and nothing else, so +//! the register file below is a plain `[u32; 32]` of raw bit patterns. +//! +//! NaN boxing — the rule that a narrower value stored in a wider `f` register +//! is held with all upper bits set to 1, and that an unboxed pattern reads back +//! as the canonical NaN — is defined in the `D` chapter, §22.2 +//! (`NaN Boxing of Narrower Values`), and applies **only when FLEN > 32**. It +//! therefore does not apply to this emulator at all. Do not "fix" the register +//! file by adding boxing: on FLEN = 32 it would be wrong. +//! +//! If this file ever grows to FLEN = 64 (RV32D / RV64D), [`FpRegs::read_single`] +//! and [`FpRegs::write_single`] are the two places that change: `write_single` +//! would set the upper 32 bits to all ones, and `read_single` would return the +//! canonical NaN for any register whose upper half is not all ones. Every +//! single-precision executor goes through that pair precisely so the widening +//! has one home rather than thirty-odd call sites. + +/// Invalid operation (`NV`) — `fflags` bit 4. +/// +/// §21.2 `Floating-Point Control and Status Register`, accrued exception flag +/// field: bits 4:0 are `NV DZ OF UF NX` from bit 4 down to bit 0. +pub const FFLAG_NV: u8 = 1 << 4; +/// Divide by zero (`DZ`) — `fflags` bit 3. +pub const FFLAG_DZ: u8 = 1 << 3; +/// Overflow (`OF`) — `fflags` bit 2. +pub const FFLAG_OF: u8 = 1 << 2; +/// Underflow (`UF`) — `fflags` bit 1. +pub const FFLAG_UF: u8 = 1 << 1; +/// Inexact (`NX`) — `fflags` bit 0. +pub const FFLAG_NX: u8 = 1; + +/// Mask of the five defined accrued-exception bits. +pub const FFLAGS_MASK: u8 = 0x1f; + +/// CSR number of `fflags` (accrued exception flags alone). +pub const CSR_FFLAGS: u16 = 0x001; +/// CSR number of `frm` (dynamic rounding mode alone). +pub const CSR_FRM: u16 = 0x002; +/// CSR number of `fcsr` (`frm` in bits 7:5 over `fflags` in bits 4:0). +pub const CSR_FCSR: u16 = 0x003; + +/// A RISC-V floating-point rounding mode. +/// +/// §21.2 `Floating-Point Control and Status Register`, rounding-mode encoding: +/// +/// | Encoding | Mnemonic | Meaning | +/// |---------:|----------|--------------------------------------------| +/// | `000` | `RNE` | Round to nearest, ties to even | +/// | `001` | `RTZ` | Round towards zero | +/// | `010` | `RDN` | Round down (towards −∞) | +/// | `011` | `RUP` | Round up (towards +∞) | +/// | `100` | `RMM` | Round to nearest, ties to max magnitude | +/// | `101` | — | *Reserved* — illegal instruction | +/// | `110` | — | *Reserved* — illegal instruction | +/// | `111` | `DYN` | In an instruction: use `frm`. In `frm`: invalid | +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum RoundingMode { + /// `RNE` — round to nearest, ties to even. The IEEE-754 default, and the + /// only mode `docs/design/float.md` §2 lets the *shader* tier see. This + /// emulator implements all five because RV32F machine code may name any of + /// them, whatever the shader tier chooses to emit. + Rne, + /// `RTZ` — round towards zero (truncate). + Rtz, + /// `RDN` — round down, towards −∞. + Rdn, + /// `RUP` — round up, towards +∞. + Rup, + /// `RMM` — round to nearest, ties away from zero. + Rmm, +} + +impl RoundingMode { + /// Resolve an instruction's 3-bit `rm` field against the current `frm`. + /// + /// Returns `None` for every encoding the spec rejects, and the caller must + /// turn that into an illegal-instruction error (§21.2): `rm` = `101` or + /// `110` are reserved, and `rm` = `111` (`DYN`) is illegal when `frm` + /// itself holds a reserved or invalid value (`101`, `110`, or `111`). + pub fn resolve(inst_rm: u8, frm: u8) -> Option { + let selected = if inst_rm == 0b111 { frm } else { inst_rm }; + match selected { + 0b000 => Some(RoundingMode::Rne), + 0b001 => Some(RoundingMode::Rtz), + 0b010 => Some(RoundingMode::Rdn), + 0b011 => Some(RoundingMode::Rup), + 0b100 => Some(RoundingMode::Rmm), + // 101 and 110 are reserved; 111 reaching here means `frm` held DYN, + // which is not a rounding mode. + _ => None, + } + } +} + +/// The F extension's architectural state: `f0`–`f31` plus `fcsr`. +/// +/// Registers hold **raw 32-bit patterns**, never a decoded `f32`. Sign- +/// injection, `FMV.X.W`/`FMV.W.X`, `FCLASS.S` and the load/store pair all move +/// bits without interpreting them, and routing any of those through an `f32` +/// would quietly canonicalize NaN payloads that the spec requires be preserved. +#[derive(Debug, Clone)] +pub struct FpRegs { + /// `f0`–`f31`, raw binary32 bit patterns (FLEN = 32; see the module docs). + regs: [u32; 32], + /// Accrued exception flags, `fflags` bits 4:0. Sticky: set by arithmetic, + /// cleared only by an explicit CSR write (§21.2). + fflags: u8, + /// Dynamic rounding mode, `frm` bits 2:0. + frm: u8, +} + +impl Default for FpRegs { + fn default() -> Self { + Self::new() + } +} + +impl FpRegs { + /// Reset state: all registers zero, no accrued flags, `frm` = `RNE`. + /// + /// The spec leaves the register file's reset value unspecified; zeroing is + /// the emulator's choice and matches how the integer file is initialized. + pub const fn new() -> Self { + Self { + regs: [0; 32], + fflags: 0, + frm: 0, + } + } + + /// Read `f[idx]` as a binary32 bit pattern. + /// + /// FLEN = 32, so this is an unconditional load — see the module docs for + /// why there is no NaN-unboxing check, and why this function exists anyway. + #[inline] + pub fn read_single(&self, idx: u8) -> u32 { + self.regs[(idx & 0x1f) as usize] + } + + /// Write a binary32 bit pattern to `f[idx]`. + /// + /// Unlike `x0`, **no floating-point register is hardwired to zero**: `f0` is + /// an ordinary register (§21.1). + #[inline] + pub fn write_single(&mut self, idx: u8, bits: u32) { + self.regs[(idx & 0x1f) as usize] = bits; + } + + /// Current accrued exception flags (`fflags`, CSR `0x001`). + #[inline] + pub fn fflags(&self) -> u8 { + self.fflags + } + + /// Overwrite `fflags`; bits above 4:0 are reserved and ignored. + #[inline] + pub fn set_fflags(&mut self, value: u8) { + self.fflags = value & FFLAGS_MASK; + } + + /// OR new exception flags into `fflags`. + /// + /// §21.2: the flags are **accrued** — an arithmetic instruction never + /// clears a flag another instruction set. Only a CSR write clears them. + #[inline] + pub fn accrue(&mut self, flags: u8) { + self.fflags |= flags & FFLAGS_MASK; + } + + /// Current dynamic rounding mode (`frm`, CSR `0x002`), as a 3-bit field. + /// + /// The value may be one the spec calls invalid (`101`–`111`); that is + /// legal to *hold*, and only becomes an illegal instruction when an + /// instruction with `rm` = `DYN` tries to use it. See + /// [`RoundingMode::resolve`]. + #[inline] + pub fn frm(&self) -> u8 { + self.frm + } + + /// Overwrite `frm`; bits above 2:0 are ignored. + #[inline] + pub fn set_frm(&mut self, value: u8) { + self.frm = value & 0x7; + } + + /// Read `fcsr` (CSR `0x003`): `frm` in bits 7:5 over `fflags` in bits 4:0. + #[inline] + pub fn fcsr(&self) -> u8 { + (self.frm << 5) | self.fflags + } + + /// Write `fcsr`. Bits 31:8 are reserved (`WPRI`) and are discarded. + #[inline] + pub fn set_fcsr(&mut self, value: u32) { + self.fflags = (value as u8) & FFLAGS_MASK; + self.frm = ((value >> 5) as u8) & 0x7; + } + + /// Read one of the three F-extension CSRs by number. + /// + /// Returns `None` for every other CSR so the caller can keep the + /// emulator's long-standing "CSR reads return 0" behaviour for the CSRs it + /// genuinely does not model (`cycle`, `mstatus`, …). Only `fflags`, `frm` + /// and `fcsr` became real state; nothing else changed. + #[inline] + pub fn read_csr(&self, csr: u16) -> Option { + match csr { + CSR_FFLAGS => Some(u32::from(self.fflags)), + CSR_FRM => Some(u32::from(self.frm)), + CSR_FCSR => Some(u32::from(self.fcsr())), + _ => None, + } + } + + /// Write one of the three F-extension CSRs by number. + /// + /// Returns `true` if the CSR was one of ours (and was written), `false` if + /// the caller should fall back to its no-op handling. + #[inline] + pub fn write_csr(&mut self, csr: u16, value: u32) -> bool { + match csr { + CSR_FFLAGS => { + self.set_fflags(value as u8); + true + } + CSR_FRM => { + self.set_frm(value as u8); + true + } + CSR_FCSR => { + self.set_fcsr(value); + true + } + _ => false, + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn registers_round_trip_raw_bits() { + let mut fp = FpRegs::new(); + // A signaling NaN with a payload: nothing in the register file may + // canonicalize it. + fp.write_single(7, 0x7f80_0001); + assert_eq!(fp.read_single(7), 0x7f80_0001); + // f0 is an ordinary register, unlike x0. + fp.write_single(0, 0xdead_beef); + assert_eq!(fp.read_single(0), 0xdead_beef); + } + + #[test] + fn fcsr_packs_frm_above_fflags() { + let mut fp = FpRegs::new(); + fp.set_fflags(FFLAG_NV | FFLAG_NX); + fp.set_frm(0b011); + assert_eq!(fp.fcsr(), (0b011 << 5) | 0b1_0001); + assert_eq!(fp.fflags(), 0b1_0001); + assert_eq!(fp.frm(), 0b011); + } + + #[test] + fn fcsr_write_splits_into_frm_and_fflags() { + let mut fp = FpRegs::new(); + fp.set_fcsr(0b1010_1010); + assert_eq!(fp.frm(), 0b101); + assert_eq!(fp.fflags(), 0b0_1010); + } + + #[test] + fn fcsr_write_discards_reserved_high_bits() { + let mut fp = FpRegs::new(); + fp.set_fcsr(0xffff_ff00); + assert_eq!(fp.fcsr(), 0); + } + + #[test] + fn fflags_write_discards_bits_above_four() { + let mut fp = FpRegs::new(); + fp.set_fflags(0xff); + assert_eq!(fp.fflags(), FFLAGS_MASK); + } + + #[test] + fn accrue_is_sticky() { + let mut fp = FpRegs::new(); + fp.accrue(FFLAG_NX); + fp.accrue(FFLAG_OF); + assert_eq!(fp.fflags(), FFLAG_NX | FFLAG_OF); + fp.accrue(0); + assert_eq!(fp.fflags(), FFLAG_NX | FFLAG_OF); + } + + #[test] + fn csr_dispatch_covers_only_the_three_fp_csrs() { + let mut fp = FpRegs::new(); + assert_eq!(fp.read_csr(CSR_FFLAGS), Some(0)); + assert_eq!(fp.read_csr(CSR_FRM), Some(0)); + assert_eq!(fp.read_csr(CSR_FCSR), Some(0)); + // `cycle`, and anything else, stays unmodelled. + assert_eq!(fp.read_csr(0xc00), None); + assert!(!fp.write_csr(0xc00, 1)); + + assert!(fp.write_csr(CSR_FRM, 0b010)); + assert_eq!(fp.frm(), 0b010); + assert!(fp.write_csr(CSR_FFLAGS, FFLAG_DZ as u32)); + assert_eq!(fp.fflags(), FFLAG_DZ); + assert_eq!(fp.read_csr(CSR_FCSR), Some(u32::from((0b010 << 5) | FFLAG_DZ))); + } + + #[test] + fn resolve_maps_the_five_defined_static_modes() { + assert_eq!(RoundingMode::resolve(0b000, 0), Some(RoundingMode::Rne)); + assert_eq!(RoundingMode::resolve(0b001, 0), Some(RoundingMode::Rtz)); + assert_eq!(RoundingMode::resolve(0b010, 0), Some(RoundingMode::Rdn)); + assert_eq!(RoundingMode::resolve(0b011, 0), Some(RoundingMode::Rup)); + assert_eq!(RoundingMode::resolve(0b100, 0), Some(RoundingMode::Rmm)); + } + + #[test] + fn resolve_rejects_reserved_static_modes() { + assert_eq!(RoundingMode::resolve(0b101, 0), None); + assert_eq!(RoundingMode::resolve(0b110, 0), None); + } + + #[test] + fn resolve_dyn_reads_frm() { + assert_eq!(RoundingMode::resolve(0b111, 0b011), Some(RoundingMode::Rup)); + // frm holding a reserved or invalid value makes DYN illegal. + assert_eq!(RoundingMode::resolve(0b111, 0b101), None); + assert_eq!(RoundingMode::resolve(0b111, 0b110), None); + assert_eq!(RoundingMode::resolve(0b111, 0b111), None); + } +} diff --git a/lp-riscv/lp-riscv-emu/src/emu/mod.rs b/lp-riscv/lp-riscv-emu/src/emu/mod.rs index 4ce212666..67d477584 100644 --- a/lp-riscv/lp-riscv-emu/src/emu/mod.rs +++ b/lp-riscv/lp-riscv-emu/src/emu/mod.rs @@ -3,10 +3,12 @@ mod decoder; pub mod emulator; pub mod error; mod executor; +pub mod fp_regs; pub mod logging; #[cfg(feature = "std")] pub use emulator::FrameOutcome; pub use emulator::{DEFAULT_CALL_INSTRUCTION_LIMIT, Riscv32Emulator}; +pub use fp_regs::{FpRegs, RoundingMode}; pub use error::{EmulatorError, trap_code_from_cranelift}; pub use logging::InstLog; diff --git a/lp-shader/lps-filetests/src/parse/parse_annotation.rs b/lp-shader/lps-filetests/src/parse/parse_annotation.rs index 74ee53633..fe3e2a27d 100644 --- a/lp-shader/lps-filetests/src/parse/parse_annotation.rs +++ b/lp-shader/lps-filetests/src/parse/parse_annotation.rs @@ -278,10 +278,19 @@ mod tests { .map(|t| t.name()) .collect(); // Both exclusions bite: `interp.f32` and `wgpu.f32` are subtracted from - // the f32 family, leaving the compiled f32 target. This assertion used - // to expect nothing at all — `wasm.f32` is the "compiled f32 target - // registers" case the selector was written to anticipate. - assert_eq!(matched, vec!["wasm.f32".to_string()], "{matched:?}"); + // the f32 family, leaving the compiled f32 targets. This assertion used + // to expect nothing at all — the compiled f32 targets are the case the + // selector was written to anticipate, and each new backend that gains an + // f32 mode joins the list here. + assert_eq!( + matched, + vec![ + "wasm.f32".to_string(), + "rv32n.f32".to_string(), + "rv32lpn.f32".to_string() + ], + "{matched:?}" + ); } #[test] diff --git a/lp-shader/lps-filetests/src/targets/display.rs b/lp-shader/lps-filetests/src/targets/display.rs index f8a2622bf..471794079 100644 --- a/lp-shader/lps-filetests/src/targets/display.rs +++ b/lp-shader/lps-filetests/src/targets/display.rs @@ -291,7 +291,10 @@ mod tests { fn test_parse_target_filters_comma_and_shorthand() { let v = parse_target_filters("rv32n,wasm").expect("parse"); let names: Vec = v.iter().map(|t| t.name()).collect(); - assert_eq!(names, vec!["rv32n.q32", "wasm.q32", "wasm.f32"]); + assert_eq!( + names, + vec!["rv32n.q32", "rv32n.f32", "wasm.q32", "wasm.f32"] + ); } #[test] @@ -319,18 +322,36 @@ mod tests { assert_eq!(names, vec!["wasm.q32", "wasm.f32"]); } + /// Like `wasm`, the rv32 backend shorthands select every registered float + /// mode. A caller that meant the shipping fixed-point target must spell + /// `rv32n.q32` — the soft-float sibling is orders of magnitude slower. #[test] fn test_parse_target_filters_rv32n_shorthand() { let v = parse_target_filters("rv32n").expect("parse"); - assert_eq!(v.len(), 1); - assert_eq!(v[0].name(), "rv32n.q32"); + let names: Vec = v.iter().map(|t| t.name()).collect(); + assert_eq!(names, vec!["rv32n.q32", "rv32n.f32"]); } #[test] fn test_parse_target_filters_rv32lpn_shorthand() { let v = parse_target_filters("rv32lpn").expect("parse"); - assert_eq!(v.len(), 1); - assert_eq!(v[0].name(), "rv32lpn.q32"); + let names: Vec = v.iter().map(|t| t.name()).collect(); + assert_eq!(names, vec!["rv32lpn.q32", "rv32lpn.f32"]); + } + + /// The soft-float rv32 targets exist and name the same backend as their + /// Q32 siblings — only `float_mode` differs, which is what makes an + /// `@unimplemented(float_mode=f32)` predicate cover them. + #[test] + fn test_target_name_rv32_f32_pair() { + for (name, frontend) in [("rv32n.f32", Frontend::Naga), ("rv32lpn.f32", Frontend::Lp)] { + let t = Target::from_name(name).expect("registered"); + assert_eq!(t.name(), name); + assert_eq!(t.float_mode, FloatMode::F32); + assert_eq!(t.backend, Backend::Rv32fa); + assert_eq!(t.frontend, frontend); + assert_eq!(t.isa, super::super::Isa::Riscv32); + } } #[test] diff --git a/lp-shader/lps-filetests/src/targets/mod.rs b/lp-shader/lps-filetests/src/targets/mod.rs index b71fb460c..c17548e3e 100644 --- a/lp-shader/lps-filetests/src/targets/mod.rs +++ b/lp-shader/lps-filetests/src/targets/mod.rs @@ -101,6 +101,15 @@ pub struct Target { /// deliberately **not** in [`DEFAULT_TARGETS`] — see the corpus findings from the /// run that first exercised it (`@lpfn`/`@glsl` builtin imports still resolve to /// the Q32 builtin ids, so any file calling one produces an invalid module). +/// +/// `rv32n.f32` / `rv32lpn.f32` are the **soft-float** rv32 targets (roadmap M9): +/// the same `lpvm-native` backend, compiled in `FloatMode::F32`, where every +/// float op is a call to `__addsf3` and friends inside `lp-riscv-emu`. Also +/// deliberately **not** in [`DEFAULT_TARGETS`] (roadmap Q6: rv32 f32 variants run +/// on demand). They are slow — each arithmetic op is a function call through the +/// emulator — and they are not the shipping numeric mode for any rv32 board, so +/// their cost belongs to a deliberate run, not to every `cargo test`. Select them +/// explicitly: `-t rv32lpn.f32`. pub const ALL_TARGETS: &[Target] = &[ Target { frontend: Frontend::Naga, @@ -165,6 +174,20 @@ pub const ALL_TARGETS: &[Target] = &[ isa: Isa::Wasm32, exec_mode: ExecMode::Emulator, }, + Target { + frontend: Frontend::Naga, + backend: Backend::Rv32fa, + float_mode: FloatMode::F32, + isa: Isa::Riscv32, + exec_mode: ExecMode::Emulator, + }, + Target { + frontend: Frontend::Lp, + backend: Backend::Rv32fa, + float_mode: FloatMode::F32, + isa: Isa::Riscv32, + exec_mode: ExecMode::Emulator, + }, ]; /// Default targets for local `cargo test` / app runs: rv32n, rv32lpn (lps-glsl diff --git a/lp-shader/lps-shared/src/lib.rs b/lp-shader/lps-shared/src/lib.rs index 5ae57c138..b1c97e9c6 100644 --- a/lp-shader/lps-shared/src/lib.rs +++ b/lp-shader/lps-shared/src/lib.rs @@ -27,7 +27,8 @@ pub mod value_path; pub use layout::{VMCTX_HEADER_SIZE, array_stride, round_up, type_alignment, type_size}; pub use lps_value_f32::LpsValueF32; pub use lps_value_q32::{ - LpsValueQ32, LpsValueQ32Error, lps_value_f32_to_q32, q32_to_lps_value_f32, + FloatLaneAbi, LpsValueQ32, LpsValueQ32Error, lanes_to_lps_value_f32, lps_value_f32_to_lanes, + lps_value_f32_to_q32, q32_to_lps_value_f32, }; pub use sig::{FnParam, LpsFnKind, LpsFnSig, LpsModuleSig, ParamQualifier}; pub use texture_binding_validate::validate_texture_binding_specs_against_module; diff --git a/lp-shader/lps-shared/src/lps_value_q32.rs b/lp-shader/lps-shared/src/lps_value_q32.rs index ac9ea48df..dc38344c8 100644 --- a/lp-shader/lps-shared/src/lps_value_q32.rs +++ b/lp-shader/lps-shared/src/lps_value_q32.rs @@ -70,12 +70,63 @@ fn f32_to_q32_abi(x: f32) -> Q32 { Q32::from_fixed(q32_encode(x)) } +/// How one GLSL `float` lane is packed into its ABI word. +/// +/// Both float modes lay aggregates out identically — the same std430 offsets, +/// the same dense array lanes, the same word count per type. **Only the `float` +/// lane's contents differ.** This enum is that one difference, so the traversal +/// that walks structs, arrays, and matrices is shared instead of forked, and +/// cannot drift between the two modes. +#[derive(Clone, Copy, PartialEq, Eq, Debug)] +pub enum FloatLaneAbi { + /// Q16.16 fixed point, via [`q32_encode`] (saturating) and [`Q32::to_f32`]. + Q16_16, + /// The IEEE-754 binary32 **bit pattern**, as produced by + /// [`f32::to_bits`]. Used by every native-f32 backend: on a soft-float ABI + /// a `float` is literally this word in an integer register. + Ieee754Bits, +} + +impl FloatLaneAbi { + fn encode(self, x: f32) -> Q32 { + match self { + FloatLaneAbi::Q16_16 => f32_to_q32_abi(x), + // The `Q32` newtype is being used here as "one raw ABI lane word", + // not as a Q16.16 number. That is the whole trick that lets the two + // modes share a traversal; nothing downstream interprets the word + // until [`FloatLaneAbi::decode`] reverses it. + FloatLaneAbi::Ieee754Bits => Q32::from_fixed(x.to_bits() as i32), + } + } + + fn decode(self, q: Q32) -> f32 { + match self { + FloatLaneAbi::Q16_16 => q.to_f32(), + FloatLaneAbi::Ieee754Bits => f32::from_bits(q.to_fixed() as u32), + } + } +} + /// Convert [`LpsValueF32`] to [`LpsValueQ32`] using [`q32_encode`] for float components /// so host arguments match compiler constant emission and the historical `f64` path. pub fn lps_value_f32_to_q32( ty: &LpsType, v: &LpsValueF32, ) -> Result { + lps_value_f32_to_lanes(ty, v, FloatLaneAbi::Q16_16) +} + +/// [`lps_value_f32_to_q32`] with the float-lane encoding chosen explicitly. +/// +/// With [`FloatLaneAbi::Ieee754Bits`] the result is **not** a Q16.16 value: each +/// `F32` component carries a raw IEEE bit pattern in the [`Q32`] newtype. Pair +/// it with [`lanes_to_lps_value_f32`] using the same `abi`. +pub fn lps_value_f32_to_lanes( + ty: &LpsType, + v: &LpsValueF32, + abi: FloatLaneAbi, +) -> Result { + let f32_to_q32_abi = |x: f32| abi.encode(x); Ok(match (ty, v) { (LpsType::Texture2D, LpsValueF32::Texture2D(v)) => LpsValueQ32::Texture2D(*v), (LpsType::Texture2D, _) => { @@ -173,7 +224,7 @@ pub fn lps_value_f32_to_q32( } let mut out = Vec::with_capacity(items.len()); for it in items.iter() { - out.push(lps_value_f32_to_q32(element, it)?); + out.push(lps_value_f32_to_lanes(element, it, abi)?); } LpsValueQ32::Array(out.into_boxed_slice()) } @@ -195,7 +246,7 @@ pub fn lps_value_f32_to_q32( "expected field `{key}`, got `{fname}`" ))); } - out.push((fname.clone(), lps_value_f32_to_q32(&m.ty, fv)?)); + out.push((fname.clone(), lps_value_f32_to_lanes(&m.ty, fv, abi)?)); } LpsValueQ32::Struct { name: name.clone(), @@ -213,7 +264,18 @@ pub fn lps_value_f32_to_q32( /// Convert [`LpsValueQ32`] to [`LpsValueF32`] (`Q32` components become `f32` via [`Q32::to_f32`]). pub fn q32_to_lps_value_f32(ty: &LpsType, v: LpsValueQ32) -> Result { + lanes_to_lps_value_f32(ty, v, FloatLaneAbi::Q16_16) +} + +/// [`q32_to_lps_value_f32`] with the float-lane encoding chosen explicitly — +/// the inverse of [`lps_value_f32_to_lanes`] for the same `abi`. +pub fn lanes_to_lps_value_f32( + ty: &LpsType, + v: LpsValueQ32, + abi: FloatLaneAbi, +) -> Result { let bad = || LpsValueQ32Error::TypeMismatch(format!("return shape mismatch for type {ty:?}")); + let dec = |q: Q32| abi.decode(q); Ok(match (ty, v) { (LpsType::Texture2D, LpsValueQ32::Texture2D(v)) => LpsValueF32::Texture2D(v), @@ -222,17 +284,17 @@ pub fn q32_to_lps_value_f32(ty: &LpsType, v: LpsValueQ32) -> Result LpsValueF32::F32(x.to_f32()), + (LpsType::Float, LpsValueQ32::F32(x)) => LpsValueF32::F32(dec(x)), (LpsType::Int, LpsValueQ32::I32(x)) => LpsValueF32::I32(x), (LpsType::UInt, LpsValueQ32::U32(x)) => LpsValueF32::U32(x), (LpsType::Bool, LpsValueQ32::Bool(b)) => LpsValueF32::Bool(b), - (LpsType::Vec2, LpsValueQ32::Vec2(a)) => LpsValueF32::Vec2([a[0].to_f32(), a[1].to_f32()]), + (LpsType::Vec2, LpsValueQ32::Vec2(a)) => LpsValueF32::Vec2([dec(a[0]), dec(a[1])]), (LpsType::Vec3, LpsValueQ32::Vec3(a)) => { - LpsValueF32::Vec3([a[0].to_f32(), a[1].to_f32(), a[2].to_f32()]) + LpsValueF32::Vec3([dec(a[0]), dec(a[1]), dec(a[2])]) } (LpsType::Vec4, LpsValueQ32::Vec4(a)) => { - LpsValueF32::Vec4([a[0].to_f32(), a[1].to_f32(), a[2].to_f32(), a[3].to_f32()]) + LpsValueF32::Vec4([dec(a[0]), dec(a[1]), dec(a[2]), dec(a[3])]) } (LpsType::IVec2, LpsValueQ32::IVec2(a)) => LpsValueF32::IVec2(a), @@ -248,38 +310,38 @@ pub fn q32_to_lps_value_f32(ty: &LpsType, v: LpsValueQ32) -> Result LpsValueF32::BVec4(a), (LpsType::Mat2, LpsValueQ32::Mat2x2(m)) => LpsValueF32::Mat2x2([ - [m[0][0].to_f32(), m[0][1].to_f32()], - [m[1][0].to_f32(), m[1][1].to_f32()], + [dec(m[0][0]), dec(m[0][1])], + [dec(m[1][0]), dec(m[1][1])], ]), (LpsType::Mat3, LpsValueQ32::Mat3x3(m)) => LpsValueF32::Mat3x3([ - [m[0][0].to_f32(), m[0][1].to_f32(), m[0][2].to_f32()], - [m[1][0].to_f32(), m[1][1].to_f32(), m[1][2].to_f32()], - [m[2][0].to_f32(), m[2][1].to_f32(), m[2][2].to_f32()], + [dec(m[0][0]), dec(m[0][1]), dec(m[0][2])], + [dec(m[1][0]), dec(m[1][1]), dec(m[1][2])], + [dec(m[2][0]), dec(m[2][1]), dec(m[2][2])], ]), (LpsType::Mat4, LpsValueQ32::Mat4x4(m)) => LpsValueF32::Mat4x4([ [ - m[0][0].to_f32(), - m[0][1].to_f32(), - m[0][2].to_f32(), - m[0][3].to_f32(), + dec(m[0][0]), + dec(m[0][1]), + dec(m[0][2]), + dec(m[0][3]), ], [ - m[1][0].to_f32(), - m[1][1].to_f32(), - m[1][2].to_f32(), - m[1][3].to_f32(), + dec(m[1][0]), + dec(m[1][1]), + dec(m[1][2]), + dec(m[1][3]), ], [ - m[2][0].to_f32(), - m[2][1].to_f32(), - m[2][2].to_f32(), - m[2][3].to_f32(), + dec(m[2][0]), + dec(m[2][1]), + dec(m[2][2]), + dec(m[2][3]), ], [ - m[3][0].to_f32(), - m[3][1].to_f32(), - m[3][2].to_f32(), - m[3][3].to_f32(), + dec(m[3][0]), + dec(m[3][1]), + dec(m[3][2]), + dec(m[3][3]), ], ]), @@ -289,7 +351,7 @@ pub fn q32_to_lps_value_f32(ty: &LpsType, v: LpsValueQ32) -> Result Result CompileOptions { - CompileOptions { - float_mode: self.module.options.float_mode, - config: self.module.options.config.clone(), - ..Default::default() - } - } - fn resolve_render_texture(&mut self, fn_name: &str) -> Result { if let Some(entry) = &self.render_texture_cache { if entry.name == fn_name { @@ -441,11 +433,23 @@ impl NativeEmuInstance { Ok(isa) => isa, Err(e) => return Err(FailedRun::without_state(format!("{e}"))), }; - let opts = self.cranelift_options(); + // `FloatMode::Q32`, deliberately, even when the module is compiled in + // f32 mode. This signature describes how the **host enters the guest**, + // and `lpvm-native` targets the *soft-float* ABI + // (`EF_RISCV_FLOAT_ABI_SOFT`, and Xtensa likewise today): an f32 + // argument or return lives in an integer register, exactly like a Q32 + // word. Passing `FloatMode::F32` here asks the emulator to marshal a + // `types::F32` in a float register file that this ABI does not use — it + // fails with "Unsupported return type: types::F32", and if it ever + // stopped failing it would be reading the wrong register. + // + // When a hardware-FPU backend lands ([`crate::isa::F32Lowering:: + // HardwareFpu`]), the entry ABI changes with it and this becomes a + // query on the target rather than a constant. let sig = signature_for_ir_func( ir_func, CallConv::SystemV, - opts.float_mode, + FloatMode::Q32, isa.pointer_type(), &*isa, ); @@ -799,11 +803,7 @@ impl LpvmInstance for NativeEmuInstance { self.last_debug = None; self.last_guest_instruction_count = None; self.last_guest_cycle_count = None; - if self.module.options.float_mode != FloatMode::Q32 { - return Err(NativeError::Call(CallError::Unsupported(String::from( - "NativeEmuInstance::call requires FloatMode::Q32", - )))); - } + let lane_abi = float_lane_abi(self.module.options.float_mode); let gfn = self .module @@ -822,12 +822,6 @@ impl LpvmInstance for NativeEmuInstance { } } - if gfn.return_type == LpsType::Void { - return Err(NativeError::Call(CallError::Unsupported(String::from( - "void return is not represented as LpsValue; use a typed return", - )))); - } - if gfn.parameters.len() != args.len() { return Err(NativeError::Call(CallError::Arity { expected: gfn.parameters.len(), @@ -835,7 +829,7 @@ impl LpvmInstance for NativeEmuInstance { })); } - let flat = flat_q32_words_from_f32_args(&gfn.parameters, args)?; + let flat = flat_words_from_f32_args(&gfn.parameters, args, lane_abi)?; let ir_func = self .module .ir @@ -853,9 +847,7 @@ impl LpvmInstance for NativeEmuInstance { } let words = self.invoke_flat(name, &flat, CycleModel::default())?; - let gq = decode_q32_return(&gfn.return_type, &words)?; - q32_to_lps_value_f32(&gfn.return_type, gq) - .map_err(|e| NativeError::Call(CallError::TypeMismatch(e.to_string()))) + Ok(decode_return_to_f32(&gfn.return_type, &words, lane_abi)?) } fn call_q32(&mut self, name: &str, args: &[i32]) -> Result, Self::Error> { diff --git a/lp-shader/lpvm-native/src/rt_jit/builtins.rs b/lp-shader/lpvm-native/src/rt_jit/builtins.rs index de9380b57..b95aa5054 100644 --- a/lp-shader/lpvm-native/src/rt_jit/builtins.rs +++ b/lp-shader/lpvm-native/src/rt_jit/builtins.rs @@ -35,6 +35,62 @@ impl BuiltinTable { self.symbols.insert(String::from(bid.name()), p as usize); } } + #[cfg(all(feature = "float-f32", target_arch = "riscv32"))] + self.populate_soft_float(); + } + + /// Add the platform soft-float symbols the f32 lowering calls directly. + /// + /// These are not LightPlayer builtins and are not in [`BuiltinId`] — that is + /// the point of roadmap D1. The addresses come from ordinary `extern "C"` + /// declarations, so the *linker* answers where they live: on the ESP32-C6 + /// that is the chip's ROM `rvfplib` (`esp32c6.rom.rvfp.ld`, e.g. + /// `__addsf3 = 0x400009f8`), and in a host-linked rv32 image it is Rust's + /// `compiler_builtins`. Either way the JIT gets a real address and the + /// firmware pays no flash for the implementation. + /// + /// rv32-only: Xtensa answers [`crate::isa::F32Lowering::Unsupported`], so + /// nothing there can emit a call to one of these names. Adding the arm when + /// an Xtensa float backend lands is a deliberate decision (the S3 has an + /// FPU; soft float would be the wrong answer for it), not an oversight. + #[cfg(all(feature = "float-f32", target_arch = "riscv32"))] + fn populate_soft_float(&mut self) { + // SAFETY (declaration only): these are the standard soft-float ABI + // entry points. Nothing here calls them — the table stores addresses for + // the JIT's auipc+jalr fixups, and the *generated* code performs the + // calls with the ABI the lowering emitted. + unsafe extern "C" { + fn __addsf3(a: f32, b: f32) -> f32; + fn __subsf3(a: f32, b: f32) -> f32; + fn __mulsf3(a: f32, b: f32) -> f32; + fn __divsf3(a: f32, b: f32) -> f32; + fn __eqsf2(a: f32, b: f32) -> i32; + fn __nesf2(a: f32, b: f32) -> i32; + fn __ltsf2(a: f32, b: f32) -> i32; + fn __lesf2(a: f32, b: f32) -> i32; + fn __gtsf2(a: f32, b: f32) -> i32; + fn __gesf2(a: f32, b: f32) -> i32; + fn __floatsisf(a: i32) -> f32; + fn __floatunsisf(a: u32) -> f32; + } + + let entries: [(&str, usize); 12] = [ + ("__addsf3", __addsf3 as *const () as usize), + ("__subsf3", __subsf3 as *const () as usize), + ("__mulsf3", __mulsf3 as *const () as usize), + ("__divsf3", __divsf3 as *const () as usize), + ("__eqsf2", __eqsf2 as *const () as usize), + ("__nesf2", __nesf2 as *const () as usize), + ("__ltsf2", __ltsf2 as *const () as usize), + ("__lesf2", __lesf2 as *const () as usize), + ("__gtsf2", __gtsf2 as *const () as usize), + ("__gesf2", __gesf2 as *const () as usize), + ("__floatsisf", __floatsisf as *const () as usize), + ("__floatunsisf", __floatunsisf as *const () as usize), + ]; + for (name, addr) in entries { + self.symbols.insert(String::from(name), addr); + } } #[must_use] diff --git a/lp-shader/lpvm-native/src/rt_jit/instance.rs b/lp-shader/lpvm-native/src/rt_jit/instance.rs index dcc8d1297..6e43ae494 100644 --- a/lp-shader/lpvm-native/src/rt_jit/instance.rs +++ b/lp-shader/lpvm-native/src/rt_jit/instance.rs @@ -1,16 +1,16 @@ //! [`LpvmInstance`] for direct JIT calls (register args only; see `invoke_flat` limits). use alloc::format; -use alloc::string::{String, ToString}; +use alloc::string::String; use alloc::vec::Vec; use lpir::FloatMode; use lps_shared::{LpsType, LpsValueQ32, ParamQualifier, lps_value_f32::LpsValueF32}; use lpvm::{ CallError, DEFAULT_VMCTX_FUEL, INVOCATION_INDEX_ARMED, LpvmBuffer, LpvmInstance, - TRAP_CODE_NONE, VMCTX_OFFSET_FUEL, VMCTX_OFFSET_TRAP, decode_global_read, decode_q32_return, - encode_global_write, encode_uniform_write, encode_uniform_write_q32, - flat_q32_words_from_f32_args, global_data_span, glsl_component_count, q32_to_lps_value_f32, + TRAP_CODE_NONE, VMCTX_OFFSET_FUEL, VMCTX_OFFSET_TRAP, decode_global_read, + decode_return_to_f32, encode_global_write, encode_uniform_write, encode_uniform_write_q32, + flat_words_from_f32_args, float_lane_abi, global_data_span, glsl_component_count, validate_compute_tick_sig, }; @@ -418,11 +418,7 @@ impl LpvmInstance for NativeJitInstance { fn call(&mut self, name: &str, args: &[LpsValueF32]) -> Result { // Reset globals before each call to ensure fresh state self.reset_globals(); - if self.module.inner.options.float_mode != FloatMode::Q32 { - return Err(NativeError::Call(CallError::Unsupported(String::from( - "NativeJitInstance::call requires FloatMode::Q32", - )))); - } + let lane_abi = float_lane_abi(self.module.inner.options.float_mode); let gfn = self .module @@ -442,12 +438,6 @@ impl LpvmInstance for NativeJitInstance { } } - if gfn.return_type == LpsType::Void { - return Err(NativeError::Call(CallError::Unsupported(String::from( - "void return is not represented as LpsValue; use a typed return", - )))); - } - if gfn.parameters.len() != args.len() { return Err(NativeError::Call(CallError::Arity { expected: gfn.parameters.len(), @@ -455,7 +445,7 @@ impl LpvmInstance for NativeJitInstance { })); } - let flat = flat_q32_words_from_f32_args(&gfn.parameters, args)?; + let flat = flat_words_from_f32_args(&gfn.parameters, args, lane_abi)?; let entry_info = self .module .inner @@ -472,9 +462,7 @@ impl LpvmInstance for NativeJitInstance { } let words = self.invoke_flat(name, &flat)?; - let gq = decode_q32_return(&gfn.return_type, &words)?; - q32_to_lps_value_f32(&gfn.return_type, gq) - .map_err(|e| NativeError::Call(CallError::TypeMismatch(e.to_string()))) + Ok(decode_return_to_f32(&gfn.return_type, &words, lane_abi)?) } fn call_q32(&mut self, name: &str, args: &[i32]) -> Result, Self::Error> { diff --git a/lp-shader/lpvm/src/lib.rs b/lp-shader/lpvm/src/lib.rs index 3e7f6f7b9..47cb0ca31 100644 --- a/lp-shader/lpvm/src/lib.rs +++ b/lp-shader/lpvm/src/lib.rs @@ -70,7 +70,8 @@ pub use lps_shared::path_resolve::{LpsTypePathExt, PathError}; pub use lps_shared::value_path::{LpsValuePathError, LpsValuePathExt}; pub use lps_shared::{LayoutRules, LpsFnSig, LpsType, StructMember}; pub use lpvm_abi::{ - CallError, CallResult, GlslReturn, decode_q32_return, flat_q32_words_from_f32_args, + CallError, CallResult, GlslReturn, decode_q32_return, decode_return_to_f32, float_lane_abi, + flat_q32_words_from_f32_args, flat_words_from_f32_args, flatten_q32_arg, flatten_q32_return, glsl_component_count, unflatten_q32_args, }; pub use lpvm_data_q32::LpvmDataQ32; diff --git a/lp-shader/lpvm/src/lpvm_abi.rs b/lp-shader/lpvm/src/lpvm_abi.rs index ba85c450b..be78464b8 100644 --- a/lp-shader/lpvm/src/lpvm_abi.rs +++ b/lp-shader/lpvm/src/lpvm_abi.rs @@ -15,11 +15,12 @@ use alloc::string::{String, ToString}; use alloc::vec::Vec; use core::fmt; +use lpir::FloatMode; use lps_q32::Q32; use lps_shared::layout::{round_up, type_alignment, type_size}; use lps_shared::{ - FnParam, LayoutRules, LpsTexture2DDescriptor, LpsTexture2DValue, LpsType, LpsValueF32, - LpsValueQ32, ParamQualifier, + FloatLaneAbi, FnParam, LayoutRules, LpsTexture2DDescriptor, LpsTexture2DValue, LpsType, + LpsValueF32, LpsValueQ32, ParamQualifier, }; /// Split a single slice of flattened Q32 argument words (per-parameter order) into @@ -69,10 +70,35 @@ pub fn flatten_q32_return(ty: &LpsType, v: &LpsValueQ32) -> Result, Cal flatten_q32_arg(&p, v) } +/// The float-lane encoding a compiled module's [`FloatMode`] implies. +/// +/// One-liner, but it is the *only* place the mapping is written, so a backend +/// cannot marshal arguments under one convention and results under another. +#[must_use] +pub fn float_lane_abi(fm: FloatMode) -> FloatLaneAbi { + match fm { + FloatMode::Q32 => FloatLaneAbi::Q16_16, + FloatMode::F32 => FloatLaneAbi::Ieee754Bits, + } +} + /// Build flattened Q32 words from marshaled [`LpsValueF32`] arguments using GLSL metadata. pub fn flat_q32_words_from_f32_args( params: &[FnParam], args: &[LpsValueF32], +) -> Result, CallError> { + flat_words_from_f32_args(params, args, FloatLaneAbi::Q16_16) +} + +/// [`flat_q32_words_from_f32_args`] with the float-lane encoding chosen +/// explicitly — [`FloatLaneAbi::Ieee754Bits`] for a native-f32 backend. +/// +/// Only the `float` lane changes between the two; the aggregate layout this +/// walks is identical, which is why there is one traversal and not two. +pub fn flat_words_from_f32_args( + params: &[FnParam], + args: &[LpsValueF32], + abi: FloatLaneAbi, ) -> Result, CallError> { if params.len() != args.len() { return Err(CallError::Arity { @@ -82,13 +108,34 @@ pub fn flat_q32_words_from_f32_args( } let mut flat = Vec::new(); for (p, a) in params.iter().zip(args.iter()) { - let q = lps_shared::lps_value_f32_to_q32(&p.ty, a) + let q = lps_shared::lps_value_f32_to_lanes(&p.ty, a, abi) .map_err(|e| CallError::TypeMismatch(e.to_string()))?; flat.extend(flatten_q32_arg(p, &q)?); } Ok(flat) } +/// Decode flattened return words straight to [`LpsValueF32`] for `abi`. +/// +/// [`LpsType::Void`] answers `F32(0.0)`, matching every other f32-capable +/// runtime (`InterpInstance::call`, the wgpu probe, and the Q32 harness path in +/// `lps-filetests`): `LpsValueF32` has no void variant, and the corpus is full +/// of `// run: some_void_fn() == 0.0` directives whose point is that the call +/// executes without trapping. Returning an error instead cost `wasm.f32` +/// 26 files in the M1 corpus run. +pub fn decode_return_to_f32( + ty: &LpsType, + words: &[i32], + abi: FloatLaneAbi, +) -> Result { + if *ty == LpsType::Void { + return Ok(LpsValueF32::F32(0.0)); + } + let lanes = decode_q32_return(ty, words)?; + lps_shared::lanes_to_lps_value_f32(ty, lanes, abi) + .map_err(|e| CallError::TypeMismatch(e.to_string())) +} + /// Result of a shader call: optional returned value plus `out` / `inout` values (future). #[derive(Clone, Debug, PartialEq)] pub struct GlslReturn { From f4bef6bc3db3ffeb3e76c65feb49c28659fefc8a Mon Sep 17 00:00:00 2001 From: Yona Appletree Date: Fri, 31 Jul 2026 18:37:54 -0700 Subject: [PATCH 03/15] =?UTF-8?q?feat(lp-riscv-emu):=20RV32F=20from=20the?= =?UTF-8?q?=20spec=20=E2=80=94=2034=20instructions,=20real=20fcsr?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Written from the RISC-V Unprivileged ISA v20240411 Chapter 21, with the section title cited alongside every number so the references survive a renumbering. No GPL implementation was read. It is a **soft-float** implementation — integer arithmetic on exact significands with one rounding step — and that is not incidental. Native host f32 fails three ways at once: it cannot do the four non-RNE rounding modes, it exposes no status word (so every exception flag would be a guess), and hosts propagate NaN payloads where §21.3 requires the canonical NaN. Doing it in integers makes NV/DZ/OF/UF/NX exact rather than approximated, and keeps the crate no_std for fw-emu. FLEN is 32 here, so there is **no** NaN boxing — that rule is §22.2 and applies only when FLEN > 32. `read_single`/`write_single` are the two places a future FLEN=64 widening would change; the docs say so, because "add NaN boxing" is the obvious wrong fix. fflags/frm/fcsr become real state, replacing the returns-zero CSR stub for those three CSR numbers only. Everything else stays a no-op. 77 tests: every rounding mode on a value that rounds differently in each, canonical NaN from each NaN-producing op, FMIN/FMAX signed zero and NaN, signalling-vs-quiet comparisons, FCVT saturation at both ends and on NaN, FSGNJ bit-exactness on NaN, all ten FCLASS classes, sticky flag accrual, and an FMA case where fused differs from unfused. Zcf (C.FLW/C.FSW) is deliberately out of scope: nothing emits it. Plan: 2026-07-30-1745-f32-native-math (M9) --- lp-riscv/lp-riscv-emu/README.md | 161 +++ .../src/emu/emulator/execution.rs | 16 +- .../lp-riscv-emu/src/emu/executor/float.rs | 1195 ++++++++++++++++- lp-riscv/lp-riscv-emu/src/emu/executor/mod.rs | 4 +- .../lp-riscv-emu/src/emu/executor/system.rs | 45 +- lp-riscv/lp-riscv-emu/src/emu/fp_regs.rs | 5 +- lp-riscv/lp-riscv-emu/src/emu/mod.rs | 2 +- lp-shader/lpvm-native/README.md | 60 + 8 files changed, 1423 insertions(+), 65 deletions(-) create mode 100644 lp-riscv/lp-riscv-emu/README.md diff --git a/lp-riscv/lp-riscv-emu/README.md b/lp-riscv/lp-riscv-emu/README.md new file mode 100644 index 000000000..16041eb09 --- /dev/null +++ b/lp-riscv/lp-riscv-emu/README.md @@ -0,0 +1,161 @@ +# lp-riscv-emu + +The RISC-V 32-bit **emulator** LightPlayer uses to run and debug generated +code on the host: instruction executors, the register files, the run loops, +`EmulatorError`, and the rv32 frame-pointer backtrace walk. The arch-neutral +machinery it builds on — memory model, `StepResult` / `TrapCode`, serial, time, +cycle accounting, the profiler — lives in [`lp-emu-core`](../../lp-emu/lp-emu-core); +decoding of the base ISA is delegated to [`lp-riscv-inst`](../lp-riscv-inst). + +``` +src/emu/ + emulator/ Riscv32Emulator: state, run loops, single-step, registers, + function-call ABI helpers, backtraces, debug dumps. + executor/ one module per instruction group: + arithmetic · immediate · load_store · branch · jump · + compressed · atomic · system · float + fp_regs.rs RV32F architectural state: f0-f31 and fcsr. + error.rs EmulatorError. + logging.rs LogLevel-gated instruction ring log (InstLog). +``` + +The crate is `#![no_std]` by default (`fw-emu` links it that way on bare-metal +RV32); the `std` feature adds host-only conveniences. + +## Supported ISA + +`RV32IMAC` plus the `Zicsr` CSR instructions, and — since milestone M9 of the +f32 roadmap — the **`F` standard extension (RV32F)**. + +## RV32F floating point + +### Provenance + +Implemented from *The RISC-V Instruction Set Manual, Volume I: Unprivileged +Architecture*, version **20240411**, Chapter 21 (`"F" Standard Extension for +Single-Precision Floating-Point, Version 2.2`), cross-read with IEEE 754-2008 +where the RISC-V text defers to it. Section numbers in the source are from that +release, and each citation also names the section *title* so it survives a +renumbering: + +| Section | Title | What it pins here | +|---|---|---| +| §21.1 | F Register State | 32 registers, FLEN = 32, `f0` is ordinary | +| §21.2 | Floating-Point Control and Status Register | `fflags` / `frm` / `fcsr`, the rounding-mode encodings, accrued flags | +| §21.3 | NaN Generation and Propagation | the canonical NaN `0x7fc00000` | +| §21.4 | Subnormal Arithmetic | full IEEE subnormals, no flush-to-zero | +| §21.5 | Single-Precision Load and Store Instructions | `FLW` / `FSW` | +| §21.6 | Single-Precision Floating-Point Computational Instructions | `FADD`…`FSQRT`, sign injection, `FMIN`/`FMAX`, the fused multiply-add family | +| §21.7 | Single-Precision Floating-Point Conversion and Move Instructions | `FCVT.*`, `FMV.X.W`, `FMV.W.X` | +| §21.8 | Single-Precision Floating-Point Compare Instructions | `FEQ` quiet vs `FLT`/`FLE` signaling | +| §21.9 | Single-Precision Floating-Point Classify Instruction | the 10-bit `FCLASS.S` mask | +| §22.2 | NaN Boxing of Narrower Values (`D` chapter) | why there is **no** boxing at FLEN = 32 | + +**No GPL implementation was read or transliterated.** QEMU, GDB, GCC and +glibc's soft-float are behavioral references at most; nothing here is derived +from their source. See `docs/adr/2026-07-29-license-provenance-discipline.md`. + +`lp-riscv-inst` has no `F` support and does not gain any: `executor/float.rs` +decodes the instruction word itself. + +### It is a soft-float implementation, deliberately + +Every arithmetic result is computed with integer arithmetic on the exact +significand and rounded exactly once. Native host `f32` operations are not used, +for three individually-disqualifying reasons: + +1. **Rounding modes.** RV32F has five (`RNE RTZ RDN RUP RMM`); Rust's `f32` + operators only ever give the host's, which is `RNE`. +2. **Exception flags.** `NV DZ OF UF NX` must be reported exactly, and Rust + exposes no access to the host FPU status word. +3. **NaN canonicalization.** §21.3 requires the canonical NaN out of every + NaN-producing operation; hosts propagate operand payloads instead. + +The consequence worth stating plainly: **the exception flags are exact, not +estimated.** They fall out of the same rounding step that produces the result, +so there is no approximation to disclose. It also keeps the crate `no_std`: +`f32::sqrt` and `f32::mul_add` live in `std`. + +The arithmetic core is small — an unpack, an exact aligned add on `u128` +significands, and one `round_pack` that every operation funnels through — plus +a digit-by-digit integer square root for `FSQRT.S`. + +### Semantics worth knowing before you change anything + +- **FLEN is 32, so there is no NaN boxing.** Boxing (§22.2) applies only when + FLEN > 32. `FpRegs::read_single` / `write_single` exist as the single hook + where a future FLEN = 64 widening would add it. +- **Canonical NaN, always.** `0x7fc00000` out of every NaN-producing operation. + Payloads are never propagated. The exceptions are the pure bit movers — + `FSGNJ.S`/`FSGNJN.S`/`FSGNJX.S`, `FMV.X.W`/`FMV.W.X`, `FLW`/`FSW` — which + never interpret their operand, never canonicalize, and never raise a flag. +- **`FMIN.S`/`FMAX.S`** return the non-NaN operand when exactly one is a NaN, + the canonical NaN when both are, and set `NV` for a signaling NaN operand + *even when the result is a number*. For these instructions only, `-0.0` is + less than `+0.0`. +- **`FEQ.S` is quiet; `FLT.S`/`FLE.S` signal.** Only a signaling NaN sets `NV` + for `FEQ.S`; any NaN operand sets it for the other two. +- **Float → integer saturates, and the range check is on the *rounded* + result.** NaN converts to the destination's **maximum** (`i32::MAX` / + `u32::MAX`), not to `0` as a Rust `as` cast would. Out of range sets `NV` + only — invalid suppresses inexact. +- **`FNMSUB.S` is `-(a*b) + c` and `FNMADD.S` is `-(a*b) - c`**, not + `-(a*b - c)` / `-(a*b + c)`. The difference is observable in the sign of an + exactly-zero result, and it is implemented as the spec words it. +- **Subnormals are not flushed.** §21.4 requires full IEEE subnormal + arithmetic. `docs/design/float.md` §4 lists flush-to-zero as *target-defined*, + but that latitude is about the **shader** tier on wasm/GPU/S3 — the same + document records that "wasm and RV32F preserve denormals (their specs require + it)". Do not add an FTZ mode here. +- **Underflow is detected after rounding.** `UF` is raised only when the + result is tiny *after* rounding *and* inexact, so a value that rounds up to + the smallest normal is inexact without being an underflow. + +### CSRs + +`fflags` (`0x001`), `frm` (`0x002`) and `fcsr` (`0x003`) are **real state**, +reachable through the ordinary `CSRRW`/`CSRRS`/`CSRRC` family and their +immediate forms. Every *other* CSR keeps this emulator's long-standing +behaviour — reads return 0, writes are discarded — because nothing here models +`mstatus`, the counters, or the machine-mode CSRs, and an honest no-op beats a +plausible fiction. That split lives in `executor/system.rs`. + +Rounding-mode encodings `101` and `110` are reserved, and `rm = DYN` (`111`) +against an `frm` holding `101`, `110` or `111` is equally invalid; all four +raise `EmulatorError::InvalidInstruction`. + +### Not implemented + +- **`Zcf` (`C.FLW` / `C.FSW`).** Compressed float encodings are a separate + extension. Nothing in this repo emits them — the shipping target is + RV32IMAC, which has no `F` at all — and the F-bearing successor we expect + (`RV32IMAFC`) would need `Zcf` added deliberately, with its own encodings and + tests. A `C.FLW` word today falls through `compressed.rs` as an unknown + compressed instruction, which is the right answer for a hart without `Zcf`. +- **`D` / `Q` / `Zfh`.** A non-`00` `fmt` field is an illegal instruction. +- **Trapping on FP exceptions.** The flags accrue in `fcsr`; nothing traps, + which is what the F extension specifies. +- **A measured FP cycle cost.** FP instructions are classified into + `lp_emu_core::InstClass`'s `Float*` buckets, but no `CycleModel` assigns them + a measured cost — `Esp32C6` is a core with no FPU. Treat FP cycle counts as + instruction counts. + +### Tests + +`executor/float.rs` and `fp_regs.rs` carry the conformance claim in their +`#[cfg(test)] mod tests`. Because this emulator is intended as the oracle for a +future RV32F code generator, the tests *are* the claim, and they assert on bit +patterns rather than on float equality. Notable strands: + +- Every `+ - * /` result is diffed **bit for bit against the host FPU** over a + cross product of ordinary, extreme, and subnormal values — IEEE 754 pins + those four operations exactly, so the host is a valid oracle for `RNE` + (NaN results excluded, since that is where RISC-V deliberately differs). +- `FSQRT.S` is checked against an independent Newton–Raphson root computed in + `f64` with `core`-only arithmetic. +- All five rounding modes, including an exact-tie case, which is the only way + to distinguish `RNE` from `RMM`. +- Every one of the ten `FCLASS.S` classes, both saturation ends of both + integer conversions, the `FMIN`/`FMAX` NaN and signed-zero rules, `FEQ` vs + `FLT`/`FLE` NaN behaviour, sticky flag accrual, and a fused multiply-add case + where the fused and unfused results genuinely differ. diff --git a/lp-riscv/lp-riscv-emu/src/emu/emulator/execution.rs b/lp-riscv/lp-riscv-emu/src/emu/emulator/execution.rs index 51bf39425..fede41c13 100644 --- a/lp-riscv/lp-riscv-emu/src/emu/emulator/execution.rs +++ b/lp-riscv/lp-riscv-emu/src/emu/emulator/execution.rs @@ -66,15 +66,13 @@ impl Riscv32Emulator { // Execute instruction using new executor let pc = self.pc; let exec_result = match self.log_level { - LogLevel::None => { - decode_execute::( - inst_word, - pc, - &mut self.regs, - &mut self.memory, - &mut self.fp, - )? - } + LogLevel::None => decode_execute::( + inst_word, + pc, + &mut self.regs, + &mut self.memory, + &mut self.fp, + )?, _ => decode_execute::( inst_word, pc, diff --git a/lp-riscv/lp-riscv-emu/src/emu/executor/float.rs b/lp-riscv/lp-riscv-emu/src/emu/executor/float.rs index f668da5c7..6759587e5 100644 --- a/lp-riscv/lp-riscv-emu/src/emu/executor/float.rs +++ b/lp-riscv/lp-riscv-emu/src/emu/executor/float.rs @@ -240,10 +240,10 @@ fn execute_fma_family( let c = fp.read_single(rs3); let (a, c) = match opcode { - OPCODE_MADD => (a, c), // ( a * b) + c - OPCODE_MSUB => (a, c ^ SIGN_BIT), // ( a * b) - c - OPCODE_NMSUB => (a ^ SIGN_BIT, c), // (-a * b) + c - _ => (a ^ SIGN_BIT, c ^ SIGN_BIT), // (-a * b) - c (OPCODE_NMADD) + OPCODE_MADD => (a, c), // ( a * b) + c + OPCODE_MSUB => (a, c ^ SIGN_BIT), // ( a * b) - c + OPCODE_NMSUB => (a ^ SIGN_BIT, c), // (-a * b) + c + _ => (a ^ SIGN_BIT, c ^ SIGN_BIT), // (-a * b) - c (OPCODE_NMADD) }; let (bits, flags) = f32_fma(a, b, c, rm); @@ -316,9 +316,9 @@ fn execute_op_fp( let a = fp.read_single(rs1_field); let b = fp.read_single(rs2_field); let sign = match rm_field { - 0b000 => b & SIGN_BIT, // FSGNJ.S - 0b001 => !b & SIGN_BIT, // FSGNJN.S - 0b010 => (a ^ b) & SIGN_BIT, // FSGNJX.S + 0b000 => b & SIGN_BIT, // FSGNJ.S + 0b001 => !b & SIGN_BIT, // FSGNJN.S + 0b010 => (a ^ b) & SIGN_BIT, // FSGNJX.S _ => { return Err(invalid( pc, @@ -609,13 +609,7 @@ fn unpack(bits: u32) -> (bool, i32, u128) { /// Underflow follows the IEEE 754 default that §21.2 adopts: `UF` is raised /// when the result is tiny **after** rounding *and* inexact. A value that /// rounds up to the smallest normal is therefore not an underflow. -fn round_pack( - sign: bool, - exp: i32, - sig: u128, - extra_sticky: bool, - rm: RoundingMode, -) -> (u32, u8) { +fn round_pack(sign: bool, exp: i32, sig: u128, extra_sticky: bool, rm: RoundingMode) -> (u32, u8) { let sign_bit = sign_bit_of(sign); if sig == 0 { @@ -922,8 +916,7 @@ fn f32_fma(a: u32, b: u32, c: u32, rm: RoundingMode) -> (u32, u8) { // multiplicands are inf and zero, **even when the addend is a quiet NaN**. // IEEE 754-2008 §7.2 leaves that case implementation-defined; RISC-V pins // it, so the check goes ahead of the NaN check. - let product_invalid = - (is_inf(a) && is_zero(b)) || (is_zero(a) && is_inf(b)); + let product_invalid = (is_inf(a) && is_zero(b)) || (is_zero(a) && is_inf(b)); if product_invalid { return (CANONICAL_NAN, FFLAG_NV); } @@ -1237,3 +1230,1173 @@ fn isqrt(n: u128) -> (u128, bool) { } (root >> 1, remainder == 0) } + +#[cfg(test)] +mod tests { + extern crate alloc; + use alloc::vec; + + use super::*; + use crate::emu::executor::{LoggingDisabled, decode_execute}; + use crate::emu::fp_regs::FFLAGS_MASK; + use lp_emu_core::{DEFAULT_RAM_START, Memory}; + + const RNE: RoundingMode = RoundingMode::Rne; + const RTZ: RoundingMode = RoundingMode::Rtz; + const RDN: RoundingMode = RoundingMode::Rdn; + const RUP: RoundingMode = RoundingMode::Rup; + const RMM: RoundingMode = RoundingMode::Rmm; + const ALL_MODES: [RoundingMode; 5] = [RNE, RTZ, RDN, RUP, RMM]; + + const ONE: u32 = 0x3f80_0000; + const NEG_ONE: u32 = 0xbf80_0000; + const TWO: u32 = 0x4000_0000; + const THREE: u32 = 0x4040_0000; + const NINE: u32 = 0x4110_0000; + const NEG_ZERO: u32 = 0x8000_0000; + const NEG_INF: u32 = 0xff80_0000; + /// A quiet NaN carrying a payload, to prove payloads are not propagated. + const QNAN_PAYLOAD: u32 = 0x7fc0_1234; + /// A signaling NaN (quiet bit clear, nonzero significand). + const SNAN: u32 = 0x7f80_0001; + /// `2^-23`, one ulp of 1.0. + const ULP_OF_ONE: u32 = 0x3400_0000; + /// `2^-24`, exactly half an ulp of 1.0 — the tie case. + const HALF_ULP_OF_ONE: u32 = 0x3380_0000; + /// The smallest positive subnormal, `2^-149`. + const MIN_SUBNORMAL: u32 = 0x0000_0001; + /// The largest subnormal, `2^-126 - 2^-149`. + const MAX_SUBNORMAL: u32 = 0x007f_ffff; + /// The smallest positive normal, `2^-126`. + const MIN_NORMAL: u32 = 0x0080_0000; + + // -- arithmetic --------------------------------------------------------- + + /// Every `+ - * /` result agrees, bit for bit, with the host FPU's + /// correctly-rounded binary32. The host is a legitimate oracle for RNE: + /// IEEE 754 pins these four operations exactly, so any disagreement is a + /// bug in the soft-float path. NaN results are excluded — that is the one + /// place RISC-V deliberately differs from the host. + #[test] + fn arithmetic_matches_the_host_fpu_bit_for_bit() { + let values: [f32; 22] = [ + 0.0, + -0.0, + 1.0, + -1.0, + 0.5, + 2.0, + 3.0, + 0.1, + 0.2, + 0.3, + 1e-5, + 1e5, + 1e20, + 1e-20, + 1e38, + 1e-38, + f32::MIN_POSITIVE, + 16_777_216.0, + 8_388_609.0, + 123.456, + -987.654, + -7.5, + ]; + let subnormals = [ + f32::from_bits(MIN_SUBNORMAL), + f32::from_bits(0x0040_0000), + f32::from_bits(0x0000_ffff), + ]; + + for a in values.iter().chain(subnormals.iter()).copied() { + for b in values.iter().chain(subnormals.iter()).copied() { + let (ab, bb) = (a.to_bits(), b.to_bits()); + let cases = [ + ("add", f32_add(ab, bb, RNE).0, a + b), + ("sub", f32_add(ab, bb ^ SIGN_BIT, RNE).0, a - b), + ("mul", f32_mul(ab, bb, RNE).0, a * b), + ("div", f32_div(ab, bb, RNE).0, a / b), + ]; + for (name, got, want) in cases { + if want.is_nan() { + // The host picks its own NaN sign and payload; RISC-V + // pins the canonical NaN instead. + assert_eq!( + got, CANONICAL_NAN, + "{name}({a}, {b}) should be the canonical NaN" + ); + continue; + } + assert_eq!( + got, + want.to_bits(), + "{name}({a}, {b}): got {got:#010x}, host {:#010x}", + want.to_bits() + ); + } + } + } + } + + /// Exact halves and the `0.1 + 0.2` identity, asserted on bit patterns. + #[test] + fn exact_and_representative_sums_have_the_expected_bit_patterns() { + assert_eq!(f32_add(0x3f00_0000, 0x3f00_0000, RNE), (ONE, 0)); // 0.5 + 0.5 + assert_eq!(f32_add(ONE, ONE, RNE), (TWO, 0)); + assert_eq!(f32_mul(THREE, 0x40a0_0000, RNE), (0x4170_0000, 0)); // 3 * 5 + assert_eq!(f32_add(THREE, 0x40a0_0000, RNE), (0x4100_0000, 0)); // 3 + 5 + // 0.1f32 + 0.2f32 is inexact and must report NX. + let (bits, flags) = f32_add(0.1f32.to_bits(), 0.2f32.to_bits(), RNE); + assert_eq!(bits, (0.1f32 + 0.2f32).to_bits()); + assert_eq!(flags, FFLAG_NX); + } + + #[test] + fn adding_half_an_ulp_ties_to_even() { + // 1.0 + 2^-24 is exactly halfway between 1.0 and 1.0 + 2^-23. + // RNE breaks the tie toward the even significand, which is 1.0. + assert_eq!(f32_add(ONE, HALF_ULP_OF_ONE, RNE), (ONE, FFLAG_NX)); + // From an odd significand, the same tie goes the other way. + assert_eq!( + f32_add(0x3f80_0001, HALF_ULP_OF_ONE, RNE), + (0x3f80_0002, FFLAG_NX) + ); + // One whole ulp is exact. + assert_eq!(f32_add(ONE, ULP_OF_ONE, RNE), (0x3f80_0001, 0)); + } + + #[test] + fn subnormals_are_preserved_not_flushed() { + // §21.4: full IEEE subnormal arithmetic. Contrast with the + // target-defined FTZ latitude in docs/design/float.md §4, which is + // about the shader tier and not about this emulator. + assert_eq!(f32_add(MIN_SUBNORMAL, MIN_SUBNORMAL, RNE), (0x0000_0002, 0)); + // Halving the smallest normal lands exactly on a subnormal: exact, so + // no underflow flag despite being tiny. + assert_eq!(f32_div(MIN_NORMAL, TWO, RNE), (0x0040_0000, 0)); + // A subnormal operand keeps its full value in a product. + assert_eq!(f32_mul(MIN_SUBNORMAL, TWO, RNE), (0x0000_0002, 0)); + // And the largest subnormal plus one ulp is exactly the smallest normal. + assert_eq!(f32_add(MAX_SUBNORMAL, MIN_SUBNORMAL, RNE), (MIN_NORMAL, 0)); + } + + #[test] + fn gradual_underflow_sets_uf_and_nx() { + // 2^-150 is exactly halfway between 0 and the smallest subnormal. + assert_eq!( + f32_div(MIN_SUBNORMAL, TWO, RNE), + (0, FFLAG_UF | FFLAG_NX), + "ties-to-even rounds down to zero" + ); + assert_eq!( + f32_div(MIN_SUBNORMAL, TWO, RUP), + (MIN_SUBNORMAL, FFLAG_UF | FFLAG_NX) + ); + assert_eq!( + f32_div(MIN_SUBNORMAL, TWO, RMM), + (MIN_SUBNORMAL, FFLAG_UF | FFLAG_NX) + ); + assert_eq!(f32_div(MIN_SUBNORMAL, TWO, RTZ), (0, FFLAG_UF | FFLAG_NX)); + // A negative tiny result under RDN rounds away from zero. + assert_eq!( + f32_div(MIN_SUBNORMAL | SIGN_BIT, TWO, RDN), + (MIN_SUBNORMAL | SIGN_BIT, FFLAG_UF | FFLAG_NX) + ); + } + + #[test] + fn tininess_is_detected_after_rounding() { + // largest_subnormal + 2^-150 is exactly halfway between the largest + // subnormal and the smallest normal. Ties-to-even reaches the normal, + // which is *not* tiny — so NX without UF. Truncating stays subnormal, + // which is tiny and inexact — NX *and* UF. The half-ulp addend is only + // expressible as an exact fused product. + let (a, b) = (MIN_SUBNORMAL, 0x3f00_0000); // 2^-149 * 0.5 + assert_eq!(f32_fma(a, b, MAX_SUBNORMAL, RNE), (MIN_NORMAL, FFLAG_NX)); + assert_eq!( + f32_fma(a, b, MAX_SUBNORMAL, RTZ), + (MAX_SUBNORMAL, FFLAG_UF | FFLAG_NX) + ); + } + + #[test] + fn overflow_saturates_per_rounding_mode() { + let expected = [ + (RNE, POS_INF), + (RTZ, MAX_FINITE), + (RDN, MAX_FINITE), + (RUP, POS_INF), + (RMM, POS_INF), + ]; + for (rm, want) in expected { + assert_eq!( + f32_add(MAX_FINITE, MAX_FINITE, rm), + (want, FFLAG_OF | FFLAG_NX), + "positive overflow under {rm:?}" + ); + } + let expected_negative = [ + (RNE, NEG_INF), + (RTZ, MAX_FINITE | SIGN_BIT), + (RDN, NEG_INF), + (RUP, MAX_FINITE | SIGN_BIT), + (RMM, NEG_INF), + ]; + for (rm, want) in expected_negative { + assert_eq!( + f32_add(MAX_FINITE | SIGN_BIT, MAX_FINITE | SIGN_BIT, rm), + (want, FFLAG_OF | FFLAG_NX), + "negative overflow under {rm:?}" + ); + } + } + + #[test] + fn divide_by_zero_sets_dz_not_nv() { + assert_eq!(f32_div(ONE, 0, RNE), (POS_INF, FFLAG_DZ)); + assert_eq!(f32_div(NEG_ONE, 0, RNE), (NEG_INF, FFLAG_DZ)); + assert_eq!(f32_div(ONE, NEG_ZERO, RNE), (NEG_INF, FFLAG_DZ)); + assert_eq!(f32_div(NEG_ONE, NEG_ZERO, RNE), (POS_INF, FFLAG_DZ)); + // 0/0 is invalid, not a divide by zero. + assert_eq!(f32_div(0, 0, RNE), (CANONICAL_NAN, FFLAG_NV)); + } + + // -- rounding modes ----------------------------------------------------- + + #[test] + fn every_rounding_mode_is_distinguished() { + // A positive inexact quotient: 1/3. Down = 0x3eaaaaaa, up = 0x3eaaaaab. + let positive = [ + (RNE, 0x3eaa_aaab), + (RTZ, 0x3eaa_aaaa), + (RDN, 0x3eaa_aaaa), + (RUP, 0x3eaa_aaab), + (RMM, 0x3eaa_aaab), + ]; + for (rm, want) in positive { + assert_eq!( + f32_div(ONE, THREE, rm), + (want, FFLAG_NX), + "1/3 under {rm:?}" + ); + } + // The same magnitude, negated: RDN and RUP swap, RTZ still truncates. + let negative = [ + (RNE, 0xbeaa_aaab), + (RTZ, 0xbeaa_aaaa), + (RDN, 0xbeaa_aaab), + (RUP, 0xbeaa_aaaa), + (RMM, 0xbeaa_aaab), + ]; + for (rm, want) in negative { + assert_eq!( + f32_div(NEG_ONE, THREE, rm), + (want, FFLAG_NX), + "-1/3 under {rm:?}" + ); + } + // An exact tie separates RNE (to even) from RMM (away from zero), + // which no non-tie case can. + let tie = [ + (RNE, ONE), + (RTZ, ONE), + (RDN, ONE), + (RUP, 0x3f80_0001), + (RMM, 0x3f80_0001), + ]; + for (rm, want) in tie { + assert_eq!( + f32_add(ONE, HALF_ULP_OF_ONE, rm), + (want, FFLAG_NX), + "1 + 2^-24 under {rm:?}" + ); + } + } + + #[test] + fn reserved_rounding_mode_encodings_are_rejected() { + for rm in [0b101u8, 0b110] { + assert_eq!(RoundingMode::resolve(rm, 0), None); + } + // DYN against an invalid frm. + for frm in [0b101u8, 0b110, 0b111] { + assert_eq!(RoundingMode::resolve(0b111, frm), None); + } + } + + // -- NaN handling ------------------------------------------------------- + + #[test] + fn every_nan_producing_operation_yields_the_canonical_nan() { + // From non-NaN operands. + assert_eq!(f32_add(POS_INF, NEG_INF, RNE), (CANONICAL_NAN, FFLAG_NV)); + assert_eq!( + f32_add(POS_INF, POS_INF ^ SIGN_BIT, RNE), + (CANONICAL_NAN, FFLAG_NV) + ); + assert_eq!(f32_mul(0, POS_INF, RNE), (CANONICAL_NAN, FFLAG_NV)); + assert_eq!(f32_mul(NEG_ZERO, NEG_INF, RNE), (CANONICAL_NAN, FFLAG_NV)); + assert_eq!(f32_div(0, 0, RNE), (CANONICAL_NAN, FFLAG_NV)); + assert_eq!(f32_div(POS_INF, POS_INF, RNE), (CANONICAL_NAN, FFLAG_NV)); + assert_eq!(f32_sqrt(NEG_ONE, RNE), (CANONICAL_NAN, FFLAG_NV)); + assert_eq!(f32_sqrt(NEG_INF, RNE), (CANONICAL_NAN, FFLAG_NV)); + assert_eq!(f32_fma(POS_INF, 0, ONE, RNE), (CANONICAL_NAN, FFLAG_NV)); + assert_eq!( + f32_fma(TWO, POS_INF, NEG_INF, RNE), + (CANONICAL_NAN, FFLAG_NV) + ); + } + + #[test] + fn nan_payloads_are_never_propagated() { + // §21.3: RISC-V returns the canonical NaN; it does not forward the + // operand's payload the way IEEE 754 recommends. + for (bits, _) in [ + f32_add(ONE, QNAN_PAYLOAD, RNE), + f32_add(QNAN_PAYLOAD, ONE, RNE), + f32_mul(QNAN_PAYLOAD, TWO, RNE), + f32_div(QNAN_PAYLOAD, TWO, RNE), + f32_sqrt(QNAN_PAYLOAD, RNE), + f32_fma(QNAN_PAYLOAD, ONE, ONE, RNE), + ] { + assert_eq!(bits, CANONICAL_NAN); + } + // A quiet NaN operand alone does not set any flag. + assert_eq!(f32_add(ONE, QNAN_PAYLOAD, RNE).1, 0); + // Not even a *negative* quiet NaN keeps its sign. + assert_eq!(f32_add(ONE, QNAN_PAYLOAD | SIGN_BIT, RNE).0, CANONICAL_NAN); + } + + #[test] + fn signaling_nan_operands_set_nv() { + assert_eq!(f32_add(ONE, SNAN, RNE), (CANONICAL_NAN, FFLAG_NV)); + assert_eq!(f32_mul(SNAN, ONE, RNE), (CANONICAL_NAN, FFLAG_NV)); + assert_eq!(f32_div(SNAN, ONE, RNE), (CANONICAL_NAN, FFLAG_NV)); + assert_eq!(f32_sqrt(SNAN, RNE), (CANONICAL_NAN, FFLAG_NV)); + assert_eq!(f32_fma(ONE, ONE, SNAN, RNE), (CANONICAL_NAN, FFLAG_NV)); + // A negative signaling NaN is still signaling. + assert_eq!( + f32_add(ONE, SNAN | SIGN_BIT, RNE), + (CANONICAL_NAN, FFLAG_NV) + ); + } + + #[test] + fn infinities_pass_through_arithmetic() { + assert_eq!(f32_add(POS_INF, ONE, RNE), (POS_INF, 0)); + assert_eq!(f32_add(NEG_INF, ONE, RNE), (NEG_INF, 0)); + assert_eq!(f32_mul(POS_INF, NEG_ONE, RNE), (NEG_INF, 0)); + assert_eq!(f32_div(ONE, POS_INF, RNE), (0, 0)); + assert_eq!(f32_div(NEG_ONE, POS_INF, RNE), (NEG_ZERO, 0)); + assert_eq!(f32_sqrt(POS_INF, RNE), (POS_INF, 0)); + } + + // -- zeros -------------------------------------------------------------- + + #[test] + fn zero_signs_follow_ieee_and_the_rounding_mode() { + // IEEE 754-2008 §6.3: exact cancellation is +0 except under RDN. + assert_eq!(f32_add(ONE, NEG_ONE, RNE), (0, 0)); + assert_eq!(f32_add(ONE, NEG_ONE, RDN), (NEG_ZERO, 0)); + assert_eq!(f32_add(0, NEG_ZERO, RNE), (0, 0)); + assert_eq!(f32_add(0, NEG_ZERO, RDN), (NEG_ZERO, 0)); + assert_eq!(f32_add(NEG_ZERO, NEG_ZERO, RNE), (NEG_ZERO, 0)); + assert_eq!(f32_add(0, 0, RNE), (0, 0)); + // Signed zero survives multiplication. + assert_eq!(f32_mul(NEG_ONE, 0, RNE), (NEG_ZERO, 0)); + assert_eq!(f32_mul(NEG_ONE, NEG_ZERO, RNE), (0, 0)); + // sqrt(-0) is -0 (IEEE 754 §5.4.1) — the one negative input that is + // not an invalid operation. + assert_eq!(f32_sqrt(NEG_ZERO, RNE), (NEG_ZERO, 0)); + assert_eq!(f32_sqrt(0, RNE), (0, 0)); + } + + // -- FSQRT -------------------------------------------------------------- + + #[test] + fn sqrt_is_exact_on_perfect_squares() { + for (input, want) in [ + (ONE, ONE), + (0x4080_0000, TWO), // sqrt(4) = 2 + (NINE, THREE), // sqrt(9) = 3 + (0x3e80_0000, 0x3f00_0000), // sqrt(0.25) = 0.5 + (0x4b80_0000, 0x4580_0000), // sqrt(2^24) = 2^12 + ] { + assert_eq!(f32_sqrt(input, RNE), (want, 0), "sqrt({input:#010x})"); + } + } + + #[test] + fn sqrt_is_correctly_rounded() { + for value in [ + 2.0f32, 3.0, 5.0, 10.0, 0.1, 0.3, 1e-20, 1e20, 1.5, 123.456, 1e-38, + ] { + let (bits, flags) = f32_sqrt(value.to_bits(), RNE); + assert_eq!( + bits, + reference_sqrt(value).to_bits(), + "sqrt({value}) got {bits:#010x}" + ); + // None of these are perfect squares, so all are inexact. + assert_eq!(flags, FFLAG_NX, "sqrt({value}) flags"); + } + // The classic constant, pinned by literal. + assert_eq!(f32_sqrt(TWO, RNE).0, 0x3fb5_04f3); + } + + #[test] + fn sqrt_honours_the_rounding_mode() { + let down = f32_sqrt(TWO, RTZ).0; + let up = f32_sqrt(TWO, RUP).0; + assert_eq!(up, down + 1, "RTZ and RUP must straddle the exact root"); + assert_eq!(f32_sqrt(TWO, RDN).0, down); + let nearest = f32_sqrt(TWO, RNE).0; + assert!(nearest == down || nearest == up); + // Perfect squares are unaffected by the mode. + for rm in ALL_MODES { + assert_eq!(f32_sqrt(NINE, rm), (THREE, 0), "sqrt(9) under {rm:?}"); + } + } + + // -- fused multiply-add ------------------------------------------------- + + #[test] + fn fma_differs_from_an_unfused_multiply_then_add() { + // a*b is just below the midpoint between 1.0 and 1.0 + 2^-23, so an + // unfused multiply rounds it to exactly 1.0 and the subsequent add + // cancels to zero. Fused, the product keeps its low bits and the sum + // is a small nonzero number — exactly representable, so no flags. + let a = 0x3f80_0001; // 1 + 2^-23 + let b = 0x3f7f_ffff; // 1 - 2^-24 + let c = NEG_ONE; + + let unfused_product = f32_mul(a, b, RNE); + assert_eq!(unfused_product, (ONE, FFLAG_NX)); + assert_eq!(f32_add(unfused_product.0, c, RNE), (0, 0)); + + assert_eq!(f32_fma(a, b, c, RNE), (0x337f_fffe, 0)); + } + + #[test] + fn fma_rounds_exactly_once() { + // 1*1 + 2^-24 is an exact tie, so the single rounding is observable: + // ties-to-even keeps 1.0 while round-up reaches the next float. + assert_eq!(f32_fma(ONE, ONE, HALF_ULP_OF_ONE, RNE), (ONE, FFLAG_NX)); + assert_eq!( + f32_fma(ONE, ONE, HALF_ULP_OF_ONE, RUP), + (0x3f80_0001, FFLAG_NX) + ); + // Exactly representable results agree with a straightforward f64 + // evaluation, which does no rounding at all for these inputs. + for (a, b, c) in [ + (1.5f32, 2.25f32, 0.75f32), + (-3.5, 7.25, 100.0), + (2.0, 4.0, -0.5), + ] { + let (bits, flags) = f32_fma(a.to_bits(), b.to_bits(), c.to_bits(), RNE); + let want = ((a as f64) * (b as f64) + (c as f64)) as f32; + assert_eq!(bits, want.to_bits(), "fma({a}, {b}, {c})"); + assert_eq!(flags, 0, "fma({a}, {b}, {c}) is exact"); + } + } + + #[test] + fn fma_family_sign_conventions() { + // §21.6 spells the four out separately; FNMADD.S is -(a*b) - c, which + // is not the same as -(a*b + c) when the result is an exact zero. + let (a, b, c) = (TWO, THREE, NINE); + assert_eq!(f32_fma(a, b, c, RNE).0, 0x4170_0000); // FMADD: 6 + 9 = 15 + assert_eq!(f32_fma(a, b, c ^ SIGN_BIT, RNE).0, 0xc040_0000); // FMSUB: 6 - 9 = -3 + assert_eq!(f32_fma(a ^ SIGN_BIT, b, c, RNE).0, 0x4040_0000); // FNMSUB: -6 + 9 = 3 + assert_eq!(f32_fma(a ^ SIGN_BIT, b, c ^ SIGN_BIT, RNE).0, 0xc170_0000); // FNMADD + + // The observable difference: FNMADD.S(1, 1, -1) is -(1*1) - (-1) = +0, + // while -(1*1 + -1) would be -0. + assert_eq!(f32_fma(NEG_ONE, ONE, ONE, RNE), (0, 0)); + } + + #[test] + fn fma_signals_invalid_for_inf_times_zero_even_with_a_quiet_nan_addend() { + // §21.6 pins the case IEEE 754-2008 §7.2 leaves implementation-defined. + assert_eq!( + f32_fma(POS_INF, 0, QNAN_PAYLOAD, RNE), + (CANONICAL_NAN, FFLAG_NV) + ); + assert_eq!( + f32_fma(NEG_ZERO, NEG_INF, QNAN_PAYLOAD, RNE), + (CANONICAL_NAN, FFLAG_NV) + ); + } + + #[test] + fn fma_handles_zero_and_infinite_operands() { + // Zero product plus a value is the value. + assert_eq!(f32_fma(0, TWO, THREE, RNE), (THREE, 0)); + // (+0 * +1) + -0 cancels; the sign follows the rounding mode. + assert_eq!(f32_fma(0, ONE, NEG_ZERO, RNE), (0, 0)); + assert_eq!(f32_fma(0, ONE, NEG_ZERO, RDN), (NEG_ZERO, 0)); + // Infinite product with a finite addend. + assert_eq!(f32_fma(POS_INF, TWO, ONE, RNE), (POS_INF, 0)); + // Finite product with an infinite addend. + assert_eq!(f32_fma(TWO, THREE, NEG_INF, RNE), (NEG_INF, 0)); + // A nonzero product plus a zero addend is the rounded product. + assert_eq!(f32_fma(THREE, THREE, NEG_ZERO, RNE), (NINE, 0)); + } + + // -- FMIN / FMAX -------------------------------------------------------- + + #[test] + fn min_max_return_the_non_nan_operand() { + assert_eq!(f32_min_max(ONE, QNAN_PAYLOAD, false), (ONE, 0)); + assert_eq!(f32_min_max(QNAN_PAYLOAD, ONE, false), (ONE, 0)); + assert_eq!(f32_min_max(ONE, QNAN_PAYLOAD, true), (ONE, 0)); + assert_eq!(f32_min_max(QNAN_PAYLOAD, ONE, true), (ONE, 0)); + } + + #[test] + fn min_max_of_two_nans_is_the_canonical_nan() { + assert_eq!( + f32_min_max(QNAN_PAYLOAD, 0x7fc0_5678, false), + (CANONICAL_NAN, 0) + ); + assert_eq!( + f32_min_max(QNAN_PAYLOAD, 0x7fc0_5678, true), + (CANONICAL_NAN, 0) + ); + } + + #[test] + fn min_max_signaling_nan_sets_nv_even_with_a_numeric_result() { + assert_eq!(f32_min_max(SNAN, ONE, false), (ONE, FFLAG_NV)); + assert_eq!(f32_min_max(ONE, SNAN, true), (ONE, FFLAG_NV)); + assert_eq!(f32_min_max(SNAN, SNAN, false), (CANONICAL_NAN, FFLAG_NV)); + } + + #[test] + fn min_max_treat_negative_zero_as_less_than_positive_zero() { + // "For the purposes of these instructions only" (§21.6) — FLT.S still + // says the two zeros are equal, which the compare tests check. + assert_eq!(f32_min_max(NEG_ZERO, 0, false), (NEG_ZERO, 0)); + assert_eq!(f32_min_max(0, NEG_ZERO, false), (NEG_ZERO, 0)); + assert_eq!(f32_min_max(NEG_ZERO, 0, true), (0, 0)); + assert_eq!(f32_min_max(0, NEG_ZERO, true), (0, 0)); + } + + #[test] + fn min_max_order_ordinary_values() { + assert_eq!(f32_min_max(ONE, TWO, false), (ONE, 0)); + assert_eq!(f32_min_max(ONE, TWO, true), (TWO, 0)); + assert_eq!(f32_min_max(NEG_ONE, ONE, false), (NEG_ONE, 0)); + assert_eq!(f32_min_max(NEG_INF, POS_INF, false), (NEG_INF, 0)); + assert_eq!(f32_min_max(NEG_INF, POS_INF, true), (POS_INF, 0)); + assert_eq!( + f32_min_max(MIN_SUBNORMAL, MIN_NORMAL, false), + (MIN_SUBNORMAL, 0) + ); + } + + // -- comparisons -------------------------------------------------------- + + #[test] + fn feq_is_a_quiet_comparison() { + // §21.8: only a signaling NaN sets NV. + assert_eq!(f32_eq(QNAN_PAYLOAD, ONE), (false, 0)); + assert_eq!(f32_eq(ONE, QNAN_PAYLOAD), (false, 0)); + assert_eq!(f32_eq(QNAN_PAYLOAD, QNAN_PAYLOAD), (false, 0)); + assert_eq!(f32_eq(SNAN, ONE), (false, FFLAG_NV)); + assert_eq!(f32_eq(ONE, SNAN), (false, FFLAG_NV)); + } + + #[test] + fn flt_and_fle_are_signaling_comparisons() { + // §21.8: any NaN operand sets NV. + assert_eq!(f32_lt(QNAN_PAYLOAD, ONE), (false, FFLAG_NV)); + assert_eq!(f32_le(QNAN_PAYLOAD, ONE), (false, FFLAG_NV)); + assert_eq!(f32_lt(ONE, QNAN_PAYLOAD), (false, FFLAG_NV)); + assert_eq!(f32_le(QNAN_PAYLOAD, QNAN_PAYLOAD), (false, FFLAG_NV)); + assert_eq!(f32_lt(SNAN, ONE), (false, FFLAG_NV)); + } + + #[test] + fn comparisons_treat_the_two_zeros_as_equal() { + assert_eq!(f32_eq(NEG_ZERO, 0), (true, 0)); + assert_eq!(f32_lt(NEG_ZERO, 0), (false, 0)); + assert_eq!(f32_le(NEG_ZERO, 0), (true, 0)); + assert_eq!(f32_le(0, NEG_ZERO), (true, 0)); + } + + #[test] + fn comparisons_order_ordinary_values() { + assert_eq!(f32_lt(ONE, TWO), (true, 0)); + assert_eq!(f32_lt(TWO, ONE), (false, 0)); + assert_eq!(f32_le(ONE, ONE), (true, 0)); + assert_eq!(f32_eq(ONE, ONE), (true, 0)); + assert_eq!(f32_lt(NEG_ONE, ONE), (true, 0)); + assert_eq!(f32_lt(NEG_INF, NEG_ONE), (true, 0)); + assert_eq!(f32_lt(ONE, POS_INF), (true, 0)); + } + + // -- sign injection ----------------------------------------------------- + + #[test] + fn sign_injection_is_bit_exact_and_never_canonicalizes() { + // Decoded through the executor, because the sign-injection logic lives + // in the OP-FP arm rather than in a helper. + let cases = [ + // (funct3, rs1, rs2, expected) + (0b000u8, QNAN_PAYLOAD, NEG_ZERO, QNAN_PAYLOAD | SIGN_BIT), + (0b000, QNAN_PAYLOAD | SIGN_BIT, 0, QNAN_PAYLOAD), + (0b001, QNAN_PAYLOAD, 0, QNAN_PAYLOAD | SIGN_BIT), + (0b001, QNAN_PAYLOAD, NEG_ZERO, QNAN_PAYLOAD), + (0b010, NEG_ONE, NEG_ONE, ONE), + (0b010, NEG_ONE, ONE, NEG_ONE), + // A signaling NaN stays signaling: no quieting, no NV. + (0b000, SNAN | SIGN_BIT, 0, SNAN), + ]; + for (funct3, a, b, want) in cases { + let mut h = Harness::new(); + h.fp.write_single(1, a); + h.fp.write_single(2, b); + h.run(enc_op_fp(0b001_0000, 2, 1, funct3, 3)); + assert_eq!( + h.fp.read_single(3), + want, + "sign injection funct3={funct3:03b}" + ); + assert_eq!(h.fp.fflags(), 0, "sign injection must raise no flags"); + } + } + + // -- FCLASS ------------------------------------------------------------- + + #[test] + fn fclass_covers_all_ten_classes() { + // §21.9, in the spec's table order. + let cases = [ + (NEG_INF, 1 << 0), + (NEG_ONE, 1 << 1), + (MIN_SUBNORMAL | SIGN_BIT, 1 << 2), + (NEG_ZERO, 1 << 3), + (0, 1 << 4), + (MIN_SUBNORMAL, 1 << 5), + (ONE, 1 << 6), + (POS_INF, 1 << 7), + (SNAN, 1 << 8), + (QNAN_PAYLOAD, 1 << 9), + ]; + for (bits, want) in cases { + assert_eq!(f32_classify(bits), want, "FCLASS.S({bits:#010x})"); + assert_eq!(f32_classify(bits).count_ones(), 1); + } + // A NaN classifies by quiet/signaling, not by sign. + assert_eq!(f32_classify(SNAN | SIGN_BIT), 1 << 8); + assert_eq!(f32_classify(QNAN_PAYLOAD | SIGN_BIT), 1 << 9); + // The boundaries between the subnormal and normal classes. + assert_eq!(f32_classify(MAX_SUBNORMAL), 1 << 5); + assert_eq!(f32_classify(MIN_NORMAL), 1 << 6); + assert_eq!(f32_classify(MAX_FINITE), 1 << 6); + } + + // -- conversions -------------------------------------------------------- + + #[test] + fn fcvt_w_s_saturates_at_both_ends_and_for_nan() { + // §21.7's table of invalid-input behaviour. NaN goes to the *maximum*, + // which is neither what a Rust `as` cast does (0) nor what C promises. + assert_eq!(f32_to_i32(QNAN_PAYLOAD, true, RNE), (i32::MAX, FFLAG_NV)); + assert_eq!(f32_to_i32(SNAN, true, RNE), (i32::MAX, FFLAG_NV)); + assert_eq!(f32_to_i32(POS_INF, true, RNE), (i32::MAX, FFLAG_NV)); + assert_eq!(f32_to_i32(NEG_INF, true, RNE), (i32::MIN, FFLAG_NV)); + // 2^31 is one past the top. + assert_eq!(f32_to_i32(0x4f00_0000, true, RNE), (i32::MAX, FFLAG_NV)); + // -2^31 is exactly representable and therefore valid. + assert_eq!(f32_to_i32(0xcf00_0000, true, RNE), (i32::MIN, 0)); + // One float below -2^31 is out of range. + assert_eq!(f32_to_i32(0xcf00_0001, true, RNE), (i32::MIN, FFLAG_NV)); + // The largest in-range float, 2^31 - 128. + assert_eq!(f32_to_i32(0x4eff_ffff, true, RNE), (2_147_483_520, 0)); + } + + #[test] + fn fcvt_wu_s_saturates_at_both_ends_and_for_nan() { + let umax = u32::MAX as i32; + assert_eq!(f32_to_i32(QNAN_PAYLOAD, false, RNE), (umax, FFLAG_NV)); + assert_eq!(f32_to_i32(POS_INF, false, RNE), (umax, FFLAG_NV)); + assert_eq!(f32_to_i32(NEG_INF, false, RNE), (0, FFLAG_NV)); + assert_eq!(f32_to_i32(NEG_ONE, false, RNE), (0, FFLAG_NV)); + // 2^32 is one past the top; 2^32 - 256 is the largest in-range float. + assert_eq!(f32_to_i32(0x4f80_0000, false, RNE), (umax, FFLAG_NV)); + assert_eq!( + f32_to_i32(0x4f7f_ffff, false, RNE), + (4_294_967_040u32 as i32, 0) + ); + // -0.0 converts to 0 without complaint. + assert_eq!(f32_to_i32(NEG_ZERO, false, RNE), (0, 0)); + } + + #[test] + fn fcvt_range_check_applies_to_the_rounded_result() { + // -0.5 truncates to 0, which is representable: valid, inexact. + assert_eq!(f32_to_i32(0xbf00_0000, false, RTZ), (0, FFLAG_NX)); + // The same input rounded down is -1, which is not: invalid, and NV + // suppresses NX. + assert_eq!(f32_to_i32(0xbf00_0000, false, RDN), (0, FFLAG_NV)); + // RNE takes -0.5 to zero (ties to even), also in range. + assert_eq!(f32_to_i32(0xbf00_0000, false, RNE), (0, FFLAG_NX)); + } + + #[test] + fn fcvt_to_integer_honours_every_rounding_mode() { + let one_and_a_half = 0x3fc0_0000u32; + let two_and_a_half = 0x4020_0000u32; + let minus_one_and_a_half = one_and_a_half | SIGN_BIT; + let expected = [ + (RNE, 2, 2, -2), + (RTZ, 1, 2, -1), + (RDN, 1, 2, -2), + (RUP, 2, 3, -1), + (RMM, 2, 3, -2), + ]; + for (rm, want_1_5, want_2_5, want_neg_1_5) in expected { + assert_eq!( + f32_to_i32(one_and_a_half, true, rm), + (want_1_5, FFLAG_NX), + "1.5 under {rm:?}" + ); + assert_eq!( + f32_to_i32(two_and_a_half, true, rm), + (want_2_5, FFLAG_NX), + "2.5 under {rm:?}" + ); + assert_eq!( + f32_to_i32(minus_one_and_a_half, true, rm), + (want_neg_1_5, FFLAG_NX), + "-1.5 under {rm:?}" + ); + } + // Exact integers set no flag. + assert_eq!(f32_to_i32(THREE, true, RTZ), (3, 0)); + assert_eq!(f32_to_i32(0, true, RNE), (0, 0)); + } + + #[test] + fn fcvt_from_integer_is_exact_below_two_to_the_24() { + assert_eq!(i32_to_f32(0, true, RNE), (0, 0)); + assert_eq!(i32_to_f32(1, true, RNE), (ONE, 0)); + assert_eq!(i32_to_f32(-1, true, RNE), (NEG_ONE, 0)); + assert_eq!(i32_to_f32(123, true, RNE), (0x42f6_0000, 0)); + assert_eq!(i32_to_f32(16_777_216, true, RNE), (0x4b80_0000, 0)); + assert_eq!(i32_to_f32(i32::MIN, true, RNE), (0xcf00_0000, 0)); + } + + #[test] + fn fcvt_from_integer_rounds_above_two_to_the_24() { + // 2^24 + 1 is a tie between 2^24 and 2^24 + 2; RNE picks the even one. + assert_eq!(i32_to_f32(16_777_217, true, RNE), (0x4b80_0000, FFLAG_NX)); + assert_eq!(i32_to_f32(16_777_217, true, RUP), (0x4b80_0001, FFLAG_NX)); + assert_eq!(i32_to_f32(16_777_217, true, RTZ), (0x4b80_0000, FFLAG_NX)); + // i32::MAX rounds up to 2^31 under RNE, down under RTZ. + assert_eq!(i32_to_f32(i32::MAX, true, RNE), (0x4f00_0000, FFLAG_NX)); + assert_eq!(i32_to_f32(i32::MAX, true, RTZ), (0x4eff_ffff, FFLAG_NX)); + // Unsigned reads the same register as a u32. + assert_eq!(i32_to_f32(-1, false, RNE), (0x4f80_0000, FFLAG_NX)); + assert_eq!(i32_to_f32(-1, false, RTZ), (0x4f7f_ffff, FFLAG_NX)); + } + + #[test] + fn integer_and_float_conversions_round_trip() { + for value in [0i32, 1, -1, 42, -42, 65_536, -65_536, 8_388_607, -8_388_607] { + let (bits, _) = i32_to_f32(value, true, RNE); + assert_eq!( + f32_to_i32(bits, true, RTZ), + (value, 0), + "round trip {value}" + ); + } + } + + // -- decode / dispatch -------------------------------------------------- + + #[test] + fn flw_and_fsw_round_trip_a_raw_bit_pattern_through_memory() { + // A signaling NaN with a payload: loads and stores must not touch it. + let pattern = 0xff80_1234u32; + let mut h = Harness::new(); + h.regs[1] = DEFAULT_RAM_START as i32; + h.fp.write_single(5, pattern); + + h.run(enc_s(OPCODE_STORE_FP, 0b010, 1, 5, 8)); // FSW f5, 8(x1) + assert_eq!( + h.memory.read_word(DEFAULT_RAM_START + 8).unwrap() as u32, + pattern + ); + + h.run(enc_i(OPCODE_LOAD_FP, 6, 0b010, 1, 8)); // FLW f6, 8(x1) + assert_eq!(h.fp.read_single(6), pattern); + assert_eq!(h.fp.fflags(), 0); + } + + #[test] + fn flw_and_fsw_accept_negative_offsets() { + let mut h = Harness::new(); + h.regs[1] = (DEFAULT_RAM_START + 64) as i32; + h.fp.write_single(5, ONE); + h.run(enc_s(OPCODE_STORE_FP, 0b010, 1, 5, -16)); + h.run(enc_i(OPCODE_LOAD_FP, 6, 0b010, 1, -16)); + assert_eq!(h.fp.read_single(6), ONE); + } + + #[test] + fn non_word_float_load_and_store_widths_are_illegal() { + // funct3 = 011 would be FLD/FSD, which RV32F does not implement. + let mut h = Harness::new(); + assert!(h.try_run(enc_i(OPCODE_LOAD_FP, 1, 0b011, 0, 0)).is_err()); + assert!(h.try_run(enc_s(OPCODE_STORE_FP, 0b011, 0, 1, 0)).is_err()); + } + + #[test] + fn op_fp_arithmetic_writes_the_destination_and_accrues_flags() { + let mut h = Harness::new(); + h.fp.write_single(1, ONE); + h.fp.write_single(2, THREE); + h.run(enc_op_fp(0b000_1100, 2, 1, 0b000, 3)); // FDIV.S f3, f1, f2 + assert_eq!(h.fp.read_single(3), 0x3eaa_aaab); + assert_eq!(h.fp.fflags(), FFLAG_NX); + } + + #[test] + fn every_op_fp_arithmetic_encoding_dispatches() { + let cases = [ + (0b000_0000u8, NINE), // FADD.S 6 + 3 = 9 + (0b000_0100, THREE), // FSUB.S 6 - 3 = 3 + (0b000_1000, 0x4190_0000), // FMUL.S 6 * 3 = 18 + (0b000_1100, TWO), // FDIV.S 6 / 3 = 2 + ]; + for (funct7, want) in cases { + let mut h = Harness::new(); + h.fp.write_single(1, 0x40c0_0000); // 6.0 + h.fp.write_single(2, THREE); + h.run(enc_op_fp(funct7, 2, 1, 0b000, 3)); + assert_eq!(h.fp.read_single(3), want, "funct7={funct7:07b}"); + } + // FSQRT.S takes a single source. + let mut h = Harness::new(); + h.fp.write_single(1, NINE); + h.run(enc_op_fp(0b010_1100, 0, 1, 0b000, 2)); + assert_eq!(h.fp.read_single(2), THREE); + // FMIN.S / FMAX.S. + let mut h = Harness::new(); + h.fp.write_single(1, ONE); + h.fp.write_single(2, TWO); + h.run(enc_op_fp(0b001_0100, 2, 1, 0b000, 3)); + h.run(enc_op_fp(0b001_0100, 2, 1, 0b001, 4)); + assert_eq!(h.fp.read_single(3), ONE); + assert_eq!(h.fp.read_single(4), TWO); + } + + #[test] + fn integer_conversion_encodings_dispatch() { + let mut h = Harness::new(); + h.fp.write_single(1, 0xc0c0_0000); // -6.0 + h.run(enc_op_fp(0b110_0000, 0b00000, 1, 0b001, 2)); // FCVT.W.S (RTZ) + assert_eq!(h.regs[2], -6); + h.run(enc_op_fp(0b110_0000, 0b00001, 1, 0b001, 3)); // FCVT.WU.S (RTZ) + assert_eq!(h.regs[3], 0); + assert_eq!(h.fp.fflags(), FFLAG_NV); + + let mut h = Harness::new(); + h.regs[1] = -6; + h.run(enc_op_fp(0b110_1000, 0b00000, 1, 0b000, 2)); // FCVT.S.W + assert_eq!(h.fp.read_single(2), 0xc0c0_0000); + h.run(enc_op_fp(0b110_1000, 0b00001, 1, 0b000, 3)); // FCVT.S.WU + assert_eq!(h.fp.read_single(3), 0x4f80_0000); // 2^32 - 6, rounded to 2^32 + } + + #[test] + fn exception_flags_accrue_and_are_never_cleared_by_arithmetic() { + let mut h = Harness::new(); + h.fp.write_single(1, ONE); + h.fp.write_single(2, THREE); + h.fp.write_single(3, 0); + h.fp.write_single(7, NEG_ONE); + + // An inexact divide sets NX. + h.run(enc_op_fp(0b000_1100, 2, 1, 0b000, 4)); + assert_eq!(h.fp.fflags(), FFLAG_NX); + // An exact add must not clear it. + h.run(enc_op_fp(0b000_0000, 1, 1, 0b000, 5)); // FADD.S f5, f1, f1 + assert_eq!(h.fp.fflags(), FFLAG_NX); + // A divide by zero adds DZ on top. + h.run(enc_op_fp(0b000_1100, 3, 1, 0b000, 6)); + assert_eq!(h.fp.fflags(), FFLAG_NX | FFLAG_DZ); + // And an invalid operation adds NV. + h.run(enc_op_fp(0b010_1100, 0, 7, 0b000, 8)); // FSQRT.S f8, f7 (f7 < 0) + assert_eq!(h.fp.fflags(), FFLAG_NX | FFLAG_DZ | FFLAG_NV); + // Nothing outside the five defined bits is ever set. + assert_eq!(h.fp.fflags() & !FFLAGS_MASK, 0); + } + + #[test] + fn dynamic_rounding_mode_comes_from_frm() { + let mut h = Harness::new(); + h.fp.write_single(1, ONE); + h.fp.write_single(2, THREE); + h.fp.set_frm(0b001); // RTZ + h.run(enc_op_fp(0b000_1100, 2, 1, 0b111, 3)); // FDIV.S with rm = DYN + assert_eq!(h.fp.read_single(3), 0x3eaa_aaaa); + } + + #[test] + fn reserved_rounding_modes_raise_illegal_instruction() { + for rm in [0b101u8, 0b110] { + let mut h = Harness::new(); + let err = h.try_run(enc_op_fp(0b000_0000, 2, 1, rm, 3)).unwrap_err(); + assert!(matches!(err, EmulatorError::InvalidInstruction { .. })); + } + // DYN with frm holding an invalid value is equally illegal. + let mut h = Harness::new(); + h.fp.set_frm(0b111); + let err = h + .try_run(enc_op_fp(0b000_0000, 2, 1, 0b111, 3)) + .unwrap_err(); + assert!(matches!(err, EmulatorError::InvalidInstruction { .. })); + // The fused multiply-add family checks the same field. + let mut h = Harness::new(); + assert!(h.try_run(enc_r4(OPCODE_MADD, 3, 2, 1, 0b101, 4)).is_err()); + } + + #[test] + fn fmv_moves_raw_bits_in_both_directions() { + let pattern = 0xff80_1234u32; // a negative signaling NaN + let mut h = Harness::new(); + h.regs[1] = pattern as i32; + h.run(enc_op_fp(0b111_1000, 0, 1, 0b000, 4)); // FMV.W.X f4, x1 + assert_eq!(h.fp.read_single(4), pattern); + h.run(enc_op_fp(0b111_0000, 0, 4, 0b000, 2)); // FMV.X.W x2, f4 + assert_eq!(h.regs[2] as u32, pattern); + assert_eq!(h.fp.fflags(), 0, "moves raise no flags"); + } + + #[test] + fn fclass_and_compares_write_the_integer_register() { + let mut h = Harness::new(); + h.fp.write_single(1, NEG_INF); + h.fp.write_single(2, ONE); + h.run(enc_op_fp(0b111_0000, 0, 1, 0b001, 3)); // FCLASS.S x3, f1 + assert_eq!(h.regs[3], 1); + h.run(enc_op_fp(0b101_0000, 2, 1, 0b001, 4)); // FLT.S x4, f1, f2 + assert_eq!(h.regs[4], 1); + h.run(enc_op_fp(0b101_0000, 1, 2, 0b010, 5)); // FEQ.S x5, f2, f1 + assert_eq!(h.regs[5], 0); + h.run(enc_op_fp(0b101_0000, 2, 2, 0b000, 6)); // FLE.S x6, f2, f2 + assert_eq!(h.regs[6], 1); + } + + #[test] + fn integer_destination_x0_is_never_written() { + let mut h = Harness::new(); + h.fp.write_single(1, ONE); + h.run(enc_op_fp(0b111_0000, 0, 1, 0b000, 0)); // FMV.X.W x0, f1 + assert_eq!(h.regs[0], 0); + h.run(enc_op_fp(0b101_0000, 1, 1, 0b010, 0)); // FEQ.S x0, f1, f1 + assert_eq!(h.regs[0], 0); + } + + #[test] + fn f0_is_an_ordinary_writable_register() { + // Unlike x0, no floating-point register is hardwired to zero (§21.1). + let mut h = Harness::new(); + h.fp.write_single(1, ONE); + h.run(enc_op_fp(0b000_0000, 1, 1, 0b000, 0)); // FADD.S f0, f1, f1 + assert_eq!(h.fp.read_single(0), TWO); + } + + #[test] + fn fused_multiply_add_opcodes_decode_to_the_right_signs() { + let cases = [ + (OPCODE_MADD, 0x4170_0000u32), // 6 + 9 = 15 + (OPCODE_MSUB, 0xc040_0000), // 6 - 9 = -3 + (OPCODE_NMSUB, 0x4040_0000), // -6 + 9 = 3 + (OPCODE_NMADD, 0xc170_0000), // -6 - 9 = -15 + ]; + for (opcode, want) in cases { + let mut h = Harness::new(); + h.fp.write_single(1, TWO); + h.fp.write_single(2, THREE); + h.fp.write_single(3, NINE); + h.run(enc_r4(opcode, 3, 2, 1, 0b000, 4)); + assert_eq!(h.fp.read_single(4), want, "opcode {opcode:#04x}"); + } + } + + #[test] + fn unrecognized_float_encodings_are_illegal_instructions() { + let mut h = Harness::new(); + // An OP-FP funct7 RV32F does not define. + assert!(h.try_run(enc_op_fp(0b011_1111, 0, 0, 0b000, 0)).is_err()); + // FSQRT.S with a nonzero rs2. + assert!(h.try_run(enc_op_fp(0b010_1100, 1, 0, 0b000, 0)).is_err()); + // A compare with an undefined funct3. + assert!(h.try_run(enc_op_fp(0b101_0000, 0, 0, 0b100, 0)).is_err()); + // A sign-injection variant that does not exist. + assert!(h.try_run(enc_op_fp(0b001_0000, 0, 0, 0b011, 0)).is_err()); + // FMIN/FMAX with an undefined funct3. + assert!(h.try_run(enc_op_fp(0b001_0100, 0, 0, 0b010, 0)).is_err()); + // FCVT selectors RV32F does not define (rs2 = 2 is an RV64 form). + assert!( + h.try_run(enc_op_fp(0b110_0000, 0b00010, 0, 0b000, 0)) + .is_err() + ); + assert!( + h.try_run(enc_op_fp(0b110_1000, 0b00010, 0, 0b000, 0)) + .is_err() + ); + // Double precision (fmt = 01) in the fused multiply-add family. + assert!( + h.try_run(enc_r4(OPCODE_MADD, 0, 0, 0, 0b000, 0) | (0b01 << 25)) + .is_err() + ); + // FMV.W.X with a nonzero rs2 field. + assert!(h.try_run(enc_op_fp(0b111_1000, 1, 0, 0b000, 0)).is_err()); + // FMV.X.W / FCLASS.S with an undefined funct3. + assert!(h.try_run(enc_op_fp(0b111_0000, 0, 0, 0b010, 0)).is_err()); + } + + #[test] + fn a_float_program_runs_through_the_emulator() { + use crate::Riscv32Emulator; + use lp_emu_core::StepResult; + + // FLW f1, 0(x1); FLW f2, 4(x1); FADD.S f3, f1, f2; FSW f3, 8(x1); EBREAK + let program = [ + enc_i(OPCODE_LOAD_FP, 1, 0b010, 1, 0), + enc_i(OPCODE_LOAD_FP, 2, 0b010, 1, 4), + enc_op_fp(0b000_0000, 2, 1, 0b000, 3), + enc_s(OPCODE_STORE_FP, 0b010, 1, 3, 8), + lp_riscv_inst::encode::ebreak(), + ]; + let mut code = alloc::vec::Vec::new(); + for word in program { + code.extend_from_slice(&word.to_le_bytes()); + } + let mut ram = vec![0u8; 1024]; + ram[0..4].copy_from_slice(&0.1f32.to_bits().to_le_bytes()); + ram[4..8].copy_from_slice(&0.2f32.to_bits().to_le_bytes()); + + let mut emu = Riscv32Emulator::new(code, ram); + emu.set_register(Gpr::new(1), DEFAULT_RAM_START as i32); + emu.set_pc(0); + loop { + match emu.step().expect("step") { + StepResult::Continue => {} + StepResult::Halted => break, + other => panic!("unexpected {other:?}"), + } + } + assert_eq!( + emu.memory().read_word(DEFAULT_RAM_START + 8).unwrap() as u32, + (0.1f32 + 0.2f32).to_bits() + ); + assert_eq!(emu.get_fp_register(3), (0.1f32 + 0.2f32).to_bits()); + assert_eq!(emu.fp_regs().fflags(), FFLAG_NX); + } + + #[test] + fn all_rounding_modes_stay_wired_up() { + // A guard against silently dropping a mode from the tables above. + assert_eq!(ALL_MODES.len(), 5); + for rm in ALL_MODES { + assert_eq!(f32_add(ONE, ONE, rm), (TWO, 0), "exact add under {rm:?}"); + assert_eq!(f32_mul(TWO, THREE, rm), (0x40c0_0000, 0)); + } + } + + // -- helpers ------------------------------------------------------------ + + /// A minimal decode-and-execute rig: integer registers, RAM, and FP state. + struct Harness { + regs: [i32; 32], + memory: Memory, + fp: FpRegs, + } + + impl Harness { + fn new() -> Self { + Self { + regs: [0i32; 32], + memory: Memory::with_default_addresses(vec![], vec![0u8; 1024]), + fp: FpRegs::new(), + } + } + + fn try_run(&mut self, inst_word: u32) -> Result { + decode_execute::( + inst_word, + 0, + &mut self.regs, + &mut self.memory, + &mut self.fp, + ) + } + + fn run(&mut self, inst_word: u32) -> ExecutionResult { + self.try_run(inst_word).expect("instruction should execute") + } + } + + fn enc_i(opcode: u8, rd: u8, funct3: u8, rs1: u8, imm: i32) -> u32 { + (((imm as u32) & 0xfff) << 20) + | (u32::from(rs1) << 15) + | (u32::from(funct3) << 12) + | (u32::from(rd) << 7) + | u32::from(opcode) + } + + fn enc_s(opcode: u8, funct3: u8, rs1: u8, rs2: u8, imm: i32) -> u32 { + let imm = imm as u32; + (((imm >> 5) & 0x7f) << 25) + | (u32::from(rs2) << 20) + | (u32::from(rs1) << 15) + | (u32::from(funct3) << 12) + | ((imm & 0x1f) << 7) + | u32::from(opcode) + } + + fn enc_op_fp(funct7: u8, rs2: u8, rs1: u8, rm: u8, rd: u8) -> u32 { + (u32::from(funct7) << 25) + | (u32::from(rs2) << 20) + | (u32::from(rs1) << 15) + | (u32::from(rm) << 12) + | (u32::from(rd) << 7) + | u32::from(OPCODE_OP_FP) + } + + fn enc_r4(opcode: u8, rs3: u8, rs2: u8, rs1: u8, rm: u8, rd: u8) -> u32 { + (u32::from(rs3) << 27) + | (u32::from(rs2) << 20) + | (u32::from(rs1) << 15) + | (u32::from(rm) << 12) + | (u32::from(rd) << 7) + | u32::from(opcode) + } + + /// An independent square root, for the correctly-rounded check. + /// + /// Newton–Raphson in `f64` from a bit-twiddled seed, using only `core` + /// arithmetic (`f32::sqrt` lives in `std`, which this crate does not + /// require). Rounding the `f64` root to `f32` is safe: `f64` carries + /// 53 >= 2*24 + 2 bits, the classical width at which a narrowing double + /// rounding of a square root cannot differ from rounding the exact result. + fn reference_sqrt(x: f32) -> f32 { + let a = x as f64; + if a <= 0.0 { + return x; + } + let mut g = f64::from_bits((a.to_bits() + 0x3ff0_0000_0000_0000) >> 1); + for _ in 0..10 { + g = 0.5 * (g + a / g); + } + g as f32 + } +} diff --git a/lp-riscv/lp-riscv-emu/src/emu/executor/mod.rs b/lp-riscv/lp-riscv-emu/src/emu/executor/mod.rs index 66742f995..f2d8e40b0 100644 --- a/lp-riscv/lp-riscv-emu/src/emu/executor/mod.rs +++ b/lp-riscv/lp-riscv-emu/src/emu/executor/mod.rs @@ -134,9 +134,7 @@ pub fn decode_execute( | float::OPCODE_MSUB | float::OPCODE_NMSUB | float::OPCODE_NMADD - | float::OPCODE_OP_FP => { - float::decode_execute_float::(inst_word, pc, regs, _memory, fp) - } + | float::OPCODE_OP_FP => float::decode_execute_float::(inst_word, pc, regs, _memory, fp), _ => Err(EmulatorError::InvalidInstruction { pc, instruction: inst_word, diff --git a/lp-riscv/lp-riscv-emu/src/emu/executor/system.rs b/lp-riscv/lp-riscv-emu/src/emu/executor/system.rs index 4c1de7139..b2f9f9cb6 100644 --- a/lp-riscv/lp-riscv-emu/src/emu/executor/system.rs +++ b/lp-riscv/lp-riscv-emu/src/emu/executor/system.rs @@ -65,17 +65,7 @@ pub(super) fn decode_execute_system( 0b010 | 0b110 => CsrOp::Set, _ => CsrOp::Clear, }; - execute_csr::( - rd, - csr, - op, - source, - source_is_zero, - inst_word, - pc, - regs, - fp, - ) + execute_csr::(rd, csr, op, source, source_is_zero, inst_word, pc, regs, fp) } } @@ -294,14 +284,9 @@ mod tests { let mut fp = FpRegs::new(); let inst_word = encode::ecall(); - let result = decode_execute_system::( - inst_word, - 0, - &mut regs, - &mut memory, - &mut fp, - ) - .unwrap(); + let result = + decode_execute_system::(inst_word, 0, &mut regs, &mut memory, &mut fp) + .unwrap(); assert!(result.syscall); assert!(!result.should_halt); @@ -315,14 +300,9 @@ mod tests { let mut fp = FpRegs::new(); let inst_word = encode::ebreak(); - let result = decode_execute_system::( - inst_word, - 0, - &mut regs, - &mut memory, - &mut fp, - ) - .unwrap(); + let result = + decode_execute_system::(inst_word, 0, &mut regs, &mut memory, &mut fp) + .unwrap(); assert!(!result.syscall); assert!(result.should_halt); @@ -336,14 +316,9 @@ mod tests { let mut fp = FpRegs::new(); let inst_word = encode::ecall(); - let result = decode_execute_system::( - inst_word, - 0, - &mut regs, - &mut memory, - &mut fp, - ) - .unwrap(); + let result = + decode_execute_system::(inst_word, 0, &mut regs, &mut memory, &mut fp) + .unwrap(); assert!(result.syscall); assert!(result.log.is_some()); diff --git a/lp-riscv/lp-riscv-emu/src/emu/fp_regs.rs b/lp-riscv/lp-riscv-emu/src/emu/fp_regs.rs index 0b3fef845..495b4be0a 100644 --- a/lp-riscv/lp-riscv-emu/src/emu/fp_regs.rs +++ b/lp-riscv/lp-riscv-emu/src/emu/fp_regs.rs @@ -322,7 +322,10 @@ mod tests { assert_eq!(fp.frm(), 0b010); assert!(fp.write_csr(CSR_FFLAGS, FFLAG_DZ as u32)); assert_eq!(fp.fflags(), FFLAG_DZ); - assert_eq!(fp.read_csr(CSR_FCSR), Some(u32::from((0b010 << 5) | FFLAG_DZ))); + assert_eq!( + fp.read_csr(CSR_FCSR), + Some(u32::from((0b010 << 5) | FFLAG_DZ)) + ); } #[test] diff --git a/lp-riscv/lp-riscv-emu/src/emu/mod.rs b/lp-riscv/lp-riscv-emu/src/emu/mod.rs index 67d477584..2d31a122b 100644 --- a/lp-riscv/lp-riscv-emu/src/emu/mod.rs +++ b/lp-riscv/lp-riscv-emu/src/emu/mod.rs @@ -9,6 +9,6 @@ pub mod logging; #[cfg(feature = "std")] pub use emulator::FrameOutcome; pub use emulator::{DEFAULT_CALL_INSTRUCTION_LIMIT, Riscv32Emulator}; -pub use fp_regs::{FpRegs, RoundingMode}; pub use error::{EmulatorError, trap_code_from_cranelift}; +pub use fp_regs::{FpRegs, RoundingMode}; pub use logging::InstLog; diff --git a/lp-shader/lpvm-native/README.md b/lp-shader/lpvm-native/README.md index 2138afd2c..1c6a8ba6d 100644 --- a/lp-shader/lpvm-native/README.md +++ b/lp-shader/lpvm-native/README.md @@ -96,6 +96,11 @@ fallback into the other file. Hardware-float codegen (Xtensa FPU, RV32F) fills these in; until then this is *shape without content*, and the Q32 path is provably unchanged by it. +Note that **native f32 on rv32 does not change this**: the soft-float path (see +[Float mode](#float-mode-and-the-float-capability-seam)) keeps every value in +integer registers, because that is what the soft-float ABI does. The empty float +pool is the right answer for both numeric modes on every target we ship today. + Two subtleties worth keeping straight: - **A vreg's class comes from the instruction that defines it, not from its LPIR @@ -250,6 +255,61 @@ touching them. `isa/xt/imm.rs` is such a file: its per-opcode immediate table is derived data, and **the encoder silently truncates**, so every immediate must be gated through `is_legal` before it reaches `lp_xt_inst::encode`. +### Float mode and the float-capability seam + +The backend compiles a shader in one of two numeric modes +(`NativeCompileOptions::float_mode`): **Q16.16 fixed point**, where a GLSL +`float` is an integer and every float op is integer arithmetic, or **native +f32**, IEEE-754 binary32. Q32 lowering lives in `lower.rs`; f32 lowering lives in +`lower_f32.rs`, behind the `float-f32` feature. + +**"rv32" is not one float story**, and that is what the seam exists for. The +ESP32-C6 and RP2350's Hazard3 are RV32IMAC with no F extension; the ESP32-S31 and +ESP32-P4 are RV32IMAFC with a per-core FPU. So float capability is a named +property of the target, `IsaTarget::f32_lowering`: + +| `F32Lowering` | Meaning | Who answers it | +| ----------------- | -------------------------------------------------------------------- | -------------- | +| `SoftFloatCalls` | Float ops call the platform soft-float library; values in **integer** registers | `Rv32imac` | +| `HardwareFpu` | Float ops are FP instructions on the float register file | *nobody yet* | +| `Unsupported` | `FloatMode::F32` is a compile error naming the target | `Xtensa` (until M7) | + +**Nothing answers `HardwareFpu`**, and `isa::tests::f32_lowering_never_claims_hardware` +keeps it that way. That is the guarantee: no code path in this crate can emit an +F-extension instruction, so no C6 can be handed a `fadd.s` it would trap on. An +F-bearing rv32 part is a **new `IsaTarget` variant**, never a flag on `Rv32imac` +— the variant names the hardware, per the type's own doc comment. + +**Soft float calls the platform ABI directly, with no wrapper.** `__addsf3`, +`__ltsf2`, `__floatsisf` and friends are emitted as ordinary `Call` VInsts, the +same mechanism Q32 uses for its helpers. The symbols already exist in every rv32 +image: on the C6 the linker resolves them to the chip's **ROM** `rvfplib` +(`esp-rom-sys`'s `esp32c6.rom.rvfp.ld`), costing zero app flash, and in the host +emulator's builtins image to Rust's `compiler_builtins`. See +`docs/adr/2026-07-31-soft-float-via-compiler-builtins.md` for why there is no +LightPlayer layer in between, what the comparison routines' return convention +means for NaN, and why float→int is the one deliberate exception. + +Ops with no soft-float ABI symbol — `sqrt`, `floor`/`ceil`/`trunc`/`nearest`, +`min`/`max`, the unorm lane conversions — call the native-f32 builtin family +(`__lp_lpir_*_f32`, in `lps-builtins` behind its own `float-f32`). That is not a +wrapper; it is the only implementation. + +`float-f32` is off by default for the measured reason the `isa-*` gates exist: +`FloatMode` is matched on a runtime value, so LTO cannot drop the f32 arms, and +the shipping ESP32-C6 image runs Fixed-mode shaders only. The single +configuration in the tree that turns it on for a device is `fw-esp32c6`'s +`test_f32_softfloat` harness. + +**Import resolution is mode-aware, and must stay that way.** `@glsl::sin` in f32 +mode resolves to `LpGlslSinF32`, never to the Q32 twin. Getting this wrong does +not produce a type error: a builtin taking a vector receives an `IrType::Pointer` +whose signature is `i32` in *both* modes, so a Q32 builtin will happily +reinterpret f32 bit patterns as Q16.16 and return plausible wrong colors. The +wasm backend shipped that bug (M1 corpus findings §3); the resolvers in +`lps-builtin-ids` never fall back across modes, so an unmapped name surfaces as a +named "unknown builtin symbol" relocation failure instead. + ### Fuel Metering Emitted code is fuel-metered so an infinite loop in shader code aborts From d62fa2d99e3396ee2780148d5306d0950f8ce7dc Mon Sep 17 00:00:00 2001 From: Yona Appletree Date: Fri, 31 Jul 2026 18:43:57 -0700 Subject: [PATCH 04/15] =?UTF-8?q?test(filetests):=20rv32=20soft-float=20co?= =?UTF-8?q?rpus=20disposition=20=E2=80=94=20849/849=20on=20both=20targets?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit First run of the whole corpus against the soft-float rv32 targets. It found **no backend defect**: 833/849 and 846/849 files passed on their own merits, and every one of the 16 + 3 failures was an existing gap whose annotation names targets one by one and had never heard of these two names. The annotations added say what is actually true: - 11 `global-future/*` files — the lps-glsl frontend cannot parse them, exactly as their rv32lpn.q32/xtlpn.q32 rows already record. - the UB domain probes (`edge-{exp,trig}-domain`, `edge-nan-inf-propagation`) whose `~= 0.0` expectation was written for the clamping Q32 path. - `edge-precision:19` — round(2.5). float.md §4 makes the tie direction target-defined, and lps-glsl routes `round` to the ties-away builtin where naga lowers it to ties-to-even. A real, legal frontend divergence. - `overload-local-duplicate` — the same regalloc limit its q32 row records; one `@broken(float_mode=f32, backend=rv32n)` covers both siblings, since `backend=rv32n` names Backend::Rv32fa. Q32 is provably untouched: every added annotation matches only the two new f32 target names, no DEFAULT_TARGET among them, and no baseline moved. `just test-filetests` — 31562/31562, 849/849 files. Plan: 2026-07-30-1745-f32-native-math (M9) --- lp-shader/lps-filetests/README.md | 46 ++++++++++++++++++- .../builtins/common-intbitstofloat.glsl | 8 ++++ .../filetests/builtins/edge-exp-domain.glsl | 8 ++++ .../builtins/edge-nan-inf-propagation.glsl | 2 + .../filetests/builtins/edge-precision.glsl | 1 + .../filetests/builtins/edge-trig-domain.glsl | 15 ++++++ .../function/overload-local-duplicate.glsl | 4 ++ .../filetests/global-future/access-read.glsl | 12 +++++ .../filetests/global-future/access-write.glsl | 12 +++++ .../global-future/buffer-declare.glsl | 5 ++ .../filetests/global-future/in-declare.glsl | 8 ++++ .../filetests/global-future/in-readonly.glsl | 10 ++++ .../global-future/in-write-error.glsl | 7 +++ .../global-future/mixed-qualifiers-error.glsl | 6 +++ .../filetests/global-future/out-declare.glsl | 8 ++++ .../global-future/out-read-error.glsl | 4 ++ .../global-future/out-writeonly.glsl | 9 ++++ .../global-future/shared-declare.glsl | 9 ++++ 18 files changed, 172 insertions(+), 2 deletions(-) diff --git a/lp-shader/lps-filetests/README.md b/lp-shader/lps-filetests/README.md index c2edd2ac7..21464ca6d 100644 --- a/lp-shader/lps-filetests/README.md +++ b/lp-shader/lps-filetests/README.md @@ -17,6 +17,8 @@ Filetest infrastructure for validating GLSL compilation and execution across all | `xtn.q32` | Q32 fixed-point | `lpvm-native` → **Xtensa** emulator + linked builtins (ESP32-S3 board profile) | no — explicit `--target xtn.q32`; needs the Xtensa builtins image | | `xtlpn.q32` | Q32 fixed-point | `lps-glsl` frontend → `lpvm-native` → Xtensa emulator | no — as above | | `wasm.f32` | IEEE f32 | `lpvm-wasm`'s f32 emit path → wasmtime | no — explicit `--target wasm.f32`; see below | +| `rv32n.f32` | IEEE f32 (soft) | `lpvm-native` f32 lowering → RV32 emulator, float ops as soft-float calls | no — explicit `--target rv32n.f32`; see below | +| `rv32lpn.f32` | IEEE f32 (soft) | `lps-glsl` frontend → the same soft-float path | no — as above | **Q32 is the primary tier**: the four Q32 targets assert exact on-device semantics and their expectations are the ground truth. `interp.f32` asserts @@ -65,6 +67,46 @@ this target first ran; file-level dispositions for compile-only files landed with the axis selectors, so it now carries `@unimplemented(wasm.f32)` like any other builtin-blocked file. +### The rv32 soft-float pair + +`rv32n.f32` / `rv32lpn.f32` are the Q32 rv32 targets' f32 siblings: same corpus, +same `lpvm-native` backend, same emulator — compiled in `FloatMode::F32`, where +every float op becomes a call to the platform soft-float library (`__addsf3`, +`__ltsf2`, …) that the guest builtins image already links. See +`docs/adr/2026-07-31-soft-float-via-compiler-builtins.md`. + +```bash +scripts/filetests.sh --target rv32lpn.f32 # the device pipeline in Float mode +scripts/filetests.sh --target rv32n.f32 # naga frontend, same backend +``` + +Note the `rv32n` and `rv32lpn` shorthands now expand to **both** float modes, so +a run that means the shipping fixed-point target must say `rv32n.q32`. + +They are deliberately **not** in `DEFAULT_TARGETS` (f32 roadmap Q6): every float +op is a function call inside an emulator, so they are the slowest targets in the +set, and Float mode is not the shipping numeric mode for any rv32 board. Their +cost belongs to a deliberate run. + +Disposition when first exercised (2026-07-31, roadmap M9): **849/849 files** on +both, with 6367/6367 and 6342/6342 test-level passes. Nothing needed a new +backend fix — the annotations added were all existing gaps that name targets one +by one: + +- **11 `global-future/*` files** — the `lps-glsl` frontend cannot parse them at + all, exactly as recorded for `rv32lpn.q32` / `xtlpn.q32`. +- **`builtins/edge-{exp,trig}-domain`, `edge-nan-inf-propagation`** — + undefined-behaviour domain probes whose `~= 0.0` expectation was written for + the clamping Q32 path, plus two files naga rejects outright. +- **`builtins/edge-precision:19`** (`rv32lpn.f32` only) — `round(2.5)`. The tie + direction is *target-defined* (`docs/design/float.md` §4), and the `lps-glsl` + frontend routes `round` to the ties-away-from-zero builtin where naga lowers it + to ties-to-even. A real frontend divergence, legal under the spec. +- **`function/overload-local-duplicate`** — genuine overloads still hit a + `lpvm-native` regalloc limit, the same one its `float_mode=q32` row records; + the new `@broken(float_mode=f32, backend=rv32n)` covers both f32 siblings in + one predicate, because `backend=rv32n` names `Backend::Rv32fa`. + ### The Xtensa pair `xtn.q32` / `xtlpn.q32` are the Xtensa mirrors of `rv32n.q32` / `rv32lpn.q32` — @@ -371,8 +413,8 @@ could not read" path — that is how a malformed selector stays visible. **`DEFAULT_TARGETS`** (when the runner does not pass `--target`): `rv32n.q32`, `rv32lpn.q32`, `rv32c.q32`, `wasm.q32`, `interp.f32`. CI runs this list via -`just test-filetests`; `wgpu.f32`, `xtn.q32`, `xtlpn.q32` and `wasm.f32` are -explicit-only (see Targets above). +`just test-filetests`; `wgpu.f32`, `xtn.q32`, `xtlpn.q32`, `wasm.f32`, +`rv32n.f32` and `rv32lpn.f32` are explicit-only (see Targets above). ### Error tests (`// test error`) cover **both** frontends diff --git a/lp-shader/lps-filetests/filetests/builtins/common-intbitstofloat.glsl b/lp-shader/lps-filetests/filetests/builtins/common-intbitstofloat.glsl index 530abee0e..e90dea19c 100644 --- a/lp-shader/lps-filetests/filetests/builtins/common-intbitstofloat.glsl +++ b/lp-shader/lps-filetests/filetests/builtins/common-intbitstofloat.glsl @@ -12,6 +12,7 @@ float test_intbitstofloat_zero() { // @unimplemented(rv32lpn.q32) // @unimplemented(xtlpn.q32) +// @unsupported(rv32lpn.f32) // run: test_intbitstofloat_zero() ~= 0.0 float test_intbitstofloat_one() { @@ -25,6 +26,7 @@ float test_intbitstofloat_one() { // @unimplemented(xtn.q32) // @unimplemented(rv32lpn.q32) // @unimplemented(xtlpn.q32) +// @unsupported(rv32lpn.f32) // run: test_intbitstofloat_one() ~= 1.0 float test_intbitstofloat_neg_one() { @@ -38,6 +40,7 @@ float test_intbitstofloat_neg_one() { // @unimplemented(xtn.q32) // @unimplemented(rv32lpn.q32) // @unimplemented(xtlpn.q32) +// @unsupported(rv32lpn.f32) // run: test_intbitstofloat_neg_one() ~= -1.0 float test_intbitstofloat_inf() { @@ -51,6 +54,7 @@ float test_intbitstofloat_inf() { // @unimplemented(xtn.q32) // @unimplemented(rv32lpn.q32) // @unimplemented(xtlpn.q32) +// @unsupported(rv32lpn.f32) // run: test_intbitstofloat_inf() ~= 1.0 / 0.0 float test_intbitstofloat_neg_inf() { @@ -64,6 +68,7 @@ float test_intbitstofloat_neg_inf() { // @unimplemented(xtn.q32) // @unimplemented(rv32lpn.q32) // @unimplemented(xtlpn.q32) +// @unsupported(rv32lpn.f32) // run: test_intbitstofloat_neg_inf() ~= -1.0 / 0.0 vec2 test_intbitstofloat_vec2() { @@ -77,6 +82,7 @@ vec2 test_intbitstofloat_vec2() { // @unimplemented(xtn.q32) // @unimplemented(rv32lpn.q32) // @unimplemented(xtlpn.q32) +// @unsupported(rv32lpn.f32) // run: test_intbitstofloat_vec2() ~= vec2(1.0, -1.0) vec3 test_intbitstofloat_vec3() { @@ -90,6 +96,7 @@ vec3 test_intbitstofloat_vec3() { // @unimplemented(xtn.q32) // @unimplemented(rv32lpn.q32) // @unimplemented(xtlpn.q32) +// @unsupported(rv32lpn.f32) // run: test_intbitstofloat_vec3() ~= vec3(0.0, 1.0, 2.0) vec4 test_intbitstofloat_vec4() { @@ -103,6 +110,7 @@ vec4 test_intbitstofloat_vec4() { // @unimplemented(xtn.q32) // @unimplemented(rv32lpn.q32) // @unimplemented(xtlpn.q32) +// @unsupported(rv32lpn.f32) // run: test_intbitstofloat_vec4() ~= vec4(1.0, 0.0, -1.0, 1.0 / 0.0) diff --git a/lp-shader/lps-filetests/filetests/builtins/edge-exp-domain.glsl b/lp-shader/lps-filetests/filetests/builtins/edge-exp-domain.glsl index 51789fd86..cb77c5f48 100644 --- a/lp-shader/lps-filetests/filetests/builtins/edge-exp-domain.glsl +++ b/lp-shader/lps-filetests/filetests/builtins/edge-exp-domain.glsl @@ -15,6 +15,7 @@ float test_pow_negative_base() { } // @unsupported(frontend!=lp) +// @unsupported(rv32lpn.f32) // run: test_pow_negative_base() ~= 0.0 float test_pow_zero_negative_exponent() { @@ -23,6 +24,7 @@ float test_pow_zero_negative_exponent() { } // @unsupported(frontend!=lp) +// @unsupported(rv32lpn.f32) // run: test_pow_zero_negative_exponent() ~= 0.0 float test_pow_zero_zero() { @@ -31,6 +33,7 @@ float test_pow_zero_zero() { } // @unsupported(frontend!=lp) +// @unsupported(rv32lpn.f32) // run: test_pow_zero_zero() ~= 0.0 float test_log_zero() { @@ -39,6 +42,7 @@ float test_log_zero() { } // @unsupported(frontend!=lp) +// @unsupported(rv32lpn.f32) // run: test_log_zero() ~= 0.0 float test_log_negative() { @@ -47,6 +51,7 @@ float test_log_negative() { } // @unsupported(frontend!=lp) +// @unsupported(rv32lpn.f32) // run: test_log_negative() ~= 0.0 float test_log2_zero() { @@ -55,6 +60,7 @@ float test_log2_zero() { } // @unsupported(frontend!=lp) +// @unsupported(rv32lpn.f32) // run: test_log2_zero() ~= 0.0 float test_log2_negative() { @@ -63,6 +69,7 @@ float test_log2_negative() { } // @unsupported(frontend!=lp) +// @unsupported(rv32lpn.f32) // run: test_log2_negative() ~= 0.0 float test_sqrt_negative() { @@ -71,6 +78,7 @@ float test_sqrt_negative() { } // @unsupported(frontend!=lp) +// @unsupported(rv32lpn.f32) // run: test_sqrt_negative() ~= 0.0 float test_inversesqrt_zero() { diff --git a/lp-shader/lps-filetests/filetests/builtins/edge-nan-inf-propagation.glsl b/lp-shader/lps-filetests/filetests/builtins/edge-nan-inf-propagation.glsl index 7bc61a211..2fc5a16fe 100644 --- a/lp-shader/lps-filetests/filetests/builtins/edge-nan-inf-propagation.glsl +++ b/lp-shader/lps-filetests/filetests/builtins/edge-nan-inf-propagation.glsl @@ -24,6 +24,7 @@ bool test_isnan_inf() { // wasm.f32: shader does not compile on any target (frontend gap) — same cause // as the @unsupported entries above, not an f32-specific failure. // @unsupported(wasm.f32) +// @unsupported(rv32n.f32) // run: test_isnan_inf() == false bool test_isnan_neg_inf() { @@ -39,6 +40,7 @@ bool test_isnan_neg_inf() { // @unsupported(interp.f32) // @unsupported(wgpu.f32) // @unsupported(wasm.f32) +// @unsupported(rv32n.f32) // run: test_isnan_neg_inf() == false bool test_isinf_inf() { diff --git a/lp-shader/lps-filetests/filetests/builtins/edge-precision.glsl b/lp-shader/lps-filetests/filetests/builtins/edge-precision.glsl index bf8fbf994..6844cc1a2 100644 --- a/lp-shader/lps-filetests/filetests/builtins/edge-precision.glsl +++ b/lp-shader/lps-filetests/filetests/builtins/edge-precision.glsl @@ -16,6 +16,7 @@ float test_round_half_up() { // @unsupported(rv32lpn.q32) // @unsupported(wasm.q32) // run[q32]: test_round_half_up() ~= 3.0 +// @unsupported(rv32lpn.f32) // run[f32]: test_round_half_up() ~= 2.0 float test_round_half_down() { diff --git a/lp-shader/lps-filetests/filetests/builtins/edge-trig-domain.glsl b/lp-shader/lps-filetests/filetests/builtins/edge-trig-domain.glsl index 2d9034a44..07cf92194 100644 --- a/lp-shader/lps-filetests/filetests/builtins/edge-trig-domain.glsl +++ b/lp-shader/lps-filetests/filetests/builtins/edge-trig-domain.glsl @@ -24,6 +24,8 @@ float test_asin_domain_over() { // wasm.f32: shader does not compile on any target (frontend gap) — same cause // as the @unsupported entries above, not an f32-specific failure. // @unsupported(wasm.f32) +// @unsupported(rv32lpn.f32) +// @unsupported(rv32n.f32) // run: test_asin_domain_over() ~= 0.0 float test_asin_domain_under() { @@ -39,6 +41,8 @@ float test_asin_domain_under() { // @unsupported(interp.f32) // @unsupported(wgpu.f32) // @unsupported(wasm.f32) +// @unsupported(rv32lpn.f32) +// @unsupported(rv32n.f32) // run: test_asin_domain_under() ~= 0.0 float test_acos_domain_over() { @@ -70,6 +74,7 @@ float test_atan2_zero_zero() { // @unsupported(interp.f32) // @unsupported(wgpu.f32) // @unsupported(wasm.f32) +// @unsupported(rv32n.f32) // run: test_atan2_zero_zero() ~= 0.0 float test_acosh_domain_under() { @@ -85,6 +90,8 @@ float test_acosh_domain_under() { // @unsupported(interp.f32) // @unsupported(wgpu.f32) // @unsupported(wasm.f32) +// @unsupported(rv32lpn.f32) +// @unsupported(rv32n.f32) // run: test_acosh_domain_under() ~= 0.0 float test_atanh_domain_over() { @@ -100,6 +107,8 @@ float test_atanh_domain_over() { // @unsupported(interp.f32) // @unsupported(wgpu.f32) // @unsupported(wasm.f32) +// @unsupported(rv32lpn.f32) +// @unsupported(rv32n.f32) // run: test_atanh_domain_over() ~= 0.0 float test_atanh_domain_under() { @@ -115,6 +124,8 @@ float test_atanh_domain_under() { // @unsupported(interp.f32) // @unsupported(wgpu.f32) // @unsupported(wasm.f32) +// @unsupported(rv32lpn.f32) +// @unsupported(rv32n.f32) // run: test_atanh_domain_under() ~= 0.0 float test_atanh_domain_one() { @@ -130,6 +141,8 @@ float test_atanh_domain_one() { // @unsupported(interp.f32) // @unsupported(wgpu.f32) // @unsupported(wasm.f32) +// @unsupported(rv32lpn.f32) +// @unsupported(rv32n.f32) // run: test_atanh_domain_one() ~= 0.0 float test_atanh_domain_neg_one() { @@ -146,6 +159,8 @@ float test_atanh_domain_neg_one() { // @unsupported(interp.f32) // @unsupported(wgpu.f32) // @unsupported(wasm.f32) +// @unsupported(rv32lpn.f32) +// @unsupported(rv32n.f32) // run: test_atanh_domain_neg_one() ~= 0.0 diff --git a/lp-shader/lps-filetests/filetests/function/overload-local-duplicate.glsl b/lp-shader/lps-filetests/filetests/function/overload-local-duplicate.glsl index 0e8ade447..aafea4083 100644 --- a/lp-shader/lps-filetests/filetests/function/overload-local-duplicate.glsl +++ b/lp-shader/lps-filetests/filetests/function/overload-local-duplicate.glsl @@ -35,6 +35,8 @@ float test_overload_scalar_vs_vector() { // genuine overloads work only on the wasm backend (see the header for each backend) // @broken(float_mode=q32, backend!=wasm) +// the same backend limits as the q32 row above, in the other float mode +// @broken(float_mode=f32, backend=rv32n) // run: test_overload_scalar_vs_vector() ~= 22.0 float combine(float a) { @@ -53,4 +55,6 @@ float test_overload_arity_and_nested_call() { // genuine overloads work only on the wasm backend (see the header for each backend) // @broken(float_mode=q32, backend!=wasm) +// the same backend limits as the q32 row above, in the other float mode +// @broken(float_mode=f32, backend=rv32n) // run: test_overload_arity_and_nested_call() ~= 8.0 diff --git a/lp-shader/lps-filetests/filetests/global-future/access-read.glsl b/lp-shader/lps-filetests/filetests/global-future/access-read.glsl index 92f2fc4ad..2b2005958 100644 --- a/lp-shader/lps-filetests/filetests/global-future/access-read.glsl +++ b/lp-shader/lps-filetests/filetests/global-future/access-read.glsl @@ -30,6 +30,7 @@ float test_access_read_float() { // wasm.f32: shader does not compile on any target (frontend gap) — same cause // as the @unsupported entries above, not an f32-specific failure. // @unsupported(wasm.f32) +// @unsupported(rv32lpn.f32) // run: test_access_read_float() ~= 42.0 int test_access_read_int() { @@ -42,6 +43,7 @@ int test_access_read_int() { // @unsupported(xtlpn.q32) // @unsupported(wgpu.f32) // @unsupported(wasm.f32) +// @unsupported(rv32lpn.f32) // run: test_access_read_int() == -123 uint test_access_read_uint() { @@ -54,6 +56,7 @@ uint test_access_read_uint() { // @unsupported(xtlpn.q32) // @unsupported(wgpu.f32) // @unsupported(wasm.f32) +// @unsupported(rv32lpn.f32) // run: test_access_read_uint() == 987 bool test_access_read_bool() { @@ -66,6 +69,7 @@ bool test_access_read_bool() { // @unsupported(xtlpn.q32) // @unsupported(wgpu.f32) // @unsupported(wasm.f32) +// @unsupported(rv32lpn.f32) // run: test_access_read_bool() == true vec2 test_access_read_vec2() { @@ -78,6 +82,7 @@ vec2 test_access_read_vec2() { // @unsupported(xtlpn.q32) // @unsupported(wgpu.f32) // @unsupported(wasm.f32) +// @unsupported(rv32lpn.f32) // run: test_access_read_vec2() ~= vec2(1.0, 2.0) vec3 test_access_read_vec3() { @@ -90,6 +95,7 @@ vec3 test_access_read_vec3() { // @unsupported(xtlpn.q32) // @unsupported(wgpu.f32) // @unsupported(wasm.f32) +// @unsupported(rv32lpn.f32) // run: test_access_read_vec3() ~= vec3(1.0, 2.0, 3.0) vec4 test_access_read_vec4() { @@ -102,6 +108,7 @@ vec4 test_access_read_vec4() { // @unsupported(xtlpn.q32) // @unsupported(wgpu.f32) // @unsupported(wasm.f32) +// @unsupported(rv32lpn.f32) // run: test_access_read_vec4() ~= vec4(1.0, 2.0, 3.0, 4.0) mat2 test_access_read_mat2() { @@ -114,6 +121,7 @@ mat2 test_access_read_mat2() { // @unsupported(xtlpn.q32) // @unsupported(wgpu.f32) // @unsupported(wasm.f32) +// @unsupported(rv32lpn.f32) // run: test_access_read_mat2() ~= mat2(1.0, 2.0, 3.0, 4.0) float test_access_read_const() { @@ -126,6 +134,7 @@ float test_access_read_const() { // @unsupported(xtlpn.q32) // @unsupported(wgpu.f32) // @unsupported(wasm.f32) +// @unsupported(rv32lpn.f32) // run: test_access_read_const() ~= 6.28 float test_access_read_uniform() { @@ -138,6 +147,7 @@ float test_access_read_uniform() { // @unsupported(xtlpn.q32) // @unsupported(wgpu.f32) // @unsupported(wasm.f32) +// @unsupported(rv32lpn.f32) // run: test_access_read_uniform() ~= 1.0 vec3 test_access_read_in() { @@ -150,6 +160,7 @@ vec3 test_access_read_in() { // @unsupported(xtlpn.q32) // @unsupported(wgpu.f32) // @unsupported(wasm.f32) +// @unsupported(rv32lpn.f32) // run: test_access_read_in() ~= vec3(1.0, 1.0, 1.0) float test_access_read_buffer() { @@ -165,4 +176,5 @@ float test_access_read_buffer() { // @unsupported(xtlpn.q32) // @unsupported(wgpu.f32) // @unsupported(wasm.f32) +// @unsupported(rv32lpn.f32) // run: test_access_read_buffer() ~= 15.0 diff --git a/lp-shader/lps-filetests/filetests/global-future/access-write.glsl b/lp-shader/lps-filetests/filetests/global-future/access-write.glsl index dbdc7b389..d3db46448 100644 --- a/lp-shader/lps-filetests/filetests/global-future/access-write.glsl +++ b/lp-shader/lps-filetests/filetests/global-future/access-write.glsl @@ -29,6 +29,7 @@ void test_access_write_float() { // wasm.f32: shader does not compile on any target (frontend gap) — same cause // as the @unsupported entries above, not an f32-specific failure. // @unsupported(wasm.f32) +// @unsupported(rv32lpn.f32) // run: test_access_write_float() == 0.0 void test_access_write_int() { @@ -41,6 +42,7 @@ void test_access_write_int() { // @unsupported(xtlpn.q32) // @unsupported(wgpu.f32) // @unsupported(wasm.f32) +// @unsupported(rv32lpn.f32) // run: test_access_write_int() == 0.0 void test_access_write_uint() { @@ -53,6 +55,7 @@ void test_access_write_uint() { // @unsupported(xtlpn.q32) // @unsupported(wgpu.f32) // @unsupported(wasm.f32) +// @unsupported(rv32lpn.f32) // run: test_access_write_uint() == 0.0 void test_access_write_bool() { @@ -65,6 +68,7 @@ void test_access_write_bool() { // @unsupported(xtlpn.q32) // @unsupported(wgpu.f32) // @unsupported(wasm.f32) +// @unsupported(rv32lpn.f32) // run: test_access_write_bool() == 0.0 void test_access_write_vec2() { @@ -77,6 +81,7 @@ void test_access_write_vec2() { // @unsupported(xtlpn.q32) // @unsupported(wgpu.f32) // @unsupported(wasm.f32) +// @unsupported(rv32lpn.f32) // run: test_access_write_vec2() == 0.0 void test_access_write_vec3() { @@ -89,6 +94,7 @@ void test_access_write_vec3() { // @unsupported(xtlpn.q32) // @unsupported(wgpu.f32) // @unsupported(wasm.f32) +// @unsupported(rv32lpn.f32) // run: test_access_write_vec3() == 0.0 void test_access_write_vec4() { @@ -101,6 +107,7 @@ void test_access_write_vec4() { // @unsupported(xtlpn.q32) // @unsupported(wgpu.f32) // @unsupported(wasm.f32) +// @unsupported(rv32lpn.f32) // run: test_access_write_vec4() == 0.0 void test_access_write_mat2() { @@ -113,6 +120,7 @@ void test_access_write_mat2() { // @unsupported(xtlpn.q32) // @unsupported(wgpu.f32) // @unsupported(wasm.f32) +// @unsupported(rv32lpn.f32) // run: test_access_write_mat2() == 0.0 void test_access_write_out() { @@ -125,6 +133,7 @@ void test_access_write_out() { // @unsupported(xtlpn.q32) // @unsupported(wgpu.f32) // @unsupported(wasm.f32) +// @unsupported(rv32lpn.f32) // run: test_access_write_out() == 0.0 void test_access_write_buffer() { @@ -140,6 +149,7 @@ void test_access_write_buffer() { // @unsupported(xtlpn.q32) // @unsupported(wgpu.f32) // @unsupported(wasm.f32) +// @unsupported(rv32lpn.f32) // run: test_access_write_buffer() == 0.0 void test_access_write_shared() { @@ -152,6 +162,7 @@ void test_access_write_shared() { // @unsupported(xtlpn.q32) // @unsupported(wgpu.f32) // @unsupported(wasm.f32) +// @unsupported(rv32lpn.f32) // run: test_access_write_shared() == 0.0 float test_access_write_read() { @@ -169,4 +180,5 @@ float test_access_write_read() { // @unsupported(xtlpn.q32) // @unsupported(wgpu.f32) // @unsupported(wasm.f32) +// @unsupported(rv32lpn.f32) // run: test_access_write_read() ~= 157.0 diff --git a/lp-shader/lps-filetests/filetests/global-future/buffer-declare.glsl b/lp-shader/lps-filetests/filetests/global-future/buffer-declare.glsl index d6ee31603..84ed3ce5f 100644 --- a/lp-shader/lps-filetests/filetests/global-future/buffer-declare.glsl +++ b/lp-shader/lps-filetests/filetests/global-future/buffer-declare.glsl @@ -35,6 +35,7 @@ float test_declare_buffer_array() { // wasm.f32: shader does not compile on any target (frontend gap) — same cause // as the @unsupported entries above, not an f32-specific failure. // @unsupported(wasm.f32) +// @unsupported(rv32lpn.f32) // run: test_declare_buffer_array() ~= 66.0 vec4 test_declare_buffer_structured() { @@ -52,6 +53,7 @@ vec4 test_declare_buffer_structured() { // @unsupported(xtlpn.q32) // @unsupported(wgpu.f32) // @unsupported(wasm.f32) +// @unsupported(rv32lpn.f32) // run: test_declare_buffer_structured() ~= vec4(2.0, 2.0, 2.0, 4.0) mat4 test_declare_buffer_matrix() { @@ -67,6 +69,7 @@ mat4 test_declare_buffer_matrix() { // @unsupported(xtlpn.q32) // @unsupported(wgpu.f32) // @unsupported(wasm.f32) +// @unsupported(rv32lpn.f32) // run: test_declare_buffer_matrix() ~= mat4(2.0, 0.0, 0.0, 0.0, 0.0, 2.0, 0.0, 0.0, 0.0, 0.0, 2.0, 0.0, 0.0, 0.0, 0.0, 2.0) int test_declare_buffer_int_array() { @@ -83,6 +86,7 @@ int test_declare_buffer_int_array() { // @unsupported(xtlpn.q32) // @unsupported(wgpu.f32) // @unsupported(wasm.f32) +// @unsupported(rv32lpn.f32) // run: test_declare_buffer_int_array() == 60 float test_declare_buffer_single() { @@ -100,4 +104,5 @@ float test_declare_buffer_single() { // @unsupported(xtlpn.q32) // @unsupported(wgpu.f32) // @unsupported(wasm.f32) +// @unsupported(rv32lpn.f32) // run: test_declare_buffer_single() ~= 5.14 diff --git a/lp-shader/lps-filetests/filetests/global-future/in-declare.glsl b/lp-shader/lps-filetests/filetests/global-future/in-declare.glsl index 2503db3d6..3ae0e552c 100644 --- a/lp-shader/lps-filetests/filetests/global-future/in-declare.glsl +++ b/lp-shader/lps-filetests/filetests/global-future/in-declare.glsl @@ -21,6 +21,7 @@ float test_declare_in_float() { // @unsupported(rv32lpn.q32) // @unsupported(xtlpn.q32) // @unsupported(wgpu.f32) +// @unsupported(rv32lpn.f32) // run: test_declare_in_float() ~= 1.0 int test_declare_in_int() { @@ -31,6 +32,7 @@ int test_declare_in_int() { // @unsupported(rv32lpn.q32) // @unsupported(xtlpn.q32) // @unsupported(wgpu.f32) +// @unsupported(rv32lpn.f32) // run: test_declare_in_int() == 0 uint test_declare_in_uint() { @@ -41,6 +43,7 @@ uint test_declare_in_uint() { // @unsupported(rv32lpn.q32) // @unsupported(xtlpn.q32) // @unsupported(wgpu.f32) +// @unsupported(rv32lpn.f32) // run: test_declare_in_uint() == 1u bool test_declare_in_bool() { @@ -51,6 +54,7 @@ bool test_declare_in_bool() { // @unsupported(rv32lpn.q32) // @unsupported(xtlpn.q32) // @unsupported(wgpu.f32) +// @unsupported(rv32lpn.f32) // run: test_declare_in_bool() == false vec2 test_declare_in_vec2() { @@ -61,6 +65,7 @@ vec2 test_declare_in_vec2() { // @unsupported(rv32lpn.q32) // @unsupported(xtlpn.q32) // @unsupported(wgpu.f32) +// @unsupported(rv32lpn.f32) // run: test_declare_in_vec2() ~= vec2(0.5, 0.5) vec3 test_declare_in_vec3() { @@ -71,6 +76,7 @@ vec3 test_declare_in_vec3() { // @unsupported(rv32lpn.q32) // @unsupported(xtlpn.q32) // @unsupported(wgpu.f32) +// @unsupported(rv32lpn.f32) // run: test_declare_in_vec3() ~= vec3(0.0, 0.0, 0.0) vec4 test_declare_in_vec4() { @@ -81,6 +87,7 @@ vec4 test_declare_in_vec4() { // @unsupported(rv32lpn.q32) // @unsupported(xtlpn.q32) // @unsupported(wgpu.f32) +// @unsupported(rv32lpn.f32) // run: test_declare_in_vec4() ~= vec4(0.0, 0.0, 0.0, 0.0) float test_declare_in_calculate() { @@ -94,4 +101,5 @@ float test_declare_in_calculate() { // @unsupported(rv32lpn.q32) // @unsupported(xtlpn.q32) // @unsupported(wgpu.f32) +// @unsupported(rv32lpn.f32) // run: test_declare_in_calculate() ~= 0.2 diff --git a/lp-shader/lps-filetests/filetests/global-future/in-readonly.glsl b/lp-shader/lps-filetests/filetests/global-future/in-readonly.glsl index a647e8171..ea65f397e 100644 --- a/lp-shader/lps-filetests/filetests/global-future/in-readonly.glsl +++ b/lp-shader/lps-filetests/filetests/global-future/in-readonly.glsl @@ -20,6 +20,7 @@ float test_in_readonly_float() { // @unsupported(rv32lpn.q32) // @unsupported(xtlpn.q32) // @unsupported(wgpu.f32) +// @unsupported(rv32lpn.f32) // run: test_in_readonly_float() ~= 1.0 int test_in_readonly_int() { @@ -30,6 +31,7 @@ int test_in_readonly_int() { // @unsupported(rv32lpn.q32) // @unsupported(xtlpn.q32) // @unsupported(wgpu.f32) +// @unsupported(rv32lpn.f32) // run: test_in_readonly_int() == 100 uint test_in_readonly_uint() { @@ -40,6 +42,7 @@ uint test_in_readonly_uint() { // @unsupported(rv32lpn.q32) // @unsupported(xtlpn.q32) // @unsupported(wgpu.f32) +// @unsupported(rv32lpn.f32) // run: test_in_readonly_uint() == 50u bool test_in_readonly_bool() { @@ -50,6 +53,7 @@ bool test_in_readonly_bool() { // @unsupported(rv32lpn.q32) // @unsupported(xtlpn.q32) // @unsupported(wgpu.f32) +// @unsupported(rv32lpn.f32) // run: test_in_readonly_bool() == false vec2 test_in_readonly_vec2() { @@ -60,6 +64,7 @@ vec2 test_in_readonly_vec2() { // @unsupported(rv32lpn.q32) // @unsupported(xtlpn.q32) // @unsupported(wgpu.f32) +// @unsupported(rv32lpn.f32) // run: test_in_readonly_vec2() ~= vec2(0.0, 0.0) vec3 test_in_readonly_vec3() { @@ -70,6 +75,7 @@ vec3 test_in_readonly_vec3() { // @unsupported(rv32lpn.q32) // @unsupported(xtlpn.q32) // @unsupported(wgpu.f32) +// @unsupported(rv32lpn.f32) // run: test_in_readonly_vec3() ~= vec3(0.0, 1.0, 0.0) vec4 test_in_readonly_vec4() { @@ -80,6 +86,7 @@ vec4 test_in_readonly_vec4() { // @unsupported(rv32lpn.q32) // @unsupported(xtlpn.q32) // @unsupported(wgpu.f32) +// @unsupported(rv32lpn.f32) // run: test_in_readonly_vec4() ~= vec4(0.0, 0.0, 0.0, 0.0) float test_in_readonly_calculations() { @@ -95,6 +102,7 @@ float test_in_readonly_calculations() { // @unsupported(rv32lpn.q32) // @unsupported(xtlpn.q32) // @unsupported(wgpu.f32) +// @unsupported(rv32lpn.f32) // run: test_in_readonly_calculations() ~= 0.3 vec4 test_in_readonly_vertex_processing() { @@ -113,6 +121,7 @@ vec4 test_in_readonly_vertex_processing() { // @unsupported(rv32lpn.q32) // @unsupported(xtlpn.q32) // @unsupported(wgpu.f32) +// @unsupported(rv32lpn.f32) // run: test_in_readonly_vertex_processing() ~= vec4(0.0, 0.0, 0.0, 0.0) float test_in_readonly_texture_mapping() { @@ -132,4 +141,5 @@ float test_in_readonly_texture_mapping() { // @unsupported(rv32lpn.q32) // @unsupported(xtlpn.q32) // @unsupported(wgpu.f32) +// @unsupported(rv32lpn.f32) // run: test_in_readonly_texture_mapping() ~= -2.0 diff --git a/lp-shader/lps-filetests/filetests/global-future/in-write-error.glsl b/lp-shader/lps-filetests/filetests/global-future/in-write-error.glsl index a587c3f71..7b5dd9be7 100644 --- a/lp-shader/lps-filetests/filetests/global-future/in-write-error.glsl +++ b/lp-shader/lps-filetests/filetests/global-future/in-write-error.glsl @@ -26,6 +26,7 @@ float test_edge_in_write_error_read() { // @unsupported(rv32lpn.q32) // @unsupported(xtlpn.q32) // @unsupported(wgpu.f32) +// @unsupported(rv32lpn.f32) // run: test_edge_in_write_error_read() ~= 1.0 int test_edge_in_write_error_int() { @@ -36,6 +37,7 @@ int test_edge_in_write_error_int() { // @unsupported(rv32lpn.q32) // @unsupported(xtlpn.q32) // @unsupported(wgpu.f32) +// @unsupported(rv32lpn.f32) // run: test_edge_in_write_error_int() == 10 vec2 test_edge_in_write_error_vec2() { @@ -46,6 +48,7 @@ vec2 test_edge_in_write_error_vec2() { // @unsupported(rv32lpn.q32) // @unsupported(xtlpn.q32) // @unsupported(wgpu.f32) +// @unsupported(rv32lpn.f32) // run: test_edge_in_write_error_vec2() ~= vec2(0.0, 0.0) vec3 test_edge_in_write_error_vec3() { @@ -56,6 +59,7 @@ vec3 test_edge_in_write_error_vec3() { // @unsupported(rv32lpn.q32) // @unsupported(xtlpn.q32) // @unsupported(wgpu.f32) +// @unsupported(rv32lpn.f32) // run: test_edge_in_write_error_vec3() ~= vec3(0.0, 1.0, 0.0) vec4 test_edge_in_write_error_vec4() { @@ -66,6 +70,7 @@ vec4 test_edge_in_write_error_vec4() { // @unsupported(rv32lpn.q32) // @unsupported(xtlpn.q32) // @unsupported(wgpu.f32) +// @unsupported(rv32lpn.f32) // run: test_edge_in_write_error_vec4() ~= vec4(0.0, 0.0, 0.0, 0.0) float test_edge_in_write_error_calculations() { @@ -84,6 +89,7 @@ float test_edge_in_write_error_calculations() { // @unsupported(xtlpn.q32) // @unsupported(wgpu.f32) // @unimplemented(wasm.f32) +// @unsupported(rv32lpn.f32) // run: test_edge_in_write_error_calculations() ~= 7.2 vec4 test_edge_in_write_error_vertex_processing() { @@ -103,4 +109,5 @@ vec4 test_edge_in_write_error_vertex_processing() { // @unsupported(rv32lpn.q32) // @unsupported(xtlpn.q32) // @unsupported(wgpu.f32) +// @unsupported(rv32lpn.f32) // run: test_edge_in_write_error_vertex_processing() ~= vec4(0.0, 0.0, 0.0, 0.0) diff --git a/lp-shader/lps-filetests/filetests/global-future/mixed-qualifiers-error.glsl b/lp-shader/lps-filetests/filetests/global-future/mixed-qualifiers-error.glsl index 765e556b7..0372e6f90 100644 --- a/lp-shader/lps-filetests/filetests/global-future/mixed-qualifiers-error.glsl +++ b/lp-shader/lps-filetests/filetests/global-future/mixed-qualifiers-error.glsl @@ -31,6 +31,7 @@ float test_edge_multiple_qualifiers_error_const() { // wasm.f32: shader does not compile on any target (frontend gap) — same cause // as the @unsupported entries above, not an f32-specific failure. // @unsupported(wasm.f32) +// @unsupported(rv32lpn.f32) // run: test_edge_multiple_qualifiers_error_const() ~= 6.28 float test_edge_multiple_qualifiers_error_uniform() { @@ -43,6 +44,7 @@ float test_edge_multiple_qualifiers_error_uniform() { // @unsupported(xtlpn.q32) // @unsupported(wgpu.f32) // @unsupported(wasm.f32) +// @unsupported(rv32lpn.f32) // run: test_edge_multiple_qualifiers_error_uniform() ~= 1.0 vec2 test_edge_multiple_qualifiers_error_in() { @@ -55,6 +57,7 @@ vec2 test_edge_multiple_qualifiers_error_in() { // @unsupported(xtlpn.q32) // @unsupported(wgpu.f32) // @unsupported(wasm.f32) +// @unsupported(rv32lpn.f32) // run: test_edge_multiple_qualifiers_error_in() ~= vec2(1.0, 1.0) void test_edge_multiple_qualifiers_error_out() { @@ -67,6 +70,7 @@ void test_edge_multiple_qualifiers_error_out() { // @unsupported(xtlpn.q32) // @unsupported(wgpu.f32) // @unsupported(wasm.f32) +// @unsupported(rv32lpn.f32) // run: test_edge_multiple_qualifiers_error_out() == 0.0 float test_edge_multiple_qualifiers_error_buffer() { @@ -80,6 +84,7 @@ float test_edge_multiple_qualifiers_error_buffer() { // @unsupported(xtlpn.q32) // @unsupported(wgpu.f32) // @unsupported(wasm.f32) +// @unsupported(rv32lpn.f32) // run: test_edge_multiple_qualifiers_error_buffer() ~= 42.0 float test_edge_multiple_qualifiers_error_combined() { @@ -100,4 +105,5 @@ float test_edge_multiple_qualifiers_error_combined() { // @unsupported(xtlpn.q32) // @unsupported(wgpu.f32) // @unsupported(wasm.f32) +// @unsupported(rv32lpn.f32) // run: test_edge_multiple_qualifiers_error_combined() ~= 8.28 diff --git a/lp-shader/lps-filetests/filetests/global-future/out-declare.glsl b/lp-shader/lps-filetests/filetests/global-future/out-declare.glsl index ec04902e5..b2ea8ec4a 100644 --- a/lp-shader/lps-filetests/filetests/global-future/out-declare.glsl +++ b/lp-shader/lps-filetests/filetests/global-future/out-declare.glsl @@ -21,6 +21,7 @@ void test_declare_out_float() { // @unsupported(rv32lpn.q32) // @unsupported(xtlpn.q32) // @unsupported(wgpu.f32) +// @unsupported(rv32lpn.f32) // run: test_declare_out_float() == 0.0 void test_declare_out_int() { @@ -31,6 +32,7 @@ void test_declare_out_int() { // @unsupported(rv32lpn.q32) // @unsupported(xtlpn.q32) // @unsupported(wgpu.f32) +// @unsupported(rv32lpn.f32) // run: test_declare_out_int() == 0.0 void test_declare_out_uint() { @@ -41,6 +43,7 @@ void test_declare_out_uint() { // @unsupported(rv32lpn.q32) // @unsupported(xtlpn.q32) // @unsupported(wgpu.f32) +// @unsupported(rv32lpn.f32) // run: test_declare_out_uint() == 0.0 void test_declare_out_bool() { @@ -51,6 +54,7 @@ void test_declare_out_bool() { // @unsupported(rv32lpn.q32) // @unsupported(xtlpn.q32) // @unsupported(wgpu.f32) +// @unsupported(rv32lpn.f32) // run: test_declare_out_bool() == 0.0 void test_declare_out_vec2() { @@ -61,6 +65,7 @@ void test_declare_out_vec2() { // @unsupported(rv32lpn.q32) // @unsupported(xtlpn.q32) // @unsupported(wgpu.f32) +// @unsupported(rv32lpn.f32) // run: test_declare_out_vec2() == 0.0 void test_declare_out_vec3() { @@ -71,6 +76,7 @@ void test_declare_out_vec3() { // @unsupported(rv32lpn.q32) // @unsupported(xtlpn.q32) // @unsupported(wgpu.f32) +// @unsupported(rv32lpn.f32) // run: test_declare_out_vec3() == 0.0 void test_declare_out_vec4() { @@ -81,6 +87,7 @@ void test_declare_out_vec4() { // @unsupported(rv32lpn.q32) // @unsupported(xtlpn.q32) // @unsupported(wgpu.f32) +// @unsupported(rv32lpn.f32) // run: test_declare_out_vec4() == 0.0 void test_declare_out_calculate() { @@ -95,4 +102,5 @@ void test_declare_out_calculate() { // @unsupported(rv32lpn.q32) // @unsupported(xtlpn.q32) // @unsupported(wgpu.f32) +// @unsupported(rv32lpn.f32) // run: test_declare_out_calculate() == 0.0 diff --git a/lp-shader/lps-filetests/filetests/global-future/out-read-error.glsl b/lp-shader/lps-filetests/filetests/global-future/out-read-error.glsl index 2a43e72fe..941396c9e 100644 --- a/lp-shader/lps-filetests/filetests/global-future/out-read-error.glsl +++ b/lp-shader/lps-filetests/filetests/global-future/out-read-error.glsl @@ -25,6 +25,7 @@ void test_edge_out_read_error_write() { // @unsupported(rv32lpn.q32) // @unsupported(xtlpn.q32) // @unsupported(wgpu.f32) +// @unsupported(rv32lpn.f32) // run: test_edge_out_read_error_write() == 0.0 // These reads may or may not be allowed depending on GLSL version and shader stage: @@ -47,6 +48,7 @@ float test_edge_out_read_error_indirect() { // @unsupported(rv32lpn.q32) // @unsupported(xtlpn.q32) // @unsupported(wgpu.f32) +// @unsupported(rv32lpn.f32) // run: test_edge_out_read_error_indirect() ~= 0.0 void test_edge_out_read_error_multiple_writes() { @@ -59,6 +61,7 @@ void test_edge_out_read_error_multiple_writes() { // @unsupported(rv32lpn.q32) // @unsupported(xtlpn.q32) // @unsupported(wgpu.f32) +// @unsupported(rv32lpn.f32) // run: test_edge_out_read_error_multiple_writes() == 0.0 void test_edge_out_read_error_fragment_output() { @@ -74,4 +77,5 @@ void test_edge_out_read_error_fragment_output() { // @unsupported(rv32lpn.q32) // @unsupported(xtlpn.q32) // @unsupported(wgpu.f32) +// @unsupported(rv32lpn.f32) // run: test_edge_out_read_error_fragment_output() == 0.0 diff --git a/lp-shader/lps-filetests/filetests/global-future/out-writeonly.glsl b/lp-shader/lps-filetests/filetests/global-future/out-writeonly.glsl index 1293c9f17..d6d65d3da 100644 --- a/lp-shader/lps-filetests/filetests/global-future/out-writeonly.glsl +++ b/lp-shader/lps-filetests/filetests/global-future/out-writeonly.glsl @@ -20,6 +20,7 @@ void test_out_writeonly_float() { // @unsupported(rv32lpn.q32) // @unsupported(xtlpn.q32) // @unsupported(wgpu.f32) +// @unsupported(rv32lpn.f32) // run: test_out_writeonly_float() == 0.0 void test_out_writeonly_int() { @@ -30,6 +31,7 @@ void test_out_writeonly_int() { // @unsupported(rv32lpn.q32) // @unsupported(xtlpn.q32) // @unsupported(wgpu.f32) +// @unsupported(rv32lpn.f32) // run: test_out_writeonly_int() == 0.0 void test_out_writeonly_uint() { @@ -40,6 +42,7 @@ void test_out_writeonly_uint() { // @unsupported(rv32lpn.q32) // @unsupported(xtlpn.q32) // @unsupported(wgpu.f32) +// @unsupported(rv32lpn.f32) // run: test_out_writeonly_uint() == 0.0 void test_out_writeonly_bool() { @@ -50,6 +53,7 @@ void test_out_writeonly_bool() { // @unsupported(rv32lpn.q32) // @unsupported(xtlpn.q32) // @unsupported(wgpu.f32) +// @unsupported(rv32lpn.f32) // run: test_out_writeonly_bool() == 0.0 void test_out_writeonly_vec2() { @@ -60,6 +64,7 @@ void test_out_writeonly_vec2() { // @unsupported(rv32lpn.q32) // @unsupported(xtlpn.q32) // @unsupported(wgpu.f32) +// @unsupported(rv32lpn.f32) // run: test_out_writeonly_vec2() == 0.0 void test_out_writeonly_vec3() { @@ -70,6 +75,7 @@ void test_out_writeonly_vec3() { // @unsupported(rv32lpn.q32) // @unsupported(xtlpn.q32) // @unsupported(wgpu.f32) +// @unsupported(rv32lpn.f32) // run: test_out_writeonly_vec3() == 0.0 void test_out_writeonly_vec4() { @@ -80,6 +86,7 @@ void test_out_writeonly_vec4() { // @unsupported(rv32lpn.q32) // @unsupported(xtlpn.q32) // @unsupported(wgpu.f32) +// @unsupported(rv32lpn.f32) // run: test_out_writeonly_vec4() == 0.0 void test_out_writeonly_calculations() { @@ -95,6 +102,7 @@ void test_out_writeonly_calculations() { // @unsupported(rv32lpn.q32) // @unsupported(xtlpn.q32) // @unsupported(wgpu.f32) +// @unsupported(rv32lpn.f32) // run: test_out_writeonly_calculations() == 0.0 void test_out_writeonly_fragment_output() { @@ -111,4 +119,5 @@ void test_out_writeonly_fragment_output() { // @unsupported(rv32lpn.q32) // @unsupported(xtlpn.q32) // @unsupported(wgpu.f32) +// @unsupported(rv32lpn.f32) // run: test_out_writeonly_fragment_output() == 0.0 diff --git a/lp-shader/lps-filetests/filetests/global-future/shared-declare.glsl b/lp-shader/lps-filetests/filetests/global-future/shared-declare.glsl index 64bae419b..cd0c6147d 100644 --- a/lp-shader/lps-filetests/filetests/global-future/shared-declare.glsl +++ b/lp-shader/lps-filetests/filetests/global-future/shared-declare.glsl @@ -28,6 +28,7 @@ float test_declare_shared_float() { // wasm.f32: shader does not compile on any target (frontend gap) — same cause // as the @unsupported entries above, not an f32-specific failure. // @unsupported(wasm.f32) +// @unsupported(rv32lpn.f32) // run: test_declare_shared_float() ~= 42.0 int test_declare_shared_int() { @@ -41,6 +42,7 @@ int test_declare_shared_int() { // @unsupported(xtlpn.q32) // @unsupported(wgpu.f32) // @unsupported(wasm.f32) +// @unsupported(rv32lpn.f32) // run: test_declare_shared_int() == 123 uint test_declare_shared_uint() { @@ -54,6 +56,7 @@ uint test_declare_shared_uint() { // @unsupported(xtlpn.q32) // @unsupported(wgpu.f32) // @unsupported(wasm.f32) +// @unsupported(rv32lpn.f32) // run: test_declare_shared_uint() == 256 bool test_declare_shared_bool() { @@ -67,6 +70,7 @@ bool test_declare_shared_bool() { // @unsupported(xtlpn.q32) // @unsupported(wgpu.f32) // @unsupported(wasm.f32) +// @unsupported(rv32lpn.f32) // run: test_declare_shared_bool() == true vec2 test_declare_shared_vec2() { @@ -80,6 +84,7 @@ vec2 test_declare_shared_vec2() { // @unsupported(xtlpn.q32) // @unsupported(wgpu.f32) // @unsupported(wasm.f32) +// @unsupported(rv32lpn.f32) // run: test_declare_shared_vec2() ~= vec2(10.0, 20.0) vec3 test_declare_shared_vec3() { @@ -93,6 +98,7 @@ vec3 test_declare_shared_vec3() { // @unsupported(xtlpn.q32) // @unsupported(wgpu.f32) // @unsupported(wasm.f32) +// @unsupported(rv32lpn.f32) // run: test_declare_shared_vec3() ~= vec3(0.0, 1.0, 0.0) vec4 test_declare_shared_vec4() { @@ -106,6 +112,7 @@ vec4 test_declare_shared_vec4() { // @unsupported(xtlpn.q32) // @unsupported(wgpu.f32) // @unsupported(wasm.f32) +// @unsupported(rv32lpn.f32) // run: test_declare_shared_vec4() ~= vec4(1.0, 0.5, 0.0, 1.0) mat2 test_declare_shared_mat2() { @@ -119,6 +126,7 @@ mat2 test_declare_shared_mat2() { // @unsupported(xtlpn.q32) // @unsupported(wgpu.f32) // @unsupported(wasm.f32) +// @unsupported(rv32lpn.f32) // run: test_declare_shared_mat2() ~= mat2(1.0, 0.0, 0.0, 1.0) float test_declare_shared_array() { @@ -136,4 +144,5 @@ float test_declare_shared_array() { // @unsupported(xtlpn.q32) // @unsupported(wgpu.f32) // @unsupported(wasm.f32) +// @unsupported(rv32lpn.f32) // run: test_declare_shared_array() ~= 6.0 From 318f23bb9e339976bd4e40bf6971aaab3be1b653 Mon Sep 17 00:00:00 2001 From: Yona Appletree Date: Fri, 31 Jul 2026 18:46:18 -0700 Subject: [PATCH 05/15] fix(lpvm-native): keep the f32 builtin-id tables out of the Fixed-only image MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Making import resolution mode-aware cost the shipping ESP32-C6 image **+3,904 B**, and the `float-f32` gate on this crate did not stop it: `lps-builtin-ids` is a separate crate every firmware links, its f32 name->id tables are reachable from exactly one call, and asking for them on a runtime value keeps all of them. Pinning the resolver to Q32 when `float-f32` is off is not a shortcut — without the feature a `FloatMode::F32` shader is a named lowering error long before resolution runs, so the f32 tables are genuinely unreachable. C6 image back to 2,862,432 B (283,296 B headroom) — +192 B vs the branch base, which is the mode-aware argument/return marshalling in `rt_jit`. The lesson is worth keeping: a feature gate on the crate that *uses* a table does not gate the crate that *holds* it. Plan: 2026-07-30-1745-f32-native-math (M9) --- lp-shader/lpvm-native/src/lower.rs | 33 ++++++++++++++++++++++++++---- 1 file changed, 29 insertions(+), 4 deletions(-) diff --git a/lp-shader/lpvm-native/src/lower.rs b/lp-shader/lpvm-native/src/lower.rs index fef6fc594..461a15be3 100644 --- a/lp-shader/lpvm-native/src/lower.rs +++ b/lp-shader/lpvm-native/src/lower.rs @@ -2177,10 +2177,7 @@ fn resolve_import_to_builtin( decl: &lpir::lpir_module::ImportDecl, mode: FloatMode, ) -> Option { - let bmode = match mode { - FloatMode::Q32 => BuiltinMode::Q32, - FloatMode::F32 => BuiltinMode::F32, - }; + let bmode = builtin_mode(mode); match decl.module_name.as_str() { "glsl" => { let ac = decl.param_types.len(); @@ -2210,6 +2207,34 @@ fn resolve_import_to_builtin( } } +/// [`FloatMode`] → the builtin-id resolver's mode. +/// +/// **Pinned to Q32 when `float-f32` is off, and that is a flash decision.** +/// `lps-builtin-ids` is a separate crate that every firmware image links, and +/// its f32 name→id tables are reachable from exactly one place: this call. Ask +/// for them on a *runtime* value and LTO has to keep all of them — +/// **+3,904 B measured on the ESP32-C6 image**, for tables a Fixed-only device +/// can never reach, because without `float-f32` a `FloatMode::F32` shader is a +/// named lowering error long before it gets here. +/// +/// This is the same shape as the `isa-*` gates, and it is worth knowing that the +/// gate on `lpvm-native`'s own f32 code was *not* enough on its own: the id +/// tables live in another crate and needed their own cut point. +#[cfg(feature = "float-f32")] +fn builtin_mode(mode: FloatMode) -> BuiltinMode { + match mode { + FloatMode::Q32 => BuiltinMode::Q32, + FloatMode::F32 => BuiltinMode::F32, + } +} + +/// See the sibling above — without `float-f32` there is no f32 lowering to +/// resolve imports for, so the f32 resolvers stay unreachable and drop out. +#[cfg(not(feature = "float-f32"))] +fn builtin_mode(_mode: FloatMode) -> BuiltinMode { + BuiltinMode::Q32 +} + /// Strip the numeric suffix from LPFX import names (e.g., "lpfn_psrdnoise_34" → "lpfn_psrdnoise"). fn lpfn_strip_suffix(func_name: &str) -> Option<&str> { let (base, tail) = func_name.rsplit_once('_')?; From 529a7b8a43b50bd43845d8716c1438019b8c727f Mon Sep 17 00:00:00 2001 From: Yona Appletree Date: Fri, 31 Jul 2026 18:47:30 -0700 Subject: [PATCH 06/15] =?UTF-8?q?docs:=20soft-float=20f32=20=E2=80=94=20th?= =?UTF-8?q?e=20capability=20seam,=20the=20C6=20harness,=20and=20the=20flas?= =?UTF-8?q?h=20lesson?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The lesson worth the space: a feature gate on the crate that *uses* a table does not gate the crate that *holds* it. lps-builtin-ids is linked by every image and cost +3,904 B on the C6 before the resolver was pinned. Plan: 2026-07-30-1745-f32-native-math (M9) --- AGENTS.md | 13 +++++++++++++ lp-fw/fw-esp32c6/README.md | 31 +++++++++++++++++++++++++++++++ 2 files changed, 44 insertions(+) diff --git a/AGENTS.md b/AGENTS.md index 22eea4fed..10cddd4b0 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -101,6 +101,19 @@ Do NOT disable the compiler. The compiler is the product. - **Default server/engine builds include the full compiler pipeline.** Optional features are for *removing* pieces (e.g. `no-shader-compile` for stripped test builds), not for *adding* the compiler. +- **`float-f32`** (`lpvm-native`, `lps-builtins`) enables IEEE-754 f32 shader + math alongside Q16.16. Off by default: `FloatMode` is matched on a *runtime* + value, so LTO cannot drop the f32 arms, and the shipping ESP32-C6 runs + Fixed-mode shaders only. The one device configuration that turns it on is + `fw-esp32c6`'s `test_f32_softfloat` harness — a test build, never product + firmware. See `docs/adr/2026-07-31-soft-float-via-compiler-builtins.md`. + +> **Gating the crate that *uses* a table does not gate the crate that *holds* +> it.** `lps-builtin-ids` is linked by every firmware image and is not behind +> `float-f32`; reaching its f32 name→id tables on a runtime value cost +> **+3,904 B** on the C6 before the resolver was pinned to Q32 in feature-off +> builds. Measure with `just fw-esp32c6-size-check` on both sides of a feature +> gate, not just the side you added. ## Sans-IO core diff --git a/lp-fw/fw-esp32c6/README.md b/lp-fw/fw-esp32c6/README.md index 01a953a52..833c359ee 100644 --- a/lp-fw/fw-esp32c6/README.md +++ b/lp-fw/fw-esp32c6/README.md @@ -88,3 +88,34 @@ The default feature set targets ESP32-C6 with server and radio support. Many profiling, or smoke tests. Keep feature additions honest: test and check modes may narrow behavior for a harness, but the normal firmware path must preserve runtime shader compilation on device. + +### `test_f32_softfloat` — IEEE f32 on a chip with no FPU + +The C6 is RV32IMAC: no F extension. It can still execute **f32 semantics** +through soft-float calls, which makes it the only rv32 *hardware* oracle for +f32 until an F-bearing part (ESP32-S31) is on the desk. + +```bash +just fwtest-f32-softfloat-esp32c6 /dev/cu.usbmodemXXXX +``` + +**Pass the port explicitly.** Several ESP32 boards are usually attached and +auto-detection has flashed the wrong one before; the recipe refuses rather than +guessing. The harness configures **no GPIO** — on the C6, GPIO12/13 are the USB +D-/D+ lines and driving them costs a physical replug. + +Two halves. `abi_probe` calls `__addsf3`/`__ltsf2`/… directly and compares raw +result words against IEEE reference bit patterns computed **off-device** — on +this chip a Rust `a + b` on two `f32`s *is* a call to `__addsf3`, so computing +the expected value here would compare the routine to itself. What that measures +is the **mask ROM**: the linker resolves these names through `esp-rom-sys`'s +`esp32c6.rom.rvfp.ld` to Espressif's ROM `rvfplib`, a different implementation +from the `compiler_builtins` the host emulator runs. `shader_cases` then +compiles a GLSL shader on the device in `FloatMode::F32`, JITs it, and calls it. + +**This is the only configuration in this crate that turns on `float-f32`**, and +it does so deliberately: the shipping image runs Fixed-mode shaders and must not +carry an f32 backend it never enters. That is what keeps +`just fw-esp32c6-size-check` measuring an unchanged product image — check both +sides of the gate when you touch it. See +`docs/adr/2026-07-31-soft-float-via-compiler-builtins.md`. From cac78cc13f6f31a7f2feef879a0d34900448a9e3 Mon Sep 17 00:00:00 2001 From: Yona Appletree Date: Fri, 31 Jul 2026 18:52:14 -0700 Subject: [PATCH 07/15] style: rustfmt + FloatLaneAbi round-trip tests MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The tests are the ones worth having on this seam: values Q16.16 cannot represent survive the IEEE lane ABI bit-for-bit, a signalling NaN's payload is carried untouched, and the two lane codecs are provably not interchangeable — if they ever agree, the mode split has collapsed. Plan: 2026-07-30-1745-f32-native-math (M9) --- .../src/tests/f32_softfloat/abi_probe.rs | 35 ++- .../src/tests/f32_softfloat/shader_cases.rs | 19 +- lp-shader/lps-shared/src/lps_value_q32.rs | 94 +++++-- lp-shader/lpvm-native/src/lower.rs | 10 +- lp-shader/lpvm-native/src/lower_f32.rs | 264 +++++++++++++----- lp-shader/lpvm-native/src/rt_jit/instance.rs | 7 +- lp-shader/lpvm/src/lib.rs | 6 +- 7 files changed, 326 insertions(+), 109 deletions(-) diff --git a/lp-fw/fw-esp32c6/src/tests/f32_softfloat/abi_probe.rs b/lp-fw/fw-esp32c6/src/tests/f32_softfloat/abi_probe.rs index 25b7ef384..d371d2451 100644 --- a/lp-fw/fw-esp32c6/src/tests/f32_softfloat/abi_probe.rs +++ b/lp-fw/fw-esp32c6/src/tests/f32_softfloat/abi_probe.rs @@ -94,14 +94,39 @@ pub fn run(report: &mut Report) { } fn arithmetic(report: &mut Report) { - check(report, "__addsf3(1,2)", bits(unsafe { __addsf3(of(F_1_0), of(F_2_0)) }), F_3_0); - check(report, "__subsf3(3,1)", bits(unsafe { __subsf3(of(F_3_0), of(F_1_0)) }), F_2_0); - check(report, "__mulsf3(3,2)", bits(unsafe { __mulsf3(of(F_3_0), of(F_2_0)) }), 0x40C0_0000); + check( + report, + "__addsf3(1,2)", + bits(unsafe { __addsf3(of(F_1_0), of(F_2_0)) }), + F_3_0, + ); + check( + report, + "__subsf3(3,1)", + bits(unsafe { __subsf3(of(F_3_0), of(F_1_0)) }), + F_2_0, + ); + check( + report, + "__mulsf3(3,2)", + bits(unsafe { __mulsf3(of(F_3_0), of(F_2_0)) }), + 0x40C0_0000, + ); // The classic: 0.1 + 0.2 must round to 0.30000001192092896, not 0.3. // Anything else means the routine is not round-to-nearest-even. - check(report, "__addsf3(0.1,0.2)", bits(unsafe { __addsf3(of(F_0_1), of(F_0_2)) }), F_0_3_SUM); + check( + report, + "__addsf3(0.1,0.2)", + bits(unsafe { __addsf3(of(F_0_1), of(F_0_2)) }), + F_0_3_SUM, + ); // 1/3 is the one-bit-wrong detector for a reciprocal-multiply shortcut. - check(report, "__divsf3(1,3)", bits(unsafe { __divsf3(of(F_1_0), of(F_3_0)) }), F_1_3RD); + check( + report, + "__divsf3(1,3)", + bits(unsafe { __divsf3(of(F_1_0), of(F_3_0)) }), + F_1_3RD, + ); } fn edge_values(report: &mut Report) { diff --git a/lp-fw/fw-esp32c6/src/tests/f32_softfloat/shader_cases.rs b/lp-fw/fw-esp32c6/src/tests/f32_softfloat/shader_cases.rs index d0d647321..74f374edb 100644 --- a/lp-fw/fw-esp32c6/src/tests/f32_softfloat/shader_cases.rs +++ b/lp-fw/fw-esp32c6/src/tests/f32_softfloat/shader_cases.rs @@ -179,10 +179,18 @@ pub fn run(report: &mut Report) { case.args.iter().map(|a| LpsValueF32::F32(*a)).collect(); match inst.call(case.func, &args) { Ok(LpsValueF32::F32(got)) => { - report.record(case.name, got.to_bits() == case.want_bits, got.to_bits(), case.want_bits); + report.record( + case.name, + got.to_bits() == case.want_bits, + got.to_bits(), + case.want_bits, + ); } Ok(other) => { - println!("[f32-soft] FAIL {}: unexpected return shape {other:?}", case.name); + println!( + "[f32-soft] FAIL {}: unexpected return shape {other:?}", + case.name + ); report.record(case.name, false, 0, case.want_bits); } Err(e) => { @@ -214,7 +222,12 @@ pub fn run(report: &mut Report) { ), other => { println!("[f32-soft] FAIL to_int: {other:?}"); - report.record("to_int(1000000.5) truncates to 1000000", false, 0, 1_000_000); + report.record( + "to_int(1000000.5) truncates to 1000000", + false, + 0, + 1_000_000, + ); } } } diff --git a/lp-shader/lps-shared/src/lps_value_q32.rs b/lp-shader/lps-shared/src/lps_value_q32.rs index dc38344c8..50a160698 100644 --- a/lp-shader/lps-shared/src/lps_value_q32.rs +++ b/lp-shader/lps-shared/src/lps_value_q32.rs @@ -309,40 +309,19 @@ pub fn lanes_to_lps_value_f32( (LpsType::BVec3, LpsValueQ32::BVec3(a)) => LpsValueF32::BVec3(a), (LpsType::BVec4, LpsValueQ32::BVec4(a)) => LpsValueF32::BVec4(a), - (LpsType::Mat2, LpsValueQ32::Mat2x2(m)) => LpsValueF32::Mat2x2([ - [dec(m[0][0]), dec(m[0][1])], - [dec(m[1][0]), dec(m[1][1])], - ]), + (LpsType::Mat2, LpsValueQ32::Mat2x2(m)) => { + LpsValueF32::Mat2x2([[dec(m[0][0]), dec(m[0][1])], [dec(m[1][0]), dec(m[1][1])]]) + } (LpsType::Mat3, LpsValueQ32::Mat3x3(m)) => LpsValueF32::Mat3x3([ [dec(m[0][0]), dec(m[0][1]), dec(m[0][2])], [dec(m[1][0]), dec(m[1][1]), dec(m[1][2])], [dec(m[2][0]), dec(m[2][1]), dec(m[2][2])], ]), (LpsType::Mat4, LpsValueQ32::Mat4x4(m)) => LpsValueF32::Mat4x4([ - [ - dec(m[0][0]), - dec(m[0][1]), - dec(m[0][2]), - dec(m[0][3]), - ], - [ - dec(m[1][0]), - dec(m[1][1]), - dec(m[1][2]), - dec(m[1][3]), - ], - [ - dec(m[2][0]), - dec(m[2][1]), - dec(m[2][2]), - dec(m[2][3]), - ], - [ - dec(m[3][0]), - dec(m[3][1]), - dec(m[3][2]), - dec(m[3][3]), - ], + [dec(m[0][0]), dec(m[0][1]), dec(m[0][2]), dec(m[0][3])], + [dec(m[1][0]), dec(m[1][1]), dec(m[1][2]), dec(m[1][3])], + [dec(m[2][0]), dec(m[2][1]), dec(m[2][2]), dec(m[2][3])], + [dec(m[3][0]), dec(m[3][1]), dec(m[3][2]), dec(m[3][3])], ]), (LpsType::Array { element, len }, LpsValueQ32::Array(items)) => { @@ -373,7 +352,10 @@ pub fn lanes_to_lps_value_f32( if fname != &key { return Err(bad()); } - fields.push((fname.clone(), lanes_to_lps_value_f32(&m.ty, fv.clone(), abi)?)); + fields.push(( + fname.clone(), + lanes_to_lps_value_f32(&m.ty, fv.clone(), abi)?, + )); } LpsValueF32::Struct { name: vname.or(name.clone()), @@ -420,4 +402,58 @@ mod tests { let back = q32_to_lps_value_f32(&ty, q).unwrap(); assert!(back.eq(&v)); } + + /// The property that makes the IEEE lane ABI worth having: values Q16.16 + /// cannot represent survive it **exactly**, bit for bit, and the two modes + /// share one traversal so a struct or matrix cannot round-trip in one mode + /// and not the other. + #[test] + fn ieee_lanes_round_trip_values_q32_cannot_hold() { + for (ty, v) in [ + (LpsType::Float, LpsValueF32::F32(1.234_567_8e10)), + (LpsType::Float, LpsValueF32::F32(1e-30)), + (LpsType::Float, LpsValueF32::F32(-0.0)), + ( + LpsType::Vec3, + LpsValueF32::Vec3([f32::INFINITY, -1e20, 1.0e-38]), + ), + ] { + let lanes = lps_value_f32_to_lanes(&ty, &v, FloatLaneAbi::Ieee754Bits).unwrap(); + let back = lanes_to_lps_value_f32(&ty, lanes, FloatLaneAbi::Ieee754Bits).unwrap(); + assert!( + back.eq(&v), + "{ty:?}: {v:?} did not survive the IEEE lane ABI" + ); + } + } + + /// NaN survives as a *bit pattern*. `eq` would be false for it, so this + /// checks the lane word directly — and it is the case a "clever" conversion + /// through `f64` or through `Q32` arithmetic would quietly destroy. + #[test] + fn ieee_lanes_carry_nan_bits_untouched() { + let signalling = f32::from_bits(0x7f80_0001); + let lanes = lps_value_f32_to_lanes( + &LpsType::Float, + &LpsValueF32::F32(signalling), + FloatLaneAbi::Ieee754Bits, + ) + .unwrap(); + let LpsValueQ32::F32(word) = lanes else { + panic!("expected a float lane"); + }; + assert_eq!(word.to_fixed() as u32, 0x7f80_0001); + } + + /// The Ieee754Bits lane is a raw word, not a number: reading it back under + /// the Q16.16 codec must not accidentally agree. If these two ever match, + /// the codec split has collapsed and the modes are silently sharing an + /// encoding. + #[test] + fn the_two_lane_abis_are_not_interchangeable() { + let v = LpsValueF32::F32(1.0); + let ieee = lps_value_f32_to_lanes(&LpsType::Float, &v, FloatLaneAbi::Ieee754Bits).unwrap(); + let fixed = lps_value_f32_to_lanes(&LpsType::Float, &v, FloatLaneAbi::Q16_16).unwrap(); + assert_ne!(ieee, fixed); + } } diff --git a/lp-shader/lpvm-native/src/lower.rs b/lp-shader/lpvm-native/src/lower.rs index 461a15be3..afb79d3e9 100644 --- a/lp-shader/lpvm-native/src/lower.rs +++ b/lp-shader/lpvm-native/src/lower.rs @@ -1497,7 +1497,15 @@ pub fn lower_lpir_op( | LpirOp::Unorm8toF { .. } => { #[cfg(feature = "float-f32")] { - crate::lower_f32::lower_f32_op(out, op, abi.isa(), src_op, symbols, vreg_pool, temps) + crate::lower_f32::lower_f32_op( + out, + op, + abi.isa(), + src_op, + symbols, + vreg_pool, + temps, + ) } // Without the feature the f32 lowering is not linked at all, which // is the point of the gate (roadmap D2: `FloatMode` is matched on a diff --git a/lp-shader/lpvm-native/src/lower_f32.rs b/lp-shader/lpvm-native/src/lower_f32.rs index 87e030e03..c59dda5dc 100644 --- a/lp-shader/lpvm-native/src/lower_f32.rs +++ b/lp-shader/lpvm-native/src/lower_f32.rs @@ -137,18 +137,42 @@ fn lower_soft_float_op( let po = pack_src_op(src_op); match op { // ── compiler-rt arithmetic ─────────────────────────────────────────── - LpirOp::Fadd { dst, lhs, rhs } => { - soft_call(out, symbols, vreg_pool, "__addsf3", &[*lhs, *rhs], &[*dst], po) - } - LpirOp::Fsub { dst, lhs, rhs } => { - soft_call(out, symbols, vreg_pool, "__subsf3", &[*lhs, *rhs], &[*dst], po) - } - LpirOp::Fmul { dst, lhs, rhs } => { - soft_call(out, symbols, vreg_pool, "__mulsf3", &[*lhs, *rhs], &[*dst], po) - } - LpirOp::Fdiv { dst, lhs, rhs } => { - soft_call(out, symbols, vreg_pool, "__divsf3", &[*lhs, *rhs], &[*dst], po) - } + LpirOp::Fadd { dst, lhs, rhs } => soft_call( + out, + symbols, + vreg_pool, + "__addsf3", + &[*lhs, *rhs], + &[*dst], + po, + ), + LpirOp::Fsub { dst, lhs, rhs } => soft_call( + out, + symbols, + vreg_pool, + "__subsf3", + &[*lhs, *rhs], + &[*dst], + po, + ), + LpirOp::Fmul { dst, lhs, rhs } => soft_call( + out, + symbols, + vreg_pool, + "__mulsf3", + &[*lhs, *rhs], + &[*dst], + po, + ), + LpirOp::Fdiv { dst, lhs, rhs } => soft_call( + out, + symbols, + vreg_pool, + "__divsf3", + &[*lhs, *rhs], + &[*dst], + po, + ), // A real divide, not the Q32 path's multiply-by-reciprocal. The Q32 // rewrite is legitimate there because Q16.16 division is already // approximate; in f32 it would silently lose the last bits of an @@ -172,32 +196,92 @@ fn lower_soft_float_op( } // ── compiler-rt comparisons ────────────────────────────────────────── - LpirOp::Feq { dst, lhs, rhs } => { - soft_compare(out, symbols, vreg_pool, temps, "__eqsf2", IcmpCond::Eq, *dst, *lhs, *rhs, po) - } - LpirOp::Fne { dst, lhs, rhs } => { - soft_compare(out, symbols, vreg_pool, temps, "__nesf2", IcmpCond::Ne, *dst, *lhs, *rhs, po) - } - LpirOp::Flt { dst, lhs, rhs } => { - soft_compare(out, symbols, vreg_pool, temps, "__ltsf2", IcmpCond::LtS, *dst, *lhs, *rhs, po) - } - LpirOp::Fle { dst, lhs, rhs } => { - soft_compare(out, symbols, vreg_pool, temps, "__lesf2", IcmpCond::LeS, *dst, *lhs, *rhs, po) - } - LpirOp::Fgt { dst, lhs, rhs } => { - soft_compare(out, symbols, vreg_pool, temps, "__gtsf2", IcmpCond::GtS, *dst, *lhs, *rhs, po) - } - LpirOp::Fge { dst, lhs, rhs } => { - soft_compare(out, symbols, vreg_pool, temps, "__gesf2", IcmpCond::GeS, *dst, *lhs, *rhs, po) - } + LpirOp::Feq { dst, lhs, rhs } => soft_compare( + out, + symbols, + vreg_pool, + temps, + "__eqsf2", + IcmpCond::Eq, + *dst, + *lhs, + *rhs, + po, + ), + LpirOp::Fne { dst, lhs, rhs } => soft_compare( + out, + symbols, + vreg_pool, + temps, + "__nesf2", + IcmpCond::Ne, + *dst, + *lhs, + *rhs, + po, + ), + LpirOp::Flt { dst, lhs, rhs } => soft_compare( + out, + symbols, + vreg_pool, + temps, + "__ltsf2", + IcmpCond::LtS, + *dst, + *lhs, + *rhs, + po, + ), + LpirOp::Fle { dst, lhs, rhs } => soft_compare( + out, + symbols, + vreg_pool, + temps, + "__lesf2", + IcmpCond::LeS, + *dst, + *lhs, + *rhs, + po, + ), + LpirOp::Fgt { dst, lhs, rhs } => soft_compare( + out, + symbols, + vreg_pool, + temps, + "__gtsf2", + IcmpCond::GtS, + *dst, + *lhs, + *rhs, + po, + ), + LpirOp::Fge { dst, lhs, rhs } => soft_compare( + out, + symbols, + vreg_pool, + temps, + "__gesf2", + IcmpCond::GeS, + *dst, + *lhs, + *rhs, + po, + ), // ── compiler-rt int → float ────────────────────────────────────────── LpirOp::ItofS { dst, src } => { soft_call(out, symbols, vreg_pool, "__floatsisf", &[*src], &[*dst], po) } - LpirOp::ItofU { dst, src } => { - soft_call(out, symbols, vreg_pool, "__floatunsisf", &[*src], &[*dst], po) - } + LpirOp::ItofU { dst, src } => soft_call( + out, + symbols, + vreg_pool, + "__floatunsisf", + &[*src], + &[*dst], + po, + ), // ── bit manipulation, no call ──────────────────────────────────────── LpirOp::FconstF32 { dst, value } => { @@ -211,12 +295,28 @@ fn lower_soft_float_op( // Flip the sign bit. Not `0.0 - x`: that turns `-0.0` into `+0.0` and // quiets a signaling NaN, both of which `float.md` §3 forbids for `-x`. LpirOp::Fneg { dst, src } => { - mask_op(out, temps, AluOp::Xor, SIGN_BIT, fa_vreg(*dst), fa_vreg(*src), po); + mask_op( + out, + temps, + AluOp::Xor, + SIGN_BIT, + fa_vreg(*dst), + fa_vreg(*src), + po, + ); Ok(()) } // Clear the sign bit; exact on NaN and ±0 for the same reason. LpirOp::Fabs { dst, src } => { - mask_op(out, temps, AluOp::And, SIGN_MASK_OFF, fa_vreg(*dst), fa_vreg(*src), po); + mask_op( + out, + temps, + AluOp::And, + SIGN_MASK_OFF, + fa_vreg(*dst), + fa_vreg(*src), + po, + ); Ok(()) } // Reinterpret: the value is already the bit pattern, in an integer @@ -233,21 +333,51 @@ fn lower_soft_float_op( } // ── native-f32 builtin family (no compiler-rt equivalent) ──────────── - LpirOp::Fsqrt { dst, src } => { - soft_call(out, symbols, vreg_pool, "__lp_lpir_fsqrt_f32", &[*src], &[*dst], po) - } - LpirOp::Ffloor { dst, src } => { - soft_call(out, symbols, vreg_pool, "__lp_lpir_ffloor_f32", &[*src], &[*dst], po) - } - LpirOp::Fceil { dst, src } => { - soft_call(out, symbols, vreg_pool, "__lp_lpir_fceil_f32", &[*src], &[*dst], po) - } - LpirOp::Ftrunc { dst, src } => { - soft_call(out, symbols, vreg_pool, "__lp_lpir_ftrunc_f32", &[*src], &[*dst], po) - } - LpirOp::Fnearest { dst, src } => { - soft_call(out, symbols, vreg_pool, "__lp_lpir_fnearest_f32", &[*src], &[*dst], po) - } + LpirOp::Fsqrt { dst, src } => soft_call( + out, + symbols, + vreg_pool, + "__lp_lpir_fsqrt_f32", + &[*src], + &[*dst], + po, + ), + LpirOp::Ffloor { dst, src } => soft_call( + out, + symbols, + vreg_pool, + "__lp_lpir_ffloor_f32", + &[*src], + &[*dst], + po, + ), + LpirOp::Fceil { dst, src } => soft_call( + out, + symbols, + vreg_pool, + "__lp_lpir_fceil_f32", + &[*src], + &[*dst], + po, + ), + LpirOp::Ftrunc { dst, src } => soft_call( + out, + symbols, + vreg_pool, + "__lp_lpir_ftrunc_f32", + &[*src], + &[*dst], + po, + ), + LpirOp::Fnearest { dst, src } => soft_call( + out, + symbols, + vreg_pool, + "__lp_lpir_fnearest_f32", + &[*src], + &[*dst], + po, + ), LpirOp::Fmin { dst, lhs, rhs } => soft_call( out, symbols, @@ -266,12 +396,24 @@ fn lower_soft_float_op( &[*dst], po, ), - LpirOp::FtoiSatS { dst, src } => { - soft_call(out, symbols, vreg_pool, f32_ftoi_sat_s_symbol(), &[*src], &[*dst], po) - } - LpirOp::FtoiSatU { dst, src } => { - soft_call(out, symbols, vreg_pool, f32_ftoi_sat_u_symbol(), &[*src], &[*dst], po) - } + LpirOp::FtoiSatS { dst, src } => soft_call( + out, + symbols, + vreg_pool, + f32_ftoi_sat_s_symbol(), + &[*src], + &[*dst], + po, + ), + LpirOp::FtoiSatU { dst, src } => soft_call( + out, + symbols, + vreg_pool, + f32_ftoi_sat_u_symbol(), + &[*src], + &[*dst], + po, + ), LpirOp::FtoUnorm16 { dst, src } => soft_call( out, symbols, @@ -656,10 +798,7 @@ mod tests { neg.as_slice(), [ VInst::IConst32 { val: SIGN_BIT, .. }, - VInst::AluRRR { - op: AluOp::Xor, - .. - } + VInst::AluRRR { op: AluOp::Xor, .. } ] )); @@ -678,10 +817,7 @@ mod tests { val: SIGN_MASK_OFF, .. }, - VInst::AluRRR { - op: AluOp::And, - .. - } + VInst::AluRRR { op: AluOp::And, .. } ] )); } diff --git a/lp-shader/lpvm-native/src/rt_jit/instance.rs b/lp-shader/lpvm-native/src/rt_jit/instance.rs index 6e43ae494..b5562f0e2 100644 --- a/lp-shader/lpvm-native/src/rt_jit/instance.rs +++ b/lp-shader/lpvm-native/src/rt_jit/instance.rs @@ -8,10 +8,9 @@ use lpir::FloatMode; use lps_shared::{LpsType, LpsValueQ32, ParamQualifier, lps_value_f32::LpsValueF32}; use lpvm::{ CallError, DEFAULT_VMCTX_FUEL, INVOCATION_INDEX_ARMED, LpvmBuffer, LpvmInstance, - TRAP_CODE_NONE, VMCTX_OFFSET_FUEL, VMCTX_OFFSET_TRAP, decode_global_read, - decode_return_to_f32, encode_global_write, encode_uniform_write, encode_uniform_write_q32, - flat_words_from_f32_args, float_lane_abi, global_data_span, glsl_component_count, - validate_compute_tick_sig, + TRAP_CODE_NONE, VMCTX_OFFSET_FUEL, VMCTX_OFFSET_TRAP, decode_global_read, decode_return_to_f32, + encode_global_write, encode_uniform_write, encode_uniform_write_q32, flat_words_from_f32_args, + float_lane_abi, global_data_span, glsl_component_count, validate_compute_tick_sig, }; use crate::error::NativeError; diff --git a/lp-shader/lpvm/src/lib.rs b/lp-shader/lpvm/src/lib.rs index 47cb0ca31..7ddf0c7ec 100644 --- a/lp-shader/lpvm/src/lib.rs +++ b/lp-shader/lpvm/src/lib.rs @@ -70,9 +70,9 @@ pub use lps_shared::path_resolve::{LpsTypePathExt, PathError}; pub use lps_shared::value_path::{LpsValuePathError, LpsValuePathExt}; pub use lps_shared::{LayoutRules, LpsFnSig, LpsType, StructMember}; pub use lpvm_abi::{ - CallError, CallResult, GlslReturn, decode_q32_return, decode_return_to_f32, float_lane_abi, - flat_q32_words_from_f32_args, flat_words_from_f32_args, - flatten_q32_arg, flatten_q32_return, glsl_component_count, unflatten_q32_args, + CallError, CallResult, GlslReturn, decode_q32_return, decode_return_to_f32, + flat_q32_words_from_f32_args, flat_words_from_f32_args, flatten_q32_arg, flatten_q32_return, + float_lane_abi, glsl_component_count, unflatten_q32_args, }; pub use lpvm_data_q32::LpvmDataQ32; pub use memory::{AllocError, BumpLpvmMemory, LpvmMemory}; From 8d658d38380f45acc595e5a2f1601009ba4b4123 Mon Sep 17 00:00:00 2001 From: Yona Appletree Date: Fri, 31 Jul 2026 19:49:17 -0700 Subject: [PATCH 08/15] feat(lpvm-native): float VInsts, the two-class map, and the Xtensa FR model MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit M7 P1 — the *vocabulary* for hardware float on Xtensa. Nothing constructs these yet (P2 lowers, P3 emits); the oracles are unit-level and real. - Nine float `VInst` variants (`FAluRRR/FAluRR/Fcmp/FSelect/FLoad32/ FStore32/Wfr/Rfr/IToF`) plus `FAluOp`, `FAluRROp`, `FcmpCond`, wired through all five per-variant helpers, the debug text form (writer + parser, with a round-trip test per variant), and the alloc-trace render. `size_of::() <= 32` still holds. - `regalloc::classes` learns the operand classes. The mixed-class ones are the calling convention in miniature: `Fcmp` reads floats and writes an int, `FSelect` takes an int condition, and `Wfr`/`Rfr` each read the file the other writes. `Call`/`Ret` stay Int-only on purpose (D1/D2). - New `isa/xt/fpr.rs`: all 16 FRs allocatable and all 16 caller-saved (D8), no reserved scratch, `CMP_BREG = b0`. No FR is callee-saved (M6-P4's measured toolchain ABI), which is what removes the FP callee-save frame region entirely — `FrameLayout` is untouched (D7). - Four `IsaTarget` float hooks populated for Xtensa behind `float-f32`; the five ABI hooks stay empty with doc comments saying they are *decided*, not unimplemented (D3), including why `move_cycle_scratch` never grows the `class` parameter M4 anticipated. - `ImmOp::FpLsiOffset` gates `lsi`/`ssi`'s 0..=1020 step-4 range. The test pins the hazard rather than the rule: offset 1024 silently encodes as 0, because `lp-xt-inst` masks the field without a range check. - `float-f32` joins `default` alongside the ISAs (every firmware crate takes this crate with `default-features = false`). Two gate leaks found by measurement and fixed: the class map ungated cost 496 B of C6 image, and the emitter's float-arm diagnostic another 1,248 B. Residual with the feature off is +1,424 B image — `.text` is 120 B *smaller*; the growth is jump-table `.rodata` from the unconditional variants, which is the D9/Q5 trade in the form D9 asked to see it. Plan: 2026-07-30-1745-f32-native-math/m7-f32-codegen-xtensa Co-Authored-By: Claude Fable 5 --- lp-shader/lpvm-native/Cargo.toml | 24 +- lp-shader/lpvm-native/src/debug/vinst.rs | 317 +++++++++++++++- lp-shader/lpvm-native/src/isa/mod.rs | 197 ++++++++-- lp-shader/lpvm-native/src/isa/rv32/emit.rs | 26 ++ lp-shader/lpvm-native/src/isa/xt/abi.rs | 51 ++- lp-shader/lpvm-native/src/isa/xt/emit.rs | 20 + lp-shader/lpvm-native/src/isa/xt/fpr.rs | 180 +++++++++ lp-shader/lpvm-native/src/isa/xt/imm.rs | 24 ++ lp-shader/lpvm-native/src/isa/xt/mod.rs | 7 + lp-shader/lpvm-native/src/lower_f32.rs | 4 +- lp-shader/lpvm-native/src/regalloc/classes.rs | 345 +++++++++++++++++- lp-shader/lpvm-native/src/regalloc/render.rs | 54 +++ .../lpvm-native/src/regalloc/test/builder.rs | 59 +-- .../src/regalloc/test/float_alloc.rs | 196 ++++++++++ .../lpvm-native/src/regalloc/test/mod.rs | 1 + lp-shader/lpvm-native/src/vinst.rs | 339 ++++++++++++++++- .../lpvm-native/tests/xt_imm_legality.rs | 49 +++ 17 files changed, 1812 insertions(+), 81 deletions(-) create mode 100644 lp-shader/lpvm-native/src/isa/xt/fpr.rs create mode 100644 lp-shader/lpvm-native/src/regalloc/test/float_alloc.rs diff --git a/lp-shader/lpvm-native/Cargo.toml b/lp-shader/lpvm-native/Cargo.toml index dfedfefd0..a0d14b27b 100644 --- a/lp-shader/lpvm-native/Cargo.toml +++ b/lp-shader/lpvm-native/Cargo.toml @@ -16,21 +16,27 @@ workspace = true # existed (LTO cannot drop it — `IsaTarget` is matched on a runtime value). # Adding a backend means adding a feature here AND opting the firmware crates # in; see README "Multi-ISA seam". -default = ["isa-rv32", "isa-xt"] +default = ["isa-rv32", "isa-xt", "float-f32"] # RISC-V 32-bit backend (ESP32-C6): `isa/rv32`, its emitter and encoder. isa-rv32 = ["dep:lp-riscv-inst"] # Xtensa backend (ESP32-S3 / classic ESP32): `isa/xt`, its emitter and the # per-opcode immediate table. isa-xt = ["dep:lp-xt-inst"] -# Native-f32 (`FloatMode::F32`) lowering: `lower_f32`, and on a JIT-capable -# target the soft-float symbol entries in the `rt_jit` builtin table. +# Native-f32 (`FloatMode::F32`) lowering: `lower_f32`, the Xtensa float +# register/ABI tables (`isa/xt/fpr`) and FP emitter, and on a JIT-capable target +# the soft-float symbol entries in the `rt_jit` builtin table. # -# Off by default, and for the same measured reason as the `isa-*` gates above: -# `FloatMode` is matched on a runtime value, so LTO cannot drop the f32 arms on -# its own. The shipping ESP32-C6 image runs Fixed-mode shaders only and must not -# grow by an f32 backend it never enters (f32 roadmap D2). Pair it with -# `lps-builtins/float-f32`, which gates the `__lp_lpir_*_f32` implementations -# this lowering calls. +# In `default` alongside the ISAs, and gated for exactly the same measured +# reason: `FloatMode` is matched on a runtime value, so LTO cannot drop the f32 +# arms on its own, and a firmware crate that takes this crate with +# `default-features = false` (all of them do) links none of it. The shipping +# ESP32-C6 image runs Fixed-mode shaders only and must not grow by an f32 +# backend it never enters (f32 roadmap D2); its zero-byte delta is the check +# that proves the gate holds. Pair it with `lps-builtins/float-f32`, which gates +# the `__lp_lpir_*_f32` implementations this lowering calls — the firmware +# crates name both (see `fw-esp32c6`'s `test_f32_softfloat`), because +# `lps-builtins` is a target-specific dependency here and cannot be forwarded +# from this feature on the host. float-f32 = [] # Enable debug info generation (increases binary size, for development only) debug = [] diff --git a/lp-shader/lpvm-native/src/debug/vinst.rs b/lp-shader/lpvm-native/src/debug/vinst.rs index 091bb852f..b99b844df 100644 --- a/lp-shader/lpvm-native/src/debug/vinst.rs +++ b/lp-shader/lpvm-native/src/debug/vinst.rs @@ -16,8 +16,27 @@ //! Br @0 // branch to label //! BrIf i0, @1 // branch if i0 != 0 //! @0: // label definition - -use crate::vinst::{AluImmOp, AluOp, IcmpCond, ModuleSymbols, SRC_OP_NONE, VInst, VReg, VRegSlice}; +//! +//! Hardware float (the `F*` family): +//! i2 = FAdd i0, i1 // FAluRRR: FAdd / FSub / FMul +//! i2 = FAbs i0 // FAluRR: FMov / FAbs / FNeg +//! i3 = Fcmp Lt, i0, i1 // IEEE compare, integer 0/1 result +//! i3 = FSelect i0, i1, i2 // cond, if_true, if_false +//! i1 = FLoad32 i0, 4 // base, offset (optional) +//! FStore32 i1, i0, 4 // src, base, offset +//! i1 = Wfr i0 // AR -> FR, bit-for-bit +//! i1 = Rfr i0 // FR -> AR, bit-for-bit +//! i1 = IToFS i0 // int -> float conversion (IToFS / IToFU) +//! +//! Every vreg is spelled `iN` regardless of register class. The class is not a +//! property of the vreg's *name* — it is read off the instruction (see +//! [`crate::regalloc::classes`]), and duplicating it in the text would create a +//! second source of truth that could disagree with the first. + +use crate::vinst::{ + AluImmOp, AluOp, FAluOp, FAluRROp, FcmpCond, IcmpCond, ModuleSymbols, SRC_OP_NONE, VInst, VReg, + VRegSlice, +}; use alloc::format; use alloc::string::String; use alloc::vec; @@ -453,6 +472,112 @@ fn parse_def_instruction( }) } + // ── Hardware float ─────────────────────────────────────────────────── + _ if FAluOp::from_mnemonic(op).is_some() => { + let fop = FAluOp::from_mnemonic(op).unwrap(); + let args = parse_two_regs(&args_str, op, line_num)?; + expect_one_dst(&dsts, op, line_num)?; + Ok(VInst::FAluRRR { + op: fop, + dst: dsts[0], + src1: args.0, + src2: args.1, + src_op: SRC_OP_NONE, + }) + } + + _ if FAluRROp::from_mnemonic(op).is_some() => { + let fop = FAluRROp::from_mnemonic(op).unwrap(); + expect_one_dst(&dsts, op, line_num)?; + Ok(VInst::FAluRR { + op: fop, + dst: dsts[0], + src: parse_ireg(args_str.trim())?, + src_op: SRC_OP_NONE, + }) + } + + "Fcmp" => { + expect_one_dst(&dsts, op, line_num)?; + // Format: Lt, i0, i1 + let parts: Vec<&str> = args_str.split(',').map(|s| s.trim()).collect(); + if parts.len() != 3 { + return Err(ParseError { + line: line_num, + message: "Fcmp needs 'Lt, i0, i1'".into(), + }); + } + let cond = FcmpCond::from_mnemonic(parts[0]).ok_or_else(|| ParseError { + line: line_num, + message: format!("Unknown float cond: {}", parts[0]), + })?; + Ok(VInst::Fcmp { + dst: dsts[0], + lhs: parse_ireg(parts[1])?, + rhs: parse_ireg(parts[2])?, + cond, + src_op: SRC_OP_NONE, + }) + } + + "FSelect" => { + expect_one_dst(&dsts, op, line_num)?; + let args = parse_args(&args_str)?; + if args.len() != 3 { + return Err(ParseError { + line: line_num, + message: "FSelect needs 3 args".into(), + }); + } + Ok(VInst::FSelect { + dst: dsts[0], + cond: args[0], + if_true: args[1], + if_false: args[2], + src_op: SRC_OP_NONE, + }) + } + + "FLoad32" => { + expect_one_dst(&dsts, op, line_num)?; + let (base, offset) = parse_base_offset(&args_str, line_num)?; + Ok(VInst::FLoad32 { + dst: dsts[0], + base, + offset, + src_op: SRC_OP_NONE, + }) + } + + "Wfr" | "Rfr" => { + expect_one_dst(&dsts, op, line_num)?; + let dst = dsts[0]; + let src = parse_ireg(args_str.trim())?; + Ok(if op == "Wfr" { + VInst::Wfr { + dst, + src, + src_op: SRC_OP_NONE, + } + } else { + VInst::Rfr { + dst, + src, + src_op: SRC_OP_NONE, + } + }) + } + + "IToFS" | "IToFU" => { + expect_one_dst(&dsts, op, line_num)?; + Ok(VInst::IToF { + dst: dsts[0], + src: parse_ireg(args_str.trim())?, + signed: op == "IToFS", + src_op: SRC_OP_NONE, + }) + } + _ => Err(ParseError { line: line_num, message: format!("Unknown instruction: {op}"), @@ -460,6 +585,43 @@ fn parse_def_instruction( } } +fn expect_one_dst(dsts: &[VReg], op: &str, line_num: usize) -> Result<(), ParseError> { + if dsts.len() == 1 { + Ok(()) + } else { + Err(ParseError { + line: line_num, + message: format!("{op} needs 1 dst"), + }) + } +} + +fn parse_two_regs(args_str: &str, op: &str, line_num: usize) -> Result<(VReg, VReg), ParseError> { + let parts: Vec<&str> = args_str.split(',').map(|s| s.trim()).collect(); + if parts.len() != 2 { + return Err(ParseError { + line: line_num, + message: format!("{op} needs 2 args"), + }); + } + Ok((parse_ireg(parts[0])?, parse_ireg(parts[1])?)) +} + +/// `i0, 4` or bare `i0` (offset defaults to 0) — the shared load/store tail. +fn parse_base_offset(args_str: &str, line_num: usize) -> Result<(VReg, i32), ParseError> { + let parts: Vec<&str> = args_str.split(',').map(|s| s.trim()).collect(); + let base = parse_ireg(parts[0])?; + let offset = if parts.len() > 1 { + parts[1].parse().map_err(|_| ParseError { + line: line_num, + message: format!("Invalid offset: {}", parts[1]), + })? + } else { + 0 + }; + Ok((base, offset)) +} + fn parse_nodef_instruction( line: &str, line_num: usize, @@ -486,6 +648,27 @@ fn parse_nodef_instruction( }); } + // FStore32 i1, i0, 4 — checked before Store32 so the prefix cannot shadow + // it (it does not today; `strip_prefix("Store32 ")` would not match + // "FStore32 ", but the ordering makes that independent of that fact). + if let Some(rest) = line.strip_prefix("FStore32 ") { + let parts: Vec<&str> = rest.split(',').map(|s| s.trim()).collect(); + if parts.len() < 2 { + return Err(ParseError { + line: line_num, + message: "FStore32 needs src, base[, offset]".into(), + }); + } + let src = parse_ireg(parts[0])?; + let (base, offset) = parse_base_offset(&parts[1..].join(","), line_num)?; + return Ok(VInst::FStore32 { + src, + base, + offset, + src_op: SRC_OP_NONE, + }); + } + // Store32 i1, i0, 4 if let Some(rest) = line.strip_prefix("Store32 ") { let parts: Vec<&str> = rest.split(',').map(|s| s.trim()).collect(); @@ -923,6 +1106,66 @@ fn format_vinst(inst: &VInst, pool: &[VReg], symbols: &ModuleSymbols) -> String format!("FuelCheck {}, @{}", ireg(vmctx), trap_label) } } + + // ── Hardware float ─────────────────────────────────────────────────── + VInst::FAluRRR { + op, + dst, + src1, + src2, + .. + } => format!( + "{} = {} {}, {}", + ireg(dst), + op.mnemonic(), + ireg(src1), + ireg(src2) + ), + VInst::FAluRR { op, dst, src, .. } => { + format!("{} = {} {}", ireg(dst), op.mnemonic(), ireg(src)) + } + VInst::Fcmp { + dst, + lhs, + rhs, + cond, + .. + } => format!( + "{} = Fcmp {}, {}, {}", + ireg(dst), + cond.mnemonic(), + ireg(lhs), + ireg(rhs) + ), + VInst::FSelect { + dst, + cond, + if_true, + if_false, + .. + } => format!( + "{} = FSelect {}, {}, {}", + ireg(dst), + ireg(cond), + ireg(if_true), + ireg(if_false) + ), + VInst::FLoad32 { + dst, base, offset, .. + } => format!("{} = FLoad32 {}, {}", ireg(dst), ireg(base), offset), + VInst::FStore32 { + src, base, offset, .. + } => format!("FStore32 {}, {}, {}", ireg(src), ireg(base), offset), + VInst::Wfr { dst, src, .. } => format!("{} = Wfr {}", ireg(dst), ireg(src)), + VInst::Rfr { dst, src, .. } => format!("{} = Rfr {}", ireg(dst), ireg(src)), + VInst::IToF { + dst, src, signed, .. + } => format!( + "{} = {} {}", + ireg(dst), + if *signed { "IToFS" } else { "IToFU" }, + ireg(src) + ), } } @@ -1126,4 +1369,74 @@ Ret i2 let (reparsed, _, _) = parse(&output).unwrap(); assert_eq!(vinsts.len(), reparsed.len()); } + + /// Every hardware-float variant survives writer → parser → writer + /// unchanged, and parses back to the *same* `VInst` value. + /// + /// Text round-trip is this phase's real oracle: nothing constructs these + /// variants until the f32 lowering lands, so a dropped field or a + /// transposed operand would otherwise sit undetected until it showed up as + /// a wrong pixel three phases later. Comparing the parsed values (not just + /// the strings) is what catches an operand swap — `Fcmp Lt, i1, i2` and + /// `Fcmp Lt, i2, i1` both round-trip textually. + #[test] + fn float_vinsts_round_trip_through_the_text_form() { + let lines = [ + "i0 = FAdd i1, i2", + "i0 = FSub i1, i2", + "i0 = FMul i1, i2", + "i0 = FMov i1", + "i0 = FAbs i1", + "i0 = FNeg i1", + "i0 = Fcmp Eq, i1, i2", + "i0 = Fcmp Ne, i1, i2", + "i0 = Fcmp Lt, i1, i2", + "i0 = Fcmp Le, i1, i2", + "i0 = Fcmp Gt, i1, i2", + "i0 = Fcmp Ge, i1, i2", + "i0 = FSelect i1, i2, i3", + "i0 = FLoad32 i1, 12", + "FStore32 i0, i1, 12", + "i0 = Wfr i1", + "i0 = Rfr i1", + "i0 = IToFS i1", + "i0 = IToFU i1", + ]; + for line in lines { + let (vinsts, syms, pool) = parse(line).unwrap_or_else(|e| panic!("{line}: {e:?}")); + assert_eq!(vinsts.len(), 1, "{line}"); + let text = format(&vinsts, &pool, &syms); + assert_eq!(text, line, "writer disagrees with parser"); + let (reparsed, _, _) = parse(&text).unwrap(); + assert_eq!(reparsed, vinsts, "{line}: value changed across a round trip"); + } + } + + /// The float mnemonics must not be mistaken for their integer neighbours. + /// `FMov`/`FNeg` sit one letter away from `Mov`/`Neg`, and the parser + /// dispatches on a bare string. + #[test] + fn float_and_integer_mnemonics_do_not_alias() { + let (f, _, _) = parse("i0 = FNeg i1").unwrap(); + let (i, _, _) = parse("i0 = Neg i1").unwrap(); + assert!(matches!(f[0], VInst::FAluRR { .. })); + assert!(matches!(i[0], VInst::Neg { .. })); + + let (fs, _, _) = parse("FStore32 i0, i1, 4").unwrap(); + let (is, _, _) = parse("Store32 i0, i1, 4").unwrap(); + assert!(matches!(fs[0], VInst::FStore32 { .. })); + assert!(matches!(is[0], VInst::Store32 { .. })); + } + + #[test] + fn float_parse_errors_are_errors() { + for bad in [ + "i0 = Fcmp Bogus, i1, i2", + "i0 = Fcmp i1, i2", + "i0 = FSelect i1, i2", + "i0 = FAdd i1", + ] { + assert!(parse(bad).is_err(), "{bad} parsed but should not have"); + } + } } diff --git a/lp-shader/lpvm-native/src/isa/mod.rs b/lp-shader/lpvm-native/src/isa/mod.rs index e3052c272..a9a662a49 100644 --- a/lp-shader/lpvm-native/src/isa/mod.rs +++ b/lp-shader/lpvm-native/src/isa/mod.rs @@ -88,15 +88,18 @@ impl IsaTarget { /// never satisfy a float constraint, and the same hardware encoding names /// two different registers in the two classes. /// - /// Neither backend has a float pool yet — hardware-FPU codegen is the - /// Xtensa f32 milestone and RV32F the rv32 one — so [`RegClass::Float`] - /// answers with an empty pool everywhere. An empty pool is not a silent - /// fallback: a float vreg reaching the allocator on such a target fails - /// with [`AllocError::OutOfRegisters`] rather than landing in a GPR. + /// Only one backend has a float pool: Xtensa, and only when `float-f32` is + /// enabled. rv32's f32 path is soft float, which keeps every value in + /// integer registers (see [`F32Lowering::SoftFloatCalls`]), so an empty + /// float pool is the *correct* answer there rather than a missing feature. + /// + /// An empty pool is not a silent fallback: a float vreg reaching the + /// allocator on such a target fails with [`AllocError::OutOfRegisters`] + /// rather than landing in a GPR and being read back as an integer. /// /// Note that a Q32 shader has no float vregs at all — a Q16.16 `float` is - /// an integer and lowers to integer instructions — so this is the honest - /// answer for the fixed-point path, not a placeholder for it. + /// an integer and lowers to integer instructions — so the empty pool is + /// also the honest answer for the fixed-point path, not a placeholder. pub fn allocatable_pool_order(self, class: RegClass) -> &'static [u8] { match class { RegClass::Int => match self { @@ -105,7 +108,14 @@ impl IsaTarget { #[cfg(feature = "isa-xt")] IsaTarget::Xtensa => crate::isa::xt::gpr::ALLOC_POOL, }, - RegClass::Float => &[], + RegClass::Float => match self { + #[cfg(feature = "isa-rv32")] + IsaTarget::Rv32imac => &[], + #[cfg(all(feature = "isa-xt", feature = "float-f32"))] + IsaTarget::Xtensa => crate::isa::xt::fpr::ALLOC_POOL, + #[cfg(all(feature = "isa-xt", not(feature = "float-f32")))] + IsaTarget::Xtensa => &[], + }, } } @@ -118,7 +128,14 @@ impl IsaTarget { #[cfg(feature = "isa-xt")] IsaTarget::Xtensa => crate::isa::xt::gpr::pool_contains(p.hw), }, - RegClass::Float => false, + RegClass::Float => match self { + #[cfg(feature = "isa-rv32")] + IsaTarget::Rv32imac => false, + #[cfg(all(feature = "isa-xt", feature = "float-f32"))] + IsaTarget::Xtensa => crate::isa::xt::fpr::pool_contains(p.hw), + #[cfg(all(feature = "isa-xt", not(feature = "float-f32")))] + IsaTarget::Xtensa => false, + }, } } @@ -131,10 +148,17 @@ impl IsaTarget { #[cfg(feature = "isa-xt")] IsaTarget::Xtensa => crate::isa::xt::gpr::reg_name(p.hw), }, - // No backend names float registers yet; the ISA-specific float - // tables arrive with their emitters. Rendering must not panic, so - // this stays a legible placeholder rather than an `unreachable!`. - RegClass::Float => "f?", + RegClass::Float => match self { + // Soft float never allocates a float register, so there is no + // name to give. Rendering must not panic, so this stays a + // legible placeholder rather than an `unreachable!`. + #[cfg(feature = "isa-rv32")] + IsaTarget::Rv32imac => "f?", + #[cfg(all(feature = "isa-xt", feature = "float-f32"))] + IsaTarget::Xtensa => crate::isa::xt::fpr::reg_name(p.hw), + #[cfg(all(feature = "isa-xt", not(feature = "float-f32")))] + IsaTarget::Xtensa => "f?", + }, } } @@ -328,7 +352,17 @@ impl IsaTarget { /// Per-class because a call clobbers each class's caller-saved bank /// independently: on RV32F `ft0..ft11` are clobbered by exactly the same /// call that clobbers `t0..t6`, and the allocator must evict from both. - /// Empty for [`RegClass::Float`] while the float pools are empty. + /// + /// Xtensa's float answer is the **whole** float pool, which is not + /// conservatism — M6-P4's static probe of `xtensa-esp32s3-elf-gcc` found + /// that no FR survives a `call8`, and that toolchain compiles the + /// `lps-builtins` f32 family this backend calls. Under-reporting here does + /// not produce a crash: it leaves a live float in a register the callee + /// overwrites, so the value is wrong only for inputs that happen to + /// straddle a call. + /// + /// Empty for [`RegClass::Float`] on rv32, where soft float keeps every + /// value in the integer bank and the integer answer already covers it. pub fn caller_saved_pool_hw(self, class: RegClass) -> &'static [u8] { match class { RegClass::Int => match self { @@ -337,7 +371,14 @@ impl IsaTarget { #[cfg(feature = "isa-xt")] IsaTarget::Xtensa => crate::isa::xt::gpr::CALLER_SAVED_POOL, }, - RegClass::Float => &[], + RegClass::Float => match self { + #[cfg(feature = "isa-rv32")] + IsaTarget::Rv32imac => &[], + #[cfg(all(feature = "isa-xt", feature = "float-f32"))] + IsaTarget::Xtensa => crate::isa::xt::fpr::CALLER_SAVED_POOL, + #[cfg(all(feature = "isa-xt", not(feature = "float-f32")))] + IsaTarget::Xtensa => &[], + }, } } @@ -351,10 +392,24 @@ impl IsaTarget { /// `a10..a15` **is** the caller-saved half of the pool, so it happens /// constantly. See `regalloc::walk::sequence_arg_moves`. /// - /// Integer-class today. A float move cycle needs a float scratch, and that - /// register is named by whichever backend first has float argument - /// registers to shuffle; this hook grows a `class` parameter then rather - /// than answering with a GPR that cannot hold the value. + /// **Integer-class permanently, on both float targets — decided, not + /// pending.** M4's version of this comment anticipated a `class` parameter + /// for a float move cycle; M7 establishes that it is not needed and this + /// hook does not grow one. + /// + /// A move cycle can only form among values staged in *argument* registers. + /// Neither float ABI in this crate puts a float there: Xtensa passes float + /// arguments in address registers as raw bit patterns (M7 D1) and rv32's + /// soft float never leaves the integer file at all. So a float vreg never + /// occupies an argument slot, never participates in an argument-move cycle, + /// and never needs a float scratch to break one. Adding the parameter + /// anyway would mean naming a float scratch register — and reserving one + /// costs a register for the life of the backend (M7 D8 declines to reserve + /// any). + /// + /// This changes only if a target arrives whose ABI passes floats in the + /// float file — RV32F's `fa0..fa7`, say. That target adds the parameter + /// *and* a scratch FR in the same change. pub fn move_cycle_scratch(self) -> PReg { PReg::int(match self { #[cfg(feature = "isa-rv32")] @@ -372,8 +427,17 @@ impl IsaTarget { /// Per-class because the return bank is per-class in every float ABI that /// matters: RV32F returns a float in `fa0`, not `a0`, so an f32-returning /// call constrains its def to a different register file than an - /// i32-returning one. `None` for [`RegClass::Float`] until a backend has - /// one. + /// i32-returning one. + /// + /// **`None` for [`RegClass::Float`] on every target here, and that is + /// settled rather than pending** (M7 D3). Neither float ABI in this crate + /// returns a value in the float file: rv32's soft float returns a `float` + /// in `a0` by construction, and Xtensa returns the raw IEEE bit pattern in + /// an address register because the esp toolchain that compiles our float + /// builtins does (M6-P4's measured probe). Lowering therefore emits an + /// explicit [`crate::vinst::VInst::Wfr`] after a float-returning call, so + /// the call's own def really is integer-class and this hook is asked the + /// integer question. pub fn direct_ret_reg(self, class: RegClass, idx: usize) -> Option { match class { RegClass::Int => match self { @@ -393,6 +457,9 @@ impl IsaTarget { /// Count of direct return registers of `class` in the hardware ABI /// (e.g. 2 for RV32 a0–a1). + /// + /// Zero for [`RegClass::Float`] for the reason spelled out on + /// [`Self::direct_ret_reg`]: no float return bank exists on either target. pub fn direct_ret_reg_count(self, class: RegClass) -> usize { match class { RegClass::Int => match self { @@ -410,6 +477,12 @@ impl IsaTarget { /// Per-class for the same reason as [`Self::direct_ret_reg`]: a hard-float /// ABI passes float arguments in the float file, and the two banks are /// indexed independently. + /// + /// **`None` for [`RegClass::Float`] by decision** (M7 D3) — the mirror of + /// [`Self::direct_ret_reg`]'s note. Float parameters arrive in address + /// registers and lowering emits a [`crate::vinst::VInst::Wfr`] at function + /// entry to move each into the float file, so the parameter vreg this hook + /// precolors is integer-class. pub fn call_arg_reg(self, class: RegClass, idx: usize) -> Option { match class { RegClass::Int => match self { @@ -425,6 +498,8 @@ impl IsaTarget { } /// Number of argument registers of `class` in the hardware calling convention. + /// + /// Zero for [`RegClass::Float`]; see [`Self::call_arg_reg`]. pub fn call_arg_reg_count(self, class: RegClass) -> usize { match class { RegClass::Int => match self { @@ -474,11 +549,17 @@ impl IsaTarget { /// (RV32 `a0`–`a7`), or `None` when the operand is stack-passed. /// /// `class` is the class of the *operand*, and it selects the register bank: - /// a hard-float ABI stages a float argument in the float file. Answering - /// `None` for [`RegClass::Float`] is what makes an unimplemented float - /// argument fall into the stack-pass path's error rather than into a GPR; - /// the slot arithmetic below (the sret/vmctx shuffles) is class-independent - /// and is reused as-is when the float banks land. + /// a hard-float ABI stages a float argument in the float file. + /// + /// **`None` for [`RegClass::Float`] permanently on both targets** (M7 D3), + /// not as a stub. Lowering emits a [`crate::vinst::VInst::Rfr`] before each + /// float call argument, so a `Call`'s `args` slice is entirely + /// integer-class by the time the allocator reads it and this early return + /// is unreachable in well-formed output. It stays as a hard floor: a float + /// vreg that somehow reached an argument slot must fail the stack-pass + /// path loudly rather than be staged into a GPR and passed as an integer. + /// The slot arithmetic below (the sret/vmctx shuffles) is class-independent + /// and would be reused as-is if a float argument bank ever landed. pub fn lpir_call_arg_target( self, class: RegClass, @@ -742,17 +823,71 @@ mod tests { ); } - /// Soft float keeps every value integer-class, so the float pools stay - /// empty. A float vreg reaching the allocator would then fail loudly with + /// Soft float keeps every value integer-class, so rv32's float pool stays + /// empty. A float vreg reaching the allocator there fails loudly with /// `OutOfRegisters` rather than quietly landing an f32 bit pattern in a GPR /// that some later pass treats as an integer. + #[cfg(feature = "isa-rv32")] #[test] - fn no_target_has_a_float_register_pool() { - for isa in every_isa() { - assert!(isa.allocatable_pool_order(RegClass::Float).is_empty()); + fn rv32_has_no_float_register_pool() { + let isa = IsaTarget::Rv32imac; + assert!(isa.allocatable_pool_order(RegClass::Float).is_empty()); + assert!(isa.caller_saved_pool_hw(RegClass::Float).is_empty()); + assert!(!isa.is_in_allocatable_pool(PReg::float(0))); + } + + /// The float pool is the *feature's* observable footprint in the register + /// model: 16 FRs when `float-f32` is on, nothing at all when it is off. + /// The off case is the gate's whole claim (M7 D9) — a leak here would mean + /// a Fixed-only image carries the float allocator. + #[cfg(feature = "isa-xt")] + #[test] + fn xtensa_float_pool_follows_the_feature() { + let isa = IsaTarget::Xtensa; + let pool = isa.allocatable_pool_order(RegClass::Float); + if cfg!(feature = "float-f32") { + assert_eq!(pool.len(), 16, "all 16 FRs are allocatable (M7 D8)"); + // Every FR is call-clobbered — the measured esp-toolchain ABI. + assert_eq!(isa.caller_saved_pool_hw(RegClass::Float), pool); + assert!(isa.is_in_allocatable_pool(PReg::float(15))); + assert_eq!(isa.reg_name(PReg::float(3)), "f3"); + } else { + assert!(pool.is_empty()); assert!(isa.caller_saved_pool_hw(RegClass::Float).is_empty()); + assert!(!isa.is_in_allocatable_pool(PReg::float(0))); + } + } + + /// The same hardware index in the two classes must not be confused: `f3` + /// and `a3` are different registers, and `reg_name` is what a spill trace + /// or an alloc dump shows a human debugging a wrong pixel. + #[cfg(all(feature = "isa-xt", feature = "float-f32"))] + #[test] + fn float_and_int_register_names_do_not_collide() { + let isa = IsaTarget::Xtensa; + for hw in 0..16u8 { + assert_ne!(isa.reg_name(PReg::int(hw)), isa.reg_name(PReg::float(hw))); + } + } + + /// No target has a float **ABI** bank, and unlike the pool this is not + /// waiting on a feature — it is M7 D3. Float values cross every call + /// boundary in address registers on both float targets, so a float vreg + /// never occupies an argument or return slot. If one of these ever answers + /// non-zero, the `Rfr`/`Wfr` transfers lowering inserts have become + /// redundant and the calling convention changed. + #[test] + fn no_target_has_a_float_abi_bank() { + for isa in every_isa() { assert_eq!(isa.direct_ret_reg_count(RegClass::Float), 0); assert_eq!(isa.call_arg_reg_count(RegClass::Float), 0); + assert_eq!(isa.direct_ret_reg(RegClass::Float, 0), None); + assert_eq!(isa.call_arg_reg(RegClass::Float, 0), None); + assert_eq!( + isa.lpir_call_arg_target(RegClass::Float, false, false, false, 0), + None + ); + assert_eq!(isa.move_cycle_scratch().class, RegClass::Int); } } } diff --git a/lp-shader/lpvm-native/src/isa/rv32/emit.rs b/lp-shader/lpvm-native/src/isa/rv32/emit.rs index 0598caca7..e653e9ce8 100644 --- a/lp-shader/lpvm-native/src/isa/rv32/emit.rs +++ b/lp-shader/lpvm-native/src/isa/rv32/emit.rs @@ -924,6 +924,32 @@ impl<'a> EmitContext<'a> { self.push_u32(encode_sw(t0, rv, fuel_off), src_op); } } + // Hardware float. `Rv32imac` names a part with **no F extension**, + // so these are not "not implemented yet" here — they are + // unreachable by construction: `IsaTarget::f32_lowering` answers + // `SoftFloatCalls` for this target, and that lowering emits only + // integer instructions. Reaching this arm means the float-capability + // seam was bypassed, and the hardware would take an + // illegal-instruction trap on the first frame if we guessed an + // encoding. An RV32F variant of `IsaTarget` supplies real arms here. + VInst::FAluRRR { .. } + | VInst::FAluRR { .. } + | VInst::Fcmp { .. } + | VInst::FSelect { .. } + | VInst::FLoad32 { .. } + | VInst::FStore32 { .. } + | VInst::Wfr { .. } + | VInst::Rfr { .. } + | VInst::IToF { .. } => { + // The message-less form on purpose. `emit_err!` with any + // message allocates a `String` through `format!` (**+512 B + // measured on the C6 image**), and adding `vinst.mnemonic()` to + // it drags the whole mnemonic table in on top (**a further + // +736 B**) — 1,248 B for an arm that is unreachable in a + // Fixed-only build. The file:line the macro captures is enough + // to find this comment. + return Err(crate::emit_err!()); + } } Ok(()) } diff --git a/lp-shader/lpvm-native/src/isa/xt/abi.rs b/lp-shader/lpvm-native/src/isa/xt/abi.rs index 9651283a3..599a1004c 100644 --- a/lp-shader/lpvm-native/src/isa/xt/abi.rs +++ b/lp-shader/lpvm-native/src/isa/xt/abi.rs @@ -92,6 +92,38 @@ pub fn alloca_base_int() -> PregSet { ])) } +/// Caller-saved FRs for clobber sets — **all 16** (M6-P4: no FR survives a +/// `call8` under the esp toolchain that compiles our float builtins). +/// +/// The `_float` sibling of [`caller_saved_int`]. There is deliberately no +/// `callee_saved_float`: the empty set has no callers, and writing one would +/// suggest an FP callee-save frame region exists. It does not (M7 D7) — which +/// is exactly why [`FRAME_TOP_RESERVED_BYTES`] and `FrameLayout::compute` are +/// unchanged by float support. +#[cfg(feature = "float-f32")] +pub fn caller_saved_float() -> PregSet { + float_set(super::fpr::CALLER_SAVED_POOL) +} + +/// Base allocatable float set: the whole FR file (M7 D8 reserves no scratch). +/// +/// Unlike [`alloca_base_int`] there is no sret adjustment to make — the sret +/// pointer is an address, and addresses are never float. +#[cfg(feature = "float-f32")] +pub fn alloca_base_float() -> PregSet { + float_set(super::fpr::ALLOC_POOL) +} + +/// Build a [`PregSet`] over the float lanes from raw FR indices. +#[cfg(feature = "float-f32")] +fn float_set(regs: &[u8]) -> PregSet { + let mut s = PregSet::EMPTY; + for &r in regs { + s.insert(PReg::float(r)); + } + s +} + /// Direct-return width: more than 2 scalar return words go through an sret /// buffer. Same value as rv32 deliberately — keeps LPIR-level return /// classification target-invariant (the windowed ABI would permit 4, but @@ -241,11 +273,26 @@ pub fn func_abi_xt(sig: &LpsFnSig, func: Option<&IrFunction>) -> crate::abi::Fun }; let mut allocatable = alloca_base_int(); + // The two classes are independent lanes of the same set, so the float file + // joins the pool by union and needs no sret adjustment of its own — the + // sret pointer is an address. + #[cfg(feature = "float-f32")] + { + allocatable = allocatable.union(alloca_base_float()); + } if is_sret { // The sret pointer lives in a2 for the whole function. allocatable.remove(A2); } + // Every FR is clobbered by a call (M6-P4). The clobber set is what makes + // the allocator evict live floats around a call, so omitting the float + // lanes here would leave a value in a register the callee overwrites. + #[cfg(feature = "float-f32")] + let caller_saved = caller_saved_int().union(caller_saved_float()); + #[cfg(not(feature = "float-f32"))] + let caller_saved = caller_saved_int(); + let total_param_slots = match func { Some(f) => f.total_param_slots() as usize, None => entry_param_scalar_count(sig), @@ -257,7 +304,9 @@ pub fn func_abi_xt(sig: &LpsFnSig, func: Option<&IrFunction>) -> crate::abi::Fun return_method, allocatable, precolors, - caller_saved_int(), + caller_saved, + // No float lane here, deliberately: no FR is callee-saved, which is + // what removes the FP callee-save frame region entirely (M7 D7). callee_saved_int(), crate::isa::IsaTarget::Xtensa, ) diff --git a/lp-shader/lpvm-native/src/isa/xt/emit.rs b/lp-shader/lpvm-native/src/isa/xt/emit.rs index dea3e2d2e..c4708177e 100644 --- a/lp-shader/lpvm-native/src/isa/xt/emit.rs +++ b/lp-shader/lpvm-native/src/isa/xt/emit.rs @@ -1355,6 +1355,26 @@ impl<'a> EmitContext<'a> { } => { self.emit_fuel_check(output, inst_idx, *decrement, *trap_label, src_op)?; } + // Hardware float. The FP emitter is roadmap M7 P3; until it lands, + // a float VInst reaching here means lowering produced instructions + // this emitter cannot encode, and the only safe answer is to refuse + // loudly. Erroring rather than skipping matters: a silently dropped + // FP instruction leaves the destination holding whatever was there + // before and renders a plausible wrong frame. + VInst::FAluRRR { .. } + | VInst::FAluRR { .. } + | VInst::Fcmp { .. } + | VInst::FSelect { .. } + | VInst::FLoad32 { .. } + | VInst::FStore32 { .. } + | VInst::Wfr { .. } + | VInst::Rfr { .. } + | VInst::IToF { .. } => { + return Err(crate::emit_err!( + "xt emitter: no FP instruction encoding yet for {} (roadmap M7 P3)", + vinst.mnemonic() + )); + } } Ok(()) } diff --git a/lp-shader/lpvm-native/src/isa/xt/fpr.rs b/lp-shader/lpvm-native/src/isa/xt/fpr.rs new file mode 100644 index 000000000..b42078e65 --- /dev/null +++ b/lp-shader/lpvm-native/src/isa/xt/fpr.rs @@ -0,0 +1,180 @@ +//! FR index helpers for Xtensa float emission (`f0`–`f15`) — the register +//! model for the ESP32-S3's Floating-Point Coprocessor. +//! +//! Sibling of [`gpr`](super::gpr), and deliberately shaped the same way, but +//! the two files describe very different hardware. +//! +//! # The FR file is flat +//! +//! `gpr.rs` spends most of its length on the register *window*: a call rotates +//! the address-register file, so the caller and the callee name the same +//! physical register differently, and `a2..a7` are preserved across a call for +//! free. **None of that has an FR analogue.** The Floating-Point Coprocessor +//! Option (Xtensa ISA Reference Manual §4.3.11, p. 67) adds a flat 16-entry +//! register file that `ENTRY`/`RETW` do not touch. So there is no caller view, +//! no callee view, and no free preservation. +//! +//! # No FR is callee-saved +//! +//! Measured, not assumed: M6-P4's static ABI probe over +//! `xtensa-esp32s3-elf-gcc 14.2.0` at `-O3` found that **every** FR is +//! call-clobbered — an FR live across a `call8` is spilled by the caller with +//! an integer `s32i.n` and reloaded with `lsi`, and no FR is saved or restored +//! by any callee. That toolchain compiles the `lps-builtins` f32 family this +//! backend calls, so its convention is the one we must interoperate with, not +//! one we get to pick. +//! +//! Two consequences the rest of the backend depends on: +//! +//! - [`CALLER_SAVED_POOL`] is the whole of [`ALLOC_POOL`], so the allocator +//! evicts every live float across a call. +//! - There is **no FP callee-save frame region** (M7 D7). `FrameLayout::compute` +//! and [`FRAME_TOP_RESERVED_BYTES`](super::abi::FRAME_TOP_RESERVED_BYTES) are +//! unchanged by float support; float spills land in the existing spill region +//! at the *bottom* of the frame, structurally distant from the +//! window-overflow reservation at the top. +//! +//! # No FR is reserved as scratch +//! +//! Unlike `gpr.rs`, which holds back `a8`/`a9`, all 16 FRs are allocatable +//! (M7 D8). Nothing in the emitted subset needs a float scratch: spill and +//! reload are a direct `ssi`/`lsi` pair, and the two sequences that do need a +//! scratch — the out-of-range spill offset and the compare's 0/1 +//! materialization — need an *address* register, which is what `a8`/`a9` +//! already are. A reservation costs a register for the life of the backend, so +//! it is not made speculatively; a future inline divide sequence that needs +//! scratch FRs carves them out of [`ALLOC_POOL`], which is the single place to +//! do it. + +/// Physical FR index (`f0`–`f15`). +pub type FReg = u8; + +/// Number of floating-point registers the coprocessor provides. +pub const FR_COUNT: u8 = 16; + +/// Registers available to the allocator — **all 16** (M7 D8). +/// +/// Order is the LRU initialization order, high to low. There is no +/// caller-saved / callee-saved split to order around (every FR is clobbered by +/// a call), and no FR carries an ABI role, so the order carries no meaning +/// beyond determinism: `f15` first keeps the low-numbered registers free +/// longest, matching `gpr::ALLOC_POOL`'s habit and making the two files' dumps +/// read alike. +pub const ALLOC_POOL: &[FReg] = &[15, 14, 13, 12, 11, 10, 9, 8, 7, 6, 5, 4, 3, 2, 1, 0]; + +/// Pool members clobbered by a call — **all of them**. +/// +/// Not a conservative default: the esp toolchain preserves no FR across a +/// `call8` (M6-P4's measured probe), so a float value live across a call must +/// be spilled by the caller. Being wrong in the other direction — claiming an +/// FR survives when it does not — silently corrupts a live value across every +/// builtin call. +pub const CALLER_SAVED_POOL: &[FReg] = ALLOC_POOL; + +/// The single Boolean register FP compares write (`b0`). +/// +/// FP compares do not produce an address-register 0/1; they set a bit in the +/// Boolean register file (ISA RM §4.3.10, p. 65 — the Boolean Option is a +/// prerequisite of the FP Coprocessor Option). M7 uses one fixed BR as an +/// implicit scratch and materializes the result into an AR with `movt`/`movf` +/// (ISA RM pp. 471, 479) inside the same emitted sequence, so the allocator +/// never learns that Boolean registers exist (M7 D5, Q1). +/// +/// **Invariant: no Boolean register is live across a `VInst` boundary.** That +/// is what makes a single fixed `b0` safe rather than a source of aliasing — +/// see the FP emitter's module doc. +pub const CMP_BREG: u8 = 0; + +/// Name for debugging / text format. +pub fn reg_name(reg: FReg) -> &'static str { + match reg { + 0 => "f0", + 1 => "f1", + 2 => "f2", + 3 => "f3", + 4 => "f4", + 5 => "f5", + 6 => "f6", + 7 => "f7", + 8 => "f8", + 9 => "f9", + 10 => "f10", + 11 => "f11", + 12 => "f12", + 13 => "f13", + 14 => "f14", + 15 => "f15", + _ => "???", + } +} + +/// Parse a register name (`f0`–`f15`) to its physical index. +#[allow( + clippy::result_unit_err, + reason = "gpr.rs shape parity: same signature as isa/xt/gpr.rs::parse_reg" +)] +pub fn parse_reg(name: &str) -> Result { + let digits = name.strip_prefix('f').ok_or(())?; + // Reject `f00`, `f+1` and similar spellings that `parse` would accept or + // that would not round-trip through `reg_name`. + if digits.is_empty() || (digits.len() > 1 && digits.starts_with('0')) { + return Err(()); + } + match digits.parse::() { + Ok(n) if n < FR_COUNT => Ok(n), + _ => Err(()), + } +} + +#[inline] +pub fn pool_contains(r: FReg) -> bool { + ALLOC_POOL.contains(&r) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn reg_name_round_trips() { + for i in 0..FR_COUNT { + assert_eq!(parse_reg(reg_name(i)), Ok(i), "round trip failed for f{i}"); + } + } + + #[test] + fn parse_reg_rejects_non_registers() { + for bad in ["f16", "f-1", "f", "a0", "", "f00", "F0"] { + assert_eq!(parse_reg(bad), Err(()), "{bad} parsed as a register"); + } + } + + /// All 16, no duplicates, nothing held back (D8). A reservation creeping in + /// later would show up here rather than as a mysteriously smaller pool. + #[test] + fn every_fr_is_allocatable() { + assert_eq!(ALLOC_POOL.len(), FR_COUNT as usize); + for r in 0..FR_COUNT { + assert!(pool_contains(r), "f{r} missing from the pool"); + } + for (i, &f) in ALLOC_POOL.iter().enumerate() { + assert!(!ALLOC_POOL[i + 1..].contains(&f), "duplicate f{f}"); + } + } + + /// The measured ABI fact, pinned: no FR survives a call, so the caller-saved + /// set is the entire pool. If this ever narrows, the frame story in + /// `abi.rs` (no FP callee-save region) narrows with it. + #[test] + fn every_fr_is_caller_saved() { + assert_eq!(CALLER_SAVED_POOL, ALLOC_POOL); + } + + /// `b0` is an implicit scratch, not an allocatable resource. It shares the + /// numeric space with `f0`, which is exactly the confusion the separate + /// constant and this assertion exist to prevent. + #[test] + fn cmp_breg_is_b0() { + assert_eq!(CMP_BREG, 0); + } +} diff --git a/lp-shader/lpvm-native/src/isa/xt/imm.rs b/lp-shader/lpvm-native/src/isa/xt/imm.rs index 639257eca..9df5883b8 100644 --- a/lp-shader/lpvm-native/src/isa/xt/imm.rs +++ b/lp-shader/lpvm-native/src/isa/xt/imm.rs @@ -235,6 +235,26 @@ pub enum ImmOp { /// `(PC & !3) + 4`, multiple of 4 (signed 18-bit *word* offset; target is /// always 4-aligned). CallDisp, + /// `lsi`/`ssi ft, as, off` — the **float** load/store offset: unsigned + /// 0..=1020, multiple of 4 (ISA RM, *Load Single Immediate*, p. 399 — the + /// `imm8` field holds `off / 4`). + /// + /// Numerically identical to [`ImmOp::L32i`]/[`ImmOp::S32i`], and a separate + /// entry anyway, for two reasons. It is the offset of a *different* + /// instruction on a different register file, so the two ranges are free to + /// diverge without this table lying; and, more urgently, it is the entry + /// that gates a known silent-corruption hazard. + /// + /// **The hazard:** `lp_xt_inst`'s encoder computes the field as + /// `(offset / 4) & 0xff` with no range check. A float spill slot at offset + /// 1024 therefore encodes as field 0 — it stores to `[base + 0]`, a + /// perfectly valid address holding some other live value, with no + /// diagnostic anywhere. Nothing downstream can distinguish that from a + /// correct spill. Every float spill offset must be gated through + /// [`is_legal`] before it reaches the encoder; past the limit, the emitter + /// takes [`Fallback::AddressScratch`] (compute `base + offset` into `a8` + /// and use offset 0) rather than truncating. + FpLsiOffset, } impl ImmOp { @@ -274,6 +294,7 @@ impl ImmOp { ImmOp::BranchB4Constu, ImmOp::JDisp, ImmOp::CallDisp, + ImmOp::FpLsiOffset, ]; } @@ -325,6 +346,9 @@ pub const fn spec(op: ImmOp) -> ImmSpec { } ImmOp::L32i | ImmOp::S32i => plain(scaled(0, 1020, 4), false, Fallback::AddressScratch), ImmOp::L32iN | ImmOp::S32iN => plain(scaled(0, 60, 4), false, Fallback::WideForm), + // -- float load/store offset (`lsi`/`ssi`); see the variant's doc for + // why the encoder's missing range check makes this load-bearing -- + ImmOp::FpLsiOffset => plain(scaled(0, 1020, 4), false, Fallback::AddressScratch), // -- literal pool -- ImmOp::L32rDisp => pcrel( scaled(-262144, -4, 4), diff --git a/lp-shader/lpvm-native/src/isa/xt/mod.rs b/lp-shader/lpvm-native/src/isa/xt/mod.rs index b7e090c06..4d6bf553c 100644 --- a/lp-shader/lpvm-native/src/isa/xt/mod.rs +++ b/lp-shader/lpvm-native/src/isa/xt/mod.rs @@ -8,6 +8,13 @@ pub mod abi; pub mod emit; +// The float register model. Gated at module granularity (M7 D9) so a +// Fixed-only image links no float tables; the `VInst` float variants +// themselves are unconditional, because `cfg` on enum variants matched +// exhaustively across five helpers and the text ser/de costs more than the +// residual it saves. +#[cfg(feature = "float-f32")] +pub mod fpr; pub mod gpr; pub mod imm; pub mod link; diff --git a/lp-shader/lpvm-native/src/lower_f32.rs b/lp-shader/lpvm-native/src/lower_f32.rs index c59dda5dc..d7c3215b2 100644 --- a/lp-shader/lpvm-native/src/lower_f32.rs +++ b/lp-shader/lpvm-native/src/lower_f32.rs @@ -966,9 +966,7 @@ mod tests { &mut temps, ) .expect_err("Xtensa has no f32 backend yet"); - let LowerError::UnsupportedOp { description } = err else { - panic!("expected UnsupportedOp"); - }; + let LowerError::UnsupportedOp { description } = err; assert!(description.contains("Xtensa"), "{description}"); assert!(out.is_empty(), "a refused op must emit nothing"); } diff --git a/lp-shader/lpvm-native/src/regalloc/classes.rs b/lp-shader/lpvm-native/src/regalloc/classes.rs index 43cdd2ae8..d7cba9d88 100644 --- a/lp-shader/lpvm-native/src/regalloc/classes.rs +++ b/lp-shader/lpvm-native/src/regalloc/classes.rs @@ -18,10 +18,23 @@ //! decision instead of re-deriving it, which is why the allocator never has to //! know which float mode it is running under. //! -//! Every instruction the backend emits today is integer, so every operand is -//! [`RegClass::Int`] — including every `f32` in a Q32 shader, which is the -//! correct answer and not a placeholder. The float arms arrive with the float -//! VInsts. +//! Every integer instruction the backend emits answers [`RegClass::Int`] for +//! every operand — including every `f32` in a Q32 shader, which is the correct +//! answer and not a placeholder. Only the hardware-float `VInst`s introduced by +//! M7 (`FAluRRR`, `Wfr`, …) put anything in [`RegClass::Float`], and only some +//! of *their* operands: the transfers and the comparison are deliberately +//! mixed-class, because that is what the calling convention is made of. +//! +//! # `Call` is Int-only, on purpose +//! +//! There are no float arms for [`VInst::Call`] or [`VInst::Ret`], and that is a +//! decision rather than an omission (M7 D1/D2). Float values travel across +//! every call and return boundary in **address registers**, as raw IEEE bit +//! patterns, because the toolchain that compiles the float builtins we call +//! does exactly that. Lowering inserts explicit [`VInst::Rfr`]/[`VInst::Wfr`] +//! transfers at those four boundaries, so by the time a `Call` is built its +//! operands really are integers, and the ABI is legible in the VInst dump +//! rather than implied by a table here. use alloc::vec::Vec; @@ -33,14 +46,104 @@ use crate::vinst::{VInst, VReg}; /// `def_idx` counts defs in [`VInst::for_each_def`] order. It is a parameter /// rather than an instruction-wide answer because a single call can return /// values of mixed class once float returns exist. +#[cfg(feature = "float-f32")] pub fn def_class(inst: &VInst, def_idx: usize) -> RegClass { - let _ = (inst, def_idx); - RegClass::Int + let _ = def_idx; + match inst { + // The float file is where the value lands. + VInst::FAluRRR { .. } + | VInst::FAluRR { .. } + | VInst::FSelect { .. } + | VInst::FLoad32 { .. } + | VInst::IToF { .. } + // `Wfr` is the AR → FR half of the boundary transfer: its *result* is + // the float. + | VInst::Wfr { .. } => RegClass::Float, + + // `Fcmp` yields an ordinary 0/1 integer, so its consumers (`BrIf`, + // `Select`, integer arithmetic) need no float awareness at all. On + // Xtensa the comparison writes a Boolean register and the emitter + // materializes the 0/1 into an AR inside the same sequence (M7 D5). + VInst::Fcmp { .. } + // `Rfr` is the FR → AR half: its result is the integer bit pattern. + | VInst::Rfr { .. } => RegClass::Int, + + _ => RegClass::Int, + } } /// Register class the `use_idx`-th operand of `inst` must be supplied in. /// -/// `use_idx` counts uses in [`VInst::for_each_use`] order. +/// `use_idx` counts uses in [`VInst::for_each_use`] order — the same order +/// [`VInst::for_each_use`] visits them, which is why the mixed-class +/// instructions below index on it rather than answering instruction-wide. +#[cfg(feature = "float-f32")] +pub fn use_class(inst: &VInst, use_idx: usize) -> RegClass { + match inst { + // Both operands come out of the float file. + VInst::FAluRRR { .. } | VInst::Fcmp { .. } => RegClass::Float, + VInst::FAluRR { .. } => RegClass::Float, + + // `cond` is an integer 0/1 (typically an `Fcmp` or `Icmp` result); the + // two candidate values are floats. Use order is (cond, if_true, + // if_false). + VInst::FSelect { .. } => { + if use_idx == 0 { + RegClass::Int + } else { + RegClass::Float + } + } + + // Addresses are always integers; only the loaded/stored value is float. + // Use order for `FStore32` is (src, base). + VInst::FLoad32 { .. } => RegClass::Int, + VInst::FStore32 { .. } => { + if use_idx == 0 { + RegClass::Float + } else { + RegClass::Int + } + } + + // The boundary transfers, each reading the file the other one writes. + // Claiming the wrong one here is the exact silent bit-reinterpretation + // `verify::verify_operand_classes` was built to catch. + VInst::Wfr { .. } => RegClass::Int, + VInst::Rfr { .. } => RegClass::Float, + + // Integer → float conversion reads an integer. + VInst::IToF { .. } => RegClass::Int, + + _ => RegClass::Int, + } +} + +/// Without `float-f32` there is no float lowering linked, so no float `VInst` +/// can be constructed and the answer is [`RegClass::Int`] for every operand of +/// every instruction — **as a constant, not as a match that happens to return +/// one every time**. +/// +/// That distinction is the whole reason these two functions are gated rather +/// than left as one implementation. A constant lets the optimizer delete the +/// call, then [`VRegClasses`]'s table, then the per-class pool machinery that +/// consumes it. A match over `&VInst` does not: LLVM cannot prove the float +/// arms are unreachable, so the class-aware allocator becomes live code in a +/// Fixed-only image. **Measured: +496 B on the ESP32-C6 image** when these were +/// left ungated. +/// +/// This is the same shape as [`crate::lower::builtin_mode`]'s gate, and it is +/// the second time on this roadmap that a *runtime-valued* query in a +/// gated-feature's shared path defeated the gate. Any new "which class / which +/// mode" query on this path needs the same treatment. +#[cfg(not(feature = "float-f32"))] +pub fn def_class(inst: &VInst, def_idx: usize) -> RegClass { + let _ = (inst, def_idx); + RegClass::Int +} + +/// See [`def_class`]'s note — same gate, same measured reason. +#[cfg(not(feature = "float-f32"))] pub fn use_class(inst: &VInst, use_idx: usize) -> RegClass { let _ = (inst, use_idx); RegClass::Int @@ -201,4 +304,232 @@ mod tests { assert_eq!(def_class(&insts[1], 0), classes.of(VReg(1))); assert_eq!(use_class(&insts[1], 0), classes.of(VReg(0))); } + + // ── The float class map ────────────────────────────────────────────────── + // + // These are the phase's real assertions. Nothing constructs a float `VInst` + // yet, so a wrong entry here would be invisible until the emitter read a + // float out of an address register — silently, as a plausible wrong number. + + use crate::vinst::{FAluOp, FAluRROp, FcmpCond}; + + const V: [VReg; 4] = [VReg(0), VReg(1), VReg(2), VReg(3)]; + + /// Assert the full operand-class signature of one instruction: the classes + /// of its defs, then of its uses, in `for_each_def` / `for_each_use` order. + fn assert_signature(inst: &VInst, defs: &[RegClass], uses: &[RegClass]) { + let mut n_defs = 0usize; + inst.for_each_def(&[], |_| n_defs += 1); + let mut n_uses = 0usize; + inst.for_each_use(&[], |_| n_uses += 1); + assert_eq!(n_defs, defs.len(), "{inst:?}: def count"); + assert_eq!(n_uses, uses.len(), "{inst:?}: use count"); + for (i, want) in defs.iter().enumerate() { + assert_eq!(def_class(inst, i), *want, "{inst:?}: def {i}"); + } + for (i, want) in uses.iter().enumerate() { + assert_eq!(use_class(inst, i), *want, "{inst:?}: use {i}"); + } + } + + const F: RegClass = RegClass::Float; + const I: RegClass = RegClass::Int; + + /// Arithmetic is float all the way through. + #[test] + fn float_arithmetic_is_float_in_every_operand() { + assert_signature( + &VInst::FAluRRR { + op: FAluOp::Add, + dst: V[0], + src1: V[1], + src2: V[2], + src_op: SRC_OP_NONE, + }, + &[F], + &[F, F], + ); + assert_signature( + &VInst::FAluRR { + op: FAluRROp::Abs, + dst: V[0], + src: V[1], + src_op: SRC_OP_NONE, + }, + &[F], + &[F], + ); + } + + /// A compare reads floats and writes an **integer** 0/1. That asymmetry is + /// what lets `BrIf`, `Select` and integer arithmetic consume a float + /// comparison with no float awareness of their own. + #[test] + fn compare_reads_float_and_writes_int() { + assert_signature( + &VInst::Fcmp { + dst: V[0], + lhs: V[1], + rhs: V[2], + cond: FcmpCond::Lt, + src_op: SRC_OP_NONE, + }, + &[I], + &[F, F], + ); + } + + /// `FSelect`'s condition is an integer and its two candidates are floats — + /// the one instruction where a use-index off-by-one swaps register files. + #[test] + fn fselect_mixes_an_int_condition_with_float_values() { + assert_signature( + &VInst::FSelect { + dst: V[0], + cond: V[1], + if_true: V[2], + if_false: V[3], + src_op: SRC_OP_NONE, + }, + &[F], + &[I, F, F], + ); + } + + /// Addresses are integers; only the value crosses into the float file. + #[test] + fn float_memory_ops_keep_the_address_integer() { + assert_signature( + &VInst::FLoad32 { + dst: V[0], + base: V[1], + offset: 0, + src_op: SRC_OP_NONE, + }, + &[F], + &[I], + ); + assert_signature( + &VInst::FStore32 { + src: V[0], + base: V[1], + offset: 0, + src_op: SRC_OP_NONE, + }, + &[], + &[F, I], + ); + } + + /// The boundary transfers, each reading one file and writing the other. + /// A `Wfr` whose source was claimed Float would be a silent + /// bit-reinterpretation: the allocator would hand it an FR, the emitter + /// would read an FR, and an address register holding an IEEE pattern would + /// never make it into the float file at all. + #[test] + fn transfers_cross_the_two_register_files() { + assert_signature( + &VInst::Wfr { + dst: V[0], + src: V[1], + src_op: SRC_OP_NONE, + }, + &[F], + &[I], + ); + assert_signature( + &VInst::Rfr { + dst: V[0], + src: V[1], + src_op: SRC_OP_NONE, + }, + &[I], + &[F], + ); + } + + /// Conversion, not transfer: reads an integer *value*, writes a float. + #[test] + fn int_to_float_reads_an_integer() { + for signed in [true, false] { + assert_signature( + &VInst::IToF { + dst: V[0], + src: V[1], + signed, + src_op: SRC_OP_NONE, + }, + &[F], + &[I], + ); + } + } + + /// `Call` and `Ret` have no float operands *by design* (M7 D1/D2): float + /// values cross those boundaries in address registers, and lowering emits + /// explicit `Rfr`/`Wfr` transfers to put them there. If this ever answers + /// Float, the calling convention changed and `lpir_call_arg_target`'s + /// float arm — which returns `None` — starts rejecting real code. + #[test] + fn calls_and_returns_carry_no_float_operands() { + use crate::vinst::{SymbolId, VRegSlice}; + let pool = vec![VReg(0), VReg(1), VReg(2)]; + let call = VInst::Call { + target: SymbolId(0), + args: VRegSlice { start: 0, count: 2 }, + rets: VRegSlice { start: 2, count: 1 }, + callee_uses_sret: false, + caller_passes_sret_ptr: false, + caller_sret_vm_abi_swap: false, + src_op: SRC_OP_NONE, + }; + assert_eq!(def_class(&call, 0), I); + for i in 0..2 { + assert_eq!(use_class(&call, i), I); + } + let _ = &pool; + + let ret = VInst::Ret { + vals: VRegSlice { start: 0, count: 2 }, + src_op: SRC_OP_NONE, + }; + for i in 0..2 { + assert_eq!(use_class(&ret, i), I); + } + } + + /// Class derivation over a mixed stream: the float defs land in the table, + /// the integer ones stay implicit. + #[test] + fn a_mixed_function_records_only_its_float_vregs() { + let insts = vec![ + VInst::IConst32 { + dst: VReg(0), + val: 0x3f80_0000u32 as i32, + src_op: SRC_OP_NONE, + }, + VInst::Wfr { + dst: VReg(1), + src: VReg(0), + src_op: SRC_OP_NONE, + }, + VInst::FAluRRR { + op: FAluOp::Mul, + dst: VReg(2), + src1: VReg(1), + src2: VReg(1), + src_op: SRC_OP_NONE, + }, + VInst::Rfr { + dst: VReg(3), + src: VReg(2), + src_op: SRC_OP_NONE, + }, + ]; + let classes = VRegClasses::compute(&insts, &[], &abi_fixtures::void_func_abi()); + assert_eq!(classes.of(VReg(0)), I, "the raw bit pattern is an integer"); + assert_eq!(classes.of(VReg(1)), F); + assert_eq!(classes.of(VReg(2)), F); + assert_eq!(classes.of(VReg(3)), I, "back out to an address register"); + } } diff --git a/lp-shader/lpvm-native/src/regalloc/render.rs b/lp-shader/lpvm-native/src/regalloc/render.rs index 907960da7..b2741f819 100644 --- a/lp-shader/lpvm-native/src/regalloc/render.rs +++ b/lp-shader/lpvm-native/src/regalloc/render.rs @@ -624,6 +624,60 @@ fn format_inst(inst: &VInst, vreg_pool: &[VReg], symbols: Option<&ModuleSymbols> format!("FuelCheck i{}, L{}", vmctx.0, trap_label) } } + + // Hardware float. Operands render as `i{n}` like every other vreg — + // the register *class* shows up in the physical register the trace + // prints alongside (`f3` vs `a3`, via `IsaTarget::reg_name`), which is + // the thing a reader debugging a class bug needs to see. + VInst::FAluRRR { + op, + dst, + src1, + src2, + .. + } => format!("i{} = {} i{}, i{}", dst.0, op.mnemonic(), src1.0, src2.0), + VInst::FAluRR { op, dst, src, .. } => { + format!("i{} = {} i{}", dst.0, op.mnemonic(), src.0) + } + VInst::Fcmp { + dst, + lhs, + rhs, + cond, + .. + } => format!( + "i{} = Fcmp {}, i{}, i{}", + dst.0, + cond.mnemonic(), + lhs.0, + rhs.0 + ), + VInst::FSelect { + dst, + cond, + if_true, + if_false, + .. + } => format!( + "i{} = FSelect i{}, i{}, i{}", + dst.0, cond.0, if_true.0, if_false.0 + ), + VInst::FLoad32 { + dst, base, offset, .. + } => format!("i{} = FLoad32 i{}, {}", dst.0, base.0, offset), + VInst::FStore32 { + src, base, offset, .. + } => format!("FStore32 i{}, i{}, {}", src.0, base.0, offset), + VInst::Wfr { dst, src, .. } => format!("i{} = Wfr i{}", dst.0, src.0), + VInst::Rfr { dst, src, .. } => format!("i{} = Rfr i{}", dst.0, src.0), + VInst::IToF { + dst, src, signed, .. + } => format!( + "i{} = {} i{}", + dst.0, + if *signed { "IToFS" } else { "IToFU" }, + src.0 + ), } } diff --git a/lp-shader/lpvm-native/src/regalloc/test/builder.rs b/lp-shader/lpvm-native/src/regalloc/test/builder.rs index db84700e3..0713bc88e 100644 --- a/lp-shader/lpvm-native/src/regalloc/test/builder.rs +++ b/lp-shader/lpvm-native/src/regalloc/test/builder.rs @@ -37,14 +37,21 @@ pub struct AllocTestBuilder { abi_params: usize, /// Same spelling as filetests: `void`, `i32`, `f32`, `vec4`, `mat4`, … abi_return: String, + isa: IsaTarget, } /// Start building an allocation test. +/// +/// Defaults to rv32 because that is the reference target for allocation +/// behaviour and what the great majority of these tests assert about. Use +/// [`AllocTestBuilder::isa`] for anything the two targets do differently — the +/// float register class, in particular, exists only on Xtensa. pub fn alloc_test() -> AllocTestBuilder { AllocTestBuilder { pool_size: None, abi_params: 0, abi_return: String::from("void"), + isa: IsaTarget::Rv32imac, } } @@ -80,35 +87,34 @@ impl AllocTestBuilder { self } + /// Target ISA for the ABI and the register pool. + /// + /// Needed for the float class: `RegClass::Float` has an empty pool on rv32 + /// (its f32 path is soft float, which never leaves the integer file), so a + /// float allocation test that ran on the default target would fail with + /// `OutOfRegisters` rather than exercising anything. + pub fn isa(mut self, isa: IsaTarget) -> Self { + self.isa = isa; + self + } + fn build_func_abi(&self) -> FuncAbi { - let return_type = lps_return_type(&self.abi_return); - if self.abi_params > 0 { - let params: Vec = (0..self.abi_params) + let sig = LpsFnSig { + name: String::from("test"), + return_type: lps_return_type(&self.abi_return), + parameters: (0..self.abi_params) .map(|i| FnParam { name: alloc::format!("arg{i}"), ty: LpsType::Int, qualifier: ParamQualifier::In, }) - .collect(); - abi::func_abi_rv32( - &LpsFnSig { - name: String::from("test"), - return_type, - parameters: params, - kind: LpsFnKind::UserDefined, - }, - None, - ) - } else { - abi::func_abi_rv32( - &LpsFnSig { - name: String::from("test"), - return_type, - parameters: Vec::new(), - kind: LpsFnKind::UserDefined, - }, - None, - ) + .collect(), + kind: LpsFnKind::UserDefined, + }; + match self.isa { + IsaTarget::Rv32imac => abi::func_abi_rv32(&sig, None), + #[cfg(feature = "isa-xt")] + IsaTarget::Xtensa => crate::isa::xt::abi::func_abi_xt(&sig, None), } } @@ -120,10 +126,13 @@ impl AllocTestBuilder { ) -> AllocTestResult { let func_abi = self.build_func_abi(); - let isa = IsaTarget::Rv32imac; + let isa = self.isa; let pool = match self.pool_size { Some(n) => RegPool::with_capacity(isa, n), - None => RegPool::new(isa), + // `for_abi` rather than `new`: it honours the registers this + // function's ABI withholds, and it is what seeds the float pool + // from the ABI's float lanes. + None => RegPool::for_abi(&func_abi), }; let output = walk_linear_with_pool(&vinsts, &vreg_pool, &func_abi, pool) diff --git a/lp-shader/lpvm-native/src/regalloc/test/float_alloc.rs b/lp-shader/lpvm-native/src/regalloc/test/float_alloc.rs new file mode 100644 index 000000000..10f061031 --- /dev/null +++ b/lp-shader/lpvm-native/src/regalloc/test/float_alloc.rs @@ -0,0 +1,196 @@ +//! Two-class allocation: floats in FRs and integers in ARs, in one function. +//! +//! These are the end-to-end assertions for M7 P1. The per-operand class map is +//! unit-tested in [`crate::regalloc::classes`]; what is left to prove is that +//! the map, the ISA's float pool, and the ABI's float lanes agree well enough +//! that the allocator can actually place a float — and that +//! [`verify_operand_classes`](crate::regalloc::verify) rejects it when they do +//! not. +//! +//! Xtensa only. rv32's f32 path is soft float, which keeps every value in the +//! integer file, so its float pool is empty on purpose and a float vreg there +//! is meant to fail. + +#![cfg(all(feature = "isa-xt", feature = "float-f32"))] + +use alloc::vec::Vec; + +use crate::abi::{PReg, RegClass}; +use crate::isa::IsaTarget; +use crate::regalloc::test::builder::alloc_test; +use crate::regalloc::{Alloc, AllocOutput}; +use crate::vinst::{VInst, VReg}; + +fn xt() -> crate::regalloc::test::builder::AllocTestBuilder { + alloc_test().isa(IsaTarget::Xtensa) +} + +/// Every physical register the allocation assigned, paired with the class the +/// operand required. +fn allocated_classes(vinsts: &[VInst], pool: &[VReg], out: &AllocOutput) -> Vec<(PReg, RegClass)> { + use crate::regalloc::classes::{def_class, use_class}; + let mut found = Vec::new(); + for (idx, inst) in vinsts.iter().enumerate() { + let base = out.inst_alloc_offsets[idx] as usize; + let mut op = 0usize; + let mut def_idx = 0usize; + inst.for_each_def(pool, |_| { + if let Alloc::Reg(p) = out.allocs[base + op] { + found.push((p.get(), def_class(inst, def_idx))); + } + op += 1; + def_idx += 1; + }); + let mut use_idx = 0usize; + inst.for_each_use(pool, |_| { + if let Alloc::Reg(p) = out.allocs[base + op] { + found.push((p.get(), use_class(inst, use_idx))); + } + op += 1; + use_idx += 1; + }); + } + found +} + +/// The headline: one function that allocates from **both** pools, and +/// `verify_alloc` — which includes `verify_operand_classes` — accepts it. +/// +/// The shape is the calling convention in miniature (M7 D1/D2): a bit pattern +/// materialized in an address register, transferred in with `Wfr`, multiplied +/// in the float file, and transferred back out with `Rfr` before the return. +#[test] +fn floats_and_ints_allocate_from_their_own_pools_in_one_function() { + let r = xt().run_vinst( + "i0 = IConst32 1065353216 + i1 = Wfr i0 + i2 = FMul i1, i1 + i3 = Rfr i2 + Ret i3", + ); + // `run_vinst` already ran `verify_alloc`; reaching here means the class + // verifier accepted every operand. Now assert it was not vacuous — that a + // float really did land in an FR. + let (vinsts, _symbols, pool) = crate::debug::vinst::parse( + "i0 = IConst32 1065353216\ni1 = Wfr i0\ni2 = FMul i1, i1\ni3 = Rfr i2\nRet i3", + ) + .unwrap(); + let found = allocated_classes(&vinsts, &pool, &r.output); + assert!( + found.iter().any(|(p, _)| p.class == RegClass::Float), + "no float register was allocated — the test would pass vacuously: {found:?}" + ); + assert!( + found.iter().any(|(p, _)| p.class == RegClass::Int), + "no integer register was allocated: {found:?}" + ); + for (preg, want) in &found { + assert_eq!(preg.class, *want, "{preg:?} allocated for a {want:?} operand"); + } + assert!( + found + .iter() + .all(|(p, _)| p.class != RegClass::Float || p.hw < 16), + "an FR index outside f0..f15: {found:?}" + ); + r.expect_spill_slots(0); +} + +/// Float pressure past the 16-register file spills — into the ordinary +/// class-tagged spill index space, with no new frame region (M7 D7). +#[test] +fn float_pressure_spills_without_a_new_frame_region() { + let mut src = alloc::string::String::new(); + // 20 live floats, then one op that reads them all pairwise. + for i in 0..20 { + src.push_str(&alloc::format!("i{i} = IConst32 {i}\n")); + } + for i in 0..20 { + src.push_str(&alloc::format!("i{} = Wfr i{i}\n", 100 + i)); + } + // Sum them, keeping every one live until its turn. + for i in 1..20 { + src.push_str(&alloc::format!("i{} = FAdd i100, i{}\n", 200 + i, 100 + i)); + } + src.push_str("i250 = Rfr i219\nRet i250"); + let r = xt().run_vinst(&src); + r.expect_spill_slots_at_least(1); +} + +/// The negative case: a deliberately wrong class is **rejected**. +/// +/// This is the assertion that gives the positive test its meaning. A `Wfr` +/// reads an *address* register; handing it a float register does not crash and +/// does not produce a bad address — the emitter would read an FR, and the IEEE +/// pattern sitting in the AR would never enter the float file. The result is a +/// plausible wrong number, which is precisely why the verifier exists. +#[test] +#[should_panic(expected = "needs a Int register but was allocated to a Float one")] +fn a_float_register_on_wfrs_integer_source_is_rejected() { + let src = "i0 = IConst32 1065353216\ni1 = Wfr i0\ni2 = Rfr i1\nRet i2"; + let (vinsts, _symbols, pool) = crate::debug::vinst::parse(src).unwrap(); + let r = xt().run_vinst(src); + let mut output = r.output; + + // `Wfr` is instruction 1; its operands are [def, use]. + let wfr_use = output.inst_alloc_offsets[1] as usize + 1; + assert!( + matches!(output.allocs[wfr_use], Alloc::Reg(p) if p.get().class == RegClass::Int), + "fixture drifted: Wfr's source was not allocated to an integer register" + ); + output.allocs[wfr_use] = Alloc::reg(PReg::float(3)); + + let func_abi = crate::isa::xt::abi::func_abi_xt( + &lps_shared::LpsFnSig { + name: alloc::string::String::from("test"), + return_type: lps_shared::LpsType::Void, + parameters: Vec::new(), + kind: lps_shared::LpsFnKind::UserDefined, + }, + None, + ); + crate::regalloc::verify::verify_alloc(&vinsts, &pool, &output, &func_abi); +} + +/// The mirror: an integer register where a float operand is required. +#[test] +#[should_panic(expected = "needs a Float register but was allocated to a Int one")] +fn an_integer_register_on_a_float_operand_is_rejected() { + let src = "i0 = IConst32 1065353216\ni1 = Wfr i0\ni2 = Rfr i1\nRet i2"; + let (vinsts, _symbols, pool) = crate::debug::vinst::parse(src).unwrap(); + let r = xt().run_vinst(src); + let mut output = r.output; + + // `Rfr` is instruction 2; its use must be a float register. + let rfr_use = output.inst_alloc_offsets[2] as usize + 1; + output.allocs[rfr_use] = Alloc::reg(PReg::int(5)); + + let func_abi = crate::isa::xt::abi::func_abi_xt( + &lps_shared::LpsFnSig { + name: alloc::string::String::from("test"), + return_type: lps_shared::LpsType::Void, + parameters: Vec::new(), + kind: lps_shared::LpsFnKind::UserDefined, + }, + None, + ); + crate::regalloc::verify::verify_alloc(&vinsts, &pool, &output, &func_abi); +} + +/// A call evicts every live float, because no FR survives a `call8` (M6-P4). +/// Under-reporting the clobber set would leave the value in a register the +/// callee overwrites — wrong only for inputs that straddle a call. +#[test] +fn a_call_evicts_live_floats() { + let r = xt().run_vinst( + "i0 = IConst32 1065353216 + i1 = Wfr i0 + i2 = Call helper (i0) + i3 = FMul i1, i1 + i4 = Rfr i3 + Ret i4", + ); + // The float is live across the call and every FR is clobbered, so it must + // have been spilled and reloaded. + r.expect_spill_slots_at_least(1); +} diff --git a/lp-shader/lpvm-native/src/regalloc/test/mod.rs b/lp-shader/lpvm-native/src/regalloc/test/mod.rs index 07d58d376..28e5b512b 100644 --- a/lp-shader/lpvm-native/src/regalloc/test/mod.rs +++ b/lp-shader/lpvm-native/src/regalloc/test/mod.rs @@ -2,3 +2,4 @@ pub mod abi_fixtures; pub mod builder; +mod float_alloc; diff --git a/lp-shader/lpvm-native/src/vinst.rs b/lp-shader/lpvm-native/src/vinst.rs index 2d0a88d21..56ad78ed3 100644 --- a/lp-shader/lpvm-native/src/vinst.rs +++ b/lp-shader/lpvm-native/src/vinst.rs @@ -227,6 +227,139 @@ impl AluImmOp { } } +// ─── Float opcode enums ────────────────────────────────────────────────────── +// +// These name IEEE-754 binary32 operations, not one ISA's mnemonics. Each maps +// to a single hardware instruction on a target with an FPU; on a soft-float +// target the ops never appear at all, because that lowering keeps every value +// integer-class and emits ordinary `Call`s (see [`crate::lower_f32`]). + +/// Three-operand float arithmetic: `dst = src1 OP src2`. +/// +/// Only the operations that are **one instruction everywhere** are here. +/// Division is not: it is a builtin call on both float targets (M7 D4), so +/// giving it a VInst would create a variant no backend could emit. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +#[repr(u8)] +pub enum FAluOp { + Add, + Sub, + Mul, +} + +impl FAluOp { + pub fn mnemonic(self) -> &'static str { + match self { + FAluOp::Add => "FAdd", + FAluOp::Sub => "FSub", + FAluOp::Mul => "FMul", + } + } + + pub fn symbol(self) -> &'static str { + match self { + FAluOp::Add => "+.f", + FAluOp::Sub => "-.f", + FAluOp::Mul => "*.f", + } + } + + pub fn from_mnemonic(s: &str) -> Option { + match s { + "FAdd" => Some(FAluOp::Add), + "FSub" => Some(FAluOp::Sub), + "FMul" => Some(FAluOp::Mul), + _ => None, + } + } +} + +/// Two-operand float ops: `dst = OP src`. +/// +/// `Abs` and `Neg` are sign-bit operations, **not** `0 - x` and `x < 0 ? -x : x` +/// — they must stay exact on NaN and `-0.0` (`docs/design/float.md` §3). The +/// hardware `abs.s`/`neg.s` are defined that way; the soft-float path spells +/// the same thing as an integer mask. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +#[repr(u8)] +pub enum FAluRROp { + Mov, + Abs, + Neg, +} + +impl FAluRROp { + pub fn mnemonic(self) -> &'static str { + match self { + FAluRROp::Mov => "FMov", + FAluRROp::Abs => "FAbs", + FAluRROp::Neg => "FNeg", + } + } + + pub fn from_mnemonic(s: &str) -> Option { + match s { + "FMov" => Some(FAluRROp::Mov), + "FAbs" => Some(FAluRROp::Abs), + "FNeg" => Some(FAluRROp::Neg), + _ => None, + } + } +} + +/// IEEE-754 comparison predicate for [`VInst::Fcmp`]. +/// +/// The NaN behavior is normative, not incidental (`docs/design/float.md` §3): +/// every **ordered** comparison is false when either operand is NaN, and `Ne` +/// is true. That is why [`FcmpCond::Ne`] is its own condition rather than a +/// negated [`FcmpCond::Eq`] — the two differ exactly on NaN, which is the one +/// input where the rewrite would be observable and wrong. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum FcmpCond { + Eq, + Ne, + Lt, + Le, + Gt, + Ge, +} + +impl FcmpCond { + pub fn mnemonic(self) -> &'static str { + match self { + FcmpCond::Eq => "Eq", + FcmpCond::Ne => "Ne", + FcmpCond::Lt => "Lt", + FcmpCond::Le => "Le", + FcmpCond::Gt => "Gt", + FcmpCond::Ge => "Ge", + } + } + + pub fn from_mnemonic(s: &str) -> Option { + match s { + "Eq" => Some(FcmpCond::Eq), + "Ne" => Some(FcmpCond::Ne), + "Lt" => Some(FcmpCond::Lt), + "Le" => Some(FcmpCond::Le), + "Gt" => Some(FcmpCond::Gt), + "Ge" => Some(FcmpCond::Ge), + _ => None, + } + } +} + +fn fcmp_cond_op(cond: FcmpCond) -> &'static str { + match cond { + FcmpCond::Eq => "==.f", + FcmpCond::Ne => "!=.f", + FcmpCond::Lt => "<.f", + FcmpCond::Le => "<=.f", + FcmpCond::Gt => ">.f", + FcmpCond::Ge => ">=.f", + } +} + // ─── Comparison condition ──────────────────────────────────────────────────── #[derive(Clone, Copy, Debug, PartialEq, Eq)] @@ -459,6 +592,105 @@ pub enum VInst { trap_label: LabelId, src_op: u16, }, + + // ─── Hardware float ────────────────────────────────────────────────────── + // + // Present unconditionally, not behind `float-f32` (M7 D9 / Q5). Feature- + // gating enum variants that five per-variant helpers and a text ser/de + // module all match exhaustively costs far more than the residual it saves, + // and the residual is measured rather than argued about. Nothing constructs + // these unless the f32 lowering module is linked. + // + // **Measured residual, ESP32-C6 image with `float-f32` off** (M7 P1/P2, + // against `2862448 B`): `.text` −120 B, `.rodata` +1,508 B, `.data` +32 B, + // image +1,424 B. The cost is *not* code — it is the jump tables of the + // per-variant matches (`for_each_def`, `for_each_use`, `src_op`, the + // emitter's dispatch) growing by nine entries at every site they inline + // into. Everything that could be gated has been: the class map, the float + // register tables, the lowering module, and the emitter's diagnostic + // strings. What is left is the price of D9, in the form D9 asked to see it. + // + // The operand *class* of every field below is fixed and is what + // `regalloc::classes` must report. Getting one wrong does not crash: it + // silently reinterprets a bit pattern between the two register files, which + // is why `regalloc::verify::verify_operand_classes` exists. + /// `dst = src1 OP src2`, one FP instruction. **dst/src1/src2 all Float.** + FAluRRR { + op: FAluOp, + dst: VReg, + src1: VReg, + src2: VReg, + src_op: u16, + }, + /// `dst = OP src`, one FP instruction. **dst/src Float.** + FAluRR { + op: FAluRROp, + dst: VReg, + src: VReg, + src_op: u16, + }, + /// IEEE comparison producing an integer 0/1. **dst Int; lhs/rhs Float.** + /// + /// The mixed classes are the point. On Xtensa the comparison itself writes + /// a Boolean register, and the emitter materializes 0/1 into an address + /// register within the same sequence (M7 D5), so no Boolean register is + /// ever live at a `VInst` boundary and the allocator never sees a third + /// register class. + Fcmp { + dst: VReg, + lhs: VReg, + rhs: VReg, + cond: FcmpCond, + src_op: u16, + }, + /// `dst = cond != 0 ? if_true : if_false`. + /// **dst/if_true/if_false Float; cond Int.** + FSelect { + dst: VReg, + cond: VReg, + if_true: VReg, + if_false: VReg, + src_op: u16, + }, + /// Word load into the float file: `dst = [base + offset]`. + /// **dst Float, base Int.** + FLoad32 { + dst: VReg, + base: VReg, + offset: i32, + src_op: u16, + }, + /// Word store from the float file: `[base + offset] = src`. + /// **src Float, base Int.** + FStore32 { + src: VReg, + base: VReg, + offset: i32, + src_op: u16, + }, + /// Integer register → float register, **bit-for-bit**. **dst Float, src Int.** + /// + /// Not a conversion — [`VInst::IToF`] is the conversion. This is the + /// boundary transfer that implements "float values travel in address + /// registers as raw IEEE bit patterns" (M7 D1/D2), and lowering is the only + /// thing that emits it. + Wfr { dst: VReg, src: VReg, src_op: u16 }, + /// Float register → integer register, bit-for-bit. **dst Int, src Float.** + /// + /// The inverse of [`VInst::Wfr`]; see its note. + Rfr { dst: VReg, src: VReg, src_op: u16 }, + /// Integer → float **conversion**, correctly rounded. **dst Float, src Int.** + /// + /// `signed` selects the interpretation of `src`. Inlined where + /// division and the float→int direction are not (M7 D4) because both + /// signednesses are a single correctly-rounded instruction with no + /// saturation question attached. + IToF { + dst: VReg, + src: VReg, + signed: bool, + src_op: u16, + }, } impl VInst { @@ -488,7 +720,16 @@ impl VInst { | VInst::IConst32 { src_op, .. } | VInst::Call { src_op, .. } | VInst::Ret { src_op, .. } - | VInst::FuelCheck { src_op, .. } => *src_op, + | VInst::FuelCheck { src_op, .. } + | VInst::FAluRRR { src_op, .. } + | VInst::FAluRR { src_op, .. } + | VInst::Fcmp { src_op, .. } + | VInst::FSelect { src_op, .. } + | VInst::FLoad32 { src_op, .. } + | VInst::FStore32 { src_op, .. } + | VInst::Wfr { src_op, .. } + | VInst::Rfr { src_op, .. } + | VInst::IToF { src_op, .. } => *src_op, VInst::Label(_, src_op) => *src_op, }; unpack_src_op(raw) @@ -510,7 +751,15 @@ impl VInst { | VInst::Load16U { dst, .. } | VInst::Load16S { dst, .. } | VInst::SlotAddr { dst, .. } - | VInst::IConst32 { dst, .. } => f(*dst), + | VInst::IConst32 { dst, .. } + | VInst::FAluRRR { dst, .. } + | VInst::FAluRR { dst, .. } + | VInst::Fcmp { dst, .. } + | VInst::FSelect { dst, .. } + | VInst::FLoad32 { dst, .. } + | VInst::Wfr { dst, .. } + | VInst::Rfr { dst, .. } + | VInst::IToF { dst, .. } => f(*dst), VInst::Store32 { .. } | VInst::Store8 { .. } | VInst::Store16 { .. } @@ -518,7 +767,8 @@ impl VInst { | VInst::Label(..) | VInst::Br { .. } | VInst::BrIf { .. } - | VInst::FuelCheck { .. } => {} + | VInst::FuelCheck { .. } + | VInst::FStore32 { .. } => {} VInst::Call { rets, .. } => { for r in rets.vregs(pool) { f(*r); @@ -590,6 +840,38 @@ impl VInst { } } VInst::FuelCheck { vmctx, .. } => f(*vmctx), + + // Float. The visit ORDER is the contract `regalloc::classes` + // indexes into — `use_class(inst, i)` answers for the i-th value + // yielded here — so reordering an arm silently swaps two operands' + // register files. + VInst::FAluRRR { src1, src2, .. } => { + f(*src1); + f(*src2); + } + VInst::Fcmp { lhs, rhs, .. } => { + f(*lhs); + f(*rhs); + } + VInst::FSelect { + cond, + if_true, + if_false, + .. + } => { + f(*cond); + f(*if_true); + f(*if_false); + } + VInst::FAluRR { src, .. } + | VInst::Wfr { src, .. } + | VInst::Rfr { src, .. } + | VInst::IToF { src, .. } => f(*src), + VInst::FLoad32 { base, .. } => f(*base), + VInst::FStore32 { src, base, .. } => { + f(*src); + f(*base); + } } } @@ -624,6 +906,16 @@ impl VInst { VInst::Ret { .. } => "Ret", VInst::Label(..) => "Label", VInst::FuelCheck { .. } => "FuelCheck", + VInst::FAluRRR { op, .. } => op.mnemonic(), + VInst::FAluRR { op, .. } => op.mnemonic(), + VInst::Fcmp { .. } => "Fcmp", + VInst::FSelect { .. } => "FSelect", + VInst::FLoad32 { .. } => "FLoad32", + VInst::FStore32 { .. } => "FStore32", + VInst::Wfr { .. } => "Wfr", + VInst::Rfr { .. } => "Rfr", + VInst::IToF { signed: true, .. } => "IToFS", + VInst::IToF { signed: false, .. } => "IToFU", } } @@ -751,6 +1043,47 @@ impl VInst { format!("fuel(v{}), trap -> {}", vmctx.0, trap_label) } } + VInst::FAluRRR { + op, + dst, + src1, + src2, + .. + } => format!("v{} = v{} {} v{}", dst.0, src1.0, op.symbol(), src2.0), + VInst::FAluRR { op, dst, src, .. } => { + format!("v{} = {}(v{})", dst.0, op.mnemonic(), src.0) + } + VInst::Fcmp { + dst, + lhs, + rhs, + cond, + .. + } => format!("v{} = v{} {} v{}", dst.0, lhs.0, fcmp_cond_op(*cond), rhs.0), + VInst::FSelect { + dst, + cond, + if_true, + if_false, + .. + } => format!( + "v{} = v{} ?.f v{} : v{}", + dst.0, cond.0, if_true.0, if_false.0 + ), + VInst::FLoad32 { + dst, base, offset, .. + } => format!("v{} = f32[v{}{:+}]", dst.0, base.0, offset), + VInst::FStore32 { + src, base, offset, .. + } => format!("f32[v{}{:+}] = v{}", base.0, offset, src.0), + VInst::Wfr { dst, src, .. } => format!("v{} = wfr v{}", dst.0, src.0), + VInst::Rfr { dst, src, .. } => format!("v{} = rfr v{}", dst.0, src.0), + VInst::IToF { + dst, src, signed, .. + } => { + let kind = if *signed { "i32" } else { "u32" }; + format!("v{} = f32(v{} as {})", dst.0, src.0, kind) + } } } } diff --git a/lp-shader/lpvm-native/tests/xt_imm_legality.rs b/lp-shader/lpvm-native/tests/xt_imm_legality.rs index 1e3fcb47b..e06e1b088 100644 --- a/lp-shader/lpvm-native/tests/xt_imm_legality.rs +++ b/lp-shader/lpvm-native/tests/xt_imm_legality.rs @@ -124,6 +124,11 @@ fn pinned_ranges_match_assembler_probes() { &[-524288, 0, 4, 524284], &[-524292, -2, 2, 524288], ), + ( + ImmOp::FpLsiOffset, + &[0, 4, 1020], + &[-4, 1, 2, 1021, 1024, 4096], + ), ]; for &(op, legal, illegal) in cases { for &v in legal { @@ -333,6 +338,50 @@ fn fallbacks_are_the_documented_lowerings() { assert_eq!(fallback(ImmOp::SextBit), Fallback::OtherOpcode); // slli+srai assert_eq!(fallback(ImmOp::BranchB4Const), Fallback::ConstThenReg); assert_eq!(fallback(ImmOp::BranchB4Constu), Fallback::ConstThenReg); + assert_eq!(fallback(ImmOp::FpLsiOffset), Fallback::AddressScratch); +} + +/// The float spill offset is the second silent-corruption hazard in the frame +/// story, after the window-overflow one — and unlike that one it has no +/// hardware alibi: `lp_xt_inst`'s encoder computes `lsi`/`ssi`'s field as +/// `(offset / 4) & 0xff` with **no range check**. +/// +/// A float spill slot at byte offset 1024 therefore encodes as field 0 and +/// addresses `[base + 0]`: a valid address holding some other live value. +/// Nothing downstream can tell that apart from a correct spill — no fault, no +/// misalignment, just a wrong number. `is_legal(FpLsiOffset, …)` is the gate +/// that has to catch it before the encoder sees it, so the aliasing is pinned +/// here rather than assumed. +#[test] +fn out_of_range_float_spill_offsets_alias_and_must_be_rejected() { + use lp_xt_inst::fp::{FReg, FpLsiOp}; + + let f = FReg::new(1); + for (bad, aliases_to) in [(1024u32, 0u32), (1028, 4), (2044, 1020)] { + assert!( + !is_legal(ImmOp::FpLsiOffset, bad as i32), + "{bad} must be rejected before it reaches the encoder" + ); + assert_eq!( + roundtrip(Inst::FpLsi(FpLsiOp::Ssi, f, r(1), bad)), + Inst::FpLsi(FpLsiOp::Ssi, f, r(1), aliases_to), + "ssi offset {bad} silently became {aliases_to}" + ); + } + // Sub-word offsets floor rather than fault, the same way `l32i`'s do. + assert!(!is_legal(ImmOp::FpLsiOffset, 1021)); + assert_eq!( + roundtrip(Inst::FpLsi(FpLsiOp::Lsi, f, r(1), 1021)), + Inst::FpLsi(FpLsiOp::Lsi, f, r(1), 1020) + ); + + // The whole legal range does round-trip exactly, so the gate is not + // over-tight either. + for off in (0..=1020u32).step_by(4) { + assert!(is_legal(ImmOp::FpLsiOffset, off as i32)); + let inst = Inst::FpLsi(FpLsiOp::Lsi, f, r(2), off); + assert_eq!(roundtrip(inst), inst, "lsi offset {off}"); + } } /// PC-relative bases are declared correctly for every displacement entry. From 93b097573a125f93b325443b175fd07220550513 Mon Sep 17 00:00:00 2001 From: Yona Appletree Date: Fri, 31 Jul 2026 20:12:28 -0700 Subject: [PATCH 09/15] =?UTF-8?q?feat(lpvm-native):=20F32=20lowering=20for?= =?UTF-8?q?=20Xtensa=20=E2=80=94=20inline=20family,=20builtin=20routing,?= =?UTF-8?q?=20AR/FR=20transfers?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit M7 P2. `IsaTarget::f32_lowering()` now answers `HardwareFpu` for Xtensa behind `float-f32`, and `lower_f32.rs` has an arm to back it up. Still nothing executes — P3 emits, P4 runs — but the VInst stream is real and its shapes are asserted. The split (D4). `fadd/fsub/fmul`, `fabs/fneg`, the six compares, float select, `itof_s/itof_u`, float loads/stores and the transfers are one FP instruction each. Everything else calls the M5 f32 builtin family through `BuiltinId`, not a string literal, so a missing symbol is a resolution error naming the op rather than a link failure three phases later. Two of those are deliberate rather than pending: division is a builtin call in Q32 as well, so this is parity; and `trunc.s` alone may not satisfy float.md §3, since whether this silicon saturates or wraps is still an open measurement. Each comparison keeps its own condition. `Fne` is not a negated `Eq` — they differ exactly on NaN, and no ordinary input would catch the rewrite. The calling convention (D1/D2): float values live in FRs inside a body and travel in address registers as raw IEEE bit patterns across every boundary, because the toolchain that compiles our float builtins does. Lowering inserts the transfers at four sites and nowhere else — entry parameters, call arguments, call results, returns — which leaves `Call` and `Ret` integer-class end to end and makes the ABI readable in the VInst dump. Parameters are the one case needing two backend identities: they arrive precolored to an address register and cannot also be the FR the body computes in, since a vreg has one class for life. They get a deterministic shadow vreg filled by the entry `Wfr` — deterministic so there is no per-function allocation on the device, and so the *word* view stays plain `fa_vreg`, which means a float parameter handed straight to another call needs no transfer at all. M9's `f32_lowering_never_claims_hardware` is narrowed rather than deleted: exactly one target may claim an FPU, and only in a build that links the FP tables. Q32 filetest snapshots byte-identical (`just test` green, no baseline paths touched); C6 image unmoved at 2 863 872 B — every byte of this phase is behind the gate. Plan: 2026-07-30-1745-f32-native-math/m7-f32-codegen-xtensa Co-Authored-By: Claude Fable 5 --- lp-shader/lpvm-native/src/debug/vinst.rs | 5 +- lp-shader/lpvm-native/src/isa/mod.rs | 71 +- lp-shader/lpvm-native/src/lower.rs | 190 ++- lp-shader/lpvm-native/src/lower_f32.rs | 1468 ++++++++++++++--- .../src/regalloc/test/float_alloc.rs | 34 +- 5 files changed, 1540 insertions(+), 228 deletions(-) diff --git a/lp-shader/lpvm-native/src/debug/vinst.rs b/lp-shader/lpvm-native/src/debug/vinst.rs index b99b844df..6292bdcec 100644 --- a/lp-shader/lpvm-native/src/debug/vinst.rs +++ b/lp-shader/lpvm-native/src/debug/vinst.rs @@ -1408,7 +1408,10 @@ Ret i2 let text = format(&vinsts, &pool, &syms); assert_eq!(text, line, "writer disagrees with parser"); let (reparsed, _, _) = parse(&text).unwrap(); - assert_eq!(reparsed, vinsts, "{line}: value changed across a round trip"); + assert_eq!( + reparsed, vinsts, + "{line}: value changed across a round trip" + ); } } diff --git a/lp-shader/lpvm-native/src/isa/mod.rs b/lp-shader/lpvm-native/src/isa/mod.rs index a9a662a49..d147f343d 100644 --- a/lp-shader/lpvm-native/src/isa/mod.rs +++ b/lp-shader/lpvm-native/src/isa/mod.rs @@ -307,21 +307,31 @@ impl IsaTarget { /// Rust's `compiler_builtins`. See /// `docs/adr/2026-07-31-soft-float-via-compiler-builtins.md`. /// - /// The Xtensa arm is [`F32Lowering::Unsupported`] until the hardware-FPU - /// emitter lands (roadmap M7). It is deliberately *not* - /// [`F32Lowering::SoftFloatCalls`]: the S3 has a real FPU, and quietly - /// giving it the slow path would hide the missing backend behind working - /// output. - /// - /// **Nothing answers [`F32Lowering::HardwareFpu`] yet**, which is what - /// makes it impossible for this crate to emit an F-extension instruction - /// for a part that would trap on it. `f32_lowering_never_claims_hardware` - /// in this module is the guard on that. + /// `Xtensa` names a part **with** the Floating-Point Coprocessor Option + /// (ESP32-S3 / LX7 — 26/26 FP instructions confirmed present on desk + /// silicon, M6-P1), so it answers [`F32Lowering::HardwareFpu`]: float ops + /// lower to single FP instructions on the flat 16-entry FR file, with the + /// operations that are *not* one instruction — divide, square root, the + /// saturating conversions, the transcendentals — routed to the same M5 + /// builtins the soft-float path calls (M7 D4). + /// + /// Without `float-f32` it answers [`F32Lowering::Unsupported`], because the + /// FP register tables and lowering are not linked. It is deliberately *not* + /// [`F32Lowering::SoftFloatCalls`] in that build: the S3 has a real FPU, and + /// quietly giving it the slow path would hide a misconfigured image behind + /// working output. + /// + /// The asymmetry with `Rv32imac` is the whole point of this hook. "rv32" + /// and "Xtensa" are not two dialects of one float story — one has no FPU at + /// all and one has a full coprocessor, and the answer has to be a named + /// property of the target rather than an assumption baked into lowering. pub fn f32_lowering(self) -> F32Lowering { match self { #[cfg(feature = "isa-rv32")] IsaTarget::Rv32imac => F32Lowering::SoftFloatCalls, - #[cfg(feature = "isa-xt")] + #[cfg(all(feature = "isa-xt", feature = "float-f32"))] + IsaTarget::Xtensa => F32Lowering::HardwareFpu, + #[cfg(all(feature = "isa-xt", not(feature = "float-f32")))] IsaTarget::Xtensa => F32Lowering::Unsupported, } } @@ -795,23 +805,40 @@ mod tests { ] } - /// The float-capability seam's whole job: no target may claim a hardware - /// FPU while no emitter in this crate can encode one. A backend that gains - /// FP instructions flips its [`IsaTarget::f32_lowering`] arm and updates - /// this assertion in the same change — which is exactly the review moment - /// we want, because that is the change that can start emitting `fadd.s`. + /// The float-capability seam's whole job: **exactly one** target may claim + /// a hardware FPU, and only in a build that links the FP tables. + /// + /// M9 asserted that *nothing* answered `HardwareFpu`, because nothing could + /// encode an FP instruction. M7 makes Xtensa the one that does — the S3 has + /// the Floating-Point Coprocessor Option and M6-P1 confirmed all 26 + /// instructions present on silicon. The assertion is kept, not deleted, + /// with the claim narrowed: any *other* target answering `HardwareFpu` is a + /// part that would take an illegal-instruction trap on the first `fadd.s`. #[test] - fn f32_lowering_never_claims_hardware() { + fn only_xtensa_with_float_f32_claims_a_hardware_fpu() { for isa in every_isa() { - assert_ne!( - isa.f32_lowering(), - F32Lowering::HardwareFpu, - "{isa:?} claims a hardware FPU, but no emitter here can encode \ - an FP instruction — and a part without the extension traps on one" + let claims_hardware = isa.f32_lowering() == F32Lowering::HardwareFpu; + let may_claim_hardware = cfg!(feature = "isa-xt") + && cfg!(feature = "float-f32") + && alloc::format!("{isa:?}") == "Xtensa"; + assert_eq!( + claims_hardware, may_claim_hardware, + "{isa:?}: hardware-FPU claim does not match what this build can encode" ); } } + /// The gate, from the capability side: with `float-f32` off there is no FP + /// emitter and no FR pool linked, so the S3 must report `Unsupported` + /// rather than fall back to soft float. A silent fallback would compile, + /// run, produce right answers, and be ~30x slower than the part is capable + /// of, with nothing pointing at why. + #[cfg(all(feature = "isa-xt", not(feature = "float-f32")))] + #[test] + fn xtensa_without_the_feature_is_unsupported_not_soft_float() { + assert_eq!(IsaTarget::Xtensa.f32_lowering(), F32Lowering::Unsupported); + } + /// The C6 is the reference rv32 part and has no F extension; soft calls are /// the only correct answer for it. #[cfg(feature = "isa-rv32")] diff --git a/lp-shader/lpvm-native/src/lower.rs b/lp-shader/lpvm-native/src/lower.rs index afb79d3e9..6ee8ff4f2 100644 --- a/lp-shader/lpvm-native/src/lower.rs +++ b/lp-shader/lpvm-native/src/lower.rs @@ -72,6 +72,72 @@ use crate::vinst::{ }; use lps_q32::q32_encode; +/// True when this compile emits hardware FP instructions, and therefore owes +/// the AR↔FR transfers at the four ABI boundaries. +/// +/// Two implementations rather than one runtime call, so that a build without +/// `float-f32` gets a **compile-time** `false` and every guarded branch below +/// disappears. That is not tidiness: `FloatMode` is a runtime value, so a +/// single implementation would keep the float lowering paths live in a +/// Fixed-only device image, which is the leak the feature exists to prevent. +#[cfg(feature = "float-f32")] +#[inline] +fn hardware_fpu(opts: &LowerOpts, abi: &ModuleAbi) -> bool { + crate::lower_f32::uses_hardware_fpu(abi.isa(), opts.float_mode) +} + +/// See the sibling above — without the feature there is no FP emitter to lower +/// for, and this folds every float branch in this module away. +#[cfg(not(feature = "float-f32"))] +#[inline] +fn hardware_fpu(_opts: &LowerOpts, _abi: &ModuleAbi) -> bool { + false +} + +/// One past the highest backend vreg reserved before lowering mints temps. +/// +/// Hardware float reserves a shadow vreg per parameter slot (see +/// [`crate::lower_f32::float_vreg`]); every other mode keeps the IR vreg count +/// exactly as it was, which is what keeps Q32's vreg numbering — and therefore +/// its filetest snapshots — byte-identical. +#[cfg(feature = "float-f32")] +#[inline] +fn vreg_watermark(func: &IrFunction, hardware_fpu: bool) -> u16 { + crate::lower_f32::vreg_watermark(func, hardware_fpu) +} + +#[cfg(not(feature = "float-f32"))] +#[inline] +fn vreg_watermark(func: &IrFunction, _hardware_fpu: bool) -> u16 { + func.vreg_types.len() as u16 +} + +/// Append already-lowered backend vregs to the pool, returning their slice. +/// +/// The counterpart of [`push_vregs_slice`] for operands lowering has built +/// itself — the transfer temps that carry float values across a call boundary, +/// in particular, which have no LPIR vreg to map from. Those are its only +/// callers, so it is gated with them. +#[cfg(feature = "float-f32")] +pub(crate) fn push_backend_vregs( + pool: &mut Vec, + vregs: &[VReg], +) -> Result { + if vregs.len() > u8::MAX as usize { + return Err(LowerError::UnsupportedOp { + description: String::from("vreg slice too long for the native backend"), + }); + } + let start = u16::try_from(pool.len()).map_err(|_| LowerError::UnsupportedOp { + description: String::from("vreg pool exhausted (u16)"), + })?; + pool.extend_from_slice(vregs); + Ok(VRegSlice { + start, + count: vregs.len() as u8, + }) +} + #[inline] pub(crate) fn fa_vreg(v: lpir::VReg) -> VReg { VReg(v.0 as u16) @@ -867,6 +933,20 @@ pub fn lower_lpir_op( if_true, if_false, } => { + // A select over float values is a float instruction, even though + // the op itself is type-agnostic in LPIR. The condition stays an + // integer 0/1 on both sides. + #[cfg(feature = "float-f32")] + if hardware_fpu(opts, abi) && crate::lower_f32::is_float(func, *dst) { + out.push(VInst::FSelect { + dst: crate::lower_f32::float_vreg(func, *dst), + cond: fa_vreg(*cond), + if_true: crate::lower_f32::float_vreg(func, *if_true), + if_false: crate::lower_f32::float_vreg(func, *if_false), + src_op: po, + }); + return Ok(()); + } out.push(VInst::Select { dst: fa_vreg(*dst), cond: fa_vreg(*cond), @@ -877,6 +957,18 @@ pub fn lower_lpir_op( Ok(()) } LpirOp::Copy { dst, src } => { + // `mov.s`, not an integer move: the value is in the float file and + // an integer `Mov` would name an address register for it. + #[cfg(feature = "float-f32")] + if hardware_fpu(opts, abi) && crate::lower_f32::is_float(func, *dst) { + out.push(VInst::FAluRR { + op: crate::vinst::FAluRROp::Mov, + dst: crate::lower_f32::float_vreg(func, *dst), + src: crate::lower_f32::float_vreg(func, *src), + src_op: po, + }); + return Ok(()); + } out.push(VInst::Mov { dst: fa_vreg(*dst), src: fa_vreg(*src), @@ -897,6 +989,19 @@ pub fn lower_lpir_op( let off = i32::try_from(*offset).map_err(|_| LowerError::UnsupportedOp { description: String::from("Load: offset does not fit i32"), })?; + // A memory access, not a call boundary: the value goes straight + // into the float file with no transfer. The address is an integer + // on both paths. + #[cfg(feature = "float-f32")] + if hardware_fpu(opts, abi) && crate::lower_f32::is_float(func, *dst) { + out.push(VInst::FLoad32 { + dst: crate::lower_f32::float_vreg(func, *dst), + base: fa_vreg(*base), + offset: off, + src_op: po, + }); + return Ok(()); + } out.push(VInst::Load32 { dst: fa_vreg(*dst), base: fa_vreg(*base), @@ -913,6 +1018,16 @@ pub fn lower_lpir_op( let off = i32::try_from(*offset).map_err(|_| LowerError::UnsupportedOp { description: String::from("Store: offset does not fit i32"), })?; + #[cfg(feature = "float-f32")] + if hardware_fpu(opts, abi) && crate::lower_f32::is_float(func, *value) { + out.push(VInst::FStore32 { + src: crate::lower_f32::float_vreg(func, *value), + base: fa_vreg(*base), + offset: off, + src_op: po, + }); + return Ok(()); + } out.push(VInst::Store32 { src: fa_vreg(*value), base: fa_vreg(*base), @@ -1035,6 +1150,20 @@ pub fn lower_lpir_op( description: String::from("Return: vreg_pool slice out of range"), }); } + // Boundary four: a float return value travels in an address + // register, so it comes out of the float file first. + #[cfg(feature = "float-f32")] + if hardware_fpu(opts, abi) { + let mut words = Vec::with_capacity(slice.len()); + for v in slice { + words.push(crate::lower_f32::word_operand(out, func, *v, temps, po)); + } + out.push(VInst::Ret { + vals: push_backend_vregs(vreg_pool, &words)?, + src_op: po, + }); + return Ok(()); + } out.push(VInst::Ret { vals: push_vregs_slice(vreg_pool, slice)?, src_op: po, @@ -1502,6 +1631,7 @@ pub fn lower_lpir_op( op, abi.isa(), src_op, + func, symbols, vreg_pool, temps, @@ -1567,6 +1697,39 @@ pub fn lower_lpir_op( let caller_passes_sret_ptr = callee_sret_ptr_in_lpir_args(ir, *callee); let caller_sret_vm_abi_swap = caller_passes_sret_ptr && callee_sret_vm_abi_swap(ir, *callee); + // Boundaries two and three. Float arguments come out of the float + // file before the call and float results go back in after it, so + // the `Call`'s own operands are entirely integer-class — which is + // what leaves the sret/vmctx slot machinery and + // `lpir_call_arg_target` working unchanged. + #[cfg(feature = "float-f32")] + if hardware_fpu(opts, abi) { + let target = symbols.intern(name); + let mut arg_words = Vec::with_capacity(args_slice.len()); + for a in args_slice { + arg_words.push(crate::lower_f32::word_operand(out, func, *a, temps, po)); + } + let mut ret_words = Vec::with_capacity(results_slice.len()); + let mut ret_transfers = Vec::with_capacity(results_slice.len()); + for r in results_slice { + let (word, float_dst) = crate::lower_f32::word_result(func, *r, temps); + ret_words.push(word); + ret_transfers.push((word, float_dst)); + } + out.push(VInst::Call { + target, + args: push_backend_vregs(vreg_pool, &arg_words)?, + rets: push_backend_vregs(vreg_pool, &ret_words)?, + callee_uses_sret, + caller_passes_sret_ptr, + caller_sret_vm_abi_swap, + src_op: po, + }); + for (word, float_dst) in ret_transfers { + crate::lower_f32::push_return_transfer(out, word, float_dst, po); + } + return Ok(()); + } out.push(VInst::Call { target: symbols.intern(name), args: push_vregs_slice(vreg_pool, args_slice)?, @@ -2084,6 +2247,7 @@ pub fn lower_ops( abi: &ModuleAbi, opts: &LowerOpts, ) -> Result { + let hw_fpu = hardware_fpu(opts, abi); // Pre-size vectors to reduce allocation overhead during lowering. // Estimate: ~2 vinsts per LPIR op, vreg pool from IR plus headroom for temps. let mut ctx = LowerCtx { @@ -2094,7 +2258,7 @@ pub fn lower_ops( out: Vec::with_capacity(func.body.len().saturating_mul(2)), vreg_pool: Vec::with_capacity(func.vreg_pool.len().saturating_add(64)), symbols: ModuleSymbols::default(), - temps: TempVRegs::new(func.vreg_types.len() as u16), + temps: TempVRegs::new(vreg_watermark(func, hw_fpu)), next_label: 0, loop_stack: Vec::new(), epilogue_label: 0, @@ -2115,15 +2279,25 @@ pub fn lower_ops( src_op: SRC_OP_NONE, }); } + // Boundary one: float parameters arrive in address registers and are moved + // into the float file once, here, before any body op reads them. + #[cfg(feature = "float-f32")] + if hw_fpu { + crate::lower_f32::push_entry_param_transfers(&mut ctx.out, func); + } + let prologue_len = ctx.out.len(); let body_root = ctx.lower_range(0, func.body.len())?; ctx.out.push(VInst::Label(ctx.epilogue_label, SRC_OP_NONE)); - // The entry FuelCheck sits before the body's regions; give it its own - // Linear region so the regalloc walk covers it (the walk only visits - // VInsts reachable through the region tree). - let root = if opts.fuel { - let entry_lin = ctx - .region_tree - .push(crate::region::Region::Linear { start: 0, end: 1 }); + // The prologue (entry FuelCheck, float parameter transfers) sits before the + // body's regions; give it its own Linear region so the regalloc walk covers + // it — the walk only visits VInsts reachable through the region tree, and a + // parameter transfer it never saw would be dropped from the allocation + // silently. + let root = if prologue_len > 0 { + let entry_lin = ctx.region_tree.push(crate::region::Region::Linear { + start: 0, + end: prologue_len as u16, + }); if body_root == REGION_ID_NONE { entry_lin } else { diff --git a/lp-shader/lpvm-native/src/lower_f32.rs b/lp-shader/lpvm-native/src/lower_f32.rs index d7c3215b2..c4025a51d 100644 --- a/lp-shader/lpvm-native/src/lower_f32.rs +++ b/lp-shader/lpvm-native/src/lower_f32.rs @@ -65,18 +65,79 @@ //! the IEEE encoding and lower to one or two integer instructions. `Fabs` in //! particular **must** be the sign-bit mask rather than a comparison, so it //! stays exact on NaN and `-0.0` (`docs/design/float.md` §3). +//! +//! # Hardware FPU: single instructions, and a call for the rest +//! +//! On a part with a real single-precision FPU — the ESP32-S3's Floating-Point +//! Coprocessor today — the point of native f32 is that a handful of operations +//! collapse from a call or a five-instruction Q32 sequence to **one +//! instruction**. That set is exactly what is inlined (M7 D4): +//! +//! | Inlined — one FP instruction | Routed to an M5 builtin | +//! |---|---| +//! | `Fadd` `Fsub` `Fmul` | `Fdiv` `Fsqrt` | +//! | `Fabs` `Fneg`, float moves | `Ffloor` `Fceil` `Ftrunc` `Fnearest` | +//! | the six comparisons, float select | `Fmin` `Fmax` | +//! | `ItofS` `ItofU` | `FtoiSatS` `FtoiSatU`, the unorm conversions | +//! | float loads and stores, the AR↔FR transfers | every transcendental and `lpfn` | +//! +//! The right-hand column is the same mechanism Q32 uses today, and every symbol +//! on it already exists. Two of the omissions are deliberate rather than +//! pending. **Division** is a builtin call in Q32 as well, so calling one here +//! is parity; inlining the `div0.s`/`divn.s` estimate sequence buys speed and +//! costs a hard dependency on an exhaustive extraction of the chip's +//! implementation-defined estimate tables. **Float→int** is routed because +//! `trunc.s` alone may not satisfy `float.md` §3: whether this silicon +//! saturates or wraps for finite out-of-range inputs is an open measurement, +//! and a builtin that is correct by construction beats an instruction that +//! might be. +//! +//! # The hardware calling convention: FR-internally, AR-at-boundaries +//! +//! Float values live in float registers inside a function body and travel in +//! **address registers, as raw IEEE bit patterns**, across every parameter, +//! call-argument, call-return and function-return boundary (M7 D1). +//! +//! This is not a free choice. The `lps-builtins` f32 family is compiled by +//! `xtensa-esp32s3-elf-gcc`, and that toolchain's measured ABI (M6-P4) passes +//! floats in `a2..a7` and returns them in `a2`; no FR is callee-saved and none +//! carries an argument. A second convention for LPIR-internal calls would push +//! a mode axis through the sret/vmctx machinery for no measured gain. +//! +//! **Lowering inserts the transfers, at exactly four places and nowhere else** +//! (M7 D2): +//! +//! | Boundary | Transfer | +//! |---|---| +//! | function entry, float parameter | [`push_entry_param_transfers`] → `Wfr` | +//! | before a call, float argument | [`word_operand`] → `Rfr` | +//! | after a call, float result | [`word_result`] → `Wfr` | +//! | before a return, float value | [`word_operand`] → `Rfr` | +//! +//! Not regalloc, and not the emitter. Doing it here means the allocator sees +//! only ordinary same-class copies plus two cross-class moves it can allocate +//! normally, the emitter's class gate stays a simple dispatch, and — the part +//! that matters for review — **the ABI is visible in the VInst dump**, so a +//! test can read it rather than infer it from generated code. +//! +//! The consequence worth stating explicitly: a `Call`'s arguments and results +//! are always integer-class by the time the allocator sees them, which is why +//! [`crate::regalloc::classes`] has no float arms for `Call` or `Ret` and why +//! `IsaTarget`'s float ABI hooks are permanently empty. use alloc::string::String; use alloc::vec::Vec; -use lpir::LpirOp; +use lpir::{IrFunction, IrType, LpirOp}; +use lps_builtin_ids::{Mode as BuiltinMode, lpir_builtin_id}; use crate::abi::RegClass; use crate::error::LowerError; use crate::isa::{F32Lowering, IsaTarget}; use crate::lower::{fa_vreg, push_vregs_slice}; use crate::vinst::{ - AluOp, IcmpCond, ModuleSymbols, TempVRegs, VInst, VReg, VRegSlice, pack_src_op, + AluOp, FAluOp, FAluRROp, FcmpCond, IcmpCond, ModuleSymbols, TempVRegs, VInst, VReg, VRegSlice, + pack_src_op, }; /// IEEE-754 binary32 sign bit — the mask `Fneg` flips and `Fabs` clears. @@ -98,6 +159,7 @@ pub fn lower_f32_op( op: &LpirOp, isa: IsaTarget, src_op: Option, + func: &IrFunction, symbols: &mut ModuleSymbols, vreg_pool: &mut Vec, temps: &mut TempVRegs, @@ -112,16 +174,9 @@ pub fn lower_f32_op( and no hardware-FPU emitter for this target" ), }), - // No `IsaTarget` answers this today, and `isa::tests:: - // f32_lowering_never_claims_hardware` keeps it that way. Erroring rather - // than falling through to soft float is deliberate: a target that claims - // an FPU and silently gets library calls would pass its tests and be - // ~30x slower than it was supposed to be, with nothing pointing at why. - F32Lowering::HardwareFpu => Err(LowerError::UnsupportedOp { - description: alloc::format!( - "{isa:?} claims a hardware FPU but this crate has no FP emitter" - ), - }), + F32Lowering::HardwareFpu => { + lower_hardware_fpu_op(out, op, src_op, func, symbols, vreg_pool, temps) + } } } @@ -457,213 +512,806 @@ fn lower_soft_float_op( } } -/// Symbol for `LpirOp::FtoiSatS` in f32 mode, and why it is not `__fixsfsi`. +// ─── Hardware FPU (Xtensa / ESP32-S3) ─────────────────────────────────────── + +/// True when this compile emits hardware FP instructions, and therefore owes +/// the AR↔FR boundary transfers described in this module's docs. /// -/// `__fixsfsi` **is** in the soft-float ABI, and the direct-call rule would -/// otherwise apply. It is skipped because the ABI does not pin the answers LPIR -/// needs: libgcc documents float→int conversion as undefined for out-of-range -/// inputs, and `docs/design/float.md` §3 requires finite out-of-range values to -/// saturate. Rust's `compiler_builtins` happens to saturate (and map NaN to 0), -/// but the ESP32-C6 resolves this symbol to the chip's ROM `rvfplib`, which is a -/// *different implementation* — so calling it would mean the emulator and the -/// silicon could legally disagree at exactly the edges the corpus tests. +/// The one predicate `lower.rs` consults. Everything the hardware path does +/// differently — float loads and stores, float selects, the transfers at calls +/// and returns, the entry parameter shadows — hangs off this single answer, so +/// there is exactly one place where a build can be wrong about whether it has +/// an FPU. +pub fn uses_hardware_fpu(isa: IsaTarget, mode: lpir::FloatMode) -> bool { + mode == lpir::FloatMode::F32 && isa.f32_lowering() == F32Lowering::HardwareFpu +} + +/// Is LPIR vreg `v` float-typed in `func`? /// -/// `__lp_lpir_ftoi_sat_s_f32` is one implementation everywhere, and it is the -/// same rule wasm's `i32.trunc_sat_f32_s` follows, so the three f32 targets -/// agree by construction. The C6 harness measures the ROM's behavior separately -/// (`fw-esp32c6` `test_f32_softfloat`) so this can be revisited with data. -fn f32_ftoi_sat_s_symbol() -> &'static str { - "__lp_lpir_ftoi_sat_s_f32" +/// In hardware-f32 mode this is exactly "does `v` live in the float register +/// file", which is why it can drive the class decisions. In Q32 mode it is +/// *not* — a Q16.16 `float` is an integer — and no caller here reaches it in +/// that mode. +pub fn is_float(func: &IrFunction, v: lpir::VReg) -> bool { + func.vreg_types.get(v.0 as usize) == Some(&IrType::F32) } -/// Unsigned sibling of [`f32_ftoi_sat_s_symbol`]; same reasoning, `__fixunssfsi`. -fn f32_ftoi_sat_u_symbol() -> &'static str { - "__lp_lpir_ftoi_sat_u_f32" +/// First backend vreg of the entry parameter shadow block. +fn param_shadow_base(func: &IrFunction) -> u16 { + // `max` rather than `vreg_types.len()`: a function whose declared vreg + // table is shorter than its ABI parameter list would otherwise alias a + // shadow onto a parameter's own vreg (`regalloc::render` has the same + // guard for the same reason). + core::cmp::max(func.vreg_types.len() as u16, func.total_param_slots()) } -/// `dst = src OP imm` where `imm` is a full 32-bit mask (never a legal -/// 12-bit immediate on rv32, so it always materializes). -fn mask_op( - out: &mut Vec, - temps: &mut TempVRegs, - op: AluOp, - mask: i32, - dst: VReg, - src: VReg, - po: u16, -) { - let m = temps.mint(); - out.push(VInst::IConst32 { - dst: m, - val: mask, - src_op: po, - }); - out.push(VInst::AluRRR { - op, - dst, - src1: src, - src2: m, - src_op: po, - }); +/// One past the highest backend vreg lowering reserves before minting temps. +/// +/// In hardware-f32 mode this leaves room for one shadow per parameter slot; in +/// every other mode it is the IR vreg count unchanged, so Q32 and soft float +/// keep the exact vreg numbering they had — which is what keeps their filetest +/// snapshots byte-identical. +pub fn vreg_watermark(func: &IrFunction, hardware_fpu: bool) -> u16 { + if hardware_fpu { + param_shadow_base(func).saturating_add(func.total_param_slots()) + } else { + func.vreg_types.len() as u16 + } } -/// `dst = (call(lhs, rhs) COND 0)` — the comparison shape from the module docs. -#[allow( - clippy::too_many_arguments, - reason = "one call site per comparison op; splitting it would only move the arguments" -)] -fn soft_compare( - out: &mut Vec, - symbols: &mut ModuleSymbols, - vreg_pool: &mut Vec, - temps: &mut TempVRegs, - symbol: &'static str, - cond: IcmpCond, - dst: lpir::VReg, - lhs: lpir::VReg, - rhs: lpir::VReg, - po: u16, -) -> Result<(), LowerError> { - let raw = temps.mint(); - call_native( - out, - symbols, - vreg_pool, - symbol, - &[fa_vreg(lhs), fa_vreg(rhs)], - &[raw], - po, - )?; - let zero = temps.mint(); - out.push(VInst::IConst32 { - dst: zero, - val: 0, - src_op: po, - }); - out.push(VInst::Icmp { - dst: fa_vreg(dst), - lhs: raw, - rhs: zero, - cond, - src_op: po, - }); - Ok(()) +/// The backend vreg holding `v`'s value **in the float register file**. +/// +/// For an ordinary float value this is the identity mapping: the value is +/// computed by an FP instruction and never has another form. +/// +/// A float **parameter** is the exception, and the reason this function exists. +/// It arrives in an address register (M7 D1) and its backend vreg is precolored +/// to that AR by the ABI, so the same vreg cannot also be the FR the body +/// computes with — a vreg has one class for its whole life. The float view gets +/// its own vreg in the shadow block, filled by a `Wfr` at function entry. +/// +/// The split is deterministic rather than a lookup table on purpose: it costs +/// no per-function allocation on the device, and it means "the word view of a +/// parameter" stays reachable as plain [`fa_vreg`] for the passthrough case +/// (a float parameter handed straight to another call needs no transfer at +/// all). +pub fn float_vreg(func: &IrFunction, v: lpir::VReg) -> VReg { + if is_param(func, v) { + VReg(param_shadow_base(func).saturating_add(v.0 as u16)) + } else { + fa_vreg(v) + } } -/// [`crate::lower`]'s `sym_call` with the packed `src_op` already computed. -fn soft_call( - out: &mut Vec, - symbols: &mut ModuleSymbols, - vreg_pool: &mut Vec, - symbol: &'static str, - args: &[lpir::VReg], - rets: &[lpir::VReg], - po: u16, -) -> Result<(), LowerError> { - out.push(VInst::Call { - target: symbols.intern(symbol), - args: push_vregs_slice(vreg_pool, args)?, - rets: push_vregs_slice(vreg_pool, rets)?, - callee_uses_sret: false, - caller_passes_sret_ptr: false, - caller_sret_vm_abi_swap: false, - src_op: po, - }); - Ok(()) +fn is_param(func: &IrFunction, v: lpir::VReg) -> bool { + (v.0 as u16) < func.total_param_slots() } -/// [`soft_call`] for operands that are already backend vregs (temps), not LPIR -/// ones. -fn call_native( +/// Emit the function-entry transfers: one `Wfr` per float parameter. +/// +/// The first of the four boundaries lowering owns. Returns the number of +/// instructions pushed so the caller can extend the prologue's region — the +/// regalloc walk only visits VInsts reachable through the region tree, and a +/// transfer outside it would be silently dropped. +pub fn push_entry_param_transfers(out: &mut Vec, func: &IrFunction) -> usize { + let mut n = 0; + for slot in 0..func.total_param_slots() { + let v = lpir::VReg(u32::from(slot)); + if !is_float(func, v) { + continue; + } + out.push(VInst::Wfr { + dst: float_vreg(func, v), + src: fa_vreg(v), + src_op: crate::vinst::SRC_OP_NONE, + }); + n += 1; + } + n +} + +/// The backend vreg holding `v` as a raw IEEE **word** in an address register, +/// emitting the `Rfr` transfer first when the value currently lives in an FR. +/// +/// Boundaries two and four: call arguments and return values. A non-float value +/// is already a word and passes through untouched, and so does a float +/// *parameter* — it never left the address register, so re-deriving it from the +/// shadow would be a pointless round trip. +pub fn word_operand( out: &mut Vec, - symbols: &mut ModuleSymbols, - vreg_pool: &mut Vec, - symbol: &'static str, - args: &[VReg], - rets: &[VReg], + func: &IrFunction, + v: lpir::VReg, + temps: &mut TempVRegs, po: u16, -) -> Result<(), LowerError> { - out.push(VInst::Call { - target: symbols.intern(symbol), - args: push_native_vregs(vreg_pool, args)?, - rets: push_native_vregs(vreg_pool, rets)?, - callee_uses_sret: false, - caller_passes_sret_ptr: false, - caller_sret_vm_abi_swap: false, +) -> VReg { + if !is_float(func, v) || is_param(func, v) { + return fa_vreg(v); + } + let word = temps.mint(); + out.push(VInst::Rfr { + dst: word, + src: float_vreg(func, v), src_op: po, }); - Ok(()) + word } -fn push_native_vregs(pool: &mut Vec, vregs: &[VReg]) -> Result { - if vregs.len() > u8::MAX as usize { - return Err(LowerError::UnsupportedOp { - description: String::from("vreg slice too long for FA backend"), - }); +/// Plan a call's result: the vreg the `Call` should define, plus the transfer +/// owed afterwards. +/// +/// Boundary three. A float result arrives in an address register, so the call +/// defines a fresh word temp and the returned `Some(float_dst)` is the `Wfr` +/// the caller must push *after* the `Call` — ordering this function cannot do +/// itself, since the call has not been emitted yet. +pub fn word_result( + func: &IrFunction, + v: lpir::VReg, + temps: &mut TempVRegs, +) -> (VReg, Option) { + if !is_float(func, v) { + return (fa_vreg(v), None); } - let start = u16::try_from(pool.len()).map_err(|_| LowerError::UnsupportedOp { - description: String::from("vreg pool exhausted (u16)"), - })?; - pool.extend_from_slice(vregs); - Ok(VRegSlice { - start, - count: vregs.len() as u8, - }) + let word = temps.mint(); + (word, Some(float_vreg(func, v))) } -/// Compile-time assertion that soft float stays integer-class. +/// Resolve an LPIR op name to its native-f32 builtin symbol. /// -/// Not a runtime check — a note where a reader will look. Every `VInst` this -/// module emits is `Call`, `IConst32`, `AluRRR`, `Icmp`, or `Mov`, and -/// [`crate::abi::classify`] answers [`RegClass::Int`] for all of them. That is -/// the property that keeps the empty float register pool from ever being -/// consulted on this path. -const _: () = { - assert!(matches!(RegClass::Int, RegClass::Int)); -}; - -#[cfg(test)] -mod tests { - use super::*; - - fn lower(op: LpirOp, isa: IsaTarget) -> (Vec, ModuleSymbols, Vec) { - let mut out = Vec::new(); - let mut symbols = ModuleSymbols::default(); - let mut pool = Vec::new(); - let mut temps = TempVRegs::new(64); - lower_f32_op( - &mut out, - &op, - isa, - Some(0), - &mut symbols, - &mut pool, - &mut temps, - ) - .expect("lowering should succeed"); - (out, symbols, pool) - } +/// Goes through [`BuiltinId`] rather than spelling `"__lp_lpir_fdiv_f32"` as a +/// literal, so a symbol that does not exist is a resolution error here instead +/// of a link failure three phases later. The error names the op and the mode +/// because the one thing this must never do is fall back to the Q32 sibling: +/// the resolver's rule is that a resolver never crosses modes, and a Q32 callee +/// given IEEE bit patterns returns plausible wrong numbers rather than failing. +fn f32_builtin_symbol(name: &str, argc: usize) -> Result<&'static str, LowerError> { + lpir_builtin_id(name, argc, BuiltinMode::F32) + .map(|id| id.name()) + .ok_or_else(|| LowerError::UnsupportedOp { + description: alloc::format!( + "no native-f32 builtin for LPIR `{name}`/{argc} arg(s); this is a gap in the \ + f32 builtin family, and a resolver never crosses modes to a Q32 sibling" + ), + }) +} - fn called_symbols(insts: &[VInst], symbols: &ModuleSymbols) -> Vec { - insts - .iter() - .filter_map(|i| match i { - VInst::Call { target, .. } => Some(String::from(symbols.name(*target))), - _ => None, - }) - .collect() - } +/// The hardware-FPU arm: single FP instructions where one exists, a builtin +/// call where it does not (M7 D4). +#[allow( + clippy::too_many_arguments, + reason = "mirrors lower_lpir_op's threading of the same lowering context" +)] +fn lower_hardware_fpu_op( + out: &mut Vec, + op: &LpirOp, + src_op: Option, + func: &IrFunction, + symbols: &mut ModuleSymbols, + vreg_pool: &mut Vec, + temps: &mut TempVRegs, +) -> Result<(), LowerError> { + let po = pack_src_op(src_op); + // Local shorthands. `f` is the float-file view of a vreg, `i` the ordinary + // integer one; picking the wrong one is a register-class error the + // allocator's verifier catches, not a silent miscompile. + let f = |v: lpir::VReg| float_vreg(func, v); + let i = fa_vreg; - fn v(n: u32) -> lpir::VReg { - lpir::VReg(n) - } + match op { + // ── One FP instruction each ────────────────────────────────────────── + LpirOp::Fadd { dst, lhs, rhs } => { + push_falu(out, FAluOp::Add, f(*dst), f(*lhs), f(*rhs), po); + Ok(()) + } + LpirOp::Fsub { dst, lhs, rhs } => { + push_falu(out, FAluOp::Sub, f(*dst), f(*lhs), f(*rhs), po); + Ok(()) + } + LpirOp::Fmul { dst, lhs, rhs } => { + push_falu(out, FAluOp::Mul, f(*dst), f(*lhs), f(*rhs), po); + Ok(()) + } + // Sign-bit operations in the float domain. `abs.s`/`neg.s` are defined + // as bit manipulations, so they stay exact on NaN and `-0.0` + // (`docs/design/float.md` §3) — the same property the soft-float path + // gets from its integer masks. + LpirOp::Fabs { dst, src } => { + push_falu_rr(out, FAluRROp::Abs, f(*dst), f(*src), po); + Ok(()) + } + LpirOp::Fneg { dst, src } => { + push_falu_rr(out, FAluRROp::Neg, f(*dst), f(*src), po); + Ok(()) + } - #[cfg(feature = "isa-rv32")] - const RV32: IsaTarget = IsaTarget::Rv32imac; + // Comparisons produce an ordinary integer 0/1, so `dst` is the plain + // vreg. Each condition is its own `FcmpCond` — `Fne` in particular is + // NOT a negated `Eq`, because the two differ exactly on NaN, which is + // the row `float.md` §3 makes normative. + LpirOp::Feq { dst, lhs, rhs } => { + push_fcmp(out, FcmpCond::Eq, i(*dst), f(*lhs), f(*rhs), po); + Ok(()) + } + LpirOp::Fne { dst, lhs, rhs } => { + push_fcmp(out, FcmpCond::Ne, i(*dst), f(*lhs), f(*rhs), po); + Ok(()) + } + LpirOp::Flt { dst, lhs, rhs } => { + push_fcmp(out, FcmpCond::Lt, i(*dst), f(*lhs), f(*rhs), po); + Ok(()) + } + LpirOp::Fle { dst, lhs, rhs } => { + push_fcmp(out, FcmpCond::Le, i(*dst), f(*lhs), f(*rhs), po); + Ok(()) + } + LpirOp::Fgt { dst, lhs, rhs } => { + push_fcmp(out, FcmpCond::Gt, i(*dst), f(*lhs), f(*rhs), po); + Ok(()) + } + LpirOp::Fge { dst, lhs, rhs } => { + push_fcmp(out, FcmpCond::Ge, i(*dst), f(*lhs), f(*rhs), po); + Ok(()) + } - /// The D1 claim, asserted: arithmetic is one call at the platform symbol, - /// with no LightPlayer name in between. - #[cfg(feature = "isa-rv32")] + // `float.s` / `ufloat.s` with scale 0: one correctly-rounded + // instruction each, and — unlike the float→int direction — with no + // saturation question attached, which is why this half is inlined and + // the other half is a builtin call (M7 D4). + LpirOp::ItofS { dst, src } => { + out.push(VInst::IToF { + dst: f(*dst), + src: i(*src), + signed: true, + src_op: po, + }); + Ok(()) + } + LpirOp::ItofU { dst, src } => { + out.push(VInst::IToF { + dst: f(*dst), + src: i(*src), + signed: false, + src_op: po, + }); + Ok(()) + } + + // No `FConst32` VInst (M7 D11): materialize the IEEE pattern with the + // integer constant machinery that already has a literal pool, then + // transfer. Two instructions, no third literal pool to maintain. + LpirOp::FconstF32 { dst, value } => { + let word = temps.mint(); + out.push(VInst::IConst32 { + dst: word, + val: value.to_bits() as i32, + src_op: po, + }); + out.push(VInst::Wfr { + dst: f(*dst), + src: word, + src_op: po, + }); + Ok(()) + } + // A reinterpretation, not a conversion — which on this target is + // exactly what the AR→FR transfer instruction does. + LpirOp::FfromI32Bits { dst, src } => { + out.push(VInst::Wfr { + dst: f(*dst), + src: i(*src), + src_op: po, + }); + Ok(()) + } + + // ── Builtin calls (M7 D4) ──────────────────────────────────────────── + // + // Everything that is not a single instruction goes to the M5 f32 + // family, the same mechanism Q32 uses today. Division is the one worth + // naming: Q32 calls a builtin for it as well, so this is parity rather + // than a regression, and inlining the `div0.s`/`divn.s` estimate + // sequence would buy speed at the cost of a hard dependency on M6-P6's + // exhaustive tables. + LpirOp::Fdiv { dst, lhs, rhs } => fp_call( + out, + func, + symbols, + vreg_pool, + temps, + "fdiv", + &[*lhs, *rhs], + &[*dst], + po, + ), + LpirOp::Fsqrt { dst, src } => fp_call( + out, + func, + symbols, + vreg_pool, + temps, + "sqrt", + &[*src], + &[*dst], + po, + ), + LpirOp::Ffloor { dst, src } => fp_call( + out, + func, + symbols, + vreg_pool, + temps, + "ffloor", + &[*src], + &[*dst], + po, + ), + LpirOp::Fceil { dst, src } => fp_call( + out, + func, + symbols, + vreg_pool, + temps, + "fceil", + &[*src], + &[*dst], + po, + ), + LpirOp::Ftrunc { dst, src } => fp_call( + out, + func, + symbols, + vreg_pool, + temps, + "ftrunc", + &[*src], + &[*dst], + po, + ), + LpirOp::Fnearest { dst, src } => fp_call( + out, + func, + symbols, + vreg_pool, + temps, + "fnearest", + &[*src], + &[*dst], + po, + ), + LpirOp::Fmin { dst, lhs, rhs } => fp_call( + out, + func, + symbols, + vreg_pool, + temps, + "fmin", + &[*lhs, *rhs], + &[*dst], + po, + ), + LpirOp::Fmax { dst, lhs, rhs } => fp_call( + out, + func, + symbols, + vreg_pool, + temps, + "fmax", + &[*lhs, *rhs], + &[*dst], + po, + ), + // Saturating float→int. `trunc.s` alone does not satisfy + // `float.md` §3 — whether the S3's truncation saturates or wraps for + // finite out-of-range inputs is an unresolved M6-P6 measurement — so + // this routes to a builtin that is correct by construction rather than + // to an instruction that might be. An inline `trunc.s` (plus a clamp, + // if the measurement says so) is named follow-up work. + LpirOp::FtoiSatS { dst, src } => fp_call( + out, + func, + symbols, + vreg_pool, + temps, + "ftoi_sat_s", + &[*src], + &[*dst], + po, + ), + LpirOp::FtoiSatU { dst, src } => fp_call( + out, + func, + symbols, + vreg_pool, + temps, + "ftoi_sat_u", + &[*src], + &[*dst], + po, + ), + LpirOp::FtoUnorm16 { dst, src } => fp_call( + out, + func, + symbols, + vreg_pool, + temps, + "fto_unorm16", + &[*src], + &[*dst], + po, + ), + LpirOp::FtoUnorm8 { dst, src } => fp_call( + out, + func, + symbols, + vreg_pool, + temps, + "fto_unorm8", + &[*src], + &[*dst], + po, + ), + LpirOp::Unorm16toF { dst, src } => fp_call( + out, + func, + symbols, + vreg_pool, + temps, + "unorm16_to_f", + &[*src], + &[*dst], + po, + ), + LpirOp::Unorm8toF { dst, src } => fp_call( + out, + func, + symbols, + vreg_pool, + temps, + "unorm8_to_f", + &[*src], + &[*dst], + po, + ), + // `x / c`. The Q32 path rewrites this to a multiply by a precomputed + // reciprocal, which is a legitimate approximation in Q16.16 and an + // incorrect one here: `/` is a correctly-rounded row of `float.md` §3. + LpirOp::FdivConstF32 { dst, lhs, rhs } => { + let divisor_word = temps.mint(); + out.push(VInst::IConst32 { + dst: divisor_word, + val: rhs.to_bits() as i32, + src_op: po, + }); + let lhs_word = word_operand(out, func, *lhs, temps, po); + let (ret_word, ret_float) = word_result(func, *dst, temps); + call_native( + out, + symbols, + vreg_pool, + f32_builtin_symbol("fdiv", 2)?, + &[lhs_word, divisor_word], + &[ret_word], + po, + )?; + push_return_transfer(out, ret_word, ret_float, po); + Ok(()) + } + + other => Err(LowerError::UnsupportedOp { + description: alloc::format!("not a float op, or unhandled in f32 mode: {other:?}"), + }), + } +} + +fn push_falu(out: &mut Vec, op: FAluOp, dst: VReg, src1: VReg, src2: VReg, po: u16) { + out.push(VInst::FAluRRR { + op, + dst, + src1, + src2, + src_op: po, + }); +} + +fn push_falu_rr(out: &mut Vec, op: FAluRROp, dst: VReg, src: VReg, po: u16) { + out.push(VInst::FAluRR { + op, + dst, + src, + src_op: po, + }); +} + +fn push_fcmp(out: &mut Vec, cond: FcmpCond, dst: VReg, lhs: VReg, rhs: VReg, po: u16) { + out.push(VInst::Fcmp { + dst, + lhs, + rhs, + cond, + src_op: po, + }); +} + +/// A builtin call with the boundary transfers around it: `Rfr` per float +/// argument, the `Call`, then `Wfr` per float result. +#[allow( + clippy::too_many_arguments, + reason = "one call site per builtin-routed op; splitting it would only move the arguments" +)] +fn fp_call( + out: &mut Vec, + func: &IrFunction, + symbols: &mut ModuleSymbols, + vreg_pool: &mut Vec, + temps: &mut TempVRegs, + lpir_name: &str, + args: &[lpir::VReg], + rets: &[lpir::VReg], + po: u16, +) -> Result<(), LowerError> { + let symbol = f32_builtin_symbol(lpir_name, args.len())?; + let mut arg_words = Vec::with_capacity(args.len()); + for a in args { + arg_words.push(word_operand(out, func, *a, temps, po)); + } + let mut ret_words = Vec::with_capacity(rets.len()); + let mut ret_transfers = Vec::with_capacity(rets.len()); + for r in rets { + let (word, float_dst) = word_result(func, *r, temps); + ret_words.push(word); + ret_transfers.push((word, float_dst)); + } + call_native(out, symbols, vreg_pool, symbol, &arg_words, &ret_words, po)?; + for (word, float_dst) in ret_transfers { + push_return_transfer(out, word, float_dst, po); + } + Ok(()) +} + +/// Push the `Wfr` a float-returning call owes, if it owes one. +pub fn push_return_transfer(out: &mut Vec, word: VReg, float_dst: Option, po: u16) { + if let Some(dst) = float_dst { + out.push(VInst::Wfr { + dst, + src: word, + src_op: po, + }); + } +} + +/// Symbol for `LpirOp::FtoiSatS` in f32 mode, and why it is not `__fixsfsi`. +/// +/// `__fixsfsi` **is** in the soft-float ABI, and the direct-call rule would +/// otherwise apply. It is skipped because the ABI does not pin the answers LPIR +/// needs: libgcc documents float→int conversion as undefined for out-of-range +/// inputs, and `docs/design/float.md` §3 requires finite out-of-range values to +/// saturate. Rust's `compiler_builtins` happens to saturate (and map NaN to 0), +/// but the ESP32-C6 resolves this symbol to the chip's ROM `rvfplib`, which is a +/// *different implementation* — so calling it would mean the emulator and the +/// silicon could legally disagree at exactly the edges the corpus tests. +/// +/// `__lp_lpir_ftoi_sat_s_f32` is one implementation everywhere, and it is the +/// same rule wasm's `i32.trunc_sat_f32_s` follows, so the three f32 targets +/// agree by construction. The C6 harness measures the ROM's behavior separately +/// (`fw-esp32c6` `test_f32_softfloat`) so this can be revisited with data. +fn f32_ftoi_sat_s_symbol() -> &'static str { + "__lp_lpir_ftoi_sat_s_f32" +} + +/// Unsigned sibling of [`f32_ftoi_sat_s_symbol`]; same reasoning, `__fixunssfsi`. +fn f32_ftoi_sat_u_symbol() -> &'static str { + "__lp_lpir_ftoi_sat_u_f32" +} + +/// `dst = src OP imm` where `imm` is a full 32-bit mask (never a legal +/// 12-bit immediate on rv32, so it always materializes). +fn mask_op( + out: &mut Vec, + temps: &mut TempVRegs, + op: AluOp, + mask: i32, + dst: VReg, + src: VReg, + po: u16, +) { + let m = temps.mint(); + out.push(VInst::IConst32 { + dst: m, + val: mask, + src_op: po, + }); + out.push(VInst::AluRRR { + op, + dst, + src1: src, + src2: m, + src_op: po, + }); +} + +/// `dst = (call(lhs, rhs) COND 0)` — the comparison shape from the module docs. +#[allow( + clippy::too_many_arguments, + reason = "one call site per comparison op; splitting it would only move the arguments" +)] +fn soft_compare( + out: &mut Vec, + symbols: &mut ModuleSymbols, + vreg_pool: &mut Vec, + temps: &mut TempVRegs, + symbol: &'static str, + cond: IcmpCond, + dst: lpir::VReg, + lhs: lpir::VReg, + rhs: lpir::VReg, + po: u16, +) -> Result<(), LowerError> { + let raw = temps.mint(); + call_native( + out, + symbols, + vreg_pool, + symbol, + &[fa_vreg(lhs), fa_vreg(rhs)], + &[raw], + po, + )?; + let zero = temps.mint(); + out.push(VInst::IConst32 { + dst: zero, + val: 0, + src_op: po, + }); + out.push(VInst::Icmp { + dst: fa_vreg(dst), + lhs: raw, + rhs: zero, + cond, + src_op: po, + }); + Ok(()) +} + +/// [`crate::lower`]'s `sym_call` with the packed `src_op` already computed. +fn soft_call( + out: &mut Vec, + symbols: &mut ModuleSymbols, + vreg_pool: &mut Vec, + symbol: &'static str, + args: &[lpir::VReg], + rets: &[lpir::VReg], + po: u16, +) -> Result<(), LowerError> { + out.push(VInst::Call { + target: symbols.intern(symbol), + args: push_vregs_slice(vreg_pool, args)?, + rets: push_vregs_slice(vreg_pool, rets)?, + callee_uses_sret: false, + caller_passes_sret_ptr: false, + caller_sret_vm_abi_swap: false, + src_op: po, + }); + Ok(()) +} + +/// [`soft_call`] for operands that are already backend vregs (temps), not LPIR +/// ones. +fn call_native( + out: &mut Vec, + symbols: &mut ModuleSymbols, + vreg_pool: &mut Vec, + symbol: &'static str, + args: &[VReg], + rets: &[VReg], + po: u16, +) -> Result<(), LowerError> { + out.push(VInst::Call { + target: symbols.intern(symbol), + args: push_native_vregs(vreg_pool, args)?, + rets: push_native_vregs(vreg_pool, rets)?, + callee_uses_sret: false, + caller_passes_sret_ptr: false, + caller_sret_vm_abi_swap: false, + src_op: po, + }); + Ok(()) +} + +fn push_native_vregs(pool: &mut Vec, vregs: &[VReg]) -> Result { + if vregs.len() > u8::MAX as usize { + return Err(LowerError::UnsupportedOp { + description: String::from("vreg slice too long for FA backend"), + }); + } + let start = u16::try_from(pool.len()).map_err(|_| LowerError::UnsupportedOp { + description: String::from("vreg pool exhausted (u16)"), + })?; + pool.extend_from_slice(vregs); + Ok(VRegSlice { + start, + count: vregs.len() as u8, + }) +} + +/// Compile-time assertion that soft float stays integer-class. +/// +/// Not a runtime check — a note where a reader will look. Every `VInst` this +/// module emits is `Call`, `IConst32`, `AluRRR`, `Icmp`, or `Mov`, and +/// [`crate::abi::classify`] answers [`RegClass::Int`] for all of them. That is +/// the property that keeps the empty float register pool from ever being +/// consulted on this path. +const _: () = { + assert!(matches!(RegClass::Int, RegClass::Int)); +}; + +#[cfg(test)] +mod tests { + use super::*; + + /// A function whose vregs `0..n` are all float-typed and which declares no + /// parameters, so `float_vreg` is the identity and the tests read as the + /// instruction shapes they are about. + fn float_func(n: u32) -> IrFunction { + IrFunction { + name: String::new(), + is_entry: true, + vmctx_vreg: lpir::VReg(0), + param_count: 0, + return_types: alloc::vec![], + sret_arg: None, + vreg_types: (0..n).map(|_| IrType::F32).collect(), + slots: alloc::vec![], + body: alloc::vec![].into(), + vreg_pool: alloc::vec![], + } + } + + fn lower(op: LpirOp, isa: IsaTarget) -> (Vec, ModuleSymbols, Vec) { + lower_in(op, isa, &float_func(16)) + } + + fn lower_in( + op: LpirOp, + isa: IsaTarget, + func: &IrFunction, + ) -> (Vec, ModuleSymbols, Vec) { + let mut out = Vec::new(); + let mut symbols = ModuleSymbols::default(); + let mut pool = Vec::new(); + let mut temps = TempVRegs::new(64); + lower_f32_op( + &mut out, + &op, + isa, + Some(0), + func, + &mut symbols, + &mut pool, + &mut temps, + ) + .expect("lowering should succeed"); + (out, symbols, pool) + } + + fn called_symbols(insts: &[VInst], symbols: &ModuleSymbols) -> Vec { + insts + .iter() + .filter_map(|i| match i { + VInst::Call { target, .. } => Some(String::from(symbols.name(*target))), + _ => None, + }) + .collect() + } + + fn v(n: u32) -> lpir::VReg { + lpir::VReg(n) + } + + #[cfg(feature = "isa-rv32")] + const RV32: IsaTarget = IsaTarget::Rv32imac; + + /// The D1 claim, asserted: arithmetic is one call at the platform symbol, + /// with no LightPlayer name in between. + #[cfg(feature = "isa-rv32")] #[test] fn arithmetic_calls_compiler_rt_directly() { for (op, want) in [ @@ -944,10 +1592,437 @@ mod tests { } } - /// The seam refuses rather than silently giving an FPU part the slow path. + // ── Hardware FPU ───────────────────────────────────────────────────────── + + #[cfg(feature = "isa-xt")] + const XT: IsaTarget = IsaTarget::Xtensa; + + /// The point of the milestone, asserted: the operations that *have* a + /// single hardware instruction produce exactly one VInst and no call. + #[cfg(feature = "isa-xt")] + #[test] + fn the_inline_family_is_one_instruction_each() { + for (op, want) in [ + ( + LpirOp::Fadd { + dst: v(0), + lhs: v(1), + rhs: v(2), + }, + FAluOp::Add, + ), + ( + LpirOp::Fsub { + dst: v(0), + lhs: v(1), + rhs: v(2), + }, + FAluOp::Sub, + ), + ( + LpirOp::Fmul { + dst: v(0), + lhs: v(1), + rhs: v(2), + }, + FAluOp::Mul, + ), + ] { + let (insts, symbols, _) = lower(op, XT); + assert!(called_symbols(&insts, &symbols).is_empty(), "no call"); + assert!( + matches!(insts.as_slice(), [VInst::FAluRRR { op, .. }] if *op == want), + "{want:?}: {insts:?}" + ); + } + + for (op, want) in [ + ( + LpirOp::Fabs { + dst: v(0), + src: v(1), + }, + FAluRROp::Abs, + ), + ( + LpirOp::Fneg { + dst: v(0), + src: v(1), + }, + FAluRROp::Neg, + ), + ] { + let (insts, _, _) = lower(op, XT); + assert!(matches!(insts.as_slice(), [VInst::FAluRR { op, .. }] if *op == want)); + } + + for (op, signed) in [ + ( + LpirOp::ItofS { + dst: v(0), + src: v(1), + }, + true, + ), + ( + LpirOp::ItofU { + dst: v(0), + src: v(1), + }, + false, + ), + ] { + let (insts, _, _) = lower(op, XT); + assert!(matches!(insts.as_slice(), [VInst::IToF { signed: s, .. }] if *s == signed)); + } + } + + /// Each comparison keeps its own condition. `Fne` in particular must **not** + /// be a negated `Eq`: they differ exactly on NaN, which `float.md` §3 makes + /// a guaranteed row, and no ordinary test input would catch the rewrite. + #[cfg(feature = "isa-xt")] + #[test] + fn each_comparison_keeps_its_own_condition() { + for (op, want) in [ + ( + LpirOp::Feq { + dst: v(0), + lhs: v(1), + rhs: v(2), + }, + FcmpCond::Eq, + ), + ( + LpirOp::Fne { + dst: v(0), + lhs: v(1), + rhs: v(2), + }, + FcmpCond::Ne, + ), + ( + LpirOp::Flt { + dst: v(0), + lhs: v(1), + rhs: v(2), + }, + FcmpCond::Lt, + ), + ( + LpirOp::Fle { + dst: v(0), + lhs: v(1), + rhs: v(2), + }, + FcmpCond::Le, + ), + ( + LpirOp::Fgt { + dst: v(0), + lhs: v(1), + rhs: v(2), + }, + FcmpCond::Gt, + ), + ( + LpirOp::Fge { + dst: v(0), + lhs: v(1), + rhs: v(2), + }, + FcmpCond::Ge, + ), + ] { + let (insts, _, _) = lower(op, XT); + assert!( + matches!(insts.as_slice(), [VInst::Fcmp { cond, .. }] if *cond == want), + "{want:?}: {insts:?}" + ); + } + } + + /// A float constant is the integer literal machinery plus one transfer + /// (M7 D11) — no third literal pool, and no `FConst32` VInst. + #[cfg(feature = "isa-xt")] + #[test] + fn a_float_constant_is_iconst_plus_a_transfer() { + let (insts, symbols, _) = lower( + LpirOp::FconstF32 { + dst: v(0), + value: 1.0, + }, + XT, + ); + assert!(called_symbols(&insts, &symbols).is_empty()); + assert!( + matches!( + insts.as_slice(), + [VInst::IConst32 { val, .. }, VInst::Wfr { .. }] if *val == 0x3f80_0000u32 as i32 + ), + "{insts:?}" + ); + } + + /// The builtin-routed half (M7 D4). Every symbol must be an `_f32` one: + /// resolving a `_q32` sibling would hand a fixed-point callee IEEE bit + /// patterns and return plausible wrong numbers rather than failing. + #[cfg(feature = "isa-xt")] + #[test] + fn everything_else_calls_the_f32_builtin_family() { + for (op, want) in [ + ( + LpirOp::Fdiv { + dst: v(0), + lhs: v(1), + rhs: v(2), + }, + "__lp_lpir_fdiv_f32", + ), + ( + LpirOp::Fsqrt { + dst: v(0), + src: v(1), + }, + "__lp_lpir_fsqrt_f32", + ), + ( + LpirOp::Ffloor { + dst: v(0), + src: v(1), + }, + "__lp_lpir_ffloor_f32", + ), + ( + LpirOp::Fceil { + dst: v(0), + src: v(1), + }, + "__lp_lpir_fceil_f32", + ), + ( + LpirOp::Ftrunc { + dst: v(0), + src: v(1), + }, + "__lp_lpir_ftrunc_f32", + ), + ( + LpirOp::Fnearest { + dst: v(0), + src: v(1), + }, + "__lp_lpir_fnearest_f32", + ), + ( + LpirOp::Fmin { + dst: v(0), + lhs: v(1), + rhs: v(2), + }, + "__lp_lpir_fmin_f32", + ), + ( + LpirOp::Fmax { + dst: v(0), + lhs: v(1), + rhs: v(2), + }, + "__lp_lpir_fmax_f32", + ), + ( + LpirOp::FtoiSatS { + dst: v(0), + src: v(1), + }, + "__lp_lpir_ftoi_sat_s_f32", + ), + ( + LpirOp::FtoiSatU { + dst: v(0), + src: v(1), + }, + "__lp_lpir_ftoi_sat_u_f32", + ), + ( + LpirOp::FtoUnorm16 { + dst: v(0), + src: v(1), + }, + "__lp_lpir_fto_unorm16_f32", + ), + ( + LpirOp::FtoUnorm8 { + dst: v(0), + src: v(1), + }, + "__lp_lpir_fto_unorm8_f32", + ), + ( + LpirOp::Unorm16toF { + dst: v(0), + src: v(1), + }, + "__lp_lpir_unorm16_to_f_f32", + ), + ( + LpirOp::Unorm8toF { + dst: v(0), + src: v(1), + }, + "__lp_lpir_unorm8_to_f_f32", + ), + ] { + let (insts, symbols, _) = lower(op, XT); + let called = called_symbols(&insts, &symbols); + assert_eq!(called, alloc::vec![want]); + assert!(!called[0].ends_with("_q32"), "resolved a Q32 sibling"); + } + } + + /// A float-argument, float-returning builtin call is surrounded by exactly + /// the transfers the ABI requires: `Rfr` per argument, then the `Call`, + /// then `Wfr` for the result (M7 D1/D2). + #[cfg(feature = "isa-xt")] + #[test] + fn a_builtin_call_is_wrapped_in_rfr_then_call_then_wfr() { + // vregs 1..3 are past the (vmctx-only) parameter block, so `float_vreg` + // is the identity for them and the assertions can name vregs directly. + let func = float_func(8); + let (insts, _, pool) = lower_in( + LpirOp::Fdiv { + dst: v(3), + lhs: v(1), + rhs: v(2), + }, + XT, + &func, + ); + assert!( + matches!( + insts.as_slice(), + [ + VInst::Rfr { .. }, + VInst::Rfr { .. }, + VInst::Call { .. }, + VInst::Wfr { .. } + ] + ), + "{insts:?}" + ); + // The call's own operands are the transfer temps, never the float + // vregs — that is what keeps `Call` integer-class end to end. + let VInst::Call { args, rets, .. } = &insts[2] else { + unreachable!() + }; + let VInst::Rfr { + dst: a0, src: s0, .. + } = insts[0] + else { + unreachable!() + }; + assert_eq!(args.vregs(&pool)[0], a0); + assert_eq!(s0, VReg(1), "the argument came out of the float file"); + let VInst::Wfr { dst, src, .. } = insts[3] else { + unreachable!() + }; + assert_eq!(src, rets.vregs(&pool)[0]); + assert_eq!(dst, VReg(3), "the result went into the float file"); + } + + /// Float **parameters** are the one case where an LPIR vreg needs two + /// backend identities: the address register it arrives in, and the float + /// register the body computes with. The entry `Wfr` is what links them. + #[cfg(feature = "isa-xt")] + #[test] + fn float_parameters_get_one_entry_transfer_each() { + // vmctx (Pointer) + two float params + one int param. + let func = IrFunction { + name: String::new(), + is_entry: true, + vmctx_vreg: lpir::VReg(0), + param_count: 3, + return_types: alloc::vec![], + sret_arg: None, + vreg_types: alloc::vec![IrType::Pointer, IrType::F32, IrType::F32, IrType::I32], + slots: alloc::vec![], + body: alloc::vec![].into(), + vreg_pool: alloc::vec![], + }; + let mut out = Vec::new(); + let n = push_entry_param_transfers(&mut out, &func); + assert_eq!(n, 2, "one per float parameter, and no more"); + assert_eq!(out.len(), 2); + for (i, inst) in out.iter().enumerate() { + let param = lpir::VReg(i as u32 + 1); + assert!( + matches!(inst, VInst::Wfr { dst, src, .. } + if *src == fa_vreg(param) && *dst == float_vreg(&func, param)), + "{inst:?}" + ); + } + // The shadow is a distinct vreg from the parameter's own — sharing one + // would give a single vreg two register classes, which the allocator + // cannot represent. + for p in [lpir::VReg(1), lpir::VReg(2)] { + assert_ne!(float_vreg(&func, p), fa_vreg(p)); + } + // Non-parameters keep the identity mapping, so nothing else in the + // function pays for the parameter special case. + assert_eq!(float_vreg(&func, lpir::VReg(9)), fa_vreg(lpir::VReg(9))); + // Temps start above the shadow block. + assert!(vreg_watermark(&func, true) > func.total_param_slots()); + assert_eq!(vreg_watermark(&func, false), func.vreg_types.len() as u16); + } + + /// A float parameter handed straight to a call needs **no** transfer: it + /// never left the address register. Emitting one would be correct but a + /// pointless FR round trip on the hot path. #[cfg(feature = "isa-xt")] #[test] - fn xtensa_f32_is_a_named_error_not_a_silent_soft_float_fallback() { + fn a_float_parameter_passed_through_needs_no_transfer() { + let func = IrFunction { + name: String::new(), + is_entry: true, + vmctx_vreg: lpir::VReg(0), + param_count: 1, + return_types: alloc::vec![], + sret_arg: None, + vreg_types: alloc::vec![IrType::Pointer, IrType::F32, IrType::F32], + slots: alloc::vec![], + body: alloc::vec![].into(), + vreg_pool: alloc::vec![], + }; + let mut out = Vec::new(); + let mut temps = TempVRegs::new(vreg_watermark(&func, true)); + let word = word_operand(&mut out, &func, lpir::VReg(1), &mut temps, 0); + assert_eq!(word, fa_vreg(lpir::VReg(1))); + assert!(out.is_empty(), "no transfer for a parameter: {out:?}"); + + // A computed float, by contrast, does need one. + let word = word_operand(&mut out, &func, lpir::VReg(2), &mut temps, 0); + assert_ne!(word, fa_vreg(lpir::VReg(2))); + assert!(matches!(out.as_slice(), [VInst::Rfr { .. }])); + } + + /// The resolver never crosses modes. A missing f32 builtin has to name the + /// op, because the alternative — quietly resolving the Q32 sibling — is the + /// defect class that produces plausible wrong pixels instead of an error. + #[test] + fn a_missing_f32_builtin_names_the_op_rather_than_falling_back() { + let err = f32_builtin_symbol("no_such_lpir_op", 1).expect_err("must not resolve"); + let LowerError::UnsupportedOp { description } = err; + assert!(description.contains("no_such_lpir_op"), "{description}"); + assert!(description.contains("f32"), "{description}"); + // Sanity: the resolver *does* work for a real name, so the test above + // is not passing because everything fails. + assert_eq!(f32_builtin_symbol("fdiv", 2).unwrap(), "__lp_lpir_fdiv_f32"); + } + + /// With `float-f32` off the Xtensa arm is unreachable, and the seam must + /// refuse rather than quietly hand an FPU part the soft-float path. + #[cfg(all(feature = "isa-xt", not(feature = "float-f32")))] + #[test] + fn xtensa_without_the_feature_is_a_named_error() { let mut out = Vec::new(); let mut symbols = ModuleSymbols::default(); let mut pool = Vec::new(); @@ -961,11 +2036,12 @@ mod tests { }, IsaTarget::Xtensa, Some(0), + &float_func(4), &mut symbols, &mut pool, &mut temps, ) - .expect_err("Xtensa has no f32 backend yet"); + .expect_err("no FP backend linked"); let LowerError::UnsupportedOp { description } = err; assert!(description.contains("Xtensa"), "{description}"); assert!(out.is_empty(), "a refused op must emit nothing"); diff --git a/lp-shader/lpvm-native/src/regalloc/test/float_alloc.rs b/lp-shader/lpvm-native/src/regalloc/test/float_alloc.rs index 10f061031..3a1047406 100644 --- a/lp-shader/lpvm-native/src/regalloc/test/float_alloc.rs +++ b/lp-shader/lpvm-native/src/regalloc/test/float_alloc.rs @@ -85,7 +85,10 @@ fn floats_and_ints_allocate_from_their_own_pools_in_one_function() { "no integer register was allocated: {found:?}" ); for (preg, want) in &found { - assert_eq!(preg.class, *want, "{preg:?} allocated for a {want:?} operand"); + assert_eq!( + preg.class, *want, + "{preg:?} allocated for a {want:?} operand" + ); } assert!( found @@ -177,6 +180,35 @@ fn an_integer_register_on_a_float_operand_is_rejected() { crate::regalloc::verify::verify_alloc(&vinsts, &pool, &output, &func_abi); } +/// The entry-transfer shape, allocated end to end: a value arrives in a +/// **precolored address register**, crosses into the float file, is computed on +/// there, and crosses back before the return. +/// +/// This is the calling convention (M7 D1/D2) as the allocator sees it, and the +/// only case here where an ABI-precolored register meets a float operand — the +/// interaction that decided the parameter shadow in `lower_f32::float_vreg`. +/// The verifier accepting it is the claim; checking that a float register was +/// actually handed out is what keeps the claim from being vacuous. +#[test] +fn a_parameter_crosses_into_the_float_file_and_back() { + let src = "i10 = Wfr i1 + i11 = FMul i10, i10 + i12 = Rfr i11 + Ret i12"; + let r = xt().abi_params(2).run_vinst(src); + r.expect_spill_slots(0); + + let (vinsts, _symbols, pool) = crate::debug::vinst::parse(src).unwrap(); + let found = allocated_classes(&vinsts, &pool, &r.output); + assert!( + found.iter().any(|(p, _)| p.class == RegClass::Float), + "the body never reached the float file: {found:?}" + ); + for (preg, want) in &found { + assert_eq!(preg.class, *want, "{preg:?} for a {want:?} operand"); + } +} + /// A call evicts every live float, because no FR survives a `call8` (M6-P4). /// Under-reporting the clobber set would leave the value in a register the /// callee overwrites — wrong only for inputs that straddle a call. From db37e5c714ed5d351aa7070e87cfe398f82ecb18 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Sat, 1 Aug 2026 04:30:29 +0000 Subject: [PATCH 10/15] chore(studio): auto-refresh story baselines [validate-stories] Captured in the pinned CI environment by run: https://github.com/light-player/lightplayer/actions/runs/30681544826 --- ...ration__node-ui__status-indicators__sm.png | Bin 58284 -> 58309 bytes 1 file changed, 0 insertions(+), 0 deletions(-) diff --git a/lp-app/lpa-studio-web/story-images/exploration__node-ui__status-indicators__sm.png b/lp-app/lpa-studio-web/story-images/exploration__node-ui__status-indicators__sm.png index 5b6cc790840d2045de57e315851b291c7bcc2bde..2d41f7663be9537da272a7026613002576ec603c 100644 GIT binary patch delta 23059 zcmXV#bzBtR*T(^ol8^-{0f|{)0ZA#55Ej^7LRd;f8YHAUghiy5X6UY^Q#zFvL>iSY zN$FAnfyeLf_563{&dfdM&YbhP?>STP=~myTTX6t>)O+YU79;;IUz4mpjwgKzU?Q|C z#ab~wLOkkuI`b}^OX(Dyp%lc0m<*)|x}dSse8+at89LnOw&1&6qH+-W!1c-RC%-KA z4h&vrc^q)D**=p2JnSK$H*w$9xBltAu0KxX^WU(*v&z~y+OzYSlREBaUzI>-7`T3L zTw7E{GGCG7j#=JFvkj>!o`|!9PN&B%_suSkTR>Z}ht)s1$JJsA= zElvs_gS>d>3LU00$BO;K`?L9ADlhRIy{RM2cMWfQbu=5F> zh>$Rb(6O9-d+tj`-5*+Gb1njjt{>GQw-@2vl#>Wll(Qm&jNIdCsRXBmeM7J2oUM0H zQgLV%0}UtFXByM~S3l_YzY{r5au%!@-xe$lPH{ARhg*2fSy~AP;M`aP?PT|BS?t zhBO;`eaTg;i=G)Q^`@`9xY&4D!?i2)7I#Ww;ev2#YoJIqq(H`;>OcM{-Z9)aNu73} zNpBwX2g|?ad2_d*EJT*%TTv55^M{WN_nUH5T1XN3pE*mD%8Us${ci)hbq-1Q04=e( zM$%QYqief_bqWrwI3+SMUZ+~8HoYH1t82)c7M~bD6-U_eR~Grq=RIebFBu}gNFMDs z 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 P?Z5+HDM%Gc7=HRc|2d;5 delta 23034 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 From f695a41c413664ce07445bc76e538553df6c6ac9 Mon Sep 17 00:00:00 2001 From: Yona Appletree Date: Fri, 31 Jul 2026 22:44:43 -0700 Subject: [PATCH 11/15] =?UTF-8?q?feat(lpvm-native):=20the=20Xtensa=20FP=20?= =?UTF-8?q?emitter=20=E2=80=94=20every=20float=20VInst,=20and=20float=20sp?= =?UTF-8?q?ills?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit P3 of M7. `isa/xt/emit_fp.rs` encodes the nine float `VInst`s through `lp-xt-inst`'s FP model, behind `float-f32`; `isa/xt/emit.rs` keeps the integer half and dispatches to it. The class gate is split rather than widened: `hw()` still refuses a float allocation outright, `fhw()` is its mirror and refuses an integer one. `a3` and `f3` share a hardware index and are different registers, so a class confusion is a reinterpreted bit pattern, not a crash — two narrow gates turn it into an emit error at the operand that was wrong. Two things the plan did not have right, both caught by the emulator rather than by reading: - **`!=` maps to `oeq.s` + `movf`, not `ueq.s` + `movf`.** D5's table gave the latter, which computes `!ueq` = "ordered and unequal" — *false* on NaN, where float.md §3 Guaranteed makes `!=` true. The one comparison the spec singles out was the one the table got wrong. - **One FR is reserved as scratch (`f15`), narrowing D8's "all 16."** The allocator can put an instruction's *destination* on the stack, and an FP instruction still has to write into a register before it can be stored — the float counterpart of what `a8` does for integers. One is provably enough: only defs are ever stack-allocated (`alloc_use` returns a register on every path), and no float VInst has two float defs. Float spills use `lsi`/`ssi` with a checked immediate. `lp-xt-inst`'s encoder computes `(offset / 4) & 0xff` with no range check, so an out-of-range offset silently addresses a *different slot*; every float access goes through `ImmOp::FpLsiOffset` and takes the address-scratch fallback, pinned by a test that asserts the encoding and not just the value. The frame is untouched: no `FrameLayout`, prologue or epilogue change, because no FR is callee-saved (M6-P4 measured). `arithmetic_propagates_nan` is marked `#[ignore]` awaiting M6-P6 — NaN propagation through arithmetic is a deliberately-unresolved `lp-xt-emu` policy field, and the test is left standing rather than weakened. Plan: 2026-07-30-1745-f32-native-math/m7-f32-codegen-xtensa --- lp-shader/lpvm-native/src/isa/mod.rs | 15 +- lp-shader/lpvm-native/src/isa/xt/emit.rs | 70 +- lp-shader/lpvm-native/src/isa/xt/emit_fp.rs | 1165 +++++++++++++++++++ lp-shader/lpvm-native/src/isa/xt/fpr.rs | 77 +- lp-shader/lpvm-native/src/isa/xt/mod.rs | 3 + lp-xt/fixtures/Cargo.lock | 1 + lp-xt/lps-builtins-xt-app/Cargo.toml | 17 + scripts/build-builtins-xt.sh | 14 +- 8 files changed, 1319 insertions(+), 43 deletions(-) create mode 100644 lp-shader/lpvm-native/src/isa/xt/emit_fp.rs diff --git a/lp-shader/lpvm-native/src/isa/mod.rs b/lp-shader/lpvm-native/src/isa/mod.rs index d147f343d..6a584d695 100644 --- a/lp-shader/lpvm-native/src/isa/mod.rs +++ b/lp-shader/lpvm-native/src/isa/mod.rs @@ -864,19 +864,24 @@ mod tests { } /// The float pool is the *feature's* observable footprint in the register - /// model: 16 FRs when `float-f32` is on, nothing at all when it is off. - /// The off case is the gate's whole claim (M7 D9) — a leak here would mean - /// a Fixed-only image carries the float allocator. + /// model: 15 of the 16 FRs when `float-f32` is on (`f15` is the emitter's + /// scratch — see `isa::xt::fpr`), nothing at all when it is off. The off + /// case is the gate's whole claim (M7 D9) — a leak here would mean a + /// Fixed-only image carries the float allocator. #[cfg(feature = "isa-xt")] #[test] fn xtensa_float_pool_follows_the_feature() { let isa = IsaTarget::Xtensa; let pool = isa.allocatable_pool_order(RegClass::Float); if cfg!(feature = "float-f32") { - assert_eq!(pool.len(), 16, "all 16 FRs are allocatable (M7 D8)"); + assert_eq!(pool.len(), 15, "15 FRs allocatable, f15 is the scratch"); // Every FR is call-clobbered — the measured esp-toolchain ABI. assert_eq!(isa.caller_saved_pool_hw(RegClass::Float), pool); - assert!(isa.is_in_allocatable_pool(PReg::float(15))); + assert!(isa.is_in_allocatable_pool(PReg::float(14))); + assert!( + !isa.is_in_allocatable_pool(PReg::float(15)), + "the emitter scratch must never be handed to the allocator" + ); assert_eq!(isa.reg_name(PReg::float(3)), "f3"); } else { assert!(pool.is_empty()); diff --git a/lp-shader/lpvm-native/src/isa/xt/emit.rs b/lp-shader/lpvm-native/src/isa/xt/emit.rs index c4708177e..0410afb4f 100644 --- a/lp-shader/lpvm-native/src/isa/xt/emit.rs +++ b/lp-shader/lpvm-native/src/isa/xt/emit.rs @@ -51,6 +51,15 @@ //! documented hard error (no `MOVSP` idiom — pinned by the experiment's ABI //! ADR). //! +//! ## The float half +//! +//! Every float `VInst`, the float spill/reload edits and the FP register model +//! live in [`super::emit_fp`], behind the `float-f32` feature. This module owns +//! the layout contract, the literal pool, branch relaxation, the frame and the +//! integer instruction set; that one owns the FP encodings, the Boolean- +//! register discipline and the `lsi`/`ssi` immediate discipline. The frame is +//! *not* split between them: float support changes nothing about it. +//! //! ## Branch fixups //! //! Conditional branches (`beqz`/`bnez`, BRI12 ±2 KB) are layout items resolved @@ -74,11 +83,11 @@ use crate::regalloc::{Alloc, AllocError, AllocOutput, Edit, EditPoint}; use crate::vinst::{AluImmOp, AluOp, IcmpCond, LabelId, ModuleSymbols, VInst, VReg}; /// Primary emitter scratch (`a8`) as an encoder operand. -const S0: Reg = Reg::new(SCRATCH); +pub(super) const S0: Reg = Reg::new(SCRATCH); /// Secondary emitter scratch (`a9`) as an encoder operand. -const S1: Reg = Reg::new(SCRATCH2); +pub(super) const S1: Reg = Reg::new(SCRATCH2); /// The stack pointer (`a1`) as an encoder operand. -const SP: Reg = Reg::new(SP_REG); +pub(super) const SP: Reg = Reg::new(SP_REG); /// The windowed call increment this backend emits (`CALLX8`), fixed by /// [`super::gpr::CALL_ROTATION`]'s register model. @@ -183,7 +192,7 @@ pub struct EmitContext<'a> { /// `(item index, byte offset within that Bytes run, src_op)` — translated /// to absolute `(code_offset, Some(src_op))` debug lines after layout. marks: Vec<(usize, u32, u32)>, - frame: FrameLayout, + pub(super) frame: FrameLayout, symbols: &'a ModuleSymbols, /// Kept for API parity with [`emit_function`] (e.g. future pool-indexed lowering). #[allow( @@ -238,7 +247,7 @@ impl<'a> EmitContext<'a> { } /// Append one encoded instruction to the current `Bytes` run. - fn inst(&mut self, i: Inst, src_op: Option) { + pub(super) fn inst(&mut self, i: Inst, src_op: Option) { let bytes = encode(&i); let idx = match self.items.last() { Some(Slot { @@ -307,7 +316,7 @@ impl<'a> EmitContext<'a> { /// Materialize a 32-bit constant into `rd` (`movi`, else pooled `l32r` — /// the table's [`ImmOp::Movi`] fallback `LiteralPool`). - fn iconst(&mut self, rd: Reg, val: i32, src_op: Option) { + pub(super) fn iconst(&mut self, rd: Reg, val: i32, src_op: Option) { if imm::is_legal(ImmOp::Movi, val) { self.inst(Inst::Movi(rd, val), src_op); } else { @@ -321,7 +330,7 @@ impl<'a> EmitContext<'a> { /// `tmp` must differ from `rs` when the constant path is reachable /// (checked; `tmp == rd` is fine — the constant path allows it only when /// `rd != rs`). - fn add_imm( + pub(super) fn add_imm( &mut self, rd: Reg, rs: Reg, @@ -360,7 +369,7 @@ impl<'a> EmitContext<'a> { /// `op` (negative, out of range, or misaligned — the table's /// `AddressScratch` fallback). The value operand must therefore not live /// in `S0` when the fallback is reachable; the constant scratch is `S1`. - fn mem_addr( + pub(super) fn mem_addr( &mut self, base: Reg, offset: i32, @@ -411,11 +420,15 @@ impl<'a> EmitContext<'a> { // --- operand plumbing (rv32-parity) ------------------------------------ - fn operand_alloc(output: &AllocOutput, inst_idx: usize, operand_idx: usize) -> Alloc { + pub(super) fn operand_alloc( + output: &AllocOutput, + inst_idx: usize, + operand_idx: usize, + ) -> Alloc { output.operand_alloc(inst_idx as u16, operand_idx as u16) } - fn is_dead_def(output: &AllocOutput, inst_idx: usize, def_op_idx: usize) -> bool { + pub(super) fn is_dead_def(output: &AllocOutput, inst_idx: usize, def_op_idx: usize) -> bool { matches!( Self::operand_alloc(output, inst_idx, def_op_idx), Alloc::None @@ -430,7 +443,7 @@ impl<'a> EmitContext<'a> { /// milestone, and `a0..a15` and `f0..f15` are different register files, so /// an integer instruction against a float index would be silently wrong /// rather than merely unimplemented. - fn hw(preg: crate::abi::PackedPReg) -> Result { + pub(super) fn hw(preg: crate::abi::PackedPReg) -> Result { match preg.class() { RegClass::Float => Err(crate::emit_err!( "allocation names float register f{} — Xtensa has no FPU backend", @@ -443,7 +456,7 @@ impl<'a> EmitContext<'a> { /// Use a vreg: return its physical register, reloading from spill into /// `temp` if needed. - fn use_vreg( + pub(super) fn use_vreg( &mut self, output: &AllocOutput, inst_idx: usize, @@ -463,7 +476,7 @@ impl<'a> EmitContext<'a> { /// Def a vreg: return the physical register to write to (the caller must /// [`Self::store_def_vreg`] afterwards when the def is spilled). - fn def_vreg( + pub(super) fn def_vreg( &mut self, output: &AllocOutput, inst_idx: usize, @@ -478,7 +491,7 @@ impl<'a> EmitContext<'a> { } /// Store a spilled def after it was written to `temp`. - fn store_def_vreg( + pub(super) fn store_def_vreg( &mut self, output: &AllocOutput, inst_idx: usize, @@ -494,6 +507,13 @@ impl<'a> EmitContext<'a> { /// Emit an allocator edit (reload/spill/reg move) as concrete instructions. fn emit_edit(&mut self, edit: &Edit, src_op: Option) -> Result<(), AllocError> { + // Float-class edits use `lsi`/`ssi`/`mov.s`; see `emit_fp` for which + // shapes it claims and why stack-to-stack deliberately is not one of + // them (it is a class-free word copy the integer path already does). + #[cfg(feature = "float-f32")] + if self.emit_float_edit(edit, src_op)? { + return Ok(()); + } match edit { Edit::Move { from, to } => match (*from, *to) { (Alloc::None, _) | (_, Alloc::None) => return Err(crate::emit_err!()), @@ -1355,12 +1375,26 @@ impl<'a> EmitContext<'a> { } => { self.emit_fuel_check(output, inst_idx, *decrement, *trap_label, src_op)?; } - // Hardware float. The FP emitter is roadmap M7 P3; until it lands, - // a float VInst reaching here means lowering produced instructions - // this emitter cannot encode, and the only safe answer is to refuse + // Hardware float — the float half lives in `super::emit_fp`. + #[cfg(feature = "float-f32")] + VInst::FAluRRR { .. } + | VInst::FAluRR { .. } + | VInst::Fcmp { .. } + | VInst::FSelect { .. } + | VInst::FLoad32 { .. } + | VInst::FStore32 { .. } + | VInst::Wfr { .. } + | VInst::Rfr { .. } + | VInst::IToF { .. } => { + self.emit_float_vinst(vinst, output, inst_idx, src_op)?; + } + // Without `float-f32` there is no FP emitter linked, so a float + // VInst reaching here means lowering produced instructions this + // build cannot encode, and the only safe answer is to refuse // loudly. Erroring rather than skipping matters: a silently dropped // FP instruction leaves the destination holding whatever was there // before and renders a plausible wrong frame. + #[cfg(not(feature = "float-f32"))] VInst::FAluRRR { .. } | VInst::FAluRR { .. } | VInst::Fcmp { .. } @@ -1371,7 +1405,7 @@ impl<'a> EmitContext<'a> { | VInst::Rfr { .. } | VInst::IToF { .. } => { return Err(crate::emit_err!( - "xt emitter: no FP instruction encoding yet for {} (roadmap M7 P3)", + "xt emitter: {} needs the `float-f32` feature (M7 D9)", vinst.mnemonic() )); } diff --git a/lp-shader/lpvm-native/src/isa/xt/emit_fp.rs b/lp-shader/lpvm-native/src/isa/xt/emit_fp.rs new file mode 100644 index 000000000..485c304a0 --- /dev/null +++ b/lp-shader/lpvm-native/src/isa/xt/emit_fp.rs @@ -0,0 +1,1165 @@ +//! The float half of the Xtensa emitter: every float `VInst`, and the float +//! spill/reload edits, encoded through `lp-xt-inst`'s FP model. +//! +//! Gated on `float-f32` (M7 D9) — a Fixed-only image links none of this. The +//! integer half is [`super::emit`], which owns the layout contract, the +//! literal pool, branch relaxation and the frame; this module adds no +//! machinery of its own and, like its sibling, **never packs bytes**. +//! +//! ## The FP register model +//! +//! Values live in `f0`–`f15` ([`super::fpr`]), a flat file the window rotation +//! does not touch. Fifteen are allocatable; `f15` is the emitter's scratch, +//! the FR counterpart of `a8`, needed because the allocator can place a float +//! *destination* on the stack and an FP instruction still has to write into a +//! register before it can be stored. Address-register scratch stays `a8`/`a9` +//! throughout, so the two files' scratch reservations never interact. +//! +//! Float values cross every call and function boundary in **address** +//! registers as raw IEEE bit patterns (M7 D1/D2). Lowering, not this module, +//! inserts the [`VInst::Wfr`]/[`VInst::Rfr`] transfers that implement it; all +//! this module knows is how to encode them. +//! +//! ## The Boolean-register invariant +//! +//! FP compares write a Boolean register, never an address register. M7 uses +//! one fixed BR — [`fpr::CMP_BREG`] (`b0`) — as an implicit scratch and +//! materializes the 0/1 into the compare's integer destination inside the same +//! emitted sequence, so the allocator never learns Boolean registers exist +//! (D5). +//! +//! **Invariant: no Boolean register is ever live across a `VInst` boundary.** +//! +//! That single sentence is what makes one fixed `b0` safe rather than a source +//! of aliasing. Every sequence below that sets `b0` also consumes it before it +//! returns. A future fused compare-and-branch optimization — the obvious next +//! step, since `bt`/`bf` can branch on `b0` directly and would save the two +//! `movi`s — must either preserve this invariant or teach the allocator about +//! the Boolean file; it may not quietly extend a `b0` live range across an +//! instruction boundary, because the very next compare would clobber it. +//! +//! ## The `lsi`/`ssi` immediate discipline +//! +//! `lsi`/`ssi` offsets are `0..=1020` and must be a multiple of 4 — the +//! encoding holds `offset / 4` in an 8-bit field. `lp-xt-inst`'s encoder +//! computes `(offset / 4) & 0xff` with **no range check**, so an out-of-range +//! offset does not fail: it silently encodes a *different slot*. A frame with +//! more than 255 spill slots below a float access would read or write the +//! wrong four bytes and produce a plausible wrong number, with nothing in the +//! disassembly looking unusual. +//! +//! So every float memory access here goes through +//! [`ImmOp::FpLsiOffset`](super::imm::ImmOp::FpLsiOffset) and takes that +//! table's `AddressScratch` fallback when the offset does not fit: compute +//! `base + offset` into `a8` (materializing through `a9`), then access at +//! offset 0. There is no path from this module to an unchecked FP offset. +//! +//! ## No frame change accompanies FP +//! +//! `FrameLayout`, the prologue's single `entry` and the epilogue's single +//! `retw` are untouched by float support, and that is a *derived* fact, not an +//! omission: no FR is callee-saved (measured, M6-P4), so there is no FP +//! callee-save region to lay out. Float spills are 4 bytes in the existing +//! class-tagged spill index space at the **bottom** of the frame, while the +//! window-overflow handler scribbles in the reservation at the **top**. They +//! cannot collide (M7 D7), and `tests/xt_pipeline_f32.rs` pins that rather +//! than leaving it argued. + +use lp_xt_inst::{BReg, FReg, FpCmpOp, FpLsiOp, FpMovArOp, FpRrOp, FpRrrOp, Inst, IntToFpOp, Reg}; + +use super::emit::{EmitContext, S0, S1, SP}; +use super::fpr; +use super::imm::{self, ImmOp}; +use crate::abi::{PackedPReg, RegClass}; +use crate::regalloc::{Alloc, AllocError, AllocOutput, Edit}; +use crate::vinst::{FAluOp, FAluRROp, FcmpCond, VInst}; + +/// The emitter's float scratch as an encoder operand. +const F_SCRATCH: FReg = FReg::new(fpr::SCRATCH); + +/// The Boolean register FP compares write, as an encoder operand. +const B_CMP: BReg = BReg::new(fpr::CMP_BREG); + +impl EmitContext<'_> { + // --- the class gate, float side --------------------------------------- + + /// The `f`-register named by a register allocation. + /// + /// The mirror of [`EmitContext::hw`], and the reason that one keeps + /// rejecting `RegClass::Float` instead of learning to unwrap it: `a3` and + /// `f3` share a hardware index but are different registers in different + /// files, so a class confusion is not a crash, it is a bit pattern + /// reinterpreted between the integer and float worlds. Two narrow gates + /// that each reject the other class turn that into an emit error at the + /// exact operand that was wrong. + pub(super) fn fhw(preg: PackedPReg) -> Result { + match preg.class() { + RegClass::Int => Err(crate::emit_err!( + "allocation names address register a{} where a float register is required", + preg.hw() + )), + RegClass::Float if preg.hw() < fpr::FR_COUNT => Ok(FReg::new(preg.hw())), + RegClass::Float => Err(crate::emit_err!("allocation names non-FR f{}", preg.hw())), + } + } + + /// Use a float vreg: return the FR holding it. + /// + /// Unlike the integer [`EmitContext::use_vreg`] there is no reload path, + /// because there is nothing to reload: `regalloc::walk::alloc_use` returns + /// `Alloc::Reg` on every path it has, inserting a reload *edit* before the + /// instruction when the value was spilled. The only code that forces a use + /// to the stack is the sret `Ret` constraint, and no float `VInst` is a + /// `Ret` — float values reach a return through an `Rfr` first (D1). + /// + /// So a `Stack` use here means the allocator's contract changed, and the + /// honest answer is to say so. Reloading into the single float scratch + /// instead would be wrong the moment an instruction had two spilled float + /// uses: the second reload would overwrite the first. + fn fuse_vreg( + &mut self, + output: &AllocOutput, + inst_idx: usize, + operand_idx: usize, + ) -> Result { + match Self::operand_alloc(output, inst_idx, operand_idx) { + Alloc::Reg(preg) => Self::fhw(preg), + Alloc::Stack(slot) => Err(crate::emit_err!( + "float use operand {operand_idx} allocated to spill slot {slot}; \ + the allocator is expected to reload float uses into registers via edits" + )), + Alloc::None => Err(crate::emit_err!()), + } + } + + /// Def a float vreg: return the FR to write to. A stack-allocated def + /// computes into [`F_SCRATCH`] and is stored by [`Self::fstore_def_vreg`]. + fn fdef_vreg( + &mut self, + output: &AllocOutput, + inst_idx: usize, + operand_idx: usize, + ) -> Result { + match Self::operand_alloc(output, inst_idx, operand_idx) { + Alloc::Reg(preg) => Self::fhw(preg), + Alloc::Stack(_) => Ok(F_SCRATCH), + Alloc::None => Err(crate::emit_err!()), + } + } + + /// Store a spilled float def after it was written to [`F_SCRATCH`]. + /// + /// Must be called *after* the instruction that produced the value, which + /// is also what makes reusing `a8`/`a9` for the address fallback safe: any + /// integer operand those held has already been consumed. + fn fstore_def_vreg( + &mut self, + output: &AllocOutput, + inst_idx: usize, + operand_idx: usize, + src_op: Option, + ) -> Result<(), AllocError> { + if let Alloc::Stack(slot) = Self::operand_alloc(output, inst_idx, operand_idx) { + self.fspill_store(F_SCRATCH, slot, src_op)?; + } + Ok(()) + } + + // --- float spill slots ------------------------------------------------- + + /// Reduce a float access to an encodable `(base, offset)` pair, taking the + /// [`ImmOp::FpLsiOffset`] `AddressScratch` fallback when the offset does + /// not fit. See the module doc for why the check is not optional. + fn fp_mem_addr( + &mut self, + base: Reg, + offset: i32, + src_op: Option, + ) -> Result<(Reg, u32), AllocError> { + if imm::is_legal(ImmOp::FpLsiOffset, offset) { + Ok((base, offset as u32)) + } else { + // `a8 = base + offset`, materializing the constant through `a9`. + // Neither can alias `base`: both are reserved from the allocatable + // pool, so no vreg ever lives in them. + self.add_imm(S0, base, offset, S1, src_op)?; + Ok((S0, 0)) + } + } + + /// `lsi dst, ` (SP-relative — FP == SP on Xtensa). + fn fspill_load(&mut self, dst: FReg, slot: u8, src_op: Option) -> Result<(), AllocError> { + let off = self + .frame + .spill_offset_from_sp(slot as u32) + .ok_or(crate::emit_err!())?; + let (base, o) = self.fp_mem_addr(SP, off, src_op)?; + self.inst(Inst::FpLsi(FpLsiOp::Lsi, dst, base, o), src_op); + Ok(()) + } + + /// `ssi src, ` (SP-relative). + fn fspill_store(&mut self, src: FReg, slot: u8, src_op: Option) -> Result<(), AllocError> { + let off = self + .frame + .spill_offset_from_sp(slot as u32) + .ok_or(crate::emit_err!())?; + let (base, o) = self.fp_mem_addr(SP, off, src_op)?; + self.inst(Inst::FpLsi(FpLsiOp::Ssi, src, base, o), src_op); + Ok(()) + } + + /// `mov.s`, elided when the registers coincide. + fn fmov(&mut self, rd: FReg, rs: FReg, src_op: Option) { + if rd != rs { + self.inst(Inst::FpRr(FpRrOp::MovS, rd, rs), src_op); + } + } + + // --- allocator edits --------------------------------------------------- + + /// Handle an allocator edit that moves a **float** value, returning + /// `false` when the edit is not float-class and the integer path should + /// take it. + /// + /// A stack-to-stack move is deliberately left to the integer path even for + /// floats: it is a bit-for-bit copy of one 4-byte slot to another, and + /// `l32i`/`s32i` through `a8` move those bytes exactly as `lsi`/`ssi` + /// would — without consuming the single float scratch, and without the + /// class information the edit does not carry (both endpoints are + /// `Alloc::Stack`, which has no class). + pub(super) fn emit_float_edit( + &mut self, + edit: &Edit, + src_op: Option, + ) -> Result { + let Edit::Move { from, to } = edit else { + // `LoadIncomingArg` reads a stack-passed parameter word. Float + // parameters arrive in address registers as bit patterns (D1), so + // the destination of one is always integer-class; a float + // destination here would mean the ABI changed underneath lowering. + if let Edit::LoadIncomingArg { to, .. } = edit + && matches!(to, Alloc::Reg(p) if p.class() == RegClass::Float) + { + return Err(crate::emit_err!( + "incoming argument loaded straight into a float register; \ + float arguments travel in address registers (M7 D1)" + )); + } + return Ok(false); + }; + match (*from, *to) { + (Alloc::Reg(src), Alloc::Reg(dst)) + if src.class() == RegClass::Float && dst.class() == RegClass::Float => + { + let (d, s) = (Self::fhw(dst)?, Self::fhw(src)?); + self.fmov(d, s, src_op); + Ok(true) + } + // A cross-class register move is not something lowering emits: + // AR↔FR transfers are explicit `Wfr`/`Rfr` VInsts precisely so the + // allocator sees ordinary same-class copies (D2). Reaching here + // means a vreg's class changed between def and use. + (Alloc::Reg(src), Alloc::Reg(dst)) if src.class() != dst.class() => { + Err(crate::emit_err!( + "allocator edit moves between register classes ({:?} -> {:?}); \ + AR/FR transfers are Wfr/Rfr VInsts, not moves (M7 D2)", + src.class(), + dst.class() + )) + } + (Alloc::Stack(slot), Alloc::Reg(dst)) if dst.class() == RegClass::Float => { + let d = Self::fhw(dst)?; + self.fspill_load(d, slot, src_op)?; + Ok(true) + } + (Alloc::Reg(src), Alloc::Stack(slot)) if src.class() == RegClass::Float => { + let s = Self::fhw(src)?; + self.fspill_store(s, slot, src_op)?; + Ok(true) + } + _ => Ok(false), + } + } + + // --- float VInst emission ---------------------------------------------- + + /// Emit one float `VInst`. Operand indices follow the allocator's layout: + /// defs in `for_each_def` order, then uses in `for_each_use` order. + pub(super) fn emit_float_vinst( + &mut self, + vinst: &VInst, + output: &AllocOutput, + inst_idx: usize, + src_op: Option, + ) -> Result<(), AllocError> { + match vinst { + VInst::FAluRRR { op, .. } => { + if Self::is_dead_def(output, inst_idx, 0) { + return Ok(()); + } + let s1 = self.fuse_vreg(output, inst_idx, 1)?; + let s2 = self.fuse_vreg(output, inst_idx, 2)?; + let d = self.fdef_vreg(output, inst_idx, 0)?; + let fop = match op { + FAluOp::Add => FpRrrOp::AddS, + FAluOp::Sub => FpRrrOp::SubS, + FAluOp::Mul => FpRrrOp::MulS, + }; + self.inst(Inst::FpRrr(fop, d, s1, s2), src_op); + self.fstore_def_vreg(output, inst_idx, 0, src_op) + } + VInst::FAluRR { op, .. } => { + if Self::is_dead_def(output, inst_idx, 0) { + return Ok(()); + } + let s = self.fuse_vreg(output, inst_idx, 1)?; + let d = self.fdef_vreg(output, inst_idx, 0)?; + let fop = match op { + FAluRROp::Mov => FpRrOp::MovS, + FAluRROp::Abs => FpRrOp::AbsS, + FAluRROp::Neg => FpRrOp::NegS, + }; + // `mov.s fr, fr` is the identity; every other form is not, so + // only the move may be elided. + if *op == FAluRROp::Mov { + self.fmov(d, s, src_op); + } else { + self.inst(Inst::FpRr(fop, d, s), src_op); + } + self.fstore_def_vreg(output, inst_idx, 0, src_op) + } + VInst::Fcmp { cond, .. } => self.emit_fcmp(output, inst_idx, *cond, src_op), + VInst::FSelect { .. } => self.emit_fselect(output, inst_idx, src_op), + VInst::FLoad32 { offset, .. } => { + if Self::is_dead_def(output, inst_idx, 0) { + return Ok(()); + } + let b = self.use_vreg(output, inst_idx, 1, S0, src_op)?; + let (b, o) = self.fp_mem_addr(b, *offset, src_op)?; + let d = self.fdef_vreg(output, inst_idx, 0)?; + self.inst(Inst::FpLsi(FpLsiOp::Lsi, d, b, o), src_op); + self.fstore_def_vreg(output, inst_idx, 0, src_op) + } + VInst::FStore32 { offset, .. } => { + let s = self.fuse_vreg(output, inst_idx, 0)?; + let b = self.use_vreg(output, inst_idx, 1, S0, src_op)?; + let (b, o) = self.fp_mem_addr(b, *offset, src_op)?; + self.inst(Inst::FpLsi(FpLsiOp::Ssi, s, b, o), src_op); + Ok(()) + } + VInst::Wfr { .. } => { + if Self::is_dead_def(output, inst_idx, 0) { + return Ok(()); + } + let s = self.use_vreg(output, inst_idx, 1, S0, src_op)?; + let d = self.fdef_vreg(output, inst_idx, 0)?; + self.inst(Inst::Wfr(d, s), src_op); + self.fstore_def_vreg(output, inst_idx, 0, src_op) + } + VInst::Rfr { .. } => { + if Self::is_dead_def(output, inst_idx, 0) { + return Ok(()); + } + let s = self.fuse_vreg(output, inst_idx, 1)?; + let d = self.def_vreg(output, inst_idx, 0, S0)?; + self.inst(Inst::Rfr(d, s), src_op); + self.store_def_vreg(output, inst_idx, 0, S0, src_op) + } + VInst::IToF { signed, .. } => { + if Self::is_dead_def(output, inst_idx, 0) { + return Ok(()); + } + let s = self.use_vreg(output, inst_idx, 1, S0, src_op)?; + let d = self.fdef_vreg(output, inst_idx, 0)?; + let op = if *signed { + IntToFpOp::FloatS + } else { + IntToFpOp::UfloatS + }; + // Scale 0: `float.s fr, as, 0` is the plain conversion. The + // immediate is a binary *post*-scale (divide by 2^imm), and + // LPIR has no scaled-conversion op to feed it. + self.inst(Inst::IntToFp(op, d, s, 0), src_op); + self.fstore_def_vreg(output, inst_idx, 0, src_op) + } + _ => Err(crate::emit_err!( + "emit_float_vinst called with a non-float VInst: {}", + vinst.mnemonic() + )), + } + } + + /// `Fcmp` — compare into `b0`, then materialize 0/1 into an address + /// register (D5). + /// + /// ```text + /// .s b0, fs, ft + /// movi a_dst, 0 + /// movi a_scr, 1 + /// movt a_dst, a_scr, b0 # movf for Ne + /// ``` + /// + /// The mapping is fixed by `docs/design/float.md` §3, which makes NaN + /// behavior *Guaranteed*: ordered compares are false when either operand + /// is NaN, and `!=` is true. `Gt`/`Ge` swap the operands rather than + /// inventing predicates the ISA does not have. + /// + /// **`Ne` is `oeq.s` consumed with `movf`**, i.e. `!oeq` — "unordered or + /// unequal", which is true on NaN as float.md requires. M7's plan (D5) + /// tabulated `ueq.s` with `movf` instead; that computes `!ueq` = + /// "ordered and unequal", which is *false* on NaN and would have silently + /// broken the one comparison float.md singles out. The emulator caught it + /// (`fcmp_is_correct_when_an_operand_is_nan`), and the plan's table is the + /// thing that was wrong, not this code. The `un.s` predicate the ISA also + /// offers is not needed: no LPIR condition asks for unorderedness alone. + /// + /// This does *not* make `Ne` a negated `Eq` at the `VInst` level — the two + /// consume the same Boolean with opposite moves, and collapsing them would + /// mean the emitter re-deriving the sense from context. + fn emit_fcmp( + &mut self, + output: &AllocOutput, + inst_idx: usize, + cond: FcmpCond, + src_op: Option, + ) -> Result<(), AllocError> { + if Self::is_dead_def(output, inst_idx, 0) { + return Ok(()); + } + let lhs = self.fuse_vreg(output, inst_idx, 1)?; + let rhs = self.fuse_vreg(output, inst_idx, 2)?; + + // `movt` moves when the Boolean is set, `movf` when it is clear. + let (op, swap, on_set) = match cond { + FcmpCond::Eq => (FpCmpOp::OeqS, false, true), + FcmpCond::Ne => (FpCmpOp::OeqS, false, false), + FcmpCond::Lt => (FpCmpOp::OltS, false, true), + FcmpCond::Le => (FpCmpOp::OleS, false, true), + FcmpCond::Gt => (FpCmpOp::OltS, true, true), + FcmpCond::Ge => (FpCmpOp::OleS, true, true), + }; + let (fs, ft) = if swap { (rhs, lhs) } else { (lhs, rhs) }; + self.inst(Inst::FpCmp(op, B_CMP, fs, ft), src_op); + + let d = self.def_vreg(output, inst_idx, 0, S0)?; + // The `1` needs a register of its own, distinct from the destination. + // `d` is `a8` exactly when the def is spilled, so pick the other one. + let scr = if d == S0 { S1 } else { S0 }; + self.iconst(d, 0, src_op); + self.iconst(scr, 1, src_op); + self.inst(Inst::MovBool(on_set, d, scr, B_CMP), src_op); + // `b0` is dead here: the invariant in the module doc holds for this + // sequence, and every other sequence in this file leaves it untouched. + self.store_def_vreg(output, inst_idx, 0, S0, src_op) + } + + /// `FSelect` — branch-free, via the FP conditional moves keyed on an + /// address register. + /// + /// The obvious two-instruction form (`mov.s dst, if_false` then + /// `movnez.s dst, if_true, cond`) is wrong when `dst` aliases `if_true`: + /// the first instruction destroys the value the second is supposed to + /// select. Inverting the sense in that case fixes it in *one* instruction + /// rather than adding a scratch copy. The integer `emit_select` has the + /// same trap, and `div_guard_is_correct_when_dst_aliases_an_operand` is + /// the precedent for testing all three alias cases rather than reasoning + /// about them. + fn emit_fselect( + &mut self, + output: &AllocOutput, + inst_idx: usize, + src_op: Option, + ) -> Result<(), AllocError> { + if Self::is_dead_def(output, inst_idx, 0) { + return Ok(()); + } + let cond = self.use_vreg(output, inst_idx, 1, S0, src_op)?; + let if_true = self.fuse_vreg(output, inst_idx, 2)?; + let if_false = self.fuse_vreg(output, inst_idx, 3)?; + let d = self.fdef_vreg(output, inst_idx, 0)?; + + if d == if_true { + // dst already holds the true value; overwrite it with the false + // value only when the condition is zero. + self.inst(Inst::FpMovAr(FpMovArOp::MoveqzS, d, if_false, cond), src_op); + } else { + self.fmov(d, if_false, src_op); + self.inst(Inst::FpMovAr(FpMovArOp::MovnezS, d, if_true, cond), src_op); + } + self.fstore_def_vreg(output, inst_idx, 0, src_op) + } +} + +// --------------------------------------------------------------------------- +// Emulator-backed tests +// --------------------------------------------------------------------------- + +#[cfg(test)] +mod tests { + use alloc::string::String; + use alloc::vec; + use alloc::vec::Vec; + + use lp_xt_emu::{Emulator, RunOutcome}; + use lp_xt_inst::{NullaryNarrowOp, SpecialReg, SrOp, encode}; + use lps_shared::{LpsFnKind, LpsFnSig, LpsType}; + + use super::*; + use crate::abi::{FrameLayout, PReg, PackedPReg, PregSet}; + use crate::isa::shared::IsaEmitOutput; + use crate::isa::xt::emit::emit_function; + use crate::regalloc::EditPoint; + use crate::regalloc::walk::build_operand_layout; + use crate::vinst::{ModuleSymbols, SRC_OP_NONE, VReg, VRegSlice}; + + const NONE: u16 = SRC_OP_NONE; + + fn v(n: u16) -> VReg { + VReg(n) + } + + fn ireg(hw: u8) -> Alloc { + Alloc::int_reg(hw) + } + + fn freg(hw: u8) -> Alloc { + Alloc::reg(PReg::float(hw)) + } + + fn slice(start: u16, count: u8) -> VRegSlice { + VRegSlice { start, count } + } + + fn frame(spills: u32) -> FrameLayout { + frame_with_outgoing(spills, 0) + } + + /// `outgoing` is the caller's outgoing stack-argument area, which sits at + /// the *bottom* of the frame and therefore pushes every spill slot's + /// SP-relative offset up by that much. It is the only lever a test has for + /// driving a float spill past `lsi`'s reach, because `Alloc::Stack` carries + /// a `u8` slot index and 255 slots reach exactly 1020 bytes on their own. + fn frame_with_outgoing(spills: u32, outgoing: u32) -> FrameLayout { + let sig = LpsFnSig { + name: "t".into(), + return_type: LpsType::Int, + parameters: vec![], + kind: LpsFnKind::UserDefined, + }; + let abi = crate::isa::xt::abi::func_abi_xt(&sig, None); + FrameLayout::compute( + &abi, + spills, + PregSet::EMPTY, + &[], + outgoing == 0, + 0, + outgoing, + ) + } + + /// Build an `AllocOutput` from a per-vreg allocation map, mirroring the + /// allocator's operand layout (defs first, then uses) — the same helper + /// shape `super::emit`'s tests use. + fn alloc_output( + vinsts: &[VInst], + pool: &[VReg], + map: &[(u16, Alloc)], + edits: Vec<(EditPoint, Edit)>, + spills: u32, + ) -> AllocOutput { + let (inst_alloc_offsets, total, _classes) = build_operand_layout(vinsts, pool); + let mut allocs = vec![Alloc::None; total]; + for (idx, inst) in vinsts.iter().enumerate() { + let mut ops: Vec = Vec::new(); + inst.for_each_def(pool, |r| ops.push(r)); + inst.for_each_use(pool, |r| ops.push(r)); + for (k, r) in ops.iter().enumerate() { + let a = map + .iter() + .find(|(vr, _)| *vr == r.0) + .map(|(_, a)| *a) + .unwrap_or_else(|| panic!("test map missing v{}", r.0)); + allocs[inst_alloc_offsets[idx] as usize + k] = a; + } + } + AllocOutput { + allocs, + inst_alloc_offsets, + edits, + num_spill_slots: spills, + trace: crate::regalloc::trace_sink_new(), + } + } + + fn emit( + vinsts: &[VInst], + pool: &[VReg], + output: &AllocOutput, + frame: FrameLayout, + ) -> IsaEmitOutput { + let symbols = ModuleSymbols::default(); + emit_function(vinsts, pool, output, frame, &symbols, false, true).expect("emit") + } + + /// Arm the FPU, then fall through into the compiled function. + /// + /// `Emulator::run` stages a fresh `Cpu`, and `Cpu::new()` leaves `CPENABLE` + /// clear **on purpose** — firmware that forgets to arm the coprocessor + /// faults on the host instead of silently working (M7 D6; the emulator's + /// own `an_unarmed_windowed_run_reports_a_coprocessor_trap` pins it). So a + /// host test of compiled float code has to do what P5's board init will do, + /// and this two-instruction preamble is the smallest honest version of it. + /// + /// It runs *before* the function's `ENTRY`, in the caller's window, which + /// constrains the register it may use: after the rotation the caller's + /// `a15` becomes the callee's `a7`, the sixth argument register, so this is + /// safe for any function taking five arguments or fewer (asserted below). + /// + /// The preamble is padded to a multiple of 4 bytes because the emitted + /// blob's literal pool must stay word-aligned — the layout contract in + /// `super::emit` assumes the blob starts 4-aligned, and prepending an + /// odd-sized preamble would quietly misalign every `l32r` target. + fn arm_fpu_preamble() -> Vec { + let mut p = Vec::new(); + p.extend_from_slice(&encode(&Inst::Movi(Reg::new(15), 1))); + p.extend_from_slice(&encode(&Inst::Sr( + SrOp::Wsr, + SpecialReg::Cpenable, + Reg::new(15), + ))); + while p.len() % 4 != 0 { + p.extend_from_slice(&encode(&Inst::NullaryN(NullaryNarrowOp::NopN))); + } + p + } + + /// Run a compiled function with the FPU armed, `args` in `a2..`. + fn run_f(code: &[u8], args: &[u32]) -> u32 { + assert!( + args.len() <= 5, + "the arming preamble clobbers the sixth argument register" + ); + let mut blob = arm_fpu_preamble(); + blob.extend_from_slice(code); + let mut emu = Emulator::new(); + match emu.run_with_args(&blob, 0, args) { + RunOutcome::Ok(v) => v, + RunOutcome::Trap(t) => panic!("emulator trap: {t:?}"), + } + } + + fn bits(f: f32) -> u32 { + f.to_bits() + } + + /// `f32(a0) OP f32(a1) -> f32`, through `Wfr` / the op / `Rfr` — the D1 + /// boundary convention, so the test can hand bit patterns in and read one + /// back out. + fn binop_code(op: FAluOp) -> IsaEmitOutput { + let pool = [v(0), v(1), v(2), v(3), v(4)]; + let vinsts = [ + VInst::Wfr { + dst: v(2), + src: v(0), + src_op: NONE, + }, + VInst::Wfr { + dst: v(3), + src: v(1), + src_op: NONE, + }, + VInst::FAluRRR { + op, + dst: v(4), + src1: v(2), + src2: v(3), + src_op: NONE, + }, + VInst::Rfr { + dst: v(0), + src: v(4), + src_op: NONE, + }, + VInst::Ret { + vals: slice(0, 1), + src_op: NONE, + }, + ]; + let out = alloc_output( + &vinsts, + &pool, + &[ + (0, ireg(2)), + (1, ireg(3)), + (2, freg(0)), + (3, freg(1)), + (4, freg(2)), + ], + vec![], + 0, + ); + emit(&vinsts, &pool, &out, frame(0)) + } + + #[test] + fn fadd_fsub_fmul_execute_on_the_emulator() { + for (op, l, r, want) in [ + (FAluOp::Add, 1.5f32, 2.25f32, 3.75f32), + (FAluOp::Sub, 1.5, 2.25, -0.75), + (FAluOp::Mul, 1.5, 2.25, 3.375), + ] { + let e = binop_code(op); + let got = run_f(&e.code, &[bits(l), bits(r)]); + assert_eq!( + f32::from_bits(got), + want, + "{op:?} {l} {r} gave {}", + f32::from_bits(got) + ); + } + } + + /// float.md §3, a Guaranteed row: `-1.0 * 0.0` is `-0.0` — the sign, not + /// the magnitude, carries the information. + #[test] + fn arithmetic_preserves_signed_zero() { + let e = binop_code(FAluOp::Mul); + let got = run_f(&e.code, &[bits(-1.0), bits(0.0)]); + assert_eq!(got, bits(-0.0), "-1.0 * 0.0 is -0.0, not +0.0"); + } + + /// **Awaiting M6-P6.** NaN *propagation through arithmetic* is a + /// deliberately-unresolved `lp-xt-emu` policy field (`nan_propagation`, + /// vector family F2): the emulator panics rather than inventing which + /// payload an `add.s` returns, because nothing has measured it on silicon. + /// That is M6's design, not a defect here, and this test is left in place + /// and marked rather than weakened — un-ignore it when the campaign closes + /// the field. + /// + /// The NaN behaviors M7 *can* assert today are all here already and all + /// pass: the compare predicates + /// (`fcmp_is_correct_when_an_operand_is_nan`) and the sign-bit ops' + /// payload preservation (`fabs_and_fneg_are_sign_bit_operations`), neither + /// of which reads the policy. + #[test] + #[ignore = "awaiting M6-P6: lp-xt-emu's nan_propagation policy field is unresolved"] + fn arithmetic_propagates_nan() { + let e = binop_code(FAluOp::Add); + let got = run_f(&e.code, &[bits(f32::NAN), bits(1.0)]); + assert!(f32::from_bits(got).is_nan(), "NaN + 1.0 must stay NaN"); + } + + /// `abs.s` / `neg.s` are sign-bit operations: they must not canonicalize a + /// NaN payload, and they must move a signed zero's sign. + fn unop_code(op: FAluRROp) -> IsaEmitOutput { + let pool = [v(0), v(1), v(2)]; + let vinsts = [ + VInst::Wfr { + dst: v(1), + src: v(0), + src_op: NONE, + }, + VInst::FAluRR { + op, + dst: v(2), + src: v(1), + src_op: NONE, + }, + VInst::Rfr { + dst: v(0), + src: v(2), + src_op: NONE, + }, + VInst::Ret { + vals: slice(0, 1), + src_op: NONE, + }, + ]; + let out = alloc_output( + &vinsts, + &pool, + &[(0, ireg(2)), (1, freg(0)), (2, freg(1))], + vec![], + 0, + ); + emit(&vinsts, &pool, &out, frame(0)) + } + + #[test] + fn fabs_and_fneg_are_sign_bit_operations() { + let abs = unop_code(FAluRROp::Abs); + assert_eq!(run_f(&abs.code, &[bits(-3.5)]), bits(3.5)); + assert_eq!(run_f(&abs.code, &[bits(-0.0)]), bits(0.0)); + // A NaN with a payload keeps its payload; only the sign bit clears. + assert_eq!(run_f(&abs.code, &[0xFFC0_1234]), 0x7FC0_1234); + + let neg = unop_code(FAluRROp::Neg); + assert_eq!(run_f(&neg.code, &[bits(3.5)]), bits(-3.5)); + assert_eq!(run_f(&neg.code, &[bits(0.0)]), bits(-0.0)); + assert_eq!(run_f(&neg.code, &[0x7FC0_1234]), 0xFFC0_1234); + + let mov = unop_code(FAluRROp::Mov); + assert_eq!(run_f(&mov.code, &[0xFFC0_1234]), 0xFFC0_1234); + } + + fn fcmp_code(cond: FcmpCond) -> IsaEmitOutput { + let pool = [v(0), v(1), v(2), v(3), v(4)]; + let vinsts = [ + VInst::Wfr { + dst: v(2), + src: v(0), + src_op: NONE, + }, + VInst::Wfr { + dst: v(3), + src: v(1), + src_op: NONE, + }, + VInst::Fcmp { + dst: v(4), + lhs: v(2), + rhs: v(3), + cond, + src_op: NONE, + }, + VInst::Ret { + vals: slice(4, 1), + src_op: NONE, + }, + ]; + let out = alloc_output( + &vinsts, + &pool, + &[ + (0, ireg(2)), + (1, ireg(3)), + (2, freg(0)), + (3, freg(1)), + (4, ireg(2)), + ], + vec![], + 0, + ); + emit(&vinsts, &pool, &out, frame(0)) + } + + /// All six conditions on ordinary values. + #[test] + fn fcmp_covers_all_six_conditions() { + let cases: &[(FcmpCond, f32, f32, u32)] = &[ + (FcmpCond::Eq, 1.0, 1.0, 1), + (FcmpCond::Eq, 1.0, 2.0, 0), + (FcmpCond::Ne, 1.0, 2.0, 1), + (FcmpCond::Ne, 1.0, 1.0, 0), + (FcmpCond::Lt, 1.0, 2.0, 1), + (FcmpCond::Lt, 2.0, 1.0, 0), + (FcmpCond::Lt, 1.0, 1.0, 0), + (FcmpCond::Le, 1.0, 1.0, 1), + (FcmpCond::Le, 2.0, 1.0, 0), + (FcmpCond::Gt, 2.0, 1.0, 1), + (FcmpCond::Gt, 1.0, 2.0, 0), + (FcmpCond::Gt, 1.0, 1.0, 0), + (FcmpCond::Ge, 1.0, 1.0, 1), + (FcmpCond::Ge, 1.0, 2.0, 0), + (FcmpCond::Ge, 2.0, 1.0, 1), + ]; + for &(cond, l, r, want) in cases { + let e = fcmp_code(cond); + assert_eq!( + run_f(&e.code, &[bits(l), bits(r)]), + want, + "{cond:?} {l} {r}" + ); + } + } + + /// float.md §3, a *Guaranteed* row: every ordered comparison is false when + /// an operand is NaN, and `!=` is true. This is why `Ne` is its own + /// condition rather than a negated `Eq` — the two differ exactly here. + #[test] + fn fcmp_is_correct_when_an_operand_is_nan() { + let nan = f32::NAN; + for (cond, want) in [ + (FcmpCond::Eq, 0), + (FcmpCond::Ne, 1), + (FcmpCond::Lt, 0), + (FcmpCond::Le, 0), + (FcmpCond::Gt, 0), + (FcmpCond::Ge, 0), + ] { + let e = fcmp_code(cond); + for (l, r) in [(nan, 1.0f32), (1.0, nan), (nan, nan)] { + assert_eq!( + run_f(&e.code, &[bits(l), bits(r)]), + want, + "{cond:?} with NaN ({l} {r})" + ); + } + } + } + + /// The select's three alias cases. `dst == if_true` is the one a naive + /// `mov.s` + `movnez.s` gets silently wrong, which is why it is tested + /// rather than reasoned about. + #[test] + fn fselect_is_correct_in_every_alias_case() { + // (dst, if_true, if_false) hardware float registers. + for (d, t, f) in [(2u8, 0u8, 1u8), (0, 0, 1), (1, 0, 1)] { + let pool = [v(0), v(1), v(2), v(3), v(4), v(5)]; + let vinsts = [ + VInst::Wfr { + dst: v(3), + src: v(1), + src_op: NONE, + }, + VInst::Wfr { + dst: v(4), + src: v(2), + src_op: NONE, + }, + VInst::FSelect { + dst: v(5), + cond: v(0), + if_true: v(3), + if_false: v(4), + src_op: NONE, + }, + VInst::Rfr { + dst: v(0), + src: v(5), + src_op: NONE, + }, + VInst::Ret { + vals: slice(0, 1), + src_op: NONE, + }, + ]; + let out = alloc_output( + &vinsts, + &pool, + &[ + (0, ireg(2)), + (1, ireg(3)), + (2, ireg(4)), + (3, freg(t)), + (4, freg(f)), + (5, freg(d)), + ], + vec![], + 0, + ); + let e = emit(&vinsts, &pool, &out, frame(0)); + let got_true = run_f(&e.code, &[1, bits(10.0), bits(20.0)]); + let got_false = run_f(&e.code, &[0, bits(10.0), bits(20.0)]); + assert_eq!( + f32::from_bits(got_true), + 10.0, + "dst=f{d} if_true=f{t} if_false=f{f}: cond!=0 must select if_true" + ); + assert_eq!( + f32::from_bits(got_false), + 20.0, + "dst=f{d} if_true=f{t} if_false=f{f}: cond==0 must select if_false" + ); + } + } + + #[test] + fn itof_converts_both_signednesses() { + for (signed, arg, want) in [ + (true, -3i32 as u32, -3.0f32), + (true, 7, 7.0), + (false, 0xFFFF_FFFF, 4294967296.0), + (false, 7, 7.0), + ] { + let pool = [v(0), v(1)]; + let vinsts = [ + VInst::IToF { + dst: v(1), + src: v(0), + signed, + src_op: NONE, + }, + VInst::Rfr { + dst: v(0), + src: v(1), + src_op: NONE, + }, + VInst::Ret { + vals: slice(0, 1), + src_op: NONE, + }, + ]; + let out = alloc_output(&vinsts, &pool, &[(0, ireg(2)), (1, freg(0))], vec![], 0); + let e = emit(&vinsts, &pool, &out, frame(0)); + assert_eq!(f32::from_bits(run_f(&e.code, &[arg])), want); + } + } + + // --- spill slots and the `lsi` range fallback -------------------------- + + /// `wfr` into a float vreg the allocator put on the stack, then reload it + /// through an edit and hand it back. Returns the emitted function and the + /// slot's byte offset from SP. + fn spill_round_trip(spills: u32, slot: u8, outgoing: u32) -> (IsaEmitOutput, i32) { + let pool = [v(0), v(1), v(2)]; + let vinsts = [ + // Def straight into a spill slot — the def path's scratch + // materialization followed by `ssi`. + VInst::Wfr { + dst: v(1), + src: v(0), + src_op: NONE, + }, + // Reloaded by an edit into f0, then read back out. + VInst::FAluRR { + op: FAluRROp::Mov, + dst: v(2), + src: v(1), + src_op: NONE, + }, + VInst::Rfr { + dst: v(0), + src: v(2), + src_op: NONE, + }, + VInst::Ret { + vals: slice(0, 1), + src_op: NONE, + }, + ]; + let mut out = alloc_output( + &vinsts, + &pool, + &[(0, ireg(2)), (1, Alloc::Stack(slot)), (2, freg(1))], + vec![( + EditPoint::Before(1), + Edit::Move { + from: Alloc::Stack(slot), + to: freg(0), + }, + )], + spills, + ); + // The edit reloads v1 into f0 before instruction 1, so that + // instruction's use operand names f0, not the slot — exactly what the + // allocator emits (`alloc_use` never leaves a use on the stack). + let base = out.inst_alloc_offsets[1] as usize; + out.allocs[base + 1] = freg(0); + let f = frame_with_outgoing(spills, outgoing); + let off = f.spill_offset_from_sp(slot as u32).expect("slot offset"); + (emit(&vinsts, &pool, &out, f), off) + } + + fn disassemble(code: &[u8]) -> String { + let mut s = String::new(); + let mut pc = 0usize; + while pc < code.len() { + let end = (pc + 3).min(code.len()); + let Ok((inst, len)) = lp_xt_inst::decode(&code[pc..end]) else { + pc += 1; + continue; + }; + s.push_str(&lp_xt_inst::disasm::format_inst(&inst, pc as u32)); + s.push('\n'); + pc += len; + } + s + } + + /// A float value round-tripped through a spill slot whose byte offset + /// `lsi`/`ssi` can encode directly. + #[test] + fn a_float_spill_in_range_uses_lsi_and_ssi_directly() { + let (e, off) = spill_round_trip(1, 0, 0); + assert!(imm::is_legal(ImmOp::FpLsiOffset, off)); + let text = disassemble(&e.code); + assert!( + text.contains("ssi") && text.contains("lsi"), + "expected a direct ssi/lsi pair:\n{text}" + ); + assert_eq!(f32::from_bits(run_f(&e.code, &[bits(12.5)])), 12.5); + } + + /// The silent-corruption hazard, pinned. `lp-xt-inst`'s encoder computes + /// `(offset / 4) & 0xff` with no range check, so a slot past 1020 bytes + /// encodes as a *different slot* unless the emitter takes the address + /// fallback. The value assertion alone would not catch it — a wrong slot + /// can hold the right bytes by coincidence in a small test — so the + /// encoding is asserted too. + #[test] + fn a_float_spill_past_lsi_range_takes_the_scratch_fallback() { + // Slot 255 is the highest an `Alloc::Stack` can name (1020 bytes on + // its own); a 64-byte outgoing-argument area below it pushes the slot + // to 1084, past `lsi`'s reach. + let (e, off) = spill_round_trip(256, 255, 64); + assert!( + off > 1020, + "the test must actually leave lsi range (offset {off})" + ); + assert!( + !imm::is_legal(ImmOp::FpLsiOffset, off), + "offset {off} was expected to be illegal for lsi/ssi" + ); + let text = disassemble(&e.code); + // Every ssi/lsi must name offset 0 off the scratch AR, never a + // truncated immediate field. + let mut fp_accesses = 0; + for line in text.lines() { + if line.contains("ssi") || line.contains("lsi") { + fp_accesses += 1; + assert!( + line.contains("a8, 0"), + "float access did not go through the scratch at offset 0: {line}" + ); + } + } + assert_eq!(fp_accesses, 2, "expected one ssi and one lsi:\n{text}"); + assert_eq!(f32::from_bits(run_f(&e.code, &[bits(12.5)])), 12.5); + } + + /// Float instructions must render in a disassembly — it is the first thing + /// anyone reads when a pipeline test fails. + #[test] + fn float_instructions_disassemble() { + let e = binop_code(FAluOp::Mul); + let text = disassemble(&e.code); + for want in ["wfr", "mul.s", "rfr"] { + assert!(text.contains(want), "missing {want} in:\n{text}"); + } + } + + // --- the class gate ---------------------------------------------------- + + /// The integer gate must keep rejecting a float allocation. If it ever + /// learns to unwrap one, an integer instruction can name `f3` while + /// meaning `a3`, and the failure is a wrong number rather than a crash. + #[test] + fn the_integer_gate_still_rejects_a_float_allocation() { + let pool = [v(0)]; + let vinsts = [ + VInst::IConst32 { + dst: v(0), + val: 1, + src_op: NONE, + }, + VInst::Ret { + vals: slice(0, 1), + src_op: NONE, + }, + ]; + let out = alloc_output(&vinsts, &pool, &[(0, freg(3))], vec![], 0); + let symbols = ModuleSymbols::default(); + emit_function(&vinsts, &pool, &out, frame(0), &symbols, false, true) + .expect_err("an integer instruction must not accept a float register"); + } + + /// And the float gate rejects an integer allocation — the other direction. + #[test] + fn the_float_gate_rejects_an_integer_allocation() { + assert!(EmitContext::fhw(PackedPReg::int(3)).is_err()); + assert!(EmitContext::fhw(PackedPReg::new(PReg::float(3))).is_ok()); + } +} diff --git a/lp-shader/lpvm-native/src/isa/xt/fpr.rs b/lp-shader/lpvm-native/src/isa/xt/fpr.rs index b42078e65..ae6ae94f4 100644 --- a/lp-shader/lpvm-native/src/isa/xt/fpr.rs +++ b/lp-shader/lpvm-native/src/isa/xt/fpr.rs @@ -34,17 +34,38 @@ //! at the *bottom* of the frame, structurally distant from the //! window-overflow reservation at the top. //! -//! # No FR is reserved as scratch +//! # One FR is reserved as scratch //! -//! Unlike `gpr.rs`, which holds back `a8`/`a9`, all 16 FRs are allocatable -//! (M7 D8). Nothing in the emitted subset needs a float scratch: spill and -//! reload are a direct `ssi`/`lsi` pair, and the two sequences that do need a -//! scratch — the out-of-range spill offset and the compare's 0/1 -//! materialization — need an *address* register, which is what `a8`/`a9` -//! already are. A reservation costs a register for the life of the backend, so -//! it is not made speculatively; a future inline divide sequence that needs -//! scratch FRs carves them out of [`ALLOC_POOL`], which is the single place to -//! do it. +//! `gpr.rs` holds back `a8`/`a9`; this file holds back exactly one FR, +//! [`SCRATCH`] (`f15`), leaving 15 allocatable. +//! +//! **This narrows M7 D8, and the reason is a demonstrated need, not caution.** +//! D8 was written against the spill/reload path, where it is right: an `ssi` +//! or `lsi` names the allocated FR directly and needs no third register, and +//! the two sequences that *do* need a scratch — the out-of-range spill offset +//! and the compare's 0/1 materialization — need an *address* register, which +//! `a8`/`a9` already are. What D8 did not account for is the **spilled def**. +//! +//! The register allocator can allocate an instruction's *destination* to +//! `Alloc::Stack` (`regalloc::walk::process_generic`: a def whose home register +//! was already freed by a later eviction in the backward walk, but which has a +//! spill slot). The emitter's contract for that case is to compute into a +//! scratch and then store it to the slot — that is what `def_vreg`/ +//! `store_def_vreg` do, and for integers the scratch is `a8`. An FP +//! instruction has to write *somewhere*, and there is no FR-free way to +//! produce a float result, so the float half needs the same affordance. +//! +//! **One is provably enough.** Only defs are ever stack-allocated: every *use* +//! goes through `regalloc::walk::alloc_use`, which returns `Alloc::Reg` on all +//! three of its paths (reloading through an inserted edit when the value lives +//! in a slot), and the one code path that forces uses to the stack is the sret +//! `Ret` constraint, which no float `VInst` reaches — float returns travel in +//! address registers (D1), so a float value reaches `Ret` only after an `Rfr`. +//! And no float `VInst` has two float defs. So at most one FR-sized +//! materialization is live at a time within a single emitted sequence. +//! +//! A future inline divide or sqrt sequence needing more scratch FRs carves +//! them out of [`ALLOC_POOL`] here, which remains the single place to do it. /// Physical FR index (`f0`–`f15`). pub type FReg = u8; @@ -52,15 +73,28 @@ pub type FReg = u8; /// Number of floating-point registers the coprocessor provides. pub const FR_COUNT: u8 = 16; -/// Registers available to the allocator — **all 16** (M7 D8). +/// The emitter's float scratch (`f15`) — **not allocatable**. +/// +/// The FR counterpart of [`gpr::SCRATCH`](super::gpr::SCRATCH) (`a8`), and it +/// exists for one reason: a float `VInst` whose destination the allocator put +/// on the stack must still compute into a register before it can be stored. +/// See this module's "One FR is reserved as scratch" section for why exactly +/// one suffices. +/// +/// `f15` rather than `f0` so the low-numbered registers — the ones a +/// disassembly listing shows first — stay allocatable, matching `gpr.rs`'s +/// habit of reserving out of the way of the program bank. +pub const SCRATCH: FReg = 15; + +/// Registers available to the allocator — 15 of the 16, all but [`SCRATCH`]. /// /// Order is the LRU initialization order, high to low. There is no /// caller-saved / callee-saved split to order around (every FR is clobbered by /// a call), and no FR carries an ABI role, so the order carries no meaning -/// beyond determinism: `f15` first keeps the low-numbered registers free +/// beyond determinism: high-first keeps the low-numbered registers free /// longest, matching `gpr::ALLOC_POOL`'s habit and making the two files' dumps /// read alike. -pub const ALLOC_POOL: &[FReg] = &[15, 14, 13, 12, 11, 10, 9, 8, 7, 6, 5, 4, 3, 2, 1, 0]; +pub const ALLOC_POOL: &[FReg] = &[14, 13, 12, 11, 10, 9, 8, 7, 6, 5, 4, 3, 2, 1, 0]; /// Pool members clobbered by a call — **all of them**. /// @@ -149,13 +183,20 @@ mod tests { } } - /// All 16, no duplicates, nothing held back (D8). A reservation creeping in - /// later would show up here rather than as a mysteriously smaller pool. + /// Every FR except [`SCRATCH`], no duplicates. A *second* reservation + /// creeping in later would show up here rather than as a mysteriously + /// smaller pool — and the scratch's exclusion is the whole point: if `f15` + /// were both allocatable and the emitter's materialization target, a + /// spilled def would silently clobber a live value. #[test] - fn every_fr_is_allocatable() { - assert_eq!(ALLOC_POOL.len(), FR_COUNT as usize); + fn every_fr_but_the_scratch_is_allocatable() { + assert_eq!(ALLOC_POOL.len(), FR_COUNT as usize - 1); for r in 0..FR_COUNT { - assert!(pool_contains(r), "f{r} missing from the pool"); + assert_eq!( + pool_contains(r), + r != SCRATCH, + "f{r} is on the wrong side of the scratch reservation" + ); } for (i, &f) in ALLOC_POOL.iter().enumerate() { assert!(!ALLOC_POOL[i + 1..].contains(&f), "duplicate f{f}"); diff --git a/lp-shader/lpvm-native/src/isa/xt/mod.rs b/lp-shader/lpvm-native/src/isa/xt/mod.rs index 4d6bf553c..5d3923340 100644 --- a/lp-shader/lpvm-native/src/isa/xt/mod.rs +++ b/lp-shader/lpvm-native/src/isa/xt/mod.rs @@ -8,6 +8,9 @@ pub mod abi; pub mod emit; +// The float half of the emitter, gated with the register model below. +#[cfg(feature = "float-f32")] +pub mod emit_fp; // The float register model. Gated at module granularity (M7 D9) so a // Fixed-only image links no float tables; the `VInst` float variants // themselves are unconditional, because `cfg` on enum variants matched diff --git a/lp-xt/fixtures/Cargo.lock b/lp-xt/fixtures/Cargo.lock index 610dab320..6817cdc60 100644 --- a/lp-xt/fixtures/Cargo.lock +++ b/lp-xt/fixtures/Cargo.lock @@ -58,6 +58,7 @@ version = "40.0.0" name = "lps-builtins" version = "40.0.0" dependencies = [ + "libm", "log", "lpfn-impl-macro", "lps-builtin-ids", diff --git a/lp-xt/lps-builtins-xt-app/Cargo.toml b/lp-xt/lps-builtins-xt-app/Cargo.toml index 6397057fe..96a342e5b 100644 --- a/lp-xt/lps-builtins-xt-app/Cargo.toml +++ b/lp-xt/lps-builtins-xt-app/Cargo.toml @@ -17,6 +17,23 @@ name = "lps-builtins-xt-app" path = "src/main.rs" test = false +[features] +# The native-f32 builtin family, forwarded exactly as the rv32 sibling +# (`lp-shader/lps-builtins-emu-app`) forwards it. +# +# `src/builtin_refs.rs` is *shared* with that crate, and its f32 entries carry +# `#[cfg(feature = "float-f32")]` — cfgs are evaluated against the crate being +# compiled, so without this feature declared here those entries vanish, the +# linker garbage-collects the f32 family, and every f32 builtin call M7's +# lowering emits fails to resolve against this image. That failure surfaces +# far from its cause, which is why `scripts/build-builtins-xt.sh` asserts the +# symbol count by name rather than trusting the build. +# +# Unlike the rv32 image this one is built *with* the feature on +# (`build-builtins-xt.sh` passes it): the Xtensa host path is where M7's +# hardware-f32 code runs, so the family is the point rather than dead weight. +float-f32 = ["lps-builtins/float-f32"] + [dependencies] lps-builtins = { path = "../../lp-shader/lps-builtins" } lp-xt-emu-guest = { path = "../lp-xt-emu-guest" } diff --git a/scripts/build-builtins-xt.sh b/scripts/build-builtins-xt.sh index c984facc6..ea4548276 100755 --- a/scripts/build-builtins-xt.sh +++ b/scripts/build-builtins-xt.sh @@ -42,7 +42,7 @@ export CARGO_TARGET_DIR="$PWD/target" # and staleness is not harmless here — on esp Rust 1.88 `lpc-model` genuinely # fails to compile (70x E0716 from the Slotted derive's const-promotion of a # temporary), which is what blocked the firmware crates. Fail loudly instead. -if ! cargo build --release -p lps-builtins-xt-app; then +if ! cargo build --release -p lps-builtins-xt-app --features float-f32; then echo >&2 echo "error: build failed. If that was an MSRV error, the esp toolchain is stale:" >&2 echo " installed: $(rustc +esp --version 2>/dev/null || echo unknown)" >&2 @@ -61,5 +61,15 @@ if [[ "$COUNT" -eq 0 ]]; then echo "error: $OUT contains no __lps_ symbols (dead-code elimination?)" >&2 exit 1 fi +# The f32 family is the reason M7 exists on this image: hardware-float lowering +# routes divide, sqrt, the rounding family, min/max and every transcendental to +# these symbols (M7 D4). An image built without `float-f32` links none of them +# and every such call fails to resolve — checked by name, mirroring the rv32 +# script, because the downstream failure is opaque. +F32_COUNT="$("$NM" "$OUT" | grep -c '_f32$' || true)" +if [[ "$F32_COUNT" -eq 0 ]]; then + echo "error: $OUT contains no native-f32 builtins (is --features float-f32 set?)" >&2 + exit 1 +fi SIZE="$(wc -c < "$OUT" | xargs)" -echo "lps-builtins-xt-app: $COUNT builtins, ${SIZE} B -> ${OUT#"$ROOT"/}" +echo "lps-builtins-xt-app: $COUNT builtins ($F32_COUNT native-f32), ${SIZE} B -> ${OUT#"$ROOT"/}" From f6863276a67dbaaeffe52fdc2e18d901f64d16f2 Mon Sep 17 00:00:00 2001 From: Yona Appletree Date: Fri, 31 Jul 2026 23:00:34 -0700 Subject: [PATCH 12/15] feat(lpvm-native): the f32 pipeline rig, the frame gate, and FloatImpl reporting MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit P4 of M7. `tests/xt_pipeline_f32.rs` runs compiled hardware f32 end to end on the M6 emulator through the real pipeline — LPIR → lower → regalloc → emit → link → windowed execution — and pins the milestone's headline hazard. **The frame hazard is now pinned rather than argued.** D7 claims float support adds no frame region, so float spills at the bottom of the frame cannot reach the window-overflow reservation at the top. Its failure mode is silent ancestor-frame corruption that surfaces long after the return, so the test carries several live floats across 100 nested `call8`s and checks every value on unwind — with an integer control at the same depth and shape, so a failure says "floats specifically" rather than "the window machinery". Both pass. A third test pins the frame *shape*: still one `entry`, one `retw`, still 32 reserved bytes. Also covered: all six compares including NaN, the arithmetic Guaranteed rows, select, `itof` both signednesses, float constants through `Wfr`, floats crossing a guest call in address registers (D1), float spill pressure past the pool, and both register pools saturated at once — the shape where a class confusion would show up. Unarmed `CPENABLE` faults with EXCCAUSE 32, which is the emulator proving D6's failure mode. `rt_emu` gains `call_f32_words`: raw IEEE bit patterns in and out, no conversion, because in F32 mode a word *is* the value. It is a separate entry point rather than a relaxed guard — the two modes disagree about what a word means, and a mismatch must be a message, not a wrong pixel. `call_render_texture`/`call_render_samples` stay Q32-only. `ShaderCompileStats.float_impl` stops being hardcoded to `Fixed`. `FloatImpl` moves to `lpvm` beside `LpvmModule` — the compiled module is the only thing that knows both the mode it was compiled in and the target it was compiled for — and `lp-shader` re-exports it, so no call site changes. **The builtins-image flip is configured but blocked upstream.** `lps-builtins-xt-app` now forwards `float-f32` and the build script asserts the f32 symbol count by name, mirroring the rv32 sibling. The build itself fails in the esp Rust backend, which cannot select a float constant pool, at any optimization level — see the new defect entry. The one pipeline test that calls an M5 builtin is marked `#[ignore]` pointing at it. Plan: 2026-07-30-1745-f32-native-math/m7-f32-codegen-xtensa --- ...ckend-cannot-select-float-constant-pool.md | 79 ++ lp-shader/lp-shader/src/compile_stats.rs | 41 +- lp-shader/lpvm-native/src/isa/mod.rs | 28 + lp-shader/lpvm-native/src/rt_emu/instance.rs | 115 ++ lp-shader/lpvm-native/src/rt_emu/module.rs | 8 + lp-shader/lpvm-native/src/rt_jit/module.rs | 6 + .../lpvm-native/tests/xt_pipeline_f32.rs | 1172 +++++++++++++++++ lp-shader/lpvm/src/lib.rs | 2 +- lp-shader/lpvm/src/module.rs | 43 + 9 files changed, 1464 insertions(+), 30 deletions(-) create mode 100644 docs/defects/2026-08-01-xtensa-backend-cannot-select-float-constant-pool.md create mode 100644 lp-shader/lpvm-native/tests/xt_pipeline_f32.rs diff --git a/docs/defects/2026-08-01-xtensa-backend-cannot-select-float-constant-pool.md b/docs/defects/2026-08-01-xtensa-backend-cannot-select-float-constant-pool.md new file mode 100644 index 000000000..1625fe428 --- /dev/null +++ b/docs/defects/2026-08-01-xtensa-backend-cannot-select-float-constant-pool.md @@ -0,0 +1,79 @@ +--- +status: open +found: 2026-08-01 # how: build (M7 P4, flipping the Xtensa builtins image to float-f32) +area: lp-xt/lps-builtins-xt-app, lp-shader/lps-builtins, esp Rust toolchain +class: upstream-toolchain-limitation +related: + - docs/design/float.md +--- +# The esp Xtensa backend cannot select a float constant pool, so `lps-builtins/float-f32` does not compile for Xtensa + +**Symptom** — Building the Xtensa builtins image with the f32 family enabled +fails in the compiler backend, not in our code: + +``` +rustc-LLVM ERROR: Cannot select: 0x10b3d4770: i32 = XtensaISD::PCREL_WRAPPER + TargetConstantPool:i32<@.LCP162_3 = internal constant [2 x float] + [float 0.000000e+00, float -1.000000e+00]> 0 +error: could not compile `lps-builtins-xt-app` (bin "lps-builtins-xt-app") +``` + +Reproduced with `scripts/build-builtins-xt.sh` after adding +`--features float-f32`, on +`rustc 1.95.0-nightly (95e5bda86 2026-04-15) (1.95.0.0)` from +`~/.rustup/toolchains/esp`, target `xtensa-esp32s3-none-elf`. + +**Not an optimization artifact.** The `lp-xt/fixtures` release profile uses +`opt-level = "s"` with `lto = "fat"`. Rebuilding with +`CARGO_PROFILE_RELEASE_OPT_LEVEL=1 CARGO_PROFILE_RELEASE_LTO=false` fails the +same way, only earlier — in `lps-builtins` (lib) itself rather than in the +final binary. So the failure is the backend's, at any optimization level, and +is not confined to the image crate. + +**Trigger** — `lps-builtins/src/builtins/lpfn/generative/snoise/snoise2_f32.rs` +has an 8-entry gradient lookup table written as a `match` returning +`[f32; 2]`. LLVM promotes the arms to constant-pool entries, and the Xtensa +backend has no selection rule for `PCREL_WRAPPER` over a `TargetConstantPool` +node — it can materialize the *address* of ordinary data, but not of a +constant-pool entry. The reported constant, `[0.0, -1.0]`, is that table's +arm 3. Other `[f32; N]` tables in the generative-noise family +(`worley2/3`, `gnoise3_tile`, `psrdnoise2/3`, `snoise3`, `rgb2hsv`) are +plausible further instances; only the first to be selected is reported. + +**rv32 is unaffected** — `scripts/build-builtins.sh` has passed +`--features float-f32` since M5 and builds the same source cleanly. This is +specific to the Xtensa target's backend. + +## Consequence + +The Xtensa builtins image **cannot currently carry M5's f32 symbols**. M7's +hardware-float lowering inlines the single-instruction family but routes +divide, sqrt, the rounding family, min/max, the float→int conversions and +every transcendental to those symbols (M7 D4), so on the host emulation path +those calls have nothing to resolve against. + +What this does *not* block: everything M7 P3 emits inline — `fadd`/`fsub`/ +`fmul`, the sign-bit ops, all six compares, float select, `itof`, float +load/store and the `wfr`/`rfr` boundary transfers. Those are the majority of +the emitted subset and are covered end to end by +`lp-shader/lpvm-native/tests/xt_pipeline_f32.rs`, whose one builtin-calling +case is marked `#[ignore]` pointing here. + +It is also a live risk for **M7 P5**: `fw-esp32s3` naming +`lps-builtins/float-f32` will hit the same wall at firmware build time. + +## Candidate resolutions (not chosen here — out of M7 P4's scope) + +1. **Rewrite the affected LUTs** so LLVM does not form a constant pool — e.g. + return a tuple, index a `const` array through a `static`, or compute the + gradient arithmetically. Cheapest, but it is a workaround living in shared + builtin source with no local explanation, and it must be rediscovered for + every new `[f32; N]` table. +2. **Report upstream** to `esp-rs/rust` and pin the toolchain once fixed. +3. **Split the f32 builtin feature** so the generative-noise family is + separately gated, letting Xtensa link the arithmetic builtins M7 D4 + actually needs while the noise family stays rv32/host-only. + +(3) is the closest fit to what M7 needs — the routed-to-builtin list in D4 is +arithmetic, not noise — but it changes a feature contract M5 owns, so it is a +decision for Yona rather than for this phase. diff --git a/lp-shader/lp-shader/src/compile_stats.rs b/lp-shader/lp-shader/src/compile_stats.rs index 25e84a9c4..12cfc0736 100644 --- a/lp-shader/lp-shader/src/compile_stats.rs +++ b/lp-shader/lp-shader/src/compile_stats.rs @@ -3,31 +3,15 @@ use lpvm::LpvmModule; /// How a compiled program actually performs its `float` arithmetic. /// -/// This is a *result* of compilation, not a request: the authored -/// `float_mode` slot says what the shader wants, and the backend reports -/// through this what the target could give it. Surfaced so a UI can disclose -/// "soft float" without guessing from the board (f32 roadmap D3). -#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)] -pub enum FloatImpl { - /// Q16.16 fixed point on integer instructions. - #[default] - Fixed, - /// IEEE f32 executed by a hardware FPU. - HardwareF32, - /// IEEE f32 emulated by soft-float library calls. - SoftF32, -} - -impl FloatImpl { - #[must_use] - pub fn as_str(self) -> &'static str { - match self { - Self::Fixed => "fixed", - Self::HardwareF32 => "hardware-f32", - Self::SoftF32 => "soft-f32", - } - } -} +/// A *result* of compilation, not a request: the authored `float_mode` slot +/// says what the shader wants, and the backend reports through this what the +/// target could give it. Surfaced so a UI can disclose "soft float" without +/// guessing from the board (f32 roadmap D3). +/// +/// Defined by `lpvm` alongside `LpvmModule`, because the compiled module is +/// the only thing that knows the answer, and re-exported here so this stays +/// the one place a consumer reads compile disclosure from. +pub use lpvm::FloatImpl; /// Backend-agnostic statistics captured after shader compilation. #[derive(Clone, Copy, Debug, Default, PartialEq, Eq)] @@ -37,9 +21,8 @@ pub struct LpsCompileStats { pub lpir_inst_count: usize, pub final_inst_count: Option, pub final_code_size_bytes: Option, - /// Hardware-vs-soft float disclosure. Every shipped backend is - /// [`FloatImpl::Fixed`] today; the f32 backends (roadmap M7/M9) set the - /// other two. + /// Hardware-vs-soft float disclosure, reported by the compiled module + /// itself (`LpvmModule::float_impl`) rather than assumed here. pub float_impl: FloatImpl, } @@ -52,7 +35,7 @@ impl LpsCompileStats { lpir_inst_count: count_lpir_insts(ir), final_inst_count: module.final_instruction_count(), final_code_size_bytes: module.code_size_bytes(), - float_impl: FloatImpl::Fixed, + float_impl: module.float_impl(), } } } diff --git a/lp-shader/lpvm-native/src/isa/mod.rs b/lp-shader/lpvm-native/src/isa/mod.rs index 6a584d695..5e957c912 100644 --- a/lp-shader/lpvm-native/src/isa/mod.rs +++ b/lp-shader/lpvm-native/src/isa/mod.rs @@ -10,6 +10,8 @@ use alloc::string::String; use lpir::IrFunction; use object::Architecture; +use lpvm::FloatImpl; + use crate::abi::{FrameLayout, PReg, RegClass}; use crate::regalloc::{AllocError, AllocOutput}; use crate::vinst::{AluImmOp, ModuleSymbols, VInst, VReg}; @@ -336,6 +338,32 @@ impl IsaTarget { } } + /// What a module compiled in `mode` for this target actually got, for + /// compile-stats disclosure (`LpvmModule::float_impl`, roadmap D3). + /// + /// The inverse direction of [`Self::f32_lowering`]: that one asks "how do + /// I lower this", this one asks "what do I tell the author I did". Kept + /// beside it so the two can never disagree — a target that starts emitting + /// hardware float changes one match arm and both answers follow. + /// + /// `Q32` is [`FloatImpl::Fixed`] on every target, and that is not a + /// placeholder: a Q16.16 `float` *is* an integer and lowers to integer + /// instructions, so "fixed" is the literal truth rather than a fallback. + pub fn float_impl_for(self, mode: lpir::FloatMode) -> FloatImpl { + match mode { + lpir::FloatMode::Q32 => FloatImpl::Fixed, + lpir::FloatMode::F32 => match self.f32_lowering() { + F32Lowering::HardwareFpu => FloatImpl::HardwareF32, + F32Lowering::SoftFloatCalls => FloatImpl::SoftF32, + // Unreachable by construction: an F32 module for a target that + // cannot lower float fails to compile, so no such module exists + // to report on. `Fixed` under-claims rather than over-claims, + // which is the right direction to be wrong in if it ever does. + F32Lowering::Unsupported => FloatImpl::Fixed, + }, + } + } + /// `object` crate Architecture for ELF emission. pub fn elf_architecture(self) -> Architecture { match self { diff --git a/lp-shader/lpvm-native/src/rt_emu/instance.rs b/lp-shader/lpvm-native/src/rt_emu/instance.rs index c2b59ea9a..d742e0d33 100644 --- a/lp-shader/lpvm-native/src/rt_emu/instance.rs +++ b/lp-shader/lpvm-native/src/rt_emu/instance.rs @@ -1104,6 +1104,92 @@ impl NativeEmuInstance { } Ok(words) } + + /// Invoke a function compiled in [`FloatMode::F32`], passing and returning + /// **raw IEEE-754 bit patterns**, one word per scalar component. + /// + /// The f32 counterpart of [`Self::call_q32_with_cycle_model`], and + /// deliberately the same shape: a flat word vector in, a flat word vector + /// out, no conversion in either direction. In F32 mode a word *is* the + /// value's bit pattern — that is exactly M7 D1's boundary convention, + /// where floats travel in address registers as bit patterns — so there is + /// nothing to convert and no fixed-point scale to get wrong. Callers turn + /// them into `f32` with `f32::from_bits`. + /// + /// Why a separate entry point rather than relaxing + /// [`Self::call_q32_with_cycle_model`]'s guard: the two modes disagree + /// about what a word *means*, and a Q32 caller handed F32 words gets + /// plausible garbage (`1.0f32` reads as `1065353216` in Q16.16, roughly + /// 16257.0). The mode check stays a hard error on both sides so a + /// mismatch is a message and not a wrong pixel. + /// + /// `call_render_texture` / `call_render_samples` stay Q32-only: the buffer + /// element format per float mode is a product-tier decision M7 does not + /// make. + pub fn call_f32_words(&mut self, name: &str, args: &[u32]) -> Result, NativeError> { + self.reset_globals(); + + self.last_debug = None; + self.last_guest_instruction_count = None; + self.last_guest_cycle_count = None; + if self.module.options.float_mode != FloatMode::F32 { + return Err(NativeError::Call(CallError::Unsupported(String::from( + "NativeEmuInstance::call_f32_words requires FloatMode::F32", + )))); + } + + let gfn = self + .module + .meta + .functions + .iter() + .find(|f| f.name == name) + .cloned() + .ok_or_else(|| CallError::MissingMetadata(name.into()))?; + + for p in &gfn.parameters { + if matches!(p.qualifier, ParamQualifier::Out | ParamQualifier::InOut) { + return Err(NativeError::Call(CallError::Unsupported(String::from( + "out/inout parameters are not supported for direct calling.", + )))); + } + } + + let ir_func = self + .module + .ir + .functions + .values() + .find(|f| f.name == name) + .ok_or_else(|| CallError::MissingMetadata(name.into()))?; + let param_count = ir_func.param_count as usize; + + let expected_words: usize = gfn + .parameters + .iter() + .map(|p| glsl_component_count(&p.ty)) + .sum(); + if args.len() != expected_words { + return Err(NativeError::Call(CallError::Arity { + expected: expected_words, + got: args.len(), + })); + } + if args.len() != param_count { + return Err(NativeError::Call(CallError::Unsupported(format!( + "flattened argument count {} does not match IR param_count {}", + args.len(), + param_count + )))); + } + + let flat: Vec = args.iter().map(|&w| w as i32).collect(); + let words = self.invoke_flat(name, &flat, CycleModel::default())?; + if gfn.return_type == LpsType::Void { + return Ok(Vec::new()); + } + Ok(words.into_iter().map(|w| w as u32).collect()) + } } #[cfg(test)] @@ -1168,6 +1254,35 @@ mod tests { assert_eq!(words, vec![42]); } + /// The two word entry points are not interchangeable, and the guard says + /// so rather than returning plausible garbage. + /// + /// A word means different things in the two modes — `1.0f32`'s bit pattern + /// read as Q16.16 is about 16257.0 — so a mode mismatch that "worked" + /// would surface as wrong pixels somewhere far away. Both directions are + /// checked because only checking one is how the other half rots. + #[test] + fn the_word_entry_points_refuse_the_wrong_float_mode() { + let (ir, meta) = spin_and_ok_module(); + let engine = NativeEmuEngine::new(NativeCompileOptions::default()); + let module = engine.compile(&ir, &meta).expect("compile"); + let mut inst = module.instantiate().expect("instantiate"); + + // This module is Q32 (the default), so the f32 entry must refuse it. + let err = inst + .call_f32_words("ok", &[]) + .expect_err("a Q32 module must not accept f32 words"); + let msg = alloc::format!("{err}"); + assert!( + msg.contains("FloatMode::F32"), + "the error must name the mode it wanted: {msg}" + ); + + // And the Q32 entry still works on it, so the guard is a guard and not + // a general breakage. + assert_eq!(inst.call_q32("ok", &[]).expect("q32 call"), vec![42]); + } + /// With the raised `EMU_CALL_INSTRUCTION_LIMIT`, a flat call armed with /// the DEFAULT 1M tank exhausts guest fuel (≈6M spin instructions) /// before the emulator instruction limit — the typed fuel trap, not diff --git a/lp-shader/lpvm-native/src/rt_emu/module.rs b/lp-shader/lpvm-native/src/rt_emu/module.rs index 34f4b0d60..457626004 100644 --- a/lp-shader/lpvm-native/src/rt_emu/module.rs +++ b/lp-shader/lpvm-native/src/rt_emu/module.rs @@ -38,6 +38,14 @@ impl LpvmModule for NativeEmuModule { &self.meta } + /// Both halves of the answer are on this module already: the mode it was + /// compiled in and the ISA it was compiled for. Neither alone is enough — + /// the same `FloatMode::F32` is hardware float on an S3 and soft float on + /// a C6. + fn float_impl(&self) -> lpvm::FloatImpl { + self.isa.float_impl_for(self.options.float_mode) + } + fn instantiate(&self) -> Result { use lpvm::AllocError; diff --git a/lp-shader/lpvm-native/src/rt_jit/module.rs b/lp-shader/lpvm-native/src/rt_jit/module.rs index 1f1e313ca..1bd580172 100644 --- a/lp-shader/lpvm-native/src/rt_jit/module.rs +++ b/lp-shader/lpvm-native/src/rt_jit/module.rs @@ -86,6 +86,12 @@ impl LpvmModule for NativeJitModule { &self.inner.meta } + /// The JIT always compiles for [`IsaTarget::native`] — the machine it is + /// running on — so the target needs no field of its own here. + fn float_impl(&self) -> lpvm::FloatImpl { + IsaTarget::native().float_impl_for(self.inner.options.float_mode) + } + fn instantiate(&self) -> Result { let align = 16usize; let total_size = self.inner.meta.vmctx_buffer_size(); diff --git a/lp-shader/lpvm-native/tests/xt_pipeline_f32.rs b/lp-shader/lpvm-native/tests/xt_pipeline_f32.rs new file mode 100644 index 000000000..6e171e2f3 --- /dev/null +++ b/lp-shader/lpvm-native/tests/xt_pipeline_f32.rs @@ -0,0 +1,1172 @@ +//! Full-pipeline tests for **hardware f32** on the Xtensa backend: LPIR → +//! lower → regalloc → `isa/xt` emit (integer half + `emit_fp`) → manual link +//! at the emulator's code base → windowed execution on `lp-xt-emu`. +//! +//! The f32 sibling of [`xt_pipeline`](../xt_pipeline.rs), and the same promise: +//! every value asserted here went through the real compiler pipeline with +//! `IsaTarget::Xtensa` and `FloatMode::F32`, not a hand-built VInst stream. +//! Where `xt_pipeline.rs` was the M3 gate rig, this is M7's. +//! +//! # What this file exists to prove +//! +//! Two things beyond "arithmetic works", both of which are silent-corruption +//! hazards that a shallow test cannot see: +//! +//! 1. **The frame is safe for floats** (M7 D7). Float spills live at the +//! *bottom* of the frame; the window-overflow handler scribbles in the +//! 32-byte reservation at the *top*. The argument that they cannot collide +//! is worth nothing untested, so `float_recursion_at_depth_100_*` carries +//! live floats across 100 nested `call8`s and checks every one on unwind. +//! 2. **Float spill offsets are range-checked** (`lsi`/`ssi` reach 1020 bytes, +//! and `lp-xt-inst`'s encoder truncates rather than failing). Covered at +//! the emitter level in `isa::xt::emit_fp`'s own tests, which can assert +//! the *encoding*; here the concern is that a real compiled function with +//! saturated register pressure round-trips. +//! +//! # Arming the FPU +//! +//! `Emulator::run*` stages a fresh `Cpu`, and `Cpu::new()` leaves `CPENABLE` +//! clear deliberately, so firmware that forgets to arm the coprocessor faults +//! on the host rather than silently working. Compiled shader code does not arm +//! it — that is board init's job (M7 D6, P5) — so this rig prepends a +//! two-instruction preamble that does what P5's board init will do. +//! `unarmed_float_code_faults_with_a_coprocessor_trap` is the negative +//! control: the same code without the preamble must take EXCCAUSE 32. + +use lp_collection::VecMap; +use lpir::builder::FunctionBuilder; +use lpir::{FloatMode, FuncId, IrType, LpirModule, LpirOp}; +use lps_shared::{FnParam, LpsFnKind, LpsFnSig, LpsModuleSig, LpsType, ParamQualifier}; +use lpvm_native::compile::compile_module; +use lpvm_native::isa::IsaTarget; +use lpvm_native::native_options::NativeCompileOptions; + +use lp_xt_emu::{Emulator, RunOutcome}; +use lp_xt_inst::{Inst, NullaryNarrowOp, Reg, SpecialReg, SrOp, encode}; + +/// EXCCAUSE 32 — `Coprocessor0Disabled`, what an FP instruction takes when +/// `CPENABLE` bit 0 is clear. +const EXC_COPROCESSOR0_DISABLED: u32 = 32; + +/// `movi a15, 1; wsr.cpenable a15`, padded to a multiple of 4 bytes. +/// +/// Runs *before* the entry function's `ENTRY`, in the caller's window. That +/// constrains which register it may touch: after the CALL8 rotation the +/// caller's `a15` becomes the callee's `a7`, the sixth argument register, so +/// this is safe for any entry taking five arguments or fewer — asserted in +/// [`link_blob`], because the failure would be a silently wrong argument. +/// +/// The 4-byte padding is not cosmetic: the emitted blob's literal pool must +/// stay word-aligned (`isa::xt::emit`'s layout contract assumes a 4-aligned +/// blob start), and an odd-sized preamble would misalign every `l32r` target. +fn arm_fpu_preamble() -> Vec { + let mut p = Vec::new(); + p.extend_from_slice(&encode(&Inst::Movi(Reg::new(15), 1))); + p.extend_from_slice(&encode(&Inst::Sr( + SrOp::Wsr, + SpecialReg::Cpenable, + Reg::new(15), + ))); + while p.len() % 4 != 0 { + p.extend_from_slice(&encode(&Inst::NullaryN(NullaryNarrowOp::NopN))); + } + p +} + +/// Compile `(ir, sig)` for Xtensa in `float_mode`, link every function at the +/// emulator's I-bus code base (patching literal-slot call relocations), and +/// return the linked blob plus the offset to start execution at. +/// +/// **The entry function is laid out first**, so the returned offset is always +/// 0 and, when `arm_fpu` is set, [`arm_fpu_preamble`] can simply precede it +/// and fall through. Entering at the preamble is the whole point — a run that +/// starts at the entry function instead skips the arming and every FP +/// instruction takes EXCCAUSE 32. The preamble is part of the blob *before* +/// function offsets are taken, so relocations resolve to the shifted addresses +/// with no separate fixup. +fn link_blob( + ir: &LpirModule, + sig: &LpsModuleSig, + entry_name: &str, + float_mode: FloatMode, + arm_fpu: bool, + emu: &Emulator, +) -> (Vec, u32) { + let opts = NativeCompileOptions { + float_mode, + fuel: false, + ..Default::default() + }; + let module = compile_module(ir, sig, float_mode, opts, IsaTarget::Xtensa) + .expect("xt f32 compile should succeed"); + + let funcs: Vec<_> = module.functions.iter().collect(); + let entry_idx = funcs + .iter() + .position(|f| f.name == entry_name) + .expect("entry function exists"); + let mut order: Vec = (0..funcs.len()).collect(); + order.sort_by_key(|&i| i != entry_idx); + + let mut code = if arm_fpu { arm_fpu_preamble() } else { Vec::new() }; + let entry_off = code.len() as u32; + let mut entries = VecMap::::new(); + let mut func_offsets = vec![0usize; funcs.len()]; + for &i in &order { + func_offsets[i] = code.len(); + entries.insert(funcs[i].name.clone(), code.len()); + code.extend_from_slice(&funcs[i].code); + } + + let ibus_base = emu.profile.code_ibus_base(); + for (fi, f) in funcs.iter().enumerate() { + for reloc in &f.relocs { + assert_eq!( + reloc.r_type, + IsaTarget::Xtensa.call_reloc_type(), + "unexpected reloc type" + ); + let target_off = *entries + .get(&reloc.symbol) + .unwrap_or_else(|| panic!("unresolved symbol {}", reloc.symbol)); + let target = ibus_base + target_off as u32; + let slot = func_offsets[fi] + reloc.offset; + code[slot..slot + 4].copy_from_slice(&target.to_le_bytes()); + } + } + + // Run from the preamble when arming, from the entry function otherwise — + // and those are the same place, because the entry is laid out first. + (code, if arm_fpu { 0 } else { entry_off }) +} + +/// Compile, link and run in `FloatMode::F32` with the FPU armed. +fn run_f32(ir: &LpirModule, sig: &LpsModuleSig, entry_name: &str, args: &[u32]) -> RunOutcome { + assert!( + args.len() <= 5, + "the arming preamble clobbers the sixth argument register" + ); + let mut emu = Emulator::new(); + let (code, entry_off) = link_blob(ir, sig, entry_name, FloatMode::F32, true, &emu); + emu.run_with_args(&code, entry_off, args) +} + +fn expect_ok(out: RunOutcome) -> u32 { + match out { + RunOutcome::Ok(v) => v, + RunOutcome::Trap(t) => panic!("unexpected trap: {t:?}"), + } +} + +fn bits(f: f32) -> u32 { + f.to_bits() +} + +// --------------------------------------------------------------------------- +// Module shapes +// --------------------------------------------------------------------------- + +fn float_param(name: &str) -> FnParam { + FnParam { + name: name.to_string(), + ty: LpsType::Float, + qualifier: ParamQualifier::In, + } +} + +fn int_param(name: &str) -> FnParam { + FnParam { + name: name.to_string(), + ty: LpsType::Int, + qualifier: ParamQualifier::In, + } +} + +fn one_function_sig(params: Vec, ret: LpsType) -> LpsModuleSig { + LpsModuleSig { + functions: vec![LpsFnSig { + name: "f".to_string(), + parameters: params, + return_type: ret, + kind: LpsFnKind::UserDefined, + }], + uniforms_type: None, + globals_type: None, + ..Default::default() + } +} + +fn one_function_module(func: lpir::IrFunction) -> LpirModule { + LpirModule { + imports: vec![], + functions: VecMap::from([(FuncId(0), func)]), + } +} + +/// `f(a: float, b: float) -> float`, body built by `build`. Both operands are +/// runtime arguments so nothing can be constant-folded. +fn run_float_binop( + build: impl FnOnce(&mut FunctionBuilder, lpir::VReg, lpir::VReg, lpir::VReg), + a: f32, + b: f32, +) -> u32 { + let mut fb = FunctionBuilder::new("f", &[IrType::F32, IrType::F32]); + let x = fb.add_param(IrType::F32); + let y = fb.add_param(IrType::F32); + let out = fb.alloc_vreg(IrType::F32); + build(&mut fb, x, y, out); + fb.push_return(&[out]); + let ir = one_function_module(fb.finish()); + let sig = one_function_sig( + vec![float_param("a"), float_param("b")], + LpsType::Float, + ); + expect_ok(run_f32(&ir, &sig, "f", &[0, bits(a), bits(b)])) +} + +/// `f(a: float, b: float) -> int` — the compare shape. +fn run_float_compare( + make: fn(lpir::VReg, lpir::VReg, lpir::VReg) -> LpirOp, + a: f32, + b: f32, +) -> u32 { + let mut fb = FunctionBuilder::new("f", &[IrType::F32, IrType::F32]); + let x = fb.add_param(IrType::F32); + let y = fb.add_param(IrType::F32); + let out = fb.alloc_vreg(IrType::I32); + fb.push(make(out, x, y)); + fb.push_return(&[out]); + let ir = one_function_module(fb.finish()); + let sig = one_function_sig(vec![float_param("a"), float_param("b")], LpsType::Int); + expect_ok(run_f32(&ir, &sig, "f", &[0, bits(a), bits(b)])) +} + +// --------------------------------------------------------------------------- +// Arithmetic, the inline family +// --------------------------------------------------------------------------- + +#[test] +fn fadd_fsub_fmul_through_the_pipeline() { + let add = |fb: &mut FunctionBuilder, x, y, out| { + fb.push(LpirOp::Fadd { + dst: out, + lhs: x, + rhs: y, + }) + }; + let sub = |fb: &mut FunctionBuilder, x, y, out| { + fb.push(LpirOp::Fsub { + dst: out, + lhs: x, + rhs: y, + }) + }; + let mul = |fb: &mut FunctionBuilder, x, y, out| { + fb.push(LpirOp::Fmul { + dst: out, + lhs: x, + rhs: y, + }) + }; + assert_eq!(f32::from_bits(run_float_binop(add, 1.5, 2.25)), 3.75); + assert_eq!(f32::from_bits(run_float_binop(sub, 1.5, 2.25)), -0.75); + assert_eq!(f32::from_bits(run_float_binop(mul, 1.5, 2.25)), 3.375); + // Infinities and signed zero — float.md §3 Guaranteed rows. + assert_eq!( + f32::from_bits(run_float_binop(add, f32::INFINITY, 1.0)), + f32::INFINITY + ); + assert_eq!(run_float_binop(mul, -1.0, 0.0), bits(-0.0)); + assert_eq!(run_float_binop(add, -0.0, 0.0), bits(0.0)); +} + +#[test] +fn fabs_and_fneg_through_the_pipeline() { + let abs = |fb: &mut FunctionBuilder, x, _y, out| fb.push(LpirOp::Fabs { dst: out, src: x }); + let neg = |fb: &mut FunctionBuilder, x, _y, out| fb.push(LpirOp::Fneg { dst: out, src: x }); + assert_eq!(f32::from_bits(run_float_binop(abs, -3.5, 0.0)), 3.5); + assert_eq!(run_float_binop(abs, -0.0, 0.0), bits(0.0)); + assert_eq!(f32::from_bits(run_float_binop(neg, 3.5, 0.0)), -3.5); + assert_eq!(run_float_binop(neg, 0.0, 0.0), bits(-0.0)); +} + +#[test] +fn float_constants_materialize_through_wfr() { + // A float constant is `IConst32` + `Wfr` (M7 D11) — no float literal pool + // of its own. Multiplying by it proves the bit pattern arrived intact. + let r = run_float_binop( + |fb, x, _y, out| { + let k = fb.alloc_vreg(IrType::F32); + fb.push(LpirOp::FconstF32 { + dst: k, + value: 0.25, + }); + fb.push(LpirOp::Fmul { + dst: out, + lhs: x, + rhs: k, + }); + }, + 8.0, + 0.0, + ); + assert_eq!(f32::from_bits(r), 2.0); +} + +// --------------------------------------------------------------------------- +// Compares +// --------------------------------------------------------------------------- + +fn feq(dst: lpir::VReg, lhs: lpir::VReg, rhs: lpir::VReg) -> LpirOp { + LpirOp::Feq { dst, lhs, rhs } +} +fn fne(dst: lpir::VReg, lhs: lpir::VReg, rhs: lpir::VReg) -> LpirOp { + LpirOp::Fne { dst, lhs, rhs } +} +fn flt(dst: lpir::VReg, lhs: lpir::VReg, rhs: lpir::VReg) -> LpirOp { + LpirOp::Flt { dst, lhs, rhs } +} +fn fle(dst: lpir::VReg, lhs: lpir::VReg, rhs: lpir::VReg) -> LpirOp { + LpirOp::Fle { dst, lhs, rhs } +} +fn fgt(dst: lpir::VReg, lhs: lpir::VReg, rhs: lpir::VReg) -> LpirOp { + LpirOp::Fgt { dst, lhs, rhs } +} +fn fge(dst: lpir::VReg, lhs: lpir::VReg, rhs: lpir::VReg) -> LpirOp { + LpirOp::Fge { dst, lhs, rhs } +} + +#[test] +fn all_six_float_compares_through_the_pipeline() { + for (name, make, l, r, want) in [ + ("eq", feq as fn(_, _, _) -> LpirOp, 1.0f32, 1.0f32, 1u32), + ("eq", feq, 1.0, 2.0, 0), + ("ne", fne, 1.0, 2.0, 1), + ("ne", fne, 1.0, 1.0, 0), + ("lt", flt, 1.0, 2.0, 1), + ("lt", flt, 2.0, 1.0, 0), + ("le", fle, 1.0, 1.0, 1), + ("le", fle, 2.0, 1.0, 0), + ("gt", fgt, 2.0, 1.0, 1), + ("gt", fgt, 1.0, 2.0, 0), + ("ge", fge, 1.0, 1.0, 1), + ("ge", fge, 1.0, 2.0, 0), + ] { + assert_eq!(run_float_compare(make, l, r), want, "{name}({l}, {r})"); + } +} + +/// float.md §3, a *Guaranteed* row: ordered comparisons are false when either +/// operand is NaN, and `!=` is true. +/// +/// This is the row that caught M7 D5's mapping table being wrong — it +/// tabulated `ueq.s` + `movf` for `!=`, which computes "ordered and unequal" +/// and answers *false* on NaN. See `isa::xt::emit_fp::emit_fcmp`. +#[test] +fn float_compares_handle_nan_per_the_spec() { + let nan = f32::NAN; + for (name, make, want) in [ + ("eq", feq as fn(_, _, _) -> LpirOp, 0u32), + ("ne", fne, 1), + ("lt", flt, 0), + ("le", fle, 0), + ("gt", fgt, 0), + ("ge", fge, 0), + ] { + for (l, r) in [(nan, 1.0f32), (1.0, nan), (nan, nan)] { + assert_eq!(run_float_compare(make, l, r), want, "{name} with NaN"); + } + } +} + +// --------------------------------------------------------------------------- +// Select and conversion +// --------------------------------------------------------------------------- + +#[test] +fn float_select_through_the_pipeline() { + // fabs via compare + select: (x < 0) ? -x : x, the float mirror of + // `xt_pipeline`'s `select_and_compares`. + for (x, want) in [(-12.5f32, 12.5f32), (12.5, 12.5), (0.0, 0.0)] { + let r = run_float_binop( + |fb, x, _y, out| { + let zero = fb.alloc_vreg(IrType::F32); + let cond = fb.alloc_vreg(IrType::I32); + let neg = fb.alloc_vreg(IrType::F32); + fb.push(LpirOp::FconstF32 { + dst: zero, + value: 0.0, + }); + fb.push(LpirOp::Flt { + dst: cond, + lhs: x, + rhs: zero, + }); + fb.push(LpirOp::Fneg { dst: neg, src: x }); + fb.push(LpirOp::Select { + dst: out, + cond, + if_true: neg, + if_false: x, + }); + }, + x, + 0.0, + ); + assert_eq!(f32::from_bits(r), want, "select on {x}"); + } +} + +#[test] +fn itof_signed_and_unsigned_through_the_pipeline() { + for (signed, arg, want) in [ + (true, -3i32 as u32, -3.0f32), + (true, 7, 7.0), + (false, 0xFFFF_FFFF, 4294967296.0), + (false, 7, 7.0), + ] { + let mut fb = FunctionBuilder::new("f", &[IrType::I32]); + let x = fb.add_param(IrType::I32); + let out = fb.alloc_vreg(IrType::F32); + fb.push(if signed { + LpirOp::ItofS { dst: out, src: x } + } else { + LpirOp::ItofU { dst: out, src: x } + }); + fb.push_return(&[out]); + let ir = one_function_module(fb.finish()); + let sig = one_function_sig(vec![int_param("x")], LpsType::Float); + let got = expect_ok(run_f32(&ir, &sig, "f", &[0, arg])); + assert_eq!(f32::from_bits(got), want, "itof signed={signed} of {arg:#x}"); + } +} + +// --------------------------------------------------------------------------- +// The D1 boundary: float params in, float return out, across a real call +// --------------------------------------------------------------------------- + +/// Float values travel across every call boundary in **address** registers as +/// raw IEEE bit patterns (M7 D1/D2), with `wfr`/`rfr` at the seams. A +/// guest→guest call carrying floats both ways is what makes that observable: +/// if the convention were half-applied, the callee would read an FR the caller +/// never wrote. +#[test] +fn floats_cross_a_guest_call_in_address_registers() { + // g(a, b) = a * b + a; f(a, b) = g(a, b) - b + let mut cb = FunctionBuilder::new("g", &[IrType::F32, IrType::F32]); + let ga = cb.add_param(IrType::F32); + let gb = cb.add_param(IrType::F32); + let prod = cb.alloc_vreg(IrType::F32); + let gout = cb.alloc_vreg(IrType::F32); + cb.push(LpirOp::Fmul { + dst: prod, + lhs: ga, + rhs: gb, + }); + cb.push(LpirOp::Fadd { + dst: gout, + lhs: prod, + rhs: ga, + }); + cb.push_return(&[gout]); + let g = cb.finish(); + + let mut fb = FunctionBuilder::new("f", &[IrType::F32, IrType::F32]); + let a = fb.add_param(IrType::F32); + let b = fb.add_param(IrType::F32); + let called = fb.alloc_vreg(IrType::F32); + let out = fb.alloc_vreg(IrType::F32); + fb.push_call( + lpir::CalleeRef::Local(FuncId(0)), + &[lpir::VMCTX_VREG, a, b], + &[called], + ); + fb.push(LpirOp::Fsub { + dst: out, + lhs: called, + rhs: b, + }); + fb.push_return(&[out]); + let f = fb.finish(); + + let ir = LpirModule { + imports: vec![], + functions: VecMap::from([(FuncId(0), g), (FuncId(1), f)]), + }; + let sig = LpsModuleSig { + functions: vec![ + LpsFnSig { + name: "g".to_string(), + parameters: vec![float_param("a"), float_param("b")], + return_type: LpsType::Float, + kind: LpsFnKind::UserDefined, + }, + LpsFnSig { + name: "f".to_string(), + parameters: vec![float_param("a"), float_param("b")], + return_type: LpsType::Float, + kind: LpsFnKind::UserDefined, + }, + ], + uniforms_type: None, + globals_type: None, + ..Default::default() + }; + + let (a, b) = (3.0f32, 0.5f32); + let got = expect_ok(run_f32(&ir, &sig, "f", &[0, bits(a), bits(b)])); + assert_eq!(f32::from_bits(got), a * b + a - b); +} + +// --------------------------------------------------------------------------- +// Register pressure — the counterpart to `spill_pressure_beyond_the_12_reg_pool` +// --------------------------------------------------------------------------- + +/// 24 simultaneously-live floats against a 15-register float pool, forcing +/// spills and reloads through `ssi`/`lsi`. +#[test] +fn float_spill_pressure_beyond_the_float_pool() { + let n = 24; + let r = run_float_binop( + |fb, x, _y, out| { + let one = fb.alloc_vreg(IrType::F32); + fb.push(LpirOp::FconstF32 { + dst: one, + value: 1.0, + }); + let mut vs = Vec::new(); + let mut prev = x; + for _ in 0..n { + let v = fb.alloc_vreg(IrType::F32); + fb.push(LpirOp::Fadd { + dst: v, + lhs: prev, + rhs: one, + }); + vs.push(v); + prev = v; + } + // Sum in reverse so every value stays live until it is used. + fb.push(LpirOp::FconstF32 { + dst: out, + value: 0.0, + }); + for &v in vs.iter().rev() { + fb.push(LpirOp::Fadd { + dst: out, + lhs: out, + rhs: v, + }); + } + }, + 0.0, + 0.0, + ); + // v_i = i + 1, summed. + let want: f32 = (1..=n).map(|i| i as f32).sum(); + assert_eq!(f32::from_bits(r), want); +} + +/// Both pools saturated at once — ~14 integer and ~20 float values live +/// simultaneously. This is where a class confusion shows up: an integer +/// instruction handed a float allocation, or a float spill written to an +/// integer's slot, changes the answer rather than crashing. +#[test] +fn both_register_pools_saturated_simultaneously() { + let n_int = 14i32; + let n_flt = 20; + let mut fb = FunctionBuilder::new("f", &[IrType::I32]); + let k = fb.add_param(IrType::I32); + + let one = fb.alloc_vreg(IrType::F32); + fb.push(LpirOp::FconstF32 { + dst: one, + value: 1.0, + }); + + let mut ints = Vec::new(); + for i in 0..n_int { + let v = fb.alloc_vreg(IrType::I32); + fb.push(LpirOp::IaddImm { + dst: v, + src: k, + imm: i, + }); + ints.push(v); + } + let mut flts = Vec::new(); + let mut prev = one; + for _ in 0..n_flt { + let v = fb.alloc_vreg(IrType::F32); + fb.push(LpirOp::Fadd { + dst: v, + lhs: prev, + rhs: one, + }); + flts.push(v); + prev = v; + } + + // Consume both sets in reverse, interleaved, so both pools are under + // pressure across the same instructions. + let iacc = fb.alloc_vreg(IrType::I32); + fb.push(LpirOp::IconstI32 { + dst: iacc, + value: 0, + }); + let facc = fb.alloc_vreg(IrType::F32); + fb.push(LpirOp::FconstF32 { + dst: facc, + value: 0.0, + }); + for i in 0..n_flt.max(n_int as usize) { + if let Some(&v) = ints.iter().rev().nth(i) { + fb.push(LpirOp::Iadd { + dst: iacc, + lhs: iacc, + rhs: v, + }); + } + if let Some(&v) = flts.iter().rev().nth(i) { + fb.push(LpirOp::Fadd { + dst: facc, + lhs: facc, + rhs: v, + }); + } + } + + // Fold the integer accumulator into the float one so a single return value + // depends on both, and neither can be silently dropped. + let iacc_f = fb.alloc_vreg(IrType::F32); + fb.push(LpirOp::ItofS { + dst: iacc_f, + src: iacc, + }); + let out = fb.alloc_vreg(IrType::F32); + fb.push(LpirOp::Fadd { + dst: out, + lhs: facc, + rhs: iacc_f, + }); + fb.push_return(&[out]); + + let ir = one_function_module(fb.finish()); + let sig = one_function_sig(vec![int_param("k")], LpsType::Float); + let base = 100i32; + let got = expect_ok(run_f32(&ir, &sig, "f", &[0, base as u32])); + + let want_i: i32 = (0..n_int).map(|i| base + i).sum(); + let want_f: f32 = (2..=(n_flt + 1)).map(|i| i as f32).sum(); + assert_eq!(f32::from_bits(got), want_f + want_i as f32); +} + +// --------------------------------------------------------------------------- +// The frame hazard gate (M7 D7) +// --------------------------------------------------------------------------- + +/// **The milestone's headline hazard, pinned.** +/// +/// M7 changes nothing about the frame: no FR is callee-saved (measured, +/// M6-P4), so there is no FP callee-save region, `FrameLayout::compute` is +/// untouched, and the prologue/epilogue stay one `entry` / one `retw`. Float +/// spills land in the existing spill region at the *bottom* of the frame, +/// while the window-overflow handler writes into the 32-byte reservation at +/// the *top*. +/// +/// That argument is worth nothing untested, and its failure mode is the worst +/// kind: **silent corruption of an ancestor frame that only surfaces long +/// after the return**. A shallow test cannot see it — the window has to +/// actually overflow, which needs real depth, and more than one live value has +/// to cross each call or a single-register accident can hide it. +/// +/// So: depth 100, several live floats carried across each recursive `call8`, +/// every one checked on the way back out. This is the shape that retired the +/// windowed-ABI risk in the experiment repo. +/// +/// The **integer control** is not decoration (M6-P2's precedent): if the ints +/// survive and the floats do not, the finding is "floats specifically", which +/// is a different bug from "the window machinery is broken". +#[test] +fn float_recursion_at_depth_100_preserves_every_live_value() { + // rec(n, a, b) = + // if n == 0 { a + b } + // else { rec(n-1, a, b) + a*2 + b*3 } -- a, b live across the call + // + // Every level holds `a`, `b` and the integer `n` live across its recursive + // call, so 100 frames of them are in flight at the deepest point. + let mut cb = FunctionBuilder::new("rec", &[IrType::I32, IrType::F32, IrType::F32]); + let n = cb.add_param(IrType::I32); + let a = cb.add_param(IrType::F32); + let b = cb.add_param(IrType::F32); + + let zero = cb.alloc_vreg(IrType::I32); + let is_zero = cb.alloc_vreg(IrType::I32); + let out = cb.alloc_vreg(IrType::F32); + cb.push(LpirOp::IconstI32 { + dst: zero, + value: 0, + }); + cb.push(LpirOp::Ieq { + dst: is_zero, + lhs: n, + rhs: zero, + }); + cb.push_if(is_zero); + cb.push(LpirOp::Fadd { + dst: out, + lhs: a, + rhs: b, + }); + cb.push_else(); + { + let n1 = cb.alloc_vreg(IrType::I32); + cb.push(LpirOp::IaddImm { + dst: n1, + src: n, + imm: -1, + }); + let deeper = cb.alloc_vreg(IrType::F32); + cb.push_call( + lpir::CalleeRef::Local(FuncId(0)), + &[lpir::VMCTX_VREG, n1, a, b], + &[deeper], + ); + // `a` and `b` are read *after* the call returns — that is what makes + // them live across it, and what a clobbered ancestor frame destroys. + let two = cb.alloc_vreg(IrType::F32); + let three = cb.alloc_vreg(IrType::F32); + let ta = cb.alloc_vreg(IrType::F32); + let tb = cb.alloc_vreg(IrType::F32); + let sum = cb.alloc_vreg(IrType::F32); + cb.push(LpirOp::FconstF32 { + dst: two, + value: 2.0, + }); + cb.push(LpirOp::FconstF32 { + dst: three, + value: 3.0, + }); + cb.push(LpirOp::Fmul { + dst: ta, + lhs: a, + rhs: two, + }); + cb.push(LpirOp::Fmul { + dst: tb, + lhs: b, + rhs: three, + }); + cb.push(LpirOp::Fadd { + dst: sum, + lhs: ta, + rhs: tb, + }); + cb.push(LpirOp::Fadd { + dst: out, + lhs: deeper, + rhs: sum, + }); + } + cb.end_if(); + cb.push_return(&[out]); + let rec = cb.finish(); + + let mut fb = FunctionBuilder::new("f", &[IrType::I32, IrType::F32, IrType::F32]); + let fn_ = fb.add_param(IrType::I32); + let fa = fb.add_param(IrType::F32); + let fb_ = fb.add_param(IrType::F32); + let out = fb.alloc_vreg(IrType::F32); + fb.push_call( + lpir::CalleeRef::Local(FuncId(0)), + &[lpir::VMCTX_VREG, fn_, fa, fb_], + &[out], + ); + fb.push_return(&[out]); + let f = fb.finish(); + + let ir = LpirModule { + imports: vec![], + functions: VecMap::from([(FuncId(0), rec), (FuncId(1), f)]), + }; + let params = || { + vec![ + int_param("n"), + float_param("a"), + float_param("b"), + ] + }; + let sig = LpsModuleSig { + functions: vec![ + LpsFnSig { + name: "rec".to_string(), + parameters: params(), + return_type: LpsType::Float, + kind: LpsFnKind::UserDefined, + }, + LpsFnSig { + name: "f".to_string(), + parameters: params(), + return_type: LpsType::Float, + kind: LpsFnKind::UserDefined, + }, + ], + uniforms_type: None, + globals_type: None, + ..Default::default() + }; + + let depth = 100i32; + let (a, b) = (1.25f32, 2.5f32); + let mut emu = Emulator::new(); + // 100 nested calls of a spilling function need more than the fixture + // corpus's default budget. + emu.step_budget = 8_000_000; + let (code, entry_off) = link_blob(&ir, &sig, "f", FloatMode::F32, true, &emu); + let got = match emu.run_with_args(&code, entry_off, &[0, depth as u32, bits(a), bits(b)]) { + RunOutcome::Ok(v) => v, + RunOutcome::Trap(t) => panic!("depth-{depth} float recursion trapped: {t:?}"), + }; + + let want = a + b + depth as f32 * (a * 2.0 + b * 3.0); + assert_eq!( + f32::from_bits(got), + want, + "a float live across {depth} nested call8s was corrupted" + ); +} + +/// The integer control for the test above, at the same depth and shape. +/// +/// If this fails too, the finding is the window machinery, not floats. If this +/// passes and the float one fails, the finding is precise. +#[test] +fn integer_recursion_at_depth_100_is_the_control() { + let mut cb = FunctionBuilder::new("rec", &[IrType::I32, IrType::I32, IrType::I32]); + let n = cb.add_param(IrType::I32); + let a = cb.add_param(IrType::I32); + let b = cb.add_param(IrType::I32); + let zero = cb.alloc_vreg(IrType::I32); + let is_zero = cb.alloc_vreg(IrType::I32); + let out = cb.alloc_vreg(IrType::I32); + cb.push(LpirOp::IconstI32 { + dst: zero, + value: 0, + }); + cb.push(LpirOp::Ieq { + dst: is_zero, + lhs: n, + rhs: zero, + }); + cb.push_if(is_zero); + cb.push(LpirOp::Iadd { + dst: out, + lhs: a, + rhs: b, + }); + cb.push_else(); + { + let n1 = cb.alloc_vreg(IrType::I32); + cb.push(LpirOp::IaddImm { + dst: n1, + src: n, + imm: -1, + }); + let deeper = cb.alloc_vreg(IrType::I32); + cb.push_call( + lpir::CalleeRef::Local(FuncId(0)), + &[lpir::VMCTX_VREG, n1, a, b], + &[deeper], + ); + let ta = cb.alloc_vreg(IrType::I32); + let tb = cb.alloc_vreg(IrType::I32); + let sum = cb.alloc_vreg(IrType::I32); + cb.push(LpirOp::IaddImm { + dst: ta, + src: a, + imm: 0, + }); + cb.push(LpirOp::Iadd { + dst: ta, + lhs: ta, + rhs: a, + }); + cb.push(LpirOp::IaddImm { + dst: tb, + src: b, + imm: 0, + }); + cb.push(LpirOp::Iadd { + dst: tb, + lhs: tb, + rhs: b, + }); + cb.push(LpirOp::Iadd { + dst: tb, + lhs: tb, + rhs: b, + }); + cb.push(LpirOp::Iadd { + dst: sum, + lhs: ta, + rhs: tb, + }); + cb.push(LpirOp::Iadd { + dst: out, + lhs: deeper, + rhs: sum, + }); + } + cb.end_if(); + cb.push_return(&[out]); + let rec = cb.finish(); + + let mut fb = FunctionBuilder::new("f", &[IrType::I32, IrType::I32, IrType::I32]); + let fn_ = fb.add_param(IrType::I32); + let fa = fb.add_param(IrType::I32); + let fb_ = fb.add_param(IrType::I32); + let out = fb.alloc_vreg(IrType::I32); + fb.push_call( + lpir::CalleeRef::Local(FuncId(0)), + &[lpir::VMCTX_VREG, fn_, fa, fb_], + &[out], + ); + fb.push_return(&[out]); + let f = fb.finish(); + + let ir = LpirModule { + imports: vec![], + functions: VecMap::from([(FuncId(0), rec), (FuncId(1), f)]), + }; + let params = || vec![int_param("n"), int_param("a"), int_param("b")]; + let sig = LpsModuleSig { + functions: vec![ + LpsFnSig { + name: "rec".to_string(), + parameters: params(), + return_type: LpsType::Int, + kind: LpsFnKind::UserDefined, + }, + LpsFnSig { + name: "f".to_string(), + parameters: params(), + return_type: LpsType::Int, + kind: LpsFnKind::UserDefined, + }, + ], + uniforms_type: None, + globals_type: None, + ..Default::default() + }; + + let (depth, a, b) = (100i32, 5i32, 7i32); + let mut emu = Emulator::new(); + emu.step_budget = 8_000_000; + let (code, entry_off) = link_blob(&ir, &sig, "f", FloatMode::F32, true, &emu); + let got = match emu.run_with_args(&code, entry_off, &[0, depth as u32, a as u32, b as u32]) { + RunOutcome::Ok(v) => v, + RunOutcome::Trap(t) => panic!("depth-{depth} integer recursion trapped: {t:?}"), + }; + let want = a + b + depth * (a * 2 + b * 3); + assert_eq!(got as i32, want); +} + +// --------------------------------------------------------------------------- +// CPENABLE — the D6 failure mode, proved by the emulator +// --------------------------------------------------------------------------- + +/// Without the arming preamble, the first FP instruction must **fault**, not +/// quietly execute. +/// +/// `Cpu::new()` leaves `CPENABLE` clear on purpose so that firmware which +/// forgets to arm the coprocessor fails on the host instead of on a board. +/// This test is what turns that from an emulator implementation detail into +/// M7's stated failure mode (D6): compiled float code on an unarmed core takes +/// EXCCAUSE 32. +#[test] +fn unarmed_float_code_faults_with_a_coprocessor_trap() { + let mut fb = FunctionBuilder::new("f", &[IrType::F32, IrType::F32]); + let x = fb.add_param(IrType::F32); + let y = fb.add_param(IrType::F32); + let out = fb.alloc_vreg(IrType::F32); + fb.push(LpirOp::Fadd { + dst: out, + lhs: x, + rhs: y, + }); + fb.push_return(&[out]); + let ir = one_function_module(fb.finish()); + let sig = one_function_sig(vec![float_param("a"), float_param("b")], LpsType::Float); + + let mut emu = Emulator::new(); + let (code, entry_off) = link_blob(&ir, &sig, "f", FloatMode::F32, false, &emu); + match emu.run_with_args(&code, entry_off, &[0, bits(1.0), bits(2.0)]) { + RunOutcome::Trap(t) => assert_eq!( + t.cause, EXC_COPROCESSOR0_DISABLED, + "expected a coprocessor-disabled trap, got cause {}", + t.cause + ), + RunOutcome::Ok(v) => panic!( + "unarmed FP executed instead of faulting (returned {:#010x}) — \ + the CPENABLE gate is not doing its job", + v + ), + } +} + +// --------------------------------------------------------------------------- +// Builtin routing (M7 D4) +// --------------------------------------------------------------------------- + +/// **Blocked on an upstream toolchain limitation** — see +/// `docs/defects/2026-08-01-xtensa-backend-cannot-select-float-constant-pool.md`. +/// +/// M7 D4 routes the non-inlinable float operations (divide, sqrt, the rounding +/// family, min/max, float→int, every transcendental) to M5's `_f32` builtins +/// via `sym_call`. Resolving those on the host emulation path needs +/// `lp-xt/lps-builtins-xt-app` built with `float-f32` — and that build fails in +/// the esp Rust backend, which cannot select a float constant pool, at any +/// optimization level. The image therefore carries none of the f32 family yet. +/// +/// The flip itself is done (`Cargo.toml` and `scripts/build-builtins-xt.sh`); +/// only the build is blocked. Un-ignore this when the defect is resolved. +/// `ffloor` is deliberately chosen over `fdiv`/`fsqrt`: it reaches no +/// `div0.s`/`const.s` estimate helper, so it does not additionally depend on +/// M6-P6's unresolved policy fields. +#[test] +#[ignore = "blocked: lps-builtins/float-f32 does not compile for Xtensa (see docs/defects/2026-08-01-xtensa-backend-cannot-select-float-constant-pool.md)"] +fn a_builtin_routed_float_op_resolves_and_runs() { + let r = run_float_binop( + |fb, x, _y, out| fb.push(LpirOp::Ffloor { dst: out, src: x }), + 3.75, + 0.0, + ); + assert_eq!(f32::from_bits(r), 3.0); +} + +// --------------------------------------------------------------------------- +// The frame is a non-change (M7 D7) — pinned, not asserted in prose +// --------------------------------------------------------------------------- + +/// A float-using Xtensa function must have exactly the frame it had before +/// hardware float existed. +/// +/// D7's claim is that float support adds **no** frame region: no FR is +/// callee-saved (measured, M6-P4), so there is no FP callee-save area to lay +/// out, and float spills reuse the existing class-tagged spill index space at +/// the bottom of the frame. That claim is what makes the depth-100 recursion +/// above safe — spills at the bottom cannot reach the window-overflow +/// reservation at the top. +/// +/// So this pins the shape rather than the reasoning: the reservation is still +/// 32 bytes, the prologue is still a single `entry`, the epilogue a single +/// `retw`, and no extra region appeared between them. If this test starts +/// failing, D7 stopped being true and the recursion test's safety argument +/// goes with it. +#[test] +fn a_float_function_has_the_same_frame_shape_as_before() { + use lp_xt_inst::{Inst, NullaryOp, decode}; + + let mut fb = FunctionBuilder::new("f", &[IrType::F32, IrType::F32]); + let x = fb.add_param(IrType::F32); + let y = fb.add_param(IrType::F32); + let out = fb.alloc_vreg(IrType::F32); + // Enough live floats to force spills, so the spill region is non-empty and + // its placement is actually exercised. + let mut vs = Vec::new(); + let mut prev = x; + for _ in 0..20 { + let v = fb.alloc_vreg(IrType::F32); + fb.push(LpirOp::Fadd { + dst: v, + lhs: prev, + rhs: y, + }); + vs.push(v); + prev = v; + } + fb.push(LpirOp::FconstF32 { + dst: out, + value: 0.0, + }); + for &v in vs.iter().rev() { + fb.push(LpirOp::Fadd { + dst: out, + lhs: out, + rhs: v, + }); + } + fb.push_return(&[out]); + + let ir = one_function_module(fb.finish()); + let sig = one_function_sig(vec![float_param("a"), float_param("b")], LpsType::Float); + + let emu = Emulator::new(); + let (code, entry_off) = link_blob(&ir, &sig, "f", FloatMode::F32, false, &emu); + let body = &code[entry_off as usize..]; + + // The prologue is one `entry a1, frame`. A literal pool would put a `j` + // first; this function has one (the float constant), so skip it. + let mut pc = 0usize; + let mut entries = 0usize; + let mut retws = 0usize; + let mut saw_entry_first = None; + while pc < body.len() { + let end = (pc + 3).min(body.len()); + let Ok((inst, len)) = decode(&body[pc..end]) else { + pc += 1; + continue; + }; + match inst { + Inst::Entry(..) => { + entries += 1; + if saw_entry_first.is_none() { + saw_entry_first = Some(pc); + } + } + Inst::Nullary(NullaryOp::Retw) => retws += 1, + _ => {} + } + pc += len; + } + + assert_eq!( + entries, 1, + "the prologue must still be exactly one `entry` — a second one would \ + mean float support grew a frame region" + ); + assert_eq!(retws, 1, "the epilogue must still be exactly one `retw`"); + + // And the reservation itself is untouched. + assert_eq!( + IsaTarget::Xtensa.frame_top_reserved_bytes(), + 32, + "the window-overflow reservation must stay 32 bytes (M7 D7)" + ); +} + +// --------------------------------------------------------------------------- +// Compile-stats disclosure (roadmap D3) +// --------------------------------------------------------------------------- + +/// An F32 module compiled for Xtensa reports `HardwareF32`, and a Q32 one +/// reports `Fixed`. +/// +/// The value used to be hardcoded to `Fixed` in `LpsCompileStats::from_module`; +/// it now comes from the module, which is the only thing that knows both the +/// mode it was compiled in and the target it was compiled for. +#[test] +fn the_isa_reports_what_it_actually_emitted() { + use lpvm::FloatImpl; + + assert_eq!( + IsaTarget::Xtensa.float_impl_for(FloatMode::F32), + FloatImpl::HardwareF32, + "Xtensa with float-f32 emits real FP instructions and must say so" + ); + assert_eq!( + IsaTarget::Xtensa.float_impl_for(FloatMode::Q32), + FloatImpl::Fixed, + "a Q16.16 float is an integer — `Fixed` is the literal truth here" + ); +} diff --git a/lp-shader/lpvm/src/lib.rs b/lp-shader/lpvm/src/lib.rs index 7ddf0c7ec..0193153e2 100644 --- a/lp-shader/lpvm/src/lib.rs +++ b/lp-shader/lpvm/src/lib.rs @@ -76,7 +76,7 @@ pub use lpvm_abi::{ }; pub use lpvm_data_q32::LpvmDataQ32; pub use memory::{AllocError, BumpLpvmMemory, LpvmMemory}; -pub use module::LpvmModule; +pub use module::{FloatImpl, LpvmModule}; pub use set_uniform::{encode_uniform_write, encode_uniform_write_q32}; pub use vmcontext::{ DEFAULT_INVOCATION_FUEL, DEFAULT_VMCTX_FUEL, INVOCATION_INDEX_ARMED, TRAP_CODE_NONE, diff --git a/lp-shader/lpvm/src/module.rs b/lp-shader/lpvm/src/module.rs index c32d5ac80..bb495d67c 100644 --- a/lp-shader/lpvm/src/module.rs +++ b/lp-shader/lpvm/src/module.rs @@ -6,6 +6,38 @@ use lps_shared::LpsModuleSig; use crate::debug::ModuleDebugInfo; use crate::instance::LpvmInstance; +/// How a compiled module's float arithmetic is **actually** implemented. +/// +/// A *result* of compilation, never a request (f32 roadmap D3). The request is +/// `FloatMode`, which says what the shader means; this says what the backend +/// managed to emit for it. The two can differ legitimately — an `F32` module +/// compiled for a part without an FPU is [`Self::SoftF32`] — and the +/// difference is a ~30x performance fact a shader author is entitled to see. +/// +/// It lives here, next to [`LpvmModule`], rather than in the stats type that +/// reports it, because the module is the only thing that knows the answer. +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)] +pub enum FloatImpl { + /// Q16.16 fixed point on integer instructions. + #[default] + Fixed, + /// IEEE f32 executed by a hardware FPU. + HardwareF32, + /// IEEE f32 emulated by soft-float library calls. + SoftF32, +} + +impl FloatImpl { + #[must_use] + pub fn as_str(self) -> &'static str { + match self { + Self::Fixed => "fixed", + Self::HardwareF32 => "hardware-f32", + Self::SoftF32 => "soft-f32", + } + } +} + /// A compiled shader module that can be instantiated for execution. /// /// Modules are immutable after compilation. The `signatures()` method @@ -48,4 +80,15 @@ pub trait LpvmModule { self.debug_info() .map(|debug| debug.functions.values().map(|func| func.inst_count).sum()) } + + /// How this module's float arithmetic ended up being implemented. + /// + /// Defaults to [`FloatImpl::Fixed`], which is the honest answer for every + /// backend that only compiles `FloatMode::Q32` — a Q16.16 `float` *is* an + /// integer and lowers to integer instructions. Backends that can emit + /// hardware or soft float override this; overriding it is the whole + /// mechanism behind the `float_impl` line in compile stats. + fn float_impl(&self) -> FloatImpl { + FloatImpl::Fixed + } } From 3d00175676cc46c4b17af6e2a354fbe8acef204d Mon Sep 17 00:00:00 2001 From: Yona Appletree Date: Fri, 31 Jul 2026 23:03:58 -0700 Subject: [PATCH 13/15] fix(scripts): the xt builtins f32 flip must not take the Q32 suite with it MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Passing `--features float-f32` unconditionally to the Xtensa builtins image build turned an upstream blocker into a total outage: `scripts/filetests.sh` builds that image before running the `xtn.*` / `xtlpn.*` targets, so a build that cannot succeed fails every Xtensa filetest rather than just the f32 path that depends on it. CI caught it on "Validate Xtensa (host)". The feature is now opt-in via `LP_XT_BUILTINS_F32=1`. The crate wiring stays — `lps-builtins-xt-app` forwards `float-f32` to `lps-builtins`, which is what the shared `builtin_refs.rs` cfgs need — so resolving the upstream defect is one environment variable to verify and then one default to flip. The f32 symbol count is reported unconditionally and asserted only when the family was actually requested, so the image's state is visible either way. Plan: 2026-07-30-1745-f32-native-math/m7-f32-codegen-xtensa --- ...ckend-cannot-select-float-constant-pool.md | 7 ++++ .../lpvm-native/tests/xt_pipeline_f32.rs | 5 ++- scripts/build-builtins-xt.sh | 38 +++++++++++++++---- 3 files changed, 40 insertions(+), 10 deletions(-) diff --git a/docs/defects/2026-08-01-xtensa-backend-cannot-select-float-constant-pool.md b/docs/defects/2026-08-01-xtensa-backend-cannot-select-float-constant-pool.md index 1625fe428..98ad6c296 100644 --- a/docs/defects/2026-08-01-xtensa-backend-cannot-select-float-constant-pool.md +++ b/docs/defects/2026-08-01-xtensa-backend-cannot-select-float-constant-pool.md @@ -44,6 +44,13 @@ plausible further instances; only the first to be selected is reported. `--features float-f32` since M5 and builds the same source cleanly. This is specific to the Xtensa target's backend. +**It is also load-bearing for the Q32 path.** `scripts/filetests.sh` builds +this image before running the `xtn.*` / `xtlpn.*` targets, so a script that +passes `--features float-f32` unconditionally takes the entire Xtensa filetest +suite down with it. The feature is therefore **opt-in** — the crate wiring is +in place and `LP_XT_BUILTINS_F32=1` requests it — rather than a default that +would trade a blocked f32 path for a broken fixed-point one. + ## Consequence The Xtensa builtins image **cannot currently carry M5's f32 symbols**. M7's diff --git a/lp-shader/lpvm-native/tests/xt_pipeline_f32.rs b/lp-shader/lpvm-native/tests/xt_pipeline_f32.rs index 6e171e2f3..96183fe2d 100644 --- a/lp-shader/lpvm-native/tests/xt_pipeline_f32.rs +++ b/lp-shader/lpvm-native/tests/xt_pipeline_f32.rs @@ -1028,8 +1028,9 @@ fn unarmed_float_code_faults_with_a_coprocessor_trap() { /// the esp Rust backend, which cannot select a float constant pool, at any /// optimization level. The image therefore carries none of the f32 family yet. /// -/// The flip itself is done (`Cargo.toml` and `scripts/build-builtins-xt.sh`); -/// only the build is blocked. Un-ignore this when the defect is resolved. +/// The wiring is done (`Cargo.toml`, and `scripts/build-builtins-xt.sh` behind +/// `LP_XT_BUILTINS_F32=1`); only the build is blocked. Un-ignore this, and +/// make the feature that script's default, when the defect is resolved. /// `ffloor` is deliberately chosen over `fdiv`/`fsqrt`: it reaches no /// `div0.s`/`const.s` estimate helper, so it does not additionally depend on /// M6-P6's unresolved policy fields. diff --git a/scripts/build-builtins-xt.sh b/scripts/build-builtins-xt.sh index ea4548276..df43f7825 100755 --- a/scripts/build-builtins-xt.sh +++ b/scripts/build-builtins-xt.sh @@ -42,7 +42,28 @@ export CARGO_TARGET_DIR="$PWD/target" # and staleness is not harmless here — on esp Rust 1.88 `lpc-model` genuinely # fails to compile (70x E0716 from the Slotted derive's const-promotion of a # temporary), which is what blocked the firmware crates. Fail loudly instead. -if ! cargo build --release -p lps-builtins-xt-app --features float-f32; then +# The native-f32 builtin family is **not** built in by default, and that is a +# blocker rather than a preference: `lps-builtins/float-f32` does not currently +# compile for Xtensa at all. The esp Rust backend fails with +# rustc-LLVM ERROR: Cannot select: XtensaISD::PCREL_WRAPPER +# TargetConstantPool <[2 x float] [0.0, -1.0]> +# at every optimization level, on a `[f32; 2]` gradient table in the +# generative-noise builtins. Full record and candidate fixes: +# docs/defects/2026-08-01-xtensa-backend-cannot-select-float-constant-pool.md +# +# Passing the feature unconditionally would take the *whole* Xtensa filetest +# suite down with it (`scripts/filetests.sh` builds this image before running +# xtn/xtlpn), so it is opt-in until the defect is resolved. The crate-level +# feature is already wired (`lps-builtins-xt-app/Cargo.toml`), so closing this +# is one environment variable and then one default flip. +F32_REQUESTED="${LP_XT_BUILTINS_F32:-0}" +if [[ "$F32_REQUESTED" == "1" ]]; then + BUILD_CMD=(cargo build --release -p lps-builtins-xt-app --features float-f32) +else + BUILD_CMD=(cargo build --release -p lps-builtins-xt-app) +fi + +if ! "${BUILD_CMD[@]}"; then echo >&2 echo "error: build failed. If that was an MSRV error, the esp toolchain is stale:" >&2 echo " installed: $(rustc +esp --version 2>/dev/null || echo unknown)" >&2 @@ -61,14 +82,15 @@ if [[ "$COUNT" -eq 0 ]]; then echo "error: $OUT contains no __lps_ symbols (dead-code elimination?)" >&2 exit 1 fi -# The f32 family is the reason M7 exists on this image: hardware-float lowering -# routes divide, sqrt, the rounding family, min/max and every transcendental to -# these symbols (M7 D4). An image built without `float-f32` links none of them -# and every such call fails to resolve — checked by name, mirroring the rv32 -# script, because the downstream failure is opaque. +# The f32 family is what M7's hardware-float lowering calls for everything it +# does not inline — divide, sqrt, the rounding family, min/max, the saturating +# conversions and every transcendental (M7 D4). An image without those symbols +# cannot resolve any of it, so the count is reported either way and asserted +# when the family was actually requested (mirroring the rv32 script, where a +# missing family fails 800+ filetests with one opaque message). F32_COUNT="$("$NM" "$OUT" | grep -c '_f32$' || true)" -if [[ "$F32_COUNT" -eq 0 ]]; then - echo "error: $OUT contains no native-f32 builtins (is --features float-f32 set?)" >&2 +if [[ "$F32_REQUESTED" == "1" && "$F32_COUNT" -eq 0 ]]; then + echo "error: $OUT contains no native-f32 builtins despite --features float-f32" >&2 exit 1 fi SIZE="$(wc -c < "$OUT" | xargs)" From 57f061b49955956c29529b34888cd5dbd07f7a9e Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Sat, 1 Aug 2026 06:17:20 +0000 Subject: [PATCH 14/15] chore(studio): auto-refresh story baselines [validate-stories] Captured in the pinned CI environment by run: https://github.com/light-player/lightplayer/actions/runs/30687110183 --- ...ration__node-ui__status-indicators__sm.png | Bin 58309 -> 58284 bytes 1 file changed, 0 insertions(+), 0 deletions(-) diff --git a/lp-app/lpa-studio-web/story-images/exploration__node-ui__status-indicators__sm.png b/lp-app/lpa-studio-web/story-images/exploration__node-ui__status-indicators__sm.png index 2d41f7663be9537da272a7026613002576ec603c..5b6cc790840d2045de57e315851b291c7bcc2bde 100644 GIT binary patch delta 23034 zcmXtf1ymK?_qK!xN?s5N>A93hx3t8aOLv2`bci5%N#!CrTv{6GZlnYO=@NrZ>5`BV z_;`Q+^{q8)*38*+V(oLDXYX_No}RBbWnXdP0IXNl@ZV^ZoF@BSBLs!21B@u7W-`A} zHdJ2VgEdKR8pXJ>GbKS9qma`t#v9e(x=MaY+lRD$g7XTA3~36jE%M8fkIMHFOV>En2*H``%=Jc0!c|H#eSEZdbT zv^(6(47DmxAdpdC!j|tSH@cC5(*n#KnBnkQLE7V$D~T`7+>n&S8&Thjol-QoM*EY; z6RGJm>ejb`20{q zP?&1M%rZc-?RETdfY(UsxMzp^n!iN%H+RqP2g1dbP^oflb-p09XWljvgD(qzD?HT=rOxCPrv)jDSe%uPNXz07~Sda z7>3Gs6{xMS)Hdi=5q0_S)%?MOGIY173Jb@0mnstn?<7(9d|gz7E=<+AaE-`2lcqUe zwT_A>HkA5(w0V>Hc9W_(HPI-i0SJHksh%Eurmv)>ghnvWjV=r22ab#`(6={v0rA(o zb{dRf%IR{D)O&|It!I3KT^3}I>pwH#DWS+fvsi49RqMmM$knm^9)-Gk-(0 z&;ZAN#7`d9NRIG8DcB4U5ZvuBM!i1H zM$|@6^%tK}e7v~0AgW^RwZH~9DSMYm=l{N><^72djvVWYb_ll)cWaZy@5epL4gG~y z>?`ji3oW6%U^Jke|FO7r?e8=jNNMvRPCijd>zS@3LH5OcM2YPb=MzM(LsI!&ON;7j zhtS`u%;+B^ASPaiN4F*|l%qsg7sb!Q!BWiO?lBb*lDTYo@AULxocs(nnBkm`1=?4) zeZ9w1Ha0&u_npgG?}t%cCL%W~zpSjRCSE=r7c`{?4GoQDvA`Shr3z6mH+p@T2c8#&$ZUQWVHG`)KFqf7Otj)izor?g|th-R?Ij5 ze;nlM{DmLdn!fFegFAJRTo2sMSHAbA<2>ijhdcLrcBU{r%~03YsU>eLPm_n)0J>&N z-r>TOAh7Vr5h zQ&ppH1^NG2FPNx>yu|);@9&vk!+abzOPRc4uqXDtFO}NsG$=eSr^ppmw1(gl^fQjr zBHJ-yjC@3FEHOM@i07*f!Slxwr!rvo!Z|5vsv@WbVUG1J6L?VV73gOZ#`3FQJ5CD3 z-JgR61eSY(3MmkNC3#`4B1Ajn>u=)cC(-Omc&kSfV zxP)%_QvxigFI}yEQ&JdeLH5n9Ee>@iO*ouL1{`aI7Nt2i)#AuSmJbbrj#9BDU|zcn=nQA6X~DcB{%G}$k?L#-a``jWaod}} z9~v6Wv)Y?*+yvRt)4UJ%kf4@UA2PB<7dFUnEP%Shh7x;V>)nnz>5ZntuUWd=%UBd# z>GHqc-WWdnnEE~yDpFZ~E1Apzsc#RD#%7sny!SE8fQFC;iCE=LBz>Q=F^wlk}`ntOZ`T4k{%XW=m40o?&yD!Sjqbs8H9Kmc&trQhw(A!TB zmOz|8Rzh3lT&a-(QDLw+m()go&>GLLeWY@;5HDfP2tA9pd9C-`*#k*OQmQ5~B(D)* zFlPjdw)yQIomjD~>X5@;|G9+g&39BeoKOF$eylPWEN~zG@VV~9OBw10r=hNd`vhSa z0DeNK8y5AmNG11))DR2Bb6F$=g~dVn0Mma$0y5Ft6#@M3kx8+|&rbtsIXHyjLH?hV zk4kYIml00DN0|?BfcW)DB$#vk9G{ODKK#D|4iE`LS>BIY7I)EtNUba=^jjng@>_qs zlDU0dvO*rte41;6a;@u|Z}J7;AGhbY`ac3pUF#_jWQGwmcd0!JutP>p{PV0P$lpjJ z)~onSwuTBN&C{JIPKGAR5I+^{ke^;(TDSR`Xu{Z6Smi$>Bjq$FroIfan|T%kF&w4~ z4^V$|K{@=w{A{l~pSO0H4PA`c31T=2-gF2qfS_EMN+a@c0V^)Ue5f*DvJDEt8Gf(y z$5KF<-$faeBUAv{M4C7_(khpK3-?D#?_}(1>)25B-eA8T`>il`{~^9tygwFAA}QDv zMmEWw!0Nbjmrx0oKqlKwK}y>LlV^repm5PTQOsssbp^JZg=w2MX$4Y(Cpos9=~Ca{ z1?~<@e3+}<;i7+`e>!BskSX~=a$Rh6=W3;WU(%0zOK54_n#n%Rl*wXAuoThjb{VWe z)02wew;9+*XfcUN8`Ztk;d|dSJQiN2EKkN_L}T$vV>M!##p(npqe=6$io0Ea8I_dF zDeB3c<^<#zGAp|k!TV%08H-EYe#SoHw!B|p@*M_CIH0?1zZ`ZIZz0~Fl@t=edhy}C zHm#JS=%33qyFqe`+{-WF@996yS(^4Uuc(W0i?Gf5qPDlcY!l(Qmp?QOuy1y)Kxe(D z!n^<^6kz4B)^9@h35g#Nx=%b7ltg{zmt@qa%$|C+leV2tEr26*Ukh82p2fcZEUtS} zfinKvN6z7t-vp4`tGSeFv+c}joxsJpPYJ(7Yzyoxy&~Q%Q!s+(s`C>Fnq;ig>Sd@m z;Ngl;il5ULmgVmAlXLFhn-p!l^oX9P?LS3+c4sLNir-yBe1>(!(DY~rh2o-AWMi-u zu?whUu%G)P?#|pz{IHsbn4lH!a8QC#uT9rQEnMC^Yoe8(cpK&P_+!V`#_B?g=pQTn z=nO7J`>ci!{ef!k)NsuU@1I3wtmPB_&&n&+*WOFIq8ej>TmB znppllqiLFF(m`fy@Jgh2c?fwY>|k>4Xh;|jX>b@>-4qed`LijBg;?z{J;9!GpA{;= z1N-QpF;!*`F^-4H`clLB2nFLTA%iB1H&@!DzavBBWC>HOf*jl5tgc9+qR}e`h?0dC zlH)#15K9#)aOJl<{lNHWb+Mrd_X;#7(c?pvzeyh4#6l8WUTV|Faj~#+n?1c-H!wJd z4W&@6htSxUPE-mCN~N8UB65dJswM7vSsk^BRDOm-nL|_mcF!xiyDvWz=`9K=v`n$< zeGT!XYK_&}Py3+AzAP)g$GXq6r z>>E`@h4t3BAmiFk?~4z@upvtiRno3U+&|!ulz%z3Z9i3BfD3stVr7?*Ro*%AowS3L zsj*3}jh^AP7ewr9%%yB^hJ|1*w0dTME0gSKJ~H~>=piE%Zg2!WIQ;M)YBM1HIg_uK z2H1cjH1XsV5#p^9)WdYO>Gx4>xBoH1>U4&qvhL|Ademt2!q(uT)`SJUC=|ac1|f`J z3J;!rii&1J3%~ON;)9|dfI~^aP&^k{(zjfAB=&mm;++q8ee!4fDTTWhx2{AZ4p1GQ z>|r(_LW6S4WI>;}8xCm^iZj~3F^KcD)~^3Q)EEiT-|9o|F6?1 zmVS6_+(F+ULQXp#<giZA=o12!nopUGc|rv~#!;6xn27HgO*d zW@5o#+K#xy$v=Ah_K?llZ-z2i=I^nOv7euA^w!~6Q2{vmTlymwZa>Ap?-hWK<>>V% zyl|l4c=UUz{~sQnO6jI<`mVmp0m;cg2OC~8Fpp^9 zXbDJQi<8&2S;epWo>;k5w9+s6wx0=t=hs9< zT*j86b@;h3MEcQE#iaF5zea{lMA`}#w3hZClVfCoaG!^uw~7Q5_azaRcioL1|8vg_s?R(2@7G3F zNG0`99P*T|KRN!{(2H^#)E|hznSV$_cj^gnOWD;v%NwIu8$s#w2!jDPA*DA0}B_- zcTZulZxLEVP@01ScATIw5y+++taMLFi}Qg|`FY}xe;&Jt+*q|y9TKe z+WTui-|wdvo*1*NG}-DAHOY8{R97-UzaM-?3FrxL0j7%%Z!NRqXOv9J=Vkr{q z)c1cl#|aYH9Gmo^Ujns@7QDimPaQjpJ~<_=&!9-O3De16pZ2QW13f;EE5N2}n7@Yr zEUf$%HQEI%M<#1?A62cBRW(+Gk9b;yb$eO9BVXs%GhQ?Z1H(^W&kWKbkenQv_I-Ip zif~o=KY4I(d=eU!YzTCexPOX{3V!xeI92+Cq*%hzWoU`Dg5K|7toEK*5!XL4lq;UJ zvM3mNWxN{Ow?DQH@qQcyk;y&I6rrWurmZ{7^Xex@UzDUCVu$^8r@N-Le|qVx=cU`t zwxbI^7W2>vQ{f|#)#_999%)+*jL5Fz0lA_?^$st5*1wGiJP1Tx?xnf#B$3IsTWhKv zes%sT^000({BF37O{eb8k1}b0iRLVtId3~d$uWC)2VCucck3h4Z4ds z50%R}=88KGM375Nbm+Lw_QA{9CvwVv2*t_sdQf~4t&hN``Tpw>rIJ_I`&}i4L><(8 z)25Fz&{8JRG(c44HDe`DlI-d{>t8CPrAtr83}OC+lpwSSKts+{^UryjiIeE*Cm5PU z!SFIdMm-}!2z4=Uv0h-w3uyfW9348qDvrdLlpZAs0RSWl{@p#@- zOEeT0v4a%%+!?zh@R2AOZE=;G;s?jTz{_@%*+(1%+eHUQoSelk-4jEgfxSj%c(Fck zGUfFU2^m9u8U~vcweiAOj$JzYIoG~Tv3yu8#?CH_z)-mDmvpWh1M<^ytiT8O6v-Bnt`%J-Y#d{JwG zxC}x&F7dIPI*@5Rv>fLzPyVqC9-YT`+d%R4HO<;o3d8UZ^CE`g%MC#c&TH3Tv!sCb2b$Y=p@F_X_o&=I%lc_}t+VJvae&y-qE-7Z( zxV}vDNbP4&{fee|OtOV{yfA+o>yDRFeg^9}cb5l8xwDb;&zi>rNx$nRfT5&}0xKhz zq|bG)rUaeQ!Zp>ZT`yfG5D7&hT&VchV!`9NWtt(MKtpo-lx~H9ghz_Xt70`S%p{tQ z%%ct9A}SK?PgMU{byLxF9}-X6h~N|+xqn3E`h3G?!dD)_@Y zf@AjW2;3cdXER7|5|f`fN98ZpPqmiU_Q-VJ-dkKYSwaI|v!%H#e9F!mW^Anq|FuU( z$S&!m373QqS8Fm0!8<=G9s5}gsdnks`006^L=X+QnV#TNOrcbY@gDtRkKZBV@&z)U zLpz29@Q~M0$X?23NRZDt3TA|cnKR!0*ebC~iQlivrQvsuv z)KW`@?kK#AI*yW-+2orJl)N<$2?$ukDn>qq9!rcpngu)_@*0`uM#2y!-5{)v*@YZG zr6K1baypy(j$I;!WpX!b)s}F!s>~!(+YusTSzPcb+=qS<4{#%+T#;Im6S{}$AR}r9 zBfr6c&gM)t;uvFHoPpcNWe(>Fn#2w~KYp2zphIf|MG%5U(SCwWnnr7!HuG==#{OKTDyh&{`;etb zlx#*q{`f0$sdN~c9zIf~d??3fiPDe4B9iYt@u-)@J0w)wg=V?+HgSx16E|=OJ)8wfLib)Z#|zLY@82#*0D7Bwc?1xt5_Zi;^lLA(u8E>e}O~}@nH|6x_yz$!uA(@ zmQS`;g9vrfBgAS{{|bn%#}u;`{pgQ~FN686mO<%S(d@qL2CLLNt>Y}Z@a(Kb+-nX3 zUF>g^+T6m0@?XC8E#xsVO!bN~tFnT)Ph*ReoW$UNW{`adC?M=+=UYcEp5*meEcexc zlKziVSaQJXK-Tpt-^Fj#w~heLkH}+JX2q05W{)uu{7cELXF#L)9Xq!oSXOr`tj7h8 zw@j#()y*MNbtHUy+|S~N#D3*~MDE9Drr#oGLv#2Uyjpo9-X7;RR(kG4{jMrqg zz#t4yBBD6Z63^A9;KBYrk>!^1nKI~+xAf6WpvqeSZP%#Kj8g8(UR9k4IGt18Y`)BF zslAF;jAaRos$W|;1;Ct9-VfaFgyn-ym{%GJuPpY1>x_-N8i}qVYi*( z%TaQ<|4`(de@X{j$%MEHZrR6gpx%nc73~@I*3SUvoIs)`nhk7OP=W^LO<`>Ie4Avn z+OSZLew44Z-+DPp;;5zYAYF;xC*^bOsN~Uh#enMIYp)CL{sopHRo}0xnTaNnmZA4&51zIvJIU>){Et#@!N*&*|Aeo( zwaV(NPO1=Ifo%0S3qKM!PyXPgfZ(LHU;&EWd5LWE7{b+p+5!pgT>F&&pG-iiG=bp@AL=_*jTViL33QHim8wDElB@hqwkdp$}wHVVz@hbLL%t7qHeU@y>3+q?XUkK*{N`C; zszneb^_+b?bvzG*qsKubqgZb+>KFUd1oe95PkV3GCtgkUydz!_QL4D03T7ZN3kmW9S2qZF*~=-7NBOmUCSR&KgtyGf$dSh%EOW3eUhN} z4`n3zD~2c)5j21MU@xN(b(| zI~)E&!oYICGDn!*QPujY(i<7+YsyT4JIOM3x59WX1&SI+v5Z-6Cy36HXe`yUGKmUKOss4{^7JhOU52w)E&%7dU5w=58%p6DT+_L>u$tUY?QJAA((&e6wxE zA`WoeaQggTZAZ@Whc`>jJhPstZ$6I#H$oqo9`>ZWo_|BH94lY2aCHP|LnW~0l}{7H z&e-mW*r5`kw2au!xjmG+%m*@Gz4lYO>Tc;*Ysftv$V?C&4S)}j$|O=GN)2O7p>&TO z$Tp^oQe`irF%1fGMNa6@J!9>C2Jgw#i~5B8mZ~gxNIx9aqGT)thN0%+7RV5V#8z!s ziRaF*&>NpqA_vlJ1_s{^tbghvu8;=vt-3AAFA667`x-x4iMx$8Hc^koI4=+e$tq`b zll{O%9~}6`D2yi4IBdm$rk4wEa=~V{(Q_LPpJrIoa;R=bBf<*3QO0Y zV?=yO%k)hDu;2g%ASZ4uKDG@(gR(G&={>eUnklpwZwg1D+og+_IEl!zK@5UKX{K4J zFfm_9AG&i3`kDeD0cfqY-oKlyFu0u;aPLdz1L6}a+%6w}TX^>Vt57c!X8QoVuU>!g z8`PG6VVKyAJ3bnXrdCL6;=}_{br4cD?0MeUTC6KYLklq zc(XEUg7tn9-8XHaWbX$GKKHOTPDSe;N01}Ct3*n{Y7h? zym2EbJ_nE?AfkOp1?po3Es-?-3#%8s?TOJxN-6n?uSU)1s_il%`LMR`Hg4*fk23T6 zG{2twQHAf3Jstq+EvmPY?A)Bcv+RS9y(4FV8F&}QdI)a0-J;yOOMm=0tDCPLMQKu6 zOyBu80G_LWcR{F@)RzhMWtwVrra>=WEa+Pn$yeDSOX7nAveKjf&letkN!l07u1LN+ zzM3G|LsXLuls~O3pT4C8`NY2_ZuWLQRyz9!vu!t|(TMI=>_-^ENfuQKnr;EQQ-m%W zA1H!Yov5EQDOrzEEE0~O#Mp%dWiwJ8Y4en_dm3Is)kfI1>(TVZoF7%eTb#?xldvNq zg&OYq0*lU#&HBY|vsn}`lC1@jE;b0AZKE2rW7T-TA&vz36z`i?u~E0Kxa*3PUGt4s zUOZsG(DUw*-;=hzWq-RI_3q<7(}oDLkMHLA@Yu;7L*@H~FK|%qc!lcA7-MTbcxR2$ zwW-$5TRs`|i}lm{RqI;?^3|%bzjBrOV~Tq!BIpSU_aGUc&q3JT+^pPj-&NrT0X*;) zox77PD-IPpR-=*h;AoseY<{O_j3nOSi*7!ecBKR#Yg#EVc$4fo=%H6^1$ApcO~|m! z+gtY`aS3RLvaw1zkGKvG4-bs>&A4l67Q{(Jn%Sz*|4SQ&kHuQB!{!dAQ~Zr7Ca5}M zeX@mR__q%QTW_zFr^YUVa*?-d<(62gpANj{x5OR7SaY#k|KfuiyAhot_y#{XB zCLXS2^LM)m#?Y&ZgSb%lFVILx(#SN@&$`SD^g!w5a+Z&iKXwjzDIG1)6tyo}=sdZH z^DfEv$%7IlO_DvQCIohbZWP+{F}W>*67*r zcXs|tAE}u&f}Nu8_Lj>Hj}6WI{Flv>s&iI3PzxSD>$Bgw7rerjd2@u$nnMkXpGFX- z;e^=>Vu!mV>_5fvMKWv03WBj*C91-xB79zvDN%0~1;uDY2{N@1I7VnkJiSF?1U135 zVDVo5*NvTxaeG#@3`CWYA6;*aG!p8?Sa;m@7UNrNY%SltInL{l{xe{H)7Sbh`B6$o z$MI^l`i}7qH}?q7(>O83xsTy7=Q3-C1oENe<>2vB3o7gH-X1g;ptbNR_O(yzVRN<$yLf@IIG*P0o z>S4(^x;U}_=Ow4g9!^$kJC1;aOlIA$m|>Klb|e8*jhu!4JO|6DX2D&;kwoRS1Nw|Gw3|*4d#z zeoG8#Vjutupo>DaXDTf)fQ*ds?Z@#z_8aT&Cz?8P68y`M6XSIA*Vn^bw~-Lz6$Ie^ z%S0aD#Xw*}j)H$2OuEUJwPIQ%}8H=)6n9Ckf*eo}uMtNSzcT9W+zC_`e{{nW>W zpD}5B!Q<%S#wXgN45y_}5=bJQG&>;PI_uTlf!Xvw1qrCHN6+@84AWecJ!@qpUsE#U5-VkBlxa%C&^&HLvCowK zrn`Y;q-@Xk;NR3tc=bC|A3jj^)st7Q`hn%i4?s0BT)5ur;w&}wcGJ|yEmL~Ql-8~` zi;#vqkoyK@t#KnEycvyFI(Gnc|`^UvRRdL@0#DpRFOUz}S^z57TZuj`hw-UL_ zNuo^D!~1wf>5^hH@Bjq$R45MhPN$|$$jgJFpz@}xS_e=EH(8#rYO$+1IJlaNq}|y- z`h$L|oQgmfONt5;8s1MuOBIAlXb$0hXW9}cJ>iW4cjAVAc;jR|mE!VSkTCccczfCr zw-M`8r=8+wVQzQ8 zd&(#mZ0OFtK~h-(XY`&w`|E9o`N#XUXTU%#89DpYx6q?!|~H@7+Z4D ztD`HAi{9e}BfR5{xG=0(Cx7+%F}j(_e^D;HMoixFI@qasFWJ91i@Io7o;+*%>zl{| z7y1?&x*clz^Lh9Wx#qQtMI?H5N^4)_)7u(UP{bAzBYKY?vmNS;9 zP#6=IWTMZBkgxTKiH%H};kW-+6)0Ni5iYRHa5GP!y1?uM|6WdFwl59vzYB!tarZOb zp3llVICLEir!zeEzk`_Q@Qr367;Gz<+PLs$>hEU1^|hkd=>z7T&YUX25DXv%x(J2; z8!#0Kx_-q9Cd-5CP>gq*0Ui*K-ID)pE?47TW`<*~E!OvxHP^|HbjLOCXhjxvm>CHU z>i9%V(tXFi5g+`H=i?1#d|fIgG->6k;X7Auvpm4?v*67PBM=`)vp*ue{JYQUpDjs@ z%)}T)8gB`=&KqTHSn0+3_`h+86bto5Dw|ZPR5e=X`tc$+!RSkRrF|*aNH0rcv7Sju zC7$)m_so(#do}qx_*8agDF&$2bbz*_d%%gy zJEbZ_07Fajky8>?_n=}o5)~bGpY*@Dkl0XncMyUdmzp|SstAai^zvTe8~V) z+;K8d2oiHm25Lup*a;_pSqsMHCcxIG&8y(C_Yv=YxE!=$?CMJzTDx3vUz>;yfX z4z2Z4(}*U2pvD<1Zo!m@QhZPH@Nz0=PNbrRSlpX-m1U)XOv*hn~VEH zC%MTjOg0Ee$<8chFS`G#cATkS9QcS;t}$&acVJV`{Nr0xTb7D&GO^yBw~W*)7K+RS zP@-h`civae=TJgsrjyzgA**s`aZl-(2w3ZMNlaMTbgV5X8?MjsL2jqOUzAWv@aBmd zu=0CdH~a*5@=&hYG+)6I8A3k2^}TB3jm4Tzv9_DZNlAgDsA~LU@9SbbsS3A$XtKCK zXYEo446l>_(WCn5aexhvYrcGLr-HuknRJ5W!*koo{s}2=tRCtb?~pCUoDpkAV;g=G%8Mo1rU{2_mKA08E#EtdU&Ne2$96`$ zc{*rgjZWoqFFsVq*oRN%0wRsX8n7p;iZuihK6r{B?|#}{I?r1bL+tUA0~DP#(TT85 z)HUH9*+$Mg5_DcQ*xZ*TI#_d)wkV#*7SBsa2KhRS9tiM=RX6Nc-B+`poh)&E?DhE{ zfrA8gG9W5g^rJ#g9wFMU%6WzeiS4P7-d+Cs$jjC1k_)6> zKWq8IXM<|>GNnG_0vu$}XLvlq;{;u1fcoxrdhy50MZh9bo7L@~W{m;kNKohWZ{DvN z&%6yT>WU9eC3}MMM`dG~g05FAj(N`V7;wN|N01_xorzO+5;YCLmY`2|`q!VD<(R-M zq?ZdgtZXRv27m1@WJ-%}E2{L~%4d&>@unL zKKJ0%GX>L`KQvz#PEX{*tn&@91Qu@X+c&h4)=RxXzt*;%K`bJ-fZ@ys2l(v=_rEe^ zx!L8-iI4*i+D#rjnFN%Fa~jGM&h;k9A=aI%)G8zOcd?{I(~0yubhZW#D3HNqorpewhAup|F#iqa z^soUoq~?=^h-nL^+4Nt=bM1;ln@r2~I~s3oWW@P$K+FE4K{iVDc|YE(v`&uE)w3o9 zDp)%(X|EHvJ>|u^;{k~zs%LyhI)Bu=i}V)k8A|36z@8l=TK%xENSWV11U4mYe}qQn zD@U@$*S+k9PsJfZjGO-;sqqll)o&Id#I`&$8fV$Z)}9SLQm}5CDn&JeTulBUt}J8W zv5`-*a6ch&ZayseC$guMjiZOe?@^v8&h+DKgj@u5(u8 z1Y^}|XA?Ak9AWLh#7d^{J0Y4!J>fX6ntasHrqTEM{->(26?Lt-DZx(!;egtU2i0%{E7YRCQhgr#bNlPNG!idwbG$Y`m%W`TZ#0t(IJi$F zrQ%)Sb6B*8ypXE2rB^tHQrhGSr9y0Jnggj{PsNmzw-I3d- z{eVCXIEQmQ3Vw8EVrce!FHsDE4Yj`U%*n01m*Vkpd#E_6Ch22l0iLvR;R_guQD*KD z!`$8ZY&Uqg^}xY=X>lY5Ib8mnU$aKA@p|?lM*c1<49wi2Q;hFsjt}c7Y1Z(*ShiaB zbl?#+B)~>^zcqo$K@Xy-J#+uy%pWyvsO+LPLt1vPYCrP%JuMo!BMBHbnN(0;h-w(K zNqe6AZ>Q(v#6Gnng^Z+$Uvr-Fp9S7ke*QdGyPGiZ>|{PCmQH_K-Hub)D9Jb7A)ib8 zZ7hseaMJJ8Nf=mKS-=Hn+O?#wME-2Nj}JS3R}X{%5fMs?j^JA}G_1}A2im~`k5G<5 z01&_{>G=LNYdmgPTG4&->ZZ>uUGQeOBVt>J=}X@TNYQgo-D@@jTZc(i@@4=1nWr*W z2`Od&gk{6RM=${z0ZSX1ltF<+_sHTf&knJf2;;&WR1D;gM6qI;lk%949YteA|CxN< z{b_8SN>{9Q*jRYM<~(P~DP*;(ua2_k{+RXh14}PSIt#Ck>2kA@_31xxP%WsTAai1c ziCCQ8FdGpQEPo-v|K^x&JNnBtDxx( zMepg+jlitaj23Tt;!wB+-lIn&yh9tyCK5QKKD)b>n1R7j?kSF94X#QGhQGhXNiv#m z>UD%GKhQrJUF^RXaIRx}x*6ZOx&OxF@VbxXRSspWOYFVkJ(d8T%ytv5^aqDuhPCf? zkzUHa*??AEUpMYGzi~eMrb4jaiqfHKZ4+_y!ZtcJp?VX=mTdcepTSh@=iGeF(^qR1 zKB!d4*EKp>&sOQ)q3qae54;YQPF8}S|5m%B$6bFP%2{|W@3cE9V}nMY*V8yQ|M?fU z3I+o`>9CfIO5y;2XKBxkwf1A-9X>wLJ74r`P4sYii~9T}7wV+zi0x=Y8TA^;&m%fJ zAJ7HoXomlA-neBUb}G@0E=b zy0J z-y(ZlI-Pskd_}L|^120RyZU3hgQS0u5P>nsCESHOOvVv@>%B(uav{4U=D*587?3gYvhM z-A+GGAt!AjSl$LlF*&288*SN(#E3Kqv5S#E#sGR%N|8+(u1MI|A71;keybYb0EiYW zgZasu#8O1dBqDSPVrb$Q7q~`P*~bea#>EqGnNd)kwy*WyI}Ud6%zmQDGWo-u_!6xG zTRmo5P4ug(IUSF`g+O&&`0oway4r1g$$Eo$g^-YxZUGpkM-xn}v=5`vDvGAX zRpi7yTx4@=S5C@+-uk`C4CryOQ35G3WWmUq0(Dz--KZ6=u^1%Zrkz~;1E){=d>p@{ zhTRA$jj%rvR7}+Ma-apj281f6z#~&2%jK-u3}crWUJCg~Ir&~r{a&l@Or-A@UYeV{ zYh60__zWwVj_v#0DI0h5ScP4daO6GjJ|Xq6zwhx)LU-b^#jNv_Y9+0Y(@2Q3^K@SY z)@+>Er$9xT2kVQ=)zylz6A<+w{=q7X@{=!9;@y<|n?++1;)=&EaejRiz&4X%HSC$vS8XwR6f+Y{n0`kex&c2a^s_6KMz(2qh1|S1r z`mkZn^Gen0rtc$Aq-3tEleWZB0bH>zmTbaG>dT?-ESB`Yzjtco#|y2$WbvWs5~f)6 z`ddeDt5NkMAz0^OyVfXdGeNX%zdH)g=N+0tH7;`_AbGYcJwWu=NZX!o2vujPb;xnb zLM{QewQVA$ZBm3Sx;xx>NnEG@-vIsZz~J22RX2bZN*Noj)f`IzKSB?E(0_IVcsJpxPQAd{9{}0 z*$f4+xxncNbqE?@1ap6b2nou^Px1}m;kUX0emiU1P6Zplm2BVv6VRi3CDtxs1|O&H zZn#<=fAkY%0<)9yV_TJuzvypL4wYLQpnJ=JkUKr}b4!jMFWe+i$o9{Lj$UgS{%Y#W zP9o!bQD9bgd|nywTn}oeCZ$YB?Gq5aj)K`pBGsO3o@M&o+aFjsvW*qqi8s`Tbih;1 z^kjrSEDCl`59hIg!zX-3gw%{iI*w4;Py6Q23uIYrpa>P|U^{JDxpCqF3-P zg3c2q2ZqWi3~@#JaZ&SYhGP<@QP*(r39kn{{$Bh(JN%II_Y(qQ%qa9vk5dm6Gu?iI zMiJK60#M1SXz(O!u$@Z22|QSrxdw_T19$`B(a)5%roE4hraBL6pjK%xsT-+d3?hBt z!PCeK&)Tz|k-6P`vM`IsxBq5P3^+V`80WfLs#nAct7cWTclQ1DZT?2+I6R(Be8|Q7 z-PIZx=u=De1^}Fq67574Kh~Wu%p>@svIP3i|&L=NPF^e zgZu?2sG8LVkXiHCUjoDjgxm){a8RpI({-?Hf9+a8)T9(vT%2LbUjc>ez&A^QtAF_> zBGw^G5{+XcPm1luD_38nsl6Wj7X+lLy?&CoRZ1`eb1pQVSJPliv3nW2`(iAjVlUt?YeGnxz&j8SMN5MLl)ej9~ocWPop?-rVy$#ZwKqmUbwokWEPY zI#G!df6n!NHO}vs6$%9N zN-!;i58;aU*a+hCQA;O-Q}B(8W?m|VN~=<3YrzI>X?Y$DI{dBX!b=*4LBKvPpC?vY zSTB&W^yEhJ&H0{9W>Zhj4~GIPh0TPS&WXHn^*rOe%Cz;My;g~<$gtOtCnsni>^NVz zZ^g^4896}tn5SMMk`W97Y(!P$#Yjj+72a!dfm*cDgyiukyC8#H1T=}C3254+Y# z7AZR9g+UlrLN~AG9}M~0V|{cP1ca~=lsIX-yV6F+y=tYdQGvZ-+pKNPQ%d~B+8bVX zhwphm0H%ED7Ooch`c?B+b{{nPcqmK(3fm?tU|#3Q6TyS)JQilJ22R+RRiMu&&e|i) ziQ}}PmZ*LF?Sw#Rj0FG<0FDaY&HLt$-+3_QHTs&J0R)nA5XE~Sc$=y8;S0xzL=I2r zvt*LQpG>AmW^jLiEhk}85t4Iz|Nk2K?qE8*=-oqvXdx$B^ztG~5WTmf9fBZgbWuX| zUSH8gJ6v-W;w&t7|;-PS^J zVj>^r0R^hD?1ZMt^NMxiO0k@TGJ+g0xtE{7TMTFz3R&f`7@a`u+mzzLtq{(31Vtc3 z&gKb$T|!91C}2%m_rX=eJxWR_0$!3*95?YnsO`aUhS;F1BH!wozZ3bE=3BbLPbpZ8 zO`yUfK-O6L*xt8eb9e3F;!`j?_V#Xo>|31e8c>waaIY^J5Pld50q4H0p?9{(>#$2JJG15`VcntYdrD$Eqt}X_<-Qa*@&vS1o-PfZuEqOy z!&y7OL;NOqJTo0$e!&Up7aMAgj%z%f(Eh1&n9gLbABpt1JO5Ad#SyDD4W>@|;tvR% zm^yXh4>D1*)eN3lV%)EnR{OMGuJ}`3|47qf{K4BTEltGT-G4A?d!%bkW6-YZ%|SS! z`f1@}fem3Tbn_xSCf%Hpt7C`673CK|z!-@TpPrNYzCPY7P^FTz^#nfoa%`~1$6TBE zhpf37sKwq!UhZ}C8_p2t!8BQ6e*|V>I}KMo;Xn$U#!zvdsM)7B$9?;x5aq7STK&s% zcKRlg`jNoi$Cfs}8xKmk`VW_vNzoh5HSum$3xp(2hvf=SP=Ex!w+h{O893FPykPKn zE1iC2QIpuNT{30rM{AVI@IiehLwH7taLOY-zqaX5I5biE0lnl%neZY8GmwKj5fp4` zOn-{gCe~pjE}cvCvqEUlX*g&R-x8+%n$v*gC9xWuhjTVQCAm82VU7+asZdYGnRZn2 zUc!1vq+ve?(+Q!8cyv1@yZS51oZ+5gc*kz0DYL3um|~$hG7#gg)XxgeBnVMXflznUBPGqO8XIqCWYgA?G#(l2|kwM6QQ0yi^tn-XRf_Zr>G z4M>i+ei|S%-8XyMR4O00bsWtSW!^Fk1r!;Prv7=SWomGaK^rSp&E*tndxp6E>WgaU zF>wvIU>+g6?nro)86BGn68__^&Ys@9vC;gQY{AH3WL{~#Cy$OKl`VBuo~*-4I-`)| zjRb`XF4Bk~4Fc|i$n<)7I04W{X#Ad;>x>`r^OZj@I?%H{4>kL?p78AheqZ^59G<0= z1y%5Z^XqwIAiZ=ks%X>J9=OI`m*{?CPlCC=oBP`XvTMdr-5}62639ZS`V5kDKh1NN z5Mkx-LEg}qKD@G-{>xN6_b(WrKvfnU@+;x1cP4JbU@I7&axyl!*~h7^$8SX}PXy?s6M4Ba7BlSCou>Gvs0(YL-C#{;@X4ayU^U4^^;B z!hCk~M9*c+FPw5=MbsT!AFSQ`8_@cfC1Vm?X~-5yd6w>w{?BwFXiadiAYQRX5gxC+ z>Ef^(4X&7Oic?z9xi8c03G4{Q(q;R_oP>8F-590Up=0s*fGam+W=+XESW~0UwW$qs%8*|;n4RKk8QWU?u5a<7$r!e zp8=ziUIE=Zux$D~DRMQuXa>oklt)ob5lZ=QKtPnADatBdBLc9Z@o|nt0VH2c6j=en zFCVx`SdcVPO{@qtcvO?uLFVu)tRMou+6o7(j5K;nMgEssABI(hzLhelp~umr{2d{e zd$n?l9hxmScKg?6zhZD)Jo9cixe`IaoXXc8V^W)Rrb*pJ>^+%OOWKyxsKGST=a6StG1QEuQ;vkvw!^@8Nwq zzfn6)O~AjH@00T4BTuX@t8&8a3F>+ECyyp{XX8?+4~a?!^hLIwySreCpyAc4L5}s5 z<`D+mqo*koBag7Z6#&&G&Cgkmdyo%2@=e=5l~)F^j>b_#v!}esn}1`+eO|mQAfn2V z&dABrq+(_`WcsQ|jZ7ZGPXqjgaw;f(C40AuSeq>lQxdBx3`3rp{-tGG;!DE!m_q?l zX5@XI?#79o=a3@_+;R zqZNllpgbRmt{v}a>;iRo)}O#yAmEB}6Gz}35PQyoLqZJUg#tJ*BrdZ!gx58NHBEVp z<_E7;bMG5mHGNdgJ?i@JD@CnyO9FHZ`dzV|O5(Xv@8-eJ)n{VI2rDD}0w z1s7hTPpMdQ{7}-gGsn#~B;aT3X@$|`~o^^lu zDULUmpLPOvx|h+fw3y&<^grg8uU~XAW#TldwdX74apYZLR=<_u2`#5O*#^J z%EB^q3wmdM3w*JzvwqIBd&;yR8o*hre@5)iVJMs!W~fL>4@b{!e?{S4-+3b#E?%1o zg4fa&Gjmmsd;AR4ImdsAkrsu8f5t+xVj*``;8nE>rva-;9?|@X76<{pcj$(WFh)c( zU3E7Wtl0%0`Jqd@-Oy_U<)htkRJ$NSJK5}GQFO^<<%xLGev6DDqm)_H!;!O71zh$b zFK}c1x|ES?V?|uZS_RL4yh-&9^pNgwY*vgvZ>cxYIR8E!B#uJu5kZ!-_gl5i%&>;ODRyKMlc#vNr+(Vb* zW$D$_d~YK^Jb;TbZT7};wk{9q-IA}^*&mr&Si>YFf61t|ma}IL)mZ8Qx7qsDtK5~1 zw=EJdCW5&^;uUo36biPH4Rm3+s>Qy@t$7XAcSL-wv2W~nKv6$nA zS~cA+qqUyy7>_AhXYRG8w#+@G-v?8jfx+I$Y8xM?`nRRLSsKteP0eIH^~R4sDG?D~ zamm6Cu1N-S;NO3T7G(oU0E348Fxkz7&CGq+sh2bG``WD_WI`q*t&Mpi4HHUCgH*;G zS!=lXD7{Xq7Z$yZd>a?uEns^Lo$(MiFPGY=Z&j$_qWdWmA4tCYJwy=soaNQy5*0PY zEZ6xt4%q&8s^Esz_YWT#?U$KVJ$CSHU_T^{Um0Ty1|truLo@{^m7ugYhPIg&994+V z4yFRt;HY^`sRR4Ch#5Ld;9+mP=htN6ynr?hlk7hbIfUBFaq*}GQeW92Qu`?jRPGo1 zPXCLu+06An{n+Z*&IiS`06c5@1MmH*KL7eN5lT$87-CFSMC;(N;iCEAjG;P9&uLb6 zyVzV8%<3jE%d_GqJY)YDsM>Bdz<7sgEY)W)zId~OIJM&>~lHY4(!RO-RqCc2oUz z#;S?l7DX_ZZ@6h8(monrSbrNa*WbhZeqRStHfPOU!E)Dm4H;cPfst$?2h;z)N)b`5N!z0x1 z{Z1v>eYE8LHn@1^R`90^vxLvP-mwN1A?Q!yTy^T0h z@{&{rR~*hl^Ba@+LRb1K?ea%n2g7flb-AE6xgHoSwCG-zLNmpc@E_3=yOG^MA|VKt zNPK-4215Zb7(fV9yo<#BuZDq0+)xw(f?;_9CJ(`wSFr~25Qd{CF86hZDZim7LO+7$ ze4qbU+Md^WONf39zI@!V4iS;n8Xx5S?jqyemEyV?m~?HUky>RHa%6i2KIGLXoxaWR=0 zRwEq-ZAO>BIQ!6a)MNMf*Fw!q!rtQb3|DRPa@5s9vrL)>*XvGJm|5K@qRqdzn30#7 zXg5iR)sM)Ff@!TlU#RdwNwY~{%>oohQb$Uv*Cr-YWd5!&x$)`J18YNQrvTD?^WJ8R zADy;0wUhn;Y?uJ)HLHBsk$w0391RWWlsR$+$wGl$Ii&68aFE?q)prHtU*)MFn_V4e4mk5`_p1Q8ZGJH)+qt@1_KuA!3R2v$hA2Qu-OI@*^ zV#1*hggl;v4RgNS^Uk1%xn6jneIckS&2&1qSeRdMqYXl!+x_QX>UJNcCpit%&81H{ zh-YA*I%kf|(3mBvpbVuCVL?Y3W|#h&f+R5UYx_n-g2WfYjUWrL6Yr@?x-U#LN}C0U zk(r9>yDohO3Xx-Bbz4uZI#Ca$tN7|Qn#b7>$i0Y3l-eSqU3y&Fr@{xedGNZecMea8 znO6NNgTUVkpQVS7+R{f&t>+dT^?7oE-D}5PG0mY{K*c_vwU}Y;Tqa%G2{jB=j0j#f zsEuXNlTPcW>=fbEo}>3{^=O(tL(BZ5nyEYc+>stz#^<~@c&#G`hQDm5O7Eei$WD+9U8*3{LdQe=h$Zb z$^TpGhp?OS{oF)6tv-EL~ZC@wV_ftuQiSwe|62an3J-E?J~UOVhjL5BT5Lh=b?}Sb(dD%5T8%Z;$Y$_-sBBq3o;t&DNW0G@ zmVw?fP&K{yUGme$55_qO-}qfuo5xUApNlp}v(Kw9$%xnQ@>%w-(ov)mDRa_^q8?2WT}n@l0`4%V93$%GkZ#*JEsWC7b&J+`7s7%?JnPi2jD4HHOfxW=k$uzvJ z(p#^`C19%1s=3638ZbioKH4iRnHEKNtHkUs(dsBDee(O$w?8#HR%H;o@b62PC(XhS zDG8E)e(Slurt2x!F+tuv{-*0KoGu%mt*v&;MZJpp0iWR@IgZR2 zbMQU(UBmO8V^Z386sav?s)C_c=3KrHmi|?to+VfjQ?cC1_xt?SaPK9_f1UmLC>WgA92Kzj)-PU- zn;w^4g&chHr__c5@PKm`Dc{;kriL67M)py(7i*q;6~;|Z)PK!_Ci7Zc8J0+4e{a@~ z>h`$ECeImZC}J{pU>{SZ5s-cD&<>RS4fde2AV9uov^3I>J^67_8j6lsdc&TP9GNF1 zg{Ph!3sbOJ#frMShR z*WI@L)4w{LF#)Nc!z>C)Y4F?K1@mA>PidX^3tMFB>a1Ofu5*F` zwWIi~+Y0$pzD+9ad9pI4Ut&pU8hNup-nvGKhy4mYD!RU70&%SS>_%KRlaK+2%k~7; zOA1A!fMZD@Al=CC1)bMI%XEiYqPhCtlqSYhzgzYAZ8tSvm>K|%q_SE1kTb~lTzh?F z`6d4#_niSoQ)x9m4Zg#%fCmTUwgYX(<2-Dq; z-j9wVuSUGHuUQz+h1<%145h(O$Pv@=wSR8xK zL(wB~|L>AXIF(R+)5_w8w*(oP1Bd`(0UTg6syr#dbb4T)iE41dcnuq3TqHGHB((Ws zwBls1+IG!1p+BYpWImU+q1xIwiTl;lZNOdd`3CAcxthterEHj$s?7y%+lgtEt(yog z2hA>b)l?)B^=RIaf;z$=w(jv~Z*U4z`JUN+ZI8;KPv01dbsUQm~L>hEwvhFXI_UcVL zN@NFAeGkxmJux1FVY24$x0DW^jICvdL(sj64LohM9hxOBTwtyxVIw2$LR!)UwH{G>wcr2Y={n2 zQM{5ua!DcTN`}zWbb+a>rW*CWiFdV1Ma|Q;eIQ!O%O>pi);P;ZKqgndaytevn|GH2 zJ+1dj?TZH1zMeliTK1SQ;I|{Iqzp_QYgzH(`N%O`yU58*+Grp-I^!)H}>(EJ6&qu zD=%wmK(zFKkL%OPLvPe5`k6we8iCev)Fe~-Q5h~O-;k6i_)1d)Jzs0qU2snV!%Qr;=yw#obNxFusJa%b`M2_kcz&;!SC^w8s^Ai8E-2mW#t3iYdQ!7 zn~bB8a8jTfOx6d~1PZ?~Q5}ic2YF%hZTO|WqaADd{=shq29qU^A*syT+op4CrPL|B zNdsWm?K`17@&7SC@P#HM_wRrkY33r4L?b`DH}E90rY zC`)E8slCz4nB_Fb>8jptE1SEgZFAYEAdy5Szd?=wlG&t?7T)tk>M)mNegS61V_D?g vjAp}U1B*BEKiTj{LX_N;e3XJ{SuTiaLu*K*=Z^Sc*m4J{tff?=Xc_iD1TdT- delta 23059 zcmXV#bzBtR*T(^ol8^-{0f|{)0ZA#55Ej^7LRd;f8YHAUghiy5X6UY^Q#zFvL>iSY zN$FAnfyeLf_563{&dfdM&YbhP?>STP=~myTTX6t>)O+YU79;;IUz4mpjwgKzU?Q|C z#ab~wLOkkuI`b}^OX(Dyp%lc0m<*)|x}dSse8+at89LnOw&1&6qH+-W!1c-RC%-KA z4h&vrc^q)D**=p2JnSK$H*w$9xBltAu0KxX^WU(*v&z~y+OzYSlREBaUzI>-7`T3L zTw7E{GGCG7j#=JFvkj>!o`|!9PN&B%_suSkTR>Z}ht)s1$JJsA= zElvs_gS>d>3LU00$BO;K`?L9ADlhRIy{RM2cMWfQbu=5F> zh>$Rb(6O9-d+tj`-5*+Gb1njjt{>GQw-@2vl#>Wll(Qm&jNIdCsRXBmeM7J2oUM0H zQgLV%0}UtFXByM~S3l_YzY{r5au%!@-xe$lPH{ARhg*2fSy~AP;M`aP?PT|BS?t zhBO;`eaTg;i=G)Q^`@`9xY&4D!?i2)7I#Ww;ev2#YoJIqq(H`;>OcM{-Z9)aNu73} zNpBwX2g|?ad2_d*EJT*%TTv55^M{WN_nUH5T1XN3pE*mD%8Us${ci)hbq-1Q04=e( zM$%QYqief_bqWrwI3+SMUZ+~8HoYH1t82)c7M~bD6-U_eR~Grq=RIebFBu}gNFMDs z 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 P?Z5+HDM%Gc7=HRc|2d;5 From 0f034f4e3ed29c79c11cc4bda7e7750f7a83dea1 Mon Sep 17 00:00:00 2001 From: Yona Appletree Date: Sat, 1 Aug 2026 08:43:45 -0700 Subject: [PATCH 15/15] style(lpvm-native): rustfmt xt_pipeline_f32.rs The M7 P4 pipeline tests went in unformatted; CI's fmt-check is what caught it. No behavior change. Co-Authored-By: Claude Opus 5 --- .../lpvm-native/tests/xt_pipeline_f32.rs | 25 +++++++++---------- 1 file changed, 12 insertions(+), 13 deletions(-) diff --git a/lp-shader/lpvm-native/tests/xt_pipeline_f32.rs b/lp-shader/lpvm-native/tests/xt_pipeline_f32.rs index 96183fe2d..51eb1ec67 100644 --- a/lp-shader/lpvm-native/tests/xt_pipeline_f32.rs +++ b/lp-shader/lpvm-native/tests/xt_pipeline_f32.rs @@ -108,7 +108,11 @@ fn link_blob( let mut order: Vec = (0..funcs.len()).collect(); order.sort_by_key(|&i| i != entry_idx); - let mut code = if arm_fpu { arm_fpu_preamble() } else { Vec::new() }; + let mut code = if arm_fpu { + arm_fpu_preamble() + } else { + Vec::new() + }; let entry_off = code.len() as u32; let mut entries = VecMap::::new(); let mut func_offsets = vec![0usize; funcs.len()]; @@ -217,10 +221,7 @@ fn run_float_binop( build(&mut fb, x, y, out); fb.push_return(&[out]); let ir = one_function_module(fb.finish()); - let sig = one_function_sig( - vec![float_param("a"), float_param("b")], - LpsType::Float, - ); + let sig = one_function_sig(vec![float_param("a"), float_param("b")], LpsType::Float); expect_ok(run_f32(&ir, &sig, "f", &[0, bits(a), bits(b)])) } @@ -437,7 +438,11 @@ fn itof_signed_and_unsigned_through_the_pipeline() { let ir = one_function_module(fb.finish()); let sig = one_function_sig(vec![int_param("x")], LpsType::Float); let got = expect_ok(run_f32(&ir, &sig, "f", &[0, arg])); - assert_eq!(f32::from_bits(got), want, "itof signed={signed} of {arg:#x}"); + assert_eq!( + f32::from_bits(got), + want, + "itof signed={signed} of {arg:#x}" + ); } } @@ -789,13 +794,7 @@ fn float_recursion_at_depth_100_preserves_every_live_value() { imports: vec![], functions: VecMap::from([(FuncId(0), rec), (FuncId(1), f)]), }; - let params = || { - vec![ - int_param("n"), - float_param("a"), - float_param("b"), - ] - }; + let params = || vec![int_param("n"), float_param("a"), float_param("b")]; let sig = LpsModuleSig { functions: vec![ LpsFnSig {