1d53cec2
Extract frame timing stats into bench_stats module
a73x 2026-04-17 15:24
Commit message
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(); |