typoena.dev / stories / per-keystroke
Kaizen · typing latency
The wall behind every keystroke
Analysis · 2026-07-16 – 07-21
- Kaizen 1Drive fewer gates
- 61
- Kaizen 2Raise the EPD SPI clock
- 61
- Kaizen 3Drop the settle delay
- 61
- Kaizen 4Author a faster waveform
- the floor?how close to instant?
Four rounds, each a full six-step kaizen. The trick that ties them into one story: each round's step 6 — the result — is the next round's step 1, the goal. A refutation hands the next round a sharper question; a win exposes the next cost underneath it. Follow the chain down.
None of these numbers were trustable until a prior bug was fixed: fast typing used to make the panel flap, because a partial refresh left one of the controller's two RAM banks two frames stale. Fixing the bank-toggle gave every refresh an honest per-band log — the instrument the whole campaign reads from.
Kaizen 1 · refuted
Drive fewer gates
K1·1 The goal
The metric is the honest one: milliseconds from a keystroke batch to the ink physically formed on the panel, read from the on-device {mode} refresh #N … {ms} ms log. Type-ahead absorbs keys during a refresh, so this is per batch, not per key. The first clean measurement:
K1·2 Where the time goes
A refresh is three serial costs: set the RAM window, clock the pixels out over SPI, then run the update waveform. The windowed and full-area numbers above are only 86 ms apart — which already says the waveform, not the rows, owns the clock. Tap each hypothesis for how it was tested:
K1·3 The idea
Diverge, then pick the one worth testing first. The candidates below are the standing menu; the pick is the boldest claim — that the waveform does scale with the gates driven, so restricting the scan to a 20-row band would run ~1/13 of it.
- chosenRestrict the gate scan (0x01 MUX + 0x0F)hoped ~7× on a one-line bandIf the waveform scaled with gates driven, a 20-row band would drive ~1/13 of the panel — the biggest possible win, and so the hypothesis worth killing first.
- parkedCustom partial LUT (0x32, authored)~100–200 ms (reported)The only lever that can move the waveform itself — but ghosting, DC balance, temperature, and no reference waveform to start from.
- next roundSPI clock 4 → 20 MHzthe SPI-transfer termDrains the transfer, not the waveform — K2's job, once the waveform is proven fixed.
- to testTemperature-select (0x1A sweep)?Does the partial LUT index temperature the way the fast-full one does? Untested — carried into K2.
- deferredAsync partial + deferred resync0 ms perceivedFrees the editor loop during BUSY, not the eye — the ink has already formed. Deferred.
K1·4 What could go wrong
The gate-scan registers are treacherous on this panel. The operating gate config is loaded from panel OTP at reset, can't be read back, and differs from the datasheet's power-on default — so writing 0x01 with the documented byte can mirror or garble the display. Mitigation: it's a spike on a branch, and the RAM buffer is the source of truth, so a bad paint self-heals on the next full refresh.
K1·5 The change
Program driver-output-control (0x01, MUX = band height) and the gate-scan start (0x0F) on both controllers, then run a normal windowed partial and read the log.
K1·6 The result
No gain — a 20-gate scan still ran 571 ms — and the POR scan-order byte flipped the panel vertically. Reverted as a write-only hazard. The waveform's BUSY time does not scale with the MUX on this panel: the ~543 ms is the ink forming, not the gate count.
Kaizen 2 · shipped
Drain the SPI transfer
K2·1 The goal
Carried straight from K1: the waveform is fixed, so attack the transfer. A full-screen update writes its band three times (a pre-kick 0x24, then a 0x26+0x24 resync — the same double-bank write that fixed the flapping bug). Re-measured on the day, the same-session reference was ~565 ms windowed and ~693 ms full-area.
Not the 543 / 629 from K1 — panel warmup and ambient drift move the absolute numbers ~10 % between sessions, so every delta here is quoted against a same-session baseline, not across days. Read the deltas, not the absolute milliseconds.
K2·2 Where the time goes
At 4 MHz the full-area band write is ~41 KB ≈ 86 ms; the windowed one-line band is ~3 KB ≈ 6 ms. So the SPI clock is a big lever on the full-area path (every delete, caret blink, mode-flip, and snackbar takes it) and barely a rounding error on plain typing. The bus clock sets only the pixel clock-out rate — never the waveform BUSY time — so raising it is safe in principle.
K2·3 The idea
One clear pick, plus the temperature question K1 parked — does the partial LUT index temperature like the fast-full one, opening a second lever?
- chosenEPD SPI clock 4 → 10 → 20 MHzdrains the ~86 ms band writeThe band write is the only rows-dependent cost, so a faster bus drains it. Largest effect expected on the full-area path — delete, caret, mode-flip, the snackbar.
- to testTemperature-select (0x1A sweep)?Does the partial LUT index temperature the way the fast-full one does? If so, a second lever on the waveform — worth one sweep to find out.
- parkedCustom partial LUT (0x32)~100–200 msStill the only lever on the waveform itself; still parked for its real costs.
- deferredAsync partial + deferred resync0 ms perceivedAfter the SPI bump the deferred work is ~6 ms — not worth the bank ping-pong that caused the toggling bug. Later closed for good.
K2·4 What could go wrong
20 MHz is at the SSD1683's ceiling on jumper wiring — signal integrity could give garbled or missing bands, maybe only intermittently in longer use. Blast radius is low: the RAM buffer is source of truth and a bad paint self-heals on the next refresh, and the fallback is 10 MHz, safely in-spec and still holding most of the win.
K2·5 The change
One line, then sweep it.
K2·6 The result
Same three numbers as a curve — because the shape is the reason to stop at 20, not any single bar. The full-area line bends down toward the flat typing floor and all but meets it; there is nothing left below to drain:
Kept 20 MHz. A full-panel repaint is now within ~6 ms of a one-line one — the SPI cost of driving all 272 rows has all but disappeared, and the path is essentially waveform-bound. Typing (windowed) stayed flat at ~565 ms, exactly as modelled: SPI was never its problem. And the temperature sweep came back flat — hot, cold, and default all landed the same, so that lever is closed. Panel clean through the test.
Kaizen 3 · shipped
The delay that was never 2 ms
K3·1 The goal
Handed down from K2: drain the last ~20 ms between the typing path (~565 ms) and the bare waveform (~543 ms). It's the only cost on the windowed path that isn't the LUT.
K3·2 Where the time goes
set_ram_area ends with a FreeRtos::delay_ms(2) after latching the RAM-window address, and a single partial calls it eight times. It looks like ~16 ms of overhead. It isn't:
K3·3 The idea
One idea, and it's a deletion. The settle was cargo-culted from an unrelated driver: an e-ink controller latches its RAM-window address when the SPI transaction completes — there is nothing to wait for, and upstream GxEPD2 never settles here. Set RAM_SETTLE_MS to 0.
K3·4 What could go wrong
A too-short latch would garble a band — wrong pixels the eye catches immediately — or creep ghosting over a long partial streak. Watch the panel while typing; the periodic full refresh every 64 updates clears cumulative ghosting; fall back to RAM_SETTLE_MS = 1 (a full tick) if 0 ever corrupts.
K3·5 The change
K3·6 The result
Windowed typing dropped ~565 → ~495 ms and the full-area path ~571 → ~527 ms, across 36 refreshes with a healthy log and a user-attested clean panel — no missing or garbled bands. The drop lands squarely inside the 0–80 ms the tick math predicted, so the attribution holds. Both paths now sit within one FreeRTOS tick of the bare partial waveform.
Rounds 1–3 · the wall
The wall the campaign found
The first three rounds on one time axis: a full-screen refresh — the update behind a delete, caret, mode-flip, or snackbar — before them and after. Flip it and watch the settle and transfer slices collapse while the waveform doesn't move a pixel. The dashed instant line is where none of this ever reaches.
settle8× tick-rounded RAM-window delays — 0–80 ms of nothing
transferclocking the band out at 4 MHz, three times over
waveformthe partial LUT phase schedule — the wall
Full-area landed at ~527 ms and typing at ~495 — the bare factory partial, ±15 V dragging ink through fluid. Three rounds found the wall, proved where it is, and drained everything standing in front of it. What was left was the waveform itself, and for a year that was the end of the road: the one lever on it is authoring a custom 0x32 LUT, and this panel had no reference schedule to author from — a hand-guessed A2 waveform ran fast but never darkened a pixel.
Kaizen 4 · opt-in
Author a faster waveform
K4·1 The goal
Handed down from the wall: typing sits at ~495 ms, and every lever so far only drained what wasn't the waveform, then stopped. The one thing left is the waveform. Load a real 0x32 LUT and get the per-keystroke number below the ~495 ms floor this whole story has been fighting.
K4·2 Where the time goes
The factory partial waveform is tuned for the worst case it will ever meet — a cold panel, humid air, unit-to-unit spread, years of aging. On a warm, fresh bench panel that margin is slack, and the slack is spare frames.
K4·3 The idea
Two knobs in the new waveform looked like they'd shorten the playback; only one did. The first — phase count — is what Good Display's own fast-full LUT uses, zeroing whole phase-rows to speed a refresh, so we tried the same on the partial: 12 active phases down to 8. It bought ~2 %. The partial is already so short (each phase ≈ 2.5 ms) that dropping frames barely registers, and the trim was kept only because it's harmless. The real knob is the second: the FR (frame-rate) byte in the LUT tail, which scales the whole waveform clock — run the same phases faster and the ink saturates sooner.
K4·4 What could go wrong
We're spending the vendor's safety margin, so the risks are the exact ones it was margin against. Cold is first: ink migrates slower when cold, so a clock that's solid black on a warm bench can go grey in a cold room where the factory tail still had headroom — which is why the fast LUT is opt-in, default off, not shipped. Longevity is second: faster frames stress DC balance harder per partial, so the de-ghost full refresh runs twice as often on this path (every 32 updates, not 64) to reset panel state. Both surface over a long real session, not a short bench — so the bench number is a start, not a verdict.
K4·5 The change
Load the real vendor waveform through 0x32, gated behind a fast_partial pref so only the additive Insert path drives it, then set one byte in the tail.
K4·6 The result
The bars can't show why 0x08 and not further — the curve can. It's a valley, not a slope: both the typing path and the full-area fallback bottom out at 0x08 and rise again by 0x0C, which is slower and ghostier. And both broke below the factory floor this story fought for four rounds:
~265 ms — the first number in this whole story under the ~495 ms partial floor, with the ink still solid black and no ghosting seen across the bench streak. It isn't more drive; it's less wasted drive: 0x08 runs the clock fast enough that the ink saturates and stops, the tail the vendor kept for the cold cut away. A bonus fell out of it — once a fast refresh loads the custom LUT into the controller's RAM, the factory 0xFF fallbacks behind delete, caret, and mode-switch reuse it until the next full refresh, so those dropped to ~300 ms too. Device-confirmed on 2026-07-21.
One loose end before calling it. FR shortens the waveform half of a refresh; the other half is the power cycle — trigger 0xCF ramps the ±15 V charge pump up, plays the waveform, then powers it down again, on every keystroke. If the ~265 ms hid a booster soft-start, keeping the pump energized between keystrokes (0xCC, so keystrokes 2..N skip the ramp) would expose it: the first partial after a power-down full refresh would run slower than the mid-burst ones. It didn't.
The ~265 ms is waveform BUSY end to end, not charge-pump soft-start — which retires an old guess that the floor was mostly booster ramp. 0xCC is safe (no corruption, no extra ghosting) but bought nothing here, and holding the pump hot burns rail current through every Insert-mode pause, so it's reverted to default off with the toggle left wired for a future same-session A/B. Round 1 refuted driving fewer rows; round 4 closes by refuting drawing less power — both dead ends that pin the number on the one thing left: the waveform.
The full story
The floor moved
Four rounds in, the chain fills in — the verdicts and deltas it held back at the top:
- Kaizen 1Drive fewer gatesREFUTED+28 ms · hazard
- 61
- Kaizen 2Raise the EPD SPI clockSHIPPEDfull-area −122 ms
- 61
- Kaizen 3Drop the settle delaySHIPPEDtyping −70 ms
- 61
- Kaizen 4Author a faster waveformOPT-INtyping −230 ms
- the floor~265 msink physics
The typing path across the whole campaign — a single character, from before the first round to the custom waveform:
For three rounds the honest answer was that we'd found the wall and could only drain what stood in front of it. The fourth round moved the wall — without beating physics. ~265 ms clears the ≤ 300 ms the product aims for, but it's still a waveform playing on the panel, still more than twice the ~100 ms the eye reads as instant, still ink dragged through fluid by ±15 V. What changed is whose schedule runs it: on a warm, fresh panel we stopped paying the factory's cold-and-aging margin and authored a shorter one. That margin is why it isn't the default — 0x08 rides behind an opt-in pref, off unless you ask for it, with a cold-room check and a longevity soak still to run before it could ship on by default. The wall is lower now. It's still a wall.
What it taught
- — Killing the gate-scan idea was the most useful step in the campaign: it proved the waveform is fixed, and that one fact set the target for every round after it — drain what isn't the waveform, then go author what is.
- — A literal in the source lied. delay_ms(2) was really 0–10 ms per call against a 10 ms tick — trust the tick math, not the number typed in the code. The biggest cheap win looked like a rounding error.
- — Measured timings encode architecture. 543 vs 629 ms said "the waveform drives all gates regardless" before any theory did — the number scoped the whole campaign.
- — A parked lever isn't a dead one. Authoring the waveform sat untouched for a year on one missing input — a reference schedule for this exact panel. The vendor's LUT_DATA_part was the unlock; the lever never changed, the input did.
- — Quote deltas within a session. Panel warmup moves absolutes ~10 %; every win here is a same-session before/after, never a cross-day subtraction.