a73x

1d53cec2

Extract frame timing stats into bench_stats module

a73x   2026-04-17 15:24

Commit message
Extract frame timing stats into bench_stats module

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>

build.zig
Old New
@@ -15,6 +15,12 @@ pub fn build(b: *std.Build) void {
15 .optimize = optimize, 15 .optimize = optimize,
16 }); 16 });
17 17
18 const bench_stats_mod = b.createModule(.{
19 .root_source_file = b.path("src/bench_stats.zig"),
20 .target = target,
21 .optimize = optimize,
22 });
23
18 // Lazy-fetch the ghostty dependency. On the first invocation this 24 // Lazy-fetch the ghostty dependency. On the first invocation this
19 // materializes the package; subsequent builds use the local cache. 25 // materializes the package; subsequent builds use the local cache.
20 const ghostty_dep = b.lazyDependency("ghostty", .{}); 26 const ghostty_dep = b.lazyDependency("ghostty", .{});
@@ -79,6 +85,7 @@ pub fn build(b: *std.Build) void {
79 exe_mod.addImport("wayland-client", wayland_mod); 85 exe_mod.addImport("wayland-client", wayland_mod);
80 exe_mod.addImport("config", config_mod); 86 exe_mod.addImport("config", config_mod);
81 exe_mod.addImport("frame_loop", frame_loop_mod); 87 exe_mod.addImport("frame_loop", frame_loop_mod);
88 exe_mod.addImport("bench_stats", bench_stats_mod);
82 89
83 const exe = b.addExecutable(.{ 90 const exe = b.addExecutable(.{
84 .name = "waystty", 91 .name = "waystty",
@@ -130,6 +137,15 @@ pub fn build(b: *std.Build) void {
130 }); 137 });
131 test_step.dependOn(&b.addRunArtifact(scale_tracker_tests).step); 138 test_step.dependOn(&b.addRunArtifact(scale_tracker_tests).step);
132 139
140 // Test bench_stats.zig
141 const bench_stats_test_mod = b.createModule(.{
142 .root_source_file = b.path("src/bench_stats.zig"),
143 .target = target,
144 .optimize = optimize,
145 });
146 const bench_stats_tests = b.addTest(.{ .root_module = bench_stats_test_mod });
147 test_step.dependOn(&b.addRunArtifact(bench_stats_tests).step);
148
133 // Test frame_loop.zig 149 // Test frame_loop.zig
134 const frame_loop_test_mod = b.createModule(.{ 150 const frame_loop_test_mod = b.createModule(.{
135 .root_source_file = b.path("src/frame_loop.zig"), 151 .root_source_file = b.path("src/frame_loop.zig"),
@@ -169,6 +185,7 @@ pub fn build(b: *std.Build) void {
169 main_test_mod.addImport("vt", vt_mod); 185 main_test_mod.addImport("vt", vt_mod);
170 main_test_mod.addImport("wayland-client", wayland_mod); 186 main_test_mod.addImport("wayland-client", wayland_mod);
171 main_test_mod.addImport("config", config_mod); 187 main_test_mod.addImport("config", config_mod);
188 main_test_mod.addImport("bench_stats", bench_stats_mod);
172 const main_tests = b.addTest(.{ 189 const main_tests = b.addTest(.{
173 .root_module = main_test_mod, 190 .root_module = main_test_mod,
174 }); 191 });
src/bench_stats.zig
Old New
@@ -0,0 +1,202 @@
1 const std = @import("std");
2
3 pub const FrameTiming = struct {
4 snapshot_us: u32 = 0,
5 row_rebuild_us: u32 = 0,
6 atlas_upload_us: u32 = 0,
7 instance_upload_us: u32 = 0,
8 gpu_submit_us: u32 = 0,
9
10 pub fn total(self: FrameTiming) u32 {
11 return self.snapshot_us +
12 self.row_rebuild_us +
13 self.atlas_upload_us +
14 self.instance_upload_us +
15 self.gpu_submit_us;
16 }
17 };
18
19 pub const FrameTimingRing = struct {
20 pub const capacity = 256;
21
22 entries: [capacity]FrameTiming = [_]FrameTiming{.{}} ** capacity,
23 head: usize = 0,
24 count: usize = 0,
25
26 pub fn push(self: *FrameTimingRing, timing: FrameTiming) void {
27 const idx = if (self.count < capacity) self.count else self.head;
28 self.entries[idx] = timing;
29 if (self.count < capacity) {
30 self.count += 1;
31 } else {
32 self.head = (self.head + 1) % capacity;
33 }
34 }
35
36 /// Return a slice of valid entries in insertion order.
37 /// Caller must provide a scratch buffer of `capacity` entries.
38 pub fn orderedSlice(self: *const FrameTimingRing, buf: *[capacity]FrameTiming) []const FrameTiming {
39 if (self.count < capacity) {
40 return self.entries[0..self.count];
41 }
42 // Ring has wrapped — copy from head..end then 0..head
43 const tail_len = capacity - self.head;
44 @memcpy(buf[0..tail_len], self.entries[self.head..capacity]);
45 @memcpy(buf[tail_len..capacity], self.entries[0..self.head]);
46 return buf[0..capacity];
47 }
48 };
49
50 pub const SectionStats = struct {
51 min: u32 = 0,
52 avg: u32 = 0,
53 p99: u32 = 0,
54 max: u32 = 0,
55 };
56
57 pub const FrameTimingStats = struct {
58 snapshot: SectionStats = .{},
59 row_rebuild: SectionStats = .{},
60 atlas_upload: SectionStats = .{},
61 instance_upload: SectionStats = .{},
62 gpu_submit: SectionStats = .{},
63 total: SectionStats = .{},
64 frame_count: usize = 0,
65 };
66
67 pub fn computeSectionStats(values: []u32) SectionStats {
68 if (values.len == 0) return .{};
69 std.mem.sort(u32, values, {}, std.sort.asc(u32));
70 var sum: u64 = 0;
71 for (values) |v| sum += v;
72 const p99_idx = if (values.len <= 1) 0 else ((values.len - 1) * 99) / 100;
73 return .{
74 .min = values[0],
75 .avg = @intCast(sum / values.len),
76 .p99 = values[p99_idx],
77 .max = values[values.len - 1],
78 };
79 }
80
81 pub fn computeFrameStats(ring: *const FrameTimingRing) FrameTimingStats {
82 if (ring.count == 0) return .{};
83
84 var ordered_buf: [FrameTimingRing.capacity]FrameTiming = undefined;
85 const entries = ring.orderedSlice(&ordered_buf);
86 const n = entries.len;
87
88 var snapshot_vals: [FrameTimingRing.capacity]u32 = undefined;
89 var row_rebuild_vals: [FrameTimingRing.capacity]u32 = undefined;
90 var atlas_upload_vals: [FrameTimingRing.capacity]u32 = undefined;
91 var instance_upload_vals: [FrameTimingRing.capacity]u32 = undefined;
92 var gpu_submit_vals: [FrameTimingRing.capacity]u32 = undefined;
93 var total_vals: [FrameTimingRing.capacity]u32 = undefined;
94
95 for (entries, 0..) |e, i| {
96 snapshot_vals[i] = e.snapshot_us;
97 row_rebuild_vals[i] = e.row_rebuild_us;
98 atlas_upload_vals[i] = e.atlas_upload_us;
99 instance_upload_vals[i] = e.instance_upload_us;
100 gpu_submit_vals[i] = e.gpu_submit_us;
101 total_vals[i] = e.total();
102 }
103
104 return .{
105 .snapshot = computeSectionStats(snapshot_vals[0..n]),
106 .row_rebuild = computeSectionStats(row_rebuild_vals[0..n]),
107 .atlas_upload = computeSectionStats(atlas_upload_vals[0..n]),
108 .instance_upload = computeSectionStats(instance_upload_vals[0..n]),
109 .gpu_submit = computeSectionStats(gpu_submit_vals[0..n]),
110 .total = computeSectionStats(total_vals[0..n]),
111 .frame_count = n,
112 };
113 }
114
115 pub fn printFrameStats(stats: FrameTimingStats) void {
116 const row_fmt = "{s:<20}{d:>6}{d:>6}{d:>6}{d:>6}\n";
117 std.debug.print("\n=== waystty frame timing ({d} frames) ===\n", .{stats.frame_count});
118 std.debug.print("{s:<20}{s:>6}{s:>6}{s:>6}{s:>6} (us)\n", .{ "section", "min", "avg", "p99", "max" });
119 std.debug.print(row_fmt, .{ "snapshot", stats.snapshot.min, stats.snapshot.avg, stats.snapshot.p99, stats.snapshot.max });
120 std.debug.print(row_fmt, .{ "row_rebuild", stats.row_rebuild.min, stats.row_rebuild.avg, stats.row_rebuild.p99, stats.row_rebuild.max });
121 std.debug.print(row_fmt, .{ "atlas_upload", stats.atlas_upload.min, stats.atlas_upload.avg, stats.atlas_upload.p99, stats.atlas_upload.max });
122 std.debug.print(row_fmt, .{ "instance_upload", stats.instance_upload.min, stats.instance_upload.avg, stats.instance_upload.p99, stats.instance_upload.max });
123 std.debug.print(row_fmt, .{ "gpu_submit", stats.gpu_submit.min, stats.gpu_submit.avg, stats.gpu_submit.p99, stats.gpu_submit.max });
124 std.debug.print("----------------------------------------------------\n", .{});
125 std.debug.print(row_fmt, .{ "total", stats.total.min, stats.total.avg, stats.total.p99, stats.total.max });
126 }
127
128 test "FrameTiming.total sums all sections" {
129 const ft: FrameTiming = .{
130 .snapshot_us = 10,
131 .row_rebuild_us = 20,
132 .atlas_upload_us = 30,
133 .instance_upload_us = 40,
134 .gpu_submit_us = 50,
135 };
136 try std.testing.expectEqual(@as(u32, 150), ft.total());
137 }
138
139 test "FrameTimingRing records and wraps correctly" {
140 var ring = FrameTimingRing{};
141 try std.testing.expectEqual(@as(usize, 0), ring.count);
142
143 ring.push(.{ .snapshot_us = 1, .row_rebuild_us = 2, .atlas_upload_us = 3, .instance_upload_us = 4, .gpu_submit_us = 5 });
144 try std.testing.expectEqual(@as(usize, 1), ring.count);
145 try std.testing.expectEqual(@as(u32, 1), ring.entries[0].snapshot_us);
146
147 // Fill to capacity
148 for (1..FrameTimingRing.capacity) |i| {
149 ring.push(.{ .snapshot_us = @intCast(i + 1), .row_rebuild_us = 0, .atlas_upload_us = 0, .instance_upload_us = 0, .gpu_submit_us = 0 });
150 }
151 try std.testing.expectEqual(FrameTimingRing.capacity, ring.count);
152
153 // One more wraps around — overwrites entries[0], head advances to 1
154 ring.push(.{ .snapshot_us = 999, .row_rebuild_us = 0, .atlas_upload_us = 0, .instance_upload_us = 0, .gpu_submit_us = 0 });
155 try std.testing.expectEqual(FrameTimingRing.capacity, ring.count);
156 // Newest entry is at (head + capacity - 1) % capacity = 0
157 try std.testing.expectEqual(@as(u32, 999), ring.entries[0].snapshot_us);
158 // head has advanced past the overwritten slot
159 try std.testing.expectEqual(@as(usize, 1), ring.head);
160 }
161
162 test "FrameTimingRing.orderedSlice returns entries in insertion order after wrap" {
163 var ring = FrameTimingRing{};
164 // Push capacity + 3 entries so the ring wraps
165 for (0..FrameTimingRing.capacity + 3) |i| {
166 ring.push(.{ .snapshot_us = @intCast(i), .row_rebuild_us = 0, .atlas_upload_us = 0, .instance_upload_us = 0, .gpu_submit_us = 0 });
167 }
168 var buf: [FrameTimingRing.capacity]FrameTiming = undefined;
169 const ordered = ring.orderedSlice(&buf);
170 try std.testing.expectEqual(FrameTimingRing.capacity, ordered.len);
171 // First entry should be the 4th pushed (index 3), last should be capacity+2
172 try std.testing.expectEqual(@as(u32, 3), ordered[0].snapshot_us);
173 try std.testing.expectEqual(@as(u32, FrameTimingRing.capacity + 2), ordered[ordered.len - 1].snapshot_us);
174 }
175
176 test "FrameTimingStats computes min/avg/p99/max correctly" {
177 var ring = FrameTimingRing{};
178 // Push 100 frames with snapshot_us = 1..100
179 for (0..100) |i| {
180 ring.push(.{
181 .snapshot_us = @intCast(i + 1),
182 .row_rebuild_us = 0,
183 .atlas_upload_us = 0,
184 .instance_upload_us = 0,
185 .gpu_submit_us = 0,
186 });
187 }
188 const stats = computeFrameStats(&ring);
189 try std.testing.expectEqual(@as(u32, 1), stats.snapshot.min);
190 try std.testing.expectEqual(@as(u32, 100), stats.snapshot.max);
191 try std.testing.expectEqual(@as(u32, 50), stats.snapshot.avg);
192 // p99 of 1..100 = value at index 98 (0-based) = 99
193 try std.testing.expectEqual(@as(u32, 99), stats.snapshot.p99);
194 try std.testing.expectEqual(@as(usize, 100), stats.frame_count);
195 }
196
197 test "FrameTimingStats handles empty ring" {
198 var ring = FrameTimingRing{};
199 const stats = computeFrameStats(&ring);
200 try std.testing.expectEqual(@as(usize, 0), stats.frame_count);
201 try std.testing.expectEqual(@as(u32, 0), stats.snapshot.min);
202 }
src/main.zig
Old New
@@ -7,6 +7,14 @@ const renderer = @import("renderer");
7 const font = @import("font"); 7 const font = @import("font");
8 const config = @import("config"); 8 const config = @import("config");
9 const vk = @import("vulkan"); 9 const vk = @import("vulkan");
10 const bench_stats = @import("bench_stats");
11 const FrameTiming = bench_stats.FrameTiming;
12 const FrameTimingRing = bench_stats.FrameTimingRing;
13 const SectionStats = bench_stats.SectionStats;
14 const FrameTimingStats = bench_stats.FrameTimingStats;
15 const computeSectionStats = bench_stats.computeSectionStats;
16 const computeFrameStats = bench_stats.computeFrameStats;
17 const printFrameStats = bench_stats.printFrameStats;
10 18
11 const c = @cImport({ 19 const c = @cImport({
12 @cInclude("xkbcommon/xkbcommon-keysyms.h"); 20 @cInclude("xkbcommon/xkbcommon-keysyms.h");
@@ -951,131 +959,6 @@ fn clampSelectionSpan(span: SelectionSpan, cols: u16, rows: u16) ?SelectionSpan
951 } else null; 959 } else null;
952 } 960 }
953 961
954 const FrameTiming = struct {
955 snapshot_us: u32 = 0,
956 row_rebuild_us: u32 = 0,
957 atlas_upload_us: u32 = 0,
958 instance_upload_us: u32 = 0,
959 gpu_submit_us: u32 = 0,
960
961 fn total(self: FrameTiming) u32 {
962 return self.snapshot_us +
963 self.row_rebuild_us +
964 self.atlas_upload_us +
965 self.instance_upload_us +
966 self.gpu_submit_us;
967 }
968 };
969
970 const FrameTimingRing = struct {
971 const capacity = 256;
972
973 entries: [capacity]FrameTiming = [_]FrameTiming{.{}} ** capacity,
974 head: usize = 0,
975 count: usize = 0,
976
977 fn push(self: *FrameTimingRing, timing: FrameTiming) void {
978 const idx = if (self.count < capacity) self.count else self.head;
979 self.entries[idx] = timing;
980 if (self.count < capacity) {
981 self.count += 1;
982 } else {
983 self.head = (self.head + 1) % capacity;
984 }
985 }
986
987 /// Return a slice of valid entries in insertion order.
988 /// Caller must provide a scratch buffer of `capacity` entries.
989 fn orderedSlice(self: *const FrameTimingRing, buf: *[capacity]FrameTiming) []const FrameTiming {
990 if (self.count < capacity) {
991 return self.entries[0..self.count];
992 }
993 // Ring has wrapped — copy from head..end then 0..head
994 const tail_len = capacity - self.head;
995 @memcpy(buf[0..tail_len], self.entries[self.head..capacity]);
996 @memcpy(buf[tail_len..capacity], self.entries[0..self.head]);
997 return buf[0..capacity];
998 }
999 };
1000
1001 const SectionStats = struct {
1002 min: u32 = 0,
1003 avg: u32 = 0,
1004 p99: u32 = 0,
1005 max: u32 = 0,
1006 };
1007
1008 const FrameTimingStats = struct {
1009 snapshot: SectionStats = .{},
1010 row_rebuild: SectionStats = .{},
1011 atlas_upload: SectionStats = .{},
1012 instance_upload: SectionStats = .{},
1013 gpu_submit: SectionStats = .{},
1014 total: SectionStats = .{},
1015 frame_count: usize = 0,
1016 };
1017
1018 fn computeSectionStats(values: []u32) SectionStats {
1019 if (values.len == 0) return .{};
1020 std.mem.sort(u32, values, {}, std.sort.asc(u32));
1021 var sum: u64 = 0;
1022 for (values) |v| sum += v;
1023 const p99_idx = if (values.len <= 1) 0 else ((values.len - 1) * 99) / 100;
1024 return .{
1025 .min = values[0],
1026 .avg = @intCast(sum / values.len),
1027 .p99 = values[p99_idx],
1028 .max = values[values.len - 1],
1029 };
1030 }
1031
1032 fn computeFrameStats(ring: *const FrameTimingRing) FrameTimingStats {
1033 if (ring.count == 0) return .{};
1034
1035 var ordered_buf: [FrameTimingRing.capacity]FrameTiming = undefined;
1036 const entries = ring.orderedSlice(&ordered_buf);
1037 const n = entries.len;
1038
1039 var snapshot_vals: [FrameTimingRing.capacity]u32 = undefined;
1040 var row_rebuild_vals: [FrameTimingRing.capacity]u32 = undefined;
1041 var atlas_upload_vals: [FrameTimingRing.capacity]u32 = undefined;
1042 var instance_upload_vals: [FrameTimingRing.capacity]u32 = undefined;
1043 var gpu_submit_vals: [FrameTimingRing.capacity]u32 = undefined;
1044 var total_vals: [FrameTimingRing.capacity]u32 = undefined;
1045
1046 for (entries, 0..) |e, i| {
1047 snapshot_vals[i] = e.snapshot_us;
1048 row_rebuild_vals[i] = e.row_rebuild_us;
1049 atlas_upload_vals[i] = e.atlas_upload_us;
1050 instance_upload_vals[i] = e.instance_upload_us;
1051 gpu_submit_vals[i] = e.gpu_submit_us;
1052 total_vals[i] = e.total();
1053 }
1054
1055 return .{
1056 .snapshot = computeSectionStats(snapshot_vals[0..n]),
1057 .row_rebuild = computeSectionStats(row_rebuild_vals[0..n]),
1058 .atlas_upload = computeSectionStats(atlas_upload_vals[0..n]),
1059 .instance_upload = computeSectionStats(instance_upload_vals[0..n]),
1060 .gpu_submit = computeSectionStats(gpu_submit_vals[0..n]),
1061 .total = computeSectionStats(total_vals[0..n]),
1062 .frame_count = n,
1063 };
1064 }
1065
1066 fn printFrameStats(stats: FrameTimingStats) void {
1067 const row_fmt = "{s:<20}{d:>6}{d:>6}{d:>6}{d:>6}\n";
1068 std.debug.print("\n=== waystty frame timing ({d} frames) ===\n", .{stats.frame_count});
1069 std.debug.print("{s:<20}{s:>6}{s:>6}{s:>6}{s:>6} (us)\n", .{ "section", "min", "avg", "p99", "max" });
1070 std.debug.print(row_fmt, .{ "snapshot", stats.snapshot.min, stats.snapshot.avg, stats.snapshot.p99, stats.snapshot.max });
1071 std.debug.print(row_fmt, .{ "row_rebuild", stats.row_rebuild.min, stats.row_rebuild.avg, stats.row_rebuild.p99, stats.row_rebuild.max });
1072 std.debug.print(row_fmt, .{ "atlas_upload", stats.atlas_upload.min, stats.atlas_upload.avg, stats.atlas_upload.p99, stats.atlas_upload.max });
1073 std.debug.print(row_fmt, .{ "instance_upload", stats.instance_upload.min, stats.instance_upload.avg, stats.instance_upload.p99, stats.instance_upload.max });
1074 std.debug.print(row_fmt, .{ "gpu_submit", stats.gpu_submit.min, stats.gpu_submit.avg, stats.gpu_submit.p99, stats.gpu_submit.max });
1075 std.debug.print("----------------------------------------------------\n", .{});
1076 std.debug.print(row_fmt, .{ "total", stats.total.min, stats.total.avg, stats.total.p99, stats.total.max });
1077 }
1078
1079 var sigusr1_received: std.atomic.Value(bool) = std.atomic.Value(bool).init(false); 962 var sigusr1_received: std.atomic.Value(bool) = std.atomic.Value(bool).init(false);
1080 963
1081 fn sigusr1Handler(_: c_int) callconv(.c) void { 964 fn sigusr1Handler(_: c_int) callconv(.c) void {
@@ -3147,82 +3030,6 @@ test "buildTextCoverageCompareScene repeats the same specimen in four panels" {
3147 ); 3030 );
3148 } 3031 }
3149 3032
3150 test "FrameTiming.total sums all sections" {
3151 const ft: FrameTiming = .{
3152 .snapshot_us = 10,
3153 .row_rebuild_us = 20,
3154 .atlas_upload_us = 30,
3155 .instance_upload_us = 40,
3156 .gpu_submit_us = 50,
3157 };
3158 try std.testing.expectEqual(@as(u32, 150), ft.total());
3159 }
3160
3161 test "FrameTimingRing records and wraps correctly" {
3162 var ring = FrameTimingRing{};
3163 try std.testing.expectEqual(@as(usize, 0), ring.count);
3164
3165 ring.push(.{ .snapshot_us = 1, .row_rebuild_us = 2, .atlas_upload_us = 3, .instance_upload_us = 4, .gpu_submit_us = 5 });
3166 try std.testing.expectEqual(@as(usize, 1), ring.count);
3167 try std.testing.expectEqual(@as(u32, 1), ring.entries[0].snapshot_us);
3168
3169 // Fill to capacity
3170 for (1..FrameTimingRing.capacity) |i| {
3171 ring.push(.{ .snapshot_us = @intCast(i + 1), .row_rebuild_us = 0, .atlas_upload_us = 0, .instance_upload_us = 0, .gpu_submit_us = 0 });
3172 }
3173 try std.testing.expectEqual(FrameTimingRing.capacity, ring.count);
3174
3175 // One more wraps around — overwrites entries[0], head advances to 1
3176 ring.push(.{ .snapshot_us = 999, .row_rebuild_us = 0, .atlas_upload_us = 0, .instance_upload_us = 0, .gpu_submit_us = 0 });
3177 try std.testing.expectEqual(FrameTimingRing.capacity, ring.count);
3178 // Newest entry is at (head + capacity - 1) % capacity = 0
3179 try std.testing.expectEqual(@as(u32, 999), ring.entries[0].snapshot_us);
3180 // head has advanced past the overwritten slot
3181 try std.testing.expectEqual(@as(usize, 1), ring.head);
3182 }
3183
3184 test "FrameTimingRing.orderedSlice returns entries in insertion order after wrap" {
3185 var ring = FrameTimingRing{};
3186 // Push capacity + 3 entries so the ring wraps
3187 for (0..FrameTimingRing.capacity + 3) |i| {
3188 ring.push(.{ .snapshot_us = @intCast(i), .row_rebuild_us = 0, .atlas_upload_us = 0, .instance_upload_us = 0, .gpu_submit_us = 0 });
3189 }
3190 var buf: [FrameTimingRing.capacity]FrameTiming = undefined;
3191 const ordered = ring.orderedSlice(&buf);
3192 try std.testing.expectEqual(FrameTimingRing.capacity, ordered.len);
3193 // First entry should be the 4th pushed (index 3), last should be capacity+2
3194 try std.testing.expectEqual(@as(u32, 3), ordered[0].snapshot_us);
3195 try std.testing.expectEqual(@as(u32, FrameTimingRing.capacity + 2), ordered[ordered.len - 1].snapshot_us);
3196 }
3197
3198 test "FrameTimingStats computes min/avg/p99/max correctly" {
3199 var ring = FrameTimingRing{};
3200 // Push 100 frames with snapshot_us = 1..100
3201 for (0..100) |i| {
3202 ring.push(.{
3203 .snapshot_us = @intCast(i + 1),
3204 .row_rebuild_us = 0,
3205 .atlas_upload_us = 0,
3206 .instance_upload_us = 0,
3207 .gpu_submit_us = 0,
3208 });
3209 }
3210 const stats = computeFrameStats(&ring);
3211 try std.testing.expectEqual(@as(u32, 1), stats.snapshot.min);
3212 try std.testing.expectEqual(@as(u32, 100), stats.snapshot.max);
3213 try std.testing.expectEqual(@as(u32, 50), stats.snapshot.avg);
3214 // p99 of 1..100 = value at index 98 (0-based) = 99
3215 try std.testing.expectEqual(@as(u32, 99), stats.snapshot.p99);
3216 try std.testing.expectEqual(@as(usize, 100), stats.frame_count);
3217 }
3218
3219 test "FrameTimingStats handles empty ring" {
3220 var ring = FrameTimingRing{};
3221 const stats = computeFrameStats(&ring);
3222 try std.testing.expectEqual(@as(usize, 0), stats.frame_count);
3223 try std.testing.expectEqual(@as(u32, 0), stats.snapshot.min);
3224 }
3225
3226 fn runRenderSmokeTest(alloc: std.mem.Allocator) !void { 3033 fn runRenderSmokeTest(alloc: std.mem.Allocator) !void {
3227 const conn = try wayland_client.Connection.init(alloc); 3034 const conn = try wayland_client.Connection.init(alloc);
3228 defer conn.deinit(); 3035 defer conn.deinit();