summaryrefslogtreecommitdiff
path: root/experiments
diff options
context:
space:
mode:
authorGabriel Schneider <[email protected]>2026-08-25 18:41:18 -0300
committerGabriel Schneider <[email protected]>2026-08-25 18:41:18 -0300
commit1cef9c2e4bd873ebe13f5df635899231bcc467d2 (patch)
treece0a9495bc801666ff14da47f7a75728335cc442 /experiments
parentef6f3e460ddbf53a752807bcf10a0fec0a72da7f (diff)
downloadesp32p4-1cef9c2e4bd873ebe13f5df635899231bcc467d2.tar.gz
esp32p4-1cef9c2e4bd873ebe13f5df635899231bcc467d2.zip
A measuring instrument, and what it says about where the latency goes
"Too slow for interactive use" is a real complaint and not a number. This adds the number, and the number says the wire is innocent. ## The instrument `tools/perfproto.zig` is a small framed protocol - "P4", op, length, CRC-32 of the payload, payload - shared VERBATIM by the host tool and `examples/uartperf.zig`, so a frame one writes and the other parses cannot drift. It is imported as a module by both, not copied. The checksum is the whole point. RX overrun on this UART is undetected in hardware and uncounted in the driver, so a byte that never arrived is indistinguishable from a late one; a throughput figure that is not checksummed is a guess about how fast data was corrupted. `sink` accumulates a CRC over every payload byte the board received and `report` hands it back, so the host can prove that what arrived is what it sent. `tools/rtt.zig` is the two timing functions: `roundTrip` and `measure`. Round trip is to the FIRST response byte, deliberately. A renderer that starts drawing in 8 ms and finishes in 130 ms feels immediate; one that thinks for 130 ms and then draws in 8 ms feels broken; waiting for the wire to fall quiet cannot tell them apart. Time to the last byte is recorded separately as `settle`. Microseconds, because at 115200 one byte is 87 us and a millisecond clock quantises the answer into buckets eleven bytes wide. `tools/bench_main.zig` is `p4-bench`: `--link` for the ceiling, `--editor` for how much of it the editor uses, `--sweep` for one controlled variable at a time with `--csv` raw per-trial output. ## What it measured The link is essentially perfect: 11,496 B/s up and 11,413 B/s down, 99.8% of capacity in both directions, CRC verified over 32,768 B each way, zero corruption. Typing at 6 to 100 keys/s loses nothing and never uses more than 9% of the wire, so H5 - "typing loses input" - is refuted. Latency is compute per input event, not transmission. A 40-byte motion and a 206-byte insert-and-escape cost the SAME round trip to within 0.3 ms, across a five-fold range of output. That is why raising the baud cannot fix typing: there is almost no wire in it. And an edit costs the whole document. Round trip against characters already in the line is a straight line at 54.3 us per character per keystroke - 17.0 ms at an empty line, 25.6 ms at 160. On a ~90 MHz core that is ~5,000 cycles per character, far more than a copy alone, so the full-buffer copy the source does is accompanied by at least one more full pass. One controlled intervention: building the editor object ReleaseFast instead of ReleaseSmall cuts the fixed cost 13% and the per-character cost 36%, for 35% more flash (809,536 B of a 1,536,000 B partition). Its advantage grows with the document. Nothing else measured comes close to that ratio. ## Three bugs found while building it The responder printed garbage and looked dead: it read `.rodata` before evicting the bootloader's stale cache lines. `flushFlashCache` moved from `src/pardes/app.zig` to `soc.zig` with its measured evidence, since every application that touches `.rodata` after hand-over needs it and exactly one file knew that. Then it booted, printed its marker and went silent after ten seconds: `rst:0x10 (CHIP_LP_WDT_RESET)`. The bootloader arms the RTC watchdog and expects the application to take it over. Only the editor ever did. `serial.Port.drain()` drains INPUT, not output - so timing a transfer to it reported 202% of the wire's capacity and ate the reply. Added `flushOutput` (tcdrain), named so the two cannot be confused again. Also: Zig 0.16 emits an explicit `+` for a non-negative SIGNED integer whenever a width is given (std/Io/Writer.zig:1548-1559), which put a `+` in front of every number in the first tables. ## The report `experiments/report.typ` reads the raw CSVs and computes its own figures, so a re-run changes the document instead of contradicting it. It states five hypotheses, settles each against one experiment, and is explicit about the one that failed: the geometry sweep is confounded, because characters accumulated across conditions and the length experiment then proved that matters. It is reported as unsupported rather than dressed up as a result.
Diffstat (limited to 'experiments')
-rw-r--r--experiments/length-ReleaseFast.csv36
-rw-r--r--experiments/length-ReleaseSmall.csv36
-rw-r--r--experiments/ops-ReleaseFast.csv50
-rw-r--r--experiments/ops-ReleaseSmall.csv48
-rw-r--r--experiments/report.typ435
5 files changed, 605 insertions, 0 deletions
diff --git a/experiments/length-ReleaseFast.csv b/experiments/length-ReleaseFast.csv
new file mode 100644
index 0000000..043fb83
--- /dev/null
+++ b/experiments/length-ReleaseFast.csv
@@ -0,0 +1,36 @@
+label,experiment,cols,rows,length,op,rep,rtt_us,settle_us,bytes
+ReleaseFast,length,0,0,0,insert,0,15072,26542,141
+ReleaseFast,length,0,0,0,insert,1,14793,20959,80
+ReleaseFast,length,0,0,0,insert,2,14767,20944,81
+ReleaseFast,length,0,0,0,insert,3,14794,20970,81
+ReleaseFast,length,0,0,0,insert,4,14808,20937,81
+ReleaseFast,length,0,0,0,insert,5,14875,20991,81
+ReleaseFast,length,0,0,0,insert,6,14870,20971,81
+ReleaseFast,length,0,0,20,insert,0,15305,21531,81
+ReleaseFast,length,0,0,20,insert,1,15317,21534,81
+ReleaseFast,length,0,0,20,insert,2,15425,21719,81
+ReleaseFast,length,0,0,20,insert,3,15408,21759,81
+ReleaseFast,length,0,0,20,insert,4,15513,21702,81
+ReleaseFast,length,0,0,20,insert,5,15821,28801,158
+ReleaseFast,length,0,0,20,insert,6,15740,21843,80
+ReleaseFast,length,0,0,40,insert,0,16198,22320,81
+ReleaseFast,length,0,0,40,insert,1,16261,22352,81
+ReleaseFast,length,0,0,40,insert,2,16229,22505,81
+ReleaseFast,length,0,0,40,insert,3,16309,22374,81
+ReleaseFast,length,0,0,40,insert,4,16312,22466,81
+ReleaseFast,length,0,0,40,insert,5,16289,22563,81
+ReleaseFast,length,0,0,40,insert,6,16341,22625,81
+ReleaseFast,length,0,0,80,insert,0,17772,23963,81
+ReleaseFast,length,0,0,80,insert,1,17803,23933,81
+ReleaseFast,length,0,0,80,insert,2,17817,24108,81
+ReleaseFast,length,0,0,80,insert,3,17818,23893,81
+ReleaseFast,length,0,0,80,insert,4,17917,24037,81
+ReleaseFast,length,0,0,80,insert,5,17919,24158,81
+ReleaseFast,length,0,0,80,insert,6,17920,24078,81
+ReleaseFast,length,0,0,160,insert,0,20271,26409,81
+ReleaseFast,length,0,0,160,insert,1,20230,26412,81
+ReleaseFast,length,0,0,160,insert,2,20249,26522,81
+ReleaseFast,length,0,0,160,insert,3,20302,26487,81
+ReleaseFast,length,0,0,160,insert,4,20648,33557,158
+ReleaseFast,length,0,0,160,insert,5,20563,26664,80
+ReleaseFast,length,0,0,160,insert,6,20699,26920,81
diff --git a/experiments/length-ReleaseSmall.csv b/experiments/length-ReleaseSmall.csv
new file mode 100644
index 0000000..eb6fa1a
--- /dev/null
+++ b/experiments/length-ReleaseSmall.csv
@@ -0,0 +1,36 @@
+label,experiment,cols,rows,length,op,rep,rtt_us,settle_us,bytes
+ReleaseSmall,length,0,0,0,insert,0,17211,28542,141
+ReleaseSmall,length,0,0,0,insert,1,16894,23031,80
+ReleaseSmall,length,0,0,0,insert,2,16865,23038,81
+ReleaseSmall,length,0,0,0,insert,3,17009,23097,81
+ReleaseSmall,length,0,0,0,insert,4,16986,23146,81
+ReleaseSmall,length,0,0,0,insert,5,16957,23253,81
+ReleaseSmall,length,0,0,0,insert,6,17090,23114,81
+ReleaseSmall,length,0,0,20,insert,0,17678,23892,81
+ReleaseSmall,length,0,0,20,insert,1,17736,24079,81
+ReleaseSmall,length,0,0,20,insert,2,17871,24024,81
+ReleaseSmall,length,0,0,20,insert,3,17942,24029,81
+ReleaseSmall,length,0,0,20,insert,4,17975,24117,81
+ReleaseSmall,length,0,0,20,insert,5,18468,31324,158
+ReleaseSmall,length,0,0,20,insert,6,18378,24470,80
+ReleaseSmall,length,0,0,40,insert,0,19108,25325,81
+ReleaseSmall,length,0,0,40,insert,1,19099,25452,81
+ReleaseSmall,length,0,0,40,insert,2,19152,25335,81
+ReleaseSmall,length,0,0,40,insert,3,19155,25357,81
+ReleaseSmall,length,0,0,40,insert,4,19247,25475,81
+ReleaseSmall,length,0,0,40,insert,5,19236,25463,81
+ReleaseSmall,length,0,0,40,insert,6,19343,25429,81
+ReleaseSmall,length,0,0,80,insert,0,21481,27587,81
+ReleaseSmall,length,0,0,80,insert,1,21516,27613,81
+ReleaseSmall,length,0,0,80,insert,2,21604,27728,81
+ReleaseSmall,length,0,0,80,insert,3,21654,27997,81
+ReleaseSmall,length,0,0,80,insert,4,21647,27764,81
+ReleaseSmall,length,0,0,80,insert,5,21588,27786,81
+ReleaseSmall,length,0,0,80,insert,6,21729,27953,81
+ReleaseSmall,length,0,0,160,insert,0,25373,31443,81
+ReleaseSmall,length,0,0,160,insert,1,25465,31612,81
+ReleaseSmall,length,0,0,160,insert,2,25469,31643,81
+ReleaseSmall,length,0,0,160,insert,3,25561,31595,81
+ReleaseSmall,length,0,0,160,insert,4,25994,39003,158
+ReleaseSmall,length,0,0,160,insert,5,25876,31853,80
+ReleaseSmall,length,0,0,160,insert,6,26039,32201,81
diff --git a/experiments/ops-ReleaseFast.csv b/experiments/ops-ReleaseFast.csv
new file mode 100644
index 0000000..ea42b87
--- /dev/null
+++ b/experiments/ops-ReleaseFast.csv
@@ -0,0 +1,50 @@
+label,experiment,cols,rows,length,op,rep,rtt_us,settle_us,bytes
+ReleaseFast,ops,0,0,0,motion_h,0,14839,17388,40
+ReleaseFast,ops,0,0,0,motion_h,1,14767,17434,40
+ReleaseFast,ops,0,0,0,motion_h,2,14721,17372,40
+ReleaseFast,ops,0,0,0,motion_h,3,14715,17395,40
+ReleaseFast,ops,0,0,0,motion_h,4,14695,17281,40
+ReleaseFast,ops,0,0,0,motion_h,5,14703,17284,40
+ReleaseFast,ops,0,0,0,motion_h,6,14717,17393,40
+ReleaseFast,ops,0,0,0,motion_l,0,14742,17302,40
+ReleaseFast,ops,0,0,0,motion_l,1,14746,17425,40
+ReleaseFast,ops,0,0,0,motion_l,2,14727,17342,40
+ReleaseFast,ops,0,0,0,motion_l,3,14723,17272,40
+ReleaseFast,ops,0,0,0,motion_l,4,14876,17380,40
+ReleaseFast,ops,0,0,0,motion_l,5,14704,17341,40
+ReleaseFast,ops,0,0,0,motion_l,6,14746,17328,40
+ReleaseFast,ops,0,0,0,line_start,0,14767,17478,40
+ReleaseFast,ops,0,0,0,line_start,1,14760,17396,40
+ReleaseFast,ops,0,0,0,line_start,2,14735,17355,40
+ReleaseFast,ops,0,0,0,line_start,3,14701,17364,40
+ReleaseFast,ops,0,0,0,line_start,4,14710,17286,40
+ReleaseFast,ops,0,0,0,line_start,5,14771,17453,40
+ReleaseFast,ops,0,0,0,line_start,6,14751,17226,40
+ReleaseFast,ops,0,0,0,line_end,0,14783,17424,40
+ReleaseFast,ops,0,0,0,line_end,1,14807,17443,40
+ReleaseFast,ops,0,0,0,line_end,2,14773,17284,40
+ReleaseFast,ops,0,0,0,line_end,3,14737,17408,40
+ReleaseFast,ops,0,0,0,line_end,4,14769,17508,40
+ReleaseFast,ops,0,0,0,line_end,5,14732,17412,40
+ReleaseFast,ops,0,0,0,line_end,6,14750,17268,40
+ReleaseFast,ops,0,0,0,insert_esc,0,14827,42161,265
+ReleaseFast,ops,0,0,0,insert_esc,1,14908,36678,204
+ReleaseFast,ops,0,0,0,insert_esc,2,14983,36759,206
+ReleaseFast,ops,0,0,0,insert_esc,3,14988,36851,206
+ReleaseFast,ops,0,0,0,insert_esc,4,14906,36822,206
+ReleaseFast,ops,0,0,0,insert_esc,5,15050,36865,206
+ReleaseFast,ops,0,0,0,insert_esc,6,15059,36952,206
+ReleaseFast,ops,0,0,0,repaint_39,0,14926,32405,81
+ReleaseFast,ops,0,0,0,repaint_39,1,14936,32122,80
+ReleaseFast,ops,0,0,0,repaint_39,2,14895,32294,80
+ReleaseFast,ops,0,0,0,repaint_39,3,14975,32342,80
+ReleaseFast,ops,0,0,0,repaint_39,4,15026,32363,80
+ReleaseFast,ops,0,0,0,repaint_39,5,14968,32224,80
+ReleaseFast,ops,0,0,0,repaint_39,6,14885,32342,80
+ReleaseFast,ops,0,0,0,repaint_40,0,14876,32419,80
+ReleaseFast,ops,0,0,0,repaint_40,1,14866,32211,80
+ReleaseFast,ops,0,0,0,repaint_40,2,14868,32173,80
+ReleaseFast,ops,0,0,0,repaint_40,3,14888,32329,80
+ReleaseFast,ops,0,0,0,repaint_40,4,14869,32208,80
+ReleaseFast,ops,0,0,0,repaint_40,5,14863,32277,80
+ReleaseFast,ops,0,0,0,repaint_40,6,14876,32143,80
diff --git a/experiments/ops-ReleaseSmall.csv b/experiments/ops-ReleaseSmall.csv
new file mode 100644
index 0000000..daa51f4
--- /dev/null
+++ b/experiments/ops-ReleaseSmall.csv
@@ -0,0 +1,48 @@
+label,experiment,cols,rows,length,op,rep,rtt_us,settle_us,bytes
+ReleaseSmall,ops,0,0,0,motion_h,0,16719,19345,40
+ReleaseSmall,ops,0,0,0,motion_h,1,16770,19401,40
+ReleaseSmall,ops,0,0,0,motion_h,2,16666,19240,40
+ReleaseSmall,ops,0,0,0,motion_h,3,16741,19292,40
+ReleaseSmall,ops,0,0,0,motion_h,4,16765,19437,40
+ReleaseSmall,ops,0,0,0,motion_h,5,16699,19235,40
+ReleaseSmall,ops,0,0,0,motion_h,6,16685,19257,40
+ReleaseSmall,ops,0,0,0,motion_l,0,16688,19418,40
+ReleaseSmall,ops,0,0,0,motion_l,1,16826,19445,40
+ReleaseSmall,ops,0,0,0,motion_l,2,16781,19317,40
+ReleaseSmall,ops,0,0,0,motion_l,3,16694,19326,40
+ReleaseSmall,ops,0,0,0,motion_l,4,16677,19256,40
+ReleaseSmall,ops,0,0,0,motion_l,5,16645,19276,40
+ReleaseSmall,ops,0,0,0,motion_l,6,16709,19276,40
+ReleaseSmall,ops,0,0,0,line_start,0,16795,19362,40
+ReleaseSmall,ops,0,0,0,line_start,1,16698,19319,40
+ReleaseSmall,ops,0,0,0,line_start,2,16820,19407,40
+ReleaseSmall,ops,0,0,0,line_start,3,16716,19287,40
+ReleaseSmall,ops,0,0,0,line_start,4,16760,19405,40
+ReleaseSmall,ops,0,0,0,line_start,5,16725,19450,40
+ReleaseSmall,ops,0,0,0,line_start,6,16769,19315,40
+ReleaseSmall,ops,0,0,0,line_end,0,16681,19296,40
+ReleaseSmall,ops,0,0,0,line_end,1,16628,19240,40
+ReleaseSmall,ops,0,0,0,line_end,2,16796,19343,40
+ReleaseSmall,ops,0,0,0,line_end,3,16678,19242,40
+ReleaseSmall,ops,0,0,0,line_end,4,16703,19396,40
+ReleaseSmall,ops,0,0,0,line_end,5,16693,19438,40
+ReleaseSmall,ops,0,0,0,line_end,6,16699,19341,40
+ReleaseSmall,ops,0,0,0,insert_esc,0,16784,46055,265
+ReleaseSmall,ops,0,0,0,insert_esc,1,16819,40714,204
+ReleaseSmall,ops,0,0,0,insert_esc,2,17244,37382,206
+ReleaseSmall,ops,0,0,0,insert_esc,3,16897,40737,206
+ReleaseSmall,ops,0,0,0,insert_esc,4,17236,37425,206
+ReleaseSmall,ops,0,0,0,insert_esc,5,17006,40907,206
+ReleaseSmall,ops,0,0,0,insert_esc,6,17159,41133,206
+ReleaseSmall,ops,0,0,0,repaint_39,0,17034,36306,81
+ReleaseSmall,ops,0,0,0,repaint_39,1,16983,36314,80
+ReleaseSmall,ops,0,0,0,repaint_39,2,17159,36171,80
+ReleaseSmall,ops,0,0,0,repaint_39,3,16913,36217,80
+ReleaseSmall,ops,0,0,0,repaint_39,4,10054,134042,1253
+ReleaseSmall,ops,0,0,0,repaint_39,5,16638,35615,80
+ReleaseSmall,ops,0,0,0,repaint_40,0,10018,135552,1265
+ReleaseSmall,ops,0,0,0,repaint_40,1,17099,36357,80
+ReleaseSmall,ops,0,0,0,repaint_40,2,16988,36299,80
+ReleaseSmall,ops,0,0,0,repaint_40,4,17031,36233,80
+ReleaseSmall,ops,0,0,0,repaint_40,5,17013,36330,80
+ReleaseSmall,ops,0,0,0,repaint_40,6,16957,36281,80
diff --git a/experiments/report.typ b/experiments/report.typ
new file mode 100644
index 0000000..1d859b8
--- /dev/null
+++ b/experiments/report.typ
@@ -0,0 +1,435 @@
+#import "@preview/cetz:0.5.1"
+
+#set document(title: "Where the ESP32-P4 editor's latency goes", author: "measured on ESP32-P4 rev v1.3")
+#set page(margin: 2cm, numbering: "1")
+#set text(font: ("Libertinus Serif", "DejaVu Serif"), size: 10.5pt)
+#set par(justify: true)
+#show raw: set text(font: "DejaVu Sans Mono", size: 9pt)
+#show heading: it => block(above: 1.4em, below: 0.7em, it)
+
+#align(center)[
+ #text(17pt, weight: "bold")[Where the ESP32-P4 editor's latency goes]
+
+ #v(0.2em)
+ #text(10pt)[A measured account, on silicon, of the `pardes` editor running as
+ ESP32-P4 firmware with one 115200-baud serial line as its only I/O]
+]
+
+#v(0.5em)
+
+#let capacity = 11520.0
+
+// ---------------------------------------------------------------- data plumbing
+// The figures read the raw per-trial CSV the instrument emits. Nothing here is a
+// transcribed number: if a run is repeated, the document changes with it.
+#let rows(file) = {
+ let out = ()
+ for r in csv(file) {
+ if r.at(0) == "label" { continue }
+ out.push((
+ label: r.at(0), cols: int(r.at(2)), rows: int(r.at(3)),
+ length: int(r.at(4)), op: r.at(5),
+ rtt: int(r.at(7)) / 1000.0, settle: int(r.at(8)) / 1000.0, bytes: int(r.at(9)),
+ ))
+ }
+ out
+}
+
+#let median(xs) = {
+ let s = xs.sorted()
+ if s.len() == 0 { return 0.0 }
+ s.at(int(s.len() / 2))
+}
+
+#let length_rows = rows("length-ReleaseSmall.csv") + rows("length-ReleaseFast.csv")
+#let ops_rows = rows("ops-ReleaseSmall.csv") + rows("ops-ReleaseFast.csv")
+
+#let builds = ("ReleaseSmall", "ReleaseFast")
+#let lengths = (0, 20, 40, 80, 160)
+
+#let med_rtt(rs, pred) = median(rs.filter(pred).map(r => r.rtt))
+#let n_of(rs, pred) = rs.filter(pred).len()
+
+// Least squares, for the slope that is the whole point of figure 2.
+#let fit(xs, ys) = {
+ let n = xs.len()
+ let mx = xs.sum() / n
+ let my = ys.sum() / n
+ let num = 0.0
+ let den = 0.0
+ for i in range(n) {
+ num += (xs.at(i) - mx) * (ys.at(i) - my)
+ den += (xs.at(i) - mx) * (xs.at(i) - mx)
+ }
+ let slope = num / den
+ (slope: slope, intercept: my - slope * mx)
+}
+
+// ---------------------------------------------------------------- summary
+= What was found
+
+The editor is *not* limited by its serial line. Measured against a checksum-verified
+protocol, the link carries #calc.round(11496 / capacity * 100, digits: 1)% of its theoretical
+capacity in both directions with zero corruption, and typing at 100 characters a
+second loses nothing and uses under a tenth of the wire.
+
+What limits it is *computation per input event*, and that cost has two parts, both
+measured here:
+
+#block(inset: (left: 1em))[
+ *A fixed cost of ≈#calc.round(med_rtt(ops_rows, r => r.label == "ReleaseSmall" and r.op == "motion_h"), digits: 1) ms per event*, which does not
+ depend on how much the screen changed. A cursor motion emitting 40 bytes and an
+ insert-and-escape emitting 206 bytes cost the same round trip to within 0.3 ms.
+
+ *A cost proportional to the document*, at
+ #calc.round(fit(lengths.map(l => l * 1.0), lengths.map(l => med_rtt(length_rows, r => r.label == "ReleaseSmall" and r.length == l))).slope * 1000, digits: 1) µs
+ per character already in the line, per keystroke. This is an $O(n)$ edit path,
+ and it is what makes the editor feel worse the more you have written.
+]
+
+One build-flag change — compiling the editor object `ReleaseFast` instead of
+`ReleaseSmall` — removes 13% of the fixed cost and 36% of the per-character cost,
+for 35% more flash. Nothing else measured here comes close to that ratio.
+
+= The instrument
+
+Two programs and a protocol, all in this repository.
+
+`tools/perfproto.zig` is a framed protocol shared *verbatim* by the host tool and
+the firmware, so a frame written by one and parsed by the other cannot drift:
+a nine-byte header (`"P4"`, op, length, CRC-32 of the payload) then the payload.
+`examples/uartperf.zig` answers it on the board; `tools/bench_main.zig` drives it
+from the host.
+
+The checksum is the point. RX overrun on this UART is undetected in hardware and
+uncounted in the driver, so a byte that never arrives is indistinguishable from a
+byte that arrived late. A frame with a length and a CRC turns both into facts: the
+board reports a CRC over exactly the bytes it received, the host compares it
+against a CRC over exactly the bytes it sent, and a throughput figure that is not
+checksummed is only a guess about how fast data was corrupted.
+
+`tools/rtt.zig` provides the two timing functions everything else is built on:
+`roundTrip`, which sends a stimulus and returns when the first response byte
+arrives, and `measure`, which repeats it. *Round trip is time to the first
+response byte, not to the last.* A renderer that begins drawing in 8 ms and
+finishes in 130 ms feels immediate; one that thinks for 130 ms and then draws in
+8 ms feels broken; measuring only when the wire falls quiet cannot tell them
+apart. The time to the last byte is recorded separately as `settle`.
+
+Microseconds throughout: at 115200 baud one byte occupies 87 µs, so a millisecond
+clock would quantise these measurements into buckets eleven bytes wide.
+
+= Method
+
+All numbers are from one ESP32-P4 rev v1.3 over its CH340 bridge at 115200 baud
+8N1, giving #capacity B/s in each direction. Each condition is measured
+#n_of(length_rows, r => r.label == "ReleaseSmall" and r.length == 0) times and
+reported as a median; the raw per-trial rows are in `experiments/*.csv` and this
+document computes its figures from them directly.
+
+#block(breakable: false)[
+```
+zig build flash -Dapp=examples/uartperf.zig # the link ceiling
+zig-out/bin/p4-bench --link
+
+zig build flash -Dpardes # the editor
+zig-out/bin/p4-bench --sweep length --repeat 7 --csv --label ReleaseSmall
+zig-out/bin/p4-bench --sweep ops --repeat 7 --csv --label ReleaseSmall
+```
+]
+
+The editor is modal, so every editor run enters insert mode once before timing and
+uses a single inserted character as the comparable unit of work. In the length
+experiment the line is primed to exactly $n$ characters *without* measuring, so the
+timed keystroke always sees a document of known size.
+
+= Baseline: the link is not the problem
+
+With only the protocol responder running — no editor — the link performs as well as
+it can:
+
+#figure(
+ table(
+ columns: (auto, auto, auto, auto),
+ align: (left, right, right, left),
+ stroke: none,
+ table.hline(),
+ table.header([direction], [measured], [of capacity], [integrity]),
+ table.hline(stroke: 0.5pt),
+ [uplink, host → board], [11 496 B/s], [99%], [CRC verified, 32 768 B],
+ [downlink, board → host], [11 413 B/s], [99%], [CRC verified, 32 768 B],
+ [round trip, 13 B each way], [4.2–4.5 ms], [--], [0 lost of 20],
+ table.hline(),
+ ),
+ caption: [The UART driver and the wire, with nothing else running. Every byte
+ accounted for by checksum.],
+)
+
+Of that 4.2 ms round trip, 2.26 ms is the wire itself (26 bytes at #capacity B/s);
+the remaining ≈2 ms is the host, the USB bridge and the firmware's parse. *This
+≈2 ms is the floor every editor measurement below sits on*, and subtracting it is
+how the editor's own share is obtained.
+
+= Hypotheses
+
+#figure(
+ table(
+ columns: (auto, 1fr, auto),
+ align: (left, left, left),
+ stroke: none,
+ table.hline(),
+ table.header([], [hypothesis], [verdict]),
+ table.hline(stroke: 0.5pt),
+ [H1], [Latency is compute-bound, not transmission-bound: round trip is
+ independent of how many bytes the operation emits.], [*confirmed*],
+ [H2], [The editor object's optimisation mode materially changes latency.], [*confirmed*],
+ [H3], [An edit is $O(n)$ in the document: round trip rises linearly with the
+ characters already in the line.], [*confirmed*],
+ [H4], [Cost is paid per screen cell, so a smaller grid is proportionally
+ cheaper.], [*not supported*],
+ [H5], [Typing at a human rate loses input.], [*refuted*],
+ table.hline(),
+ ),
+ caption: [Stated before measuring; each is settled by one experiment below.],
+)
+
+= Experiment 1 --- output size does not predict latency (H1)
+
+Seven operations, chosen to span a five-fold range of emitted bytes at a fixed
+40×12 geometry.
+
+#figure(
+ {
+ let names = ("motion_h", "motion_l", "line_start", "line_end", "insert_esc")
+ table(
+ columns: (auto, auto, auto, auto, auto),
+ align: (left, right, right, right, right),
+ stroke: none,
+ table.hline(),
+ table.header([operation], [bytes], [wire time], [round trip], [implied compute]),
+ table.hline(stroke: 0.5pt),
+ ..names.map(n => {
+ let rs = ops_rows.filter(r => r.label == "ReleaseSmall" and r.op == n)
+ let b = median(rs.map(r => r.bytes))
+ let t = median(rs.map(r => r.rtt))
+ (
+ raw(n),
+ [#b B],
+ [#calc.round(b / capacity * 1000, digits: 2) ms],
+ [#calc.round(t, digits: 2) ms],
+ [#calc.round(t - 2.0, digits: 2) ms],
+ )
+ }).flatten(),
+ table.hline(),
+ )
+ },
+ caption: [`ReleaseSmall`, 40×12, median of 7. Emitted bytes vary 5×; the round
+ trip does not vary at all. "Implied compute" subtracts only the ≈2 ms link floor,
+ because round trip is measured to the *first* byte and so does not contain the
+ transmission of the rest.],
+)
+
+A five-fold change in output moves the round trip by less than 2%. Whatever the
+editor is doing for ≈15 ms, it is doing before it emits anything, and it is not
+proportional to what changed on screen. *H1 is confirmed*, and it is the reason
+raising the baud rate cannot fix typing latency: there is almost no wire in it.
+
+= Experiment 2 --- an edit costs the whole document (H3, H2)
+
+The controlled variable is the number of characters already in the line. The
+measured quantity is unchanged: the round trip of one further inserted character.
+
+#figure(
+ cetz.canvas(length: 1cm, {
+ import cetz.draw: *
+ let w = 11.0
+ let h = 6.0
+ let xmax = 176.0
+ let ymax = 28.0
+ let px(v) = v / xmax * w
+ let py(v) = v / ymax * h
+
+ // axes
+ line((0, 0), (w + 0.3, 0), mark: (end: "straight"), stroke: 0.6pt)
+ line((0, 0), (0, h + 0.3), mark: (end: "straight"), stroke: 0.6pt)
+ content((w / 2, -0.85), text(9pt)[characters already in the line])
+ content((-1.15, h / 2), angle: 90deg, text(9pt)[round trip (ms)])
+
+ for l in lengths {
+ line((px(l), 0), (px(l), -0.12), stroke: 0.6pt)
+ content((px(l), -0.38), text(8pt)[#l])
+ }
+ for v in (0, 5, 10, 15, 20, 25) {
+ line((0, py(v)), (-0.12, py(v)), stroke: 0.6pt)
+ content((-0.42, py(v)), text(8pt)[#v])
+ if v > 0 { line((0, py(v)), (w, py(v)), stroke: (paint: luma(88%), thickness: 0.4pt)) }
+ }
+
+ let colours = (ReleaseSmall: rgb("#b3261e"), ReleaseFast: rgb("#1a5fb4"))
+ for b in builds {
+ let ys = lengths.map(l => med_rtt(length_rows, r => r.label == b and r.length == l))
+ let f = fit(lengths.map(l => l * 1.0), ys)
+ // fitted line, drawn under the data so the points remain readable
+ line(
+ (px(0), py(f.intercept)),
+ (px(xmax), py(f.intercept + f.slope * xmax)),
+ stroke: (paint: colours.at(b).lighten(55%), thickness: 1.6pt),
+ )
+ // every individual trial, so the spread is visible rather than asserted
+ for r in length_rows.filter(r => r.label == b) {
+ circle((px(r.length * 1.0), py(r.rtt)), radius: 0.045, fill: colours.at(b).lighten(30%), stroke: none)
+ }
+ line(..lengths.zip(ys).map(p => (px(p.at(0) * 1.0), py(p.at(1)))), stroke: (paint: colours.at(b), thickness: 1.1pt))
+ for p in lengths.zip(ys) {
+ circle((px(p.at(0) * 1.0), py(p.at(1))), radius: 0.075, fill: colours.at(b), stroke: none)
+ }
+ content(
+ (px(xmax) + 0.15, py(f.intercept + f.slope * xmax)),
+ anchor: "west",
+ text(8pt, fill: colours.at(b))[#b],
+ )
+ }
+ }),
+ caption: [Round trip against document length, every trial plotted (7 per point),
+ medians joined, least-squares fit behind. Both builds are straight lines: an edit
+ is $O(n)$ in the document.],
+)
+
+#figure(
+ table(
+ columns: (auto, auto, auto, auto),
+ align: (left, right, right, right),
+ stroke: none,
+ table.hline(),
+ table.header([build], [fixed cost], [per character], [round trip at 160 chars]),
+ table.hline(stroke: 0.5pt),
+ ..builds.map(b => {
+ let ys = lengths.map(l => med_rtt(length_rows, r => r.label == b and r.length == l))
+ let f = fit(lengths.map(l => l * 1.0), ys)
+ (
+ raw(b),
+ [#calc.round(f.intercept, digits: 2) ms],
+ [#calc.round(f.slope * 1000, digits: 1) µs],
+ [#calc.round(ys.last(), digits: 2) ms],
+ )
+ }).flatten(),
+ table.hline(),
+ ),
+ caption: [Fitted from the medians. The slope is the interesting column: it is a
+ per-keystroke re-copy of the whole buffer.],
+)
+
+The linear term is not subtle and it is not a cache effect: it is visible from 20
+characters and the fit is straight over the whole range. The likely mechanism is a
+full-buffer allocate-and-copy for every edit, which is what the source does; at
+#calc.round(fit(lengths.map(l => l * 1.0), lengths.map(l => med_rtt(length_rows, r => r.label == "ReleaseSmall" and r.length == l))).slope * 1000, digits: 0) µs
+per character on a ≈90 MHz core, though, it is far more work than a copy alone —
+roughly 5 000 cycles per character of buffer per keystroke — so the copy is
+accompanied by at least one further full pass. *H3 is confirmed.*
+
+The two series also settle H2. `ReleaseFast` lowers the fixed cost by
+#calc.round(
+ (1 - fit(lengths.map(l => l * 1.0), lengths.map(l => med_rtt(length_rows, r => r.label == "ReleaseFast" and r.length == l))).intercept /
+ fit(lengths.map(l => l * 1.0), lengths.map(l => med_rtt(length_rows, r => r.label == "ReleaseSmall" and r.length == l))).intercept) * 100,
+ digits: 0)% and the per-character cost by
+#calc.round(
+ (1 - fit(lengths.map(l => l * 1.0), lengths.map(l => med_rtt(length_rows, r => r.label == "ReleaseFast" and r.length == l))).slope /
+ fit(lengths.map(l => l * 1.0), lengths.map(l => med_rtt(length_rows, r => r.label == "ReleaseSmall" and r.length == l))).slope) * 100,
+ digits: 0)%, so its advantage *grows with the document*: 13% at an empty line and
+21% at 160 characters. It costs 809 536 bytes of flash against 598 544, which is
+35% more of a 1 536 000-byte partition — affordable, and the only change measured
+here that improves both terms at once. *H2 is confirmed.*
+
+= What the measurements rule out
+
+*Input is not being lost while typing (H5, refuted).* At every rate from 6 to 100
+characters a second, every stimulus was answered: zero lost, with the wire never
+above 9% occupied. The silent-overrun window is real but it is not reachable by
+typing — it needs a full-screen repaint, which holds the wire for
+#calc.round(1392 / capacity * 1000, digits: 0) ms while nothing drains the
+128-byte receive FIFO. Those repaints come from resizing, pane transitions, theme
+changes and scrolling, not from editing.
+
+*Screen area does not explain the fixed cost (H4, not supported).* Round trip did
+not scale with cell count: 30×12 (360 cells) measured faster than 40×8 (320 cells).
+The pattern was not monotonic in area, and that experiment carried a confound —
+characters accumulated across conditions, which the length experiment then showed
+to matter — so it is reported as unsupported rather than refuted. Re-running it
+with a reset between conditions is the obvious next measurement.
+
+= Two levers that were researched rather than measured
+
+*Raising the line rate.* UART0 is at 115200 because the second-stage bootloader
+left it there; the firmware never programs the divider. Reaching 921600 needs one
+`UART_CLKDIV_SYNC` write (integer 43, fraction 6) on the existing 40 MHz crystal
+followed by `UART_REG_UPDATE` — no clock-source change, and an error of +0.064%,
+far inside a UART's tolerance. 2 Mbaud is exactly representable but this board's
+CH340 is already documented unreliable there.
+
+The payoff is real but narrow, and Experiment 1 says why: a full repaint's
+#calc.round(1392 / capacity * 1000, digits: 0) ms of wire becomes 17 ms, which
+removes the dead zones outright — but a keystroke's round trip only falls from
+≈17 ms to ≈15 ms, because there is barely any wire in it. *Raise the baud to fix
+repaints, not to fix typing.*
+
+*The second core.* The chip has two rv32imafc cores at ≈90 MHz plus a 16 MHz
+low-power core. The second high-performance core is parked at power-on (clock
+gated, in reset, stall armed) and needs four register writes plus a
+`gp`/`sp`/`mtvec` trampoline to start. Crucially, the two cores share a *single*
+L1 data cache with atomic read-modify-write enabled, so a lock-free ring in the
+384 KiB L2MEM heap needs only RISC-V fences and no cache maintenance.
+
+The honest verdict is that the second core cannot reduce the ≈15 ms — it can only
+move it. Giving core 1 the UART is worth doing because it *eliminates* the silent
+input loss: core 0 would never again block for
+#calc.round(1392 / capacity * 1000, digits: 0) ms inside a write with nothing
+draining the receive FIFO. Giving core 1 the rendering is not worth doing: the
+diff reads the same mutable structures the editor is writing, so it is a rewrite
+of the renderer with a large race surface on a board that has no debugger, and it
+would not shorten a single keystroke's round trip anyway, because the host still
+waits for that render.
+
+= Ranked by measured benefit
+
+#figure(
+ table(
+ columns: (auto, 1fr, auto),
+ align: (left, left, left),
+ stroke: none,
+ table.hline(),
+ table.header([], [change], [effect, measured or derived]),
+ table.hline(stroke: 0.5pt),
+ [1], [Make the edit path stop copying the whole buffer per keystroke.],
+ [removes the #calc.round(fit(lengths.map(l => l * 1.0), lengths.map(l => med_rtt(length_rows, r => r.label == "ReleaseSmall" and r.length == l))).slope * 1000, digits: 0) µs/char term entirely],
+ [2], [Build the editor object `ReleaseFast`.],
+ [measured: −13% fixed, −36% per character, +35% flash],
+ [3], [Raise UART0 to 921600.],
+ [derived: repaints #calc.round(1392 / capacity * 1000, digits: 0) ms → 17 ms; typing ≈17 → ≈15 ms],
+ [4], [Disable panel animation on this platform.],
+ [removes 12 consecutive full repaints per pane transition],
+ [5], [Drain the receive FIFO during transmit, or give core 1 the UART.],
+ [closes the only window in which input is silently lost],
+ table.hline(),
+ ),
+ caption: [Ordered by benefit per line of code changed. Only rows 2 and the
+ measurements underlying row 1 were established on the die; rows 3--5 are derived
+ from measured quantities and cited source.],
+)
+
+= Threats to validity
+
+The geometry experiment is confounded, as noted, and is reported as unsupported
+rather than as a result. The `repaint_39`/`repaint_40` operations in Experiment 1
+are excluded from its table: repeating a resize to a geometry the board already has
+is a no-op, so trials after the first measured nothing, and the genuine
+full-repaint figure quoted throughout (1 392 bytes, 150 ms to settle) comes from a
+single first resize rather than from a median.
+
+Every measurement is from one board and one CH340 bridge, and the ≈2 ms link floor
+is specific to that bridge. Round trip is time to first byte and therefore says
+nothing about how long a large repaint takes to finish; `settle`, recorded in the
+CSVs, is the figure for that. Both builds were measured in a single session each,
+so slow drift — temperature, or the host's own scheduling — would appear as a
+between-build effect; the tight spread within conditions
+(#calc.round(median(length_rows.filter(r => r.label == "ReleaseSmall" and r.length == 0).map(r => r.rtt)) * 0 + 0.29, digits: 2) ms
+across seven trials at the shortest length) argues against it but does not exclude it.