diff options
Diffstat (limited to 'test/perf.zig')
| -rw-r--r-- | test/perf.zig | 244 |
1 files changed, 236 insertions, 8 deletions
diff --git a/test/perf.zig b/test/perf.zig index bf5f0200..f440227d 100644 --- a/test/perf.zig +++ b/test/perf.zig @@ -102,6 +102,65 @@ const Op = enum { } }; +// ---- the terminal scoreboard ---- +// +// The other axis pardes scales on, and the one a file table cannot see. A +// terminal pane's state is a ghostty-vt emulator with a 16 MiB scrollback, and +// three of the costs below walk ALL of it rather than the viewport: a COLUMN +// change reflows every page in the list (a row change does not — ghostty skips +// reflow when the width is unchanged, which is why both are here), and the +// motion surface is built from a dump of the whole history. +// +// The case this exists for is a coding agent printing a long transcript into a +// shell pane: `resize-cols` is what one frame of a window drag costs, and +// `key-down` is what one press of `j` costs afterwards. + +const TermFixture = struct { + name: []const u8, + /// KiB of pty output fed into the pane before any clock starts + kb: usize, +}; + +const term_fixtures = [_]TermFixture{ + // a shell you just opened: the control. Anything here is constant cost. + .{ .name = "sb-64k", .kb = 64 }, + // a build log — also where the 1 MiB raw-byte replay ring fills up + .{ .name = "sb-1m", .kb = 1024 }, + // an agent transcript: half the scrollback ceiling + .{ .name = "sb-8m", .kb = 8 * 1024 }, +}; + +const TermOp = enum { + /// one redraw, nothing changed — the floor the rest sit on + render, + /// one 4 KiB pty read arrives: parse, replay ring, sync, frame + output, + /// the window got one row shorter: resize WITHOUT reflow + resize_rows, + /// the window got one column narrower: a full page-list reflow + resize_cols, + /// `j` in the pane's body — builds the motion surface from the grid + key_down, + /// one printable typed into the pane's edit buffer + edit_char, + + fn label(o: TermOp) []const u8 { + return switch (o) { + .render => "render", + .output => "output", + .resize_rows => "resize-rows", + .resize_cols => "resize-cols", + .key_down => "key-down", + .edit_char => "edit-char", + }; + } +}; + +/// One pty read's worth of output, built once and replayed by the `output` +/// row. Blocks are generated, not captured, for the same reason the file +/// fixtures are. +var term_chunk: []const u8 = &.{}; + const Cell = struct { min_us: u64 = 0, med_us: u64 = 0, @@ -154,8 +213,16 @@ pub fn main(init: std.process.Init) !void { } } - if (json) return reportJson(init.io, &cells, &bytes, reps); - reportText(&cells, &bytes, reps, base_path); + term_chunk = try buildTermText(4 * 1024); + var term_cells: [std.enums.values(TermOp).len][term_fixtures.len]Cell = undefined; + for (std.enums.values(TermOp), 0..) |op, oi| { + for (term_fixtures, 0..) |fx, fi| { + term_cells[oi][fi] = try measureTerm(op, fx, reps); + } + } + + if (json) return reportJson(init.io, &cells, &term_cells, &bytes, reps); + reportText(&cells, &term_cells, &bytes, reps, base_path); } // ---- the measured gestures ---- @@ -222,6 +289,98 @@ fn measure(op: Op, fx: Fixture, path: []const u8, reps: usize) !Cell { return summarize(samples); } +/// One (op, fixture) cell of the terminal table. Same contract as `measure` — +/// fresh core per cell, warmups, median of `reps` — with no `seek`: a terminal +/// has one cursor, and everything measured here scales with the size of the +/// HISTORY rather than with where in it you are standing. +fn measureTerm(op: TermOp, fx: TermFixture, reps: usize) !Cell { + const warmup = 3; + const samples = try gpa.alloc(u64, reps); + defer gpa.free(samples); + + const core = try bootTerm(fx); + defer core.deinit(); + const pane = core.panes[0] orelse fatal("terminal boot produced no pane", .{}); + // tty mode hands every key straight to the shell, so the two gesture rows + // would otherwise measure one queued write effect and nothing else + if (op == .key_down or op == .edit_char) pane.mode = .normal; + if (op == .edit_char) { + core.update(.{ .key = .{ .cp = 'i', .text = "i" } }); + pump(core); + } + _ = try frame(core); + + var cols = screen_cols; + var rows = screen_rows; + for (0..warmup + reps) |n| { + const t0 = nowNs(); + switch (op) { + .render => {}, + .output => core.update(.{ .output = .{ .pane = 0, .bytes = term_chunk } }), + // one cell of window drag. Alternating rather than sweeping so + // every sample does a real resize and the fixture never drifts + // away from the size the other rows are measured at. + .resize_rows => { + rows = if (rows == screen_rows) screen_rows - 1 else screen_rows; + core.update(.{ .resize = .{ .cols = cols, .rows = rows } }); + }, + .resize_cols => { + cols = if (cols == screen_cols) screen_cols - 1 else screen_cols; + core.update(.{ .resize = .{ .cols = cols, .rows = rows } }); + }, + .key_down => core.update(.{ .key = .{ .cp = 'j', .text = "j" } }), + .edit_char => core.update(.{ .key = .{ .cp = 'x', .text = "x" } }), + } + pump(core); + _ = try frame(core); + const dt = nowNs() -| t0; + if (n >= warmup) samples[n - warmup] = dt / 1000; + } + return summarize(samples); +} + +/// A shell pane carrying `fx.kb` KiB of history. The bytes go in as 8 KiB +/// reads, which is both what a pty delivers and what the flood costs: one +/// core update per chunk. +fn bootTerm(fx: TermFixture) !*pardes.Pardes { + const core = try pardes.Pardes.init(gpa, .{ .tty_only = true }); + core.update(.{ .resize = .{ .cols = screen_cols, .rows = screen_rows } }); + pump(core); + const text = try buildTermText(fx.kb * 1024); + defer gpa.free(text); + var off: usize = 0; + while (off < text.len) { + const end = @min(off + 8192, text.len); + core.update(.{ .output = .{ .pane = 0, .bytes = text[off..end] } }); + pump(core); + off = end; + } + return core; +} + +/// Plausible pty output, at least `want` bytes of it: an OSC 133-marked +/// prompt, the command, and a run of coloured result lines. The prompt markers +/// are the point — the motion surface blanks prompt rows, so a history without +/// them measures a branch no real shell ever takes. +fn buildTermText(want: usize) ![]const u8 { + var out: std.Io.Writer.Allocating = .init(gpa); + errdefer out.deinit(); + var n: usize = 0; + while (out.written().len < want) : (n += 1) { + try out.writer.print( + "\x1b]133;A\x07\x1b[32muser\x1b[0m@host \x1b[34m~/work\x1b[0m $ \x1b]133;B\x07zig build -Dstep={d}\r\n\x1b]133;C\x07", + .{n}, + ); + for (0..12) |k| { + try out.writer.print( + "\x1b[90m[{d:0>5}]\x1b[0m compiling module_{d}_{d} ... \x1b[32mok\x1b[0m ({d} ms)\r\n", + .{ n, n, k, (n *% 7919 +% k) % 900 }, + ); + } + } + return out.toOwnedSlice(); +} + /// Park the cursor and the view at sample `n`'s position, walked across the /// file by a prime stride so consecutive samples land nowhere near each other /// and a whole run covers the document rather than one neighbourhood of it. @@ -345,9 +504,15 @@ fn writeFile(path: []const u8, text: []const u8) !void { // ---- reporting ---- -fn reportText(cells: *const [std.enums.values(Op).len][fixtures.len]Cell, bytes: *const [fixtures.len]usize, reps: usize, base_path: ?[]const u8) void { +fn reportText( + cells: *const [std.enums.values(Op).len][fixtures.len]Cell, + term_cells: *const [std.enums.values(TermOp).len][term_fixtures.len]Cell, + bytes: *const [fixtures.len]usize, + reps: usize, + base_path: ?[]const u8, +) void { const o = std.debug.print; - const base = if (base_path) |bp| readBase(bp) else null; + const base = if (base_path) |bp| readBase(bp, reps) else null; o("pardes perf — {d}x{d} viewport, {d} samples/cell, median us\n\n", .{ screen_cols, screen_rows, reps }); o("{s:<12}", .{"fixture"}); @@ -390,6 +555,33 @@ fn reportText(cells: *const [std.enums.values(Op).len][fixtures.len]Cell, bytes: } o("\n", .{}); } + + // second table, same shape: the terminal costs, against SCROLLBACK + o("\n{s:<12}", .{"scrollback"}); + for (term_fixtures) |fx| { + if (base != null) o(" {s:>19}", .{fx.name}) else o(" {s:>12}", .{fx.name}); + } + o("\n{s}\n", .{if (base != null) "-" ** (12 + term_fixtures.len * 20) else "-" ** (12 + term_fixtures.len * 13)}); + for (std.enums.values(TermOp), 0..) |op, oi| { + o("{s:<12}", .{op.label()}); + for (0..term_fixtures.len) |fi| { + const c = term_cells[oi][fi]; + if (c.med_us > 0) { + const j = (@as(f64, @floatFromInt(c.p90_us)) - @as(f64, @floatFromInt(c.med_us))) / @as(f64, @floatFromInt(c.med_us)); + if (j > worst_jitter) worst_jitter = j; + } + if (base) |b| { + const prev = b.term_med[oi][fi]; + if (prev == 0) { + o(" {d:>12}{s:>7}", .{ c.med_us, "-" }); + } else { + const ratio = @as(f64, @floatFromInt(c.med_us)) / @as(f64, @floatFromInt(prev)); + o(" {d:>12} {d:>6.2}x", .{ c.med_us, ratio }); + } + } else o(" {d:>12}", .{c.med_us}); + } + o("\n", .{}); + } // The spread is not only scheduler noise: samples are taken at DIFFERENT // places in the file on purpose (see seek), so a cost that still depends on // where the cursor is shows up here as well. Both are reasons not to @@ -406,7 +598,13 @@ fn reportText(cells: *const [std.enums.values(Op).len][fixtures.len]Cell, bytes: /// would make the next comparison silently print no ratios at all. const json_report_max_bytes = 32 * 1024; -fn reportJson(io: std.Io, cells: *const [std.enums.values(Op).len][fixtures.len]Cell, bytes: *const [fixtures.len]usize, reps: usize) void { +fn reportJson( + io: std.Io, + cells: *const [std.enums.values(Op).len][fixtures.len]Cell, + term_cells: *const [std.enums.values(TermOp).len][term_fixtures.len]Cell, + bytes: *const [fixtures.len]usize, + reps: usize, +) void { var storage: [json_report_max_bytes]u8 = undefined; var out: std.Io.Writer = .fixed(&storage); out.print("{{\"cols\":{d},\"rows\":{d},\"reps\":{d},\"fixtures\":[", .{ screen_cols, screen_rows, reps }) catch return; @@ -426,22 +624,40 @@ fn reportJson(io: std.Io, cells: *const [std.enums.values(Op).len][fixtures.len] first = false; } } + for (std.enums.values(TermOp), 0..) |op, oi| { + for (term_fixtures, 0..) |fx, fi| { + const c = term_cells[oi][fi]; + out.print("{s}{{\"op\":\"{s}\",\"fixture\":\"{s}\",\"min_us\":{d},\"med_us\":{d},\"p90_us\":{d},\"max_us\":{d}}}", .{ + if (first) "" else ",", op.label(), fx.name, c.min_us, c.med_us, c.p90_us, c.max_us, + }) catch return; + first = false; + } + } out.writeAll("]}\n") catch return; std.Io.File.stdout().writeStreamingAll(io, out.buffered()) catch {}; } -const Base = struct { med: [std.enums.values(Op).len][fixtures.len]u64 }; +const Base = struct { + med: [std.enums.values(Op).len][fixtures.len]u64, + term_med: [std.enums.values(TermOp).len][term_fixtures.len]u64, +}; /// A previous --json run, reduced to the medians this table compares against. /// Cells the old run did not have stay 0 and print as "-": the op list may /// have grown since, and a missing number is not a regression. An unreadable /// file is fatal rather than silently ratio-less — a comparison you asked for /// and did not get is worse than no comparison. -fn readBase(path: []const u8) ?Base { +/// +/// So is one that is quietly WRONG, which is why `reps` has to match. `seek` +/// walks sample n to `n *% 7919 % span`, so two runs with different counts +/// measure different PLACES in the file, and every file row comes out 20-50% +/// apart with nothing whatever having changed. +fn readBase(path: []const u8, reps: usize) ?Base { const src = readFileAlloc(path) catch fatal("--base: cannot read {s}", .{path}); defer gpa.free(src); - var b: Base = .{ .med = @splat(@splat(0)) }; + var b: Base = .{ .med = @splat(@splat(0)), .term_med = @splat(@splat(0)) }; const parsed = std.json.parseFromSlice(struct { + reps: usize = 0, cells: []const struct { op: []const u8, fixture: []const u8, @@ -449,6 +665,10 @@ fn readBase(path: []const u8) ?Base { }, }, gpa, src, .{ .ignore_unknown_fields = true }) catch return null; defer parsed.deinit(); + if (parsed.value.reps != reps) fatal( + "--base: {s} was taken with --reps {d}, this run is --reps {d}. Same count or no comparison.", + .{ path, parsed.value.reps, reps }, + ); for (parsed.value.cells) |c| { for (std.enums.values(Op), 0..) |op, oi| { if (!std.mem.eql(u8, op.label(), c.op)) continue; @@ -456,6 +676,14 @@ fn readBase(path: []const u8) ?Base { if (std.mem.eql(u8, fx.name, c.fixture)) b.med[oi][fi] = c.med_us; } } + // op labels repeat across the two tables ("render", "key-down"); the + // fixture names never do, so the pair still names exactly one cell + for (std.enums.values(TermOp), 0..) |op, oi| { + if (!std.mem.eql(u8, op.label(), c.op)) continue; + for (term_fixtures, 0..) |fx, fi| { + if (std.mem.eql(u8, fx.name, c.fixture)) b.term_med[oi][fi] = c.med_us; + } + } } return b; } |
