velxio/docs/wiki/esp32-spi-display-performan...

399 lines
19 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

# ESP32 QEMU display performance — the SPI latency bottleneck
> How the ESP32 Doom / ILI9341 raycaster went from **~0.04 FPS** (one full
> 320×240 redraw every ~25 s) to **~1 FPS (≈2037×)** by collapsing the
> per-event SPI **C→Python ctypes crossings**, plus a full record of what was
> tried, what failed, and why — so nobody re-runs the dead ends.
>
> Bottom line: ESP32 display slowness in Velxio is **not** the QEMU TCG compute,
> **not** the libqemu `-O` level, **not** the backend→frontend WebSocket
> transport, and **not** the arduino-esp32 core version. It is the **number of
> times QEMU calls back into the Python worker per SPI event** (one per byte,
> plus one per chip-select toggle). Reduce that count and the emulation speeds
> up almost linearly.
Measured 2026-06 with the chrome-devtools MCP + py-spy on the live `esp32-doom`
example (Velxio frontend + backend from this repo). All FPS numbers are
**wall-clock game FPS** = `spi_batch bytes received / 153600` (320×240×16bpp =
one full ILI9341 frame) over a fixed 2530 s steady-state window.
---
## Table of Contents
1. [The pipeline (where the bytes go)](#1-the-pipeline)
2. [Symptom and measurement method](#2-symptom-and-measurement)
3. [Dead ends — what we ruled out and why](#3-dead-ends)
4. [The real bottleneck — per-event C→Python crossings](#4-the-real-bottleneck)
5. [Fix #1 — batch SPI data in C (≈5×)](#5-fix-1-batch-spi-data)
6. [Fix #2 — gate the chip-select crossings (≈6× more)](#6-fix-2-gate-cs)
7. [Results](#7-results)
8. [Things that must NOT be done](#8-do-not)
9. [Remaining headroom](#9-remaining-headroom)
10. [Build & test playbook](#10-build-and-test-playbook)
11. [Key files and symbols](#11-key-files)
12. [Open issues / regression notes](#12-open-issues)
---
## 1. The pipeline
The ESP32 family runs inside the forked `qemu-lcgamboa` (loaded as
`libqemu-xtensa.dll` / `.so` via ctypes from `esp32_worker.py`, a per-sim
subprocess). A TFT redraw flows like this:
```
guest firmware (Adafruit_ILI9341 over hardware VSPI)
└─ writes the SPI peripheral FIFO / data register
└─ esp32_spi.c (the SPI controller model)
└─ ssi_transfer() per byte ── SSI bus ──► PICSIMLAB_SPI_transfer()
└─ picsimlab_spi_event(id, byte<<8) ← C function pointer
└─ ctypes/libffi trampoline ──► Python _on_spi_event()
└─ buffer the byte; flush as a base64 `spi_batch`
└─ WebSocket ──► frontend ILI9341 decoder ──► canvas
```
A full 320×240×16bpp frame is **153,600 bytes**. The crucial fact: every one of
those bytes was crossing the **C→Python boundary** (a libffi closure call into
the worker), **153k times per frame**. On top of that, the SPI peripheral
toggles **chip-select** around each transaction, and each CS toggle was *also*
a separate C→Python crossing (`spi_cs_irq_handler` → `picsimlab_spi_event(..|0x01)`).
The frontend ILI9341 decoder (`frontend/src/simulation/parts/ComplexParts.ts`)
needs the **DC pin** (command vs data) interleaved with the byte stream; DC
arrives as a separate `gpio_change` WebSocket message. Keeping the byte stream
and the DC stream correctly ordered on the single WS channel is what makes the
flush timing load-bearing (see §8).
---
## 2. Symptom and measurement
**Symptom:** the `esp32-doom` raycaster (a deliberate full-screen-redraw
benchmark) painted a frame column-by-column over ~25 s — visually ~0.04 FPS.
The user perceives "muy lento".
**Method (reproducible):**
- Front + back running locally; load `/example/esp32-doom`, click **Run**.
- In the page, wrap `CanvasRenderingContext2D.prototype.putImageData` to count
blits, and wrap `window.WebSocket` to tally message types and sum `spi_batch`
base64 payload bytes.
- **wall-clock game FPS** = `Δ(spi_batch bytes) / 153600 / Δt`. This is robust
because the Doom redraws the whole screen every frame, so bytes-per-frame is
constant regardless of the frontend's flush coalescing.
- For where-the-time-goes: `py-spy record --native --pid <worker>` (the worker
is `python … esp32_worker.py`), plus temporary `fprintf(stderr,…)` /
`_log()` counters in the C and Python hot paths.
**lcgamboa singleton gotcha:** the ctypes-loaded libqemu keeps process-global
state and `qemu_init` is one-shot. Running the sim **twice in one backend
process** corrupts the SPI peripheral (CS events stop firing → black screen).
To measure honestly: deploy the DLL → **restart the backend** (fresh process) →
**reload the page****Run once** → read the FPS. Never trust results after
repeated Run/Stop cycling in one backend lifetime.
---
## 3. Dead ends
Three plausible hypotheses were each implemented, measured, and **gave zero
gain**. Documented here so they are not retried.
### 3.1 "The synchronous `_emit` blocks QEMU on the stdout pipe" → no gain
`_emit` does `sys.stdout.write(json.dumps(obj)); flush()` under a lock, on the
QEMU thread. Hypothesis: a full pipe stalls `qemu_main_loop()` inside `flush()`
(the rebind note in `esp32_worker.py::main()` worries about exactly this for
UART). Fix tried: rewrite `_emit` to enqueue onto a `queue.Queue` drained by a
single writer thread, so producers never touch stdout.
**Result: 0.040 → 0.039 FPS. Nothing.** The frontend drains the pipe fast
enough that it never fills, so the flush never actually blocked. Re-tested again
*after* the batching fixes (when event rates were higher): still nothing — the
JSON/stdout work was never the bottleneck. **Reverted.**
### 3.2 "The shipped libqemu is a debug `-O0` build" → no gain
True observation: both build scripts pass `--enable-debug`
(`build_libqemu-esp32.sh:20`, `build_libqemu-esp32-win.sh:52`), which in this
QEMU (`configure:770-775`) forces `-Doptimization=0` **and** `CONFIG_DEBUG_TCG`.
So the shipped DLL is an unoptimized build of the emulator runtime. Fix tried:
rebuild release — drop `--enable-debug`, keep `--enable-debug-info`, add
`--disable-qom-cast-debug`. Verified the result: **1515 objects at `-O2`, zero
`-O0`, no `CONFIG_DEBUG_TCG`**, correct 58.6 MB DLL loaded by the worker, worker
CPU-bound at ~1.5 cores.
**Result: 0.040 → 0.041 FPS. Nothing.** Why: the time is **not in libqemu's C**.
It is in the `python312.dll` / `_ctypes.pyd` / `libffi-8.dll` boundary (see §4),
which `-O2` on libqemu cannot touch. The guest raycaster runs as TCG-JITted host
code whose speed is set by TCG's code generator, also independent of the `-O`
level. **Reverted to the pristine debug DLL** before the real fix.
> Lesson: a CPU-bound worker that does **not** speed up under `-O2` is a strong
> signal the cost is in the ctypes/Python layer, not in the emulator's C.
### 3.3 "Kill the CS storm by suppressing the per-CS flush" → BREAKS rendering
Early attempt: stop flushing the SPI byte buffer on every CS toggle and stop
emitting the CS `spi_event`. **This produced random pixels / no walls** — the
"cosas aleatorias" symptom. Reason: the per-CS flush was **load-bearing for
ordering**. The frontend decoder keys command-vs-data off the DC `gpio_change`
interleaved with the byte stream; flushing per transaction is what kept the two
streams correctly ordered. Lumping bytes from many DC phases into one batch that
lands after several DC toggles decodes as garbage. **The correct fix (§6) keeps
ordering by moving the flush to DC changes — it does not just delete it.**
---
## 4. The real bottleneck
`py-spy --native` on the worker during a Doom run (release build, with symbols):
| Frame (inclusive) | Share |
|---|---|
| `ffi_call_go` / `ffi_prep_go_closure` (libffi — the ctypes crossing) | **~62%** |
| `ssi_transfer` (libqemu — the per-byte SSI transfer) | ~23% |
| `_emit``json.dumps` → cp1252 encode | ~12% |
| guest TCG code (anonymous JIT addresses) + softmmu `helper_st*_mmu` | ~21% |
Read past the skew (py-spy fell behind on the heavy native unwind): the dominant
**non-libqemu** cost is the **C→Python boundary**, reached once per SPI byte and
once per CS toggle. Temporary counters confirmed the volume directly: **~150k
per-byte `_on_spi_event` calls per frame**, plus **~9,300 CS `spi_event`/s**.
That reconciles every dead end:
- `-O2` didn't help → the cost is in python/ctypes/libffi, not libqemu's C.
- async `_emit` didn't help → it offloads the *emit*, not the *crossing count*.
- The only lever that moves the needle is **reducing how many times QEMU calls
into Python.**
---
## 5. Fix #1 — batch SPI data
**Idea:** the SPI controller already has the whole transfer buffer in C *before*
it loops `ssi_transfer` per byte. Hand the whole write-only buffer to Python in
**one** call instead of one libffi crossing per byte.
**C side (`hw/xtensa/esp32_picsimlab.c`):** a new callback, appended as the last
field of `callbacks_t` and registered opt-in (same "only if non-NULL" pattern as
`gpio_matrix_cb`, so older workers see no change):
```c
void (*picsimlab_spi_event_batch)(const uint8_t id, const uint8_t *mosi, const int len);
```
**C side (`hw/ssi/esp32_spi.c::esp32_spi_txrx_buffer`):** before the per-byte
loop, take the fast path when the transfer is write-dominant and the bus
peripheral is the host shim:
```c
if (rx_bytes <= 1 && tx_bytes > 1 && picsimlab_spi_event_batch) {
BusState *b = BUS(s->spi);
BusChild *ch = b ? QTAILQ_FIRST(&b->children) : NULL;
if (ch && object_dynamic_cast(OBJECT(ch->child), "picsimlab_spi")) {
picsimlab_spi_event_batch(0, (const uint8_t *) buf, tx_bytes);
if (rx_bytes >= 1) ((uint8_t *) buf)[0] = 0xFF; /* dummy MISO, ignored */
return;
}
}
```
The same batching is applied to the write-only **DMA** data loops in
`esp32_spi_do_command` (gated on `addr_in == 0`).
**Worker side (`esp32_worker.py`):** `_on_spi_batch(bus_id, mosi_ptr, length)`
reads the buffer with `ctypes.string_at` and replays the per-byte side effects
in bulk — custom-chip routing, then ePaper feed, then the `spi_batch` buffer.
Registered as a new `_SPI_BATCH` `CFUNCTYPE` appended to `_CallbacksT` (same
field order as `callbacks_t`).
### The gate detail that cost ~6 rebuilds
The first version gated on `rx_bytes == 0` and **never fired** — counters showed
the batch path at 0 while the per-byte path ran 150k×/frame. The reason, found
by printing `object_get_typename` + rx/tx inside `esp32_spi_txrx_buffer`:
```
[DBG-TXRX] rx=1 tx=64 child=picsimlab_spi
```
The Doom's pixel writes arrive as **`tx=64, rx=1`**, not `rx=0`. The Adafruit /
arduino-esp32 SPI driver enables MISO with `miso_dlen=0`, which
`bitlen_to_bytes(0)` turns into **one dummy status byte** the driver ignores. So
the gate must be `rx_bytes <= 1` and write a dummy `0xFF` back for that one byte.
Also confirmed by elimination: the Doom uses the `use_cs=true` path
(`esp32_picsimlab.c:696-697`), **not** DMA (`[DBG-C]` never fired) and **not**
`xfer_32_bits` (commented out at `esp32_picsimlab.c:694-695`); and the
`object_dynamic_cast("picsimlab_spi")` always passed — the cast was never the
blocker, `rx==0` was.
### Safety
- `rx_bytes <= 1` write-only only → the guest isn't reading real MISO, so the
dummy `0xFF` is correct for displays. Genuine reads (`rx_bytes > 1`) take the
per-byte path untouched.
- `object_dynamic_cast(…, "picsimlab_spi")` → the **SPI flash** (a `w25x16` /
`gd25q*` model on SPI1, `esp32_picsimlab.c:1042-1063`) and any real on-bus
device keep the per-byte path. The host shim lives only on SPI2/SPI3
(`esp32_picsimlab.c:795-796`).
**Result: 0.04 → ~0.19 FPS (≈5×), render correct.**
---
## 6. Fix #2 — gate the chip-select crossings
After data batching, the next per-event crossing was **CS toggles** at ~9,300/s.
Each Adafruit hardware-SPI transfer toggles the peripheral CS, firing
`spi_cs_irq_handler``picsimlab_spi_event(…|0x01)` — another C→Python crossing
that the ILI9341 frontend decoder **does not even use** (it tracks DC, not CS).
**C side (`hw/xtensa/esp32_picsimlab.c`):**
```c
int picsimlab_spi_cs_events = 1; /* default ON = old behaviour */
static void spi_cs_irq_handler(void *opaque, int n, int level) {
if (picsimlab_spi_cs_events)
picsimlab_spi_event(n >> 2, ((((n & 3)<<1)|level)<<8)|0x01);
}
void qemu_picsimlab_enable_spi_cs_events(int enabled) {
picsimlab_spi_cs_events = enabled ? 1 : 0;
}
```
**Worker side (`esp32_worker.py`):**
- `_sync_cs_events()` turns CS events **OFF for pure-display sims** and **ON when
an ePaper / custom-chip SPI slave is registered** (`_epaper_state` or
`_chip_spi_runtimes` non-empty). Called after `'booted'` and at every slave
registration. No-op on older libqemu (the symbol is absent → `AttributeError`
caught).
- Because CS no longer signals transaction boundaries, the SPI-batch **flush
ordering moves into `_on_pin_change`**: flush `_spi_byte_buf` *before* emitting
any `gpio_change`. Since DC is a `gpio_change`, the byte stream stays correctly
ordered against the DC pin (this replaces what the per-CS flush used to do —
it does not delete ordering, see §3.3 / §8).
**Default-ON + worker-opt-out** is deliberate: an old worker or any sim with a CS
consumer sees the original behaviour; only pure-display sims pay nothing for CS.
**Result: 0.19 → ~1.01.5 FPS (≈2637× over baseline), render correct,
`spi_event` rate → 0 for the Doom.**
---
## 7. Results
| Stage | Wall-clock FPS | vs baseline | Notes |
|---|---|---|---|
| Baseline (shipped debug DLL) | 0.040 | 1× | ~6 KB/s pixels, 1 frame / 25 s |
| `-O2` release rebuild | 0.041 | 1× | no gain (§3.2) |
| async `_emit` | 0.039 | 1× | no gain (§3.1) |
| **+ SPI data batching** | ~0.19 | **~5×** | §5 |
| **+ CS crossing gating** | **~1.01.5** | **~2637×** | §6 |
Render verified correct each time by sampling the `wokwi-ili9341` canvas (≥97%
non-black, coherent ceiling/wall/floor scene). At the final state the frontend
blits ~10 `putImageData`/s — the display is visibly moving, not painting over
25 s. (FPS varies 2237× run-to-run with the auto-demo's wall-following load.)
---
## 8. Things that must NOT be done
- **Do not suppress the per-transaction SPI flush without moving it.** The
frontend ILI9341 decoder interleaves the DC `gpio_change` with the SPI byte
stream; some flush trigger must keep them ordered. Fix #2 moves it to
`_on_pin_change` (before each `gpio_change`). Deleting it → random pixels.
- **Do not chase `-O2` / `--disable-qom-cast-debug` / LTO for display FPS.**
Measured zero gain — the cost is the ctypes boundary, not libqemu's C. (`-O2`
is still a reasonable general build choice, just not the lever here.)
- **Do not re-add async `_emit` expecting FPS** — measured zero, twice.
- **Do not bump to arduino-esp32 v3.x for FPS.** Irrelevant to this bottleneck,
and blocked anyway by the IDF-5 cache-disable panic under lcgamboa + the
IDF-4.4.x-matched QEMU ROM blobs (see `esp32-cache-disable-runtime-crash.md`).
- **Do not measure after repeated Run/Stop in one backend** (lcgamboa singleton,
§2) — you'll get a black screen and a false negative.
---
## 9. Remaining headroom
After CS gating, the next per-event cost is the **DC pin `gpio_change`**
(~5.6k/s). This **cannot be gated** — the frontend decoder needs DC to tell
command from data. Going further would require a different approach:
- **Encode DC state into the SPI byte stream** so DC stops being a separate
per-toggle crossing/message (fold a command/data marker into `spi_batch`).
- **Decode the ILI9341 in the worker** and ship a framebuffer / dirty-rect (like
ePaper `esp32_spi_slaves.py` and the I2C `I2CWriteSink` already do). Cuts WS +
frontend cost and removes the DC dependency, but is a larger change.
- **Reduce guest work in the sketch** (lower resolution / framerate) — cheapest,
but changes the example, not the emulator.
Diminishing returns; ~30× already makes a full-screen software raycaster usable.
---
## 10. Build & test playbook
Building `libqemu-xtensa.dll` on the Windows dev box (MSYS2 MINGW64):
- **QEMU `configure:174` rejects paths with spaces** — the repo path
`…\velxio release\…` fails. Build through a **space-free junction**:
`mklink /J C:\qf "E:\…\third-party\qemu-lcgamboa"`, then build from `/c/qf`.
- **Full build:** `./configure --target-list=xtensa-softmmu,riscv32-softmmu …`
(see `build_libqemu-esp32-win.sh`) + `ninja -C build qemu-system-xtensa.exe
qemu-system-riscv32.exe`, then re-link as a DLL via the `.rsp` patch. ~20 min /
1570 objects.
- **`0xC0000142` (`STATUS_DLL_INIT_FAILED`) on `cc` right after a reboot** is a
transient resource issue from too many parallel compilers — retry with `-j4`.
- **Incremental rebuild** (one `.c` changed): do **not** `rm -rf build`; just
`ninja -C build qemu-system-xtensa.exe` + relink — ~23 min.
- **Deploy:** copy `build/libqemu-xtensa.dll` to `backend/app/services/`. The
worker loads it fresh per Run; restart the backend to be safe.
- **MinGW DLL deps:** the DLL needs `C:\msys64\mingw64\bin` on the loader path
(glib, libgcc_s_seh, libffi, pixman, gcrypt, slirp…). The running backend
resolves these already; a bare `ctypes.CDLL` test needs
`os.add_dll_directory(r'C:\msys64\mingw64\bin')`.
The Linux/CI build (`build_libqemu-esp32.sh` + the `qemu-prebuilt` release) needs
the same source change; bump the prebuilt DLLs there to ship the fix.
---
## 11. Key files and symbols
| File | Role |
|---|---|
| `third-party/qemu-lcgamboa/hw/ssi/esp32_spi.c` | SPI controller; `esp32_spi_txrx_buffer` + DMA loops carry the batch fast path |
| `third-party/qemu-lcgamboa/hw/ssi/picsimlab_spi.c` | `PICSIMLAB_SPI_transfer``picsimlab_spi_event` (the old per-byte crossing) |
| `third-party/qemu-lcgamboa/hw/xtensa/esp32_picsimlab.c` | `callbacks_t`, `picsimlab_spi_event_batch`, `spi_cs_irq_handler`, `picsimlab_spi_cs_events`, `qemu_picsimlab_enable_spi_cs_events` |
| `backend/app/services/esp32_worker.py` | `_on_spi_event` (per-byte), `_on_spi_batch` (bulk), `_on_pin_change` (DC flush), `_sync_cs_events`, `_CallbacksT` / `_SPI_BATCH` |
| `backend/app/services/esp32_spi_slaves.py` | worker-side ePaper SSD168x decoder (the framebuffer model worth copying for ILI9341) |
| `frontend/src/simulation/parts/ComplexParts.ts` | frontend ILI9341 decoder (DC-keyed, consumes `spi_batch` + `gpio_change`) |
| `build_libqemu-esp32-win.sh` / `build_libqemu-esp32.sh` | the build scripts |
---
## 12. Open issues / regression notes
- **rx=1 dummy MISO (narrow):** the batch path returns `0xFF` for the one dummy
MISO byte. Correct for write-only displays and ePaper. A custom SPI chip doing
a `tx>1, rx=1` *read* would get `0xFF` instead of its real MISO byte (the
worker still feeds it the data — only the one echoed byte differs). Tighten
before shipping if any custom chip relies on that pattern.
- **CS opt-in coverage:** `_sync_cs_events()` is called at the current ePaper /
custom-chip registration sites + after `'booted'`. If new high-dynamic SPI
slave paths are added, call it there too (it's idempotent).
- **ePaper `epaper-2in9-esp32-weather` renders blank — PRE-EXISTING, unrelated.**
Verified by testing the pristine shipped DLL **and** `git HEAD` worker: the
`velxio-epaper` panel is 100% white before any of this work too. `epaper_update`
fires once but no content is written. Separate bug (likely the SSD168x decoder
vs the panel's actual controller, or the sketch's CS handling) — not caused by
the SPI batching / CS gating here.