Performance & Memory¶
projectMM's per-step performance contracts live in the scenario JSONs — each test/scenarios/*.json step carries a per-target contract block (tick_us ceiling + free_heap floor) and an observed block (the latest reading per target). The scenarios are the source of truth and the assertion surface: every PR runs against them. See testing.md § Performance contracts for the contract semantics and renegotiation workflow. The headline numbers users care about are in README.md § Performance.
This document holds what scenarios can't carry: structural sizes (sizeof), build-variant deltas, and the WiFi/Ethernet physics that explain why a contract comes out where it does.
Render-loop model. The Layer's buffer persists frame-to-frame — Layer::tick() does not clear it (the FastLED/WLED/MoonLight convention; see architecture.md § Buffer persistence). This removed the per-frame full-buffer memset that a clear-every-frame model pays, and replaced N per-effect draw::fade passes with a single collected fade (Layer::fadeToBlackBy MINs the requested amounts and applies one buffer pass per frame) — so a layer with several fading effects now pays one fade pass, not N. Net hot-path effect on the tick numbers below is small (the clear/fade are one linear pass over the buffer, dwarfed by per-light effect compute and the output driver), but the model is what the scenario observed blocks were re-measured against on this cycle.
Desktop (64-bit)¶
Desktop ArtNet sends to a non-existent IP so packets complete instantly; freeHeap returns 0 (unlimited). Per-step tick budgets live in per-host contract.desktop-<os> blocks across the scenarios — desktop-macos for macOS arm64, desktop-windows for Windows x64, desktop-linux for Linux. The sizeof and dynamic-memory numbers below apply to all 64-bit desktop targets; tick numbers differ by host CPU and live in the scenario contracts.
sizeof (desktop, 64-bit)¶
| Class | sizeof (bytes) |
|---|---|
| MoonModule | 120 |
| Layer | 208 |
| Drivers | 408 |
| GridLayout | 128 |
| SystemModule | 368 |
| NetworkModule | 336 |
| HttpServerModule | 168 |
Drivers grew from 120 → 408 with the per-driver Correction stage (256-entry brightness LUT + channel-order table). Other classes grew ~16-32 bytes each as MoonModule itself grew (rolling-range observed slot + wired-by-code flag + per-child tickChildren accounting fields).
Binary sizes:
| Target | Size | Build |
|---|---|---|
| macOS arm64 | 358 KB | debug-arm64 (release-strip is smaller) |
| Windows x64 | 432 KB | MSVC Release, static CRT |
Memory at 128×128 with mirror¶
| Module | dynamicBytes | Breakdown |
|---|---|---|
| Layer | 92 KB | 12 KB buffer + 80 KB LUT (uint32_t indices on 64-bit) |
| Drivers | 48 KB | output buffer (128×128×3) |
ESP32 — Olimex Gateway Rev G (no PSRAM, 320 KB internal)¶
Per-step tick/heap live in contract.esp32-eth-wifi and contract.esp32-eth across the scenarios; see the README perf table for the headline grid×board matrix. The notes below cover what those rows don't.
Run-to-run variance¶
Individual measurements vary ~5–10% on the Olimex board with no configuration change — inherent ESP32/Ethernet timing jitter (lwIP tcpip_thread scheduling, EMAC DMA, Ethernet ACK pacing). Scenarios use 10% default ESP32 tolerance to absorb this; when a step trips, re-run before treating it as a real regression. The collect_kpi.py --commit gate parses a single tick: line from esp32/monitor.log and can flag an unlucky sample — same rule applies.
All-effects sweep (every effect, no modifier, Ethernet + ArtNet)¶
This Olimex sweep ran each effect alone over a Layer (no modifier) at four square grids, through the real ArtNet + Preview drivers — the per-effect cost of the same pipeline the README's single-effect headline row measures. Numbers are from a live run; apply the ~5–10% variance above. (The per-effect sweep merged into the light/heavy bracket of scenario_perf_full; this table is the archived Olimex run.)
FPS (= 1,000,000 / tick µs):
| Effect | 16² | 32² | 64² | 128² |
|---|---|---|---|---|
| Lines | 12658 | 7633 | 2304 | 23 |
| Rainbow | 3831 | 968 | 143 | 22 |
| Noise | 1117 | 324 | 71 | 17 |
| Plasma | 3194 | 829 | 135 | 18 |
| PlasmaPalette | 6024 | 1733 | 267 | 21 |
| Metaballs | 2016 | 521 | 102 | 18 |
| Fire | 2762 | 784 | 159 | 21 |
| Particles | 4716 | 1848 | 424 | 30 |
| GlowParticles | 1706 | 586 | 128 | 14 |
| Checkerboard | 8474 | 2617 | 397 | 21 |
| Spiral | 2403 | 571 | 87 | 15 |
| Rings | 1118 | 284 | 45 | 12 |
| LavaLamp | 3030 | 756 | 113 | 18 |
| GameOfLife | 6802 | 1519 | 226 | 13 |
At 128² nearly every effect converges to ~12–23 FPS: the board is ArtNet-output-bound there (the ~38 ms synchronous send dominates the tick), so effect-compute differences wash out — the same physics the README narrates for the S3 over WiFi. Effect cost is visible at 64² and below, where Rings / Noise / Spiral are the heaviest and Lines / Checkerboard the lightest.
Free internal heap (KB) — the scarce resource on a no-PSRAM board; drops as the grid grows because the Layer buffer + LUT and the driver output buffer live in internal RAM:
| Effect | 16² | 32² | 64² | 128² |
|---|---|---|---|---|
| Lines | 220 | 214 | 195 | 126 |
| Rainbow | 173 | 167 | 158 | 126 |
| Noise | 171 | 168 | 159 | 126 |
| Plasma | 173 | 171 | 162 | 126 |
| PlasmaPalette | 170 | 168 | 160 | 126 |
| Metaballs | 173 | 171 | 162 | 126 |
| Fire | 173 | 170 | 157 | 110 |
| Particles | 172 | 167 | 149 | 77 |
| GlowParticles | 173 | 171 | 162 | 126 |
| Checkerboard | 173 | 168 | 159 | 123 |
| Spiral | 170 | 169 | 160 | 123 |
| Rings | 170 | 168 | 160 | 124 |
| LavaLamp | 170 | 169 | 160 | 124 |
| GameOfLife | 171 | 165 | 150 | 90 |
Largest free internal block (KB) — the memory-pressure signal that matters: free heap can be ample while fragmentation leaves no single block big enough for the next allocation:
| Effect | 16² | 32² | 64² | 128² |
|---|---|---|---|---|
| Lines | 108 | 108 | 108 | 62 |
| Rainbow | 92 | 88 | 76 | 62 |
| Noise | 92 | 88 | 76 | 62 |
| Plasma | 92 | 92 | 84 | 62 |
| PlasmaPalette | 92 | 88 | 80 | 62 |
| Metaballs | 92 | 92 | 84 | 62 |
| Fire | 96 | 92 | 76 | 62 |
| Particles | 80 | 80 | 68 | 34 |
| GlowParticles | 84 | 84 | 80 | 62 |
| Checkerboard | 96 | 88 | 72 | 62 |
| Spiral | 88 | 88 | 76 | 62 |
| Rings | 92 | 88 | 80 | 62 |
| LavaLamp | 92 | 88 | 72 | 62 |
| GameOfLife | 88 | 84 | 68 | 46 |
Most effects hold the same ~126 KB free / 62 KB block at 128² — their per-cell state is negligible next to the buffers. The exceptions carry real per-cell state: Particles (77 KB / 34 KB) and GameOfLife (90 KB / 46 KB) allocate a parallel grid-sized array, and Fire (110 KB) a heat map. Those three are the ones to watch for fragmentation headroom on a no-PSRAM board at large grids.
ArtNet over WiFi vs Ethernet¶
| Ethernet | WiFi STA | |
|---|---|---|
| ArtNet (97 UDP packets) | ~27,000 µs | ~110,000 µs |
| Total tick | ~50,000 µs / 20 FPS | ~130,000 µs / 7 FPS |
WiFi sendto() is ~1,140 µs/packet vs Ethernet's ~280 µs — CSMA/CA backoff, rate adaptation, link-layer retries. Not a code regression; WiFi physics. For ArtNet at 16K lights, use Ethernet.
Build-variant note: WiFi-only esp32 is slow on Olimex¶
Same source tree, same MCU (ESP32 classic, 160 MHz):
| Board / firmware | 128×128 tick | ArtNet send |
|---|---|---|
Olimex Gateway, old WiFi-only esp32 (pre-collapse) |
220 ms (4 FPS) | 155 ms |
Olimex Gateway, default esp32 (WiFi + Ethernet) |
85–95 ms (10–12 FPS) | 38 ms |
The default esp32 build carries both the WiFi and Ethernet stacks, and sdkconfig.defaults.eth enlarges the shared lwIP/WiFi buffer pool via CONFIG_ETH_DMA_* — those buffers roughly quadruple ArtNet throughput versus a WiFi-only buffer pool. Generic ESP32 boards (no PCB-trace antenna, less stable 3V3) vary wildly in WiFi TX quality vs the Olimex.
Memory at 128×128 with mirror¶
| Module | dynamicBytes | Breakdown |
|---|---|---|
| Layer | 52 KB | 12 KB buffer + 40 KB LUT (uint16_t indices on ESP32) |
| Drivers | 48 KB | output buffer (128×128×3) |
| System + Network | 0 | char buffers in class, no heap |
LUT is half desktop size (uint16_t vs uint32_t per entry). The 1:1 (no-modifier) path skips the LUT entirely; see scenario_Layer_memory_1to1 vs scenario_MultiplyModifier_memory_lut.
Heap breakdown (128×128, mirror, RainbowEffect, Ethernet + mDNS)¶
| Component | Bytes | Notes |
|---|---|---|
| Boot heap | 290,240 | Before any init |
| After Ethernet + mDNS init | ~240,000 | lwIP + Ethernet + mDNS driver |
| Layer buffer | 12,288 | 64×64×3 (logical, halved by mirror) |
| Mapping LUT | 40,962 | offsets + destinations (uint16_t) |
| Driver output buffer | 49,152 | 128×128×3 (physical) |
| Preview frame | 0 | Zero-copy: pointer to output buffer |
| HTTP + WebSocket | ~8,000 | server + kernel buffers |
| MoonModule instances | ~3,000 | all modules combined |
| Free heap (running) | ~104,000 | stable, no leaks |
ESP32-S3 — ESP32-S3 N16R8 Dev (16 MB flash, 8 MB octal PSRAM)¶
esp32s3-n16r8 firmware on the ESP32-S3 N16R8 Dev at Network.txPowerSetting=8 dBm (the brown-out cap injected by deviceModels.json). 128×128 grid, Mirror XY, ArtNet over WiFi STA — the grid sweep (now part of scenario_perf_full) against the live device. Per-step tick/heap live in observed.esp32s3-n16r8 across the scenarios. Numbers below are the 128×128 step.
| Metric | Value | Notes |
|---|---|---|
| Total tick | ~164 ms / 6 FPS | Dominated by ArtNet at the 8 dBm cap |
| ArtNetSend | ~93 ms (97 UDP packets) | ~960 µs/packet — slower than full-power WiFi (cf. Olimex esp32-eth-wifi at 38 ms) because the cap cuts radio TX margin, association-rate adaptation lands at a lower MCS rate, and packets retry more |
| Free internal RAM | ~240 KB | The comparable, scarce resource. Stays flat (~238–240 KB) across all grid sizes — the Layer buffer + LUT live in PSRAM, so growing the grid doesn't touch internal RAM. This is the number the README perf table shows for the S3, so devices compare on the same axis. |
| Free heap (incl. PSRAM) | ~8,163 KB | The PSRAM-merged total (totalHeap reports 8 MB combined). Looks huge but isn't the constraint — assume PSRAM is ample for now. |
| maxBlock (internal) | ~164 KB | Internal-RAM largest contiguous block — the scarce-resource KPI. maxAllocBlock (any-memory) reports ~8 MB on PSRAM boards and is meaningless as a pressure signal; SystemModule + scenario_runner use maxInternalAllocBlock instead. |
| Layer buffer | 92 KB | In PSRAM (auto by heap_caps preference) |
| Image | 1,307 KB | ~30% larger than esp32-eth-wifi due to USB-Serial-JTAG driver + Improv-dual-transport listener |
Per-grid-size FPS from the same sweep: 16×16 → 1672, 32×32 → 287, 64×64 → 25, 128×128 → 6. The steep drop is ArtNet-bound: packet count scales with the pixel count, and at 8 dBm each packet is ~3× slower on-air than the Olimex Ethernet path.
Why ArtNet is slower at 8 dBm¶
The brown-out cap drops TX power 12 dB below default (8 dBm vs ~20 dBm). At lower TX power, the WiFi PHY rate-adaptation algorithm picks a slower MCS rate to maintain link margin — for a frame burst this means more time on-air per packet. ~960 µs/packet × 97 packets = the ~93 ms ArtNet budget. The cap is the price of a stable association on this hardware; without it the radio brown-outs and ArtNet doesn't get sent at all.
Use Ethernet-capable boards for high-FPS ArtNet workloads. The ESP32-S3 N16R8 Dev fits the "lots of PSRAM, accept WiFi compromise" niche — large pixel buffers or feature-heavy effects that wouldn't fit in 320 KB internal RAM.
Memory at 128×128 with mirror¶
| Module | dynamicBytes | Notes |
|---|---|---|
| Layer | 92 KB | Buffer lives in PSRAM (vs 12 KB on Olimex internal) — full uint32_t LUT instead of halved uint16_t |
| Drivers | 48 KB | Output buffer (128×128×3) |
| Free internal | ~240 KB free, ~164 KB largest block | Plenty of headroom for WiFi + lwIP + Improv-on-both-transports |
The PSRAM-merged heap (totalHeap() > totalInternalHeap()) is auto-detected — SystemModule binds the psram progress control only when this comparison is true. See docs/moonmodules/core/SystemModule.md.
All-effects sweep — render-only (no output driver, audio + discovery disabled)¶
A render-only per-effect sweep on the S3 (observed.esp32s3-n16r8, build Jun 17 2026; this curve is what scenario_perf_full's light/heavy bracket now measures on-device). Unlike the Olimex sweep above (which runs through the ArtNet driver and is output-bound at 128²), this one measures raw render cost: audio (I2S sampling) and the Devices module (the blocking HTTP discovery sweep) are disabled and no output driver is attached, so the tick is Layout→Layer→effect only. On the S3 the Layer buffer lives in PSRAM, so effect-compute is visible all the way to 16K pixels (it never converges to an output-bound floor the way the no-PSRAM Olimex does).
Tick (µs) — render only, ~5–10% run-to-run variance applies:
| Effect | 16² (256) | 32² (1K) | 64² (4K) | 128² (16K) |
|---|---|---|---|---|
| Lines | 88 | 96 | 179 | 6,425 |
| Rainbow | 285 | 849 | 3,228 | 16,207 |
| Noise | 913 | 2,951 | 11,661 | 51,230 |
| Plasma | 352 | 1,020 | 3,744 | 20,020 |
| PlasmaPalette | 146 | 423 | 1,765 | 10,085 |
| Metaballs | 462 | 1,757 | 6,108 | 28,576 |
| Fire | 382 | 1,138 | 4,505 | 22,745 |
| Particles | 229 | 535 | 1,945 | 15,792 |
| GlowParticles | 580 | 1,874 | 6,959 | 31,479 |
| Checkerboard | 121 | 345 | 1,098 | 8,500 |
| Spiral | 465 | 1,379 | 6,712 | 24,666 |
| Rings | 852 | 2,455 | 9,383 | 41,403 |
| LavaLamp | 309 | 974 | 3,612 | 21,243 |
| GameOfLife | 138 | 413 | 1,870 | 16,127 |
The cheapest (Lines, Checkerboard, PlasmaPalette) clear ~100 FPS even at 16K; the heaviest is Noise (51 ms = ~19 FPS at 16K — simplex noise per pixel), then Rings and GlowParticles. Effect-compute differences stay visible across the whole range because nothing is output-bound here.
Free internal heap holds ~8.54 MB at small grids and ~8.46–8.49 MB at 16K — the ~50–100 KB delta is just the grid-sized render buffer (the model array), and it returns to ~8.54 MB whenever the grid shrinks: no leak, no fragmentation creep across the sweep. Largest free internal block stays ~90–110 KB throughout. (Internal RAM is not the constraint on this PSRAM board; the Layer buffer is in PSRAM.)
MoonLive (scripted effect) — tick + memory¶
A MoonLiveEffect compiles its source text to native Xtensa once on the cold path (prepare), then run() is a single function-pointer call each tick. Measured on the S3 at 16×16 (the bench grid the engine is exercised on; the per-tick cost is the native loop, not interpretation):
| Script | Tick (µs) | Exec block (heap) |
|---|---|---|
setRGB(5, 255, 0, 0) (one pixel) |
26 | ~52 B |
setRGB(random16(256), 0, 255, 0) (one host call) |
29 | ~140 B |
fill(0, 0, 255) (loop over all lights) |
47 | ~68 B |
The tick cost is native-code speed — a setRGB is a bounds-guard + three byte stores (~26 µs including the per-tick module overhead), fill adds the per-light loop. The exec block scales with the program, not a fixed cap: a one-liner is tens of bytes of machine code (place() allocates the emitted length, word-rounded), reported as the module's dynamic memory (setDynamicBytes(codeLen())) so it shows on the UI card. At rest the engine itself is ~48 B of members + that exec block; the compile path's transient buffers (staging, IR, assembler ≈ 4 KB) live on the cold-path stack and are freed on return — see docs/backlog/livescripts-analysis-top-down.md § 3.7 for how this scales as the language grows.
Multi-pin LED driving (all three peripherals, 128×128 grid)¶
Each parallel LED driver run on real hardware at a 128×128 = 16384-light grid, 8 lanes (2026-07-12). The GPIOs used are recorded because they double as the seed for each board's usable-pin map (the per-model deviceModels.json pin defaults are built from proven-working sets, not datasheet guesses). Every listed pin drove WS2812 output on that board without conflict.
| Peripheral | Board | Pins used (8 lanes) | Result | Ceiling / bound |
|---|---|---|---|---|
| Parlio | ESP32-P4 (Waveshare P4-NANO) | 20,21,22,23,24,25,26,27 |
Drivers tick ~30100 µs, fps 30 at 16384 lights (8 lanes, SWAR transpose) |
65535 bytes/lane single-shot = 897 RGB lights/lane; an over-limit frame fails with a loud status |
| LCD_CAM i80 (MultiPinLedDriver) | ESP32-S3 N16R8 Dev | data 18,5,6,7,8,9,10,11 · WR(clock) 12 · DC 13 |
Same encoder, healthy on real i80; encode scales ~6 µs/light (8×512 = 4096 → 23 ms; 8×1024 = 8192 → 50 ms) | single-DMA init ceiling 8192–12288 lights (8×1024 inits; 8×1536 → "LCD init failed — check pins/memory"). A data lane on WR/DC only corrupts that lane (it carries the bus-control waveform, not pixels), so the driver warns and keeps running — a board that wires all lanes but drives fewer strands can legitimately park WR/DC on an unused data pin. WR and DC on the same GPIO is rejected up front (the bus needs two distinct control lines). |
| RMT | classic ESP32 (LOLIN D32 / WROOM) | 2,4,13,14,16,17,18,19 (pin 2 = a real 24-LED strand) |
8-pin RMT drives 8×256 = 2048 lights (tick ~12.6 ms), scales to ~8192 before the tick plateaus; all lanes healthy, pin-2 strand verified lit | silent alloc-fail: the RMT symbol buffer sizes for the driver's count window, so count=0 on a 16384-grid needs ~1.5 MB, fails on the ~90 KB heap, and tick() bails with no status (LEDs dark). Bound the driver with the start/count window; a status for this is backlogged. |
| I2S i80 | classic ESP32 (ESP32-WROVER) | data 2,4,13,14,18,19,21,22 · WR(clock) 32 · DC 33 (pin 2 = a real strand, verified lit) |
The classic ESP32 runs the same MultiPinLedDriver over the I2S peripheral in i80 mode (IDF routes the i80 API to I2S here, to LCD_CAM on the S3/P4 — one driver, chip-picked backend). 8-lane doubling sweep (128×128 grid, 2026-07-13): 64/pin (512) → 4877 µs, 128/pin (1024) → 8575 µs, 256/pin (2048) → 15638 µs. Scales linearly at ~7.6 µs/light (heavier than the S3's LCD_CAM ~6 µs — the classic I2S clock path). frameTime reports the WS2812 wire floor (512 → 243 fps, 2048 → 67 fps). The MultiPinLed status reports the live count. 16 lanes work on classic too (the I2S peripheral does the 16-bit i80 bus, 16×256 = 4096 verified), but the WROVER exposes only ~13 non-strap pins, so 8-lane is the practical set. |
Internal-RAM ceiling: 2048 lights at 8 lanes (4096 at 16). The classic I2S backend cannot DMA from PSRAM (esp_lcd_i80_alloc_draw_buffer rejects MALLOC_CAP_SPIRAM — "external memory is not supported"), so its frame buffer is internal-DMA-RAM only (maxBlock ≈ 76 KB). Swept at 8 lanes on a 128×128 grid (2026-07-13): 64/pin (512) ✅, 128/pin (1024) ✅, 256/pin (2048) ✅ — then 512/pin (4096) and above → i80 bus init failed — check pins / memory, a clean degrade, not a crash (uptime kept climbing through every rung). That lands exactly on the parallel-I2S acceptance floor (8×256 = 2048), so the classic chip meets its floor and no more. The opposite of the LCD_CAM row below, which reaches 16384 via PSRAM — the classic chip's DMA simply can't get there. The render is decoupled from this ceiling: the same sweep kept rendering the full 128×128 = 16384-light grid at every rung (Layer ≈ 511 ms/frame, from PSRAM) while the output was capped — so a big grid still renders, it just can't all reach the LEDs. At 16K lights the effect render (511 ms) dwarfs the output (24 ms), so multicore cannot help: the render is the wall on this chip. Two classic-only quirks the driver handles: the I2S i80 tx has an unconditional command phase whose busy-wait hangs to a watchdog reset unless given a real 8-bit command (lcd_cmd_bits=8 / kI80Cmd=0), and the draw buffer + a done-ISR marked IRAM_ATTR. |
| LCD_CAM 16-lane | ESP32-S3 (SE 16 V1 + LightCrafter 16, n8r8) | SE16 data 47,48,21,38,14,39,13,40,12,41,11,42,10,2,3,1 · WR/DC 5/6; LC16 data 47,21,14,9,8,16,15,7,1,2,42,41,40,39,38,48 · WR/DC ghost 33/34 |
Reaches the full 16384 lights — the 16K target — where Parlio caps at 4096. SE16 16-lane doubling sweep (128×128 grid), async double-buffer ON (re-measured 2026-07-13 after Step 1.5): 512 → 1843 µs, 1024 → 3422 µs, 2048 → 6612 µs, 4096 → 15153 µs, 8192 → 26788 µs, 16384 → 49916 µs (~20 fps) — the driver tick is now the encode alone, the WS2812 wire wait overlapped in background DMA (frameTime reports it separately: 16384 → 28786 µs). That's ~30–56 % faster than the pre-Step-1.5 blocking path the earlier row measured (async OFF reproduces it within 3 %: 4096 → 22518 µs, 16384 → 77732 µs vs the old 21945 / 76979 µs — so the lcd→i80 rename is behavior-neutral; the speedup is Step 1.5, not the rename). The MultiPinLed status reports the live count (driving N of 16384 lights). |
No contiguous-block ceiling — the key difference from Parlio. LCD_CAM allocates its DMA buffer via esp_lcd_i80_alloc_draw_buffer from PSRAM, so it isn't bound by the ~368 KB largest-internal-block limit that caps Parlio at 4096 lights; it drives all 16384. 16K is now ~20 fps (up from ~13 fps pre-Step-1.5). The ENCODE is the wall here, not the wire: async hides the 28,786 µs wire behind DMA (which alone would allow ~35 fps), so the tick is the 49,916 µs encode → ~20 fps. Recovering the rest of the deep-per-lane wall (§ Step 3, multicore top-down) — though the ~50 ms encode still runs on core 0, which on the LC16 starves the W5500 SPI-Ethernet (also core 0) → link drops, HTTP times out while the render loop keeps ticking. This is the measured contention that justifies the multicore pipeline (Step 2) on classic/S3 — a core-budget limit, not a fault. |
| Parlio 16-lane | ESP32-P4 (testbench, n16r8) | 16 data pins 21,20,22,23,24,25,26,27,32,33,39,40,41,42,43,44 |
16-lane doubling sweep (ledsPerPin 32→256/pin on a 128×128 grid, 2026-07-12; reproduced within 0.3% on a second P4). Tick scales linearly with lights: 512 → 1653 µs, 1024 → 2925 µs, 2048 → 5514 µs, 4096 → 10760 µs at 256/pin. Async double-buffer shipped (Step 1.5, 2026-07-13): with doubleBuffer ON, the ~7.5 ms WS2812 wire wait moves into background DMA, so the driver tick at 256/pin drops 10,820 → 3,790 µs and the whole board rises 48 → 76 fps (system tick 20.6 → 13.0 ms). The frameTime KPI reports the measured wire floor directly — live 7474 µs (133 fps max) here (the true, measured output ceiling). With the wire hidden, the tick is now effect render (~7.3 ms) + driver (~3.8 ms) serial → the effect is the next bottleneck, which the multicore pipeline (Step 2) overlaps toward the 133 fps frameTime ceiling. (doubleBuffer OFF reproduces the pre-Step-1.5 10,820 µs / 92 driver-fps exactly — the synchronous path, kept as the opt-out. ON is simply the better configuration; the switch exists to A/B it. Its one-frame latency saving is below the perceptual A/V-sync threshold, so there is no user class — sound-reactive included — that should run it OFF for latency.) The ParlioLed status reports the live count (driving N of 16384 lights). |
Single-DMA ceiling ≈ 4096 lights (256/pin). 512/pin (8192) → Parlio init failed — check pins / memory. The P4 has 33 MB free heap but the largest contiguous internal block is ~368 KB, and the 16-bit single-shot DMA buffer needs one contiguous block — so it's a contiguous-block limit, not total memory (it bites well before the 65535-byte/lane byte cap). Reaching the full 16384 (1024/pin) needs the Parlio chunked-transfer work (frame split across DMA bursts) — deferred indefinitely, since >~65K lights on one chip is a network-distribution problem. |
LOLIN D32 (classic ESP32-WROOM) usable LED GPIOs: 4,13,14,18,19,21,22,23,25,26,27,32,33 plus 16,17 (free on WROOM — they're the PSRAM bus only on WROVER). Avoid straps 0,2,12,15, the onboard LED on 5, and battery-sense on 35; input-only 34–39 can't drive an LED. (Chip-level set: gpio-usage.md.)
Diagnostic used: RMT tickTimeUs > 1000 = actively encoding (LEDs on); a tiny ~30 µs tick = the symbol alloc failed and tick() bailed (dark). dynamicBytes is not reported for RMT (plain-heap symbol buffer), so it always reads 0.
The acceptance floors these establish for the parallel backends: RMT 8×256 = 2048 (verified above); the parallel-I2S (classic i80) driver 16×256 = 4096 (verified 2026-07-13); the virtual (shift-register) driver 48×256 = 12288 — each backend must clear its floor on real hardware.
Multicore: the whole output stage on core 1 (multicore, Step 2)¶
The multicore control on the Drivers container runs every driver's per-frame work — the LED encode, the ArtNet packet build, the preview frame build — on a core-1 task, while the render loop draws the next frame on core 0. A frame costs max(render, output) instead of render + output. It stacks with the driver's doubleBuffer (which hides the WS2812 wire behind DMA on one core); this hides the encode behind the render on the other.
Measured live by flipping the switch (SE16, 64×64 grid, 16 lanes × 256 = 4096 lights; reproduced ON→OFF→ON):
multicore ON |
multicore OFF |
|
|---|---|---|
| whole-board fps | 46 | 32 |
| system tick | 21,316 µs | 30,857 µs |
Drivers cost on core 0 |
2,261 µs | 15,404 µs |
| Preview | 91 µs | 49 µs |
| HttpServer | 409 µs | 348 µs |
85 % of the output stage leaves the render core (15,404 → 2,261 µs), for +44 % fps. The encode itself is unchanged (~20 ms at 4096 lights on the S3) — it simply runs on the other core, where the render no longer waits for it.
Calling the network stack from core 1 costs ~100 µs/frame, and it does not matter. lwIP is pinned to core 0 (CONFIG_ESP_WIFI_TASK_PINNED_TO_CORE_0), so a driver that writes a socket still hands its bytes to the network task there — only the CPU half (packet / frame building) offloads. The cross-core lock and cache bouncing show up as Preview 49 → 91 µs and HttpServer 348 → 409 µs. That ~100 µs is set against the ~13,000 µs of output work removed from core 0 — a 130:1 trade, which is why no driver is special-cased: when the split is on, all of them move.
It also fixes the contention that motivated the work. A ~19 ms inline encode on core 0 previously starved the network stack sharing that core — the LightCrafter 16's W5500 Ethernet dropped its link and HTTP timed out while the render loop kept ticking. With the encode on core 1, an HTTP hammer during a heavy 8192-light encode holds: 77 requests, median 163 ms, one timeout.
Per-chip renderWait — the read-only KPI reporting the worst core-0 wait at the frame boundary in the last second. It says how much idle time a second handoff buffer (the deferred ping-pong step) would recover, so the decision is measured rather than assumed:
| Board | Backend | renderWait, heaviest effect (Noise @ 128²) |
|---|---|---|
| SE16 (S3) | LCD_CAM i80 | ~1 µs |
| P4 | Parlio | ~7 µs |
| WROVER | classic I2S | ~15 µs |
Under a heavy effect the render dominates on every board, so core 0 never idles and the ping-pong buffer would buy nothing. It only pays when the effect is much cheaper than the output work (a light effect driving many lights), where the wait grows to milliseconds.
Memory and degrade. The split needs one frame buffer — the stable frame core 1 reads while core 0 renders the next. When two or more layers composite (or a single layer needs a LUT map) that buffer already exists; in the identity single-layer case, which is otherwise zero-copy, the split allocates it. If it does not fit, the split does not engage: no task is spawned, every driver ticks inline on core 0, and the driver reads the layer buffer directly — exactly the pre-multicore behavior, and doubleBuffer's DMA overlap still applies. The switch stays on (the user's intent is kept) and the split re-engages by itself once the memory is there, so there is no half-split state where the two cores could wait on each other.
Incremental cost analysis (scenario_perf_light / scenario_perf_full)¶
These two scenarios start from a clean canvas and add one subsystem at a time, measuring the tick/heap delta per step, so each module's cost is isolated. Measured live (2026-06-17, render-only, audio + discovery disabled) on all three boards:
- classic — Olimex Gateway, ESP32 @240MHz, no PSRAM (320KB internal),
nrOfLightsType=uint16 - S3 — ESP32-S3 N16R8 @240MHz, 8MB PSRAM, uint32
- P4 — Waveshare P4-NANO, ESP32-P4 @400MHz dual-core, 32MB PSRAM, uint32
All figures tick µs at 16² unless a grid is named; ~5–10% run-to-run variance, so small (<~30µs) deltas are near the noise floor.
Per-subsystem cost (added one at a time, 16² grid)¶
Absolute tick at each step (the diff vs the prior row is that subsystem's cost):
| Step | classic | S3 | P4 | Reading |
|---|---|---|---|---|
| Render floor (Grid+Layer+Checkerboard) | 129 | 133 | 67 | the baseline; P4 ~2× faster |
| − Audio disabled | 116 | 111 | n/a | audio ≈ +13–22µs/tick (fixed I2S block-read; no mic on the P4) |
| − Devices discovery disabled | 116 | 112 | 56 | idle discovery is free (boot sweep is one-shot) |
| + MultiplyModifier (2×2) | 315 | 292 | 96 | +180–200µs — the per-frame blend+map over the LUT |
| + PreviewDriver | 115 | 118 | 56 | apparatus; free |
| + NetworkSendDriver | 139 | 141 | 67 | ArtNet/DDP build+send; cheap at this size |
| + RmtLedDriver (64 LEDs) | 152 | 120 | 56 | per-frame encode+transmit at a fixed 64-LED output |
| + MultiPinLedDriver (64 LEDs) | ✓³ | 142 | 57² | i80 bus: classic ESP32 → I2S, S3/P4 → LCD_CAM |
| + ParlioLedDriver (64 LEDs) | n/a¹ | n/a | 58 | P4 Parlio |
¹ Parlio is P4-only; its classic row is not a real measurement (the driver isn't compiled/registered on classic, the optional add is skipped, so the row re-measures the prior pipeline). Gating drivers out per chip is done in main.cpp (each registerType is #if-gated on the SOC macro). ² P4 has LCD_CAM too, but Parlio is its scale path. ³ MultiPinLedDriver is registered on classic (over the I2S i80 backend) as of 2026-07-13, so the classic i80 row is now a real measurement — see § Multi-pin LED driving. "n/a" = driver absent on that chip.
Expected, and confirmed everywhere: audio is a small fixed per-tick cost; idle discovery is free; output drivers are cheap at a capped 64-LED output (none dominates the render path). The modifier's +~190µs at 16² is the one notable per-frame add — explained below (it's the blend+map, and it pays for itself at large grids).
Multi-layer composition (the Drivers composite loop): a single enabled Layer is the pass-through fast path (the driver reads the Layer's buffer directly — zero composite cost, the figures above). Each additional enabled Layer adds one blendMap pass over the physical buffer (integer alpha-over or additive, branch-resolved once per layer), so N enabled layers cost ≈ N × the per-layer write — linear in layer count, same shape as the per-effect sweep. The RegionModifier adds nothing unless present (no modifier = the identity fast path).
Branch re-verification (2026-06-25, multi-layer + RegionModifier): the live perf scenarios (scenario_perf_light / _full / _modifier_swap) were re-run on all three boards on this branch's firmware. The per-effect and per-modifier numbers match the tables here within run-to-run variance — the new composite/RegionModifier code does not regress the single-layer pipeline (it's opt-in: a one-layer tree runs the same path as before).
Effect compute — light vs heavy bracket, across grid sizes (render-only)¶
Tick µs; FPS in parens for the 16K row:
| Grid (pixels) | classic | S3 | P4 |
|---|---|---|---|
| Checkerboard (light) | |||
| 16² (256) | 149 | 119 | 61 |
| 32² (1K) | 357 | 328 | 133 |
| 64² (4K) | 1,147 | 1,090 | 452 |
| 128² (16K) | 4,360 | 7,949 | 1,940 |
| Noise (heavy) | |||
| 16² (256) | 1,010 | 799 | 313 |
| 32² (1K) | 3,203 | 2,831 | 1,120 |
| 64² (4K) | 13,547 | 11,235 | 4,358 |
| 128² (16K) | 62,316 (16 FPS) | 50,555 (20 FPS) | 17,433 (57 FPS) |
All curves scale ~linear in pixel count (no superlinear blowup → no realloc/fragmentation pathology). The heavy effect is the 16K bottleneck on every board, and the board ranking is P4 ≫ S3 > classic on heavy compute (the P4's 400MHz dual-core is ~3× the S3). Surprise worth noting: at light-16K the classic (4,360µs) beats the S3 (7,949µs) — the S3's PSRAM-resident buffer has higher access latency than the classic's internal RAM for the cheap Checkerboard inner loop, and classic's uint16 LUT is half the size; on the heavy effect the compute dominates and the S3 pulls ahead again. Fixed-point / strided-sampling ideas are on the backlog.
MultiplyModifier — compute down, memory up (Noise effect)¶
Often misread (I misread it first): with the default 2×2 kaleidoscope the modifier makes the logical grid ¼-size, so the effect computes on fewer pixels — the modifier reduces tick at large grids, and its real cost is the 1:N mapping-LUT memory.
Tick µs, Noise alone vs Noise+Multiply:
| Grid (physical) | classic alone | classic +Mult | S3 alone | S3 +Mult | P4 alone | P4 +Mult |
|---|---|---|---|---|---|---|
| 16² | 1,010 | 456 | 799 | 385 | 313 | 163 |
| 32² | 3,203 | 1,808 | 2,831 | 1,573 | 1,120 | 533 |
| 64² | 13,547 | 6,958 | 11,235 | 6,552 | 4,358 | 2,058 |
| 128² (16K) | 62,316 | 28,466 (35 FPS) | 50,555 | 29,647 | 17,433 | 9,964 (100 FPS) |
So the modifier roughly halves the heavy tick at every grid (¼ logical area, but the 1:N map adds back some cost). The memory price is the LUT-destinations array — on the S3 it cost +1.7KB(16²)→+93KB(16K); on the classic at 16K it ran with ~36KB free heap / ~26KB largest block — tight but working, no crash, no degrade. This confirms the no-PSRAM viability: 16K Noise+Multiply runs on the classic at 35 FPS render-only (and has historically run at 10–20 FPS when also sending out over ArtNet — that send, not the render, was the limiter). Not a no-PSRAM blocker.
ESP32 firmware size¶
Board: the default esp32 (WiFi + Ethernet — the largest classic variant, measured pre-collapse as esp32-eth-wifi). Partition layout: app0/app1 = 1.75 MB each, LittleFS = 384 KB, coredump = 64 KB.
| Size | |
|---|---|
| Firmware image | ~1.27 MB |
| App partition | 1.75 MB (~72% used, ~28% headroom) |
| DRAM used | 38 KB |
| DRAM free | 142 KB |
sizeof(MoonModule) ESP32 |
56 bytes |
Component breakdown (default esp32)¶
Run from project root after a clean build:
uv run moondeck/build/build_esp32.py --firmware esp32
idf.py -B build/esp32-esp32 \
-DSDKCONFIG=build/esp32-esp32/sdkconfig \
size-components | head -40
These numbers shift with IDF version + sdkconfig — treat as rough proportions.
| Category | Approx | What |
|---|---|---|
| WiFi stack | ~400 KB | esp_wifi + wpa_supplicant + esp_phy. ~1/3 of the binary; esp32-eth drops it entirely (image → ~602 KB). |
| lwIP networking | ~180 KB | TCP/IP stack, DHCP, DNS, ARP, mDNS, SNTP. |
| TLS + cert bundle | ~170 KB | mbedtls + Mozilla root bundle (~50 KB). Used by esp_https_ota; reused by any future HTTPS client. |
| FreeRTOS + IDF core | ~150 KB | Kernel, esp_event, esp_timer, heap, logging, partition ops. Always present. |
| projectMM code | ~120 KB | src/core/ + src/light/ + src/platform/esp32/ + src/main.cpp. ~10% of the binary. |
| HTTP server + WS | ~60 KB | esp_http_server + HttpServerModule routing. |
| Embedded UI assets | ~50 KB | index.html, app.js, style.css, preview3d.js, install-picker.js, logo PNG — packed as constexpr uint8_t[]. |
esp_https_ota + HTTP client |
~40 KB | OTA-from-URL machinery. |
| LittleFS | ~30 KB | joltwallet/esp_littlefs component. |
| Ethernet stack | ~30 KB | esp_eth + LAN8720 PHY. Present in every classic variant since the collapse (RMII driver is always compiled in). |
| Misc (alignment, .rodata) | ~40 KB | Format strings, error tables, version metadata. |
Variant size deltas¶
| Variant | Image | Delta | Difference |
|---|---|---|---|
esp32 (default, WiFi + RMII Eth) |
1.27 MB | — | Everything compiled in |
esp32-eth |
0.60 MB | −670 KB | WiFi stack excluded (EXCLUDE_COMPONENTS) |
esp32s3-n16r8 |
~1.27 MB | similar | Xtensa LX7, 16 MB flash, different partition table; W5500 SPI Eth instead of RMII |
The default esp32 carries both the WiFi and Ethernet stacks (1.27 MB); esp32-eth is the Ethernet-only build that drops the WiFi stack for ~670 KB less image.
Size budget for upcoming features¶
| Feature | Est. | Rationale |
|---|---|---|
| Mozilla cert bundle trimmed | −40 KB | CONFIG_MBEDTLS_CERTIFICATE_BUNDLE_DEFAULT_CMN keeps common roots only. _NONE saves ~50 KB but breaks TLS. |
| Static IPv6 | +20 KB | lwIP IPv6 component (off by default). Only if a deployment needs it. |
WebSocket TLS (wss://) |
~0 KB | Reuses linked mbedTLS; certificate handling adds <5 KB. |