📚 More on this topic: LLM on a Microcontroller: ESP32-S3 at 22.6 tok/s · Why Your Local LLM Is Slow · Does RAM Speed Matter for Local LLMs? · InsiderLLM Benchmarks

If you want a small language model running on a battery, tokens per second is the wrong number to stare at. What drains the cell is energy per token. And on a microcontroller the board around the chip can burn as much as the work itself.

So I measured it. The model is the 28.9M-parameter TinyStories model from esp32-ai by slvDev (Viacheslav Sierbov), the one I took from 10.3 to 22.6 tok/s on an ESP32-S3. That figure is end to end with serial output included, or 43.3 ms per token of decode.

The S3 has got faster since. Rebuilding Espressif’s libraries with 64-byte data-cache lines cut decode to 32.7 ms at in-spec clocks and 25.8 ms at an experimental 120 MHz, against 42.5 ms on stock libraries, with identical output (record). Those two builds are S3-B and S3-D here.

The P4 runs the port Barkın Sarıkartal wrote in esp32-ai PR #24, plus four exact changes of mine on top. A logger on each board’s USB line counted every joule for five minutes of back-to-back generation, three times per board.

I wrote the predictions down first and locked them with a hash. Four held. One failed, and I already knew it would. More on that below.

The boxes:

  • ESP32-S3 N16R8, run as two builds:
    • S3-B: in-spec flash and PSRAM at 80 MHz;
    • S3-D: the same chip with experimental 120 MHz memory clocks.
  • Waveshare ESP32-P4-ETH: silicon v3.2 at 400 MHz.

All three ran greedy decoding on the same prompt and produced the same 200-token output.

Why energy, not just speed

A faster chip usually draws more power, so speed alone doesn’t tell you what a battery will see. Energy per token folds both together:

energy per token = average power × time per token

A board that draws more power but finishes each token in far less time can come out ahead. A board that idles hungrily pays for that on every token, whether it is computing or not.

There’s a second reason to measure it rather than estimate it. A microcontroller board’s power figure isn’t the SoC alone. The logger here sits on the USB VBUS line. So every number in this piece includes the USB-to-serial bridge, the power LED, the regulators and, on the P4-ETH, an Ethernet PHY that nothing in this test uses. Every figure is board-level. That is what a battery wired to the same board would supply. It isn’t what the SoC alone would use.

The results

Window energy is the headline metric. It’s the energy inside the firmware’s sync windows, which bracket each generation (prefill, decode, sampling and serial output), divided by the tokens produced. Mean of three 5-minute runs per board.

Boardms/tokenGenerate power (per-rep range)Idle powerWindow mJ/tokenTotal mJ/tokenMarginal mJ/tokenTokens per joule (window)
ESP32-P413.5720.5423–0.5431 W0.29952 W7.9097.98063.5760126.4
ESP32-S3-D26.3080.4468–0.4498 W0.20707 W12.35712.42976.682680.9
ESP32-S3-B33.4010.4199–0.4249 W0.20408 W14.70914.79957.656268.0

Error bars, from the rep spread plus the reference meter’s accuracy:

BoardWindow mJ/tokenTotal mJ/tokenMarginal mJ/token
P47.8334–7.98507.9046–8.05683.2919–3.8571
S3-D12.2148–12.524012.2885–12.59566.2688–7.1167
S3-B14.4718–14.919114.5619–15.01047.0809–8.1985

The P4 board uses about 46% less energy per token than the S3-B, from the headline table’s window means (1 − 7.909/14.709). That’s while drawing more power the whole time.

The three metrics answer different questions:

  • Total spreads the whole 5-minute window’s energy over the tokens.
  • Window counts only the energy inside the generations. It’s almost the same as total here, because the generations run back to back.
  • Marginal subtracts what the board draws sitting idle, which I’ll come to.

Faster is cheaper

Look at the two S3 builds first, because they isolate the effect. Same chip, same board, same model, same output. The only difference is the memory clock: 120 MHz on S3-D against 80 MHz on S3-B.

S3-BS3-D
ms/token33.40126.308
Generate power0.4199–0.4249 W0.4468–0.4498 W
Window mJ/token14.70912.357

S3-D draws more power in every rep and still spends less energy per token, because the time it saves outweighs the extra draw. On this board, the cheapest token is the fastest one.

The P4 is the same story, only stronger. Its board draws 0.5423–0.5431 W while generating, the most of the three. But at 13.572 ms per token it finishes each token in well under half the S3-B’s time, and the energy per token falls to 7.909 mJ.

The practical reading: if you’re picking a part for a battery-powered model, don’t reject the faster chip because its datasheet power is higher. The time per token carries most of the weight.

The idle tax

Here’s the part worth slowing down for. Sitting at its prompt after a fresh reset, doing nothing, each board draws about half of what it draws while generating:

BoardIdle as a share of generate powerMarginal as a share of total J/token
ESP32-S3-B48%52%
ESP32-S3-D46%54%
ESP32-P455%45%

That’s where the two energy numbers split:

  • Total J/token is what a battery would see if the board did nothing but generate. The idle draw is part of every token.
  • Marginal J/token subtracts the idle draw. It’s the cost of generation on a board that is powered on anyway, say one that is already up to run a sensor or a display.

On marginal, the gap widens: 3.5760 mJ for the P4 against 7.6562 for the S3-B, or 279.64 against 130.63 tokens per joule (the RESULTS “Tokens per joule” table). The P4 gets more out of each extra joule than its board-level total suggests, because more of its total is idle.

