Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions backend/src/GpuRenderer.zig
Original file line number Diff line number Diff line change
Expand Up @@ -1017,6 +1017,7 @@ pub fn textureCreate(self: *GpuRenderer, pixels: [*]const u8, options: dvui.Text
.sampler = self.samplerFor(options.interpolation, options.wrap_u, options.wrap_v),
};
try self.upload(tex, pixels, options.format, options.width, 0, 0, options.width, options.height);
health.counts.textures += 1;

return .{ .ptr = tex, .width = options.width, .height = options.height, .format = options.format, .interpolation = options.interpolation, .wrap_u = options.wrap_u, .wrap_v = options.wrap_v };
}
Expand Down Expand Up @@ -1093,6 +1094,7 @@ pub fn textureCreateTarget(self: *GpuRenderer, options: dvui.Texture.CreateOptio
// Starts transparent (`Texture.Target.create`).
.needs_clear = true,
};
health.counts.targets += 1;
return .{ .ptr = tex, .width = options.width, .height = options.height, .format = options.format, .interpolation = options.interpolation, .wrap_u = .clamp, .wrap_v = .clamp };
}

Expand All @@ -1119,6 +1121,7 @@ pub fn textureDestroy(self: *GpuRenderer, ptr: *anyopaque) void {
// Released once the command buffers using it are done (SDL defers it).
c.SDL_ReleaseGPUTexture(self.device, tex.texture);
self.tex_pool.destroy(tex);
health.counts.destroyed += 1;
}

