Skip to content

refactor(xt): model flash — the builtins image is firmware, not JIT output - #247

Merged
Yona-Appletree merged 12 commits into
mainfrom
claude/f32-xt-emu-flash-model
Aug 1, 2026
Merged

refactor(xt): model flash — the builtins image is firmware, not JIT output#247
Yona-Appletree merged 12 commits into
mainfrom
claude/f32-xt-emu-flash-model

Conversation

@Yona-Appletree

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

Copy link
Copy Markdown
Member

Merges after #246 (stacked on claude/f32-xtensa-fp-constpool-workaround). claude/*-based PRs get no CI until retargeted to main — only the CLA check runs here, so the gates below were run locally.

The change

On real ESP32 silicon, firmware .text executes from flash through the cache (XIP; boot logs show vaddr=0x4200_0020 map). Only JIT-produced code lives in SRAM. The host emulator collapsed both into one modeled SRAM code region, with three consequences:

  • The builtins image and JIT'd shader code competed for the same 128 KiB. Turning on float-f32 left 931 bytes for shader code and took xtn.q32 from 849/849 files to 522/849.
  • That produced a wrong product conclusion — "f32 can't fit on classic ESP32" — by conflating the shader-JIT budget with the builtins' footprint.
  • It hid a bug class: a shader→builtin call spans SRAM→flash, far outside call8's ±512 KiB. An accidentally-direct call would pass emulation and fail on hardware.

So the model now matches the hardware: BoardProfile gains read-only IROM/DROM windows plus an internal-SRAM region for an image's .data/.bss, the builtins image links as flash-resident firmware, and the whole SRAM code region is the shader's (128 KiB S3 / 92 KiB classic — unchanged in size; the point is that nothing is resident in front of it). --features float-f32 is now unconditional for the Xtensa builtins image and LP_XT_BUILTINS_F32 is gone.

Address map

base provenance
S3 IROM 0x4200_0000 esp-hal 1.1.1 (MIT/Apache-2.0) ld/esp32s3/memory.x irom_seg; corroborated by lpc-shared's backtrace validator (0x4200_0000..0x4400_0000) and S3 boot logs
S3 DROM 0x3C00_0000 same file, drom_seg
S3 image DRAM 0x3FCA_8000 measured SRAM1 window, free span between the code and stack regions
classic IROM 0x400D_0000 esp-hal 1.1.1 ld/esp32/memory.x irom_seg
classic DROM 0x3F40_0000 same file, drom_seg
classic image DRAM 0x3FFD_0000 SRAM2 (dram_seg), above the measured stack region

Lengths are the model's (256 KiB IROM / 64 KiB DROM / 32 KiB DRAM), not hardware's — regions are allocated per host call. Every flash number is labelled documented/observed in board.rs, not silicon-probed like the SRAM ones around it. No GPL source consulted.

SHARED_DBUS_BASE moved 0x3F40_00000x3000_0000: the old base is classic's DROM base, which the existing add_shared overlap assertion caught. The shared-memory ADR is amended accordingly — its own text predicted exactly this rot.

Image after the change: .text 113,757 B at 0x4200_0000 (43% of the modeled IROM), .rodata 16,156 B at 0x3C00_0000 (25% of DROM), no .data/.bss.

Oracles (all green, none regenerated)

gate result
scripts/filetests.sh -t xtn.q32 849/849 files, 6336/6336 tests with float-f32 unconditional (was 522/849)
scripts/filetests.sh -t xtlpn.q32 849/849 files, 6385/6385 tests
cargo test -p lp-xt-emu (incl. fp_conformance) green — raw-payload suites run in the code region and are untouched
lpvm-native/tests/xt_pipeline_f32.rs 16/16, including a_builtin_routed_float_op_resolves_and_runs against IROM addresses
just test-xt-host green
just check exit 0
just test exit 0

Fidelity test landed

lp-xt/lp-xt-emu/tests/call_range.rs — a direct CALL8 toward a flash builtin traps instead of arriving, the JIT's l32r+callx8 form arrives, and flash is read-only to the guest but writable through the loader path.

It also recorded a finding worth having: on classic ESP32, IROM sits only ~192 KiB above the SRAM1 I-bus window, comfortably inside CALL8's range — a direct call 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.

