summaryrefslogtreecommitdiff
path: root/tools/rtt.zig
blob: d971c740a818e2114278d797340677f829c33a6e (plain) (blame)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
//! 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();
}