pub fn readPixels(self: *GpuRenderer, rect: dvui.Rect.Physical, pixels_out: [*]u8) !void {
Expand Down
5 changes: 5 additions & 0 deletions backend/src/Health.zig
Original file line number Diff line number Diff line change
Expand Up @@ -70,6 +70,11 @@ pub const Counts = struct {
target_switches: u32 = 0,
/// Texture creations and updates uploaded.
uploads: u32 = 0,
/// Textures made from pixels, render targets made, and either destroyed: GPU allocations,
/// which a frame that only draws makes none of (the profiler's `core.profile.Gpu`).
textures: u32 = 0,
targets: u32 = 0,
destroyed: u32 = 0,

fn add(a: *Counts, b: Counts) void {
inline for (std.meta.fields(Counts)) |f| @field(a, f.name) +|= @field(b, f.name);
Expand Down
6 changes: 6 additions & 0 deletions backend/src/SDLBackend.zig
Original file line number Diff line number Diff line change
Expand Up @@ -355,6 +355,12 @@ pub var frame_ended_hook: ?*const fn (end_micros: ?u32) bool = null;
/// whether or not anyone reads them; reading them sorts the frame times and asks SDL (and AppKit)
/// for their windows, so it is for a run's end or a once-a-second readout, not every frame. Main
/// thread.
/// What every frame so far asked the GPU for, summed (`Health.total`): the profiler reads its
/// textures, targets and target switches at each frame's start (`core.profile.Gpu`).
pub fn gpuTotals() Health.Counts {
return Health.current.total;
}

pub fn health() Health.Snapshot {
var s = Health.current.snapshot();
var n: c_int = 0;
Expand Down
97 changes: 75 additions & 22 deletions core/profile.zig
Original file line number Diff line number Diff line change
Expand Up @@ -33,11 +33,13 @@ pub const FrameCauses = @import("profile/FrameCauses.zig");
/// Spans on any thread, each occurrence kept, while the profiler records (`span`, `spans`,
/// `writeTrace`).
pub const Spans = @import("profile/Spans.zig");
/// What a frame asked the GPU to make and unmake, counted by the backend (`hostFrameBegin`).
pub const Gpu = @import("profile/Gpu.zig");

/// Bumped whenever `Profiler`'s layout changes. Part of the key the host publishes it under
/// (`publish_key`), so a plugin built against another layout finds none and records nothing: the
/// `abi` field it would check sits wherever its own layout put it, which a reordered struct moves.
pub const abi: u32 = 4;
pub const abi: u32 = 5;

pub const max_entries = 1024;
const max_depth = 48;
Expand Down Expand Up @@ -89,6 +91,10 @@ pub const FrameStats = struct {
worst_work_ns: u64 = 0,
fps: f64 = 0,
frames: u32 = 0,
/// Textures and render targets made and destroyed, and target switches, per frame: GPU work
/// no scope times, which can cost far more than the frame's own (`Gpu`). Null where the
/// backend does not count it.
gpu: ?Gpu.PerFrame = null,
/// Pointer moves (mouse or touch) the app was given, per second (`countInput`). Where frames
/// are drawn only when there is input, the frame rate can be no better than this.
inputs_per_s: f64 = 0,
Expand All @@ -111,6 +117,8 @@ pub const Profiler = struct {
/// calls in it.
history_work: [history_len]u32 = @splat(0),
history_submit: [history_len]u32 = @splat(0),
/// Each frame's GPU counts, landed like its submit.
history_gpu: [history_len]Gpu = @splat(.{}),
history_ns: [history_len][max_entries]u32 = @splat(@splat(0)),
history_calls: [history_len][max_entries]u16 = @splat(@splat(0)),
history_head: usize = 0,
Expand Down Expand Up @@ -138,6 +146,12 @@ pub const Profiler = struct {
win_submit_ns: u64 = 0,
win_submit_frames: u32 = 0,
win_inputs: u64 = 0,
win_gpu: Gpu.Sum = .{},
/// The backend's GPU totals at the last frame's start, for the next frame's difference. Kept
/// whether or not the frame records, so the first recorded frame counts only itself.
gpu_total: ?Gpu = null,
/// When the last recorded frame ended, the time it is kept under as a hitch (`Lookback`).
recorded_at_ns: i128 = 0,
/// This frame's pointer moves (`countInput`).
frame_inputs: u32 = 0,
/// The history slot the last frame was recorded in, for its submit to land in when the
Expand All @@ -158,25 +172,27 @@ pub const Profiler = struct {
self.win_submit_ns = 0;
self.win_submit_frames = 0;
self.win_inputs = 0;
self.win_gpu = .{};
self.frame_inputs = 0;
self.recorded_slot = null;
self.stats = .{};
self.history_head = 0;
self.history_filled = 0;
self.history_work = @splat(0);
self.history_submit = @splat(0);
self.history_gpu = @splat(.{});
for (&self.history_ns) |*h| h.* = @splat(0);
for (&self.history_calls) |*h| h.* = @splat(0);
if (host_lookback) |lb| lb.reset();
}

/// The frame `ago` frames back (0 the latest recorded): its work, its submit (0 until the
/// next frame reports it), and entries' times and calls, indexed as `slice`. Null past what
/// the history holds.
pub fn historyFrame(self: *Profiler, ago: usize) ?struct { work_ns: u32, submit_ns: u32, ns: []const u32, calls: []const u16 } {
/// The frame `ago` frames back (0 the latest recorded): its work, its submit and GPU counts
/// (0 until the next frame reports them), and entries' times and calls, indexed as `slice`.
/// Null past what the history holds.
pub fn historyFrame(self: *Profiler, ago: usize) ?struct { work_ns: u32, submit_ns: u32, gpu: Gpu, ns: []const u32, calls: []const u16 } {
if (ago >= self.history_filled) return null;
const slot = (self.history_head + history_len - 1 - ago) % history_len;
return .{ .work_ns = self.history_work[slot], .submit_ns = self.history_submit[slot], .ns = self.history_ns[slot][0..self.count], .calls = self.history_calls[slot][0..self.count] };
return .{ .work_ns = self.history_work[slot], .submit_ns = self.history_submit[slot], .gpu = self.history_gpu[slot], .ns = self.history_ns[slot][0..self.count], .calls = self.history_calls[slot][0..self.count] };
}

pub fn slice(self: *Profiler) []Entry {
Expand Down Expand Up @@ -248,7 +264,7 @@ pub const Profiler = struct {
self.frame_inputs +|= n;
}

fn frameBegin(self: *Profiler, prev_submit_ns: ?u64) void {
fn frameBegin(self: *Profiler, prev_submit_ns: ?u64, gpu_total: ?Gpu) void {
const t = now();
self.active = self.enabled or t < self.wanted_until_ns;
if (self.prev_frame_start != 0 and self.active and !self.paused) {
Expand All @@ -260,6 +276,16 @@ pub const Profiler = struct {
self.win_submit_ns += ns;
self.win_submit_frames += 1;
};
// So do its GPU counts: the backend's totals since the frame before's start.
if (gpu_total) |total| {
if (self.recorded_slot) |slot| if (self.gpu_total) |prev| {
const g = total.since(prev);
self.history_gpu[slot] = g;
self.win_gpu.add(g);
if (host_lookback) |lb| lb.noteGpu(self.recorded_at_ns, g);
};
self.gpu_total = total;
}
self.recorded_slot = null;
self.prev_frame_start = t;
self.frame_start = t;
Expand All @@ -283,7 +309,9 @@ pub const Profiler = struct {
const slot = self.history_head;
self.history_work[slot] = @intCast(@min(work, std.math.maxInt(u32)));
self.history_submit[slot] = 0;
self.history_gpu[slot] = .{};
self.recorded_slot = slot;
self.recorded_at_ns = t;
for (self.slice(), 0..) |e, i| {
self.history_ns[slot][i] = @intCast(@min(e.frame_ns, std.math.maxInt(u32)));
self.history_calls[slot][i] = @intCast(@min(e.frame_calls, std.math.maxInt(u16)));
Expand Down Expand Up @@ -318,6 +346,7 @@ pub const Profiler = struct {
.fps = @as(f64, @floatFromInt(self.win_frames)) / (elapsed / std.time.ns_per_s),
.frames = self.win_frames,
.inputs_per_s = @as(f64, @floatFromInt(self.win_inputs)) / (elapsed / std.time.ns_per_s),
.gpu = self.win_gpu.perFrame(),
};
if (host_lookback) |lb| lb.foldWindow(.{
.end_ns = t,
Expand All @@ -343,6 +372,7 @@ pub const Profiler = struct {
self.win_submit_ns = 0;
self.win_submit_frames = 0;
self.win_inputs = 0;
self.win_gpu = .{};
}
};

Expand Down Expand Up @@ -515,10 +545,11 @@ pub fn host() *Profiler {
/// Host only, at the start of every frame, before anything is timed: start the frame, and
/// publish the profiler to the plugins through the shared window. `prev_submit_ns` is how long
/// the backend took to end the last frame after fizzy's part of it (`FrameStats.submit_ns`),
/// where the backend measures that.
pub fn hostFrameBegin(prev_submit_ns: ?u64) void {
/// where the backend measures that; `gpu_total` the backend's running GPU totals (`Gpu`), where
/// it counts them.
pub fn hostFrameBegin(prev_submit_ns: ?u64, gpu_total: ?Gpu) void {
is_host = true;
host_profiler.frameBegin(prev_submit_ns);
host_profiler.frameBegin(prev_submit_ns, gpu_total);
dvui.dataSet(null, publish_id, publish_key, @as(usize, @intFromPtr(&host_profiler)));
if (host_lookback == null and host_profiler.active) host_lookback = Lookback.create(std.heap.page_allocator, max_entries) catch null;
if (host_lookback) |lb| dvui.dataSet(null, publish_id, lookback_key, @as(usize, @intFromPtr(lb)));
Expand Down Expand Up @@ -657,6 +688,7 @@ pub fn report(p: *const Profiler, w: *std.Io.Writer, opts: ReportOptions) std.Io
st.fps, millis(st.interval_ns), millis(st.work_ns), millis(@floatFromInt(st.worst_work_ns)),
});
if (st.submit_ns) |sub| try w.print(".submit_ms = {d:.3}, ", .{millis(sub)});
if (st.gpu) |g| try w.print(".gpu = .{{ .textures = {d:.2}, .targets = {d:.2}, .destroyed = {d:.2}, .target_switches = {d:.2} }}, ", .{ g.textures, g.targets, g.destroyed, g.target_switches });
try w.print(".frames = {d}, .inputs_per_s = {d:.1} }},\n", .{ st.frames, st.inputs_per_s });

// Each owner's own time: the self time of every scope it owns.
Expand Down Expand Up @@ -768,8 +800,8 @@ fn writeHitches(p: *const Profiler, lb: *const Lookback, w: *std.Io.Writer, opts
return ss[a] > ss[b];
}
}.lt);
try w.print(" .{{ .ago_s = {d:.1}, .work_ms = {d:.3}, .top = .{{\n", .{
@as(f64, @floatFromInt(t - h.at_ns)) / std.time.ns_per_s, millis(@floatFromInt(h.work_ns)),
try w.print(" .{{ .ago_s = {d:.1}, .work_ms = {d:.3}, .gpu = .{{ .textures = {d}, .targets = {d}, .destroyed = {d}, .target_switches = {d} }}, .top = .{{\n", .{
@as(f64, @floatFromInt(t - h.at_ns)) / std.time.ns_per_s, millis(@floatFromInt(h.work_ns)), h.gpu.textures, h.gpu.targets, h.gpu.destroyed, h.gpu.target_switches,
});
for (order[0..@min(n, opts.top)]) |i| {
if (millis(@floatFromInt(self_ns[i])) < opts.min_ms) break;
Expand Down Expand Up @@ -813,7 +845,7 @@ test "a report says what each owner and scope cost, as ZON that reads back" {
var p: Profiler = .{ .wanted_until_ns = std.math.maxInt(i96) };
// Two frames a window apart, so the second publishes averages.
for (0..2) |_| {
p.frameBegin(null);
p.frameBegin(null, null);
const outer = p.open("fizzy", "draw").?;
const inner = p.open("text", "surface").?;
const until = now() + 2 * std.time.ns_per_ms;
Expand All @@ -829,7 +861,7 @@ test "a report says what each owner and scope cost, as ZON that reads back" {
const text = w.buffered();

const Report = struct {
frame: struct { fps: f64, interval_ms: f64, work_ms: f64, worst_work_ms: f64, submit_ms: ?f64 = null, frames: u32, inputs_per_s: f64 },
frame: struct { fps: f64, interval_ms: f64, work_ms: f64, worst_work_ms: f64, submit_ms: ?f64 = null, gpu: ?Gpu.PerFrame = null, frames: u32, inputs_per_s: f64 },
owners: []const struct { owner: []const u8, self_ms: f64 },
scopes: []const struct { owner: []const u8, name: []const u8, avg_ms: f64, self_ms: f64, max_ms: f64, calls: f64, parent: ?u16 = null },
};
Expand All @@ -847,16 +879,16 @@ test "a report says what each owner and scope cost, as ZON that reads back" {
test "nothing records unless the window is open or someone asked" {
dvui.io = std.testing.io;
var p: Profiler = .{};
p.frameBegin(null);
p.frameBegin(null, null);
try std.testing.expect(!p.active);
p.wanted_until_ns = now() + std.time.ns_per_s;
p.frameBegin(null);
p.frameBegin(null, null);
try std.testing.expect(p.active);
}

test "scopes nest, and a frame's time folds into the window's averages" {
var p: Profiler = .{ .enabled = true };
p.frameBegin(null);
p.frameBegin(null, null);
const a = p.open("pixi", "draw").?;
const b = p.open("pixi", "bubbles").?;
p.close(b);
Expand All @@ -874,7 +906,7 @@ test "scopes nest, and a frame's time folds into the window's averages" {

test "a scope whose end was skipped does not strand the ones after it" {
var p: Profiler = .{ .enabled = true };
p.frameBegin(null);
p.frameBegin(null, null);
const a = p.open("fizzy", "frame").?;
_ = p.open("x", "lost").?; // never closed
p.close(a);
Expand All @@ -883,18 +915,39 @@ test "a scope whose end was skipped does not strand the ones after it" {

test "a frame's submit lands in its own slot when the next frame begins" {
var p: Profiler = .{ .enabled = true };
p.frameBegin(null);
p.frameBegin(null, null);
p.countInput(3);
p.frameEnd();
try std.testing.expectEqual(@as(u32, 0), p.historyFrame(0).?.submit_ns);
p.frameBegin(5 * std.time.ns_per_ms);
p.frameBegin(5 * std.time.ns_per_ms, null);
try std.testing.expectEqual(@as(u32, 5 * std.time.ns_per_ms), p.historyFrame(0).?.submit_ns);
try std.testing.expectEqual(@as(u64, 3), p.win_inputs);
// Paused, a frame is not recorded and its submit goes nowhere.
p.frameEnd();
p.paused = true;
p.frameBegin(null);
p.frameBegin(null, null);
p.frameEnd();
p.frameBegin(7 * std.time.ns_per_ms);
p.frameBegin(7 * std.time.ns_per_ms, null);
try std.testing.expectEqual(@as(u32, 0), p.historyFrame(0).?.submit_ns);
}

test "a frame's GPU counts land in its own slot and the window's average, not before it records" {
dvui.io = std.testing.io;
var p: Profiler = .{};
// Closed: the totals are followed, nothing is kept.
p.frameBegin(null, .{ .targets = 100 });
p.frameEnd();
p.enabled = true;
p.frameBegin(null, .{ .targets = 103 });
p.frameEnd();
try std.testing.expectEqual(@as(u32, 0), p.historyFrame(0).?.gpu.targets);
// The frame recorded made 4 targets and switched 6 times, counted as the next one begins.
p.frameBegin(null, .{ .targets = 107, .target_switches = 6 });
try std.testing.expectEqual(@as(u32, 4), p.historyFrame(0).?.gpu.targets);
try std.testing.expectEqual(@as(u32, 6), p.historyFrame(0).?.gpu.target_switches);
p.frameEnd();
p.frameBegin(null, .{ .targets = 107, .target_switches = 6 });
p.win_start -= window_ns;
p.frameEnd();
try std.testing.expectEqual(@as(f64, 2), p.stats.gpu.?.targets);
}
86 changes: 86 additions & 0 deletions core/profile/Gpu.zig
Original file line number Diff line number Diff line change
@@ -0,0 +1,86 @@
//! What a frame asked the GPU to make and unmake: textures, render targets, and the switches
//! between targets. Each costs little to ask for and can cost a great deal where the driver does
//! it (WebGL on Safari's ANGLE over Metal recreates a framebuffer in milliseconds), none of it
//! inside any scope the profiler times — so the profiler counts it rather than times it.
//!
//! The backend counts, where every image's textures pass (`dvui.textureCreate` and
//! `Texture.Target.create` from the host and every plugin end up in the host's backend): running
//! totals that only grow, an integer add beside each call into the GPU. The profiler reads them at
//! each frame's start (`core.profile.hostFrameBegin`) and keeps the difference as the last frame's.
//! The same struct is both: a backend's totals, wrapping, and a frame's counts (`since`).
//!
//! std-only, so it tests with `Lookback`.
const Gpu = @This();

const std = @import("std");

/// Textures made from pixels (`textureCreate`).
textures: u32 = 0,
/// Render targets made (`textureCreateTarget`).
targets: u32 = 0,
/// Textures and targets destroyed.
destroyed: u32 = 0,
/// Times drawing moved from one target (or the window) to another.
target_switches: u32 = 0,

/// The counts from totals `prev` to totals `self`: what was asked in between. Wraps, as the
/// totals do.
pub fn since(self: Gpu, prev: Gpu) Gpu {
var out: Gpu = .{};
inline for (std.meta.fields(Gpu)) |f| @field(out, f.name) = @field(self, f.name) -% @field(prev, f.name);
return out;
}

/// Any texture or target made or destroyed: what costs the driver an allocation.
pub fn allocates(self: Gpu) bool {
return self.textures != 0 or self.targets != 0 or self.destroyed != 0;
}

/// Counts summed over many frames, for their average (`perFrame`).
pub const Sum = struct {
textures: u64 = 0,
targets: u64 = 0,
destroyed: u64 = 0,
target_switches: u64 = 0,
frames: u32 = 0,

pub fn add(self: *Sum, g: Gpu) void {
inline for (std.meta.fields(Gpu)) |f| @field(self, f.name) += @field(g, f.name);
self.frames += 1;
}

/// Each count per frame; null when no frame was counted.
pub fn perFrame(self: Sum) ?PerFrame {
if (self.frames == 0) return null;
const n: f64 = @floatFromInt(self.frames);
var out: PerFrame = .{};
inline for (std.meta.fields(Gpu)) |f| @field(out, f.name) = @as(f64, @floatFromInt(@field(self, f.name))) / n;
return out;
}
};

/// Counts per frame, averaged.
pub const PerFrame = struct {
textures: f64 = 0,
targets: f64 = 0,
destroyed: f64 = 0,
target_switches: f64 = 0,
};

test "a frame's counts are the totals' difference, across a wrap" {
const prev: Gpu = .{ .textures = std.math.maxInt(u32), .targets = 3 };
const now: Gpu = .{ .textures = 1, .targets = 8, .target_switches = 4 };
const d = now.since(prev);
try std.testing.expectEqual(@as(u32, 2), d.textures);
try std.testing.expectEqual(@as(u32, 5), d.targets);
try std.testing.expect(d.allocates());
try std.testing.expect(!(Gpu{ .target_switches = 9 }).allocates());

var sum: Sum = .{};
try std.testing.expectEqual(@as(?PerFrame, null), sum.perFrame());
sum.add(.{ .targets = 5 });
sum.add(.{ .targets = 4, .target_switches = 2 });
const pf = sum.perFrame().?;
try std.testing.expectEqual(@as(f64, 4.5), pf.targets);
try std.testing.expectEqual(@as(f64, 1), pf.target_switches);
}
Loading
Loading