summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorGabriel Schneider <[email protected]>2026-09-29 13:19:08 -0300
committerGabriel Schneider <[email protected]>2026-10-01 00:12:16 -0300
commit4e224b7c7e67fafcbe7359fcf534048e254b701d (patch)
tree33539e34e8cee525792767facebf36356bc28b08
parent7ec97214a7a6ee905167b7f94a800a8c1d70b100 (diff)
downloadpardes-4e224b7c7e67fafcbe7359fcf534048e254b701d.tar.gz
pardes-4e224b7c7e67fafcbe7359fcf534048e254b701d.zip
The theme's chrome is worked out once, not per grapheme; still post passes let the GUI rest; a perf gate
somtrmsz's contrast floors made ChromeTheme.fromTheme run its focus-tint and separator searches (pow calls each), and Output's RowDecoration.styleAt called it for every grapheme of every highlighted row: the 50k-line file's render went from 0.11 ms to 13 ms. Pardes.bodyChrome now keeps the theme's chrome, worked out again only when the theme differs; recolorSyntax asks for it once a decorated row, and a plain row (nearly every row of a file) never asks. ReleaseFast, medium fixture, median us, before -> after (main): render 13150 -> 76 (111), key-down 14658 -> 79 (110), wheel 13589 -> 76 (109), open 14891 -> 2095 (2055), edit-char 16703 -> 3006 (1251; the rest of that gap is editing and tree-sitter, not this). Post.animating asked for frames whenever the window had focus and any pass was ready, so a still pass kept the GUI drawing at the display's rate (Bloom: 49% of a core idle, in a hidden test window). A pass now says whether it moves on its own: the CRT (its hum and dither) and a Shadertoy file whose source reads iTime, iFrame or iDate (shader_build.readsTime; the flag rides the wire's post message); Bloom, Vignette and Grain are still. Bloom idle: 49% -> 1.2%, as with no pass. zig build perf-gate: the 50k-line file's gestures, each's fastest sample within 3x of the recorded baseline's (test/perf-baseline-<platform>- <optimize>.json, recorded from this build), run with every unit-test. On the regressed code it fails at 180x for render.
-rw-r--r--build.zig15
-rw-r--r--src/File.zig3
-rw-r--r--src/Output.zig15
-rw-r--r--src/detached/wire.zig11
-rw-r--r--src/gui/Post.zig27
-rw-r--r--src/pardes.zig12
-rw-r--r--src/shader_build.zig34
-rw-r--r--test/perf-baseline-gui-Debug.json1
-rw-r--r--test/perf-baseline-tty-Debug.json1
-rw-r--r--test/perf-baseline-tty-ReleaseFast.json1
-rw-r--r--test/perf.zig52
11 files changed, 155 insertions, 17 deletions
diff --git a/build.zig b/build.zig
index 48987edf..79de8a81 100644
--- a/build.zig
+++ b/build.zig
@@ -1282,6 +1282,18 @@ pub fn build(b: *std.Build) void {
run_perf.setCwd(b.path("."));
const perf_step = b.step("perf", "gesture latency or bounded terminal stress (-- [--json] [--reps N] [--base old.json] [--terminal-mib N])");
perf_step.dependOn(&run_perf.step);
+ // The gate: the 50k-line file's gestures against the baseline this
+ // build shape recorded, each within 3x of its best. Record one with
+ // `zig build perf -Dplatform=P [-Doptimize=O] -- --only medium
+ // --reps 10 --json > test/perf-baseline-P-O.json` (a build that is
+ // known good). ponytail: the numbers are this machine's; another
+ // machine records its own.
+ const perf_gate = b.addRunArtifact(perf);
+ perf_gate.has_side_effects = true;
+ perf_gate.setCwd(b.path("."));
+ perf_gate.addArgs(&.{ "--only", "medium", "--reps", "10", "--base", b.fmt("test/perf-baseline-{t}-{t}.json", .{ platform, optimize }), "--max-ratio", "3" });
+ const perf_gate_step = b.step("perf-gate", "fail when a gesture on the 50k-line file takes over 3x its recorded baseline (test/perf-baseline-<platform>-<optimize>.json)");
+ perf_gate_step.dependOn(&perf_gate.step);
const perf_tests = b.addTest(.{
.root_module = b.createModule(.{
.target = target,
@@ -1430,6 +1442,9 @@ pub fn build(b: *std.Build) void {
b.step("history", "compare command output and test/build runtime across jj revisions").dependOn(&run_history.step);
const unit_step = b.step("unit-test", "run native shell and module unit tests");
+ // The perf gate runs with every unit-test (a regression like a
+ // contrast search per grapheme costs 100x, far past the gate).
+ unit_step.dependOn(perf_gate_step);
const unit_match = testMatch(b, unit_profile, test_filters, unit_step);
const lspbench_tests = b.addTest(.{ .root_module = lspbench.root_module, .filters = test_filters });
const run_lspbench_tests = b.addRunArtifact(lspbench_tests);
diff --git a/src/File.zig b/src/File.zig
index 5f6d7097..1d838c4f 100644
--- a/src/File.zig
+++ b/src/File.zig
@@ -1293,6 +1293,7 @@ pub fn recolorSyntax(p: *Pardes, s: *Surface, pane: *Pane, f: *State, r: pardes.
const row_index: usize = @intCast(@max(0, pane.wrapAt(vr).line));
const metadata: ?locations.Row = if (row_index < f.location_rows.len) f.location_rows[row_index] else null;
const decoration = Output.decorateRow(f.output, line, metadata);
+ const chrome: ?*const pardes.ChromeTheme = if (decoration.plain()) null else p.bodyChrome();
var c: usize = 0;
var screen_c: usize = 0;
while (hs + c < limit and prefix_width + screen_c < tw) {
@@ -1315,7 +1316,7 @@ pub fn recolorSyntax(p: *Pardes, s: *Surface, pane: *Pane, f: *State, r: pardes.
}
// Results keep their exact live text. Only the location,
// matched source range and diagnostic label gain emphasis.
- if (decoration.styleAt(p, hs + c)) |style| {
+ if (if (chrome) |ch| decoration.styleAt(ch, hs + c) else null) |style| {
var fill: usize = 0;
while (fill < cells and prefix_width + screen_c + fill < tw) : (fill += 1) {
const cell = s.at(tx + @as(u16, @intCast(prefix_width + screen_c + fill)), body_y + vr);
diff --git a/src/Output.zig b/src/Output.zig
index b8dd5e34..6ea81924 100644
--- a/src/Output.zig
+++ b/src/Output.zig
@@ -54,10 +54,17 @@ const RowDecoration = struct {
mark_end: usize = 0,
severity: enum { none, err, warning, info, hint } = .none,
- pub fn styleAt(row: RowDecoration, p: *const Pardes, byte: usize) ?pardes.CellStyle {
- // Body paper changes immediately, so its ink must change with it
- // even while the surrounding tags are still fading.
- const chrome = pardes.ChromeTheme.fromTheme(p.theme());
+ /// Nothing in the row is decorated: most rows of most files.
+ pub fn plain(row: RowDecoration) bool {
+ return !row.declaration and row.location_end == 0 and row.prefix_end == 0 and row.mark_end <= row.mark_start;
+ }
+
+ /// `chrome` is the theme's own (Pardes.bodyChrome): body paper changes
+ /// immediately, so its ink must change with it even while the
+ /// surrounding tags are still fading. Once a frame, never a grapheme:
+ /// working it out takes the contrast searches' pow calls.
+ pub fn styleAt(row: RowDecoration, chrome: *const pardes.ChromeTheme, byte: usize) ?pardes.CellStyle {
+ if (row.plain()) return null;
if (row.declaration or byte < @max(row.location_end, row.prefix_end))
return .{ .fg = .{ .rgb = chrome.lineno } };
if (byte < row.mark_start or byte >= row.mark_end) return null;
diff --git a/src/detached/wire.zig b/src/detached/wire.zig
index ee8c72fb..15d3641c 100644
--- a/src/detached/wire.zig
+++ b/src/detached/wire.zig
@@ -1178,6 +1178,7 @@ pub fn encodeServer(out: []u8, msg: ServerMsg) Error![]const u8 {
// 0 a file, else a bundled scene and one.
try w.putByte(if (pass.scene) |scene| @as(u8, @intFromEnum(scene)) + 1 else 0);
try w.putByte(pass.level);
+ try w.putByte(@intFromBool(pass.animated));
try w.putU32(pass.revision);
try w.putSlice16(pass.path);
try w.putSlice32(pass.spirv);
@@ -1206,7 +1207,7 @@ pub fn serverBound(msg: ServerMsg) usize {
.open_link => |u| u.len,
.post => |p| blk: {
var n: usize = 0;
- for (p.list()) |pass| n += 12 + pass.path.len + pass.spirv.len;
+ for (p.list()) |pass| n += 13 + pass.path.len + pass.spirv.len;
break :blk n;
},
};
@@ -1280,6 +1281,11 @@ pub fn decodeServer(tag: u8, payload: []const u8) Error!ServerMsg {
pass.* = .{
.scene = if (kind == 0) null else std.enums.fromInt(Scene, kind - 1) orelse return error.BadValue,
.level = try r.getByte(),
+ .animated = switch (try r.getByte()) {
+ 0 => false,
+ 1 => true,
+ else => return error.BadValue,
+ },
.revision = try r.getU32(),
.path = try r.getSlice16(),
.spirv = try r.getSlice32(),
@@ -1406,7 +1412,7 @@ test "detached wire: every server message round-trips" {
try testing.expectEqualStrings("https://x", (try roundServer(&buf, .{ .open_link = "https://x" })).open_link);
var chain: Post = .{ .animation = .always, .len = 2 };
chain.passes[0] = .{ .scene = .bloom, .level = 3 };
- chain.passes[1] = .{ .path = "~/wave.glsl", .spirv = "\x03\x02\x23\x07spirv", .revision = 7 };
+ chain.passes[1] = .{ .path = "~/wave.glsl", .spirv = "\x03\x02\x23\x07spirv", .revision = 7, .animated = true };
const back = (try roundServer(&buf, .{ .post = chain })).post;
try testing.expectEqual(ShaderAnimation.always, back.animation);
try testing.expectEqual(@as(u8, 2), back.len);
@@ -1416,6 +1422,7 @@ test "detached wire: every server message round-trips" {
try testing.expectEqualStrings("~/wave.glsl", back.passes[1].path);
try testing.expectEqualStrings("\x03\x02\x23\x07spirv", back.passes[1].spirv);
try testing.expectEqual(@as(u32, 7), back.passes[1].revision);
+ try testing.expect(back.passes[1].animated and !back.passes[0].animated);
}
test "detached wire: only the display's own effects are on the wire" {
diff --git a/src/gui/Post.zig b/src/gui/Post.zig
index 06310b7f..602b2790 100644
--- a/src/gui/Post.zig
+++ b/src/gui/Post.zig
@@ -94,6 +94,8 @@ pub const Pass = struct {
pipeline: ?*Pipeline = null,
/// The SPIR-V revision `pipeline` was made from (shader_build.Pass).
revision: u32 = 0,
+ /// It moves on its own (shader_build.Pass).
+ animated: bool = false,
};
passes: [max]Pass = @splat(.{}),
@@ -140,7 +142,7 @@ pub fn sync(post: *Post, gpa: std.mem.Allocator, device: *c.SDL_GPUDevice, forma
post.bloom_down = makePipeline(device, bloom_format, bloom_down_spv, 1, 1) catch null;
post.bloom_up = makePipeline(device, bloom_format, bloom_up_spv, 1, 1) catch null;
}
- next[i] = .{ .scene = scene, .level = entry.level, .pipeline = post.bundled.get(scene) };
+ next[i] = .{ .scene = scene, .level = entry.level, .pipeline = post.bundled.get(scene), .animated = entry.animated };
continue;
}
// A file already in the chain keeps its pipeline and its path.
@@ -151,6 +153,7 @@ pub fn sync(post: *Post, gpa: std.mem.Allocator, device: *c.SDL_GPUDevice, forma
break;
} else next[i] = .{ .path = gpa.dupe(u8, entry.path) catch &.{} };
const pass = &next[i];
+ pass.animated = entry.animated;
if (pass.revision == entry.revision or entry.spirv.len == 0) continue;
pass.revision = entry.revision;
const made = makePipeline(device, format, entry.spirv, 1, 4) catch {
@@ -200,8 +203,14 @@ pub fn ready(post: *const Post) bool {
}
/// The chain redraws on its own (level A) as ShaderAnimation says.
+/// Only a pass that moves on its own (shader_build.Pass.animated) asks for
+/// frames while idle: a still one (Bloom, Vignette, Grain, a file that reads
+/// no clock) is drawn again only with a frame of the core's.
pub fn animating(post: *const Post, mode: pardes.config.Runtime.ShaderAnimation) bool {
- return post.ready() and switch (mode) {
+ const moving = for (post.passes[0..post.len]) |pass| {
+ if (pass.pipeline != null and pass.animated) break true;
+ } else false;
+ return moving and switch (mode) {
.off => false,
.on => post.uniforms.focus != 0,
.always => true,
@@ -664,3 +673,17 @@ test "a moved cursor becomes the previous one, stamped with the time of the move
try std.testing.expectEqual([4]f32{ 1.0 / 255.0, 2.0 / 255.0, 3.0 / 255.0, 1 }, post.uniforms.selection_foreground_color);
}
+
+test "a still pass asks for no idle frames; one that reads the clock does" {
+ var post: Post = .{};
+ const fake: *Pipeline = @ptrFromInt(0x1000);
+ post.passes[0] = .{ .scene = .bloom, .level = 2, .pipeline = fake };
+ post.len = 1;
+ try std.testing.expect(!post.animating(.on) and !post.animating(.always));
+ post.passes[1] = .{ .scene = .crt, .level = 2, .pipeline = fake, .animated = true };
+ post.len = 2;
+ try std.testing.expect(post.animating(.on) and post.animating(.always) and !post.animating(.off));
+ // Unfocused, `on` rests.
+ post.uniforms.focus = 0;
+ try std.testing.expect(!post.animating(.on) and post.animating(.always));
+}
diff --git a/src/pardes.zig b/src/pardes.zig
index 1f136f2b..ae04b4e7 100644
--- a/src/pardes.zig
+++ b/src/pardes.zig
@@ -4380,6 +4380,8 @@ pub const Pardes = struct {
tty_filter_palette: panes.Terminal.FilterPalette = .{},
font_request_taken: bool = false,
custom_theme: ?Theme = null,
+ /// bodyChrome's: the theme it was worked out for, and it.
+ body_chrome: ?struct { theme: Theme, chrome: ChromeTheme } = null,
theme_file_path: config.Runtime.Text(limits.host_path_cap) = .{},
theme_file_generation: u32 = 0,
theme_file_pane: u8 = 0,
@@ -7063,6 +7065,16 @@ pub const Pardes = struct {
return &p.chrome_animation.displayed;
}
+ /// The theme's own chrome, not a fade's (`theme()`'s, worked out once
+ /// per theme: its contrast searches take pow calls, too many for a
+ /// grapheme or a row).
+ pub fn bodyChrome(p: *Pardes) *const ChromeTheme {
+ const th = p.theme();
+ if (p.body_chrome) |*cached| if (std.meta.eql(cached.theme, th.*)) return &cached.chrome;
+ p.body_chrome = .{ .theme = th.*, .chrome = ChromeTheme.fromTheme(th) };
+ return &p.body_chrome.?.chrome;
+ }
+
/// When core animation next needs a frame, on the `stepped_ns`
/// timeline: a frame from now while anything moves, the end of the
/// wait while something only waits (a message lingering, a look-hover
diff --git a/src/shader_build.zig b/src/shader_build.zig
index 8aa7eb5e..c2610fd7 100644
--- a/src/shader_build.zig
+++ b/src/shader_build.zig
@@ -32,8 +32,24 @@ pub const Pass = struct {
path: []const u8 = &.{},
spirv: []const u8 = &.{},
revision: u32 = 0,
+ /// It moves on its own (reads the clock or the frame count), so the
+ /// chain redraws while idle; a still pass draws only with a new frame.
+ animated: bool = false,
};
+/// The bundled passes that move on their own: the CRT's hum and dither.
+pub fn sceneAnimated(scene: Scene) bool {
+ return scene == .crt;
+}
+
+/// Whether a Shadertoy source moves on its own: it reads the time
+/// (iTime, iTimeDelta and the times of a cursor move or a focus change),
+/// the frame count or the date. A mention in a comment counts too.
+pub fn readsTime(source: []const u8) bool {
+ for ([_][]const u8{ "iTime", "iFrame", "iDate" }) |name| if (std.mem.indexOf(u8, source, name) != null) return true;
+ return false;
+}
+
/// Who to wake when a compile is done: the host's loop.
pub const Wake = struct {
ctx: ?*anyopaque = null,
@@ -47,6 +63,7 @@ const File = struct {
source: ?u64 = null,
spirv: []u8 = &.{},
revision: u32 = 0,
+ animated: bool = false,
/// Wants a compile: new to the chain, or its directory changed.
stale: bool = true,
};
@@ -56,7 +73,7 @@ const Result = union(enum) {
unchanged,
missing,
failed: struct { text: []u8, source: u64 },
- spirv: struct { bytes: []u8, source: u64 },
+ spirv: struct { bytes: []u8, source: u64, animated: bool },
};
/// One compile of the stale files, on its own thread.
@@ -148,8 +165,8 @@ pub fn recheck(b: *Build) void {
/// The chain as passes, in its order.
pub fn view(b: *const Build, chain: *const Chain, out: *[max]Pass) []const Pass {
for (chain.list(), 0..) |entry, i| {
- out[i] = if (entry.scene) |scene| .{ .scene = scene, .level = entry.level } else for (b.files[0..b.len]) |file| {
- if (std.mem.eql(u8, file.path, entry.path.get())) break .{ .path = file.path, .spirv = file.spirv, .revision = file.revision };
+ out[i] = if (entry.scene) |scene| .{ .scene = scene, .level = entry.level, .animated = sceneAnimated(scene) } else for (b.files[0..b.len]) |file| {
+ if (std.mem.eql(u8, file.path, entry.path.get())) break .{ .path = file.path, .spirv = file.spirv, .revision = file.revision, .animated = file.animated };
} else .{ .path = entry.path.get() };
}
return out[0..chain.len];
@@ -211,6 +228,7 @@ fn collect(b: *Build, gpa: std.mem.Allocator, core: *pardes.Pardes, job: *Job) v
gpa.free(f.spirv);
f.spirv = r.bytes;
f.source = r.source;
+ f.animated = r.animated;
f.revision +%= 1;
b.revision +%= 1;
},
@@ -291,7 +309,7 @@ fn compileOne(gpa: std.mem.Allocator, io: std.Io, path: []const u8, known: ?u64)
.exited => |code| code == 0,
else => false,
};
- if (clean) return .{ .spirv = .{ .bytes = reader.toOwnedSlice(0) catch return .none, .source = source } };
+ if (clean) return .{ .spirv = .{ .bytes = reader.toOwnedSlice(0) catch return .none, .source = source, .animated = readsTime(body) } };
const said = reader.toOwnedSlice(1) catch return .none;
// glslc calls its input <stdin>; the file is what a person knows.
defer gpa.free(said);
@@ -400,3 +418,11 @@ test "a save that changes a file compiles it again, a failed one keeps the last
const second = b.view(&core.settings.post, &out)[0];
try std.testing.expect(second.revision != first.revision and b.revision != at);
}
+
+test "a pass animates when it reads the clock or the frame count, and only the CRT of the bundled ones" {
+ try std.testing.expect(readsTime("o = vec4(sin(iTime));"));
+ try std.testing.expect(readsTime("float f = float(iFrame % 64);"));
+ try std.testing.expect(readsTime("x = iTimeCursorChange;"));
+ try std.testing.expect(!readsTime("o = texture(iChannel0, p / iResolution.xy);"));
+ for (std.enums.values(Scene)) |scene| try std.testing.expectEqual(scene == .crt, sceneAnimated(scene));
+}
diff --git a/test/perf-baseline-gui-Debug.json b/test/perf-baseline-gui-Debug.json
new file mode 100644
index 00000000..fd64ebf9
--- /dev/null
+++ b/test/perf-baseline-gui-Debug.json
@@ -0,0 +1 @@
+{"harness":"67f85bf9692f1778","build":{"compiler":"0.16.0","target":"x86_64-linux.5.10...6.19-gnu.2.38","cpu":"x86_64","driver_optimize":"ReleaseFast","core_optimize":"Debug","c_optimize":"ReleaseFast","terminal_optimize":"ReleaseSafe","platform":"gui","grammars":"full","mupdf":true,"jpx":true,"tracy":false,"quic":false,"theme_animation":true,"terminal_simd":true,"prebuilt_shaders":false},"cols":120,"rows":40,"reps":10,"only":"medium","fixtures":[{"name":"small","lines":1000,"cols":60,"bytes":62694},{"name":"medium","lines":50000,"cols":60,"bytes":3201944},{"name":"large","lines":300000,"cols":60,"bytes":19464444},{"name":"longline","lines":400,"cols":8000,"bytes":3200400},{"name":"esp32p4","lines":12,"cols":240,"bytes":2892}],"cells":[{"op":"open","fixture":"medium","min_us":1768,"med_us":2025,"p90_us":2122,"max_us":2125},{"op":"render","fixture":"medium","min_us":72,"med_us":75,"p90_us":76,"max_us":83},{"op":"key-down","fixture":"medium","min_us":73,"med_us":76,"p90_us":80,"max_us":84},{"op":"key-right","fixture":"medium","min_us":74,"med_us":77,"p90_us":81,"max_us":84},{"op":"page-down","fixture":"medium","min_us":73,"med_us":74,"p90_us":77,"max_us":79},{"op":"wheel","fixture":"medium","min_us":71,"med_us":75,"p90_us":81,"max_us":81},{"op":"edit-char","fixture":"medium","min_us":2413,"med_us":2674,"p90_us":2943,"max_us":3111}]}
diff --git a/test/perf-baseline-tty-Debug.json b/test/perf-baseline-tty-Debug.json
new file mode 100644
index 00000000..682ea028
--- /dev/null
+++ b/test/perf-baseline-tty-Debug.json
@@ -0,0 +1 @@
+{"harness":"67f85bf9692f1778","build":{"compiler":"0.16.0","target":"x86_64-linux.5.10...6.19-gnu.2.38","cpu":"x86_64","driver_optimize":"ReleaseFast","core_optimize":"Debug","c_optimize":"ReleaseFast","terminal_optimize":"ReleaseSafe","platform":"tty","grammars":"full","mupdf":true,"jpx":true,"tracy":false,"quic":false,"theme_animation":true,"terminal_simd":true,"prebuilt_shaders":false},"cols":120,"rows":40,"reps":10,"only":"medium","fixtures":[{"name":"small","lines":1000,"cols":60,"bytes":62694},{"name":"medium","lines":50000,"cols":60,"bytes":3201944},{"name":"large","lines":300000,"cols":60,"bytes":19464444},{"name":"longline","lines":400,"cols":8000,"bytes":3200400},{"name":"esp32p4","lines":12,"cols":240,"bytes":2892}],"cells":[{"op":"open","fixture":"medium","min_us":1944,"med_us":2130,"p90_us":2232,"max_us":2394},{"op":"render","fixture":"medium","min_us":71,"med_us":75,"p90_us":76,"max_us":91},{"op":"key-down","fixture":"medium","min_us":72,"med_us":79,"p90_us":93,"max_us":102},{"op":"key-right","fixture":"medium","min_us":73,"med_us":76,"p90_us":78,"max_us":81},{"op":"page-down","fixture":"medium","min_us":72,"med_us":73,"p90_us":76,"max_us":76},{"op":"wheel","fixture":"medium","min_us":71,"med_us":85,"p90_us":96,"max_us":102},{"op":"edit-char","fixture":"medium","min_us":2612,"med_us":3112,"p90_us":3238,"max_us":3618}]}
diff --git a/test/perf-baseline-tty-ReleaseFast.json b/test/perf-baseline-tty-ReleaseFast.json
new file mode 100644
index 00000000..4fd96d92
--- /dev/null
+++ b/test/perf-baseline-tty-ReleaseFast.json
@@ -0,0 +1 @@
+{"harness":"67f85bf9692f1778","build":{"compiler":"0.16.0","target":"x86_64-linux.5.10...6.19-gnu.2.38","cpu":"x86_64","driver_optimize":"ReleaseFast","core_optimize":"ReleaseFast","c_optimize":"ReleaseFast","terminal_optimize":"ReleaseFast","platform":"tty","grammars":"full","mupdf":true,"jpx":true,"tracy":false,"quic":false,"theme_animation":true,"terminal_simd":true,"prebuilt_shaders":false},"cols":120,"rows":40,"reps":10,"only":"medium","fixtures":[{"name":"small","lines":1000,"cols":60,"bytes":62694},{"name":"medium","lines":50000,"cols":60,"bytes":3201944},{"name":"large","lines":300000,"cols":60,"bytes":19464444},{"name":"longline","lines":400,"cols":8000,"bytes":3200400},{"name":"esp32p4","lines":12,"cols":240,"bytes":2892}],"cells":[{"op":"open","fixture":"medium","min_us":1971,"med_us":2259,"p90_us":2308,"max_us":2339},{"op":"render","fixture":"medium","min_us":71,"med_us":75,"p90_us":78,"max_us":79},{"op":"key-down","fixture":"medium","min_us":73,"med_us":77,"p90_us":79,"max_us":95},{"op":"key-right","fixture":"medium","min_us":72,"med_us":77,"p90_us":80,"max_us":88},{"op":"page-down","fixture":"medium","min_us":72,"med_us":76,"p90_us":81,"max_us":83},{"op":"wheel","fixture":"medium","min_us":71,"med_us":75,"p90_us":80,"max_us":91},{"op":"edit-char","fixture":"medium","min_us":2662,"med_us":2999,"p90_us":3375,"max_us":3622}]}
diff --git a/test/perf.zig b/test/perf.zig
index 8eec1ed0..e244091c 100644
--- a/test/perf.zig
+++ b/test/perf.zig
@@ -323,6 +323,9 @@ const Options = struct {
mini: bool = false,
terminal_mib: ?usize = null,
self_test: bool = false,
+ /// A gate: fail when a cell's fastest sample is more than this many
+ /// times the baseline's fastest (--base's).
+ max_ratio: ?f64 = null,
fn parse(args: []const []const u8) !Options {
var opts: Options = .{};
@@ -330,7 +333,7 @@ const Options = struct {
while (i < args.len) : (i += 1) {
const arg = args[i];
if (!std.mem.startsWith(u8, arg, "--")) return error.UnknownOption;
- const option = std.meta.stringToEnum(enum { json, creation, startup, mini, @"terminal-mib", @"self-test", reps, cols, rows, base, only }, arg[2..]) orelse
+ const option = std.meta.stringToEnum(enum { json, creation, startup, mini, @"terminal-mib", @"self-test", reps, cols, rows, base, only, @"max-ratio" }, arg[2..]) orelse
return error.UnknownOption;
switch (option) {
.json => opts.json = true,
@@ -349,6 +352,7 @@ const Options = struct {
.base => opts.base = value,
.only => opts.only = value,
.@"terminal-mib" => opts.terminal_mib = std.fmt.parseInt(usize, value, 10) catch return error.InvalidNumber,
+ .@"max-ratio" => opts.max_ratio = std.fmt.parseFloat(f64, value) catch return error.InvalidNumber,
else => unreachable,
}
},
@@ -363,6 +367,10 @@ const Options = struct {
if (opts.creation or opts.only != null or opts.base != null) return error.IncompatibleOptions;
}
if (opts.base) |path| if (path.len == 0) return error.MissingValue;
+ if (opts.max_ratio) |ratio| {
+ if (opts.base == null) return error.IncompatibleOptions;
+ if (!(ratio >= 1)) return error.InvalidMeasurement;
+ }
if (opts.mini and (opts.creation or opts.terminal_mib != null or opts.only != null or opts.base != null))
return error.IncompatibleOptions;
if (opts.only) |name| {
@@ -431,8 +439,28 @@ pub fn main(init: std.process.Init) !void {
}
}
- if (opts.json) return reportJson(init.io, &cells, &term_cells, &bytes, opts);
- reportText(&cells, &term_cells, &bytes, reps, opts.base, base);
+ if (opts.json) try reportJson(init.io, &cells, &term_cells, &bytes, opts) else reportText(&cells, &term_cells, &bytes, reps, opts.base, base);
+ if (opts.max_ratio) |ratio| try gate(&cells, &base.?, ratio);
+}
+
+/// The performance gate (zig build perf-gate): every measured cell's
+/// fastest sample against the baseline's fastest. The fastest, not the
+/// median: a loaded machine slows the median, rarely the best of a run,
+/// while a regression slows every sample.
+fn gate(cells: *const [std.enums.values(Op).len][fixtures.len]Cell, base: *const Base, ratio: f64) !void {
+ var failed = false;
+ for (std.enums.values(Op), 0..) |op, oi| for (fixtures, 0..) |fx, fi| {
+ const then = base.min[oi][fi];
+ const now = cells[oi][fi].min_us;
+ if (then == 0 or now == 0) continue;
+ const x = @as(f64, @floatFromInt(now)) / @as(f64, @floatFromInt(then));
+ if (x > ratio) {
+ std.debug.print("perf gate: {s} on {s} took {d} us at best, {d:.1}x the baseline's {d} us (limit {d:.1}x)\n", .{ op.label(), fx.name, now, x, then, ratio });
+ failed = true;
+ }
+ };
+ if (failed) return error.PerformanceRegression;
+ std.debug.print("perf gate: every cell within {d:.1}x of the baseline\n", .{ratio});
}
fn measure(op: Op, fx: Fixture, path: []const u8, expected_bytes: usize, reps: usize) !Cell {
@@ -1378,6 +1406,7 @@ fn writeJson(
const Base = struct {
med: [std.enums.values(Op).len][fixtures.len]u64,
+ min: [std.enums.values(Op).len][fixtures.len]u64 = @splat(@splat(0)),
term_med: [std.enums.values(TermOp).len][term_fixtures.len]u64,
};
@@ -1443,6 +1472,7 @@ fn parseBase(src: []const u8, opts: Options, bytes: *const [fixtures.len]usize)
if (file_seen[oi][index]) return error.BaselineCoverage;
file_seen[oi][index] = true;
base.med[oi][index] = cell.med_us;
+ base.min[oi][index] = cell.min_us;
} else {
if (opts.only != null) return error.BaselineCoverage;
const ti = for (term_fixtures, 0..) |fixture, index| {
@@ -1487,8 +1517,22 @@ fn selfTest() !void {
&.{ "--terminal-mib", "1", "--only", "small" }, &.{ "--terminal-mib", "1", "--creation" },
&.{ "--terminal-mib", "1", "--base", "old.json" }, &.{ "--mini", "--creation" },
&.{ "--mini", "--terminal-mib", "1" }, &.{ "--mini", "--base", "old.json" },
- &.{ "--mini", "--only", "small" },
+ &.{ "--mini", "--only", "small" }, &.{ "--max-ratio", "3" },
}) |args| try testing.expectError(error.IncompatibleOptions, Options.parse(args));
+ try testing.expectError(error.InvalidMeasurement, Options.parse(&.{ "--base", "old.json", "--max-ratio", "0.5" }));
+ const gated = try Options.parse(&.{ "--only", "medium", "--base", "old.json", "--max-ratio", "3" });
+ try testing.expectEqual(@as(f64, 3), gated.max_ratio.?);
+ {
+ // A cell three and a half times its baseline's best fails; the
+ // rest pass.
+ var cells: [std.enums.values(Op).len][fixtures.len]Cell = @splat(@splat(.{}));
+ var base: Base = .{ .med = @splat(@splat(0)), .term_med = @splat(@splat(0)) };
+ base.min[1][1] = 100;
+ cells[1][1] = .{ .min_us = 290, .med_us = 300, .p90_us = 300, .max_us = 300 };
+ try gate(&cells, &base, 3);
+ cells[1][1].min_us = 350;
+ try testing.expectError(error.PerformanceRegression, gate(&cells, &base, 3));
+ }
const selected = try Options.parse(&.{ "--only", "small", "--reps", "2", "--cols", "80", "--rows", "24" });
try testing.expectEqual(@as(usize, 2), selected.reps);
try testing.expectEqual(@as(u16, 80), selected.cols);