1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
|
//! The editing scoreboard: how long one user gesture costs against file size.
//!
//! zig build perf -- the table
//! zig build perf -- --json -- the same, machine-readable
//! zig build perf -- --base old.json -- table + a before/after delta column
//! zig build perf -- --reps 60 -- more samples (default 25)
//!
//! It drives the core directly — feed an Event, drain the effects,
//! call render — exactly like test/hxdiff.zig, so there is no pty, no shell
//! and no scheduler between the clock and the code under test. A run is
//! self-contained: the fixtures are generated into /tmp/pardes-perf here and
//! rewritten every run, so the numbers do not move when this repo's own
//! sources do and there is no stale input to chase.
//!
//! WHAT A CELL MEANS. Every input row is the WHOLE gesture: update(event),
//! drain the effect queue, render one frame into a fresh arena — what a user
//! waits for between pressing a key and seeing the screen. `render` on its own
//! is the redraw with nothing changed (a resize, a refresh), which is the floor
//! every other row sits on.
//!
//! WHERE THE CURSOR IS matters more than anything else here: several of the
//! core's line lookups are scans from byte 0, so a measurement taken at line 3
//! of a 200k-line file reports a cost the user never pays. Each sample seeks to
//! a different position spread across the whole file BEFORE the clock starts,
//! so the median is the cost at a typical place in the document, not at its
//! top.
const std = @import("std");
const libc = std.c;
const pardes = @import("pardes");
// ghostty-vt narrates every sequence it does not implement, and the fixture
// text goes nowhere near a terminal here — cut the libraries to errors so the
// table is the only thing on the screen.
pub const std_options: std.Options = .{ .log_level = .err };
const gpa = std.heap.page_allocator;
/// std.time.Timer is gone in 0.16; clock_gettime is what dump.zig and
/// test/lspbench.zig already use.
fn nowNs() u64 {
var ts: std.c.timespec = undefined;
_ = std.c.clock_gettime(.MONOTONIC, &ts);
return @as(u64, @intCast(ts.sec)) *| 1_000_000_000 +| @as(u64, @intCast(ts.nsec));
}
/// The viewport every fixture is measured in. Bigger than the 80x24 the helix
/// harness pins (that one matches helix's own harness; this one wants a screen
/// somebody actually works in), and fixed, because half of these numbers scale
/// with the number of cells on the screen.
/// The viewport every measurement runs in. Overridable, because the SHAPE of the
/// screen is one of the things this table exists to hold constant while something
/// else varies - and the ESP32-P4 firmware runs a 40x12 grid, where a render costs
/// 47x what it costs here for a tenth of the cells. Profiling that needs the same
/// geometry, not a scaled guess.
var screen_cols: u16 = 120;
var screen_rows: u16 = 40;
/// The four shapes a file comes in. `lines` x `cols` is the generated body;
/// the point of the pair is that "big" has two different meanings and they
/// break different code — a 200k-line file is long in the LINE index, an
/// 8000-column file is long inside ONE line, and a scan that is fine for one
/// is quadratic for the other.
const Fixture = struct {
name: []const u8,
lines: usize,
cols: usize,
};
const fixtures = [_]Fixture{
// the control: a normal source file. Anything that shows up here is a
// constant cost, not a scaling one.
.{ .name = "small", .lines = 1_000, .cols = 60 },
.{ .name = "medium", .lines = 50_000, .cols = 60 },
// "a few MB, hundreds of thousands of lines"
.{ .name = "large", .lines = 300_000, .cols = 60 },
// the other failure mode: few lines, each one wider than any screen
.{ .name = "longline", .lines = 400, .cols = 8_000 },
// The ESP32-P4 firmware's actual document: a handful of lines, one of them wider than its
// 40-column screen. Here because a profile taken on `small` is a profile of a 1,000-line line
// index, and the board has twelve lines - so the costs that dominate there are not the costs
// that dominate it. Pair with `--cols 40 --rows 12`.
.{ .name = "esp32p4", .lines = 12, .cols = 240 },
};
const Op = enum {
/// Look at the path: disk read, pane creation, layout, first frame.
open,
/// one redraw, nothing changed
render,
/// `j` — one row of cursor movement
key_down,
/// `l` — one column. On `longline` the cursor sits deep in the line, so
/// this is the horizontal-scroll path; elsewhere it is a plain step.
key_right,
/// PageDown
page_down,
/// one mouse wheel tick
wheel,
/// one printable typed in insert mode
edit_char,
fn label(o: Op) []const u8 {
return switch (o) {
.open => "open",
.render => "render",
.key_down => "key-down",
.key_right => "key-right",
.page_down => "page-down",
.wheel => "wheel",
.edit_char => "edit-char",
};
}
};
// ---- the terminal scoreboard ----
//
// The other axis pardes scales on, and the one a file table cannot see. A
// terminal pane's state is a ghostty-vt emulator with a 16 MiB scrollback, and
// three of the costs below walk ALL of it rather than the viewport: a COLUMN
// change reflows every page in the list (a row change does not — ghostty skips
// reflow when the width is unchanged, which is why both are here), and the
// motion surface is built from a dump of the whole history.
//
// The case this exists for is a coding agent printing a long transcript into a
// shell pane: `resize-cols` is what one frame of a window drag costs, and
// `key-down` is what one press of `j` costs afterwards.
const TermFixture = struct {
name: []const u8,
/// KiB of pty output fed into the pane before any clock starts
kb: usize,
};
const term_fixtures = [_]TermFixture{
// a shell you just opened: the control. Anything here is constant cost.
.{ .name = "sb-64k", .kb = 64 },
// a build log — also where the 1 MiB raw-byte replay ring fills up
.{ .name = "sb-1m", .kb = 1024 },
// an agent transcript: half the scrollback ceiling
.{ .name = "sb-8m", .kb = 8 * 1024 },
};
const TermOp = enum {
/// one redraw, nothing changed — the floor the rest sit on
render,
/// one 4 KiB pty read arrives: parse, replay ring, sync, frame
output,
/// the window got one row shorter: resize WITHOUT reflow
resize_rows,
/// the window got one column narrower: a full page-list reflow
resize_cols,
/// `j` in the pane's body — builds the motion surface from the grid
key_down,
/// one printable typed into the pane's edit buffer
edit_char,
fn label(o: TermOp) []const u8 {
return switch (o) {
.render => "render",
.output => "output",
.resize_rows => "resize-rows",
.resize_cols => "resize-cols",
.key_down => "key-down",
.edit_char => "edit-char",
};
}
};
/// One pty read's worth of output, built once and replayed by the `output`
/// row. Blocks are generated, not captured, for the same reason the file
/// fixtures are.
var term_chunk: []const u8 = &.{};
const Cell = struct {
min_us: u64 = 0,
med_us: u64 = 0,
p90_us: u64 = 0,
max_us: u64 = 0,
};
pub fn main(init: std.process.Init) !void {
const args = try init.minimal.args.toSlice(init.arena.allocator());
var json = false;
var reps: usize = 25;
var base_path: ?[]const u8 = null;
var only: ?[]const u8 = null;
var i: usize = 1;
while (i < args.len) : (i += 1) {
const a = args[i];
if (std.mem.eql(u8, a, "--json")) {
json = true;
} else if (std.mem.eql(u8, a, "--reps") and i + 1 < args.len) {
i += 1;
reps = std.fmt.parseInt(usize, args[i], 10) catch reps;
} else if (std.mem.eql(u8, a, "--base") and i + 1 < args.len) {
i += 1;
base_path = args[i];
} else if (std.mem.eql(u8, a, "--cols") and i + 1 < args.len) {
i += 1;
screen_cols = std.fmt.parseInt(u16, args[i], 10) catch screen_cols;
} else if (std.mem.eql(u8, a, "--rows") and i + 1 < args.len) {
i += 1;
screen_rows = std.fmt.parseInt(u16, args[i], 10) catch screen_rows;
} else if (std.mem.eql(u8, a, "--only") and i + 1 < args.len) {
i += 1;
only = args[i];
} else fatal("usage: pardes-perf [--json] [--reps N] [--base old.json] [--cols N] [--rows N] [--only NAME]", .{});
}
// fixtures live in a temp dir and are rewritten every run: they are inputs
// to a measurement, and a stale one silently changes what the table means.
// .zig extensions on purpose — that is what puts tree-sitter in the frame.
const dir = "/tmp/pardes-perf";
_ = libc.mkdir(dir, 0o755); // EEXIST is fine
var paths: [fixtures.len][]const u8 = undefined;
var bytes: [fixtures.len]usize = undefined;
for (fixtures, 0..) |fx, fi| {
const path = try std.fmt.allocPrint(gpa, "{s}/{s}.zig", .{ dir, fx.name });
const text = try generate(fx);
try writeFile(path, text);
paths[fi] = path;
// what got WRITTEN, not lines*cols: a line whose code is already wider
// than `cols` is left alone rather than truncated, so the nominal
// product would understate the file the editor actually opens
bytes[fi] = text.len;
gpa.free(text);
}
// `--only` leaves the other cells zeroed rather than reshaping the table. Its purpose is
// profiling, not reporting: under `perf record` a single 63 ms cell on the largest fixture
// swamps the samples, and the question "what does ONE keystroke on a small document spend its
// time in" cannot be answered from a profile dominated by a different one.
var cells: [std.enums.values(Op).len][fixtures.len]Cell = @splat(@splat(.{}));
for (std.enums.values(Op), 0..) |op, oi| {
for (fixtures, 0..) |fx, fi| {
if (only) |name| if (!std.mem.eql(u8, name, fx.name)) continue;
cells[oi][fi] = try measure(op, fx, paths[fi], reps);
}
}
term_chunk = try buildTermText(4 * 1024);
var term_cells: [std.enums.values(TermOp).len][term_fixtures.len]Cell = @splat(@splat(.{}));
for (std.enums.values(TermOp), 0..) |op, oi| {
for (term_fixtures, 0..) |fx, fi| {
if (only != null) continue;
term_cells[oi][fi] = try measureTerm(op, fx, reps);
}
}
if (json) return reportJson(init.io, &cells, &term_cells, &bytes, reps);
reportText(&cells, &term_cells, &bytes, reps, base_path);
}
// ---- the measured gestures ----
/// One (op, fixture) cell: `reps` timed samples plus three warmup rounds,
/// reported as min / median / p90 / max. The spread between median and p90 is
/// the run's noise, and reportText prints the worst one so a reader knows how
/// big a difference has to be before it is real.
fn measure(op: Op, fx: Fixture, path: []const u8, reps: usize) !Cell {
const warmup = 3;
const samples = try gpa.alloc(u64, reps);
defer gpa.free(samples);
if (op == .open) {
// fresh core per sample: opening is a one-shot, and the second Look at
// the same path only refocuses the pane already holding it.
for (0..warmup + reps) |n| {
const core = try boot();
defer core.deinit();
const t0 = nowNs();
core.lookAt(0, path);
pump(core);
_ = try frame(core);
const dt = nowNs() -| t0;
if (n >= warmup) samples[n - warmup] = dt / 1000;
}
return summarize(samples);
}
const core = try boot();
defer core.deinit();
core.lookAt(0, path);
pump(core);
const id = filePane(core) orelse fatal("Look {s} opened no file pane", .{path});
core.active = id;
const pane = core.panes[id].?;
_ = try frame(core);
// insert mode is entered ONCE: `i` is a mode change, not a keystroke of
// typing, and measuring it inside every edit sample would report the wrong
// thing entirely.
if (op == .edit_char) {
core.update(.{ .key = .{ .cp = 'i', .text = "i" } });
pump(core);
}
for (0..warmup + reps) |n| {
seek(pane, fx, n);
_ = try frame(core); // settle the view at the new spot, untimed
const t0 = nowNs();
switch (op) {
.open => unreachable,
.render => {},
.key_down => core.update(.{ .key = .{ .cp = 'j', .text = "j" } }),
.key_right => core.update(.{ .key = .{ .cp = 'l', .text = "l" } }),
.page_down => core.update(.{ .key = .{ .cp = pardes.Key.page_down } }),
.wheel => core.update(.{ .mouse = .{ .button = .wheel_down, .kind = .press, .col = 4, .row = 8 } }),
.edit_char => core.update(.{ .key = .{ .cp = 'x', .text = "x" } }),
}
pump(core);
_ = try frame(core);
const dt = nowNs() -| t0;
if (n >= warmup) samples[n - warmup] = dt / 1000;
}
return summarize(samples);
}
/// One (op, fixture) cell of the terminal table. Same contract as `measure` —
/// fresh core per cell, warmups, median of `reps` — with no `seek`: a terminal
/// has one cursor, and everything measured here scales with the size of the
/// HISTORY rather than with where in it you are standing.
fn measureTerm(op: TermOp, fx: TermFixture, reps: usize) !Cell {
const warmup = 3;
const samples = try gpa.alloc(u64, reps);
defer gpa.free(samples);
const core = try bootTerm(fx);
defer core.deinit();
const pane = core.panes[0] orelse fatal("terminal boot produced no pane", .{});
// tty mode hands every key straight to the shell, so the two gesture rows
// would otherwise measure one queued write effect and nothing else
if (op == .key_down or op == .edit_char) pane.mode = .normal;
if (op == .edit_char) {
core.update(.{ .key = .{ .cp = 'i', .text = "i" } });
pump(core);
}
_ = try frame(core);
var cols = screen_cols;
var rows = screen_rows;
for (0..warmup + reps) |n| {
const t0 = nowNs();
switch (op) {
.render => {},
.output => core.update(.{ .output = .{ .pane = 0, .bytes = term_chunk } }),
// one cell of window drag. Alternating rather than sweeping so
// every sample does a real resize and the fixture never drifts
// away from the size the other rows are measured at.
.resize_rows => {
rows = if (rows == screen_rows) screen_rows - 1 else screen_rows;
core.update(.{ .resize = .{ .cols = cols, .rows = rows } });
},
.resize_cols => {
cols = if (cols == screen_cols) screen_cols - 1 else screen_cols;
core.update(.{ .resize = .{ .cols = cols, .rows = rows } });
},
.key_down => core.update(.{ .key = .{ .cp = 'j', .text = "j" } }),
.edit_char => core.update(.{ .key = .{ .cp = 'x', .text = "x" } }),
}
pump(core);
_ = try frame(core);
const dt = nowNs() -| t0;
if (n >= warmup) samples[n - warmup] = dt / 1000;
}
return summarize(samples);
}
/// A shell pane carrying `fx.kb` KiB of history. The bytes go in as 8 KiB
/// reads, which is both what a pty delivers and what the flood costs: one
/// core update per chunk.
fn bootTerm(fx: TermFixture) !*pardes.Pardes {
const core = try pardes.Pardes.init(gpa, .{ .tty_only = true });
core.update(.{ .resize = .{ .cols = screen_cols, .rows = screen_rows } });
pump(core);
const text = try buildTermText(fx.kb * 1024);
defer gpa.free(text);
var off: usize = 0;
while (off < text.len) {
const end = @min(off + 8192, text.len);
core.update(.{ .output = .{ .pane = 0, .bytes = text[off..end] } });
pump(core);
off = end;
}
return core;
}
/// Plausible pty output, at least `want` bytes of it: an OSC 133-marked
/// prompt, the command, and a run of coloured result lines. The prompt markers
/// are the point — the motion surface blanks prompt rows, so a history without
/// them measures a branch no real shell ever takes.
fn buildTermText(want: usize) ![]const u8 {
var out: std.Io.Writer.Allocating = .init(gpa);
errdefer out.deinit();
var n: usize = 0;
while (out.written().len < want) : (n += 1) {
try out.writer.print(
"\x1b]133;A\x07\x1b[32muser\x1b[0m@host \x1b[34m~/work\x1b[0m $ \x1b]133;B\x07zig build -Dstep={d}\r\n\x1b]133;C\x07",
.{n},
);
for (0..12) |k| {
try out.writer.print(
"\x1b[90m[{d:0>5}]\x1b[0m compiling module_{d}_{d} ... \x1b[32mok\x1b[0m ({d} ms)\r\n",
.{ n, n, k, (n *% 7919 +% k) % 900 },
);
}
}
return out.toOwnedSlice();
}
/// Park the cursor and the view at sample `n`'s position, walked across the
/// file by a prime stride so consecutive samples land nowhere near each other
/// and a whole run covers the document rather than one neighbourhood of it.
/// Rows sit in the middle 80% (a position at the very top or bottom measures
/// the clamp, not the work), and on a wide fixture the column is deep inside
/// the line so `key-right` exercises the hscroll cut rather than column 1.
/// The sequence depends only on `n`, so two builds see the same positions in
/// the same order and their cells are comparable one for one.
fn seek(pane: *pardes.Pane, fx: Fixture, n: usize) void {
const f = &pane.file.?;
const span = @max(1, fx.lines * 8 / 10);
const row = fx.lines / 10 + (n *% 7919) % span;
f.scroll = @min(row, fx.lines -| 1);
f.syntax_dirty = true;
pane.cur_row = @intCast(f.scroll);
pane.cur_col = if (fx.cols > 200) @intCast(fx.cols * 3 / 4) else 0;
pane.cur_pinned = true;
pane.hscroll = @max(0, pane.cur_col - 40);
pane.sticky_col = -1;
}
fn boot() !*pardes.Pardes {
const core = try pardes.Pardes.init(gpa, .{ .tty_only = true });
core.update(.{ .resize = .{ .cols = screen_cols, .rows = screen_rows } });
pump(core);
return core;
}
/// one frame into a throwaway arena — the shell's per-frame arena, which is
/// what keeps the retained-render contract honest (vaxis stores slices into
/// whatever it was handed, so the frame's text must outlive vx.render)
fn frame(core: *pardes.Pardes) !*pardes.Surface {
var arena: std.heap.ArenaAllocator = .init(gpa);
defer arena.deinit();
return core.render(arena.allocator());
}
fn filePane(core: *pardes.Pardes) ?usize {
for (core.panes, 0..) |slot, i| {
const pane = slot orelse continue;
if (pane.file != null) return i;
}
return null;
}
/// Drain queued effects, all ignored (spawn, write, watch, ...): there is no
/// shell here, and nothing measured depends on one answering.
fn pump(core: *pardes.Pardes) void {
while (core.nextEffect()) |_| {}
}
fn summarize(samples: []u64) Cell {
if (samples.len == 0) return .{};
std.mem.sort(u64, samples, {}, std.sort.asc(u64));
return .{
.min_us = samples[0],
.med_us = samples[samples.len / 2],
.p90_us = samples[(samples.len * 9) / 10 -| 1],
.max_us = samples[samples.len - 1],
};
}
// ---- the fixture generator ----
/// Plausible Zig, so the tree-sitter pass has real nodes to walk rather than
/// one giant error node: a repeating four-line shape padded to `cols`. Long
/// lines get their width from a comment tail — the alternative (an enormous
/// string literal) makes the whole file one token and flatters every scan that
/// looks for a newline.
fn generatedPrefixLen(n: usize) usize {
return switch (n % 4) {
0 => std.fmt.count("const value_{d}: u32 = {d}; // ", .{ n, n *% 2654435761 }),
1 => std.fmt.count("pub fn helper_{d}(a: u32, b: u32) u32 {{ return a +% b *% {d}; }} // ", .{ n, n }),
2 => std.fmt.count(" const text_{d} = \"lorem ipsum dolor sit amet {d}\"; // ", .{ n, n }),
else => std.fmt.count("// comment line {d} — ", .{n}),
};
}
fn writeGeneratedPrefix(out: *std.Io.Writer, n: usize) !void {
switch (n % 4) {
0 => try out.print("const value_{d}: u32 = {d}; // ", .{ n, n *% 2654435761 }),
1 => try out.print("pub fn helper_{d}(a: u32, b: u32) u32 {{ return a +% b *% {d}; }} // ", .{ n, n }),
2 => try out.print(" const text_{d} = \"lorem ipsum dolor sit amet {d}\"; // ", .{ n, n }),
else => try out.print("// comment line {d} — ", .{n}),
}
}
fn generate(fx: Fixture) ![]u8 {
var byte_len: usize = 0;
for (0..fx.lines) |n| byte_len += @max(generatedPrefixLen(n), fx.cols) + 1;
const text = try gpa.alloc(u8, byte_len);
errdefer gpa.free(text);
var out: std.Io.Writer = .fixed(text);
for (0..fx.lines) |n| {
const start = out.end;
try writeGeneratedPrefix(&out, n);
const prefix_len = out.end - start;
if (prefix_len < fx.cols) try out.splatByteAll('x', fx.cols - prefix_len);
try out.writeByte('\n');
}
std.debug.assert(out.end == text.len);
return text;
}
fn writeFile(path: []const u8, text: []const u8) !void {
var pathbuf: [4096]u8 = undefined;
const path_z = try std.fmt.bufPrintSentinel(&pathbuf, "{s}", .{path}, 0);
const fd = libc.open(path_z, .{ .ACCMODE = .WRONLY, .CREAT = true, .TRUNC = true }, @as(libc.mode_t, 0o644));
if (fd < 0) return error.OpenFailed;
defer _ = libc.close(fd);
var off: usize = 0;
while (off < text.len) {
const n = libc.write(fd, text.ptr + off, text.len - off);
if (n < 0) {
if (libc.errno(n) == .INTR) continue;
return error.WriteFailed;
}
off += @intCast(n);
}
}
// ---- reporting ----
fn reportText(
cells: *const [std.enums.values(Op).len][fixtures.len]Cell,
term_cells: *const [std.enums.values(TermOp).len][term_fixtures.len]Cell,
bytes: *const [fixtures.len]usize,
reps: usize,
base_path: ?[]const u8,
) void {
const o = std.debug.print;
const base = if (base_path) |bp| readBase(bp, reps) else null;
o("pardes perf — {d}x{d} viewport, {d} samples/cell, median us\n\n", .{ screen_cols, screen_rows, reps });
o("{s:<12}", .{"fixture"});
for (fixtures) |fx| o(" {s:>12}", .{fx.name});
o("\n{s:<12}", .{"lines"});
for (fixtures) |fx| o(" {d:>12}", .{fx.lines});
o("\n{s:<12}", .{"cols"});
for (fixtures) |fx| o(" {d:>12}", .{fx.cols});
o("\n{s:<12}", .{"size"});
for (bytes) |b| o(" {d:>10} KB", .{b / 1024});
o("\n\n", .{});
// one row per gesture. `open` is a whole Look; every other row is
// update+effects+one frame, which is the latency a user actually sees.
o("{s:<12}", .{"op"});
for (fixtures) |fx| {
if (base != null) o(" {s:>19}", .{fx.name}) else o(" {s:>12}", .{fx.name});
}
// 12 for the op name + one column per fixture, wider when a baseline adds
// its ratio to each cell
o("\n{s}\n", .{if (base != null) "-" ** (12 + fixtures.len * 20) else "-" ** (12 + fixtures.len * 13)});
var worst_jitter: f64 = 0;
for (std.enums.values(Op), 0..) |op, oi| {
o("{s:<12}", .{op.label()});
for (0..fixtures.len) |fi| {
const c = cells[oi][fi];
if (c.med_us > 0) {
const j = (@as(f64, @floatFromInt(c.p90_us)) - @as(f64, @floatFromInt(c.med_us))) / @as(f64, @floatFromInt(c.med_us));
if (j > worst_jitter) worst_jitter = j;
}
if (base) |b| {
const prev = b.med[oi][fi];
if (prev == 0) {
o(" {d:>12}{s:>7}", .{ c.med_us, "-" });
} else {
const ratio = @as(f64, @floatFromInt(c.med_us)) / @as(f64, @floatFromInt(prev));
o(" {d:>12} {d:>6.2}x", .{ c.med_us, ratio });
}
} else o(" {d:>12}", .{c.med_us});
}
o("\n", .{});
}
// second table, same shape: the terminal costs, against SCROLLBACK
o("\n{s:<12}", .{"scrollback"});
for (term_fixtures) |fx| {
if (base != null) o(" {s:>19}", .{fx.name}) else o(" {s:>12}", .{fx.name});
}
o("\n{s}\n", .{if (base != null) "-" ** (12 + term_fixtures.len * 20) else "-" ** (12 + term_fixtures.len * 13)});
for (std.enums.values(TermOp), 0..) |op, oi| {
o("{s:<12}", .{op.label()});
for (0..term_fixtures.len) |fi| {
const c = term_cells[oi][fi];
if (c.med_us > 0) {
const j = (@as(f64, @floatFromInt(c.p90_us)) - @as(f64, @floatFromInt(c.med_us))) / @as(f64, @floatFromInt(c.med_us));
if (j > worst_jitter) worst_jitter = j;
}
if (base) |b| {
const prev = b.term_med[oi][fi];
if (prev == 0) {
o(" {d:>12}{s:>7}", .{ c.med_us, "-" });
} else {
const ratio = @as(f64, @floatFromInt(c.med_us)) / @as(f64, @floatFromInt(prev));
o(" {d:>12} {d:>6.2}x", .{ c.med_us, ratio });
}
} else o(" {d:>12}", .{c.med_us});
}
o("\n", .{});
}
// The spread is not only scheduler noise: samples are taken at DIFFERENT
// places in the file on purpose (see seek), so a cost that still depends on
// where the cursor is shows up here as well. Both are reasons not to
// believe a small difference, and the sample positions are identical from
// run to run, so two builds are still comparable cell for cell.
o("\nnoise: worst cell p90 is {d:.0}% over its median (scheduler + the spread\n", .{worst_jitter * 100});
o("of sample positions through the file). Treat a difference smaller than\n", .{});
o("that as nothing, and re-run before believing a small win.\n", .{});
if (base_path) |bp| o("baseline: {s} (x column = now / then; under 1.00 is faster)\n", .{bp});
}
/// Real stdout, not std.debug.print's stderr: this is the form `--base` reads
/// back, and `zig build perf -- --json > runs/old.json` writing an empty file
/// would make the next comparison silently print no ratios at all.
const json_report_max_bytes = 32 * 1024;
fn reportJson(
io: std.Io,
cells: *const [std.enums.values(Op).len][fixtures.len]Cell,
term_cells: *const [std.enums.values(TermOp).len][term_fixtures.len]Cell,
bytes: *const [fixtures.len]usize,
reps: usize,
) void {
var storage: [json_report_max_bytes]u8 = undefined;
var out: std.Io.Writer = .fixed(&storage);
out.print("{{\"cols\":{d},\"rows\":{d},\"reps\":{d},\"fixtures\":[", .{ screen_cols, screen_rows, reps }) catch return;
for (fixtures, 0..) |fx, fi| {
out.print("{s}{{\"name\":\"{s}\",\"lines\":{d},\"cols\":{d},\"bytes\":{d}}}", .{
if (fi > 0) "," else "", fx.name, fx.lines, fx.cols, bytes[fi],
}) catch return;
}
out.writeAll("],\"cells\":[") catch return;
var first = true;
for (std.enums.values(Op), 0..) |op, oi| {
for (fixtures, 0..) |fx, fi| {
const c = cells[oi][fi];
out.print("{s}{{\"op\":\"{s}\",\"fixture\":\"{s}\",\"min_us\":{d},\"med_us\":{d},\"p90_us\":{d},\"max_us\":{d}}}", .{
if (first) "" else ",", op.label(), fx.name, c.min_us, c.med_us, c.p90_us, c.max_us,
}) catch return;
first = false;
}
}
for (std.enums.values(TermOp), 0..) |op, oi| {
for (term_fixtures, 0..) |fx, fi| {
const c = term_cells[oi][fi];
out.print("{s}{{\"op\":\"{s}\",\"fixture\":\"{s}\",\"min_us\":{d},\"med_us\":{d},\"p90_us\":{d},\"max_us\":{d}}}", .{
if (first) "" else ",", op.label(), fx.name, c.min_us, c.med_us, c.p90_us, c.max_us,
}) catch return;
first = false;
}
}
out.writeAll("]}\n") catch return;
std.Io.File.stdout().writeStreamingAll(io, out.buffered()) catch {};
}
const Base = struct {
med: [std.enums.values(Op).len][fixtures.len]u64,
term_med: [std.enums.values(TermOp).len][term_fixtures.len]u64,
};
/// A previous --json run, reduced to the medians this table compares against.
/// Cells the old run did not have stay 0 and print as "-": the op list may
/// have grown since, and a missing number is not a regression. An unreadable
/// file is fatal rather than silently ratio-less — a comparison you asked for
/// and did not get is worse than no comparison.
///
/// So is one that is quietly WRONG, which is why `reps` has to match. `seek`
/// walks sample n to `n *% 7919 % span`, so two runs with different counts
/// measure different PLACES in the file, and every file row comes out 20-50%
/// apart with nothing whatever having changed.
fn readBase(path: []const u8, reps: usize) ?Base {
const src = readFileAlloc(path) catch fatal("--base: cannot read {s}", .{path});
defer gpa.free(src);
var b: Base = .{ .med = @splat(@splat(0)), .term_med = @splat(@splat(0)) };
const parsed = std.json.parseFromSlice(struct {
reps: usize = 0,
cells: []const struct {
op: []const u8,
fixture: []const u8,
med_us: u64,
},
}, gpa, src, .{ .ignore_unknown_fields = true }) catch return null;
defer parsed.deinit();
if (parsed.value.reps != reps) fatal(
"--base: {s} was taken with --reps {d}, this run is --reps {d}. Same count or no comparison.",
.{ path, parsed.value.reps, reps },
);
for (parsed.value.cells) |c| {
for (std.enums.values(Op), 0..) |op, oi| {
if (!std.mem.eql(u8, op.label(), c.op)) continue;
for (fixtures, 0..) |fx, fi| {
if (std.mem.eql(u8, fx.name, c.fixture)) b.med[oi][fi] = c.med_us;
}
}
// op labels repeat across the two tables ("render", "key-down"); the
// fixture names never do, so the pair still names exactly one cell
for (std.enums.values(TermOp), 0..) |op, oi| {
if (!std.mem.eql(u8, op.label(), c.op)) continue;
for (term_fixtures, 0..) |fx, fi| {
if (std.mem.eql(u8, fx.name, c.fixture)) b.term_med[oi][fi] = c.med_us;
}
}
}
return b;
}
fn readFileAlloc(path: []const u8) ![]u8 {
var pathbuf: [4096]u8 = undefined;
const path_z = try std.fmt.bufPrintSentinel(&pathbuf, "{s}", .{path}, 0);
const fd = libc.open(path_z, .{ .ACCMODE = .RDONLY });
if (fd < 0) return error.OpenFailed;
defer _ = libc.close(fd);
const end = libc.lseek(fd, 0, libc.SEEK.END);
if (end < 0 or libc.lseek(fd, 0, libc.SEEK.SET) < 0) return error.StatFailed;
const size = std.math.cast(usize, end) orelse return error.FileTooLarge;
const buf = try gpa.alloc(u8, size);
errdefer gpa.free(buf);
var offset: usize = 0;
while (offset < buf.len) {
const n = libc.read(fd, buf.ptr + offset, buf.len - offset);
if (n < 0) {
if (libc.errno(n) == .INTR) continue;
return error.ReadFailed;
}
if (n == 0) return error.UnexpectedEndOfFile;
offset += @intCast(n);
}
return buf;
}
fn fatal(comptime fmt: []const u8, args: anytype) noreturn {
std.debug.print("pardes-perf: " ++ fmt ++ "\n", args);
std.process.exit(1);
}
|