Docs

  • docs/defects/2026-08-01-xt-f32-builtins-exhaust-the-emulator-code-region.mdfixed, with the corrected classic-ESP32 conclusion (92 KiB bounds shader JIT code only; classic f32 viability is a flash-budget + unprobed-LX6-FPU question, not an SRAM one) and a new model-conflation defect class for the shape.
  • docs/adr/2026-08-01-host-emulator-models-flash.md — new, records why the three alternatives the defect proposed all preserve the wrong map.
  • 2026-07-30-xtensa-host-shared-memory amended; 2026-07-30-isa-parameterized-host-emu-engine's "~28 KiB shader budget" follow-up closed with a note that its proposed fix was the wrong one.

Yona-Appletree and others added 12 commits July 31, 2026 23:33
…pool

The esp Xtensa backend cannot select `XtensaISD::PCREL_WRAPPER` over a
`TargetConstantPool`, so any `[N x float]` constant LLVM materialises as a
constant-pool entry fails to compile for `xtensa-esp32s3-none-elf` at every
optimisation level. `lps-builtins/float-f32` tripped it in `rgb2hsv`, whose
`p.zw` constant lane pair LLVM emits as `[2 x float] [0.0, -1.0]`.

Select the integer bit patterns and bitcast after the fact, so the constants
never reach the float constant pool. `f32::to_bits` is const-evaluated, so the
values are the same floats the literals were — pinned lane-by-lane against the
literal form it replaced by a new test, since these builtins are shared with
rv32 and wasm where the original compiles fine.

The defect's guess that snoise2's gradient LUT was the trigger was wrong; it
and every other suspect (`worley2/3`, `gnoise3_tile`, `psrdnoise2/3`,
`snoise3`) compile unchanged. One site was enough.

Defect: docs/defects/2026-08-01-xtensa-backend-cannot-select-float-constant-pool.md

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
With the constant-pool workaround in place the image builds, so drop the
LP_XT_BUILTINS_F32 opt-in P4 added as a stopgap and assert the f32 symbol
count unconditionally, matching scripts/build-builtins.sh (rv32).

Un-ignore xt_pipeline_f32's builtin-routing case. It needed more than the
ignore attribute removed: the file's link_blob resolves only the module's own
functions and loads at the code region's base, where the builtins image
already lives, so a sym_call to __lp_lpir_ffloor_f32 could never resolve.
Added a builtins-linked run path with rt_emu::xt_image's layout — image .text
first, shader 4-aligned after it — hand-rolled rather than calling
build_xt_image, which sits behind the `emu` feature the file's other 15 tests
do not need. 16/16 green, ffloor(3.75) == 3.0 from the real builtin.

Record what the defect actually was: one site (rgb2hsv), not the LUT family,
and the reported constant belonged to a different function than the one that
failed.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…raint

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…onger fits

Reverts the gate flip from the previous commit, on measurement. Making
--features float-f32 the default took the xtn.q32 filetest suite from 849/849
files to 522/849, and the failures are link failures, not wrong answers.

Cause is capacity, not codegen. link.ld gives .text 112 KiB (114,688 B) of the
emulator's 128 KiB code region. Q32-only .text is 66,300 B; with float-f32 it
is 113,757 B — the image links with 931 bytes to spare, and
rt_emu::xt_image places compiled shader code in exactly that gap. Small
shaders fit, everything else fails to link.

That is a second, independent defect from the constant-pool one, and fixing
the first is what exposed it: filed as
docs/defects/2026-08-01-xt-f32-builtins-exhaust-the-emulator-code-region.md
with the measurements and three candidate resolutions.

So the family stays behind LP_XT_BUILTINS_F32=1, and the un-ignored
builtin-routing test skips loudly when the resident image lacks the f32
symbols rather than failing — one blocked capability should not redden a
suite. Verified both ways: skips on the default image, and passes
(ffloor(3.75) == 3.0 through the real __lp_lpir_ffloor_f32) on an image built
with LP_XT_BUILTINS_F32=1.

The constant-pool defect's record is corrected to say the workaround shipped
and the build it blocked now succeeds, while noting the default flip is
blocked elsewhere.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
On every ESP32 the application's code executes from flash through the
cache (XIP); only JIT-produced code lives in SRAM. The emulator collapsed
both into one SRAM code region, so a resident image competed with JIT'd
code for the same bytes and a shader->builtin call looked local when on
silicon it spans tens of megabytes.

BoardProfile gains an executable flash window (Identity alias, read-only),
a data-only flash window, and the internal-SRAM region an image's
.data/.bss belongs in. Bases are hardware, sourced from esp-hal 1.1.1's
MIT/Apache-2.0 ld/esp32{,s3}/memory.x and corroborated in-repo; lengths
are the model's, since regions are allocated per host call.

