typoena.dev / stories / cold-boot
Kaizen · boot latency
Two and a half seconds too slow
1The goal
The metric is the honest one: milliseconds from power-on to boot: cursor ready, measured with esp_log_timestamp() — not the timer that starts at app_main and quietly misses the ~1.4 s of bootloader before it. Measure the wrong clock and you optimise a number the writer never feels.
Baseline against the 1.0 gate:
2Where the time goes
Everything after app_main runs concurrently, hidden under the splash waveform. Laid out on one time axis against the sub-three-second target, the current boot looks like this — the span in red is the one this kaizen goes after:
The current boot — the phases as they run today.
pre-appbootloader + PSRAM memtest — pure overhead
splashe-ink full refresh — the hard floor
storageSD → prefs → note → snippets, hidden under the waveform
renderfirst editor partial → cursor
palette walkruns on the boot path and starves it for the FatFS lock
Getting there meant treating every span as a hypothesis to kill. Tap a factor to see how it was tested and what the measurement said about the current method:
3The idea
The ESP-IDF startup-time guide enumerates the canonical levers; every idea below maps to a knob it documents. Diverge first, then keep only what attacks the verified cause — the pre-app overhead.
- chosenDisable the PSRAM memtest~0.55–0.74 sThe biggest single lever, and free on a soldered known-good part.
- chosenCPU 160 → 240 MHz~0.1–0.2 s + a faster device all daySpeeds every keystroke too — but it's what springs the walk trap.
- rejectedQIO flash at 80 MHz~0.1–0.2 sCan brick a marginal part on boot. Risk far outweighs the gain.
- inertBootloader log level NONE<0.05 sInert on our flash path; kept only for a future OTA image.
- deferredSlim the app image (LTO / opt-size)~0.05–0.15 sLow gain for the effort and regression risk.
- deferredDeep sleep instead of cold bootsecondsA different power UX entirely — its own kaizen.
The chosen change is one coherent block: memtest off + 240 MHz — the two biggest levers, both a few minutes' work, neither risking anything on the display. QIO is deliberately rejected: a ~0.1 s gain isn't worth a brick.
4What could go wrong
The risk worth planning for wasn't a mistake in the change itself. A faster CPU can expose a concurrency bug that stayed hidden only because everything used to be slower — which is exactly what happened.
Stability
The 240 MHz CPU tightens the walk's readdir loop so it out-competes the boot read for the FatFS lock — and cursor-ready regresses.
→ Exactly what happened (7.3 s). Fix: spawn the walk after the first editor frame, off the hot path.
Stability
Memtest-off could let a defective PSRAM part boot silently.
→ Acceptable on a soldered, known-good N16R8. Re-enable it temporarily when hand-wiring a new board.
Method
sdkconfig edits don't rebuild through a plain cargo build — you flash the OLD config, it exits 0, and you measure the wrong thing.
→ Also happened. Bust the esp-idf-sys fingerprint, then verify the regenerated out/sdkconfig before trusting a number.
Rollback: the levers and the walk move are one perf(boot) commit, so a single revert restores the old, coherent state — no half-config that reintroduces the regression.
5The change
Two edits. A boot-time block in the config, and the palette walk relocated past the first frame — the fix for the trap step 4 predicted.
6The result
A solid, zero-risk win that still missed the gate by 239 ms. The last stretch is a different problem: the ~2.2 s splash waveform is the floor now, and the removable overhead is spent. The only lever left is a faster waveform — a custom 0x32 LUT, which is its own kaizen, gated on a display-quality call about ghosting.
The same timeline from step 2, now with the change applied. Flip it to watch the red slice go and the palette walk jump to the far side of the cursor-ready line:
pre-appbootloader + PSRAM memtest — pure overhead
splashe-ink full refresh — the hard floor
storageSD → prefs → note → snippets, hidden under the waveform
renderfirst editor partial → cursor
palette walkruns on the boot path and starves it for the FatFS lock
What it taught
- — A perf lever can expose a latent concurrency bug: 240 MHz didn't slow the walk, it changed the lock scheduling. The trap materialised exactly as predicted.
- — Beware inert levers (the bootloader-log knob on the espflash path) and the sdkconfig stale-cache trap — both can make you measure a lie.
- — The reusable invariant: never let a background SD walker hold the FatFS lock on the boot hot path. Spawn it after the critical reads and the first paint.