summaryrefslogtreecommitdiff
path: root/test/perf.zig
diff options
context:
space:
mode:
authorGabriel Schneider <[email protected]>2026-09-06 18:11:36 -0300
committerGabriel Schneider <[email protected]>2026-09-07 13:59:12 -0300
commit60367d8fe23f6af98ec28e3cf6c2094dfe332df0 (patch)
tree310fc734173cf771881f4691c71909135fadde97 /test/perf.zig
parentfa82cac885cb4738fe36d1e49b4749b5a3e31a4a (diff)
downloadpardes-60367d8fe23f6af98ec28e3cf6c2094dfe332df0.tar.gz
pardes-60367d8fe23f6af98ec28e3cf6c2094dfe332df0.zip
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.
Diffstat (limited to 'test/perf.zig')
-rw-r--r--test/perf.zig1328
1 files changed, 1069 insertions, 259 deletions
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]", .{});
- }
+ 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);
- // fixtures live in a temp dir and are rewritten every run: they are inputs
- // to a measurement, and a stale one silently changes what the table means.
- // .zig extensions on purpose — that is what puts tree-sitter in the frame.
- const dir = "/tmp/pardes-perf";
- _ = libc.mkdir(dir, 0o755); // EEXIST is fine
+ var 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);
-}