1d53cec2
Extract frame timing stats into bench_stats module
a73x 2026-04-17 15:24
Enables the bench baseline tool (Task 10) to reuse stat computation without linking the full waystty binary. No behavior change. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
diff --git a/build.zig b/build.zig index 17f576b..6b11a9d 100644 --- a/build.zig +++ b/build.zig @@ -15,6 +15,12 @@ pub fn build(b: *std.Build) void { .optimize = optimize, }); const bench_stats_mod = b.createModule(.{ .root_source_file = b.path("src/bench_stats.zig"), .target = target, .optimize = optimize, }); // Lazy-fetch the ghostty dependency. On the first invocation this // materializes the package; subsequent builds use the local cache. const ghostty_dep = b.lazyDependency("ghostty", .{}); @@ -79,6 +85,7 @@ pub fn build(b: *std.Build) void { exe_mod.addImport("wayland-client", wayland_mod); exe_mod.addImport("config", config_mod); exe_mod.addImport("frame_loop", frame_loop_mod); exe_mod.addImport("bench_stats", bench_stats_mod); const exe = b.addExecutable(.{ .name = "waystty", @@ -130,6 +137,15 @@ pub fn build(b: *std.Build) void { }); test_step.dependOn(&b.addRunArtifact(scale_tracker_tests).step); // Test bench_stats.zig const bench_stats_test_mod = b.createModule(.{ .root_source_file = b.path("src/bench_stats.zig"), .target = target, .optimize = optimize, }); const bench_stats_tests = b.addTest(.{ .root_module = bench_stats_test_mod }); test_step.dependOn(&b.addRunArtifact(bench_stats_tests).step); // Test frame_loop.zig const frame_loop_test_mod = b.createModule(.{ .root_source_file = b.path("src/frame_loop.zig"), @@ -169,6 +185,7 @@ pub fn build(b: *std.Build) void { main_test_mod.addImport("vt", vt_mod); main_test_mod.addImport("wayland-client", wayland_mod); main_test_mod.addImport("config", config_mod); main_test_mod.addImport("bench_stats", bench_stats_mod); const main_tests = b.addTest(.{ .root_module = main_test_mod, }); diff --git a/src/bench_stats.zig b/src/bench_stats.zig new file mode 100644 index 0000000..dd14c67 --- /dev/null +++ b/src/bench_stats.zig @@ -0,0 +1,202 @@ const std = @import("std"); pub const FrameTiming = struct { snapshot_us: u32 = 0, row_rebuild_us: u32 = 0, atlas_upload_us: u32 = 0, instance_upload_us: u32 = 0, gpu_submit_us: u32 = 0, pub fn total(self: FrameTiming) u32 { return self.snapshot_us + self.row_rebuild_us + self.atlas_upload_us + self.instance_upload_us + self.gpu_submit_us; } }; pub const FrameTimingRing = struct { pub const capacity = 256; entries: [capacity]FrameTiming = [_]FrameTiming{.{}} ** capacity, head: usize = 0, count: usize = 0, pub fn push(self: *FrameTimingRing, timing: FrameTiming) void { const idx = if (self.count < capacity) self.count else self.head; self.entries[idx] = timing; if (self.count < capacity) { self.count += 1; } else { self.head = (self.head + 1) % capacity; } } /// Return a slice of valid entries in insertion order. /// Caller must provide a scratch buffer of `capacity` entries. pub fn orderedSlice(self: *const FrameTimingRing, buf: *[capacity]FrameTiming) []const FrameTiming { if (self.count < capacity) { return self.entries[0..self.count]; } // Ring has wrapped — copy from head..end then 0..head const tail_len = capacity - self.head; @memcpy(buf[0..tail_len], self.entries[self.head..capacity]); @memcpy(buf[tail_len..capacity], self.entries[0..self.head]); return buf[0..capacity]; } }; pub const SectionStats = struct { min: u32 = 0, avg: u32 = 0, p99: u32 = 0, max: u32 = 0, }; pub const FrameTimingStats = struct { snapshot: SectionStats = .{}, row_rebuild: SectionStats = .{}, atlas_upload: SectionStats = .{}, instance_upload: SectionStats = .{}, gpu_submit: SectionStats = .{}, total: SectionStats = .{}, frame_count: usize = 0, }; pub fn computeSectionStats(values: []u32) SectionStats { if (values.len == 0) return .{}; std.mem.sort(u32, values, {}, std.sort.asc(u32)); var sum: u64 = 0; for (values) |v| sum += v; const p99_idx = if (values.len <= 1) 0 else ((values.len - 1) * 99) / 100; return .{ .min = values[0], .avg = @intCast(sum / values.len), .p99 = values[p99_idx], .max = values[values.len - 1], }; } pub fn computeFrameStats(ring: *const FrameTimingRing) FrameTimingStats { if (ring.count == 0) return .{}; var ordered_buf: [FrameTimingRing.capacity]FrameTiming = undefined; const entries = ring.orderedSlice(&ordered_buf); const n = entries.len; var snapshot_vals: [FrameTimingRing.capacity]u32 = undefined; var row_rebuild_vals: [FrameTimingRing.capacity]u32 = undefined; var atlas_upload_vals: [FrameTimingRing.capacity]u32 = undefined; var instance_upload_vals: [FrameTimingRing.capacity]u32 = undefined; var gpu_submit_vals: [FrameTimingRing.capacity]u32 = undefined; var total_vals: [FrameTimingRing.capacity]u32 = undefined; for (entries, 0..) |e, i| { snapshot_vals[i] = e.snapshot_us; row_rebuild_vals[i] = e.row_rebuild_us; atlas_upload_vals[i] = e.atlas_upload_us; instance_upload_vals[i] = e.instance_upload_us; gpu_submit_vals[i] = e.gpu_submit_us; total_vals[i] = e.total(); } return .{ .snapshot = computeSectionStats(snapshot_vals[0..n]), .row_rebuild = computeSectionStats(row_rebuild_vals[0..n]), .atlas_upload = computeSectionStats(atlas_upload_vals[0..n]), .instance_upload = computeSectionStats(instance_upload_vals[0..n]), .gpu_submit = computeSectionStats(gpu_submit_vals[0..n]), .total = computeSectionStats(total_vals[0..n]), .frame_count = n, }; } pub fn printFrameStats(stats: FrameTimingStats) void { const row_fmt = "{s:<20}{d:>6}{d:>6}{d:>6}{d:>6}\n"; std.debug.print("\n=== waystty frame timing ({d} frames) ===\n", .{stats.frame_count}); std.debug.print("{s:<20}{s:>6}{s:>6}{s:>6}{s:>6} (us)\n", .{ "section", "min", "avg", "p99", "max" }); std.debug.print(row_fmt, .{ "snapshot", stats.snapshot.min, stats.snapshot.avg, stats.snapshot.p99, stats.snapshot.max }); std.debug.print(row_fmt, .{ "row_rebuild", stats.row_rebuild.min, stats.row_rebuild.avg, stats.row_rebuild.p99, stats.row_rebuild.max }); std.debug.print(row_fmt, .{ "atlas_upload", stats.atlas_upload.min, stats.atlas_upload.avg, stats.atlas_upload.p99, stats.atlas_upload.max }); std.debug.print(row_fmt, .{ "instance_upload", stats.instance_upload.min, stats.instance_upload.avg, stats.instance_upload.p99, stats.instance_upload.max }); std.debug.print(row_fmt, .{ "gpu_submit", stats.gpu_submit.min, stats.gpu_submit.avg, stats.gpu_submit.p99, stats.gpu_submit.max }); std.debug.print("----------------------------------------------------\n", .{}); std.debug.print(row_fmt, .{ "total", stats.total.min, stats.total.avg, stats.total.p99, stats.total.max }); } test "FrameTiming.total sums all sections" { const ft: FrameTiming = .{ .snapshot_us = 10, .row_rebuild_us = 20, .atlas_upload_us = 30, .instance_upload_us = 40, .gpu_submit_us = 50, }; try std.testing.expectEqual(@as(u32, 150), ft.total()); } test "FrameTimingRing records and wraps correctly" { var ring = FrameTimingRing{}; try std.testing.expectEqual(@as(usize, 0), ring.count); ring.push(.{ .snapshot_us = 1, .row_rebuild_us = 2, .atlas_upload_us = 3, .instance_upload_us = 4, .gpu_submit_us = 5 }); try std.testing.expectEqual(@as(usize, 1), ring.count); try std.testing.expectEqual(@as(u32, 1), ring.entries[0].snapshot_us); // Fill to capacity for (1..FrameTimingRing.capacity) |i| { ring.push(.{ .snapshot_us = @intCast(i + 1), .row_rebuild_us = 0, .atlas_upload_us = 0, .instance_upload_us = 0, .gpu_submit_us = 0 }); } try std.testing.expectEqual(FrameTimingRing.capacity, ring.count); // One more wraps around — overwrites entries[0], head advances to 1 ring.push(.{ .snapshot_us = 999, .row_rebuild_us = 0, .atlas_upload_us = 0, .instance_upload_us = 0, .gpu_submit_us = 0 }); try std.testing.expectEqual(FrameTimingRing.capacity, ring.count); // Newest entry is at (head + capacity - 1) % capacity = 0 try std.testing.expectEqual(@as(u32, 999), ring.entries[0].snapshot_us); // head has advanced past the overwritten slot try std.testing.expectEqual(@as(usize, 1), ring.head); } test "FrameTimingRing.orderedSlice returns entries in insertion order after wrap" { var ring = FrameTimingRing{}; // Push capacity + 3 entries so the ring wraps for (0..FrameTimingRing.capacity + 3) |i| { ring.push(.{ .snapshot_us = @intCast(i), .row_rebuild_us = 0, .atlas_upload_us = 0, .instance_upload_us = 0, .gpu_submit_us = 0 }); } var buf: [FrameTimingRing.capacity]FrameTiming = undefined; const ordered = ring.orderedSlice(&buf); try std.testing.expectEqual(FrameTimingRing.capacity, ordered.len); // First entry should be the 4th pushed (index 3), last should be capacity+2 try std.testing.expectEqual(@as(u32, 3), ordered[0].snapshot_us); try std.testing.expectEqual(@as(u32, FrameTimingRing.capacity + 2), ordered[ordered.len - 1].snapshot_us); } test "FrameTimingStats computes min/avg/p99/max correctly" { var ring = FrameTimingRing{}; // Push 100 frames with snapshot_us = 1..100 for (0..100) |i| { ring.push(.{ .snapshot_us = @intCast(i + 1), .row_rebuild_us = 0, .atlas_upload_us = 0, .instance_upload_us = 0, .gpu_submit_us = 0, }); } const stats = computeFrameStats(&ring); try std.testing.expectEqual(@as(u32, 1), stats.snapshot.min); try std.testing.expectEqual(@as(u32, 100), stats.snapshot.max); try std.testing.expectEqual(@as(u32, 50), stats.snapshot.avg); // p99 of 1..100 = value at index 98 (0-based) = 99 try std.testing.expectEqual(@as(u32, 99), stats.snapshot.p99); try std.testing.expectEqual(@as(usize, 100), stats.frame_count); } test "FrameTimingStats handles empty ring" { var ring = FrameTimingRing{}; const stats = computeFrameStats(&ring); try std.testing.expectEqual(@as(usize, 0), stats.frame_count); try std.testing.expectEqual(@as(u32, 0), stats.snapshot.min); } diff --git a/src/main.zig b/src/main.zig index 083552b..1fe3284 100644 --- a/src/main.zig +++ b/src/main.zig @@ -7,6 +7,14 @@ const renderer = @import("renderer"); const font = @import("font"); const config = @import("config"); const vk = @import("vulkan"); const bench_stats = @import("bench_stats"); const FrameTiming = bench_stats.FrameTiming; const FrameTimingRing = bench_stats.FrameTimingRing; const SectionStats = bench_stats.SectionStats; const FrameTimingStats = bench_stats.FrameTimingStats; const computeSectionStats = bench_stats.computeSectionStats; const computeFrameStats = bench_stats.computeFrameStats; const printFrameStats = bench_stats.printFrameStats; const c = @cImport({ @cInclude("xkbcommon/xkbcommon-keysyms.h"); @@ -951,131 +959,6 @@ fn clampSelectionSpan(span: SelectionSpan, cols: u16, rows: u16) ?SelectionSpan } else null; } const FrameTiming = struct { snapshot_us: u32 = 0, row_rebuild_us: u32 = 0, atlas_upload_us: u32 = 0, instance_upload_us: u32 = 0, gpu_submit_us: u32 = 0, fn total(self: FrameTiming) u32 { return self.snapshot_us + self.row_rebuild_us + self.atlas_upload_us + self.instance_upload_us + self.gpu_submit_us; } }; const FrameTimingRing = struct { const capacity = 256; entries: [capacity]FrameTiming = [_]FrameTiming{.{}} ** capacity, head: usize = 0, count: usize = 0, fn push(self: *FrameTimingRing, timing: FrameTiming) void { const idx = if (self.count < capacity) self.count else self.head; self.entries[idx] = timing; if (self.count < capacity) { self.count += 1; } else { self.head = (self.head + 1) % capacity; } } /// Return a slice of valid entries in insertion order. /// Caller must provide a scratch buffer of `capacity` entries. fn orderedSlice(self: *const FrameTimingRing, buf: *[capacity]FrameTiming) []const FrameTiming { if (self.count < capacity) { return self.entries[0..self.count]; } // Ring has wrapped — copy from head..end then 0..head const tail_len = capacity - self.head; @memcpy(buf[0..tail_len], self.entries[self.head..capacity]); @memcpy(buf[tail_len..capacity], self.entries[0..self.head]); return buf[0..capacity]; } }; const SectionStats = struct { min: u32 = 0, avg: u32 = 0, p99: u32 = 0, max: u32 = 0, }; const FrameTimingStats = struct { snapshot: SectionStats = .{}, row_rebuild: SectionStats = .{}, atlas_upload: SectionStats = .{}, instance_upload: SectionStats = .{}, gpu_submit: SectionStats = .{}, total: SectionStats = .{}, frame_count: usize = 0, }; fn computeSectionStats(values: []u32) SectionStats { if (values.len == 0) return .{}; std.mem.sort(u32, values, {}, std.sort.asc(u32)); var sum: u64 = 0; for (values) |v| sum += v; const p99_idx = if (values.len <= 1) 0 else ((values.len - 1) * 99) / 100; return .{ .min = values[0], .avg = @intCast(sum / values.len), .p99 = values[p99_idx], .max = values[values.len - 1], }; } fn computeFrameStats(ring: *const FrameTimingRing) FrameTimingStats { if (ring.count == 0) return .{}; var ordered_buf: [FrameTimingRing.capacity]FrameTiming = undefined; const entries = ring.orderedSlice(&ordered_buf); const n = entries.len; var snapshot_vals: [FrameTimingRing.capacity]u32 = undefined; var row_rebuild_vals: [FrameTimingRing.capacity]u32 = undefined; var atlas_upload_vals: [FrameTimingRing.capacity]u32 = undefined; var instance_upload_vals: [FrameTimingRing.capacity]u32 = undefined; var gpu_submit_vals: [FrameTimingRing.capacity]u32 = undefined; var total_vals: [FrameTimingRing.capacity]u32 = undefined; for (entries, 0..) |e, i| { snapshot_vals[i] = e.snapshot_us; row_rebuild_vals[i] = e.row_rebuild_us; atlas_upload_vals[i] = e.atlas_upload_us; instance_upload_vals[i] = e.instance_upload_us; gpu_submit_vals[i] = e.gpu_submit_us; total_vals[i] = e.total(); } return .{ .snapshot = computeSectionStats(snapshot_vals[0..n]), .row_rebuild = computeSectionStats(row_rebuild_vals[0..n]), .atlas_upload = computeSectionStats(atlas_upload_vals[0..n]), .instance_upload = computeSectionStats(instance_upload_vals[0..n]), .gpu_submit = computeSectionStats(gpu_submit_vals[0..n]), .total = computeSectionStats(total_vals[0..n]), .frame_count = n, }; } fn printFrameStats(stats: FrameTimingStats) void { const row_fmt = "{s:<20}{d:>6}{d:>6}{d:>6}{d:>6}\n"; std.debug.print("\n=== waystty frame timing ({d} frames) ===\n", .{stats.frame_count}); std.debug.print("{s:<20}{s:>6}{s:>6}{s:>6}{s:>6} (us)\n", .{ "section", "min", "avg", "p99", "max" }); std.debug.print(row_fmt, .{ "snapshot", stats.snapshot.min, stats.snapshot.avg, stats.snapshot.p99, stats.snapshot.max }); std.debug.print(row_fmt, .{ "row_rebuild", stats.row_rebuild.min, stats.row_rebuild.avg, stats.row_rebuild.p99, stats.row_rebuild.max }); std.debug.print(row_fmt, .{ "atlas_upload", stats.atlas_upload.min, stats.atlas_upload.avg, stats.atlas_upload.p99, stats.atlas_upload.max }); std.debug.print(row_fmt, .{ "instance_upload", stats.instance_upload.min, stats.instance_upload.avg, stats.instance_upload.p99, stats.instance_upload.max }); std.debug.print(row_fmt, .{ "gpu_submit", stats.gpu_submit.min, stats.gpu_submit.avg, stats.gpu_submit.p99, stats.gpu_submit.max }); std.debug.print("----------------------------------------------------\n", .{}); std.debug.print(row_fmt, .{ "total", stats.total.min, stats.total.avg, stats.total.p99, stats.total.max }); } var sigusr1_received: std.atomic.Value(bool) = std.atomic.Value(bool).init(false); fn sigusr1Handler(_: c_int) callconv(.c) void { @@ -3147,82 +3030,6 @@ test "buildTextCoverageCompareScene repeats the same specimen in four panels" { ); } test "FrameTiming.total sums all sections" { const ft: FrameTiming = .{ .snapshot_us = 10, .row_rebuild_us = 20, .atlas_upload_us = 30, .instance_upload_us = 40, .gpu_submit_us = 50, }; try std.testing.expectEqual(@as(u32, 150), ft.total()); } test "FrameTimingRing records and wraps correctly" { var ring = FrameTimingRing{}; try std.testing.expectEqual(@as(usize, 0), ring.count); ring.push(.{ .snapshot_us = 1, .row_rebuild_us = 2, .atlas_upload_us = 3, .instance_upload_us = 4, .gpu_submit_us = 5 }); try std.testing.expectEqual(@as(usize, 1), ring.count); try std.testing.expectEqual(@as(u32, 1), ring.entries[0].snapshot_us); // Fill to capacity for (1..FrameTimingRing.capacity) |i| { ring.push(.{ .snapshot_us = @intCast(i + 1), .row_rebuild_us = 0, .atlas_upload_us = 0, .instance_upload_us = 0, .gpu_submit_us = 0 }); } try std.testing.expectEqual(FrameTimingRing.capacity, ring.count); // One more wraps around — overwrites entries[0], head advances to 1 ring.push(.{ .snapshot_us = 999, .row_rebuild_us = 0, .atlas_upload_us = 0, .instance_upload_us = 0, .gpu_submit_us = 0 }); try std.testing.expectEqual(FrameTimingRing.capacity, ring.count); // Newest entry is at (head + capacity - 1) % capacity = 0 try std.testing.expectEqual(@as(u32, 999), ring.entries[0].snapshot_us); // head has advanced past the overwritten slot try std.testing.expectEqual(@as(usize, 1), ring.head); } test "FrameTimingRing.orderedSlice returns entries in insertion order after wrap" { var ring = FrameTimingRing{}; // Push capacity + 3 entries so the ring wraps for (0..FrameTimingRing.capacity + 3) |i| { ring.push(.{ .snapshot_us = @intCast(i), .row_rebuild_us = 0, .atlas_upload_us = 0, .instance_upload_us = 0, .gpu_submit_us = 0 }); } var buf: [FrameTimingRing.capacity]FrameTiming = undefined; const ordered = ring.orderedSlice(&buf); try std.testing.expectEqual(FrameTimingRing.capacity, ordered.len); // First entry should be the 4th pushed (index 3), last should be capacity+2 try std.testing.expectEqual(@as(u32, 3), ordered[0].snapshot_us); try std.testing.expectEqual(@as(u32, FrameTimingRing.capacity + 2), ordered[ordered.len - 1].snapshot_us); } test "FrameTimingStats computes min/avg/p99/max correctly" { var ring = FrameTimingRing{}; // Push 100 frames with snapshot_us = 1..100 for (0..100) |i| { ring.push(.{ .snapshot_us = @intCast(i + 1), .row_rebuild_us = 0, .atlas_upload_us = 0, .instance_upload_us = 0, .gpu_submit_us = 0, }); } const stats = computeFrameStats(&ring); try std.testing.expectEqual(@as(u32, 1), stats.snapshot.min); try std.testing.expectEqual(@as(u32, 100), stats.snapshot.max); try std.testing.expectEqual(@as(u32, 50), stats.snapshot.avg); // p99 of 1..100 = value at index 98 (0-based) = 99 try std.testing.expectEqual(@as(u32, 99), stats.snapshot.p99); try std.testing.expectEqual(@as(usize, 100), stats.frame_count); } test "FrameTimingStats handles empty ring" { var ring = FrameTimingRing{}; const stats = computeFrameStats(&ring); try std.testing.expectEqual(@as(usize, 0), stats.frame_count); try std.testing.expectEqual(@as(u32, 0), stats.snapshot.min); } fn runRenderSmokeTest(alloc: std.mem.Allocator) !void { const conn = try wayland_client.Connection.init(alloc); defer conn.deinit();