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/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/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..98ad6c296 --- /dev/null +++ b/docs/defects/2026-08-01-xtensa-backend-cannot-select-float-constant-pool.md @@ -0,0 +1,86 @@ +--- +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. + +**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 +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/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/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`. 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..d371d2451 --- /dev/null +++ b/lp-fw/fw-esp32c6/src/tests/f32_softfloat/abi_probe.rs @@ -0,0 +1,277 @@ +//! 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..74f374edb --- /dev/null +++ b/lp-fw/fw-esp32c6/src/tests/f32_softfloat/shader_cases.rs @@ -0,0 +1,233 @@ +//! 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/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 4b11127d2..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,10 +66,20 @@ 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)? - } - _ => decode_execute::(inst_word, pc, &mut self.regs, &mut self.memory)?, + LogLevel::None => 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, + &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..6759587e5 --- /dev/null +++ b/lp-riscv/lp-riscv-emu/src/emu/executor/float.rs @@ -0,0 +1,2402 @@ +//! 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) +} + +#[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 3a3375358..f2d8e40b0 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,16 @@ 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 +149,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..b2f9f9cb6 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,87 @@ 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, - }), - } + }; + 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, regs, fp) } } -#[inline(always)] -fn execute_ecall( - instruction_word: u32, - pc: u32, -) -> Result { - let log = if M::ENABLED { - Some(InstLog::System { - cycle: 0, - 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, - }) +/// 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_ebreak( +fn execute_csr( + rd: Gpr, + csr: u16, + op: CsrOp, + source: u32, + source_is_zero: bool, instruction_word: u32, pc: u32, + regs: &mut [i32; 32], + fp: &mut FpRegs, ) -> 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; + let old = fp.read_csr(csr).unwrap_or(0); - 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) - } -} - -#[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 + // 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, }; - 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, - }) -} + if let Some(value) = write { + // `false` here means "not an F CSR"; that is the no-op path. + let _ = fp.write_csr(csr, value); + } -#[inline(always)] -fn execute_csrrw( - rd: Gpr, - _rs1: Gpr, - _csr: u16, - instruction_word: u32, - pc: u32, - regs: &mut [i32; 32], -) -> 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) 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 +145,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 +162,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 +170,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 +186,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 +194,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 +235,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 +261,7 @@ fn execute_csrrci( new_pc: None, should_halt: false, syscall: false, - class: InstClass::System, + class: InstClass::Fence, inst_size: 4, log, }) @@ -427,10 +281,12 @@ 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(); + decode_execute_system::(inst_word, 0, &mut regs, &mut memory, &mut fp) + .unwrap(); assert!(result.syscall); assert!(!result.should_halt); @@ -441,10 +297,12 @@ 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(); + decode_execute_system::(inst_word, 0, &mut regs, &mut memory, &mut fp) + .unwrap(); assert!(!result.syscall); assert!(result.should_halt); @@ -455,10 +313,12 @@ 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(); + decode_execute_system::(inst_word, 0, &mut regs, &mut memory, &mut fp) + .unwrap(); assert!(result.syscall); assert!(result.log.is_some()); @@ -466,4 +326,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..495b4be0a --- /dev/null +++ b/lp-riscv/lp-riscv-emu/src/emu/fp_regs.rs @@ -0,0 +1,354 @@ +//! 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..2d31a122b 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 error::{EmulatorError, trap_code_from_cranelift}; +pub use fp_regs::{FpRegs, RoundingMode}; pub use logging::InstLog; 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/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 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..50a160698 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), @@ -247,40 +309,19 @@ pub fn q32_to_lps_value_f32(ty: &LpsType, v: LpsValueQ32) -> Result LpsValueF32::BVec3(a), (LpsType::BVec4, LpsValueQ32::BVec4(a)) => 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()], - ]), + (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([ - [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(), - ], - [ - m[1][0].to_f32(), - m[1][1].to_f32(), - m[1][2].to_f32(), - m[1][3].to_f32(), - ], - [ - m[2][0].to_f32(), - m[2][1].to_f32(), - m[2][2].to_f32(), - m[2][3].to_f32(), - ], - [ - m[3][0].to_f32(), - m[3][1].to_f32(), - m[3][2].to_f32(), - m[3][3].to_f32(), - ], + [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)) => { @@ -289,7 +330,7 @@ pub fn q32_to_lps_value_f32(ty: &LpsType, v: LpsValueQ32) -> Result Result 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,77 @@ 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 5b972aafa..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}; @@ -32,6 +34,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 +49,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. /// @@ -51,15 +90,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 { @@ -68,7 +110,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 => &[], + }, } } @@ -81,7 +130,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, + }, } } @@ -94,10 +150,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?", + }, } } @@ -234,6 +297,73 @@ 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`. + /// + /// `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(all(feature = "isa-xt", feature = "float-f32"))] + IsaTarget::Xtensa => F32Lowering::HardwareFpu, + #[cfg(all(feature = "isa-xt", not(feature = "float-f32")))] + IsaTarget::Xtensa => F32Lowering::Unsupported, + } + } + + /// 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 { @@ -260,7 +390,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 { @@ -269,7 +409,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 => &[], + }, } } @@ -283,10 +430,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")] @@ -304,8 +465,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 { @@ -325,6 +495,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 { @@ -342,6 +515,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 { @@ -357,6 +536,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 { @@ -406,11 +587,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, @@ -632,3 +819,135 @@ 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: **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 only_xtensa_with_float_f32_claims_a_hardware_fpu() { + for isa in every_isa() { + 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")] + #[test] + fn rv32imac_is_soft_float() { + assert_eq!( + IsaTarget::Rv32imac.f32_lowering(), + F32Lowering::SoftFloatCalls + ); + } + + /// 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 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: 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(), 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(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()); + 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..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,6 +1375,40 @@ impl<'a> EmitContext<'a> { } => { self.emit_fuel_check(output, inst_idx, *decrement, *trap_label, src_op)?; } + // 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 { .. } + | VInst::FSelect { .. } + | VInst::FLoad32 { .. } + | VInst::FStore32 { .. } + | VInst::Wfr { .. } + | VInst::Rfr { .. } + | VInst::IToF { .. } => { + return Err(crate::emit_err!( + "xt emitter: {} needs the `float-f32` feature (M7 D9)", + vinst.mnemonic() + )); + } } Ok(()) } 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 new file mode 100644 index 000000000..ae6ae94f4 --- /dev/null +++ b/lp-shader/lpvm-native/src/isa/xt/fpr.rs @@ -0,0 +1,221 @@ +//! 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. +//! +//! # One FR is reserved as scratch +//! +//! `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; + +/// Number of floating-point registers the coprocessor provides. +pub const FR_COUNT: u8 = 16; + +/// 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: 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] = &[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"); + } + } + + /// 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_but_the_scratch_is_allocatable() { + assert_eq!(ALLOC_POOL.len(), FR_COUNT as usize - 1); + for r in 0..FR_COUNT { + 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}"); + } + } + + /// 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..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,16 @@ 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 +// 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/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..6ee8ff4f2 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; @@ -72,14 +72,83 @@ 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 fa_vreg(v: lpir::VReg) -> VReg { +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) } /// 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"), @@ -864,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), @@ -874,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), @@ -894,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), @@ -910,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), @@ -1032,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, @@ -1460,6 +1592,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 +1623,35 @@ 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, + func, + 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 +1676,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 { @@ -1536,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)?, @@ -2053,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 { @@ -2063,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, @@ -2084,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 { @@ -2124,10 +2329,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,36 +2347,76 @@ 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 = builtin_mode(mode); 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, } } +/// [`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('_')?; @@ -3308,8 +3553,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_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_float_unsupported() { + fn lower_f32_without_the_feature_names_the_feature() { let op = LpirOp::Fadd { dst: v(0), lhs: v(1), @@ -3318,23 +3596,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..c4025a51d --- /dev/null +++ b/lp-shader/lpvm-native/src/lower_f32.rs @@ -0,0 +1,2049 @@ +//! 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). +//! +//! # 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::{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, FAluOp, FAluRROp, FcmpCond, 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, + func: &IrFunction, + 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" + ), + }), + F32Lowering::HardwareFpu => { + lower_hardware_fpu_op(out, op, src_op, func, symbols, vreg_pool, temps) + } + } +} + +/// 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:?}"), + }), + } +} + +// ─── 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. +/// +/// 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`? +/// +/// 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) +} + +/// 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()) +} + +/// 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 + } +} + +/// 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) + } +} + +fn is_param(func: &IrFunction, v: lpir::VReg) -> bool { + (v.0 as u16) < func.total_param_slots() +} + +/// 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, + func: &IrFunction, + v: lpir::VReg, + temps: &mut TempVRegs, + po: u16, +) -> 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, + }); + word +} + +/// 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 word = temps.mint(); + (word, Some(float_vreg(func, v))) +} + +/// Resolve an LPIR op name to its native-f32 builtin symbol. +/// +/// 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" + ), + }) +} + +/// 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; + + 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(()) + } + + // 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(()) + } + + // `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 [ + ( + 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" + ); + } + } + + // ── 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 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(); + 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), + &float_func(4), + &mut symbols, + &mut pool, + &mut temps, + ) + .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/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..3a1047406 --- /dev/null +++ b/lp-shader/lpvm-native/src/regalloc/test/float_alloc.rs @@ -0,0 +1,228 @@ +//! 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); +} + +/// 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. +#[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/rt_emu/instance.rs b/lp-shader/lpvm-native/src/rt_emu/instance.rs index d2ec04a1c..d742e0d33 100644 --- a/lp-shader/lpvm-native/src/rt_emu/instance.rs +++ b/lp-shader/lpvm-native/src/rt_emu/instance.rs @@ -1,7 +1,7 @@ //! [`LpvmInstance`] implementation for emulated native RV32 execution. use alloc::format; -use alloc::string::{String, ToString}; +use alloc::string::String; use alloc::vec::Vec; use cranelift_codegen::data_value::DataValue; @@ -13,16 +13,16 @@ use lpir::FloatMode; use lpir::lpir_module::IrFunction; use lps_shared::{LayoutRules, LpsType, LpsValueQ32, ParamQualifier, lps_value_f32::LpsValueF32}; use lpvm::{ - CallError, LpvmBuffer, LpvmInstance, 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, - validate_compute_tick_sig, validate_render_samples_sig_ir, validate_render_texture_sig_ir, + CallError, LpvmBuffer, LpvmInstance, 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, + validate_render_samples_sig_ir, validate_render_texture_sig_ir, }; // Only the Xtensa arm allocates from the arena (for its sret buffer). #[cfg(feature = "emu-xt")] use lpvm::LpvmMemory; use lpvm::{INVOCATION_INDEX_ARMED, TRAP_CODE_NONE, VMCTX_OFFSET_FUEL, VMCTX_OFFSET_TRAP}; -use lpvm_cranelift::{CompileOptions, signature_for_ir_func, signature_uses_struct_return}; +use lpvm_cranelift::{signature_for_ir_func, signature_uses_struct_return}; use lpvm_emu::{GUEST_VMCTX_BYTES, riscv32_lpvm_reference_isa}; use crate::error::NativeError; @@ -195,14 +195,6 @@ impl NativeEmuInstance { self.armed_fuel = fuel; } - fn cranelift_options(&self) -> 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> { @@ -1112,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)] @@ -1176,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/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..b5562f0e2 100644 --- a/lp-shader/lpvm-native/src/rt_jit/instance.rs +++ b/lp-shader/lpvm-native/src/rt_jit/instance.rs @@ -1,17 +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, - 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; @@ -418,11 +417,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 +437,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 +444,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 +461,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-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/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. 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..51eb1ec67 --- /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 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. +#[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 3e7f6f7b9..0193153e2 100644 --- a/lp-shader/lpvm/src/lib.rs +++ b/lp-shader/lpvm/src/lib.rs @@ -70,12 +70,13 @@ 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, - 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}; -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/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 { 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 + } } 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..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; 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,5 +82,16 @@ if [[ "$COUNT" -eq 0 ]]; then echo "error: $OUT contains no __lps_ symbols (dead-code elimination?)" >&2 exit 1 fi +# 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_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)" -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"/}"