a73x

9e9d75b6

Add bench-baseline and bench-check

a73x   2026-04-17 16:31

Commit message
Add bench-baseline and bench-check

Baseline stores workload_sha + zig_version + waystty_sha + per-section
stats. Check compares current p99 against baseline, flagging sections
exceeding 20% p99 growth. Threshold overridable via
WAYSTTY_BENCH_REGRESSION_PCT.

main.zig now writes BaselineRecord JSON to WAYSTTY_BENCH_JSON path
alongside the existing human-readable table.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

build.zig
Old New
@@ -370,4 +370,29 @@ pub fn build(b: *std.Build) void {
370 const test_render_run = b.addRunArtifact(test_render_exe); 370 const test_render_run = b.addRunArtifact(test_render_exe);
371 test_render_run.step.dependOn(b.getInstallStep()); 371 test_render_run.step.dependOn(b.getInstallStep());
372 test_render_step.dependOn(&test_render_run.step); 372 test_render_step.dependOn(&test_render_run.step);
373
374 // bench-baseline / bench-check — frame-timing regression guard
375 const bench_baseline_mod = b.createModule(.{
376 .root_source_file = b.path("src/tools/bench_baseline.zig"),
377 .target = target,
378 .optimize = optimize,
379 });
380 bench_baseline_mod.addImport("bench_stats", bench_stats_mod);
381 const bench_baseline_exe = b.addExecutable(.{
382 .name = "bench-baseline",
383 .root_module = bench_baseline_mod,
384 });
385 b.installArtifact(bench_baseline_exe);
386
387 const bench_baseline_step = b.step("bench-baseline", "Save current frame-timing profile to tests/bench/baseline.json");
388 const bench_baseline_run = b.addRunArtifact(bench_baseline_exe);
389 bench_baseline_run.addArg("save");
390 bench_baseline_run.step.dependOn(b.getInstallStep());
391 bench_baseline_step.dependOn(&bench_baseline_run.step);
392
393 const bench_check_step = b.step("bench-check", "Compare current frame timings against baseline");
394 const bench_check_run = b.addRunArtifact(bench_baseline_exe);
395 bench_check_run.addArg("check");
396 bench_check_run.step.dependOn(b.getInstallStep());
397 bench_check_step.dependOn(&bench_check_run.step);
373 } 398 }
src/main.zig
Old New
@@ -70,6 +70,61 @@ fn updateWindowTitle(_: *vt.Terminal, ctx: ?*anyopaque, title: ?[:0]const u8) vo
70 window.setTitle(title); 70 window.setTitle(title);
71 } 71 }
72 72
73 /// If `WAYSTTY_BENCH_JSON` is set, write a BaselineRecord JSON to that path.
74 /// Machine-readable companion to `printFrameStats`.
75 fn writeBenchJson(alloc: std.mem.Allocator, stats: FrameTimingStats, workload: ?[:0]const u8) !void {
76 const path = std.posix.getenv("WAYSTTY_BENCH_JSON") orelse return;
77
78 // sha256 of the bench workload string (empty string if no workload)
79 var digest: [32]u8 = undefined;
80 std.crypto.hash.sha2.Sha256.hash(workload orelse "", &digest, .{});
81 var sha_hex: [64]u8 = undefined;
82 const hex_lut = "0123456789abcdef";
83 for (digest, 0..) |b, i| {
84 sha_hex[i * 2] = hex_lut[b >> 4];
85 sha_hex[i * 2 + 1] = hex_lut[b & 0x0f];
86 }
87
88 // git HEAD (falls back to "unknown")
89 const git_head = blk: {
90 const r = std.process.Child.run(.{
91 .allocator = alloc,
92 .argv = &.{ "git", "rev-parse", "HEAD" },
93 }) catch {
94 break :blk try alloc.dupe(u8, "unknown");
95 };
96 defer alloc.free(r.stdout);
97 defer alloc.free(r.stderr);
98 if (r.term != .Exited or r.term.Exited != 0) {
99 break :blk try alloc.dupe(u8, "unknown");
100 }
101 const trimmed = std.mem.trim(u8, r.stdout, "\n \t");
102 break :blk try alloc.dupe(u8, trimmed);
103 };
104 defer alloc.free(git_head);
105
106 const rec = bench_stats.BaselineRecord{
107 .workload_sha = &sha_hex,
108 .zig_version = @import("builtin").zig_version_string,
109 .waystty_sha = git_head,
110 .frame_count = stats.frame_count,
111 .sections = .{
112 .snapshot = stats.snapshot,
113 .row_rebuild = stats.row_rebuild,
114 .atlas_upload = stats.atlas_upload,
115 .instance_upload = stats.instance_upload,
116 .gpu_submit = stats.gpu_submit,
117 },
118 };
119
120 const json_bytes = try bench_stats.writeBaselineJson(alloc, rec);
121 defer alloc.free(json_bytes);
122
123 const out = try std.fs.cwd().createFile(path, .{});
124 defer out.close();
125 try out.writeAll(json_bytes);
126 }
127
73 pub fn main() !void { 128 pub fn main() !void {
74 var gpa: std.heap.DebugAllocator(.{}) = .init; 129 var gpa: std.heap.DebugAllocator(.{}) = .init;
75 defer _ = gpa.deinit(); 130 defer _ = gpa.deinit();
@@ -645,7 +700,11 @@ fn runTerminal(alloc: std.mem.Allocator) !void {
645 } 700 }
646 701
647 // Dump timing stats on exit 702 // Dump timing stats on exit
648 printFrameStats(computeFrameStats(&frame_ring)); 703 const final_stats = computeFrameStats(&frame_ring);
704 printFrameStats(final_stats);
705 writeBenchJson(alloc, final_stats, bench_script) catch |err| {
706 std.log.warn("bench_json write failed: {s}", .{@errorName(err)});
707 };
649 708
650 _ = try ctx.vkd.deviceWaitIdle(ctx.device); 709 _ = try ctx.vkd.deviceWaitIdle(ctx.device);
651 } 710 }
src/tools/bench_baseline.zig
Old New
@@ -0,0 +1,121 @@
1 const std = @import("std");
2 const bench_stats = @import("bench_stats");
3
4 pub fn main() !void {
5 var gpa: std.heap.DebugAllocator(.{}) = .init;
6 defer _ = gpa.deinit();
7 const alloc = gpa.allocator();
8
9 const args = try std.process.argsAlloc(alloc);
10 defer std.process.argsFree(alloc, args);
11
12 const Mode = enum { save, check };
13 const mode: Mode = if (args.len >= 2 and std.mem.eql(u8, args[1], "save"))
14 .save
15 else
16 .check;
17
18 const baseline_path = "tests/bench/baseline.json";
19 const tmp_json = "/tmp/waystty-bench-current.json";
20
21 try std.fs.cwd().makePath("tests/bench");
22
23 // Run waystty with WAYSTTY_BENCH=1 WAYSTTY_BENCH_JSON=<tmp>
24 var env = try std.process.getEnvMap(alloc);
25 defer env.deinit();
26 try env.put("WAYSTTY_BENCH", "1");
27 try env.put("WAYSTTY_BENCH_JSON", tmp_json);
28
29 const child = try std.process.Child.run(.{
30 .allocator = alloc,
31 .argv = &.{"zig-out/bin/waystty"},
32 .env_map = &env,
33 });
34 defer alloc.free(child.stdout);
35 defer alloc.free(child.stderr);
36
37 if (child.term != .Exited or child.term.Exited != 0) {
38 std.debug.print("bench: waystty exited abnormally: {any}\n stderr: {s}\n", .{ child.term, child.stderr });
39 std.process.exit(2);
40 }
41
42 const current_bytes = std.fs.cwd().readFileAlloc(alloc, tmp_json, 16 * 1024) catch |err| {
43 std.debug.print("bench: no JSON output at {s}: {s}\n", .{ tmp_json, @errorName(err) });
44 std.process.exit(2);
45 };
46 defer alloc.free(current_bytes);
47
48 const current = try bench_stats.readBaselineJson(alloc, current_bytes);
49 defer {
50 alloc.free(current.workload_sha);
51 alloc.free(current.zig_version);
52 alloc.free(current.waystty_sha);
53 }
54
55 if (mode == .save) {
56 const json_out = try bench_stats.writeBaselineJson(alloc, current);
57 defer alloc.free(json_out);
58 const out = try std.fs.cwd().createFile(baseline_path, .{});
59 defer out.close();
60 try out.writeAll(json_out);
61 std.debug.print("bench: wrote {s} (frame_count={d})\n", .{ baseline_path, current.frame_count });
62 return;
63 }
64
65 // check mode — compare current to baseline.
66 const baseline_bytes = std.fs.cwd().readFileAlloc(alloc, baseline_path, 16 * 1024) catch |err| {
67 std.debug.print("bench: no baseline at {s}: {s}\n run: zig build bench-baseline\n", .{ baseline_path, @errorName(err) });
68 std.process.exit(2);
69 };
70 defer alloc.free(baseline_bytes);
71
72 const baseline = try bench_stats.readBaselineJson(alloc, baseline_bytes);
73 defer {
74 alloc.free(baseline.workload_sha);
75 alloc.free(baseline.zig_version);
76 alloc.free(baseline.waystty_sha);
77 }
78
79 if (!std.mem.eql(u8, baseline.workload_sha, current.workload_sha)) {
80 std.debug.print("WARN: bench script changed since baseline; consider regenerating via `zig build bench-baseline`\n", .{});
81 }
82
83 const pct_threshold: f64 = blk: {
84 const v = std.posix.getenv("WAYSTTY_BENCH_REGRESSION_PCT") orelse break :blk 20.0;
85 break :blk std.fmt.parseFloat(f64, v) catch 20.0;
86 };
87
88 var regressed = false;
89
90 const SectionName = struct {
91 name: []const u8,
92 base_p99: u32,
93 cur_p99: u32,
94 };
95
96 const sections = [_]SectionName{
97 .{ .name = "snapshot", .base_p99 = baseline.sections.snapshot.p99, .cur_p99 = current.sections.snapshot.p99 },
98 .{ .name = "row_rebuild", .base_p99 = baseline.sections.row_rebuild.p99, .cur_p99 = current.sections.row_rebuild.p99 },
99 .{ .name = "atlas_upload", .base_p99 = baseline.sections.atlas_upload.p99, .cur_p99 = current.sections.atlas_upload.p99 },
100 .{ .name = "instance_upload", .base_p99 = baseline.sections.instance_upload.p99, .cur_p99 = current.sections.instance_upload.p99 },
101 .{ .name = "gpu_submit", .base_p99 = baseline.sections.gpu_submit.p99, .cur_p99 = current.sections.gpu_submit.p99 },
102 };
103
104 std.debug.print("bench: threshold {d:.1}% p99 growth\n", .{pct_threshold});
105 for (sections) |s| {
106 const delta_pct: f64 = if (s.base_p99 == 0)
107 0.0
108 else
109 ((@as(f64, @floatFromInt(s.cur_p99)) - @as(f64, @floatFromInt(s.base_p99))) / @as(f64, @floatFromInt(s.base_p99))) * 100.0;
110 const status = if (delta_pct > pct_threshold) "REGRESSION" else "OK";
111 if (delta_pct > pct_threshold) regressed = true;
112 const sign: []const u8 = if (delta_pct >= 0) "+" else "-";
113 const abs_delta: f64 = if (delta_pct >= 0) delta_pct else -delta_pct;
114 std.debug.print(
115 "bench: {s:<16} p99 {d:>5}us (baseline {d:>5}us) {s}{d:>5.1}% {s}\n",
116 .{ s.name, s.cur_p99, s.base_p99, sign, abs_delta, status },
117 );
118 }
119
120 if (regressed) std.process.exit(1);
121 }
tests/bench/baseline.json
Old New
@@ -0,0 +1,38 @@
1 {
2 "workload_sha": "066a95eee2d2f6195c0eb997e7e18a63f75b48010b538dd287056f948cc65005",
3 "zig_version": "0.15.2",
4 "waystty_sha": "828c61f589b2bac3785e37531a094d6abb1f40ad",
5 "frame_count": 5,
6 "sections": {
7 "snapshot": {
8 "min": 10,
9 "avg": 201,
10 "p99": 214,
11 "max": 725
12 },
13 "row_rebuild": {
14 "min": 115,
15 "avg": 2075,
16 "p99": 2166,
17 "max": 5447
18 },
19 "atlas_upload": {
20 "min": 0,
21 "avg": 10,
22 "p99": 0,
23 "max": 50
24 },
25 "instance_upload": {
26 "min": 4,
27 "avg": 37,
28 "p99": 21,
29 "max": 144
30 },
31 "gpu_submit": {
32 "min": 37,
33 "avg": 58,
34 "p99": 66,
35 "max": 79
36 }
37 }
38 }
\ No newline at end of file 38 \ No newline at end of file