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
10 changes: 8 additions & 2 deletions docs/adr/2026-07-30-isa-parameterized-host-emu-engine.md
Original file line number Diff line number Diff line change
Expand Up @@ -134,9 +134,15 @@ type parameter, not a trait object over emulators, and not a second engine.
word-mirrored, so `build_xt_image` rejects it explicitly. A classic host target
needs that layout reworked. **Revisit when:** an LX6 host execution target is
wanted.
- Shader code shares the 112 KiB text region with ~84 KiB of builtins, leaving
- ~~Shader code shares the 112 KiB text region with ~84 KiB of builtins, leaving
~28 KiB. Overflow is an explicit error naming the budget. **Revisit when:** a
real shader hits it — the fix is the linker script's split, not the host region.
real shader hits it — the fix is the linker script's split, not the host
region.~~ **Closed 2026-08-01, and the stated fix was the wrong one.** Both
now live where they live on the device: the builtins image links as
flash-resident firmware (IROM/DROM) and the shader gets the *whole* SRAM code
region — 128 KiB, unchanged in size. Splitting the linker script differently
would have preserved the model error. See
`docs/defects/2026-08-01-xt-f32-builtins-exhaust-the-emulator-code-region.md`.
- No measured Xtensa cycle model; `CycleModel::InstructionCount` remains the honest
default (rv32 has a measured C6 table). **Revisit when:** the perf column needs
Xtensa numbers to mean something.
14 changes: 13 additions & 1 deletion docs/adr/2026-07-30-xtensa-host-shared-memory.md
Original file line number Diff line number Diff line change
@@ -1,11 +1,23 @@
# ADR: Host-shared guest memory for the Xtensa emulator

- **Status:** Accepted
- **Status:** Accepted (amended 2026-08-01 — the address changed, the decision did not)
- **Date:** 2026-07-30
- **Deciders:** Photomancer
- **Supersedes:** None
- **Superseded by:** None

> **Amendment, 2026-08-01.** `SHARED_DBUS_BASE` is now **`0x3000_0000`**, not
> `0x3F40_0000`. This ADR's own reasoning predicted it: "the assertion, not the
> address, is the decision that matters. A comment claiming an address is free
> rots the moment a profile changes." Both profiles gained modeled flash windows
> and `0x3F40_0000` turned out to *be* classic's DROM base (esp-hal
> `ld/esp32/memory.x`, `drom_seg`). The `add_shared` assertion failed at the
> moment of the mistake, exactly as designed. The replacement is chosen on
> stronger ground than "no profile maps it": `0x3000_0000` is below the lowest
> address either chip's data bus decodes, so no future profile can reach it.
> Everything else below stands as written; read `0x3F40_0000` as the historical
> value.

## Context

