From 60367d8fe23f6af98ec28e3cf6c2094dfe332df0 Mon Sep 17 00:00:00 2001 From: Gabriel Schneider Date: Sun, 6 Sep 2026 18:11:36 -0300 Subject: Refactor panes and filesystem; replace FUSE with 9P Consolidate pane, layout, memory and host code. Serve 9P by default over Unix sockets, with runtime mounts and optional TCP/QUIC transports. Remove FUSE and obsolete proof-of-concept examples. Fix highlighting and terminal-history performance, expand differential and stress-test infrastructure, sort navigation results while preserving the next occurrence, add syntax-colored Braille minimaps, remove SPC-k, and document 9P interaction as a repository skill. --- test/perf.zig | 1330 ++++++++++++++++++++++++++++++++++++++++++++++----------- 1 file changed, 1070 insertions(+), 260 deletions(-) (limited to 'test/perf.zig') diff --git a/test/perf.zig b/test/perf.zig index 171bbfbc..5aa5c4d4 100644 --- a/test/perf.zig +++ b/test/perf.zig @@ -1,65 +1,29 @@ -//! 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. +//! End-to-end gesture latency: update, effects, render. +//! Each timed sample starts at a deterministic position across its fixture. +//! Compare builds with the same harness, geometry, fixtures and repetition count. const std = @import("std"); const libc = std.c; const pardes = @import("pardes"); +const build_identity = @import("perf_options").identity; +const BuildIdentity = @TypeOf(build_identity); -// 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; +const harness_id = blk: { + @setEvalBranchQuota(100_000); + break :blk std.fmt.comptimePrint("{x}", .{std.hash.Wyhash.hash(0, @embedFile("perf.zig"))}); +}; -/// 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, @@ -67,36 +31,20 @@ const Fixture = struct { }; 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 { @@ -112,46 +60,23 @@ 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 { @@ -166,9 +91,6 @@ const TermOp = enum { } }; -/// 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 { @@ -178,67 +100,239 @@ const Cell = struct { max_us: u64 = 0, }; +const Allocations = struct { + backing: std.mem.Allocator = gpa, + account: ?*Allocations = null, + calls: usize = 0, + bytes: usize = 0, + live: usize = 0, + peak: usize = 0, + + fn allocator(self: *Allocations) std.mem.Allocator { + return .{ .ptr = self, .vtable = &.{ .alloc = alloc, .resize = resize, .remap = remap, .free = free } }; + } + + fn alloc(ctx: *anyopaque, len: usize, alignment: std.mem.Alignment, ret: usize) ?[*]u8 { + const wrapper: *Allocations = @ptrCast(@alignCast(ctx)); + const self = wrapper.account orelse wrapper; + const ptr = wrapper.backing.rawAlloc(len, alignment, ret) orelse return null; + self.calls += 1; + self.bytes += len; + self.live += len; + self.peak = @max(self.peak, self.live); + return ptr; + } + + fn resize(ctx: *anyopaque, memory: []u8, alignment: std.mem.Alignment, len: usize, ret: usize) bool { + const wrapper: *Allocations = @ptrCast(@alignCast(ctx)); + const self = wrapper.account orelse wrapper; + if (!wrapper.backing.rawResize(memory, alignment, len, ret)) return false; + self.live = self.live - memory.len + len; + self.peak = @max(self.peak, self.live); + return true; + } + + fn remap(ctx: *anyopaque, memory: []u8, alignment: std.mem.Alignment, len: usize, ret: usize) ?[*]u8 { + const wrapper: *Allocations = @ptrCast(@alignCast(ctx)); + const self = wrapper.account orelse wrapper; + const ptr = wrapper.backing.rawRemap(memory, alignment, len, ret) orelse return null; + self.calls += 1; + self.bytes += len; + self.live = self.live - memory.len + len; + self.peak = @max(self.peak, self.live); + return ptr; + } + + fn free(ctx: *anyopaque, memory: []u8, alignment: std.mem.Alignment, ret: usize) void { + const wrapper: *Allocations = @ptrCast(@alignCast(ctx)); + const self = wrapper.account orelse wrapper; + self.live -= memory.len; + wrapper.backing.rawFree(memory, alignment, ret); + } +}; + +fn measureCreation(io: std.Io, reps: usize, json: bool) !void { + if (reps == 0 or screen_cols == 0 or screen_rows == 0) return error.InvalidMeasurement; + const samples = try gpa.alloc(u64, reps); + defer gpa.free(samples); + const deinit_samples = try gpa.alloc(u64, reps); + defer gpa.free(deinit_samples); + for ([_]bool{ false, true }) |terminal| { + if (terminal and !pardes.terminal_panes) continue; + var cold_ns: u64 = 0; + var cold_deinit_ns: u64 = 0; + var calls: usize = 0; + var bytes: usize = 0; + var live: usize = 0; + var peak: usize = 0; + for (0..reps + 1) |index| { + var counter: Allocations = .{}; + const allocator = counter.allocator(); + const begin = nowNs(); + const pane = if (terminal) + try pardes.panes.Terminal.create(allocator, screen_cols, screen_rows) + else + try pardes.panes.Terminal.createDoc(allocator, screen_cols, screen_rows); + const constructed = nowNs(); + const retained = counter.live; + pardes.panes.Terminal.deinitEmulator(pane, allocator); + allocator.destroy(pane); + const destroyed = nowNs(); + if (counter.live != 0) return error.LeakedPaneMemory; + if (index == 0) { + cold_ns = constructed - begin; + cold_deinit_ns = destroyed - constructed; + } else { + samples[index - 1] = constructed - begin; + deinit_samples[index - 1] = destroyed - constructed; + calls += counter.calls; + bytes += counter.bytes; + live += retained; + peak = @max(peak, counter.peak); + } + } + std.mem.sort(u64, samples, {}, std.sort.asc(u64)); + std.mem.sort(u64, deinit_samples, {}, std.sort.asc(u64)); + const result = .{ + .build = build_identity, + .harness = harness_id, + .case = if (terminal) "create-terminal" else "create-document", + .cols = screen_cols, + .rows = screen_rows, + .reps = reps, + .pane_bytes = @sizeOf(pardes.Pane), + .cold_ns = cold_ns, + .median_ns = samples[reps / 2], + .p95_ns = samples[@min(reps - 1, reps * 95 / 100)], + .cold_deinit_ns = cold_deinit_ns, + .deinit_median_ns = deinit_samples[reps / 2], + .allocations = calls / reps, + .allocated_bytes = bytes / reps, + .live_bytes = live / reps, + .peak_bytes = peak, + .live_after_deinit = @as(usize, 0), + }; + const text = if (json) + try std.json.Stringify.valueAlloc(gpa, result, .{}) + else + try std.fmt.allocPrint(gpa, "{s}: pane {d} B, {d} allocations / {d} B, retained {d} B, create {d} ns, deinit {d} ns, balanced", .{ + result.case, result.pane_bytes, result.allocations, result.allocated_bytes, result.live_bytes, result.median_ns, result.deinit_median_ns, + }); + defer gpa.free(text); + try std.Io.File.stdout().writeStreamingAll(io, text); + try std.Io.File.stdout().writeStreamingAll(io, "\n"); + } +} + +const Options = struct { + json: bool = false, + reps: usize = 25, + cols: u16 = 120, + rows: u16 = 40, + base: ?[]const u8 = null, + only: ?[]const u8 = null, + creation: bool = false, + mini: bool = false, + terminal_mib: ?usize = null, + self_test: bool = false, + + fn parse(args: []const []const u8) !Options { + var opts: Options = .{}; + var i: usize = 0; + while (i < args.len) : (i += 1) { + const arg = args[i]; + if (!std.mem.startsWith(u8, arg, "--")) return error.UnknownOption; + const option = std.meta.stringToEnum(enum { json, creation, mini, @"terminal-mib", @"self-test", reps, cols, rows, base, only }, arg[2..]) orelse + return error.UnknownOption; + switch (option) { + .json => opts.json = true, + .creation => opts.creation = true, + .mini => opts.mini = true, + .@"self-test" => opts.self_test = true, + else => { + i += 1; + if (i == args.len) return error.MissingValue; + const value = args[i]; + switch (option) { + .reps => opts.reps = std.fmt.parseInt(usize, value, 10) catch return error.InvalidNumber, + .cols => opts.cols = std.fmt.parseInt(u16, value, 10) catch return error.InvalidNumber, + .rows => opts.rows = std.fmt.parseInt(u16, value, 10) catch return error.InvalidNumber, + .base => opts.base = value, + .only => opts.only = value, + .@"terminal-mib" => opts.terminal_mib = std.fmt.parseInt(usize, value, 10) catch return error.InvalidNumber, + else => unreachable, + } + }, + } + } + if (opts.reps == 0 or opts.reps > std.math.maxInt(usize) / 100 or opts.cols == 0 or opts.rows == 0) + return error.InvalidMeasurement; + if (!opts.creation and (opts.cols < 8 or opts.rows < 5)) return error.InvalidMeasurement; + if (opts.terminal_mib) |mib| { + if (mib == 0 or mib > 256 or opts.cols < 32 or opts.cols > 512 or opts.rows < 8 or opts.rows > 256) + return error.InvalidMeasurement; + if (opts.creation or opts.only != null or opts.base != null) return error.IncompatibleOptions; + } + if (opts.base) |path| if (path.len == 0) return error.MissingValue; + if (opts.mini and (opts.creation or opts.terminal_mib != null or opts.only != null or opts.base != null)) + return error.IncompatibleOptions; + if (opts.only) |name| { + for (fixtures) |fixture| { + if (std.mem.eql(u8, name, fixture.name)) break; + } else return error.UnknownFixture; + } + if ((opts.creation and (opts.base != null or opts.only != null)) or + (opts.json and opts.base != null) or (opts.self_test and args.len != 1)) return error.IncompatibleOptions; + return opts; + } +}; + 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 + const opts = try Options.parse(args[1..]); + if (opts.self_test) return selfTest(); + screen_cols = opts.cols; + screen_rows = opts.rows; + const reps = opts.reps; + const only = opts.only; + if (opts.creation) return measureCreation(init.io, reps, opts.json); + if (opts.terminal_mib) |mib| return measureTerminalSession(init.io, mib, reps, opts.json); + if (opts.mini) return measureMini(init.io, init.gpa, reps, opts.json); + + var random: [12]u8 = undefined; + init.io.random(&random); + const dir = try std.fmt.allocPrint(init.arena.allocator(), "/tmp/pardes-perf-{x}", .{random}); + try std.Io.Dir.createDirAbsolute(init.io, dir, .default_dir); + defer std.Io.Dir.cwd().deleteTree(init.io, dir) catch {}; var paths: [fixtures.len][]const u8 = undefined; var bytes: [fixtures.len]usize = undefined; + var created: usize = 0; + defer for (paths[0..created]) |path| gpa.free(path); for (fixtures, 0..) |fx, fi| { const path = try std.fmt.allocPrint(gpa, "{s}/{s}.zig", .{ dir, fx.name }); + paths[fi] = path; + created += 1; const text = try generate(fx); + defer gpa.free(text); 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); } + const base = if (opts.base) |path| blk: { + const source = try readFileAlloc(path); + defer gpa.free(source); + break :blk try parseBase(source, opts, &bytes); + } else null; - // `--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); + cells[oi][fi] = try measure(op, fx, paths[fi], bytes[fi], reps); } } term_chunk = try buildTermText(4 * 1024); + defer gpa.free(term_chunk); 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| { @@ -247,24 +341,16 @@ pub fn main(init: std.process.Init) !void { } } - if (json) return reportJson(init.io, &cells, &term_cells, &bytes, reps); - reportText(&cells, &term_cells, &bytes, reps, base_path); + if (opts.json) return reportJson(init.io, &cells, &term_cells, &bytes, opts); + reportText(&cells, &term_cells, &bytes, reps, opts.base, base); } -// ---- 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 { +fn measure(op: Op, fx: Fixture, path: []const u8, expected_bytes: usize, 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(); @@ -273,6 +359,9 @@ fn measure(op: Op, fx: Fixture, path: []const u8, reps: usize) !Cell { pump(core); _ = try frame(core); const dt = nowNs() -| t0; + const id = filePane(core) orelse return error.OpenDidNotOpenFile; + const file = &core.panes[id].?.file.?; + if (!std.mem.eql(u8, file.path, path) or file.content.len != expected_bytes) return error.WrongOpenedFile; if (n >= warmup) samples[n - warmup] = dt / 1000; } return summarize(samples); @@ -282,13 +371,10 @@ fn measure(op: Op, fx: Fixture, path: []const u8, reps: usize) !Cell { defer core.deinit(); core.lookAt(0, path); pump(core); - const id = filePane(core) orelse fatal("Look {s} opened no file pane", .{path}); + const id = filePane(core) orelse return error.OpenDidNotOpenFile; 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); @@ -297,6 +383,10 @@ fn measure(op: Op, fx: Fixture, path: []const u8, reps: usize) !Cell { for (0..warmup + reps) |n| { seek(pane, fx, n); _ = try frame(core); // settle the view at the new spot, untimed + const before = .{ .row = pane.cur_row, .col = pane.cur_col, .scroll = pane.scroll(), .len = pane.file.?.content.len }; + const rect = core.rects[id]; + const wheel_col = rect.x + pardes.config.GUTTER; + const wheel_row = if (core.settings.tag_bottom) rect.y else rect.y + pardes.BOX_H; const t0 = nowNs(); switch (op) { .open => unreachable, @@ -304,21 +394,34 @@ fn measure(op: Op, fx: Fixture, path: []const u8, reps: usize) !Cell { .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 } }), + .wheel => core.update(.{ .mouse = .{ .button = .wheel_down, .kind = .press, .col = wheel_col, .row = wheel_row } }), .edit_char => core.update(.{ .key = .{ .cp = 'x', .text = "x" } }), } pump(core); _ = try frame(core); const dt = nowNs() -| t0; + switch (op) { + .open => unreachable, + .render => if (pane.cur_row != before.row or pane.cur_col != before.col or pane.file.?.content.len != before.len) + return error.RenderChangedDocument, + .key_down => if (pane.cur_row != before.row + 1) return error.DownDidNotMove, + .key_right => if (pane.cur_row != before.row or pane.cur_col != before.col + 1) return error.RightDidNotMove, + .page_down => if (pane.scroll() <= before.scroll and before.scroll < @as(i32, @intCast(fx.lines)) - pane.rows) + return error.PageDidNotScroll, + .wheel => if (pane.scroll() <= before.scroll and before.scroll < @as(i32, @intCast(fx.lines)) - pane.rows) + return error.WheelDidNotScroll, + .edit_char => { + if (pane.file.?.content.len != before.len + 1 or pane.cur_row != before.row or pane.cur_col != before.col + 1) + return error.CharacterWasNotInserted; + const line = pardes.panes.File.sourceLine(pane, before.row); + if (line[@intCast(before.col)] != 'x') return error.CharacterWasNotInserted; + }, + } 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); @@ -326,26 +429,42 @@ fn measureTerm(op: TermOp, fx: TermFixture, reps: usize) !Cell { 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 + const pane = core.panes[0] orelse return error.MissingTerminal; + const state = pane.terminal orelse return error.MissingTerminal; 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); + const motion_rows = if (op == .key_down) (try core.paneCursorLines(pane)).len else 0; + if (op == .key_down and motion_rows < 3) return error.EmptyTerminalFixture; var cols = screen_cols; var rows = screen_rows; for (0..warmup + reps) |n| { + if (op == .key_down) { + const span = @max(1, motion_rows * 8 / 10); + pane.cur_row = @intCast(@min(motion_rows - 2, motion_rows / 10 + (n *% 7919) % span)); + pane.cur_col = 0; + pane.cur_pinned = true; + pane.sticky_col = -1; + pane.ensureCursorVisible(); + _ = try frame(core); + } + const before = .{ + .row = pane.cur_row, + .col = pane.cur_col, + .edit_len = if (pane.ovl) |overlay| overlay.text.len else 0, + .replay_head = state.replay.head, + .replay_len = state.replay.len, + .cols = pane.cols, + .rows = pane.rows, + }; 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 } }); @@ -360,16 +479,45 @@ fn measureTerm(op: TermOp, fx: TermFixture, reps: usize) !Cell { pump(core); _ = try frame(core); const dt = nowNs() -| t0; + switch (op) { + .render => if (pane.cur_row != before.row or pane.cur_col != before.col or + state.replay.head != before.replay_head or state.replay.len != before.replay_len) + return error.RenderChangedTerminal, + .output => { + const capacity = state.replay.bytes.len; + if (term_chunk.len == 0 or term_chunk.len >= capacity) return error.InvalidOutputFixture; + const overflow = term_chunk.len -| (capacity - before.replay_len); + if (state.replay.len != @min(capacity, before.replay_len + term_chunk.len) or + state.replay.head != (before.replay_head + overflow) % capacity) + return error.OutputWasNotRecorded; + const start = (state.replay.head + state.replay.len - term_chunk.len) % capacity; + const first = @min(term_chunk.len, capacity - start); + if (!std.mem.eql(u8, term_chunk[0..first], state.replay.bytes[start..][0..first]) or + !std.mem.eql(u8, term_chunk[first..], state.replay.bytes[0 .. term_chunk.len - first])) + return error.WrongTerminalOutput; + }, + .resize_rows => if (pane.rows == before.rows or pane.cols != before.cols or + state.vt.screens.active.pages.rows != pane.rows) return error.TerminalDidNotResize, + .resize_cols => if (pane.cols == before.cols or pane.rows != before.rows or + state.vt.screens.active.pages.cols != pane.cols) return error.TerminalDidNotResize, + .key_down => if (pane.cur_row != before.row + 1) return error.DownDidNotMove, + .edit_char => { + const overlay = pane.ovl orelse return error.CharacterWasNotInserted; + if (overlay.text.len != before.edit_len + 1 or pane.cur_row != before.row or pane.cur_col != before.col + 1) + return error.CharacterWasNotInserted; + const line = pardes.modal.lineSlice(overlay.text, @intCast(pane.cur_row - overlay.row)); + if (before.col < 0 or before.col >= line.len or line[@intCast(before.col)] != 'x') + return error.CharacterWasNotInserted; + }, + } 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 }); + errdefer core.deinit(); core.update(.{ .resize = .{ .cols = screen_cols, .rows = screen_rows } }); pump(core); const text = try buildTermText(fx.kb * 1024); @@ -384,10 +532,6 @@ fn bootTerm(fx: TermFixture) !*pardes.Pardes { 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(); @@ -407,14 +551,505 @@ fn buildTermText(want: usize) ![]const u8 { 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 terminalBatch(buffer: []u8, batch: usize, cols: u16) ![]const u8 { + var out: std.Io.Writer = .fixed(buffer); + try out.writeAll("\r\x1b[2K"); + var record: usize = 0; + while (out.end < 8192) : (record += 1) { + try out.print("\x1b[36mturn {d} tool {d}\x1b[0m reading src/example.zig\r\n", .{ batch, record }); + try out.writeAll("\x1b[90m reviewing: \x1b[0m"); + for (0..@as(usize, cols) * 3 / 16 + 1) |_| try out.writeAll("text λ界 result "); + try out.writeAll("\r\n\x1b[33mprogress 20%\x1b[0m\r\x1b[2K\x1b[32mprogress 100% ok\x1b[0m\r\n"); + } + try out.print("\x1b[32mTURN {d:0>10} READY\x1b[0m", .{batch}); + return out.buffered(); +} + +fn checkTerminalTail(core: *pardes.Pardes, surface: *pardes.Surface, batch: usize) !void { + const pane = core.panes[0] orelse return error.MissingTerminal; + const rect = core.rects[0]; + const x = rect.x + pardes.config.GUTTER; + const y = (if (core.settings.tag_bottom) rect.y else rect.y + pardes.BOX_H) + pardes.panes.Terminal.gridCursor(pane).y; + var expected_buffer: [32]u8 = undefined; + const expected = try std.fmt.bufPrint(&expected_buffer, "TURN {d:0>10} READY", .{batch}); + for (expected, 0..) |byte, col| { + const cell = surface.at(x + @as(u16, @intCast(col)), y); + if (!std.mem.eql(u8, cell.grapheme(), &.{byte})) return error.LatestTerminalTailMissing; + if (!std.meta.eql(pardes.Color{ .index = 2 }, cell.style.fg)) return error.LatestTerminalTailStyleWrong; + } +} + +fn processPeakRss() ?usize { + const os = @import("builtin").os.tag; + if (comptime os != .linux and !os.isDarwin()) return null; + if (comptime os == .linux) { + const fd = libc.open("/proc/self/status", .{ .ACCMODE = .RDONLY }); + if (fd < 0) return null; + defer _ = libc.close(fd); + var buffer: [8192]u8 = undefined; + const n = libc.read(fd, &buffer, buffer.len); + if (n <= 0) return null; + var lines = std.mem.splitScalar(u8, buffer[0..@intCast(n)], '\n'); + while (lines.next()) |line| { + if (!std.mem.startsWith(u8, line, "VmHWM:")) continue; + var words = std.mem.tokenizeAny(u8, line[6..], " \t"); + const kib = std.fmt.parseInt(usize, words.next() orelse return null, 10) catch return null; + return std.math.mul(usize, kib, 1024) catch null; + } + return null; + } + var usage: libc.rusage = undefined; + if (libc.getrusage(libc.rusage.SELF, &usage) != 0 or usage.maxrss < 0) return null; + return @intCast(usage.maxrss); +} + +const UndoOutput = struct { + depth: usize, + input_bytes: usize, + updates: usize, + history_bytes: usize, + update_median_ns: u64, + update_p95_ns: u64, + batch_median_ns: u64, + batch_p95_ns: u64, + allocator_calls: usize, + allocator_live_before: usize, + allocator_live_after: usize, + anchor_evicted: bool, +}; + +fn measureUndoOutput(core: *pardes.Pardes, counter: *Allocations, arena: *std.heap.ArenaAllocator, samples: []u64) ![3]UndoOutput { + const pane = core.panes[0].?; + const pages = &pane.terminal.?.vt.screens.active.pages; + if (pane.ed_undo_len != 0 or pane.ed_redo_len != 0) return error.UnexpectedTerminalHistory; + if (pane.ovl) |overlay| core.gpa.free(overlay.text); + pane.ovl = null; + var result: [3]UndoOutput = undefined; + const updates = try gpa.alloc(u64, samples.len * 4); + defer gpa.free(updates); + var buffer: [16 * 1024]u8 = undefined; + for ([_]usize{ 0, 1, 64 }, 0..) |depth, phase| { + const first: i32 = @intCast(@min(128, pages.total_rows - 2)); + for (0..depth) |index| { + pane.ovl = .{ .row = first, .rows = 2, .text = try std.fmt.allocPrint(core.gpa, "saved edit {d}\nsecond row", .{index}) }; + pane.cur_row = first + 1; + pane.cur_col = 1; + pane.vsel = .{ .active = true, .row = first, .col = 0 }; + pardes.panes.Terminal.pushUndo(core, pane); + core.gpa.free(pane.ovl.?.text); + pane.ovl = null; + if (pane.ed_undo_len != index + 1) return error.TerminalUndoSnapshotFailed; + } + defer { + for (pane.ed_undo[0..pane.ed_undo_len]) |snapshot| if (snapshot.ovl) |overlay| core.gpa.free(overlay.text); + pane.ed_undo_len = 0; + } + pardes.panes.Terminal.enterTty(core, 0); + pump(core); + const tracked_before = pages.countTrackedPins(); + const start = try pages.trackPin(pages.pin(.{ .screen = .{ .y = @intCast(first) } }).?); + defer pages.untrackPin(start); + const end = try pages.trackPin(pages.pin(.{ .screen = .{ .y = @intCast(first + 1) } }).?); + defer pages.untrackPin(end); + const calls_before = counter.calls; + const live_before = counter.live; + const history_bytes = pages.page_size; + var sent: usize = 0; + var nupdates: usize = 0; + for (samples, 0..) |*sample, batch| { + const bytes = try terminalBatch(&buffer, batch, pane.cols); + _ = arena.reset(.retain_capacity); + const begin = nowNs(); + var offset: usize = 0; + while (offset < bytes.len) { + const stop = @min(offset + 4096, bytes.len); + const update_begin = nowNs(); + core.update(.{ .output = .{ .pane = 0, .bytes = bytes[offset..stop] } }); + pump(core); + updates[nupdates] = nowNs() - update_begin; + nupdates += 1; + offset = stop; + } + const surface = try core.render(arena.allocator()); + sample.* = nowNs() - begin; + sent += bytes.len; + try checkTerminalTail(core, surface, batch); + if (pages.countTrackedPins() != tracked_before + 2) return error.TerminalPinsLeaked; + const expected: i32 = if (start.garbage) 0 else @intCast((pages.pointFromPin(.screen, start.*) orelse return error.TerminalAnchorLost).screen.y); + const last: i32 = if (end.garbage) 0 else @intCast((pages.pointFromPin(.screen, end.*) orelse return error.TerminalAnchorLost).screen.y); + for (pane.ed_undo[0..pane.ed_undo_len], 0..) |snapshot, index| { + const overlay = snapshot.ovl orelse return error.TerminalUndoTextLost; + var expected_buffer: [64]u8 = undefined; + const text = try std.fmt.bufPrint(&expected_buffer, "saved edit {d}\nsecond row", .{index}); + if (!std.mem.eql(u8, text, overlay.text)) return error.TerminalUndoTextChanged; + if (overlay.row != expected or overlay.rows != @max(1, last - expected + 1) or + snapshot.cur_row != expected + 1 or snapshot.cur_col != 1 or + !snapshot.vsel.active or snapshot.vsel.row != expected or snapshot.vsel.col != 0) + return error.TerminalUndoAnchorWrong; + } + } + std.mem.sort(u64, samples, {}, std.sort.asc(u64)); + std.mem.sort(u64, updates[0..nupdates], {}, std.sort.asc(u64)); + result[phase] = .{ + .depth = depth, + .input_bytes = sent, + .updates = nupdates, + .history_bytes = history_bytes, + .update_median_ns = updates[nupdates / 2], + .update_p95_ns = updates[(nupdates * 95 + 99) / 100 - 1], + .batch_median_ns = samples[samples.len / 2], + .batch_p95_ns = samples[(samples.len * 95 + 99) / 100 - 1], + .allocator_calls = counter.calls - calls_before, + .allocator_live_before = live_before, + .allocator_live_after = counter.live, + .anchor_evicted = start.garbage, + }; + } + return result; +} + +fn measureTerminalSession(io: std.Io, mib: usize, reps: usize, json: bool) !void { + if (comptime !pardes.terminal_panes) return error.TerminalsUnavailable; + const requested_bytes = mib * 1024 * 1024; + const samples = try gpa.alloc(u64, requested_bytes / 8192 + 1); + defer gpa.free(samples); + const modal_samples = try gpa.alloc(u64, reps); + defer gpa.free(modal_samples); + var counter: Allocations = .{}; + const result = blk: { + const core = try pardes.Pardes.init(counter.allocator(), .{ .tty_only = true, .cols = screen_cols, .rows = screen_rows }); + defer core.deinit(); + pump(core); + const pane = core.panes[0] orelse return error.MissingTerminal; + const state = pane.terminal orelse return error.MissingTerminal; + pane.mode = .tty; + pane.tty_filter = false; + var arena: std.heap.ArenaAllocator = .init(counter.allocator()); + defer arena.deinit(); + _ = try core.render(arena.allocator()); + var buffer: [16 * 1024]u8 = undefined; + var sent: usize = 0; + var batches: usize = 0; + var update_render_ns: u64 = 0; + var row_count_decreases: usize = 0; + var previous_rows: usize = 0; + const stream_begin = nowNs(); + while (sent < requested_bytes) : (batches += 1) { + const bytes = try terminalBatch(&buffer, batches, pane.cols); + _ = arena.reset(.retain_capacity); + const begin = nowNs(); + var offset: usize = 0; + while (offset < bytes.len) { + const end = @min(offset + 4096, bytes.len); + core.update(.{ .output = .{ .pane = 0, .bytes = bytes[offset..end] } }); + pump(core); + offset = end; + } + const surface = try core.render(arena.allocator()); + samples[batches] = nowNs() - begin; + update_render_ns += samples[batches]; + try checkTerminalTail(core, surface, batches); + const scrollbar = state.vt.screens.active.pages.scrollbar(); + if (scrollbar.offset != scrollbar.total - pane.rows) return error.TerminalStoppedFollowing; + if (scrollbar.total < previous_rows) row_count_decreases += 1; + previous_rows = scrollbar.total; + sent += bytes.len; + } + const stream_wall_ns = nowNs() - stream_begin; + const live_after_output = counter.live; + const first_row = @as(i32, @intCast(state.vt.screens.active.pages.scrollbar().offset)) + @as(i32, pardes.panes.Terminal.gridCursor(pane).y); + _ = arena.reset(.retain_capacity); + const modal_begin = nowNs(); + core.update(.{ .key = .{ .cp = core.opts.tty_toggle, .ctrl = true } }); + core.update(.{ .key = .{ .cp = 'k', .text = "k" } }); + pump(core); + _ = try core.render(arena.allocator()); + const first_modal_ns = nowNs() - modal_begin; + if (pane.mode != .normal or !pane.cur_pinned or pane.cur_row != first_row - 1) return error.FirstModalKeyDidNotMove; + for (modal_samples, 0..) |*sample, index| { + const down = index % 2 == 0; + const before = pane.cur_row; + _ = arena.reset(.retain_capacity); + const begin = nowNs(); + core.update(.{ .key = .{ .cp = if (down) 'j' else 'k' } }); + pump(core); + _ = try core.render(arena.allocator()); + sample.* = nowNs() - begin; + if (pane.cur_row != before + @as(i32, if (down) 1 else -1)) return error.ModalKeyDidNotMove; + } + core.update(.{ .key = .{ .cp = core.opts.tty_toggle, .ctrl = true } }); + pump(core); + _ = arena.reset(.retain_capacity); + try checkTerminalTail(core, try core.render(arena.allocator()), batches - 1); + core.update(.{ .key = .{ .cp = 'f', .text = "f" } }); + const input = core.nextEffect() orelse return error.TerminalInputMissing; + if (input != .write or input.write.pane != 0 or !std.mem.eql(u8, "f", input.write.bytes.slice())) + return error.TerminalInputWrong; + if (core.nextEffect() != null) return error.UnexpectedTerminalEffect; + std.mem.sort(u64, samples[0..batches], {}, std.sort.asc(u64)); + std.mem.sort(u64, modal_samples, {}, std.sort.asc(u64)); + const modal_median_ns = modal_samples[reps / 2]; + const modal_p95_ns = modal_samples[(reps * 95 + 99) / 100 - 1]; + const live_after_modal = counter.live; + var overlay_median_ns: [2]u64 = undefined; + var overlay_p95_ns: [2]u64 = undefined; + pane.mode = .normal; + pane.cur_pinned = true; + pane.ovl = .{ .row = 0, .rows = 1, .text = try core.gpa.dupe(u8, "edited output") }; + for (0..2) |phase| { + for (modal_samples, 0..) |*sample, index| { + const tail_id = batches + phase * reps + index; + var update_buffer: [64]u8 = undefined; + const update = try std.fmt.bufPrint(&update_buffer, "\r\n\x1b[32mTURN {d:0>10} READY\x1b[0m", .{tail_id}); + _ = arena.reset(.retain_capacity); + const begin = nowNs(); + core.update(.{ .output = .{ .pane = 0, .bytes = update } }); + pump(core); + const cursor_row = pardes.panes.Terminal.gridOffset(pane) + @as(i32, pardes.panes.Terminal.gridCursor(pane).y); + pane.ovl.?.row = if (phase == 0) 0 else cursor_row - 1; + pane.cur_row = pane.ovl.?.row; + pane.cur_col = 0; + const surface = try core.render(arena.allocator()); + sample.* = nowNs() - begin; + try checkTerminalTail(core, surface, tail_id); + } + std.mem.sort(u64, modal_samples, {}, std.sort.asc(u64)); + overlay_median_ns[phase] = modal_samples[reps / 2]; + overlay_p95_ns[phase] = modal_samples[(reps * 95 + 99) / 100 - 1]; + } + const live_after_overlay = counter.live; + const undo_output = try measureUndoOutput(core, &counter, &arena, modal_samples); + break :blk .{ + .build = build_identity, + .harness = harness_id, + .case = "terminal-session", + .cols = screen_cols, + .rows = screen_rows, + .reps = reps, + .requested_bytes = requested_bytes, + .input_bytes = sent, + .batches = batches, + .update_render_ns = update_render_ns, + .stream_wall_ns = stream_wall_ns, + .mib_per_second = @as(f64, @floatFromInt(sent)) * 1_000_000_000 / 1048576 / @as(f64, @floatFromInt(@max(1, update_render_ns))), + .batch_median_ns = samples[batches / 2], + .batch_p95_ns = samples[(batches * 95 + 99) / 100 - 1], + .batch_max_ns = samples[batches - 1], + .first_modal_ns = first_modal_ns, + .modal_median_ns = modal_median_ns, + .modal_p95_ns = modal_p95_ns, + .offscreen_overlay_median_ns = overlay_median_ns[0], + .offscreen_overlay_p95_ns = overlay_p95_ns[0], + .visible_overlay_median_ns = overlay_median_ns[1], + .visible_overlay_p95_ns = overlay_p95_ns[1], + .undo_output = undo_output, + .history_rows = state.vt.screens.active.pages.scrollbar().total, + .history_bytes = state.vt.screens.active.pages.page_size, + .history_limit_bytes = state.vt.screens.active.pages.maxSize(), + .row_count_decreases = row_count_decreases, + .allocator_calls = counter.calls, + .allocator_live_after_output = live_after_output, + .allocator_live_after_modal = live_after_modal, + .allocator_live_after_overlay = live_after_overlay, + .allocator_peak_bytes = counter.peak, + .allocator_live_after_deinit = @as(usize, 0), + .process_peak_rss_bytes = processPeakRss(), + .process_rss_scope = "current image VmHWM on Linux; process rusage on Darwin", + .allocator_scope = "core and frame allocator; excludes VT page mappings", + }; + }; + if (counter.live != 0) return error.LeakedSessionMemory; + const text = if (json) + try std.json.Stringify.valueAlloc(gpa, result, .{}) + else + try std.fmt.allocPrint(gpa, "terminal session: {d} bytes, {d} batches, {d:.2} MiB/s; p95 {d} ns, first modal {d} ns; history {d} rows / {d} B, allocator peak {d} B (excludes VT mappings), process peak {?d} B", .{ + result.input_bytes, result.batches, result.mib_per_second, result.batch_p95_ns, result.first_modal_ns, + result.history_rows, result.history_bytes, result.allocator_peak_bytes, result.process_peak_rss_bytes, + }); + defer gpa.free(text); + try std.Io.File.stdout().writeStreamingAll(io, text); + try std.Io.File.stdout().writeStreamingAll(io, "\n"); +} + +const MiniProbe = enum { generate, highlight_generate, render }; +const MiniMeasurement = struct { + cold_ns: u64 = 0, + median_ns: u64 = 0, + p95_ns: u64 = 0, + allocations_total: usize = 0, + allocated_bytes_total: usize = 0, + warm_allocations_total: usize = 0, + warm_allocated_bytes_total: usize = 0, + peak_bytes: usize = 0, + live_after_warm: usize = 0, + live_after_deinit: usize = 0, +}; + +fn checkMini(actual: pardes.panes.Mini.Result, expected: pardes.panes.Mini.Result) !void { + if (!std.mem.eql(u8, actual.content, expected.content) or !std.mem.eql(u8, actual.colors, expected.colors)) + return error.MiniOutputMismatch; +} + +fn miniMeasurement(fallback: std.mem.Allocator, probe: MiniProbe, path: []const u8, source: []const u8, styles: []const u8, expected: pardes.panes.Mini.Result, reps: usize) !MiniMeasurement { + const samples = try gpa.alloc(u64, reps); + defer gpa.free(samples); + const allocators = pardes.memory.init(fallback); + defer pardes.memory.deinit(); + var counter: Allocations = .{}; + var core_counter: Allocations = .{ .backing = allocators.pardes, .account = &counter }; + var syntax_counter: Allocations = .{ .backing = allocators.tree_sitter, .account = &counter }; + var frame_counter: Allocations = .{ .backing = allocators.frame, .account = &counter }; + const allocator = core_counter.allocator(); + const syntax_allocator = syntax_counter.allocator(); + const frame_allocator = frame_counter.allocator(); + var result: MiniMeasurement = .{}; + { + if (probe != .generate) pardes.syntax.start(syntax_allocator); + defer if (probe != .generate) pardes.syntax.stop(); + const core = if (probe == .render) + try pardes.Pardes.init(allocator, .{ + .tty_only = true, + .cols = screen_cols, + .rows = screen_rows, + .tree_sitter_allocator = syntax_allocator, + .frame_allocator = frame_allocator, + }) + else + null; + defer if (core) |p| p.deinit(); + var arena: std.heap.ArenaAllocator = .init(frame_allocator); + defer arena.deinit(); + if (core) |p| { + pump(p); + try pardes.panes.Mini.open(p, 0, path); + for (p.panes, 0..) |slot, id| if (slot != null and id != p.active) try p.removePane(id); + pump(p); + p.settings.colors = true; + const file = p.panes[p.active].?.file.?; + try checkMini(.{ .content = file.content, .colors = file.mini.?.colors }, expected); + } + var frame_hash: ?u64 = null; + for (0..reps + 1) |index| { + _ = arena.reset(.retain_capacity); + const calls = counter.calls; + const bytes = counter.bytes; + const begin = nowNs(); + if (core) |p| { + const surface = try p.render(arena.allocator()); + const elapsed = nowNs() - begin; + if (index == 0) result.cold_ns = elapsed else samples[index - 1] = elapsed; + var hash: std.hash.Wyhash = .init(0); + var braille_cells: usize = 0; + for (surface.cells) |cell| { + const glyph = cell.grapheme(); + hash.update(glyph); + std.hash.autoHash(&hash, cell.style); + if (glyph.len == 3 and glyph[0] == 0xe2 and glyph[1] >= 0xa0 and glyph[1] <= 0xa3) + braille_cells += 1; + } + if (braille_cells == 0) return error.MiniNotRendered; + if (frame_hash) |old| { + if (old != hash.final()) return error.MiniRenderChanged; + } else frame_hash = hash.final(); + } else { + const highlighted = if (probe == .highlight_generate) + try pardes.syntax.highlightFileRange(syntax_allocator, path, source, 0, source.len) + else + styles; + defer if (probe == .highlight_generate) syntax_allocator.free(highlighted); + const converted = try pardes.panes.Mini.generate(allocator, source, highlighted); + const elapsed = nowNs() - begin; + defer converted.deinit(allocator); + if (index == 0) result.cold_ns = elapsed else samples[index - 1] = elapsed; + if (!std.mem.eql(u8, highlighted, styles)) return error.MiniHighlightMismatch; + try checkMini(converted, expected); + } + if (index != 0) { + result.warm_allocations_total += counter.calls - calls; + result.warm_allocated_bytes_total += counter.bytes - bytes; + } + } + result.live_after_warm = counter.live; + } + if (counter.live != 0) return error.LeakedMiniMemory; + std.mem.sort(u64, samples, {}, std.sort.asc(u64)); + result.median_ns = samples[reps / 2]; + result.p95_ns = samples[(reps * 95 + 99) / 100 - 1]; + result.allocations_total = counter.calls; + result.allocated_bytes_total = counter.bytes; + result.peak_bytes = counter.peak; + result.live_after_deinit = counter.live; + return result; +} + +fn measureMini(io: std.Io, fallback: std.mem.Allocator, reps: usize, json: bool) !void { + var random: [12]u8 = undefined; + io.random(&random); + const dir = try std.fmt.allocPrint(gpa, "/tmp/pardes-perf-mini-{x}", .{random}); + defer gpa.free(dir); + try std.Io.Dir.createDirAbsolute(io, dir, .default_dir); + defer std.Io.Dir.cwd().deleteTree(io, dir) catch {}; + for ([_]Fixture{ + .{ .name = "mini-small", .lines = 256, .cols = 72 }, + .{ .name = "mini-1m", .lines = 14_400, .cols = 72 }, + .{ .name = "mini-longline", .lines = 1, .cols = 1024 * 1024 }, + .{ .name = "mini-unicode-tabs", .lines = 4096, .cols = 0 }, + }) |fixture| { + const path = try std.fmt.allocPrint(gpa, "{s}/{s}.zig", .{ dir, fixture.name }); + defer gpa.free(path); + const unicode_line = "\tconst label = \"界 café 🙂\";\n\t// é 漢字\tcomment\n"; + const source = if (fixture.cols == 0) blk: { + const bytes = try gpa.alloc(u8, unicode_line.len * fixture.lines); + for (0..fixture.lines) |i| @memcpy(bytes[i * unicode_line.len ..][0..unicode_line.len], unicode_line); + break :blk bytes; + } else try generate(fixture); + defer gpa.free(source); + try writeFile(path, source); + const styles = blk: { + pardes.syntax.start(gpa); + defer pardes.syntax.stop(); + break :blk try pardes.syntax.highlightFileRange(gpa, path, source, 0, source.len); + }; + defer gpa.free(styles); + if (styles.len != source.len or std.mem.indexOfScalar(u8, styles, @intFromEnum(pardes.syntax.Syn.comment)) == null) + return error.MiniSyntaxUnavailable; + const expected = try pardes.panes.Mini.generate(gpa, source, styles); + defer expected.deinit(gpa); + if (expected.content.len == 0 or expected.content.len != expected.colors.len or !std.unicode.utf8ValidateSlice(expected.content)) + return error.InvalidMiniOutput; + for (std.enums.values(MiniProbe)) |probe| { + const measurement = try miniMeasurement(fallback, probe, path, source, styles, expected, reps); + const row = .{ + .build = build_identity, + .harness = harness_id, + .case = fixture.name, + .probe = @tagName(probe), + .cols = screen_cols, + .rows = screen_rows, + .reps = reps, + .input_bytes = source.len, + .input_hash = std.hash.Wyhash.hash(0, source), + .input_style_hash = std.hash.Wyhash.hash(0, styles), + .output_bytes = expected.content.len, + .output_hash = std.hash.Wyhash.hash(0, expected.content), + .output_style_hash = std.hash.Wyhash.hash(0, expected.colors), + .measurement = measurement, + .allocator = "native memory.init with process allocator fallback; core, syntax and frame pools reset per probe", + .allocation_scope = "requested probe allocations, including render setup; excludes fixed pool storage, VT mappings, fixture, reference, samples and reporting", + }; + const text = if (json) + try std.json.Stringify.valueAlloc(gpa, row, .{}) + else + try std.fmt.allocPrint(gpa, "{s} {s}: {d} -> {d} bytes, median {d} ns, peak {d} B, warm allocations {d}/{d} samples, balanced", .{ + row.case, row.probe, row.input_bytes, row.output_bytes, measurement.median_ns, measurement.peak_bytes, measurement.warm_allocations_total, reps, + }); + defer gpa.free(text); + try std.Io.File.stdout().writeStreamingAll(io, text); + try std.Io.File.stdout().writeStreamingAll(io, "\n"); + } + } +} + fn seek(pane: *pardes.Pane, fx: Fixture, n: usize) void { const f = &pane.file.?; const span = @max(1, fx.lines * 8 / 10); @@ -435,13 +1070,10 @@ fn boot() !*pardes.Pardes { 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 { +fn frame(core: *pardes.Pardes) !void { var arena: std.heap.ArenaAllocator = .init(gpa); defer arena.deinit(); - return core.render(arena.allocator()); + _ = try core.render(arena.allocator()); } fn filePane(core: *pardes.Pardes) ?usize { @@ -452,8 +1084,6 @@ fn filePane(core: *pardes.Pardes) ?usize { 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()) |_| {} } @@ -464,18 +1094,11 @@ fn summarize(samples: []u64) Cell { return .{ .min_us = samples[0], .med_us = samples[samples.len / 2], - .p90_us = samples[(samples.len * 9) / 10 -| 1], + .p90_us = samples[(samples.len * 9 + 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 }), @@ -528,17 +1151,15 @@ fn writeFile(path: []const u8, text: []const u8) !void { } } -// ---- 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, + base: ?Base, ) 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"}); @@ -551,14 +1172,10 @@ fn reportText( 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| { @@ -582,7 +1199,6 @@ fn reportText( 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}); @@ -608,20 +1224,12 @@ fn reportText( } 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( @@ -629,38 +1237,53 @@ fn reportJson( 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 { + opts: Options, +) !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; + try writeJson(&out, cells, term_cells, bytes, opts); + try std.Io.File.stdout().writeStreamingAll(io, out.buffered()); +} + +fn writeJson( + out: *std.Io.Writer, + 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, + opts: Options, +) !void { + try out.print("{{\"harness\":\"{s}\",\"build\":", .{harness_id}); + try std.json.Stringify.value(build_identity, .{}, out); + try out.print(",\"cols\":{d},\"rows\":{d},\"reps\":{d},\"only\":", .{ opts.cols, opts.rows, opts.reps }); + try std.json.Stringify.value(opts.only, .{}, out); + try out.writeAll(",\"fixtures\":["); for (fixtures, 0..) |fx, fi| { - out.print("{s}{{\"name\":\"{s}\",\"lines\":{d},\"cols\":{d},\"bytes\":{d}}}", .{ + try 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; + try out.writeAll("],\"cells\":["); var first = true; for (std.enums.values(Op), 0..) |op, oi| { for (fixtures, 0..) |fx, fi| { + if (opts.only) |name| if (!std.mem.eql(u8, name, fx.name)) continue; const c = cells[oi][fi]; - out.print("{s}{{\"op\":\"{s}\",\"fixture\":\"{s}\",\"min_us\":{d},\"med_us\":{d},\"p90_us\":{d},\"max_us\":{d}}}", .{ + try 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| { + if (opts.only == null) 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}}}", .{ + try 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 {}; + }; + try out.writeAll("]}\n"); } const Base = struct { @@ -668,50 +1291,242 @@ const Base = struct { 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; +const Report = struct { + harness: []const u8 = "", + build: BuildIdentity, + cols: u16, + rows: u16, + reps: usize, + only: ?[]const u8 = null, + fixtures: []const struct { name: []const u8, lines: usize, cols: usize, bytes: usize }, + cells: []const struct { + op: []const u8, + fixture: []const u8, + min_us: u64, + med_us: u64, + p90_us: u64, + max_us: u64, + }, +}; + +fn parseBase(src: []const u8, opts: Options, bytes: *const [fixtures.len]usize) !Base { + const parsed = std.json.parseFromSlice(Report, gpa, src, .{}) catch return error.InvalidBaseline; 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; - } + const report = parsed.value; + if (!std.mem.eql(u8, report.harness, harness_id)) return error.BaselineHarnessMismatch; + inline for (std.meta.fields(BuildIdentity)) |field| { + const before = @field(report.build, field.name); + const after = @field(build_identity, field.name); + const same = if (field.type == []const u8) std.mem.eql(u8, before, after) else before == after; + if (!same) return error.BaselineBuildMismatch; + } + if (report.cols != opts.cols or report.rows != opts.rows or report.reps != opts.reps or + ((report.only == null) != (opts.only == null))) return error.BaselineMismatch; + if (opts.only) |name| if (!std.mem.eql(u8, name, report.only.?)) return error.BaselineMismatch; + if (report.fixtures.len != fixtures.len) return error.BaselineCoverage; + + var shapes: [fixtures.len]bool = @splat(false); + for (report.fixtures) |shape| { + const fi = for (fixtures, 0..) |fixture, index| { + if (std.mem.eql(u8, shape.name, fixture.name)) break index; + } else return error.BaselineCoverage; + if (shapes[fi]) return error.BaselineCoverage; + shapes[fi] = true; + if (shape.lines != fixtures[fi].lines or shape.cols != fixtures[fi].cols or shape.bytes != bytes[fi]) + return error.BaselineMismatch; + } + + var base: Base = .{ .med = @splat(@splat(0)), .term_med = @splat(@splat(0)) }; + var file_seen: [std.enums.values(Op).len][fixtures.len]bool = @splat(@splat(false)); + var term_seen: [std.enums.values(TermOp).len][term_fixtures.len]bool = @splat(@splat(false)); + for (report.cells) |cell| { + if (cell.min_us > cell.med_us or cell.med_us > cell.p90_us or cell.p90_us > cell.max_us) + return error.InvalidBaseline; + const fi = for (fixtures, 0..) |fixture, index| { + if (std.mem.eql(u8, cell.fixture, fixture.name)) break index; + } else null; + if (fi) |index| { + if (opts.only) |name| if (!std.mem.eql(u8, name, cell.fixture)) return error.BaselineCoverage; + const oi = for (std.enums.values(Op), 0..) |op, op_index| { + if (std.mem.eql(u8, cell.op, op.label())) break op_index; + } else return error.BaselineCoverage; + if (file_seen[oi][index]) return error.BaselineCoverage; + file_seen[oi][index] = true; + base.med[oi][index] = cell.med_us; + } else { + if (opts.only != null) return error.BaselineCoverage; + const ti = for (term_fixtures, 0..) |fixture, index| { + if (std.mem.eql(u8, cell.fixture, fixture.name)) break index; + } else return error.BaselineCoverage; + const oi = for (std.enums.values(TermOp), 0..) |op, index| { + if (std.mem.eql(u8, cell.op, op.label())) break index; + } else return error.BaselineCoverage; + if (term_seen[oi][ti]) return error.BaselineCoverage; + term_seen[oi][ti] = true; + base.term_med[oi][ti] = cell.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; - } + } + for (fixtures, 0..) |fixture, fi| { + if (opts.only) |name| if (!std.mem.eql(u8, name, fixture.name)) continue; + for (file_seen) |row| if (!row[fi]) return error.BaselineCoverage; + } + if (opts.only == null) for (term_seen) |row| { + for (row) |seen| if (!seen) return error.BaselineCoverage; + }; + return base; +} + +fn selfTest() !void { + const testing = std.testing; + for ([_][]const []const u8{ + &.{ "--reps", "bad" }, &.{ "--reps", "-1" }, &.{ "--cols", "65536" }, &.{ "--rows", "" }, &.{ "--terminal-mib", "bad" }, + }) |args| try testing.expectError(error.InvalidNumber, Options.parse(args)); + for ([_][]const []const u8{ + &.{ "--reps", "0" }, &.{ "--cols", "0" }, &.{ "--rows", "0" }, + &.{ "--cols", "7" }, &.{ "--rows", "4" }, &.{ "--terminal-mib", "0" }, + &.{ "--terminal-mib", "257" }, &.{ "--terminal-mib", "1", "--cols", "31" }, &.{ "--terminal-mib", "1", "--rows", "257" }, + }) |args| try testing.expectError(error.InvalidMeasurement, Options.parse(args)); + for ([_][]const []const u8{ + &.{"--reps"}, &.{"--cols"}, &.{"--rows"}, &.{"--only"}, &.{"--base"}, &.{"--terminal-mib"}, + }) |args| try testing.expectError(error.MissingValue, Options.parse(args)); + try testing.expectError(error.UnknownOption, Options.parse(&.{"--typo"})); + try testing.expectError(error.UnknownFixture, Options.parse(&.{ "--only", "typo" })); + for ([_][]const []const u8{ + &.{ "--json", "--base", "old.json" }, &.{ "--creation", "--only", "small" }, + &.{ "--creation", "--base", "old.json" }, &.{ "--self-test", "--json" }, + &.{ "--terminal-mib", "1", "--only", "small" }, &.{ "--terminal-mib", "1", "--creation" }, + &.{ "--terminal-mib", "1", "--base", "old.json" }, &.{ "--mini", "--creation" }, + &.{ "--mini", "--terminal-mib", "1" }, &.{ "--mini", "--base", "old.json" }, + &.{ "--mini", "--only", "small" }, + }) |args| try testing.expectError(error.IncompatibleOptions, Options.parse(args)); + const selected = try Options.parse(&.{ "--only", "small", "--reps", "2", "--cols", "80", "--rows", "24" }); + try testing.expectEqual(@as(usize, 2), selected.reps); + try testing.expectEqual(@as(u16, 80), selected.cols); + try testing.expectEqual(@as(u16, 24), selected.rows); + try testing.expectEqualStrings("small", selected.only.?); + _ = try Options.parse(&.{ "--cols", "8", "--rows", "5" }); + _ = try Options.parse(&.{ "--creation", "--cols", "1", "--rows", "1" }); + const terminal = try Options.parse(&.{ "--terminal-mib", "64", "--json", "--reps", "3" }); + try testing.expectEqual(@as(usize, 64), terminal.terminal_mib.?); + const mini = try Options.parse(&.{ "--mini", "--json", "--reps", "1" }); + try testing.expect(mini.mini and mini.json and mini.reps == 1); + for ([_]u16{ 32, 120, 512 }) |cols| { + var buffer: [16 * 1024]u8 = undefined; + const bytes = try terminalBatch(&buffer, 37, cols); + try testing.expect(bytes.len >= 8192 and bytes.len <= buffer.len); + try testing.expect(std.unicode.utf8ValidateSlice(bytes)); + try testing.expect(std.mem.indexOf(u8, bytes, "λ界") != null); + try testing.expect(std.mem.endsWith(u8, bytes, "\x1b[32mTURN 0000000037 READY\x1b[0m")); + try testing.expectError(error.WriteFailed, terminalBatch(buffer[0..16], 37, cols)); + } + + var samples = [_]u64{ 2, 1 }; + const stats = summarize(&samples); + try testing.expectEqual(@as(u64, 2), stats.p90_us); + try testing.expect(stats.med_us <= stats.p90_us); + const cells: [std.enums.values(Op).len][fixtures.len]Cell = @splat(@splat(.{ .min_us = 1, .med_us = 2, .p90_us = 3, .max_us = 4 })); + const terms: [std.enums.values(TermOp).len][term_fixtures.len]Cell = @splat(@splat(.{ .min_us = 1, .med_us = 2, .p90_us = 3, .max_us = 4 })); + const bytes: [fixtures.len]usize = @splat(1024); + for ([_]Options{ .{}, selected }) |opts| { + var storage: [json_report_max_bytes]u8 = undefined; + var out: std.Io.Writer = .fixed(&storage); + try writeJson(&out, &cells, &terms, &bytes, opts); + const valid = out.buffered(); + const base = try parseBase(valid, opts, &bytes); + try testing.expectEqual(@as(u64, 2), base.med[0][0]); + var different = opts; + different.cols += 1; + try testing.expectError(error.BaselineMismatch, parseBase(valid, different, &bytes)); + different = opts; + different.reps += 1; + try testing.expectError(error.BaselineMismatch, parseBase(valid, different, &bytes)); + different = opts; + different.only = if (opts.only == null) "small" else null; + try testing.expectError(error.BaselineMismatch, parseBase(valid, different, &bytes)); + var changed_bytes = bytes; + changed_bytes[0] += 1; + try testing.expectError(error.BaselineMismatch, parseBase(valid, opts, &changed_bytes)); + try testing.expectError(error.InvalidBaseline, parseBase(valid[0 .. valid.len - 3], opts, &bytes)); + + const parsed = try std.json.parseFromSlice(Report, gpa, valid, .{ .allocate = .alloc_always }); + defer parsed.deinit(); + var report = parsed.value; + report.cells = report.cells[1..]; + var short: std.Io.Writer = .fixed(&storage); + try std.json.Stringify.value(report, .{}, &short); + try testing.expectError(error.BaselineCoverage, parseBase(short.buffered(), opts, &bytes)); + report = parsed.value; + const saved = report.cells[1]; + @constCast(report.cells)[1] = report.cells[0]; + short = .fixed(&storage); + try std.json.Stringify.value(report, .{}, &short); + try testing.expectError(error.BaselineCoverage, parseBase(short.buffered(), opts, &bytes)); + @constCast(report.cells)[1] = saved; + const first = report.cells[0]; + @constCast(report.cells)[0].op = "unknown"; + short = .fixed(&storage); + try std.json.Stringify.value(report, .{}, &short); + try testing.expectError(error.BaselineCoverage, parseBase(short.buffered(), opts, &bytes)); + @constCast(report.cells)[0] = first; + @constCast(report.cells)[0].p90_us = 0; + short = .fixed(&storage); + try std.json.Stringify.value(report, .{}, &short); + try testing.expectError(error.InvalidBaseline, parseBase(short.buffered(), opts, &bytes)); + @constCast(report.cells)[0] = first; + const shape = report.fixtures[1]; + @constCast(report.fixtures)[1] = report.fixtures[0]; + short = .fixed(&storage); + try std.json.Stringify.value(report, .{}, &short); + try testing.expectError(error.BaselineCoverage, parseBase(short.buffered(), opts, &bytes)); + @constCast(report.fixtures)[1] = shape; + report.harness = "another harness"; + short = .fixed(&storage); + try std.json.Stringify.value(report, .{}, &short); + try testing.expectError(error.BaselineHarnessMismatch, parseBase(short.buffered(), opts, &bytes)); + + short = .fixed(&storage); + try std.json.Stringify.value(parsed.value, .{}, &short); + const raw = try std.json.parseFromSlice(std.json.Value, gpa, short.buffered(), .{ .allocate = .alloc_always }); + defer raw.deinit(); + var root = raw.value.object; + const identity = root.get("build").?; + try testing.expect(root.swapRemove("build")); + short = .fixed(&storage); + try std.json.Stringify.value(std.json.Value{ .object = root }, .{}, &short); + try testing.expectError(error.InvalidBaseline, parseBase(short.buffered(), opts, &bytes)); + for ([_]std.json.Value{ .null, .{ .string = "wrong" }, .{ .object = .empty } }) |invalid| { + try root.put(raw.arena.allocator(), "build", invalid); + short = .fixed(&storage); + try std.json.Stringify.value(std.json.Value{ .object = root }, .{}, &short); + try testing.expectError(error.InvalidBaseline, parseBase(short.buffered(), opts, &bytes)); + } + try root.put(raw.arena.allocator(), "build", identity); + const identity_fields = &root.getPtr("build").?.object; + for (std.meta.fieldNames(BuildIdentity)) |name| { + const value = identity_fields.get(name).?; + identity_fields.getPtr(name).?.* = switch (value) { + .bool => |v| .{ .bool = !v }, + .string => .{ .string = "different" }, + else => unreachable, + }; + short = .fixed(&storage); + try std.json.Stringify.value(std.json.Value{ .object = root }, .{}, &short); + try testing.expectError(error.BaselineBuildMismatch, parseBase(short.buffered(), opts, &bytes)); + try testing.expect(identity_fields.swapRemove(name)); + short = .fixed(&storage); + try std.json.Stringify.value(std.json.Value{ .object = root }, .{}, &short); + try testing.expectError(error.InvalidBaseline, parseBase(short.buffered(), opts, &bytes)); + try identity_fields.put(raw.arena.allocator(), name, value); } + short = .fixed(&storage); + try std.json.Stringify.value(std.json.Value{ .object = root }, .{}, &short); + _ = try parseBase(short.buffered(), opts, &bytes); } - return b; + if (!@import("builtin").is_test) std.debug.print("perf options, build identity, percentiles and baseline coverage: pass\n", .{}); +} + +test "perf options, percentiles and baseline coverage" { + try selfTest(); } fn readFileAlloc(path: []const u8) ![]u8 { @@ -737,8 +1552,3 @@ fn readFileAlloc(path: []const u8) ![]u8 { } return buf; } - -fn fatal(comptime fmt: []const u8, args: anytype) noreturn { - std.debug.print("pardes-perf: " ++ fmt ++ "\n", args); - std.process.exit(1); -} -- cgit v1.3