Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
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
Original file line number Diff line number Diff line change
@@ -0,0 +1,81 @@
---
status: open # blocks making float-f32 the Xtensa image's default
found: 2026-08-01 # how: filetests (xtn.q32 dropped 849/849 -> 522/849 on the gate flip)
area: lp-xt/lps-builtins-xt-app, lp-shader/lpvm-native (rt_emu/xt_image), lp-xt-emu
class: capacity
related:
- docs/defects/2026-08-01-xtensa-backend-cannot-select-float-constant-pool.md
---
# With `float-f32` in, the Xtensa builtins image fills the emulator's code region and leaves no room for shader code

**Symptom** — building the Xtensa builtins image with `--features float-f32`
succeeds, and then most of the Xtensa filetest suite fails to *link*:

```
xtn.q32 without float-f32: 6336/6336 tests, 849/849 files
xtn.q32 with float-f32: 2917/6336 tests, 522/849 files (327 files fail)
```

Failures report as `compile-fail`, not as wrong answers. Small shaders pass and
large ones fail, which is the tell.

**Cause** — it is capacity, not codegen. `lp-xt/lps-builtins-xt-app/link.ld`
gives `.text` 112 KiB (114,688 B) of the emulator's 128 KiB code region, and
`.rodata`/`.data`/`.bss` the remaining 16 KiB (16,384 B). Measured with
`xtensa-esp32s3-elf-size -A`:

| build | `.text` | free IRAM | `.rodata` | free DRAM |
| --- | --- | --- | --- | --- |
| default (Q32 only) | 66,300 B | 48,388 B | 12,176 B | 4,208 B |
| `--features float-f32` | 113,757 B | **931 B** | 16,156 B | **228 B** |

`rt_emu::xt_image::build_xt_image` places compiled shader code 4-aligned after
the image's `.text` and requires it to end before the image's data segments —
i.e. in exactly that free-IRAM gap. At 931 bytes, only the smallest shaders
fit; everything else fails with

```
compiled shader code does not fit the Xtensa code region: N bytes of shader
after 113757 bytes of builtins ...
```

The image *links* because 113,757 < 114,688. It links with 0.8% headroom, so
the f32 family very nearly does not fit its own region either.

## Consequence

`scripts/build-builtins-xt.sh` cannot make `--features float-f32` its default:
`scripts/filetests.sh` builds this image before running `xtn.*`/`xtlpn.*`, so
the flip takes the whole Xtensa filetest suite down. The family stays behind
`LP_XT_BUILTINS_F32=1`, and `lpvm-native`'s
`a_builtin_routed_float_op_resolves_and_runs` skips loudly rather than failing
when the resident image lacks it.

**This is a different defect from the constant-pool one it was found behind.**
That one was "the f32 family does not compile for Xtensa"; it is worked around
and the image now builds. This one is "the f32 family does not *fit* alongside
shader code". Fixing the first exposed the second.

It is also a live question for **M7 P5**: `fw-esp32s3` is not bound by this
linker script or by the emulator's 128 KiB model, so the capacity limit is the
*host emulation* path's, not the device's. Worth confirming before assuming P5
inherits it.

## Candidate resolutions (not chosen here — needs a decision)

1. **Widen the emulator's modeled code region.** 128 KiB is the host
emulator's model, not silicon; the S3 has far more IRAM. Touches
`lp-xt-emu`'s `BoardProfile` and both linker scripts, and `lp-xt-elf`'s
`DATA_BASE = CODE_DBUS_BASE + CODE_REGION_LEN/2` convention.
2. **Give the shader its own region** rather than the tail of the builtins
region, removing the coupling between builtins size and maximum shader size
entirely. Largest change, best end state.
3. **Split the f32 builtin feature** so only the arithmetic family M7 D4
actually routes to (divide, sqrt, rounding, min/max, conversions,
transcendentals) is linked, leaving the generative-noise family — which is
most of the 47 KB — rv32/host-only. Cheapest, and the noise builtins are not
what M7's lowering calls. Changes a feature contract M5 owns, so it is
Yona's call.

(3) is the smallest thing that unblocks M7's host path; (1) or (2) is what
makes the limit stop recurring.
Original file line number Diff line number Diff line change
@@ -1,10 +1,12 @@
---
status: open
status: open # the backend is still broken; our source avoids it (see "The workaround")
found: 2026-08-01 # how: build (M7 P4, flipping the Xtensa builtins image to float-f32)
worked-around: 2026-08-01 # rgb2hsv_f32.rs; f32 image and filetests unblocked
area: lp-xt/lps-builtins-xt-app, lp-shader/lps-builtins, esp Rust toolchain
class: upstream-toolchain-limitation
related:
- docs/design/float.md
- docs/defects/2026-08-01-xt-f32-builtins-exhaust-the-emulator-code-region.md
---
# The esp Xtensa backend cannot select a float constant pool, so `lps-builtins/float-f32` does not compile for Xtensa

