//! 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; // NON-BLOCKING, and that matters. `poll(2)` takes a timeout in MILLISECONDS, so waiting // even 1 ms quantises this measurement into 1 ms buckets: a response that arrived just after // a poll returned empty is reported up to a millisecond late. Against a round trip of a few // milliseconds that is not a rounding error, it is a large fraction of the answer - measured // as ~0.75 ms of unexplained gap between this figure and the sum of the board's own stage // timings. A spin costs a busy host CPU for a few milliseconds per sample, which is the // right trade for an instrument. const n = try port.readTimeout(&buf, 0); 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(); }