Memory gains add_rom (stores fault), a fallible loader write path
(try_load_bytes/try_zero, since flash is not guest-writable), and a
mutual-disjointness assertion on every region rather than only on the
shared window. SHARED_DBUS_BASE moves 0x3F40_0000 -> 0x3000_0000: the old
base IS classic's DROM base, which that assertion caught.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
.text -> IROM (0x42000000), .rodata -> DROM (0x3c000000), .data/.bss ->
internal SRAM. LMA = VMA and no boot-time copy machinery: the emulator's
loader writes each PT_LOAD straight to its p_vaddr, the way a flasher plus
a configured cache MMU would leave memory.

rt_emu/xt_image places every segment by classifying its p_vaddr against
the board profile, so the SRAM code region is now entirely the shader's
(128 KiB S3 / 92 KiB classic, unchanged) and the largest compilable shader
no longer depends on the size of the builtins. GuestImage grows a
`regions` list for the image's flash/SRAM buffers, each trimmed to the
bytes in use since they are reloaded on every host call.

float-f32 becomes unconditional for the image: the capacity objection was
an artifact of modeling flash-resident firmware as if it shared SRAM with
the JIT. The build script now also asserts the segment addresses, so a
link.ld regression fails at the source rather than as a loader error.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
A CALL8 carries an 18-bit signed word displacement (+/-512 KiB), so on the
S3 it cannot name a flash builtin from the SRAM code region — 29 MB away —
which is why the emitter reaches every target through a literal slot and
CALLX8. While both lived in one region that constraint was invisible: an
accidentally-direct call would have passed on the host and failed on
silicon.

Four tests: the displacement arithmetic, an executed direct CALL8 that
traps instead of arriving, the JIT's indirect form arriving, and flash
being read-only to the guest but writable through the loader path.

Recorded rather than assumed: on classic ESP32 flash IROM (0x400d0000)
sits ~192 KiB above the SRAM1 I-bus window, so a direct call there IS in
range. The emitter must stay indirect because the S3 requires it, not
because every Xtensa target does — pinned so it is not rediscovered.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…usion

The defect presented as capacity and was not: the emulator modeled
flash-resident firmware as if it lived in SRAM, so the builtins image and
JIT'd shader code contended for bytes they never share on hardware. All
three candidate resolutions it proposed (widen the region, split the
region, split the feature) would have preserved the wrong map.

New defect class `model-conflation` for exactly that shape, plus the two
2026-08-01 entries the index was missing.

Corrected classic-ESP32 conclusion, recorded because the old entry
invited the wrong one: 92 KiB bounds JIT'd shader code only. Classic f32
viability is a flash-budget plus unprobed-LX6-FPU question, not an SRAM
one.

Also: lp-xt-emu README gains a flash section, the shared-memory ADR is
amended for the moved base (its own text predicted the rot), and the
isa-parameterized-engine ADR's "~28 KiB shader budget" follow-up is closed
with a note that its proposed fix was the wrong one.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
builtins_land_inside_the_modeled_code_region asserted exactly what is now
wrong. Inverted: every builtin must be in the modeled IROM window and none
may be in the SRAM code region, which belongs to JIT'd shader code.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
… try_zero

assert_free took a formatted String per view, on a path that runs five
times per emulator and an emulator is built per host call. The labels are
&str now, so nothing is formatted unless the assertion fires. try_zero
delegates to try_load_bytes in chunks rather than duplicating its
shared-region handling.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Records why the builtins image moved out of the SRAM code region and why
the three alternatives the defect proposed (widen the region, split the
region, split the f32 feature) were all rejected: each preserves a map
that does not match the device.

Also records the graded provenance rule for the new addresses (bases are
hardware from esp-hal's MIT/Apache-2.0 memory.x, lengths are the model's)
and the classic-ESP32 finding that a direct CALL8 to flash IS in range
there, so the emitter's indirection is an S3 requirement rather than a
universal Xtensa one.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@Yona-Appletree
Yona-Appletree changed the base branch from claude/f32-xtensa-fp-constpool-workaround to main August 1, 2026 16:03
@Yona-Appletree
Yona-Appletree marked this pull request as ready for review August 1, 2026 16:04
@Yona-Appletree
Yona-Appletree merged commit 08779e0 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