Iterating a Variant list costs ~330 us per element on an ESP32-S3
Measured 2026-09-24, S3 board, probe compiled through examples/esp32/adc-s3's recipe
fps-window-ms 1004 frames 314 # 20 kHz, 64-sample frames: rate is right
50 reads ms 293 samples 3200 # adc.read() alone: ~6 ms per 64 samples
50 reads+sum ms 1330 # plus `for v in s: tot = tot + v`
(1330 - 293) / 3200 = 0.32 ms per element. The probe's source is below. It uses time.monotonic, which works on ESP only since the same day's fix.
a = ms(); tot = 0; k = 0
while k < 50:
s = adc.read()
for v in s:
tot = tot + v
k = k + 1
b = ms()
2026-09-24 (frankH): where the time goes. It is not a pathology.
All board figures are ESP32-S3 at 160 MHz (read with esp_clk_cpu_freq; the summary above used to say 240, which was wrong). Compiler 58412e442c17. Probes ran through examples/esp32/adc-s3 with PXX_MAIN=, timed with esp_timer.
NilPy loops, us per element or iteration (population: one 64-element list of ints built in Python, 1,280 steps):
| shape | S3 | x86-64 host | i386, same host |
|---|---|---|---|
module for v in lst: tot = tot + v |
267 | 0.47 | 1.98 |
same, inside a def |
382 | 0.90 | 3.05 |
module while j < 64: tot = tot + j, no list |
529-547 | 0.48-0.52 | 2.31 |
The loop with no list costs as much as the one that iterates the list, so neither the list nor the iterator is the cost. The earlier "592 us/iter" plain-loop figure is this same row.
Pascal micro-benchmarks, S3 vs x86-64 (2,000 or 100,000 iterations):
| operation | S3 | S3 cycles | x86-64 |
|---|---|---|---|
x := x + i, locals |
399 ns | 64 | ~3 ns |
| same, globals | 591 ns | 95 | |
| empty procedure call | 440-475 ns | 70-76 | 4 ns |
| PXXVarRetain (int, a no-op) | 1276 ns | 204 | 14 ns |
| PXXVarBinOp, int + int | 5198 ns | 830 | 33 ns |
| PXXVarBinOpPas | 8927 ns | 1430 | 79 ns |
d := v + w, Variants |
10848 ns | 1740 | 46 ns |
d := i (box an int) |
5226 ns | 836 | 6 ns |
The S3 costs about 5-10x more cycles than x86-64 at every level, from a bare loop up to a Variant add. No row stands out from its neighbours.
Why a bare loop costs 64 cycles. Here is the disassembly of for i := 1 to 100000 do x := x + i with locals (LocalLoop in the probe). Every value is stored to the frame and reloaded. Int64 widening is done inline with a sign-fill branch. The loop bound is loaded through an inline literal pool that the code jumps over. The body comes to ~40 instructions per iteration. That is the xtensa backend's code quality, the same on every program. It is not a runtime slow path.
What is not the cause (each ruled out by a measurement above):
- the Variant list or the iteration protocol: the no-list loop is no faster;
- per-element allocation, a lock or PSRAM: the Pascal
x := x + iloop has none of them and is already 64 cycles; - an ESP-only runtime hook at loop back-edges: the same NilPy source on i386 shows the same shape, just 4-5x slower than x86-64.
What would actually buy speed, cheapest first:
- Build the S3 demos at 240 MHz (
CONFIG_ESP_DEFAULT_CPU_FREQ_MHZ_240=yin sdkconfig.defaults) for 1.5x. Not flipped here, because it is a power and thermal choice per demo, not a fix. - Register allocation or literal hoisting in the xtensa backend (Track A, O-tag). It would help every xtensa program, not just Python.
- Cheaper Variant arithmetic on 32-bit targets; the i386 row shows the 4-5x is not ESP-specific.