That idle floor is the board, not the model. On the P4-ETH it includes an Ethernet PHY that this test never uses. Every regulator and bridge chip on the board takes its share whether you’re generating or not. If you’re building something that runs on a battery, the board you choose, and what you can switch off on it, matters as much as the chip.

Prediction scorecard

I wrote five predictions before any generate run. The file was locked on 2026-10-14 at 16:56 PT, with sha256 b75eb427b6a54b39c2fe750aa76b907ffdb0c00007a6ea0f2500d6f3dadc9e04, and pushed to a private GitHub repo before any generate run. The scoring rule was strict: a prediction holds only if its whole error bar satisfies it.

Prediction (verbatim)ResultVerdict
P1“S3-B total energy is 10-25 mJ/token.”14.5619–15.0104 mJHeld
P2“P4 total J/token is lower than S3-B’s (its 2.3x speed outweighs its higher board power).”P4 − S3-B = −7.1058 to −6.5051 mJHeld
P3“P4 marginal J/token is <= 0.6x S3-B’s.”ratio 0.4015–0.5447Held
P4“S3-D total J/token is lower than S3-B’s (speed rises more than power).”S3-D − S3-B = −2.7219 to −1.9664 mJHeld
P5“P4 idle power exceeds S3 idle by >= 0.2 W (Ethernet PHY plus the bigger chip).”+0.0954 W vs S3-B, +0.0925 W vs S3-DFailed

What I knew before the lock:

  • P5’s outcome. The idle measurements had to run before the lock, so P5’s result was already in hand. I kept it as written rather than drop or reword it. The P4 board does draw more at idle than the S3, but by under 0.1 W, less than half the 0.2 W I predicted: 0.09055–0.10034 W against S3-B, 0.08740–0.09767 W against S3-D.
  • The pre-run check figures: 14.37 mJ (S3-B), 12.19 mJ (S3-D) and 7.83 mJ (P4) per token, from short checks of the sync-pin builds before the main runs. A generate window is back-to-back generations, so those figures anticipated P1, P2 and P4.
  • No marginal figure. I computed none before the lock, so P3 is the one prediction that was genuinely blind.

How it was measured

No off-the-shelf USB meter could do this. I tried a cheap inline one as a cross-check, and it read 0.00 A on all three boards. It registers a phone load, so its threshold sits somewhere above about 110 mA, and the P4 averaged 108.74 mA while generating.

So I built a logger:

  • Hardware: an INA226 current and voltage monitor with a 0.1 Ω shunt, on a perfboard with a Raspberry Pi Pico (RP2040). It sits high side on VBUS, between the PC and the board.
  • Sampling: it streams every sample (about 826 Hz) to the PC. The Pico and the PC each integrate the energy independently, and the two must agree within 0.5% or the run fails.
  • Calibration: against an Aneng SZ20 multimeter, at five loads from 26 to 146 mA.
  • A suspect part: the INA226 behaves like a clone. It reports a genuine ID, but its raw voltage gain read about 5% high and its raw current gain about 6% low, both far outside the real part’s spec. Both channels were linear, so a gain-and-offset fit corrects them. The fit residuals came out at 0.12% or less on current. A recheck at the start of the measurement day read −0.01%.
  • The error bars use the multimeter’s published accuracy (±(0.5% + 3 digits) current, ±(0.2% + 10 digits) volts), about ±0.93% on energy at the generate operating points. The fit residuals don’t set the error bars.
  • Sync windows: each firmware build raises a GPIO pin for the duration of every generation (GPIO4 on the S3, GPIO6 on the P4). The logger timestamps those edges against its own samples, through an RC filter that removes switching glitches. That’s what lets it split “generating” from “between generations”.
  • Same output: the sync-pin builds were checked against their normal builds first. They gave identical greedy output, and ms/token within 1%.

Caveats

  • Board-level only. These are USB-port figures for these specific boards. A different S3 or P4 board, with a different regulator, bridge or PHY, would land somewhere else. The idle share in particular is a property of the board.
  • One board each. One S3 board and one P4 board, three runs each. Unit-to-unit spread isn’t measured.
  • A probable clone sensor. The calibration held through the session, but the INA226’s long-term gain stability isn’t established.
  • Marginal is slightly overstated. After a run, idle draw stays above the fresh-reset idle that marginal subtracts: +4 to +6% on S3-B, +5 to +8% on S3-D, +1.4 to +1.8% on the P4. So the true marginal figures are a little lower than shown, most of all on the S3-D.
  • This bench runs the S3 about 2% slow. S3-B ran 33.40 ms/token here against 32.7 on its own benchmark, and S3-D 26.31 against 25.8. The P4 ran 13.57 against 13.7, slightly fast. It isn’t the sync pin, since the normal builds show the same offset. I haven’t explained it.
  • The cross-check meter couldn’t see the load, as above, so the logger has no independent instrument behind it beyond its calibration.

Sources and try it

Bottom line

If you’re putting a small model on a battery, measure energy per token, and pick for speed. Both comparisons here say the same thing: the build that finishes tokens faster spends less energy on each one, even when it draws more power. The ESP32-P4 board did it at 7.909 mJ per token against 14.709 for the in-spec S3.

Then look at the board, not just the chip. Half of each board’s generating draw is there at idle. If your project leaves the board powered between generations, the fastest chip on the leanest board is the one that wins.