The host-side shader execution path (`lpvm-native`'s `rt_emu`) does not copy data
Expand Down
135 changes: 135 additions & 0 deletions docs/adr/2026-08-01-host-emulator-models-flash.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,135 @@
# ADR: The host Xtensa emulator models flash, and firmware links into it

- **Status:** Accepted
- **Date:** 2026-08-01
- **Deciders:** Photomancer
- **Supersedes:** None
- **Superseded by:** None

## Context

`lp-xt-emu` modeled one executable region per board — SRAM1, 128 KiB on the S3
and 92 KiB on classic — and everything went in it: the linked `lps-builtins-xt-app`
image *and* the shader code `rt_emu` JIT-compiles. `lps-builtins-xt-app/link.ld`
split that region 112 KiB text / 16 KiB data, and `rt_emu::xt_image` placed
compiled shader functions in whatever IRAM the image's `.text` left over.

On silicon neither of those things is true. Every ESP32 executes its application
`.text` **from flash through the cache** (XIP; the boot log's `vaddr=0x4200_0020
map` lines are the cache MMU being programmed). Internal SRAM holds the stack,
`.data`/`.bss`, the heap, and — for this product — code a JIT produced at
runtime. Firmware and JIT output do not share an address region; they are not
even in the same address quadrant.

Three consequences arrived together on 2026-08-01, when `--features float-f32`
was tried as the Xtensa builtins image's default
(`docs/defects/2026-08-01-xt-f32-builtins-exhaust-the-emulator-code-region.md`):

1. The f32 family took `.text` from 66,300 B to 113,757 B, leaving **931 bytes**
of the region for shader code. The `xtn.q32` filetest suite fell from 849/849
files to 522/849 on link failures. The image and the shader were competing
for bytes that on hardware they never share.
2. It produced a wrong product conclusion — read as "f32 doesn't fit on classic
ESP32" — by conflating the shader-JIT budget with the builtins' footprint.
Classic's 92 KiB bounds JIT'd shader code and says nothing about whether the
f32 builtins fit, because those are flash-resident there too.
3. It hid a live bug class. A shader→builtin call spans SRAM→flash, ~29 MB on
the S3, far outside `CALL8`'s ±512 KiB displacement — which is why the
emitter reaches every call target through a literal-pool slot and `CALLX8`.
In a single small region an accidentally-direct call would have been *in
range on the host* and out of range on silicon: passing tests, failing
hardware.

The defect entry proposed three resolutions: widen the modeled region, give the
shader its own region, or split the f32 builtin feature so less of it links.
**All three preserve the wrong map**, and the first and third also spend real
budget to do so.

## Decision

Model flash. `BoardProfile` gains, per chip:

| | ESP32-S3 | classic ESP32 |
| --- | --- | --- |
| IROM — flash instruction window, read-only, `AliasRule::Identity` | `0x4200_0000` | `0x400D_0000` |
| DROM — flash data window, read-only, not fetchable | `0x3C00_0000` | `0x3F40_0000` |
| image `.data`/`.bss` — plain internal SRAM | `0x3FCA_8000` | `0x3FFD_0000` |

`lps-builtins-xt-app` links as flash-resident firmware into those windows.
`rt_emu::xt_image` places each `PT_LOAD` segment by classifying its `p_vaddr`
against the profile, and compiled shader code gets the **whole** SRAM code
region. Guest stores into either flash window fault; the loader paths write them
anyway, because putting an image into flash is what a flasher does.

Three properties are deliberate:

1. **The SRAM code region did not grow.** 128 KiB on the S3, 92 KiB on classic,
unchanged. The fix is not more room; it is that the builtins left. Widening
it would have been the same mistake with a bigger number, and it would have
made the modeled region stop corresponding to anything.
2. **Bases are hardware; lengths are the model's.** The S3's real `irom_seg` is
32 MB and the emulator allocates its regions once per host call, so the
modeled windows are 256 KiB / 64 KiB / 32 KiB — sized to the image with
headroom. A segment past the end is a loud load error, never a silent wrap.
3. **Provenance is graded and stated.** `board.rs`'s SRAM numbers are
hardware-measured (FINDINGS E2, the classic C1–C5 ladder). The flash numbers
are *documented and observed*: esp-hal 1.1.1's MIT-or-Apache-2.0
`ld/esp32{,s3}/memory.x`, corroborated by `lpc-shared`'s backtrace validator
and by S3 boot logs. They are labelled as the weaker grade rather than blended
in, because the difference matters if one turns out wrong. No GPL source
(binutils, GCC, QEMU) was consulted — see
`docs/adr/2026-07-29-license-provenance-discipline.md`.

`--features float-f32` becomes the Xtensa builtins image's unconditional
default; `LP_XT_BUILTINS_F32` is deleted.

## Consequences

**Good.** `xtn.q32` is 849/849 files (6336/6336 tests) *with* f32, and
`xtlpn.q32` 849/849 (6385/6385). The largest compilable shader no longer depends
on the size of the builtins — the error message that used to name the builtins'
footprint now names the shader's own size. The classic-ESP32 f32 question is
correctly reframed as a flash-budget and unprobed-LX6-FPU question. And the
SRAM→flash call reach is now testable, which `lp-xt/lp-xt-emu/tests/call_range.rs`
does.

That test also found something worth having: on **classic**, IROM sits only
~192 KiB above the SRAM1 I-bus window, comfortably inside `CALL8`'s range. A
direct call from JIT'd code to a builtin would work there. The emitter must stay
indirect because the S3 requires it, **not** because every Xtensa target does —
pinned so it is not "optimized away" after testing on one board.

**Costs.** Every emulator now installs five regions instead of two (~352 KiB
more zeroed allocation per host call, `alloc_zeroed`-backed) and the per-call
load copies the image's ~130 KiB of flash instead of the region's 128 KiB — a
wash in bytes. `Memory` now asserts every region is mutually disjoint in both
address views, not just the shared window; that assertion immediately earned its
keep by catching `SHARED_DBUS_BASE = 0x3F40_0000`, which **is** classic's DROM
base. It moved to `0x3000_0000`, below the lowest address either chip's data bus
decodes (`docs/adr/2026-07-30-xtensa-host-shared-memory.md`, amended).

**Unchanged.** `lp-xt/fixtures/link.ld` still links its guests into the SRAM code
region: those are raw payloads a runner writes into RAM, which is a different
thing from a flash-resident image. Every raw-payload suite — `fp_conformance`,
`conformance`, the metrics and trap tests — is untouched, and `lp-xt-elf`'s
`DATA_BASE = CODE_DBUS_BASE + CODE_REGION_LEN/2` convention is untouched with it.
rv32 is untouched: `GuestImage` gained an empty `regions` list there.

## Alternatives rejected

- **Widen the modeled SRAM code region.** Cheapest, and it makes the number stop
corresponding to hardware. The 128 KiB figure is the emulator's claim about
what a device has available for JIT output; inflating it to fit a resident
image would quietly retire that claim.
- **Give the shader a second SRAM region, builtins keeping the first.** Removes
the coupling without fixing the map. The call-range bug class would still be
invisible, because both regions would still be a short hop apart.
- **Split the `float-f32` feature** so only the arithmetic family M7 D4 routes
to is linked, leaving the generative-noise builtins (most of the 47 KB)
rv32-only. This spends a real feature contract to buy back space that the
device never needed spent, and it would have to be re-litigated the next time
the image grows.
- **Model flash at its true length (32 MB windows).** Correct and unaffordable:
regions are `Vec`-backed and allocated per host call. Truncating the length
while keeping the base is the honest compromise, and it is documented as such
on `MODELED_IROM_LEN`.
2 changes: 1 addition & 1 deletion docs/adr/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -84,7 +84,7 @@ holds the full context.
| Compute-tick / shader-init fuel blame route (traps abort bounded but bypass the panic/blame ledger) | `2026-07-20-lpvm-native-fuel` | Runaway compute shaders show up in practice |
| Budgeted/async shader compile (spread the ~194 ms device compile across frames instead of one long frame per apply) | `2026-07-14-shader-auto-apply` | The per-apply frame stall matters in practice |
| Classic-ESP32 (LX6) host execution: the Xtensa guest image is one flat code-region buffer, correct only under an **offset** I-bus alias; classic's SRAM1 alias is word-mirrored and `build_xt_image` rejects it | `2026-07-30-isa-parameterized-host-emu-engine` | An LX6 host execution target is wanted |
| Xtensa shader-code budget: ~28 KiB free in the 112 KiB text region after ~84 KiB of builtins; overflow is an explicit error | `2026-07-30-isa-parameterized-host-emu-engine` | A real shader hits it — the fix is `lps-builtins-xt-app/link.ld`'s split, not the host region |
| ~~Xtensa shader-code budget: ~28 KiB free in the 112 KiB text region after ~84 KiB of builtins~~ — **closed 2026-08-01**: the builtins image is flash-resident, so the shader has the whole 128 KiB SRAM code region | `2026-07-30-isa-parameterized-host-emu-engine` | Closed; the budget is now the shader's own size |
| Measured Xtensa cycle model (`CycleModel::InstructionCount` is the honest default; rv32 has a measured C6 table) | `2026-07-30-isa-parameterized-host-emu-engine`; `2026-07-28-emu-core-crate-family` | The filetest perf column needs Xtensa numbers to mean something |
| Sim-worker recovery layer 2 (timeout-streak detection → terminate+respawn preserving the unsaved-overlay mirror; NotResponding sim roster card; PreviewHost in-flight deadline) | `2026-07-23-sim-wasm-fuel`; `2026-07-24-runtime-pool` | Next sim-runtime lifecycle work (the pool landed without it; requirements in the M4 plan notes) |
| Live sim-card frames: core-owned present service for pool sessions sharing PreviewHost's CPU blit seam + the gallery routing rule (sim frames instead of a preview lease) | `2026-07-24-runtime-pool` | The roadmap's live-thumbnails item is prioritized |
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,109 @@
---
status: fixed
found: 2026-08-01 # how: filetests (xtn.q32 dropped 849/849 -> 522/849 on the gate flip)
fixed: this change # the host emulator now models flash; the image is firmware
area: lp-xt/lps-builtins-xt-app, lp-shader/lpvm-native (rt_emu/xt_image), lp-xt-emu
class: model-conflation # presented as `capacity`; that was the symptom, not the class
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.

**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.

## Root cause — the capacity number was a symptom

Everything above is accurate and none of it is the cause. The cause is that
**the emulator modeled flash-resident firmware as if it lived in SRAM**.

On real ESP32 silicon the application's `.text` executes from flash through the
cache (XIP; boot logs show `vaddr=0x4200_0020 map` segments). Only code a JIT
produces at runtime lives in SRAM. The emulator had one modeled SRAM code
region and put both in it, so the builtins image and compiled shader code were
competing for bytes that on hardware they never share. "112 KiB of `.text`
leaves 931 bytes" was a true statement about a false map.

**Fix** — `BoardProfile` gained read-only IROM/DROM windows at each chip's real
flash-cache bases, plus an internal-SRAM region for an image's `.data`/`.bss`;
`lps-builtins-xt-app` links as flash-resident firmware; `rt_emu::xt_image`
places each segment by classifying its `p_vaddr`, and the compiled shader gets
the **whole** SRAM code region. The region did not grow — 128 KiB on the S3,
92 KiB on classic, unchanged — the builtins simply left it.

`--features float-f32` is now unconditional for the Xtensa builtins image;
`LP_XT_BUILTINS_F32` is gone.

**Regression coverage** — `scripts/filetests.sh -t xtn.q32` is back to 849/849
files (6336/6336 tests) *with* float-f32, and `xtlpn.q32` to 849/849
(6385/6385); `scripts/build-builtins-xt.sh` now asserts the image's segment
addresses, so a `link.ld` regression fails at the source rather than as a
loader error 300 files later; `lp-xt/lp-xt-emu/tests/call_range.rs` covers the
bug class below.

## The corrected classic-ESP32 conclusion

The reading this defect invited — "f32 doesn't fit on classic ESP32" — was
**wrong, and wrong in a way worth naming**: it conflated two unrelated budgets.

Classic's 92 KiB code region bounds **JIT'd shader code only**. It says nothing
about whether the f32 builtins fit, because on classic as on the S3 the
builtins are flash-resident (`irom_seg` at `0x400D_0000`, 3 MB). Classic f32
viability is therefore a **flash-budget** question plus an **unprobed LX6 FPU**
question, not an SRAM one — and the flash budget is the one with room to spare.

## Lesson

A capacity failure reported in the units of the thing that overflowed is
usually a modeling failure reported in the wrong units. The number (931 bytes)
was measured, reproducible, and led straight to three candidate resolutions —
widen the region, split the region, split the feature — **all three of which
would have preserved the wrong map**. The question that dissolved it was not
"how do we make it fit" but "why are these two things in the same region at
all, when on the device they are not".

The same model gap was also hiding a live bug class. A shader→builtin call
spans SRAM→flash, tens of megabytes on the S3 and far outside `call8`'s
±512 KiB — which is why the JIT patches indirect calls. In a single small
region an accidentally-direct call would have been *in range on the host* and
out of range on silicon: passing tests, failing hardware. It is now pinned in
`call_range.rs`, along with the finding that classic's IROM sits only ~192 KiB
above its SRAM1 I-bus window, so a direct call *would* work there. The emitter
must stay indirect because the S3 requires it, not because every Xtensa target
does.
Loading
Loading