From 20b39924ceabc13c1c72fbeccdcf7f3d490228db Mon Sep 17 00:00:00 2001 From: Gabriel Schneider Date: Mon, 21 Sep 2026 23:05:00 -0300 Subject: 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) --- features.txt | 39 +++++++++++++++++++++++++++ test/perf.zig | 85 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++- 2 files changed, 123 insertions(+), 1 deletion(-) 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); -- cgit v1.3