feat: hardware float on the S3 — f32 VInsts and Xtensa lowering (M7 P1–P2) - #241
Merged
Conversation
…y seam `IsaTarget::f32_lowering` names how a target does binary32: soft-float library calls, a hardware FPU, or not at all. `Rv32imac` answers SoftFloatCalls; nothing answers HardwareFpu, and a test keeps it that way — that is what makes it impossible to emit `fadd.s` for a C6 that would trap on it. `lower_f32` lowers f32 ops to direct calls at the platform soft-float symbols (`__addsf3`, `__ltsf2`, `__floatsisf`, …) with no LightPlayer wrapper in between (roadmap D1). Values stay in integer registers, so the empty Float register class stays empty and no emitter work is needed. `neg`/`abs`/`const`/bitcast are pure bit ops with no call at all; the ops compiler-rt does not define (sqrt, floor/ceil/trunc/nearest, min/max, float→int, unorm lanes) call the M5 native-f32 builtin family, which is the only implementation and therefore not a wrapper either. Also fixes the wasm B1 defect class here before it can bite: import → builtin id resolution was Q32-hardcoded, which in f32 mode silently resolves a pointer-taking builtin to its Q16.16 twin with no type error. Behind `float-f32`, off by default, for the measured D2 reason. Plan: 2026-07-30-1745-f32-native-math (M9)
… f32 targets Three things stood between soft-float lowering and a running f32 shader: 1. `LpvmInstance::call` refused any float mode but Q32. Argument and return marshalling is now mode-aware through one `FloatLaneAbi` — the aggregate layout is identical in both modes, only the `float` lane differs, so the struct/array/matrix traversal is shared rather than forked. 2. The emulator's *entry* signature asked for `types::F32` in f32 mode and died with "Unsupported return type". `lpvm-native` targets the soft-float ABI, where an f32 argument lives in an integer register — the entry signature is Q32-shaped whatever the shader's float mode is. 3. Void returns were an error rather than 0.0, unlike every other f32-capable runtime. That asymmetry cost `wasm.f32` 26 files in the M1 corpus run. `rv32n.f32` / `rv32lpn.f32` are registered but deliberately out of DEFAULT_TARGETS (Q6: on demand) — every float op is a call, so their cost belongs to a deliberate run. `scalar/float`: 131/131 tests, 15/15 files on rv32lpn.f32. Plan: 2026-07-30-1745-f32-native-math (M9)
Written from the RISC-V Unprivileged ISA v20240411 Chapter 21, with the section title cited alongside every number so the references survive a renumbering. No GPL implementation was read. It is a **soft-float** implementation — integer arithmetic on exact significands with one rounding step — and that is not incidental. Native host f32 fails three ways at once: it cannot do the four non-RNE rounding modes, it exposes no status word (so every exception flag would be a guess), and hosts propagate NaN payloads where §21.3 requires the canonical NaN. Doing it in integers makes NV/DZ/OF/UF/NX exact rather than approximated, and keeps the crate no_std for fw-emu. FLEN is 32 here, so there is **no** NaN boxing — that rule is §22.2 and applies only when FLEN > 32. `read_single`/`write_single` are the two places a future FLEN=64 widening would change; the docs say so, because "add NaN boxing" is the obvious wrong fix. fflags/frm/fcsr become real state, replacing the returns-zero CSR stub for those three CSR numbers only. Everything else stays a no-op. 77 tests: every rounding mode on a value that rounds differently in each, canonical NaN from each NaN-producing op, FMIN/FMAX signed zero and NaN, signalling-vs-quiet comparisons, FCVT saturation at both ends and on NaN, FSGNJ bit-exactness on NaN, all ten FCLASS classes, sticky flag accrual, and an FMA case where fused differs from unfused. Zcf (C.FLW/C.FSW) is deliberately out of scope: nothing emits it. Plan: 2026-07-30-1745-f32-native-math (M9)
… targets
First run of the whole corpus against the soft-float rv32 targets. It found
**no backend defect**: 833/849 and 846/849 files passed on their own merits,
and every one of the 16 + 3 failures was an existing gap whose annotation
names targets one by one and had never heard of these two names.
The annotations added say what is actually true:
- 11 `global-future/*` files — the lps-glsl frontend cannot parse them,
exactly as their rv32lpn.q32/xtlpn.q32 rows already record.
- the UB domain probes (`edge-{exp,trig}-domain`, `edge-nan-inf-propagation`)
whose `~= 0.0` expectation was written for the clamping Q32 path.
- `edge-precision:19` — round(2.5). float.md §4 makes the tie direction
target-defined, and lps-glsl routes `round` to the ties-away builtin where
naga lowers it to ties-to-even. A real, legal frontend divergence.
- `overload-local-duplicate` — the same regalloc limit its q32 row records;
one `@broken(float_mode=f32, backend=rv32n)` covers both siblings, since
`backend=rv32n` names Backend::Rv32fa.
Q32 is provably untouched: every added annotation matches only the two new
f32 target names, no DEFAULT_TARGET among them, and no baseline moved.
`just test-filetests` — 31562/31562, 849/849 files.
Plan: 2026-07-30-1745-f32-native-math (M9)
…y image Making import resolution mode-aware cost the shipping ESP32-C6 image **+3,904 B**, and the `float-f32` gate on this crate did not stop it: `lps-builtin-ids` is a separate crate every firmware links, its f32 name->id tables are reachable from exactly one call, and asking for them on a runtime value keeps all of them. Pinning the resolver to Q32 when `float-f32` is off is not a shortcut — without the feature a `FloatMode::F32` shader is a named lowering error long before resolution runs, so the f32 tables are genuinely unreachable. C6 image back to 2,862,432 B (283,296 B headroom) — +192 B vs the branch base, which is the mode-aware argument/return marshalling in `rt_jit`. The lesson is worth keeping: a feature gate on the crate that *uses* a table does not gate the crate that *holds* it. Plan: 2026-07-30-1745-f32-native-math (M9)
…lash lesson The lesson worth the space: a feature gate on the crate that *uses* a table does not gate the crate that *holds* it. lps-builtin-ids is linked by every image and cost +3,904 B on the C6 before the resolver was pinned. Plan: 2026-07-30-1745-f32-native-math (M9)
The tests are the ones worth having on this seam: values Q16.16 cannot represent survive the IEEE lane ABI bit-for-bit, a signalling NaN's payload is carried untouched, and the two lane codecs are provably not interchangeable — if they ever agree, the mode split has collapsed. Plan: 2026-07-30-1745-f32-native-math (M9)
….rs shapes M7's Xtensa arm builds on M9's capability hook and lower_f32 module. Merges after #238.
… model M7 P1 — the *vocabulary* for hardware float on Xtensa. Nothing constructs these yet (P2 lowers, P3 emits); the oracles are unit-level and real. - Nine float `VInst` variants (`FAluRRR/FAluRR/Fcmp/FSelect/FLoad32/ FStore32/Wfr/Rfr/IToF`) plus `FAluOp`, `FAluRROp`, `FcmpCond`, wired through all five per-variant helpers, the debug text form (writer + parser, with a round-trip test per variant), and the alloc-trace render. `size_of::<VInst>() <= 32` still holds. - `regalloc::classes` learns the operand classes. The mixed-class ones are the calling convention in miniature: `Fcmp` reads floats and writes an int, `FSelect` takes an int condition, and `Wfr`/`Rfr` each read the file the other writes. `Call`/`Ret` stay Int-only on purpose (D1/D2). - New `isa/xt/fpr.rs`: all 16 FRs allocatable and all 16 caller-saved (D8), no reserved scratch, `CMP_BREG = b0`. No FR is callee-saved (M6-P4's measured toolchain ABI), which is what removes the FP callee-save frame region entirely — `FrameLayout` is untouched (D7). - Four `IsaTarget` float hooks populated for Xtensa behind `float-f32`; the five ABI hooks stay empty with doc comments saying they are *decided*, not unimplemented (D3), including why `move_cycle_scratch` never grows the `class` parameter M4 anticipated. - `ImmOp::FpLsiOffset` gates `lsi`/`ssi`'s 0..=1020 step-4 range. The test pins the hazard rather than the rule: offset 1024 silently encodes as 0, because `lp-xt-inst` masks the field without a range check. - `float-f32` joins `default` alongside the ISAs (every firmware crate takes this crate with `default-features = false`). Two gate leaks found by measurement and fixed: the class map ungated cost 496 B of C6 image, and the emitter's float-arm diagnostic another 1,248 B. Residual with the feature off is +1,424 B image — `.text` is 120 B *smaller*; the growth is jump-table `.rodata` from the unconditional variants, which is the D9/Q5 trade in the form D9 asked to see it. Plan: 2026-07-30-1745-f32-native-math/m7-f32-codegen-xtensa Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…outing, AR/FR transfers M7 P2. `IsaTarget::f32_lowering()` now answers `HardwareFpu` for Xtensa behind `float-f32`, and `lower_f32.rs` has an arm to back it up. Still nothing executes — P3 emits, P4 runs — but the VInst stream is real and its shapes are asserted. The split (D4). `fadd/fsub/fmul`, `fabs/fneg`, the six compares, float select, `itof_s/itof_u`, float loads/stores and the transfers are one FP instruction each. Everything else calls the M5 f32 builtin family through `BuiltinId`, not a string literal, so a missing symbol is a resolution error naming the op rather than a link failure three phases later. Two of those are deliberate rather than pending: division is a builtin call in Q32 as well, so this is parity; and `trunc.s` alone may not satisfy float.md §3, since whether this silicon saturates or wraps is still an open measurement. Each comparison keeps its own condition. `Fne` is not a negated `Eq` — they differ exactly on NaN, and no ordinary input would catch the rewrite. The calling convention (D1/D2): float values live in FRs inside a body and travel in address registers as raw IEEE bit patterns across every boundary, because the toolchain that compiles our float builtins does. Lowering inserts the transfers at four sites and nowhere else — entry parameters, call arguments, call results, returns — which leaves `Call` and `Ret` integer-class end to end and makes the ABI readable in the VInst dump. Parameters are the one case needing two backend identities: they arrive precolored to an address register and cannot also be the FR the body computes in, since a vreg has one class for life. They get a deterministic shadow vreg filled by the entry `Wfr` — deterministic so there is no per-function allocation on the device, and so the *word* view stays plain `fa_vreg`, which means a float parameter handed straight to another call needs no transfer at all. M9's `f32_lowering_never_claims_hardware` is narrowed rather than deleted: exactly one target may claim an FPU, and only in a build that links the FP tables. Q32 filetest snapshots byte-identical (`just test` green, no baseline paths touched); C6 image unmoved at 2 863 872 B — every byte of this phase is behind the gate. Plan: 2026-07-30-1745-f32-native-math/m7-f32-codegen-xtensa Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Captured in the pinned CI environment by run: https://github.com/light-player/lightplayer/actions/runs/30681544826
Contributor
CI refreshed the story baselines on this branchThe Review every PNG in the PR's Files changed view (swipe / onion-skin).
|
…at spills P3 of M7. `isa/xt/emit_fp.rs` encodes the nine float `VInst`s through `lp-xt-inst`'s FP model, behind `float-f32`; `isa/xt/emit.rs` keeps the integer half and dispatches to it. The class gate is split rather than widened: `hw()` still refuses a float allocation outright, `fhw()` is its mirror and refuses an integer one. `a3` and `f3` share a hardware index and are different registers, so a class confusion is a reinterpreted bit pattern, not a crash — two narrow gates turn it into an emit error at the operand that was wrong. Two things the plan did not have right, both caught by the emulator rather than by reading: - **`!=` maps to `oeq.s` + `movf`, not `ueq.s` + `movf`.** D5's table gave the latter, which computes `!ueq` = "ordered and unequal" — *false* on NaN, where float.md §3 Guaranteed makes `!=` true. The one comparison the spec singles out was the one the table got wrong. - **One FR is reserved as scratch (`f15`), narrowing D8's "all 16."** The allocator can put an instruction's *destination* on the stack, and an FP instruction still has to write into a register before it can be stored — the float counterpart of what `a8` does for integers. One is provably enough: only defs are ever stack-allocated (`alloc_use` returns a register on every path), and no float VInst has two float defs. Float spills use `lsi`/`ssi` with a checked immediate. `lp-xt-inst`'s encoder computes `(offset / 4) & 0xff` with no range check, so an out-of-range offset silently addresses a *different slot*; every float access goes through `ImmOp::FpLsiOffset` and takes the address-scratch fallback, pinned by a test that asserts the encoding and not just the value. The frame is untouched: no `FrameLayout`, prologue or epilogue change, because no FR is callee-saved (M6-P4 measured). `arithmetic_propagates_nan` is marked `#[ignore]` awaiting M6-P6 — NaN propagation through arithmetic is a deliberately-unresolved `lp-xt-emu` policy field, and the test is left standing rather than weakened. Plan: 2026-07-30-1745-f32-native-math/m7-f32-codegen-xtensa
…l reporting P4 of M7. `tests/xt_pipeline_f32.rs` runs compiled hardware f32 end to end on the M6 emulator through the real pipeline — LPIR → lower → regalloc → emit → link → windowed execution — and pins the milestone's headline hazard. **The frame hazard is now pinned rather than argued.** D7 claims float support adds no frame region, so float spills at the bottom of the frame cannot reach the window-overflow reservation at the top. Its failure mode is silent ancestor-frame corruption that surfaces long after the return, so the test carries several live floats across 100 nested `call8`s and checks every value on unwind — with an integer control at the same depth and shape, so a failure says "floats specifically" rather than "the window machinery". Both pass. A third test pins the frame *shape*: still one `entry`, one `retw`, still 32 reserved bytes. Also covered: all six compares including NaN, the arithmetic Guaranteed rows, select, `itof` both signednesses, float constants through `Wfr`, floats crossing a guest call in address registers (D1), float spill pressure past the pool, and both register pools saturated at once — the shape where a class confusion would show up. Unarmed `CPENABLE` faults with EXCCAUSE 32, which is the emulator proving D6's failure mode. `rt_emu` gains `call_f32_words`: raw IEEE bit patterns in and out, no conversion, because in F32 mode a word *is* the value. It is a separate entry point rather than a relaxed guard — the two modes disagree about what a word means, and a mismatch must be a message, not a wrong pixel. `call_render_texture`/`call_render_samples` stay Q32-only. `ShaderCompileStats.float_impl` stops being hardcoded to `Fixed`. `FloatImpl` moves to `lpvm` beside `LpvmModule` — the compiled module is the only thing that knows both the mode it was compiled in and the target it was compiled for — and `lp-shader` re-exports it, so no call site changes. **The builtins-image flip is configured but blocked upstream.** `lps-builtins-xt-app` now forwards `float-f32` and the build script asserts the f32 symbol count by name, mirroring the rv32 sibling. The build itself fails in the esp Rust backend, which cannot select a float constant pool, at any optimization level — see the new defect entry. The one pipeline test that calls an M5 builtin is marked `#[ignore]` pointing at it. Plan: 2026-07-30-1745-f32-native-math/m7-f32-codegen-xtensa
…th it Passing `--features float-f32` unconditionally to the Xtensa builtins image build turned an upstream blocker into a total outage: `scripts/filetests.sh` builds that image before running the `xtn.*` / `xtlpn.*` targets, so a build that cannot succeed fails every Xtensa filetest rather than just the f32 path that depends on it. CI caught it on "Validate Xtensa (host)". The feature is now opt-in via `LP_XT_BUILTINS_F32=1`. The crate wiring stays — `lps-builtins-xt-app` forwards `float-f32` to `lps-builtins`, which is what the shared `builtin_refs.rs` cfgs need — so resolving the upstream defect is one environment variable to verify and then one default to flip. The f32 symbol count is reported unconditionally and asserted only when the family was actually requested, so the image's state is visible either way. Plan: 2026-07-30-1745-f32-native-math/m7-f32-codegen-xtensa
Captured in the pinned CI environment by run: https://github.com/light-player/lightplayer/actions/runs/30687110183
This was referenced Aug 1, 2026
The M7 P4 pipeline tests went in unformatted; CI's fmt-check is what caught it. No behavior change. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Yona-Appletree
marked this pull request as ready for review
August 1, 2026 15:58
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.


Plan: lp2025/2026-07-30-1745-f32-native-math/m7-f32-codegen-xtensa
Path: ~/.photomancer/planning/lp2025/2026-07-30-1745-f32-native-math/m7-f32-codegen-xtensa/plan.md
Merges after #238. This branch merges
origin/claude/f32-m9-rv32-f32(M9, rv32soft float) and builds on its
IsaTarget::f32_lowering()capability hook andlower_f32.rsmodule. If #238 moves, this needs a re-merge.Scope
P1–P4 of the M7 sub-plan. P5 (firmware /
CPENABLE/ silicon) and P6 (ADRand docs) are a later launch — both are blocked on the roadmap's G2 gate. The
PR stays draft until they land.
GLSL float now compiles to Xtensa hardware FPU instructions and executes
correctly on the M6 emulator through the real pipeline.
VInstvariants, the two-class operand map,isa/xt/fpr.rs,the four populated
IsaTargetfloat hooks (the five ABI hooks stay empty withdoc comments saying why, per D3),
ImmOp::FpLsiOffset, thefloat-f32feature.
lower_f32.rs: the inline family, builtinsym_callrouting for everything else (D4), andwfr/rfrboundaryinsertion at the four sites lowering owns (D1/D2).
isa/xt/emit_fp.rs: every floatVInstencoded throughlp-xt-inst's FP model, the split class gate (hw/fhw), float spill/reloadedits, and the checked
lsi/ssiimmediate with its address-scratchfallback.
tests/xt_pipeline_f32.rs(16 tests), the frame hazard gate,rt_emu::call_f32_words, and realFloatImplreporting.The frame hazard is pinned, not argued (D7)
The milestone was written around one hazard: float spills at the bottom of the
frame versus the window-overflow reservation at the top. Its failure mode is
silent ancestor-frame corruption that surfaces long after the return.
call8, every one asserted on unwind — passes.float failure is precise rather than ambiguous with the window machinery.
entry, oneretw, still 32 reservedbytes.
FrameLayoutwas not touched.lsi-range float spill asserted at the emitter level, where theencoding can be checked — a truncated
(offset/4)&0xffcan read the rightvalue by luck, so the value alone would not catch it.
CPENABLEfaults with EXCCAUSE 32, the emulator proving D6'sfailure mode.
Two findings worth reading
D5's compare table was wrong for
!=. It tabulatedueq.s+movf,which computes "ordered and unequal" — false on NaN, where float.md §3
Guaranteed makes
!=true. Correct mapping isoeq.s+movf. The onecomparison the spec singles out was the one the plan got wrong; the emulator
caught it.
D8 narrowed to 15 of 16 FRs.
f15is reserved as the emitter's floatscratch, because the allocator can put an instruction's destination on the
stack and an FP instruction still has to write into a register first — what
a8does for integers. One is provably enough (only defs are everstack-allocated; no float
VInsthas two float defs).Blocked upstream: the Xtensa builtins image cannot carry the f32 family
lps-builtins/float-f32does not compile for Xtensa at all — the esp Rustbackend fails with
Cannot select: XtensaISD::PCREL_WRAPPER TargetConstantPool <[2 x float]>at every optimization level, on an[f32; 2]gradient LUT insnoise2_f32. rv32 builds the same source cleanly.Recorded in
docs/defects/2026-08-01-xtensa-backend-cannot-select-float-constant-pool.mdwith three candidate resolutions. The crate wiring is in place and the build
script takes
LP_XT_BUILTINS_F32=1, so closing it is one env var to verify andone default to flip. It is opt-in rather than default because
scripts/filetests.shbuilds this image before thextn.*/xtlpn.*targets —making it unconditional took the whole fixed-point Xtensa suite down, which
CI caught.
This is a live risk for P5:
fw-esp32s3naminglps-builtins/float-f32hits the same wall at firmware build time.
Invariants
xtn.q326336/6336, 849/849files; working tree clean of baseline paths. No baseline regenerated.
cargo test -p lpvm-nativegreen (343 lib + 15 pipeline + the rest).as the D9 trade (see the P1 commit body for the two gate leaks found and
fixed by measurement). P3/P4 add nothing to the feature-off image beyond
IsaTarget::float_impl_for, which const-folds per target.Resume notes
Read
plan.md, thennotes.md§"the five facts that shaped this plan", thenthe phase files — each now carries an
## Implementation Resultsection withits deviations. Design decisions D1–D11 are settled apart from the two
corrections above; implement, do not relitigate.
Next: P5 (firmware
float-f32,CPENABLEarming, f32xt_corpuscases,rt_jitentry point, the C6 zero-delta proof, the on-silicon run) and P6(the ADR). Both wait for G2.