//! The editing scoreboard: how long one user gesture costs against file size. //! //! zig build perf -- the table //! zig build perf -- --json -- the same, machine-readable //! zig build perf -- --base old.json -- table + a before/after delta column //! zig build perf -- --reps 60 -- more samples (default 25) //! //! It drives the core directly — feed an Event, drain the effects, //! call render — exactly like test/hxdiff.zig, so there is no pty, no shell //! and no scheduler between the clock and the code under test. A run is //! self-contained: the fixtures are generated into /tmp/pardes-perf here and //! rewritten every run, so the numbers do not move when this repo's own //! sources do and there is no stale input to chase. //! //! WHAT A CELL MEANS. Every input row is the WHOLE gesture: update(event), //! drain the effect queue, render one frame into a fresh arena — what a user //! waits for between pressing a key and seeing the screen. `render` on its own //! is the redraw with nothing changed (a resize, a refresh), which is the floor //! every other row sits on. //! //! WHERE THE CURSOR IS matters more than anything else here: several of the //! core's line lookups are scans from byte 0, so a measurement taken at line 3 //! of a 200k-line file reports a cost the user never pays. Each sample seeks to //! a different position spread across the whole file BEFORE the clock starts, //! so the median is the cost at a typical place in the document, not at its //! top. const std = @import("std"); const libc = std.c; const pardes = @import("pardes"); // ghostty-vt narrates every sequence it does not implement, and the fixture // text goes nowhere near a terminal here — cut the libraries to errors so the // table is the only thing on the screen. pub const std_options: std.Options = .{ .log_level = .err }; const gpa = std.heap.page_allocator; /// std.time.Timer is gone in 0.16; clock_gettime is what dump.zig and /// test/lspbench.zig already use. fn nowNs() u64 { var ts: std.c.timespec = undefined; _ = std.c.clock_gettime(.MONOTONIC, &ts); return @as(u64, @intCast(ts.sec)) *| 1_000_000_000 +| @as(u64, @intCast(ts.nsec)); } /// The viewport every fixture is measured in. Bigger than the 80x24 the helix /// harness pins (that one matches helix's own harness; this one wants a screen /// somebody actually works in), and fixed, because half of these numbers scale /// with the number of cells on the screen. /// The viewport every measurement runs in. Overridable, because the SHAPE of the /// screen is one of the things this table exists to hold constant while something /// else varies - and the ESP32-P4 firmware runs a 40x12 grid, where a render costs /// 47x what it costs here for a tenth of the cells. Profiling that needs the same /// geometry, not a scaled guess. var screen_cols: u16 = 120; var screen_rows: u16 = 40; /// The four shapes a file comes in. `lines` x `cols` is the generated body; /// the point of the pair is that "big" has two different meanings and they /// break different code — a 200k-line file is long in the LINE index, an /// 8000-column file is long inside ONE line, and a scan that is fine for one /// is quadratic for the other. const Fixture = struct { name: []const u8, lines: usize, cols: usize, }; const fixtures = [_]Fixture{ // the control: a normal source file. Anything that shows up here is a // constant cost, not a scaling one. .{ .name = "small", .lines = 1_000, .cols = 60 }, .{ .name = "medium", .lines = 50_000, .cols = 60 }, // "a few MB, hundreds of thousands of lines" .{ .name = "large", .lines = 300_000, .cols = 60 }, // the other failure mode: few lines, each one wider than any screen .{ .name = "longline", .lines = 400, .cols = 8_000 }, // The ESP32-P4 firmware's actual document: a handful of lines, one of them wider than its // 40-column screen. Here because a profile taken on `small` is a profile of a 1,000-line line // index, and the board has twelve lines - so the costs that dominate there are not the costs // that dominate it. Pair with `--cols 40 --rows 12`. .{ .name = "esp32p4", .lines = 12, .cols = 240 }, }; const Op = enum { /// Look at the path: disk read, pane creation, layout, first frame. open, /// one redraw, nothing changed render, /// `j` — one row of cursor movement key_down, /// `l` — one column. On `longline` the cursor sits deep in the line, so /// this is the horizontal-scroll path; elsewhere it is a plain step. key_right, /// PageDown page_down, /// one mouse wheel tick wheel, /// one printable typed in insert mode edit_char, fn label(o: Op) []const u8 { return switch (o) { .open => "open", .render => "render", .key_down => "key-down", .key_right => "key-right", .page_down => "page-down", .wheel => "wheel", .edit_char => "edit-char", }; } }; // ---- 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, p90_us: u64 = 0, max_us: u64 = 0, }; pub fn main(init: std.process.Init) !void { const args = try init.minimal.args.toSlice(init.arena.allocator()); var json = false; var reps: usize = 25; var base_path: ?[]const u8 = null; var only: ?[]const u8 = null; var i: usize = 1; while (i < args.len) : (i += 1) { const a = args[i]; if (std.mem.eql(u8, a, "--json")) { json = true; } else if (std.mem.eql(u8, a, "--reps") and i + 1 < args.len) { i += 1; reps = std.fmt.parseInt(usize, args[i], 10) catch reps; } else if (std.mem.eql(u8, a, "--base") and i + 1 < args.len) { i += 1; base_path = args[i]; } else if (std.mem.eql(u8, a, "--cols") and i + 1 < args.len) { i += 1; screen_cols = std.fmt.parseInt(u16, args[i], 10) catch screen_cols; } else if (std.mem.eql(u8, a, "--rows") and i + 1 < args.len) { i += 1; screen_rows = std.fmt.parseInt(u16, args[i], 10) catch screen_rows; } else if (std.mem.eql(u8, a, "--only") and i + 1 < args.len) { i += 1; only = args[i]; } else fatal("usage: pardes-perf [--json] [--reps N] [--base old.json] [--cols N] [--rows N] [--only NAME]", .{}); } // fixtures live in a temp dir and are rewritten every run: they are inputs // to a measurement, and a stale one silently changes what the table means. // .zig extensions on purpose — that is what puts tree-sitter in the frame. const dir = "/tmp/pardes-perf"; _ = libc.mkdir(dir, 0o755); // EEXIST is fine var paths: [fixtures.len][]const u8 = undefined; var bytes: [fixtures.len]usize = undefined; for (fixtures, 0..) |fx, fi| { const path = try std.fmt.allocPrint(gpa, "{s}/{s}.zig", .{ dir, fx.name }); const text = try generate(fx); try writeFile(path, text); paths[fi] = path; // what got WRITTEN, not lines*cols: a line whose code is already wider // than `cols` is left alone rather than truncated, so the nominal // product would understate the file the editor actually opens bytes[fi] = text.len; gpa.free(text); } // `--only` leaves the other cells zeroed rather than reshaping the table. Its purpose is // profiling, not reporting: under `perf record` a single 63 ms cell on the largest fixture // swamps the samples, and the question "what does ONE keystroke on a small document spend its // time in" cannot be answered from a profile dominated by a different one. var cells: [std.enums.values(Op).len][fixtures.len]Cell = @splat(@splat(.{})); for (std.enums.values(Op), 0..) |op, oi| { for (fixtures, 0..) |fx, fi| { if (only) |name| if (!std.mem.eql(u8, name, fx.name)) continue; cells[oi][fi] = try measure(op, fx, paths[fi], reps); } } term_chunk = try buildTermText(4 * 1024); var term_cells: [std.enums.values(TermOp).len][term_fixtures.len]Cell = @splat(@splat(.{})); for (std.enums.values(TermOp), 0..) |op, oi| { for (term_fixtures, 0..) |fx, fi| { if (only != null) continue; 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 ---- /// One (op, fixture) cell: `reps` timed samples plus three warmup rounds, /// reported as min / median / p90 / max. The spread between median and p90 is /// the run's noise, and reportText prints the worst one so a reader knows how /// big a difference has to be before it is real. fn measure(op: Op, fx: Fixture, path: []const u8, reps: usize) !Cell { const warmup = 3; const samples = try gpa.alloc(u64, reps); defer gpa.free(samples); if (op == .open) { // fresh core per sample: opening is a one-shot, and the second Look at // the same path only refocuses the pane already holding it. for (0..warmup + reps) |n| { const core = try boot(); defer core.deinit(); const t0 = nowNs(); core.lookAt(0, path); pump(core); _ = try frame(core); const dt = nowNs() -| t0; if (n >= warmup) samples[n - warmup] = dt / 1000; } return summarize(samples); } const core = try boot(); defer core.deinit(); core.lookAt(0, path); pump(core); const id = filePane(core) orelse fatal("Look {s} opened no file pane", .{path}); core.active = id; const pane = core.panes[id].?; _ = try frame(core); // insert mode is entered ONCE: `i` is a mode change, not a keystroke of // typing, and measuring it inside every edit sample would report the wrong // thing entirely. if (op == .edit_char) { core.update(.{ .key = .{ .cp = 'i', .text = "i" } }); pump(core); } for (0..warmup + reps) |n| { seek(pane, fx, n); _ = try frame(core); // settle the view at the new spot, untimed const t0 = nowNs(); switch (op) { .open => unreachable, .render => {}, .key_down => core.update(.{ .key = .{ .cp = 'j', .text = "j" } }), .key_right => core.update(.{ .key = .{ .cp = 'l', .text = "l" } }), .page_down => core.update(.{ .key = .{ .cp = pardes.Key.page_down } }), .wheel => core.update(.{ .mouse = .{ .button = .wheel_down, .kind = .press, .col = 4, .row = 8 } }), .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); } /// 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. /// Rows sit in the middle 80% (a position at the very top or bottom measures /// the clamp, not the work), and on a wide fixture the column is deep inside /// the line so `key-right` exercises the hscroll cut rather than column 1. /// The sequence depends only on `n`, so two builds see the same positions in /// the same order and their cells are comparable one for one. fn seek(pane: *pardes.Pane, fx: Fixture, n: usize) void { const f = &pane.file.?; const span = @max(1, fx.lines * 8 / 10); const row = fx.lines / 10 + (n *% 7919) % span; f.scroll = @min(row, fx.lines -| 1); f.syntax_dirty = true; pane.cur_row = @intCast(f.scroll); pane.cur_col = if (fx.cols > 200) @intCast(fx.cols * 3 / 4) else 0; pane.cur_pinned = true; pane.hscroll = @max(0, pane.cur_col - 40); pane.sticky_col = -1; } fn boot() !*pardes.Pardes { const core = try pardes.Pardes.init(gpa, .{ .tty_only = true }); core.update(.{ .resize = .{ .cols = screen_cols, .rows = screen_rows } }); pump(core); return core; } /// one frame into a throwaway arena — the shell's per-frame arena, which is /// what keeps the retained-render contract honest (vaxis stores slices into /// whatever it was handed, so the frame's text must outlive vx.render) fn frame(core: *pardes.Pardes) !*pardes.Surface { var arena: std.heap.ArenaAllocator = .init(gpa); defer arena.deinit(); return core.render(arena.allocator()); } fn filePane(core: *pardes.Pardes) ?usize { for (core.panes, 0..) |slot, i| { const pane = slot orelse continue; if (pane.file != null) return i; } return null; } /// Drain queued effects, all ignored (spawn, write, watch, ...): there is no /// shell here, and nothing measured depends on one answering. fn pump(core: *pardes.Pardes) void { while (core.nextEffect()) |_| {} } fn summarize(samples: []u64) Cell { if (samples.len == 0) return .{}; std.mem.sort(u64, samples, {}, std.sort.asc(u64)); return .{ .min_us = samples[0], .med_us = samples[samples.len / 2], .p90_us = samples[(samples.len * 9) / 10 -| 1], .max_us = samples[samples.len - 1], }; } // ---- the fixture generator ---- /// Plausible Zig, so the tree-sitter pass has real nodes to walk rather than /// one giant error node: a repeating four-line shape padded to `cols`. Long /// lines get their width from a comment tail — the alternative (an enormous /// string literal) makes the whole file one token and flatters every scan that /// looks for a newline. fn generatedPrefixLen(n: usize) usize { return switch (n % 4) { 0 => std.fmt.count("const value_{d}: u32 = {d}; // ", .{ n, n *% 2654435761 }), 1 => std.fmt.count("pub fn helper_{d}(a: u32, b: u32) u32 {{ return a +% b *% {d}; }} // ", .{ n, n }), 2 => std.fmt.count(" const text_{d} = \"lorem ipsum dolor sit amet {d}\"; // ", .{ n, n }), else => std.fmt.count("// comment line {d} — ", .{n}), }; } fn writeGeneratedPrefix(out: *std.Io.Writer, n: usize) !void { switch (n % 4) { 0 => try out.print("const value_{d}: u32 = {d}; // ", .{ n, n *% 2654435761 }), 1 => try out.print("pub fn helper_{d}(a: u32, b: u32) u32 {{ return a +% b *% {d}; }} // ", .{ n, n }), 2 => try out.print(" const text_{d} = \"lorem ipsum dolor sit amet {d}\"; // ", .{ n, n }), else => try out.print("// comment line {d} — ", .{n}), } } fn generate(fx: Fixture) ![]u8 { var byte_len: usize = 0; for (0..fx.lines) |n| byte_len += @max(generatedPrefixLen(n), fx.cols) + 1; const text = try gpa.alloc(u8, byte_len); errdefer gpa.free(text); var out: std.Io.Writer = .fixed(text); for (0..fx.lines) |n| { const start = out.end; try writeGeneratedPrefix(&out, n); const prefix_len = out.end - start; if (prefix_len < fx.cols) try out.splatByteAll('x', fx.cols - prefix_len); try out.writeByte('\n'); } std.debug.assert(out.end == text.len); return text; } fn writeFile(path: []const u8, text: []const u8) !void { var pathbuf: [4096]u8 = undefined; const path_z = try std.fmt.bufPrintSentinel(&pathbuf, "{s}", .{path}, 0); const fd = libc.open(path_z, .{ .ACCMODE = .WRONLY, .CREAT = true, .TRUNC = true }, @as(libc.mode_t, 0o644)); if (fd < 0) return error.OpenFailed; defer _ = libc.close(fd); var off: usize = 0; while (off < text.len) { const n = libc.write(fd, text.ptr + off, text.len - off); if (n < 0) { if (libc.errno(n) == .INTR) continue; return error.WriteFailed; } off += @intCast(n); } } // ---- reporting ---- 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, 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"}); for (fixtures) |fx| o(" {s:>12}", .{fx.name}); o("\n{s:<12}", .{"lines"}); for (fixtures) |fx| o(" {d:>12}", .{fx.lines}); o("\n{s:<12}", .{"cols"}); for (fixtures) |fx| o(" {d:>12}", .{fx.cols}); o("\n{s:<12}", .{"size"}); for (bytes) |b| o(" {d:>10} KB", .{b / 1024}); o("\n\n", .{}); // one row per gesture. `open` is a whole Look; every other row is // update+effects+one frame, which is the latency a user actually sees. o("{s:<12}", .{"op"}); for (fixtures) |fx| { if (base != null) o(" {s:>19}", .{fx.name}) else o(" {s:>12}", .{fx.name}); } // 12 for the op name + one column per fixture, wider when a baseline adds // its ratio to each cell o("\n{s}\n", .{if (base != null) "-" ** (12 + fixtures.len * 20) else "-" ** (12 + fixtures.len * 13)}); var worst_jitter: f64 = 0; for (std.enums.values(Op), 0..) |op, oi| { o("{s:<12}", .{op.label()}); for (0..fixtures.len) |fi| { const c = 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.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", .{}); } // 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 // believe a small difference, and the sample positions are identical from // run to run, so two builds are still comparable cell for cell. o("\nnoise: worst cell p90 is {d:.0}% over its median (scheduler + the spread\n", .{worst_jitter * 100}); o("of sample positions through the file). Treat a difference smaller than\n", .{}); o("that as nothing, and re-run before believing a small win.\n", .{}); if (base_path) |bp| o("baseline: {s} (x column = now / then; under 1.00 is faster)\n", .{bp}); } /// Real stdout, not std.debug.print's stderr: this is the form `--base` reads /// back, and `zig build perf -- --json > runs/old.json` writing an empty file /// 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, 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; for (fixtures, 0..) |fx, fi| { out.print("{s}{{\"name\":\"{s}\",\"lines\":{d},\"cols\":{d},\"bytes\":{d}}}", .{ if (fi > 0) "," else "", fx.name, fx.lines, fx.cols, bytes[fi], }) catch return; } out.writeAll("],\"cells\":[") catch return; var first = true; for (std.enums.values(Op), 0..) |op, oi| { for (fixtures, 0..) |fx, fi| { const c = 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; } } 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, 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. /// /// 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)), .term_med = @splat(@splat(0)) }; const parsed = std.json.parseFromSlice(struct { reps: usize = 0, cells: []const struct { op: []const u8, fixture: []const u8, med_us: u64, }, }, 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; for (fixtures, 0..) |fx, fi| { 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; } fn readFileAlloc(path: []const u8) ![]u8 { var pathbuf: [4096]u8 = undefined; const path_z = try std.fmt.bufPrintSentinel(&pathbuf, "{s}", .{path}, 0); const fd = libc.open(path_z, .{ .ACCMODE = .RDONLY }); if (fd < 0) return error.OpenFailed; defer _ = libc.close(fd); const end = libc.lseek(fd, 0, libc.SEEK.END); if (end < 0 or libc.lseek(fd, 0, libc.SEEK.SET) < 0) return error.StatFailed; const size = std.math.cast(usize, end) orelse return error.FileTooLarge; const buf = try gpa.alloc(u8, size); errdefer gpa.free(buf); var offset: usize = 0; while (offset < buf.len) { const n = libc.read(fd, buf.ptr + offset, buf.len - offset); if (n < 0) { if (libc.errno(n) == .INTR) continue; return error.ReadFailed; } if (n == 0) return error.UnexpectedEndOfFile; offset += @intCast(n); } return buf; } fn fatal(comptime fmt: []const u8, args: anytype) noreturn { std.debug.print("pardes-perf: " ++ fmt ++ "\n", args); std.process.exit(1); }