diff options
| author | Gabriel Schneider <[email protected]> | 2026-08-11 17:07:45 -0300 |
|---|---|---|
| committer | Gabriel Schneider <[email protected]> | 2026-08-12 13:04:32 -0300 |
| commit | a28c3b71a917dcaae6ba4cd87b356eeab94462c4 (patch) | |
| tree | 87f1016f28de479258cfc98ed54bda683ca27a95 | |
| parent | 24aff2afe3cc827fe1a920a044922aeacd1ff6a3 (diff) | |
| download | pardes-a28c3b71a917dcaae6ba4cd87b356eeab94462c4.tar.gz pardes-a28c3b71a917dcaae6ba4cd87b356eeab94462c4.zip | |
terminal: memoize the motion surface instead of dumping the scrollback per key
Every keystroke in a shell pane rebuilt the motion surface from scratch:
shellRows dumped ghostty's WHOLE history+active grid, split it, blanked the
prompt rows and handed back slices into the scratch arena, which the next
update threw away. A pane sitting on a multi-megabyte agent transcript paid an
O(scrollback) dump per press of `j`, and paid it once or twice per key, since
flatSurface then rebuilt the same rows joined by '\n' beside it.
The dump is now memoized against the pane it was built for (term_pane.RowsCache
on Pardes.shell_rows), gpa-owned rather than scratch-arena because the whole
point is to outlive the update that built it. One entry, not a table: the
surface is built for the pane the cursor is in, and a second pane asking would
only double a multi-megabyte buffer for a slot it is about to lose again. A
pane that is not the live one is answered from the arena as before.
The lifetime rule is the part that would have rotted silently, so it is one
rule and it is written down: `rows` is handed out to callers, so everything
that notices the entry has gone bad — output arrived, the grid reflowed, the
pane died, another pane wants the slot — only marks it `stale`, and the
buffers are freed in exactly two places, `sweep` at the TOP of an update
before any handler can be holding them, and `reset` when the editor goes away.
Nothing frees mid-update. dropPane clears the pointer immediately though: a
freed pane's address comes back from the allocator as a different pane, and an
entry still naming it would answer for the wrong grid.
Two things fall out of having the join already:
- flatSurface returns the memo's `text` verbatim when the lines it was handed
are the cached rows untouched, instead of rebuilding the join.
- paneCursorLines returns `rows` directly when there is no edit buffer, where
it used to copy the array one slice at a time to produce exactly what it was
given.
One bug on the way past, in the same function: an EMPTY edit buffer writes one
line but modal.lineCount("") is 0, so `ls` was sized one short of what the
loop writes — the same floor the paste site needs. Killing a whole line
(`A<C-u>`, `d%`) on a buffer covering the last row made that a length of zero.
And test/perf.zig grows the axis that would have caught this: a terminal
scoreboard beside the file one, three scrollback fixtures (64 KiB, 1 MiB,
8 MiB — half the ceiling) against render / output / resize-rows / resize-cols /
key-down / edit-char, sharing the existing text and JSON reports and the
--base comparison. resize-cols and resize-rows are both there because a COLUMN
change reflows every page in the list and a row change does not.
Measured on that table: key-down is 142 / 630 / 636 us across the three
fixtures — flat from 1 MiB to 8 MiB, which is the dump being gone, and render
flat at ~110 us throughout. What remains of key-down's step at 1 MiB is the
linear scan indexOf refuses to index for a terminal; that is now a ponytail
waiver naming its own price (615 us against 140 us) and the threading through
paneOff/panePos/paneLineStart it would cost, to be done the day 0.6 ms shows
up next to something anybody can feel.
| -rw-r--r-- | src/pardes.zig | 38 | ||||
| -rw-r--r-- | src/term_pane.zig | 118 | ||||
| -rw-r--r-- | test/perf.zig | 244 |
3 files changed, 382 insertions, 18 deletions
diff --git a/src/pardes.zig b/src/pardes.zig index e9cc746d..f95b4e69 100644 --- a/src/pardes.zig +++ b/src/pardes.zig @@ -4484,6 +4484,9 @@ pub const Pardes = struct { surface: Surface = .{}, /// per-update scratch (paneCursorLines, selection text); reset each update scratch: std.heap.ArenaAllocator, + /// the terminal motion surface, memoized against the pane it was built + /// for — see term_pane.RowsCache for the lifetime rule + shell_rows: term_pane.RowsCache = .{}, pub fn init(gpa: std.mem.Allocator, opts: Options) !*Pardes { const image_gpa = opts.image_allocator orelse gpa; @@ -4563,6 +4566,7 @@ pub const Pardes = struct { if (p.last_dump) |d| gpa.free(d); if (p.chord_arg) |a| gpa.free(a); if (p.pipe_wait) |*wait| wait.deinit(gpa); + p.shell_rows.reset(gpa); p.scratch.deinit(); gpa.free(p.surface.cells); if (p.surface.images.len > 0) gpa.free(p.surface.images); @@ -4594,6 +4598,7 @@ pub const Pardes = struct { pub fn deinitPane(p: *Pardes, pane: *Pane) void { std.debug.assert(pane.payloadValid()); + p.shell_rows.dropPane(pane); if (pane.image) |*iv| { p.image_gpa.free(iv.path); if (iv.rgba.len > 0) p.image_gpa.free(iv.rgba); @@ -4808,6 +4813,10 @@ pub const Pardes = struct { } pub fn update(p: *Pardes, ev: Event) void { + // Free a motion surface that went bad during the LAST update, before + // anything in this one can ask for it. Nothing frees it mid-update: + // handlers hand `rows` around for the length of a single update. + p.shell_rows.sweep(p.gpa); // A transient message is exactly as old as your last input: touch the // keyboard or the mouse and it is gone, on every pane, because a // message is a report and you have just proved you are back. Only @@ -4862,6 +4871,7 @@ pub const Pardes = struct { const pane = p.panes[o.pane] orelse return; pane.appendTtyReplay(o.bytes); pane.stream.nextSlice(o.bytes); + p.shell_rows.markStale(pane); // follow new output to the bottom only in tty mode; in // normal/insert the user is reading scrollback and the view // holds still while output streams in. Tag edits hijack @@ -6048,14 +6058,23 @@ pub const Pardes = struct { return .{ .lines = ls, .row0 = 0 }; } const rows = try term_pane.shellRows(p, pane); + // No edit buffer: the loop below would copy `rows` into a second array + // one slice at a time and return exactly what it was given. On a + // scrollback of any size that copy is the keystroke. + if (pane.ovl == null) return .{ .lines = rows, .row0 = 0 }; // the buffer may sit below the dump's last row (the grid's trailing // blanks are trimmed off), so run the loop to whichever ends later var last = rows.len; if (pane.ovl) |o| last = @max(last, @as(usize, @intCast(@max(0, o.row + o.rows)))); var count = last; if (pane.ovl) |o| { + // The split below writes ONE line for an empty buffer, but + // modal.lineCount("") is 0 — the same floor `n - 1` needs at the + // paste site. Without it, killing a whole line (`A<C-u>`, `d%`) + // sizes `ls` one short of what the loop writes, and on a buffer + // covering the last row that is a length of zero. if (o.row >= 0 and @as(usize, @intCast(o.row)) < last) - count = count - @min(@as(usize, @intCast(o.rows)), last - @as(usize, @intCast(o.row))) + modal.lineCount(o.text); + count = count - @min(@as(usize, @intCast(o.rows)), last - @as(usize, @intCast(o.row))) + @max(1, modal.lineCount(o.text)); } const ls = try arena.alloc([]const u8, count); var n: usize = 0; @@ -6097,6 +6116,13 @@ pub const Pardes = struct { fn flatSurface(p: *Pardes, pane: *Pane, pl: PaneLines) ![]const u8 { if (pane.file) |f| return f.content; if (hasPdf(pane)) return p.ensurePdfText(pane); + // The memo already holds these rows joined by '\n' — that is what its + // `text` IS. The identity check is the same argument hxOff makes below: + // `lines` came out of the cache untouched (no edit buffer stood in for + // any row), so the join cannot have drifted from it. + const c = &p.shell_rows; + if (!c.stale and c.pane == pane and + pl.lines.ptr == c.rows.ptr and pl.lines.len == c.rows.len) return c.text; const arena = p.scratch.allocator(); var total: usize = if (pl.lines.len > 0) pl.lines.len - 1 else 0; for (pl.lines) |ln| total += ln.len; @@ -6127,6 +6153,15 @@ pub const Pardes = struct { /// differential and 561 parity cases say so; the arithmetic below is /// hxOff/hxPos transcribed, with line i's terminator read off idx[i+1]-1 /// instead of searched for. + /// + /// ponytail: a terminal's motion surface is NOT one of the things with no + /// index any more — shell_rows.rows are slices into shell_rows.text, so + /// their offsets ARE the line starts, for free. Claiming them here needs + /// `p`, which means threading it through paneOff/panePos/paneLineStart and + /// their callers (paneRange, setPaneRange, the three motion helpers). Left + /// undone deliberately: measured, that is a terminal key-down of 615us + /// against 140us, and 0.6ms is not a keystroke anybody feels. Do it the + /// day one of those scans shows up next to something that matters. fn indexOf(pane: *Pane, text: []const u8) ?[]const usize { const f = if (pane.file) |*ff| ff else return null; if (text.ptr != f.content.ptr or text.len != f.content.len) return null; @@ -12563,6 +12598,7 @@ pub const Pardes = struct { // the size so bodyText renders the right number of rows if (pane.isTerminal()) { pane.vt.resize(p.gpa, .{ .cols = cols, .rows = rows }) catch {}; + p.shell_rows.markStale(pane); // reflow moved every row p.emit(.{ .resize_pty = .{ .pane = @intCast(id), .cols = cols, .rows = rows } }); } pane.cols = cols; diff --git a/src/term_pane.zig b/src/term_pane.zig index 5e50e15d..9f13832f 100644 --- a/src/term_pane.zig +++ b/src/term_pane.zig @@ -19,34 +19,134 @@ const EditSnap = pardes.EditSnap; const Ovl = pardes.Ovl; const modal = @import("modal.zig"); +/// The memo behind `shellRows`. ONE entry for the editor, because the motion +/// surface is built for the pane the cursor is in and a second pane asking +/// would only double a multi-megabyte buffer for a slot it is about to lose +/// again. gpa-owned rather than scratch-arena: the whole point is to outlive +/// the update that built it. +/// +/// LIFETIME, the part that would rot silently: `rows` is handed out to +/// callers, so the buffers are freed in exactly two places — `sweep`, at the +/// TOP of an update before any handler can be holding them, and `reset` when +/// the editor goes away. Everything that notices the entry has gone bad +/// (output arrived, the grid reflowed, the pane died, another pane wants the +/// slot) only marks it `stale`; nothing frees mid-update. That is the same +/// guarantee the scratch arena gave, spelled out. +pub const RowsCache = struct { + /// whose grid this describes; null = the slot is free + pane: ?*const Pane = null, + /// the rows joined by '\n' — `flatSurface` hands this back verbatim + /// instead of rebuilding the join on every keystroke + text: []const u8 = &.{}, + /// slices INTO `text`, absolute grid rows from 0 + rows: [][]const u8 = &.{}, + /// `text` is a prefix of this: blanking a prompt row shortens the join, + /// and the slack is not worth a second allocation to reclaim + text_alloc: []u8 = &.{}, + stale: bool = false, + + pub fn reset(c: *RowsCache, gpa: std.mem.Allocator) void { + if (c.text_alloc.len > 0) gpa.free(c.text_alloc); + if (c.rows.len > 0) gpa.free(c.rows); + c.* = .{}; + } + + /// Free a stale entry. Called at the top of `update`, and nowhere else. + pub fn sweep(c: *RowsCache, gpa: std.mem.Allocator) void { + if (c.stale) c.reset(gpa); + } + + /// `pane`'s grid moved: the entry no longer describes it. + pub fn markStale(c: *RowsCache, pane: *const Pane) void { + if (c.pane == pane) c.stale = true; + } + + /// `pane` is being destroyed. Drop the pointer now — a freed pane's + /// address can come back from the allocator as a different pane, and an + /// entry still naming it would answer for the wrong grid — but leave the + /// buffers to the next sweep, as ever. + pub fn dropPane(c: *RowsCache, pane: *const Pane) void { + if (c.pane != pane) return; + c.pane = null; + c.stale = true; + } +}; + +const Rows = struct { + text_alloc: []u8, + text: []const u8, + rows: [][]const u8, +}; + /// A terminal's shell rows as the surface sees them: the WHOLE /// history+active grid, prompt rows blanked (OSC 133), absolute grid rows /// from 0. The raw material the motion surface is composed from — the /// edit buffer is NOT applied here, so it is also what seeding the buffer -/// reads. Scratch-arena backed. +/// reads. /// ghostty's dump trims the grid's trailing blank rows; ONE of them is /// kept back, the row the cursor sits on below the last line of output. /// That row is a file's final newline: without it the surface would have /// one line fewer than the same text in a document, and every motion and /// linewise edit at the bottom would diverge. -/// ponytail: O(scrollback) dump+scan per keystroke — window it around the -/// viewport if a multi-MB scrollback ever makes navigation lag. +/// +/// Building it is O(scrollback) — a dump of the whole history — and a +/// keystroke asks for it once or twice, so the result is memoized against the +/// pane until its grid changes. A pane sitting on 16 MiB of agent transcript +/// paid that dump per press of `j` before the cache; now it pays it once per +/// chunk of output. pub fn shellRows(p: *Pardes, pane: *Pane) ![]const []const u8 { - const arena = p.scratch.allocator(); - const full = try pane.vt.screens.active.dumpStringAlloc(arena, .{ .screen = .{} }); + const c = &p.shell_rows; + if (!c.stale and c.pane == pane) return c.rows; + if (c.pane != null) { + // Another pane holds the slot. Take it for the NEXT update (the sweep + // frees what is there) and answer this one from scratch: whoever owns + // the live entry may still be holding the rows it handed out. + c.stale = true; + return (try buildRows(p.scratch.allocator(), p, pane)).rows; + } + const built = try buildRows(p.gpa, p, pane); + c.* = .{ + .pane = pane, + .text = built.text, + .rows = built.rows, + .text_alloc = built.text_alloc, + }; + return c.rows; +} + +fn buildRows(alloc: std.mem.Allocator, p: *Pardes, pane: *Pane) !Rows { + const full = try pane.vt.screens.active.dumpStringAlloc(p.scratch.allocator(), .{ .screen = .{} }); var pit = pane.vt.screens.active.pages.rowIterator(.right_down, .{ .screen = .{} }, null); // split yields one more item than delimiters; the extra final slot is the // cursor row retained below. - const rows = try arena.alloc([]const u8, std.mem.count(u8, full, "\n") + 2); + const n_rows = std.mem.count(u8, full, "\n") + 2; + const rows = try alloc.alloc([]const u8, n_rows); + errdefer alloc.free(rows); + // Blanking a prompt row only ever SHORTENS it and the retained cursor row + // adds one separator, so the dump's length plus one bounds the join. + const text = try alloc.alloc(u8, full.len + 1); + errdefer alloc.free(text); + var at: usize = 0; var n: usize = 0; var it = std.mem.splitScalar(u8, full, '\n'); while (it.next()) |raw| { const is_prompt = if (pit.next()) |pin| pin.rowAndCell().row.semantic_prompt != .none else false; - rows[n] = if (is_prompt) "" else raw; + const shown = if (is_prompt) "" else raw; + if (n > 0) { + text[at] = '\n'; + at += 1; + } + @memcpy(text[at..][0..shown.len], shown); + rows[n] = text[at..][0..shown.len]; + at += shown.len; n += 1; } - rows[n] = ""; - return rows; + text[at] = '\n'; + at += 1; + rows[n] = text[at..][0..0]; + n += 1; + std.debug.assert(n == n_rows); + return .{ .text_alloc = text, .text = text[0..at], .rows = rows }; } /// The body a terminal renders: the viewport's shell rows (prompt rows blanked 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; } |
