9e9d75b6
Add bench-baseline and bench-check
a73x 2026-04-17 16:31
Commit message
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 | |