summaryrefslogtreecommitdiff
path: root/tools/rtt.zig
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 /tools/rtt.zig
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 'tools/rtt.zig')
-rw-r--r--tools/rtt.zig170
1 files changed, 170 insertions, 0 deletions
diff --git a/tools/rtt.zig b/tools/rtt.zig
new file mode 100644
index 0000000..6ab0d87
--- /dev/null
+++ b/tools/rtt.zig
@@ -0,0 +1,170 @@
+//! Round-trip time over the board's only I/O channel: stimulus out, first byte back.
+//!
+//! Two functions, because every proposed fix for "too slow to type in" is a trade whose sign cannot
+//! be guessed - a frame-rate cap, draining RX while blocked on TX, coalescing input, raising the
+//! baud - and the only honest way to rank them is to measure the same number before and after.
+//!
+//! WHAT IS BEING TIMED, precisely: the interval from the last byte of a stimulus leaving the host to
+//! the FIRST byte of the board's response arriving. That is the latency a human perceives as
+//! responsiveness, and it is deliberately not the same as the time to finish repainting: a renderer
+//! that starts drawing in 8 ms and takes 130 ms to finish feels immediate, while one that thinks for
+//! 130 ms and then paints in 8 ms feels broken, and the two are indistinguishable if you only
+//! measure when the wire goes quiet. `settle_us` records the second number so the pair can be read
+//! together.
+//!
+//! Microseconds, not milliseconds: at 115200 baud one byte occupies 87 us, so a millisecond clock
+//! quantises this measurement into buckets 11 bytes wide.
+//!
+//! The caller owns the board's STATE. These functions send bytes and time bytes; they do not know
+//! what the editor does with them. A stimulus only produces a response if the editor is in a mode
+//! where that keystroke changes the screen - pardes is modal, so a caller measuring keystrokes must
+//! put it in insert mode first and must pick a stimulus that is not itself a mode change.
+
+const std = @import("std");
+const serial = @import("serial.zig");
+
+pub const Sample = struct {
+ /// Stimulus out -> first response byte in.
+ rtt_us: i64,
+ /// Stimulus out -> last response byte in, i.e. the wire is free again.
+ settle_us: i64,
+ /// How much the board emitted in answer. At 115200 this is also a time: bytes * 87 us.
+ bytes: usize,
+};
+
+/// One round trip. Returns null when nothing came back within `timeout_us` - which is a result, not
+/// an error: a dropped keystroke looks exactly like this, and it is the thing most worth counting.
+///
+/// `quiet_us` decides when the response is over. It must exceed the largest gap the board leaves
+/// mid-response; a renderer that pauses to allocate can stall longer than one byte time, and too
+/// small a value would split one response into two and report a `settle_us` that is too good.
+pub fn roundTrip(
+ port: *serial.Port,
+ stimulus: []const u8,
+ timeout_us: i64,
+ quiet_us: i64,
+) !?Sample {
+ // Anything still in flight belongs to the previous measurement. Without this the first read
+ // below returns instantly with stale bytes and reports an RTT near zero.
+ var drain: [1024]u8 = undefined;
+ while (try port.readTimeout(&drain, 0) > 0) {}
+
+ try port.write(stimulus);
+ const t0 = nowUs(port);
+
+ var first: i64 = -1;
+ var last: i64 = t0;
+ var bytes: usize = 0;
+ var buf: [4096]u8 = undefined;
+ while (true) {
+ const now = nowUs(port);
+ if (first < 0) {
+ if (now - t0 > timeout_us) return null;
+ } else if (now - last > quiet_us) break;
+
+ // Poll in millisecond units because that is what poll(2) takes; the TIMING above is
+ // microseconds and independent of this granularity.
+ const n = try port.readTimeout(&buf, 1);
+ if (n == 0) continue;
+ if (first < 0) first = nowUs(port);
+ bytes += n;
+ last = nowUs(port);
+ }
+ return .{ .rtt_us = first - t0, .settle_us = last - t0, .bytes = bytes };
+}
+
+pub const Stats = struct {
+ sent: u32,
+ /// Stimuli that produced no response at all inside the timeout. On this port that is a dropped
+ /// keystroke, and it is silent everywhere else in the system.
+ lost: u32,
+ min_us: i64,
+ median_us: i64,
+ max_us: i64,
+ /// Median, not mean: one 130 ms full repaint among fifty 9 ms updates should not move the
+ /// number that describes what typing feels like.
+ median_settle_us: i64,
+ median_bytes: usize,
+ /// Every response byte over the whole run, against the wire's capacity for that wall time.
+ /// 100% means the link is the limit and no amount of firmware tuning will help.
+ wire_percent: u32,
+
+ pub fn format(s: Stats, w: *std.Io.Writer) std.Io.Writer.Error!void {
+ try w.print("{d} samples, {d} lost\n", .{ s.sent, s.lost });
+ try w.print(" rtt min {d:>6} us median {d:>6} us max {d:>6} us\n", .{
+ s.min_us, s.median_us, s.max_us,
+ });
+ try w.print(" settle median {d} us ({d} B)\n", .{ s.median_settle_us, s.median_bytes });
+ try w.print(" wire {d}% of capacity\n", .{s.wire_percent});
+ }
+};
+
+pub const Options = struct {
+ samples: u32 = 20,
+ /// One byte that edits text without changing mode. `x` inserts an `x` in insert mode.
+ stimulus: []const u8 = "x",
+ /// Gap between stimuli. 80 ms is 12.5 characters a second: brisk human typing, and long enough
+ /// that a healthy editor finishes one update before the next arrives, so each sample is
+ /// independent rather than measuring a queue.
+ gap_us: i64 = 80_000,
+ timeout_us: i64 = 2_000_000,
+ quiet_us: i64 = 40_000,
+};
+
+/// `samples` round trips, summarised. The port must already be open and the board already in a
+/// state where `stimulus` changes the screen.
+pub fn measure(port: *serial.Port, opts: Options) !Stats {
+ const cap = 256;
+ var rtt: [cap]i64 = undefined;
+ var settle: [cap]i64 = undefined;
+ var size: [cap]usize = undefined;
+ var got: u32 = 0;
+ var lost: u32 = 0;
+ var total_bytes: usize = 0;
+
+ const n = @min(opts.samples, cap);
+ const t_start = nowUs(port);
+ for (0..n) |_| {
+ if (try roundTrip(port, opts.stimulus, opts.timeout_us, opts.quiet_us)) |s| {
+ rtt[got] = s.rtt_us;
+ settle[got] = s.settle_us;
+ size[got] = s.bytes;
+ total_bytes += s.bytes;
+ got += 1;
+ } else lost += 1;
+ std.Io.sleep(port.io, .fromMicroseconds(opts.gap_us), .boot) catch {};
+ }
+ const elapsed = @max(1, nowUs(port) - t_start);
+
+ if (got == 0) return .{
+ .sent = n,
+ .lost = lost,
+ .min_us = -1,
+ .median_us = -1,
+ .max_us = -1,
+ .median_settle_us = -1,
+ .median_bytes = 0,
+ .wire_percent = 0,
+ };
+
+ std.mem.sort(i64, rtt[0..got], {}, std.sort.asc(i64));
+ std.mem.sort(i64, settle[0..got], {}, std.sort.asc(i64));
+ std.mem.sort(usize, size[0..got], {}, std.sort.asc(usize));
+
+ // Bytes per second the wire can carry: baud/10, since each byte is 8N1 = 10 bit times.
+ const capacity = @as(i64, port.capacity());
+ return .{
+ .sent = n,
+ .lost = lost,
+ .min_us = rtt[0],
+ .median_us = rtt[got / 2],
+ .max_us = rtt[got - 1],
+ .median_settle_us = settle[got / 2],
+ .median_bytes = size[got / 2],
+ .wire_percent = @intCast(@divTrunc(@as(i64, @intCast(total_bytes)) * 1_000_000 * 100, elapsed * capacity)),
+ };
+}
+
+fn nowUs(port: *serial.Port) i64 {
+ return std.Io.Timestamp.now(port.io, .boot).toMicroseconds();
+}