summaryrefslogtreecommitdiff
path: root/test
diff options
context:
space:
mode:
Diffstat (limited to 'test')
-rw-r--r--test/perf.zig244
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;
}