a73x

f754bb6b

Add performance benchmarking instrumentation

a73x   2026-04-10 10:17

Commit message
Add performance benchmarking instrumentation

- Per-section frame timing ring buffer (snapshot, row_rebuild,
  atlas_upload, instance_upload, gpu_submit) with min/avg/p99/max stats
- SIGUSR1 handler dumps stats to stderr; also dumps on clean exit
- WAYSTTY_BENCH=1 env var runs a fixed workload (seq, find, yes)
  with warm-up phase instead of the user's shell
- Makefile with bench and profile (perf + flamegraph) targets
- shell_args support in pty.zig for bench script passthrough

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

.gitignore
Old New
@@ -3,3 +3,6 @@ zig-out/
3 *.o 3 *.o
4 *.swp 4 *.swp
5 .worktrees/ 5 .worktrees/
6 bench.log
7 perf.data
8 flamegraph.svg
Makefile
Old New
@@ -0,0 +1,36 @@
1 ZIG ?= zig
2 FLAMEGRAPH ?= flamegraph.pl
3 STACKCOLLAPSE ?= stackcollapse-perf.pl
4
5 .PHONY: build run test bench profile clean
6
7 build:
8 $(ZIG) build
9
10 run: build
11 $(ZIG) build run
12
13 test:
14 $(ZIG) build test
15
16 zig-out/bin/waystty: $(wildcard src/*.zig) $(wildcard shaders/*)
17 $(ZIG) build
18
19 bench: zig-out/bin/waystty
20 WAYSTTY_BENCH=1 ./zig-out/bin/waystty 2>bench.log || true
21 @echo "--- frame timing ---"
22 @grep -A 12 "waystty frame timing" bench.log || echo "(no timing data found)"
23
24 profile:
25 $(ZIG) build -Doptimize=ReleaseSafe
26 perf record -g -F 999 --no-inherit -o perf.data -- \
27 sh -c 'WAYSTTY_BENCH=1 ./zig-out/bin/waystty 2>bench.log'
28 perf script -i perf.data \
29 | $(STACKCOLLAPSE) \
30 | $(FLAMEGRAPH) > flamegraph.svg
31 @echo "--- frame timing ---"
32 @grep -A 12 "waystty frame timing" bench.log || echo "(no timing data found)"
33 xdg-open flamegraph.svg
34
35 clean:
36 rm -rf zig-out .zig-cache perf.data bench.log flamegraph.svg
src/main.zig
Old New
@@ -168,8 +168,18 @@ fn runTerminal(alloc: std.mem.Allocator) !void {
168 var atlas = try font.Atlas.init(alloc, 1024, 1024); 168 var atlas = try font.Atlas.init(alloc, 1024, 1024);
169 defer atlas.deinit(); 169 defer atlas.deinit();
170 170
171 // Upload empty atlas first (so descriptor set is valid) 171 // Precompute printable ASCII glyphs (32-126) into atlas
172 for (32..127) |cp| {
173 _ = atlas.getOrInsert(&face, @intCast(cp)) catch |err| switch (err) {
174 error.AtlasFull => break,
175 else => return err,
176 };
177 }
178 // Upload warm atlas (full upload — descriptor set needs valid data)
172 try ctx.uploadAtlas(atlas.pixels); 179 try ctx.uploadAtlas(atlas.pixels);
180 atlas.last_uploaded_y = atlas.cursor_y;
181 atlas.needs_full_upload = false;
182 atlas.dirty = false;
173 183
174 // === terminal === 184 // === terminal ===
175 var term = try vt.Terminal.init(alloc, .{ 185 var term = try vt.Terminal.init(alloc, .{
@@ -188,12 +198,25 @@ fn runTerminal(alloc: std.mem.Allocator) !void {
188 198
189 // === pty === 199 // === pty ===
190 const shell: [:0]const u8 = blk: { 200 const shell: [:0]const u8 = blk: {
201 if (std.posix.getenv("WAYSTTY_BENCH") != null) {
202 break :blk try alloc.dupeZ(u8, "/bin/sh");
203 }
191 const shell_env = std.posix.getenv("SHELL") orelse "/bin/sh"; 204 const shell_env = std.posix.getenv("SHELL") orelse "/bin/sh";
192 break :blk try alloc.dupeZ(u8, shell_env); 205 break :blk try alloc.dupeZ(u8, shell_env);
193 }; 206 };
194 defer alloc.free(shell); 207 defer alloc.free(shell);
195 208
196 var p = try pty.Pty.spawn(.{ .cols = cols, .rows = rows, .shell = shell }); 209 const bench_script: ?[:0]const u8 = if (std.posix.getenv("WAYSTTY_BENCH") != null)
210 "echo warmup; sleep 0.2; seq 1 50000; find /usr/lib -name '*.so' 2>/dev/null | head -500; yes 'hello world' | head -2000; exit 0"
211 else
212 null;
213
214 var p = try pty.Pty.spawn(.{
215 .cols = cols,
216 .rows = rows,
217 .shell = shell,
218 .shell_args = if (bench_script) |script| &.{ "-c", script } else null,
219 });
197 defer p.deinit(); 220 defer p.deinit();
198 term.setWritePtyCallback(&p, &writePtyFromTerminal); 221 term.setWritePtyCallback(&p, &writePtyFromTerminal);
199 222
@@ -202,6 +225,10 @@ fn runTerminal(alloc: std.mem.Allocator) !void {
202 defer render_cache.deinit(alloc); 225 defer render_cache.deinit(alloc);
203 try render_cache.resizeRows(alloc, rows); 226 try render_cache.resizeRows(alloc, rows);
204 227
228 // === frame timing ===
229 var frame_ring = FrameTimingRing{};
230 installSigusr1Handler();
231
205 // === main loop === 232 // === main loop ===
206 const wl_fd = conn.display.getFd(); 233 const wl_fd = conn.display.getFd();
207 var pollfds = [_]std.posix.pollfd{ 234 var pollfds = [_]std.posix.pollfd{
@@ -354,10 +381,15 @@ fn runTerminal(alloc: std.mem.Allocator) !void {
354 381
355 if (!shouldRenderFrame(render_pending, false, false)) continue; 382 if (!shouldRenderFrame(render_pending, false, false)) continue;
356 383
384 var frame_timing: FrameTiming = .{};
385
357 // === render === 386 // === render ===
358 const previous_cursor = term.render_state.cursor; 387 const previous_cursor = term.render_state.cursor;
388 var section_timer = std.time.Timer.start() catch unreachable;
359 try term.snapshot(); 389 try term.snapshot();
390 frame_timing.snapshot_us = usFromTimer(&section_timer);
360 391
392 section_timer = std.time.Timer.start() catch unreachable;
361 const default_bg = term.backgroundColor(); 393 const default_bg = term.backgroundColor();
362 const bg_uv = atlas.cursorUV(); 394 const bg_uv = atlas.cursorUV();
363 395
@@ -449,13 +481,29 @@ fn runTerminal(alloc: std.mem.Allocator) !void {
449 cursor_rebuilt = true; 481 cursor_rebuilt = true;
450 } 482 }
451 483
452 // Re-upload atlas if new glyphs were added 484 frame_timing.row_rebuild_us = usFromTimer(&section_timer);
485
486 section_timer = std.time.Timer.start() catch unreachable;
487 // Re-upload atlas if new glyphs were added (incremental)
453 if (atlas.dirty) { 488 if (atlas.dirty) {
454 try ctx.uploadAtlas(atlas.pixels); 489 const y_start = atlas.last_uploaded_y;
490 const y_end = atlas.cursor_y + atlas.row_height;
491 if (y_start < y_end) {
492 try ctx.uploadAtlasRegion(
493 atlas.pixels,
494 y_start,
495 y_end,
496 atlas.needs_full_upload,
497 );
498 atlas.last_uploaded_y = atlas.cursor_y;
499 atlas.needs_full_upload = false;
500 render_cache.layout_dirty = true;
501 }
455 atlas.dirty = false; 502 atlas.dirty = false;
456 render_cache.layout_dirty = true;
457 } 503 }
504 frame_timing.atlas_upload_us = usFromTimer(&section_timer);
458 505
506 section_timer = std.time.Timer.start() catch unreachable;
459 const upload_plan = applyRenderPlan(.{ 507 const upload_plan = applyRenderPlan(.{
460 .layout_dirty = render_cache.layout_dirty, 508 .layout_dirty = render_cache.layout_dirty,
461 .rows_rebuilt = rows_rebuilt, 509 .rows_rebuilt = rows_rebuilt,
@@ -514,6 +562,9 @@ fn runTerminal(alloc: std.mem.Allocator) !void {
514 } 562 }
515 } 563 }
516 564
565 frame_timing.instance_upload_us = usFromTimer(&section_timer);
566
567 section_timer = std.time.Timer.start() catch unreachable;
517 const baseline_coverage = renderer.coverageVariantParams(.baseline); 568 const baseline_coverage = renderer.coverageVariantParams(.baseline);
518 ctx.drawCells( 569 ctx.drawCells(
519 render_cache.total_instance_count, 570 render_cache.total_instance_count,
@@ -531,10 +582,22 @@ fn runTerminal(alloc: std.mem.Allocator) !void {
531 }, 582 },
532 else => return err, 583 else => return err,
533 }; 584 };
585 frame_timing.gpu_submit_us = usFromTimer(&section_timer);
586
587 frame_ring.push(frame_timing);
588
589 // Check for SIGUSR1 stats dump request
590 if (sigusr1_received.swap(false, .acq_rel)) {
591 printFrameStats(computeFrameStats(&frame_ring));
592 }
593
534 clearConsumedDirtyFlags(&term.render_state.dirty, dirty_rows, refresh_plan); 594 clearConsumedDirtyFlags(&term.render_state.dirty, dirty_rows, refresh_plan);
535 render_pending = false; 595 render_pending = false;
536 } 596 }
537 597
598 // Dump timing stats on exit
599 printFrameStats(computeFrameStats(&frame_ring));
600
538 _ = try ctx.vkd.deviceWaitIdle(ctx.device); 601 _ = try ctx.vkd.deviceWaitIdle(ctx.device);
539 } 602 }
540 603
@@ -855,6 +918,152 @@ fn clampSelectionSpan(span: SelectionSpan, cols: u16, rows: u16) ?SelectionSpan
855 } else null; 918 } else null;
856 } 919 }
857 920
921 const FrameTiming = struct {
922 snapshot_us: u32 = 0,
923 row_rebuild_us: u32 = 0,
924 atlas_upload_us: u32 = 0,
925 instance_upload_us: u32 = 0,
926 gpu_submit_us: u32 = 0,
927
928 fn total(self: FrameTiming) u32 {
929 return self.snapshot_us +
930 self.row_rebuild_us +
931 self.atlas_upload_us +
932 self.instance_upload_us +
933 self.gpu_submit_us;
934 }
935 };
936
937 const FrameTimingRing = struct {
938 const capacity = 256;
939
940 entries: [capacity]FrameTiming = [_]FrameTiming{.{}} ** capacity,
941 head: usize = 0,
942 count: usize = 0,
943
944 fn push(self: *FrameTimingRing, timing: FrameTiming) void {
945 const idx = if (self.count < capacity) self.count else self.head;
946 self.entries[idx] = timing;
947 if (self.count < capacity) {
948 self.count += 1;
949 } else {
950 self.head = (self.head + 1) % capacity;
951 }
952 }
953
954 /// Return a slice of valid entries in insertion order.
955 /// Caller must provide a scratch buffer of `capacity` entries.
956 fn orderedSlice(self: *const FrameTimingRing, buf: *[capacity]FrameTiming) []const FrameTiming {
957 if (self.count < capacity) {
958 return self.entries[0..self.count];
959 }
960 // Ring has wrapped — copy from head..end then 0..head
961 const tail_len = capacity - self.head;
962 @memcpy(buf[0..tail_len], self.entries[self.head..capacity]);
963 @memcpy(buf[tail_len..capacity], self.entries[0..self.head]);
964 return buf[0..capacity];
965 }
966 };
967
968 const SectionStats = struct {
969 min: u32 = 0,
970 avg: u32 = 0,
971 p99: u32 = 0,
972 max: u32 = 0,
973 };
974
975 const FrameTimingStats = struct {
976 snapshot: SectionStats = .{},
977 row_rebuild: SectionStats = .{},
978 atlas_upload: SectionStats = .{},
979 instance_upload: SectionStats = .{},
980 gpu_submit: SectionStats = .{},
981 total: SectionStats = .{},
982 frame_count: usize = 0,
983 };
984
985 fn computeSectionStats(values: []u32) SectionStats {
986 if (values.len == 0) return .{};
987 std.mem.sort(u32, values, {}, std.sort.asc(u32));
988 var sum: u64 = 0;
989 for (values) |v| sum += v;
990 const p99_idx = if (values.len <= 1) 0 else ((values.len - 1) * 99) / 100;
991 return .{
992 .min = values[0],
993 .avg = @intCast(sum / values.len),
994 .p99 = values[p99_idx],
995 .max = values[values.len - 1],
996 };
997 }
998
999 fn computeFrameStats(ring: *const FrameTimingRing) FrameTimingStats {
1000 if (ring.count == 0) return .{};
1001
1002 var ordered_buf: [FrameTimingRing.capacity]FrameTiming = undefined;
1003 const entries = ring.orderedSlice(&ordered_buf);
1004 const n = entries.len;
1005
1006 var snapshot_vals: [FrameTimingRing.capacity]u32 = undefined;
1007 var row_rebuild_vals: [FrameTimingRing.capacity]u32 = undefined;
1008 var atlas_upload_vals: [FrameTimingRing.capacity]u32 = undefined;
1009 var instance_upload_vals: [FrameTimingRing.capacity]u32 = undefined;
1010 var gpu_submit_vals: [FrameTimingRing.capacity]u32 = undefined;
1011 var total_vals: [FrameTimingRing.capacity]u32 = undefined;
1012
1013 for (entries, 0..) |e, i| {
1014 snapshot_vals[i] = e.snapshot_us;
1015 row_rebuild_vals[i] = e.row_rebuild_us;
1016 atlas_upload_vals[i] = e.atlas_upload_us;
1017 instance_upload_vals[i] = e.instance_upload_us;
1018 gpu_submit_vals[i] = e.gpu_submit_us;
1019 total_vals[i] = e.total();
1020 }
1021
1022 return .{
1023 .snapshot = computeSectionStats(snapshot_vals[0..n]),
1024 .row_rebuild = computeSectionStats(row_rebuild_vals[0..n]),
1025 .atlas_upload = computeSectionStats(atlas_upload_vals[0..n]),
1026 .instance_upload = computeSectionStats(instance_upload_vals[0..n]),
1027 .gpu_submit = computeSectionStats(gpu_submit_vals[0..n]),
1028 .total = computeSectionStats(total_vals[0..n]),
1029 .frame_count = n,
1030 };
1031 }
1032
1033 fn printFrameStats(stats: FrameTimingStats) void {
1034 const row_fmt = "{s:<20}{d:>6}{d:>6}{d:>6}{d:>6}\n";
1035 std.debug.print("\n=== waystty frame timing ({d} frames) ===\n", .{stats.frame_count});
1036 std.debug.print("{s:<20}{s:>6}{s:>6}{s:>6}{s:>6} (us)\n", .{ "section", "min", "avg", "p99", "max" });
1037 std.debug.print(row_fmt, .{ "snapshot", stats.snapshot.min, stats.snapshot.avg, stats.snapshot.p99, stats.snapshot.max });
1038 std.debug.print(row_fmt, .{ "row_rebuild", stats.row_rebuild.min, stats.row_rebuild.avg, stats.row_rebuild.p99, stats.row_rebuild.max });
1039 std.debug.print(row_fmt, .{ "atlas_upload", stats.atlas_upload.min, stats.atlas_upload.avg, stats.atlas_upload.p99, stats.atlas_upload.max });
1040 std.debug.print(row_fmt, .{ "instance_upload", stats.instance_upload.min, stats.instance_upload.avg, stats.instance_upload.p99, stats.instance_upload.max });
1041 std.debug.print(row_fmt, .{ "gpu_submit", stats.gpu_submit.min, stats.gpu_submit.avg, stats.gpu_submit.p99, stats.gpu_submit.max });
1042 std.debug.print("----------------------------------------------------\n", .{});
1043 std.debug.print(row_fmt, .{ "total", stats.total.min, stats.total.avg, stats.total.p99, stats.total.max });
1044 }
1045
1046 var sigusr1_received: std.atomic.Value(bool) = std.atomic.Value(bool).init(false);
1047
1048 fn sigusr1Handler(_: c_int) callconv(.c) void {
1049 sigusr1_received.store(true, .release);
1050 }
1051
1052 fn installSigusr1Handler() void {
1053 const act = std.posix.Sigaction{
1054 .handler = .{ .handler = sigusr1Handler },
1055 .mask = std.posix.sigemptyset(),
1056 .flags = std.posix.SA.RESTART,
1057 };
1058 std.posix.sigaction(std.posix.SIG.USR1, &act, null);
1059 }
1060
1061 fn usFromTimer(timer: *std.time.Timer) u32 {
1062 const ns = timer.read();
1063 const us = ns / std.time.ns_per_us;
1064 return std.math.cast(u32, us) orelse std.math.maxInt(u32);
1065 }
1066
858 test "SelectionSpan.normalized orders endpoints in reading order" { 1067 test "SelectionSpan.normalized orders endpoints in reading order" {
859 const span = (SelectionSpan{ 1068 const span = (SelectionSpan{
860 .start = .{ .col = 7, .row = 4 }, 1069 .start = .{ .col = 7, .row = 4 },
@@ -2914,6 +3123,82 @@ test "buildTextCoverageCompareScene repeats the same specimen in four panels" {
2914 ); 3123 );
2915 } 3124 }
2916 3125
3126 test "FrameTiming.total sums all sections" {
3127 const ft: FrameTiming = .{
3128 .snapshot_us = 10,
3129 .row_rebuild_us = 20,
3130 .atlas_upload_us = 30,
3131 .instance_upload_us = 40,
3132 .gpu_submit_us = 50,
3133 };
3134 try std.testing.expectEqual(@as(u32, 150), ft.total());
3135 }
3136
3137 test "FrameTimingRing records and wraps correctly" {
3138 var ring = FrameTimingRing{};
3139 try std.testing.expectEqual(@as(usize, 0), ring.count);
3140
3141 ring.push(.{ .snapshot_us = 1, .row_rebuild_us = 2, .atlas_upload_us = 3, .instance_upload_us = 4, .gpu_submit_us = 5 });
3142 try std.testing.expectEqual(@as(usize, 1), ring.count);
3143 try std.testing.expectEqual(@as(u32, 1), ring.entries[0].snapshot_us);
3144
3145 // Fill to capacity
3146 for (1..FrameTimingRing.capacity) |i| {
3147 ring.push(.{ .snapshot_us = @intCast(i + 1), .row_rebuild_us = 0, .atlas_upload_us = 0, .instance_upload_us = 0, .gpu_submit_us = 0 });
3148 }
3149 try std.testing.expectEqual(FrameTimingRing.capacity, ring.count);
3150
3151 // One more wraps around — overwrites entries[0], head advances to 1
3152 ring.push(.{ .snapshot_us = 999, .row_rebuild_us = 0, .atlas_upload_us = 0, .instance_upload_us = 0, .gpu_submit_us = 0 });
3153 try std.testing.expectEqual(FrameTimingRing.capacity, ring.count);
3154 // Newest entry is at (head + capacity - 1) % capacity = 0
3155 try std.testing.expectEqual(@as(u32, 999), ring.entries[0].snapshot_us);
3156 // head has advanced past the overwritten slot
3157 try std.testing.expectEqual(@as(usize, 1), ring.head);
3158 }
3159
3160 test "FrameTimingRing.orderedSlice returns entries in insertion order after wrap" {
3161 var ring = FrameTimingRing{};
3162 // Push capacity + 3 entries so the ring wraps
3163 for (0..FrameTimingRing.capacity + 3) |i| {
3164 ring.push(.{ .snapshot_us = @intCast(i), .row_rebuild_us = 0, .atlas_upload_us = 0, .instance_upload_us = 0, .gpu_submit_us = 0 });
3165 }
3166 var buf: [FrameTimingRing.capacity]FrameTiming = undefined;
3167 const ordered = ring.orderedSlice(&buf);
3168 try std.testing.expectEqual(FrameTimingRing.capacity, ordered.len);
3169 // First entry should be the 4th pushed (index 3), last should be capacity+2
3170 try std.testing.expectEqual(@as(u32, 3), ordered[0].snapshot_us);
3171 try std.testing.expectEqual(@as(u32, FrameTimingRing.capacity + 2), ordered[ordered.len - 1].snapshot_us);
3172 }
3173
3174 test "FrameTimingStats computes min/avg/p99/max correctly" {
3175 var ring = FrameTimingRing{};
3176 // Push 100 frames with snapshot_us = 1..100
3177 for (0..100) |i| {
3178 ring.push(.{
3179 .snapshot_us = @intCast(i + 1),
3180 .row_rebuild_us = 0,
3181 .atlas_upload_us = 0,
3182 .instance_upload_us = 0,
3183 .gpu_submit_us = 0,
3184 });
3185 }
3186 const stats = computeFrameStats(&ring);
3187 try std.testing.expectEqual(@as(u32, 1), stats.snapshot.min);
3188 try std.testing.expectEqual(@as(u32, 100), stats.snapshot.max);
3189 try std.testing.expectEqual(@as(u32, 50), stats.snapshot.avg);
3190 // p99 of 1..100 = value at index 98 (0-based) = 99
3191 try std.testing.expectEqual(@as(u32, 99), stats.snapshot.p99);
3192 try std.testing.expectEqual(@as(usize, 100), stats.frame_count);
3193 }
3194
3195 test "FrameTimingStats handles empty ring" {
3196 var ring = FrameTimingRing{};
3197 const stats = computeFrameStats(&ring);
3198 try std.testing.expectEqual(@as(usize, 0), stats.frame_count);
3199 try std.testing.expectEqual(@as(u32, 0), stats.snapshot.min);
3200 }
3201
2917 fn runRenderSmokeTest(alloc: std.mem.Allocator) !void { 3202 fn runRenderSmokeTest(alloc: std.mem.Allocator) !void {
2918 const conn = try wayland_client.Connection.init(alloc); 3203 const conn = try wayland_client.Connection.init(alloc);
2919 defer conn.deinit(); 3204 defer conn.deinit();
src/pty.zig
Old New
@@ -19,6 +19,7 @@ pub const Pty = struct {
19 cols: u16, 19 cols: u16,
20 rows: u16, 20 rows: u16,
21 shell: [:0]const u8, 21 shell: [:0]const u8,
22 shell_args: ?[]const [:0]const u8 = null,
22 }; 23 };
23 24
24 pub fn spawn(opts: SpawnOptions) !Pty { 25 pub fn spawn(opts: SpawnOptions) !Pty {
@@ -37,8 +38,18 @@ pub const Pty = struct {
37 // Child process 38 // Child process
38 _ = c.setenv("TERM", "xterm-256color", 1); 39 _ = c.setenv("TERM", "xterm-256color", 1);
39 40
40 var argv = [_:null]?[*:0]const u8{ opts.shell.ptr, null }; 41 if (opts.shell_args) |args| {
41 std.posix.execveZ(opts.shell.ptr, &argv, std.c.environ) catch {}; 42 std.debug.assert(args.len < 15); // argv[0] = shell, must fit in 16-slot buffer
43 var argv_buf: [16:null]?[*:0]const u8 = .{null} ** 16;
44 argv_buf[0] = opts.shell.ptr;
45 for (args, 1..) |arg, i| {
46 argv_buf[i] = arg.ptr;
47 }
48 std.posix.execveZ(opts.shell.ptr, &argv_buf, std.c.environ) catch {};
49 } else {
50 var argv = [_:null]?[*:0]const u8{ opts.shell.ptr, null };
51 std.posix.execveZ(opts.shell.ptr, &argv, std.c.environ) catch {};
52 }
42 std.process.exit(1); 53 std.process.exit(1);
43 } 54 }
44 55