perf(esp32): batch SPI to the worker + gate CS crossings (Doom ~30x)

Worker side of the libqemu picsimlab_spi_event_batch / CS-gating change:

- _on_spi_batch(): replay a whole SPI transfer in bulk (custom-chip runtime,
  then ePaper feed, then the spi_batch buffer) instead of one _on_spi_event per
  byte. Registered as a trailing _SPI_BATCH field of _CallbacksT.
- _sync_cs_events(): disable SPI chip-select callbacks for pure-display sims,
  enable them when an ePaper / custom-chip SPI slave is registered (no-op on
  older libqemu without qemu_picsimlab_enable_spi_cs_events).
- _on_pin_change(): flush the SPI batch before each gpio_change so the byte
  stream stays ordered against the DC pin now that CS no longer triggers the
  flush.

Backward compatible: an older libqemu never calls the batch callback or the CS
setter, so it just keeps the per-byte path. esp32-doom: 0.04 -> ~1.0-1.5 FPS
wall-clock (~26-37x), render verified correct.
This commit is contained in:
David Montero Crespo 2026-06-03 23:22:29 -03:00
parent 780b80778c
commit d3f9f7aa99
1 changed files with 79 additions and 0 deletions

View File

@ -140,6 +140,11 @@ _RMT_EVENT = ctypes.CFUNCTYPE(None, ctypes.c_uint8, ctypes.c_uint32,
# burn-in window confirms parity. Requires libqemu-{xtensa,riscv32} # burn-in window confirms parity. Requires libqemu-{xtensa,riscv32}
# 1.1.0+; older binaries omit the field and the placeholder runs. # 1.1.0+; older binaries omit the field and the placeholder runs.
_GPIO_MATRIX_CB = ctypes.CFUNCTYPE(None, ctypes.c_int, ctypes.c_int) _GPIO_MATRIX_CB = ctypes.CFUNCTYPE(None, ctypes.c_int, ctypes.c_int)
# Batched write-only SPI: (id, const uint8_t *mosi, int len). Collapses the
# per-byte picsimlab_spi_event ctypes crossing for TFT-style bulk writes.
# Trailing field — older libqemu builds (without the C-side batch path) simply
# never call it and fall back to per-byte picsimlab_spi_event.
_SPI_BATCH = ctypes.CFUNCTYPE(None, ctypes.c_uint8, ctypes.POINTER(ctypes.c_uint8), ctypes.c_int)
class _CallbacksT(ctypes.Structure): class _CallbacksT(ctypes.Structure):
@ -152,6 +157,7 @@ class _CallbacksT(ctypes.Structure):
('pinmap', ctypes.c_void_p), ('pinmap', ctypes.c_void_p),
('picsimlab_rmt_event', _RMT_EVENT), ('picsimlab_rmt_event', _RMT_EVENT),
('picsimlab_gpio_matrix_cb', _GPIO_MATRIX_CB), ('picsimlab_gpio_matrix_cb', _GPIO_MATRIX_CB),
('picsimlab_spi_event_batch', _SPI_BATCH),
] ]
@ -690,6 +696,14 @@ def main() -> None: # noqa: C901 (complexity OK for inline worker)
return return
gpio = int(_PINMAP[slot]) if 1 <= slot <= _GPIO_COUNT else slot gpio = int(_PINMAP[slot]) if 1 <= slot <= _GPIO_COUNT else slot
_pin_state[gpio] = value & 1 _pin_state[gpio] = value & 1
# Flush pending SPI bytes BEFORE announcing this pin change so the
# frontend processes them under the pin state (e.g. the ILI9341 DC line)
# that was in effect when they were sent. With CS events gated off for
# pure-display sims, this — plus the buffer cap / timer — is what keeps
# the SPI byte stream correctly ordered against the DC gpio_change on the
# single WS channel (the per-CS flush used to do it).
with _spi_buf_lock:
_flush_spi_batch_locked()
_emit({'type': 'gpio_change', 'pin': gpio, 'state': value}) _emit({'type': 'gpio_change', 'pin': gpio, 'state': value})
# Dispatch to any custom-chip runtime that has a vx_pin_watch on this # Dispatch to any custom-chip runtime that has a vx_pin_watch on this
@ -1021,6 +1035,18 @@ def main() -> None: # noqa: C901 (complexity OK for inline worker)
_emit({'type': 'spi_batch', 'b64': b64}) _emit({'type': 'spi_batch', 'b64': b64})
_spi_byte_buf.clear() _spi_byte_buf.clear()
def _sync_cs_events():
"""Tell QEMU whether to forward SPI chip-select toggles to us. Only
ePaper / custom-chip SPI slaves consume CS; pure-display sims (DC pin +
batched data) do not, so turning CS off there removes ~9k C->Python
crossings/sec for a TFT redraw. No-op on older libqemu builds without
the symbol (CS events stay on, as before)."""
try:
lib.qemu_picsimlab_enable_spi_cs_events(
1 if (_epaper_state or _chip_spi_runtimes) else 0)
except Exception:
pass
def _spi_flush_timer_loop(): def _spi_flush_timer_loop():
"""Background thread: flushes any pending SPI bytes every """Background thread: flushes any pending SPI bytes every
_SPI_BATCH_PERIOD_S so partial transactions reach the frontend _SPI_BATCH_PERIOD_S so partial transactions reach the frontend
@ -1102,6 +1128,52 @@ def main() -> None: # noqa: C901 (complexity OK for inline worker)
_emit({'type': 'spi_event', 'bus': bus_id, 'event': event, 'response': resp}) _emit({'type': 'spi_event', 'bus': bus_id, 'event': event, 'response': resp})
return resp return resp
def _on_spi_batch(bus_id: int, mosi_ptr, length: int) -> None:
"""Batched write-only SPI transfer — the whole MOSI buffer arrives in a
single call instead of one picsimlab_spi_event per byte. libqemu only
invokes this for rx==0 (MISO-ignored) transfers on the host SPI shim, so
nothing is returned. Mirrors the per-byte _on_spi_event side effects in
bulk: custom-chip runtimes first, then ePaper, then the spi_batch buffer.
This is the path that removes ~150k C->Python crossings/frame for TFTs."""
if length <= 0 or _stopped.is_set():
return
try:
data = ctypes.string_at(mosi_ptr, length)
except Exception:
return
# Custom-chip SPI runtimes get first dibs (replay per byte; the chip's
# MISO return is discarded because this transfer is write-only).
if _chip_spi_runtimes:
rt = _chip_spi_runtimes[0]
for mb in data:
try:
rt.spi_transfer_byte(mb)
except Exception as e:
_log(f'[custom-chip spi_batch] error: {e!r}')
return
# ePaper SSD168x: feed each byte under the current DC to every active
# slave (DC is constant for a write-only transaction).
if _epaper_state:
any_active = False
for st in _epaper_state.values():
if st['cs_low']:
any_active = True
slave = st['slave']
dc = st['dc_high']
for mb in data:
try:
slave.feed(mb, dc)
except Exception as e:
_log(f'[epaper spi_batch] error: {e!r}')
if any_active:
return
# Fast path: bulk-append to the spi_batch buffer (same buffer/flush the
# per-byte path uses, so frontend ordering is unchanged).
with _spi_buf_lock:
_spi_byte_buf.extend(data)
if len(_spi_byte_buf) >= _SPI_BATCH_FLUSH_AT:
_flush_spi_batch_locked()
# Keep callback struct alive (prevent GC from freeing ctypes closures) # Keep callback struct alive (prevent GC from freeing ctypes closures)
_cbs_ref = _CallbacksT( _cbs_ref = _CallbacksT(
picsimlab_write_pin = _WRITE_PIN(_on_pin_change), picsimlab_write_pin = _WRITE_PIN(_on_pin_change),
@ -1112,6 +1184,7 @@ def main() -> None: # noqa: C901 (complexity OK for inline worker)
pinmap = ctypes.cast(_PINMAP, ctypes.c_void_p).value, pinmap = ctypes.cast(_PINMAP, ctypes.c_void_p).value,
picsimlab_rmt_event = _RMT_EVENT(_on_rmt_event), picsimlab_rmt_event = _RMT_EVENT(_on_rmt_event),
picsimlab_gpio_matrix_cb = _GPIO_MATRIX_CB(_on_gpio_matrix), picsimlab_gpio_matrix_cb = _GPIO_MATRIX_CB(_on_gpio_matrix),
picsimlab_spi_event_batch = _SPI_BATCH(_on_spi_batch),
) )
lib.qemu_picsimlab_register_callbacks(ctypes.byref(_cbs_ref)) lib.qemu_picsimlab_register_callbacks(ctypes.byref(_cbs_ref))
# Log whether the new symbol is present in this libqemu build. # Log whether the new symbol is present in this libqemu build.
@ -1316,6 +1389,7 @@ def main() -> None: # noqa: C901 (complexity OK for inline worker)
} }
_epaper_slaves[comp_id] = slave _epaper_slaves[comp_id] = slave
_epaper_state[comp_id] = state _epaper_state[comp_id] = state
_sync_cs_events()
sensor_data['epaper_component_id'] = comp_id sensor_data['epaper_component_id'] = comp_id
_log(f"[epaper:{ctl_family}] registered '{comp_id}' " _log(f"[epaper:{ctl_family}] registered '{comp_id}' "
f"({width}x{height}) " f"({width}x{height}) "
@ -1423,6 +1497,7 @@ def main() -> None: # noqa: C901 (complexity OK for inline worker)
_log("[custom-chip] UART chip registered on UART0") _log("[custom-chip] UART chip registered on UART0")
if runtime.spi_config is not None: if runtime.spi_config is not None:
_chip_spi_runtimes.append(runtime) _chip_spi_runtimes.append(runtime)
_sync_cs_events()
_log("[custom-chip] SPI chip registered") _log("[custom-chip] SPI chip registered")
if runtime.has_pin_watches(): if runtime.has_pin_watches():
_chip_pin_watch_runtimes.append(runtime) _chip_pin_watch_runtimes.append(runtime)
@ -1439,6 +1514,9 @@ def main() -> None: # noqa: C901 (complexity OK for inline worker)
_log(f'_i2c_slaves registered: {list(_i2c_slaves.keys())}') _log(f'_i2c_slaves registered: {list(_i2c_slaves.keys())}')
_emit({'type': 'system', 'event': 'booted'}) _emit({'type': 'system', 'event': 'booted'})
# Now that the initial components are registered, tell QEMU whether to
# forward SPI CS toggles (only ePaper/custom-chip need them).
_sync_cs_events()
_log(f'QEMU started: machine={machine} firmware={firmware_path}') _log(f'QEMU started: machine={machine} firmware={firmware_path}')
_log(f'QEMU args: {[a.decode() for a in args_list]}') _log(f'QEMU args: {[a.decode() for a in args_list]}')
@ -1706,6 +1784,7 @@ def main() -> None: # noqa: C901 (complexity OK for inline worker)
} }
_epaper_slaves[comp_id] = slave _epaper_slaves[comp_id] = slave
_epaper_state[comp_id] = state _epaper_state[comp_id] = state
_sync_cs_events()
sensor_data['epaper_component_id'] = comp_id sensor_data['epaper_component_id'] = comp_id
_sensors[gpio] = sensor_data _sensors[gpio] = sensor_data
_log(f'Sensor {sensor_type} attached on GPIO {gpio}') _log(f'Sensor {sensor_type} attached on GPIO {gpio}')