a73x

5f8b00ab

Split gpu_submit into sub-stage timings (bench only)

a73x   2026-04-17 19:31

drawCells takes an optional SubmitTiming out-param populated with
wait_fences / acquire / record / submit / present micros. Callers that
don't care pass null; overhead when null is one branch and nothing else.

Main loop forwards the split into FrameTiming when WAYSTTY_BENCH=1 and,
if WAYSTTY_BENCH_CSV=<path> is set, dumps per-frame timings as CSV for
post-mortem inspection.

Used this to attribute gpu_submit outliers: 7 of top 10 spikes are pure
waitForFences time (previous frame's GPU not yet done), max 922us on a
single frame; one rare spike (182us) is inside queueSubmit itself. Both
paths are driver/compositor scheduling variance, not code issues.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

diff --git a/src/bench_stats.zig b/src/bench_stats.zig
index 457ee79..0aa45f3 100644
--- a/src/bench_stats.zig
+++ b/src/bench_stats.zig
@@ -6,6 +6,13 @@ pub const FrameTiming = struct {
    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 +
@@ -112,6 +119,35 @@ pub fn computeFrameStats(ring: *const FrameTimingRing) FrameTimingStats {
    };
}

/// 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});
diff --git a/src/main.zig b/src/main.zig
index 005e784..cea6884 100644
--- a/src/main.zig
+++ b/src/main.zig
@@ -669,11 +669,13 @@ fn runTerminal(alloc: std.mem.Allocator) !void {

        section_timer = std.time.Timer.start() catch unreachable;
        const baseline_coverage = renderer.coverageVariantParams(.baseline);
        var submit_timing: renderer.Context.SubmitTiming = .{};
        ctx.drawCells(
            render_cache.total_instance_count,
            .{ @floatFromInt(cell_w), @floatFromInt(cell_h) },
            default_bg,
            baseline_coverage,
            if (is_bench) &submit_timing else null,
        ) catch |err| switch (err) {
            error.OutOfDateKHR => {
                _ = try ctx.vkd.deviceWaitIdle(ctx.device);
@@ -687,6 +689,11 @@ fn runTerminal(alloc: std.mem.Allocator) !void {
            else => return err,
        };
        frame_timing.gpu_submit_us = usFromTimer(&section_timer);
        frame_timing.wait_fences_us = submit_timing.wait_fences_us;
        frame_timing.acquire_us = submit_timing.acquire_us;
        frame_timing.record_us = submit_timing.record_us;
        frame_timing.submit_us = submit_timing.submit_us;
        frame_timing.present_us = submit_timing.present_us;

        frame_ring.push(frame_timing);

@@ -701,6 +708,11 @@ fn runTerminal(alloc: std.mem.Allocator) !void {
    writeBenchJson(alloc, final_stats, bench_script) catch |err| {
        std.log.warn("bench_json write failed: {s}", .{@errorName(err)});
    };
    if (std.posix.getenv("WAYSTTY_BENCH_CSV")) |csv_path| {
        bench_stats.writeFrameCsv(csv_path, &frame_ring) catch |err| {
            std.log.warn("bench_csv write failed: {s}", .{@errorName(err)});
        };
    }

    _ = try ctx.vkd.deviceWaitIdle(ctx.device);
}
@@ -2620,7 +2632,7 @@ fn runDrawSmokeTest(alloc: std.mem.Allocator) !void {
        if (!frame_loop.canRender()) continue;

        const baseline_coverage = renderer.coverageVariantParams(.baseline);
        ctx.drawCells(1, .{ cell_w, cell_h }, .{ 0.0, 0.0, 0.0, 1.0 }, baseline_coverage) catch |err| switch (err) {
        ctx.drawCells(1, .{ cell_w, cell_h }, .{ 0.0, 0.0, 0.0, 1.0 }, baseline_coverage, null) catch |err| switch (err) {
            error.OutOfDateKHR => {
                _ = try ctx.vkd.deviceWaitIdle(ctx.device);
                try ctx.recreateSwapchain(window.width, window.height);
diff --git a/src/renderer.zig b/src/renderer.zig
index 57eb3ca..7ee6e29 100644
--- a/src/renderer.zig
+++ b/src/renderer.zig
@@ -1679,6 +1679,17 @@ pub const Context = struct {
        self.vkd.cmdDraw(cmd, 6, instance_count, 0, 0);
    }

    /// Optional per-stage timings populated by `drawCells` when the caller
    /// passes a non-null pointer. Useful for splitting the "gpu_submit"
    /// section into its constituent waits/records/submits in bench builds.
    pub const SubmitTiming = struct {
        wait_fences_us: u32 = 0,
        acquire_us: u32 = 0,
        record_us: u32 = 0,
        submit_us: u32 = 0,
        present_us: u32 = 0,
    };

    /// Full draw pass: bind pipeline, push constants, vertex + instance buffers, draw, present.
    pub fn drawCells(
        self: *Context,
@@ -1686,10 +1697,22 @@ pub const Context = struct {
        cell_size: [2]f32,
        clear_color: [4]f32,
        coverage_params: [2]f32,
        timing_out: ?*SubmitTiming,
    ) !void {
        var timer = if (timing_out != null) std.time.Timer.start() catch unreachable else undefined;
        const readTimer = struct {
            fn read(t: *std.time.Timer) u32 {
                return @intCast(t.read() / std.time.ns_per_us);
            }
        }.read;

        // Wait for previous frame to finish
        _ = try self.vkd.waitForFences(self.device, 1, @ptrCast(&self.in_flight_fence), .true, std.math.maxInt(u64));
        try self.vkd.resetFences(self.device, 1, @ptrCast(&self.in_flight_fence));
        if (timing_out) |t| {
            t.wait_fences_us = readTimer(&timer);
            timer.reset();
        }

        // Acquire next image
        const acquire = self.vkd.acquireNextImageKHR(
@@ -1704,6 +1727,10 @@ pub const Context = struct {
        };
        if (swapchainNeedsRebuild(acquire.result)) return error.OutOfDateKHR;
        const image_index = acquire.image_index;
        if (timing_out) |t| {
            t.acquire_us = readTimer(&timer);
            timer.reset();
        }

        // Record command buffer
        try self.vkd.resetCommandBuffer(self.command_buffer, .{});
@@ -1736,6 +1763,10 @@ pub const Context = struct {

        self.vkd.cmdEndRenderPass(self.command_buffer);
        try self.vkd.endCommandBuffer(self.command_buffer);
        if (timing_out) |t| {
            t.record_us = readTimer(&timer);
            timer.reset();
        }

        // Submit
        const wait_stage = vk.PipelineStageFlags{ .color_attachment_output_bit = true };
@@ -1748,6 +1779,10 @@ pub const Context = struct {
            .signal_semaphore_count = 1,
            .p_signal_semaphores = @ptrCast(&self.render_finished),
        }), self.in_flight_fence);
        if (timing_out) |t| {
            t.submit_us = readTimer(&timer);
            timer.reset();
        }

        // Present
        const present_result = self.vkd.queuePresentKHR(self.present_queue, &vk.PresentInfoKHR{
@@ -1761,6 +1796,7 @@ pub const Context = struct {
            else => return err,
        };
        if (swapchainNeedsRebuild(present_result)) return error.OutOfDateKHR;
        if (timing_out) |t| t.present_us = readTimer(&timer);
    }

    /// Render `instance_data` into the offscreen target and copy the rendered