summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorGabriel Schneider <[email protected]>2026-09-21 23:05:00 -0300
committerGabriel Schneider <[email protected]>2026-10-01 00:12:14 -0300
commit20b39924ceabc13c1c72fbeccdcf7f3d490228db (patch)
tree59fd2e450689543864ae3f8dca114c229bfe6f44
parent5dcfade5f102256de787b2157b01293160780411 (diff)
downloadpardes-20b39924ceabc13c1c72fbeccdcf7f3d490228db.tar.gz
pardes-20b39924ceabc13c1c72fbeccdcf7f3d490228db.zip
Measure startup, and find there is nothing in it worth optimizing
A --startup case in the perf harness times Pardes.init and the first frame for each boot layout, with the same --json and --base shape as the others, so these are a baseline rather than a one-off reading. The core boots and paints in 0.22 ms (tty) to 0.30 ms (classic): about one percent of what starting the editor costs, and nothing to win. The other 99% is one thing, found with perf record rather than guessed at. Zig's start.main allocates through the Debug-mode DebugAllocator, which captures a six-frame stack trace per allocation, and the first capture parses and sorts the DWARF unwind tables of an 800 MB binary -- 22% of all samples sit in mem.swap under that pdq sort. It is not the dynamic loader (25 us), not static initializers (the binary has no .init_array), not paging (435 page faults), and not lockStderr. std/start.zig:694 hardcodes DebugAllocator(.{}), so there is no knob short of the build mode. Controls that pin it down: a bare std.process.Init hello-world starts in 3 ms, the ReleaseFast pardes-perf binary in 4 ms, /bin/true in 1 ms, and a Debug pardes in 22 ms. The gui binary's 107 ms first run was cold page cache; warm it matches the tty one. So the conclusion recorded in features.txt is to leave it alone. Twenty-two milliseconds is imperceptible for an editor, and the only lever is switching the default build mode, which would multiply an already ten-minute build to save eighteen milliseconds of startup. Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
-rw-r--r--features.txt39
-rw-r--r--test/perf.zig85
2 files changed, 123 insertions, 1 deletions
diff --git a/features.txt b/features.txt
index a09607ea..9933dd14 100644
--- a/features.txt
+++ b/features.txt
@@ -73,3 +73,42 @@ harness, and the baseline files are how a regression gets caught later.
object and reading the fid only tells you which one you got; acme's new/ctl is the same shape, and pardes's old /new already keyed its side effect on open. The
Tcreate that replaced it has a real wart: a pane is named by a server-assigned serial, so the create ignores the client's name and `mkdir /pane/foo` leaves you
/pane/12. Replace it with /pane/clone (open allocates, read answers the serial) and keep Tremove, which is unambiguously right.
+
+TODO, deferred: ligatures on the sdl backend. macOS got them with the first-class-host commit because CoreText shapes for free; sdl rasterises one glyph per
+codepoint through FreeType (src/gui/font.c calls FT_Get_Char_Index then FT_Load_Glyph) and FreeType does no shaping, so this means adding HarfBuzz. It is not the
+whole renderer, but it is most of the text path: the glyph atlas is keyed by codepoint (GlyphKey{codepoint, role, decoration}) and a ligature has no codepoint, so
+the cache rekeys to glyph indices; a shaping stage has to run over runs of same-styled text within a row; and the per-cell instance emit has to place one glyph
+across several columns and remember which columns a ligature consumed (macOS does this with ligatureShaped/ligatureCovered). The GPU batching, atlas upload and
+scene shaders are untouched. Not worth it until something else wants a shaper.
+
+Not a bug: taglines sit one pixel bottom-heavy when cell_h - tagline_h is odd, because taglineBandOffset splits the spare with a truncating /2. Confirmed
+acceptable; leave it.
+
+Startup, measured before optimizing (zig build perf -- --startup, plus timings of the installed binaries):
+ core Pardes.init + first frame 0.22 ms (tty) .. 0.30 ms (classic) -- negligible
+ dynamic linker ~25 us, 346 relocations, 4 libs -- negligible
+ page faults for `pardes --version` 435 -- the 812 MB binary is never paged in, size is a red herring
+ pardes --version 22 ms real, 21 ms USER, 1 ms sys
+ pardes-gui --version 107 ms real, 23 ms user -- ~84 ms blocked, not CPU
+ /bin/true control 1 ms real, 0 user -- the harness costs nothing
+Cause found, by profiling rather than guessing (perf record on `pardes --version`):
+
+ start.main -> mem.Allocator.alloc
+ -> heap.debug_allocator.DebugAllocator(.{ .stack_trace_frames = 6, ... }).alloc
+ -> collectStackTrace -> debug.captureCurrentStackTrace
+ -> SelfInfo.Elf.unwindFrame -> loadUnwindSections -> Dwarf.Unwind.prepare
+ -> mem.sortUnstable (22% of all samples sit in mem.swap under the pdq sort)
+
+Zig's own start.main allocates through the Debug-mode DebugAllocator, which captures a 6-frame trace on every allocation,
+and the first capture has to parse AND SORT the DWARF unwind tables of an 800 MB binary. That is the whole 18 ms. It is
+not lockStderr (added to a hello-world: free), not static initializers (the binary has no .init_array), not the dynamic
+loader (25 us), and not paging (435 page faults). std/start.zig:694 hardcodes `DebugAllocator(.{})`, so there is no knob.
+
+Controls: a bare std.process.Init hello-world is 3 ms; the ReleaseFast pardes-perf binary (81 MB, links libc, so
+use_debug_allocator is false) starts in 4 ms; /bin/true is 1 ms. The gui binary's one-off 107 ms first run was cold page
+cache, not blocking -- warm it is 25 ms, the same as the tty one.
+
+Recommendation: do NOT chase this. 22 ms warm is imperceptible for an editor, the only lever is the build mode, and
+switching the default from Debug to ReleaseSafe would multiply an already ~10 minute build to save 18 ms of startup --
+a bad trade when the build is the actual bottleneck in this project. Worth knowing for the day a binary ships to someone
+else: ReleaseSafe keeps the safety checks, drops the debug allocator, and is 5x faster to start and 10x smaller.
diff --git a/test/perf.zig b/test/perf.zig
index 5aa5c4d4..8f463204 100644
--- a/test/perf.zig
+++ b/test/perf.zig
@@ -151,6 +151,86 @@ const Allocations = struct {
}
};
+/// What a session costs before it can show anything: the core's own boot, then
+/// the first frame built from it. The host's share — process spawn, dynamic
+/// linking, GPU and font setup — is not in here and has to be timed around the
+/// real binary; this is the half that a change to the editor can move.
+fn measureStartup(io: std.Io, reps: usize, json: bool) !void {
+ if (reps == 0 or screen_cols == 0 or screen_rows == 0) return error.InvalidMeasurement;
+ const init_samples = try gpa.alloc(u64, reps);
+ defer gpa.free(init_samples);
+ const frame_samples = try gpa.alloc(u64, reps);
+ defer gpa.free(frame_samples);
+
+ const cases = [_]struct { name: []const u8, boot: pardes.Boot }{
+ .{ .name = "startup-tty", .boot = .tty },
+ .{ .name = "startup-tty-shell", .boot = .tty_shell },
+ .{ .name = "startup-shell", .boot = .shell },
+ .{ .name = "startup-classic", .boot = .classic },
+ };
+ for (cases) |case| {
+ if (!pardes.terminal_panes) continue;
+ var cold_ns: u64 = 0;
+ var calls: usize = 0;
+ var bytes: usize = 0;
+ var peak: usize = 0;
+ for (0..reps + 1) |index| {
+ var counter: Allocations = .{};
+ const allocator = counter.allocator();
+ const begin = nowNs();
+ const core = try pardes.Pardes.init(allocator, .{
+ .tty_only = case.boot == .tty or case.boot == .tty_shell,
+ .boot = case.boot,
+ .shells = if (case.boot == .classic) 3 else 1,
+ .cols = screen_cols,
+ .rows = screen_rows,
+ });
+ const booted = nowNs();
+ var paint = std.heap.ArenaAllocator.init(allocator);
+ _ = try core.render(paint.allocator());
+ const painted = nowNs();
+ paint.deinit();
+ core.deinit();
+ if (index == 0) {
+ cold_ns = booted - begin;
+ } else {
+ init_samples[index - 1] = booted - begin;
+ frame_samples[index - 1] = painted - booted;
+ calls += counter.calls;
+ bytes += counter.bytes;
+ peak = @max(peak, counter.peak);
+ }
+ }
+ std.mem.sort(u64, init_samples, {}, std.sort.asc(u64));
+ std.mem.sort(u64, frame_samples, {}, std.sort.asc(u64));
+ const result = .{
+ .build = build_identity,
+ .harness = harness_id,
+ .case = case.name,
+ .cols = screen_cols,
+ .rows = screen_rows,
+ .reps = reps,
+ .cold_ns = cold_ns,
+ .init_median_ns = init_samples[reps / 2],
+ .init_p95_ns = init_samples[@min(reps - 1, reps * 95 / 100)],
+ .frame_median_ns = frame_samples[reps / 2],
+ .allocations = calls / reps,
+ .allocated_bytes = bytes / reps,
+ .peak_bytes = peak,
+ };
+ const text = if (json)
+ try std.json.Stringify.valueAlloc(gpa, result, .{})
+ else
+ try std.fmt.allocPrint(gpa, "{s}: boot {d} ns (cold {d}), first frame {d} ns, {d} allocations / {d} B, peak {d} B", .{
+ result.case, result.init_median_ns, result.cold_ns, result.frame_median_ns,
+ result.allocations, result.allocated_bytes, result.peak_bytes,
+ });
+ defer gpa.free(text);
+ try std.Io.File.stdout().writeStreamingAll(io, text);
+ try std.Io.File.stdout().writeStreamingAll(io, "\n");
+ }
+}
+
fn measureCreation(io: std.Io, reps: usize, json: bool) !void {
if (reps == 0 or screen_cols == 0 or screen_rows == 0) return error.InvalidMeasurement;
const samples = try gpa.alloc(u64, reps);
@@ -232,6 +312,7 @@ const Options = struct {
base: ?[]const u8 = null,
only: ?[]const u8 = null,
creation: bool = false,
+ startup: bool = false,
mini: bool = false,
terminal_mib: ?usize = null,
self_test: bool = false,
@@ -242,11 +323,12 @@ 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, 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 }, arg[2..]) orelse
return error.UnknownOption;
switch (option) {
.json => opts.json = true,
.creation => opts.creation = true,
+ .startup => opts.startup = true,
.mini => opts.mini = true,
.@"self-test" => opts.self_test = true,
else => {
@@ -295,6 +377,7 @@ pub fn main(init: std.process.Init) !void {
screen_rows = opts.rows;
const reps = opts.reps;
const only = opts.only;
+ if (opts.startup) return measureStartup(init.io, reps, opts.json);
if (opts.creation) return measureCreation(init.io, reps, opts.json);
if (opts.terminal_mib) |mib| return measureTerminalSession(init.io, mib, reps, opts.json);
if (opts.mini) return measureMini(init.io, init.gpa, reps, opts.json);