Expand All @@ -30,57 +32,107 @@ 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.
**Trigger** — a *constant aggregate* of floats. The backend can materialize
the address of ordinary data but has no selection rule for `PCREL_WRAPPER`
over a `TargetConstantPool` node, so any `[N x float]` constant LLVM chooses to
put in the constant pool is fatal. Only the first such constant to be selected
is reported, which made the trigger easy to misattribute (see below).

**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.
this image before running the `xtn.*` / `xtlpn.*` targets, so a compile failure
here takes the entire Xtensa filetest suite down with it — not just the f32
half.

## What was actually wrong (2026-08-01)

The first draft of this record named
`lps-builtins/src/builtins/lpfn/generative/snoise/snoise2_f32.rs`'s 8-entry
`[f32; 2]` gradient LUT as the trigger, inferring it from the reported
constant `[0.0, -1.0]` — which happens to be that table's arm 3. **That was
wrong.** The compiler names the containing function, and it is
`__lp_lpfn_rgb2hsv_f32`:

```rust
// lps-builtins/src/builtins/lpfn/color/space/rgb2hsv_f32.rs
let p: [f32; 4] = if g < b {
[b, g, -1.0, 2.0 / 3.0]
} else {
[g, b, 0.0, -1.0 / 3.0]
};
```

The two constant lanes of Hocevar's `p` term are selected together, and LLVM
materializes the pair as `[2 x float] [0.0, -1.0]` — the same bytes the
snoise2 arm would have produced, from an entirely different function.

