Skip to content

feat: hardware float on the S3 — f32 VInsts and Xtensa lowering (M7 P1–P2) - #241

Merged
Yona-Appletree merged 16 commits into
mainfrom
claude/f32-m7-p1p2-vinst-lowering
Aug 1, 2026
Merged

feat: hardware float on the S3 — f32 VInsts and Xtensa lowering (M7 P1–P2)#241
Yona-Appletree merged 16 commits into
mainfrom
claude/f32-m7-p1p2-vinst-lowering

Conversation

@Yona-Appletree

@Yona-Appletree Yona-Appletree commented Aug 1, 2026

Copy link
Copy Markdown
Member

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, rv32
soft float) and builds on its IsaTarget::f32_lowering() capability hook and
lower_f32.rs module. If #238 moves, this needs a re-merge.

Scope

P1–P4 of the M7 sub-plan. P5 (firmware / CPENABLE / silicon) and P6 (ADR
and 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.

  • P1 — float VInst variants, the two-class operand map, isa/xt/fpr.rs,
    the four populated IsaTarget float hooks (the five ABI hooks stay empty with
    doc comments saying why, per D3), ImmOp::FpLsiOffset, the float-f32
    feature.
  • P2 — the Xtensa hardware arm of lower_f32.rs: the inline family, builtin
    sym_call routing for everything else (D4), and wfr/rfr boundary
    insertion at the four sites lowering owns (D1/D2).
  • P3isa/xt/emit_fp.rs: every float VInst encoded through
    lp-xt-inst's FP model, the split class gate (hw/fhw), float spill/reload
    edits, and the checked lsi/ssi immediate with its address-scratch
    fallback.
  • P4tests/xt_pipeline_f32.rs (16 tests), the frame hazard gate,
    rt_emu::call_f32_words, and real FloatImpl reporting.

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.

  • depth-100 float recursion carrying three live values across each nested
    call8, every one asserted on unwind — passes.
  • an integer control at the same depth and shape — passes, so a future
    float failure is precise rather than ambiguous with the window machinery.
  • frame shape pinned: still one entry, one retw, still 32 reserved
    bytes. FrameLayout was not touched.
  • out-of-lsi-range float spill asserted at the emitter level, where the
    encoding can be checked — a truncated (offset/4)&0xff can read the right
    value by luck, so the value alone would not catch it.
  • unarmed CPENABLE faults with EXCCAUSE 32, the emulator proving D6's
    failure mode.

Two findings worth reading

  1. D5's compare table was wrong for !=. It tabulated ueq.s + movf,
    which computes "ordered and unequal" — false on NaN, where float.md §3
    Guaranteed makes != true. Correct mapping is oeq.s + movf. The one
    comparison the spec singles out was the one the plan got wrong; the emulator
    caught it.

  2. D8 narrowed to 15 of 16 FRs. f15 is reserved as the emitter's float
    scratch, 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
    a8 does for integers. One is provably enough (only defs are ever
    stack-allocated; no float VInst has two float defs).

Blocked upstream: the Xtensa builtins image cannot carry the f32 family

lps-builtins/float-f32 does not compile for Xtensa at all — the esp Rust
backend fails with Cannot select: XtensaISD::PCREL_WRAPPER TargetConstantPool <[2 x float]> at every optimization level, on an [f32; 2] gradient LUT in
snoise2_f32. rv32 builds the same source cleanly.

Recorded in docs/defects/2026-08-01-xtensa-backend-cannot-select-float-constant-pool.md
with 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 and
one default to flip. It is opt-in rather than default because
scripts/filetests.sh builds this image before the xtn.*/xtlpn.* targets —
making it unconditional took the whole fixed-point Xtensa suite down, which
CI caught.

This is a live risk for P5: fw-esp32s3 naming lps-builtins/float-f32
hits the same wall at firmware build time.

Invariants

  • Q32 filetest snapshots byte-identical — xtn.q32 6336/6336, 849/849
    files
    ; working tree clean of baseline paths. No baseline regenerated.
  • cargo test -p lpvm-native green (343 lib + 15 pipeline + the rest).
  • ESP32-C6 image measured both ways; P1 accepted +1,424 B of jump-table rodata
    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, then notes.md §"the five facts that shaped this plan", then
the phase files — each now carries an ## Implementation Result section with
its deviations. Design decisions D1–D11 are settled apart from the two
corrections above; implement, do not relitigate.

Next: P5 (firmware float-f32, CPENABLE arming, f32 xt_corpus cases,
rt_jit entry point, the C6 zero-delta proof, the on-silicon run) and P6
(the ADR). Both wait for G2.

Yona-Appletree and others added 11 commits July 31, 2026 18:07
…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>
@github-actions

github-actions Bot commented Aug 1, 2026

Copy link
Copy Markdown
Contributor

CI refreshed the story baselines on this branch

The validate-stories job detected drift and committed the fresh set: 1 changed
in 57f061b.

Review every PNG in the PR's Files changed view (swipe / onion-skin).
Your local branch is now behind — git pull before pushing again.

Story Before After
exploration__node-ui__status-indicators__sm.png exploration__node-ui__status-indicators__sm.png@3d00175676 exploration__node-ui__status-indicators__sm.png@57f061b499

Yona-Appletree and others added 4 commits July 31, 2026 23:00
…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
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
Yona-Appletree marked this pull request as ready for review August 1, 2026 15:58
@Yona-Appletree
Yona-Appletree merged commit a03ddd7 into main Aug 1, 2026
10 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant