Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
16 commits
Select commit Hold shift + click to select a range
dd29e1a
feat(lpvm-native): rv32 soft-float f32 lowering + the float-capabilit…
Yona-Appletree Aug 1, 2026
60cbf43
feat(lpvm-native): execute f32 shaders — marshalling, entry ABI, rv32…
Yona-Appletree Aug 1, 2026
f4bef6b
feat(lp-riscv-emu): RV32F from the spec — 34 instructions, real fcsr
Yona-Appletree Aug 1, 2026
d62fa2d
test(filetests): rv32 soft-float corpus disposition — 849/849 on both…
Yona-Appletree Aug 1, 2026
318f23b
fix(lpvm-native): keep the f32 builtin-id tables out of the Fixed-onl…
Yona-Appletree Aug 1, 2026
529a7b8
docs: soft-float f32 — the capability seam, the C6 harness, and the f…
Yona-Appletree Aug 1, 2026
cac78cc
style: rustfmt + FloatLaneAbi round-trip tests
Yona-Appletree Aug 1, 2026
484e344
merge: f32 M9 (rv32 soft float) — IsaTarget::f32_lowering + lower_f32…
Yona-Appletree Aug 1, 2026
8d658d3
feat(lpvm-native): float VInsts, the two-class map, and the Xtensa FR…
Yona-Appletree Aug 1, 2026
93b0975
feat(lpvm-native): F32 lowering for Xtensa — inline family, builtin r…
Yona-Appletree Aug 1, 2026
db37e5c
chore(studio): auto-refresh story baselines [validate-stories]
github-actions[bot] Aug 1, 2026
f695a41
feat(lpvm-native): the Xtensa FP emitter — every float VInst, and flo…
Yona-Appletree Aug 1, 2026
f686327
feat(lpvm-native): the f32 pipeline rig, the frame gate, and FloatImp…
Yona-Appletree Aug 1, 2026
3d00175
fix(scripts): the xt builtins f32 flip must not take the Q32 suite wi…
Yona-Appletree Aug 1, 2026
57f061b
chore(studio): auto-refresh story baselines [validate-stories]
github-actions[bot] Aug 1, 2026
0f034f4
style(lpvm-native): rustfmt xt_pipeline_f32.rs
Yona-Appletree Aug 1, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
13 changes: 13 additions & 0 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
2 changes: 2 additions & 0 deletions Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

158 changes: 158 additions & 0 deletions docs/adr/2026-07-31-soft-float-via-compiler-builtins.md
Original file line number Diff line number Diff line change
@@ -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.
3 changes: 3 additions & 0 deletions docs/adr/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
Original file line number Diff line number Diff line change
@@ -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.
39 changes: 34 additions & 5 deletions justfile
Original file line number Diff line number Diff line change
Expand Up @@ -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.
#
Expand Down Expand Up @@ -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 \
Expand Down
Loading
Loading