src/bench_stats.zig
Ref: Size: 12.1 KiB
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,
// Sub-timings inside gpu_submit (drawCells). Sum roughly equals
// gpu_submit_us modulo per-call timer overhead.
wait_fences_us: u32 = 0,
acquire_us: u32 = 0,
record_us: u32 = 0,
submit_us: u32 = 0,
present_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,
};
}
/// Dump per-frame timings as CSV when WAYSTTY_BENCH_CSV points to a path.
/// Columns: frame,snapshot_us,row_rebuild_us,atlas_upload_us,instance_upload_us,gpu_submit_us,total_us
pub fn writeFrameCsv(path: []const u8, ring: *const FrameTimingRing) !void {
var ordered_buf: [FrameTimingRing.capacity]FrameTiming = undefined;
const entries = ring.orderedSlice(&ordered_buf);
const file = try std.fs.cwd().createFile(path, .{});
defer file.close();
var buf: [512]u8 = undefined;
_ = try file.write("frame,snapshot_us,row_rebuild_us,atlas_upload_us,instance_upload_us,gpu_submit_us,wait_fences_us,acquire_us,record_us,submit_us,present_us,total_us\n");
for (entries, 0..) |e, i| {
const line = try std.fmt.bufPrint(&buf, "{d},{d},{d},{d},{d},{d},{d},{d},{d},{d},{d},{d}\n", .{
i,
e.snapshot_us,
e.row_rebuild_us,
e.atlas_upload_us,
e.instance_upload_us,
e.gpu_submit_us,
e.wait_fences_us,
e.acquire_us,
e.record_us,
e.submit_us,
e.present_us,
e.total(),
});
_ = try file.write(line);
}
}
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);
}
pub const BaselineRecord = struct {
workload_sha: []const u8,
zig_version: []const u8,
waystty_sha: []const u8,
frame_count: usize,
sections: struct {
snapshot: SectionStats,
row_rebuild: SectionStats,
atlas_upload: SectionStats,
instance_upload: SectionStats,
gpu_submit: SectionStats,
},
};
/// Serialize `rec` to JSON and return an owned slice. Caller must free.
pub fn writeBaselineJson(alloc: std.mem.Allocator, rec: BaselineRecord) ![]u8 {
var out: std.Io.Writer.Allocating = .init(alloc);
errdefer out.deinit();
try std.json.Stringify.value(rec, .{ .whitespace = .indent_2 }, &out.writer);
return out.toOwnedSlice();
}
pub fn readBaselineJson(alloc: std.mem.Allocator, bytes: []const u8) !BaselineRecord {
var parsed = try std.json.parseFromSlice(BaselineRecord, alloc, bytes, .{});
defer parsed.deinit();
return .{
.workload_sha = try alloc.dupe(u8, parsed.value.workload_sha),
.zig_version = try alloc.dupe(u8, parsed.value.zig_version),
.waystty_sha = try alloc.dupe(u8, parsed.value.waystty_sha),
.frame_count = parsed.value.frame_count,
.sections = parsed.value.sections,
};
}
test "baseline JSON round-trip" {
const alloc = std.testing.allocator;
const rec = BaselineRecord{
.workload_sha = "abcdef",
.zig_version = "0.15.0",
.waystty_sha = "123abc",
.frame_count = 256,
.sections = .{
.snapshot = .{ .min = 1, .avg = 2, .p99 = 3, .max = 4 },
.row_rebuild = .{ .min = 10, .avg = 20, .p99 = 30, .max = 40 },
.atlas_upload = .{ .min = 0, .avg = 0, .p99 = 0, .max = 0 },
.instance_upload = .{ .min = 5, .avg = 6, .p99 = 7, .max = 8 },
.gpu_submit = .{ .min = 9, .avg = 9, .p99 = 9, .max = 9 },
},
};
const json_bytes = try writeBaselineJson(alloc, rec);
defer alloc.free(json_bytes);
const parsed = try readBaselineJson(alloc, json_bytes);
defer {
alloc.free(parsed.workload_sha);
alloc.free(parsed.zig_version);
alloc.free(parsed.waystty_sha);
}
try std.testing.expectEqual(@as(usize, 256), parsed.frame_count);
try std.testing.expectEqual(@as(u32, 30), parsed.sections.row_rebuild.p99);
try std.testing.expectEqualStrings("abcdef", parsed.workload_sha);
}