**Exactly one site needed changing.** With `rgb2hsv` fixed the image builds,
and every other suspect this record named — `snoise2`'s and `snoise3`'s
gradient LUTs, `worley2/3`'s offset tables, `gnoise3_tile`'s `CORNERS`,
`psrdnoise2/3` — compiles unchanged and is present in the built ELF. The
pattern that trips the backend is narrower than "a float lookup table": it is a
*fully constant* aggregate that survives to instruction selection. A LUT
indexed by a runtime value becomes an ordinary global; a `match` whose arms are
runtime-scaled (`worley`'s `[diag, diag, 0.0]`) is not a constant aggregate at
all.

## The workaround (shipped)

`rgb2hsv_f32.rs` selects the two constant lanes as **integer bit patterns** and
bitcasts afterwards, so no float constant aggregate is ever formed:

```rust
const P_Z_IF: u32 = (-1.0f32).to_bits(); // const-evaluated from the
const P_W_IF: u32 = (2.0f32 / 3.0).to_bits(); // original literals
...
f32::from_bits(if g_lt_b { P_Z_IF } else { P_Z_ELSE })
```

Chosen over computing the gradient arithmetically because it is *provably*
bit-identical rather than argued: the constants are the literals, put through
`f32::to_bits` at compile time. Sign-of-zero is the specific hazard an
arithmetic rewrite would have introduced (`0.0 * -1.0` is `-0.0`, and the
literal table's zero lane is `+0.0` on both sides).

The site carries a comment naming this file and stating it is an upstream
limitation, not a preference, with a slot for the upstream issue URL. The
pre-workaround form is kept verbatim in the test module as an oracle:
`hocevar_p_is_bit_identical_to_the_literal_form` compares the two lane-by-lane
over a grid that crosses `g == b`, plus `-0.0` and NaN. These builtins are
shared with rv32 and wasm, where the original compiles fine, so drift would be
a silent behaviour change on targets that never had the problem.

`LP_XT_BUILTINS_F32=1 scripts/build-builtins-xt.sh` now succeeds — the thing
this defect blocked — and
`lp-shader/lpvm-native/tests/xt_pipeline_f32.rs`'s
`a_builtin_routed_float_op_resolves_and_runs` is no longer `#[ignore]`d: it
links the shader against the builtins base image and resolves `ffloor` to the
real `__lp_lpir_ffloor_f32` on the M6 emulator.

**The feature is still not the default, for an unrelated reason.** Making it
unconditional was attempted and reverted on measurement: with the family in,
the image's `.text` is 113,757 B against link.ld's 112 KiB, leaving 931 bytes
of the code region for shader code, and the xtn.q32 filetest suite drops from
849/849 files to 522/849 on link failures. That is a separate defect —
`docs/defects/2026-08-01-xt-f32-builtins-exhaust-the-emulator-code-region.md`
— and fixing this one is what exposed it.

## Why this stays open

The backend limitation is untouched. A new `[f32; N]` constant aggregate
anywhere in `lps-builtins` will fail the same way, with the same one-function-
at-a-time reporting, and **M7 P5's `fw-esp32s3` naming `lps-builtins/float-f32`
is exposed to it**. It closes when the esp fork can select
`PCREL_WRAPPER(TargetConstantPool)` and the toolchain is pinned past that.

## Remaining resolutions

1. **Report upstream** to `esp-rs/rust` and pin the toolchain once fixed. Not
yet filed; the comment in `rgb2hsv_f32.rs` and the one in
`build-builtins-xt.sh` are shaped for the URL to be dropped in.
2. **Split the f32 builtin feature** so the generative-noise family is
separately gated. No longer needed — this was the fallback for "Xtensa
cannot have the noise family at all", and it can.
Original file line number Diff line number Diff line change
Expand Up @@ -17,16 +17,49 @@
//!
//! **Tolerance:** exact against the canonical f32.

// The `p.zw` constants of Hocevar's formulation, as f32 *bit patterns*.
//
// **This is an upstream-LLVM workaround, not a preference.** Written the
// natural way — `if g < b { [b, g, -1.0, 2.0 / 3.0] } else { [g, b, 0.0,
// -1.0 / 3.0] }` — LLVM materialises the constant lane pair as a
// `[2 x float]` constant-pool entry, and the esp Xtensa backend cannot select
// `XtensaISD::PCREL_WRAPPER` over a `TargetConstantPool`, so the whole f32
// builtins family fails to build for `xtensa-esp32s3-none-elf` at every
// optimisation level. Selecting the integer bit patterns and bitcasting after
// the fact keeps the constants out of the float constant pool.
//
// Defect: docs/defects/2026-08-01-xtensa-backend-cannot-select-float-constant-pool.md
// Upstream: not yet filed.
//
// `f32::to_bits` is const-evaluated, so these are the same floats the literals
// were; `hocevar_p_is_bit_identical_to_the_literal_form` pins that, and is the
// test to keep green if this is ever reverted.
const P_Z_IF: u32 = (-1.0f32).to_bits();
const P_W_IF: u32 = (2.0f32 / 3.0).to_bits();
const P_Z_ELSE: u32 = (0.0f32).to_bits();
const P_W_ELSE: u32 = (-1.0f32 / 3.0).to_bits();

/// Hocevar's `p` term: the green/blue ordering step.
///
/// Split out of `rgb2hsv` only so the constant-pool workaround above can be
/// tested against the literal form it replaced.
#[inline(always)]
fn hocevar_p(g: f32, b: f32) -> [f32; 4] {
let g_lt_b = g < b;
[
if g_lt_b { b } else { g },
if g_lt_b { g } else { b },
f32::from_bits(if g_lt_b { P_Z_IF } else { P_Z_ELSE }),
f32::from_bits(if g_lt_b { P_W_IF } else { P_W_ELSE }),
]
}

/// Rust-facing form.
#[inline]
fn rgb2hsv(r: f32, g: f32, b: f32) -> [f32; 3] {
const EPSILON: f32 = 1.0 / 65536.0;

let p: [f32; 4] = if g < b {
[b, g, -1.0, 2.0 / 3.0]
} else {
[g, b, 0.0, -1.0 / 3.0]
};
let p = hocevar_p(g, b);
let q: [f32; 4] = if r < p[0] {
[p[0], p[1], p[3], r]
} else {
Expand Down Expand Up @@ -123,6 +156,56 @@ mod tests {
}
}

/// The pre-workaround form of `hocevar_p`, kept verbatim as the oracle.
///
/// This is the code the Xtensa constant-pool workaround replaced. It stays
/// here so the rewrite's bit-equivalence is *pinned* rather than argued —
/// these builtins are shared with rv32 and wasm, where the original form
/// compiles fine and any drift would be a silent behaviour change.
fn hocevar_p_literal(g: f32, b: f32) -> [f32; 4] {
if g < b {
[b, g, -1.0, 2.0 / 3.0]
} else {
[g, b, 0.0, -1.0 / 3.0]
}
}

#[test]
fn hocevar_p_is_bit_identical_to_the_literal_form() {
// A grid that crosses g == b in both directions, plus the cases where
// a sign-of-zero or NaN difference would hide: -0.0/+0.0 must select
// the same lanes, and `g < b` is false for any NaN operand.
const SAMPLES: [f32; 11] = [
-1.0,
-0.75,
-0.5,
-0.25,
0.0,
0.25,
0.5,
0.75,
1.0,
-0.0,
f32::NAN,
];

for &g in SAMPLES.iter() {
for &b in SAMPLES.iter() {
let got = hocevar_p(g, b);
let want = hocevar_p_literal(g, b);
for k in 0..4 {
assert_eq!(
got[k].to_bits(),
want[k].to_bits(),
"lane {k} for g={g} b={b}: {:#010x} vs {:#010x}",
got[k].to_bits(),
want[k].to_bits()
);
}
}
}
}

#[test]
fn alpha_passes_through_the_vec4_form_untouched() {
let mut out = [f32::NAN; 4];
Expand Down
Loading
Loading