From 85b1dd0dd9a3b3216eed939a5b8274209a1f4587 Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Mon, 3 Aug 2026 14:10:57 -0700 Subject: [PATCH] benchmark: formatter benchmark --- src/benchmark/TerminalFormatter.zig | 380 ++++++++++++++++++++++++++++ src/benchmark/cli.zig | 2 + src/benchmark/main.zig | 1 + 3 files changed, 383 insertions(+) create mode 100644 src/benchmark/TerminalFormatter.zig diff --git a/src/benchmark/TerminalFormatter.zig b/src/benchmark/TerminalFormatter.zig new file mode 100644 index 000000000..d935b345e --- /dev/null +++ b/src/benchmark/TerminalFormatter.zig @@ -0,0 +1,380 @@ +//! Benchmarks the terminal formatter (`terminal/formatter.zig`). +//! +//! The formatter is the hot path for clipboard copy (plain/VT/HTML), +//! `write_screen_file`, `selectionString`, terminal search window +//! encoding, and the libghostty-vt formatter C API. This benchmark +//! measures formatting terminal contents that were built during setup +//! (outside the timed region) from a pre-generated VT stream. +//! +//! ## Input +//! +//! `--data` names a pre-generated VT byte stream (for example from +//! `ghostty-gen styled`). The stream is fed to a terminal of the +//! requested dimensions with unlimited scrollback during setup. The +//! resulting screen contents (scrollback included) are what each step +//! formats. +//! +//! ## Modes +//! +//! * `noop` performs no formatting and establishes loop/setup overhead. +//! Subtract this from `format` timings when using hyperfine. +//! * `format` formats the configured region once per loop into a +//! reusable buffer. Buffer growth happens on the first iteration only. +//! * `report` formats once and prints content/output sizes. It is for +//! computing throughput (cells/s, bytes/s), not timing comparisons. +//! +//! ## Examples +//! +//! Build benchmarks in ReleaseFast mode: +//! +//! zig build -Demit-bench -Doptimize=ReleaseFast -Demit-macos-app=false +//! +//! Generate a deterministic corpus, then measure: +//! +//! ghostty-gen styled --seed=42 | head -c 640000 > /tmp/plain.vt +//! hyperfine --warmup 3 \ +//! 'ghostty-bench +terminal-formatter --mode=noop --data=/tmp/plain.vt' \ +//! 'ghostty-bench +terminal-formatter --emit=plain --loops=50 --data=/tmp/plain.vt' \ +//! 'ghostty-bench +terminal-formatter --emit=vt --loops=50 --data=/tmp/plain.vt' +const TerminalFormatter = @This(); + +const std = @import("std"); +const assert = std.debug.assert; +const Allocator = std.mem.Allocator; +const terminalpkg = @import("../terminal/main.zig"); +const formatterpkg = terminalpkg.formatter; +const Benchmark = @import("Benchmark.zig"); +const options = @import("options.zig"); +const Terminal = terminalpkg.Terminal; +const Selection = terminalpkg.Selection; +const Pin = terminalpkg.PageList.Pin; +const global = @import("../global.zig"); + +const log = std.log.scoped(.@"terminal-formatter-bench"); + +alloc: Allocator, +opts: Options, +terminal: ?Terminal = null, + +/// Reused across steps so buffer growth is a one-time setup cost. +output: std.Io.Writer.Allocating, + +/// Reused pin map storage for `--pin-map=true`. +pins: std.ArrayList(Pin) = .empty, + +pub const Options = struct { + /// Set by the shared CLI parser for string option ownership. + _arena: ?std.heap.ArenaAllocator = null, + + /// Select the operation performed inside the timed benchmark step. + mode: Mode = .format, + + /// The output format to emit. + emit: Emit = .vt, + + /// The region of the screen to format. + region: Region = .screen, + + /// Unwrap soft-wrapped lines. + unwrap: bool = false, + + /// Track the source pin of every emitted byte. This exercises the + /// (documented as expensive) pin_map path used by selectionString + /// and search. + @"pin-map": bool = false, + + /// Number of format operations per benchmark step. Increase this + /// when the content is too small for stable hyperfine measurements. + loops: u32 = 25, + + /// The size of the terminal. This affects wrapping and page sizes. + @"terminal-rows": u16 = 24, + @"terminal-cols": u16 = 80, + + /// Pre-generated VT stream fed to the terminal during setup. `-` + /// reads stdin, although a regular file is recommended so identical + /// state can be reused across runs. When unset, the terminal is + /// empty. + data: ?[]const u8 = null, + + pub fn deinit(self: *Options) void { + if (self._arena) |arena| arena.deinit(); + self.* = undefined; + } +}; + +pub const Mode = enum { + /// Establish the benchmark loop and setup overhead. + noop, + + /// Format the configured region once per loop. + format, + + /// Print content and output sizes. Not a timing benchmark. + report, +}; + +pub const Emit = enum { + plain, + vt, + html, + + fn format(self: Emit) formatterpkg.Format { + return switch (self) { + .plain => .plain, + .vt => .vt, + .html => .html, + }; + } +}; + +pub const Region = enum { + /// Everything: scrollback and active screen. + screen, + + /// Only the active screen (bottom rows). + active, + + /// Only the scrollback. + history, +}; + +pub fn create( + alloc: Allocator, + opts: Options, +) !*TerminalFormatter { + const ptr = try alloc.create(TerminalFormatter); + errdefer alloc.destroy(ptr); + ptr.* = .{ + .alloc = alloc, + .opts = opts, + .output = .init(alloc), + }; + return ptr; +} + +pub fn destroy(self: *TerminalFormatter, alloc: Allocator) void { + if (self.terminal) |*t| t.deinit(self.alloc); + self.output.deinit(); + self.pins.deinit(self.alloc); + alloc.destroy(self); +} + +pub fn benchmark(self: *TerminalFormatter) Benchmark { + return .init(self, .{ + .stepFn = switch (self.opts.mode) { + .noop => stepNoop, + .format => stepFormat, + .report => stepReport, + }, + .setupFn = setup, + .teardownFn = teardown, + }); +} + +/// Build the terminal state every mode shares. All of this is outside +/// the timed region for `Benchmark`, but is included in whole-process +/// timings, hence the `noop` mode. +fn setup(ptr: *anyopaque) Benchmark.Error!void { + const self: *TerminalFormatter = @ptrCast(@alignCast(ptr)); + self.setupImpl() catch |err| { + log.warn("failed to prepare formatter benchmark err={}", .{err}); + return error.BenchmarkFailed; + }; +} + +fn setupImpl(self: *TerminalFormatter) !void { + if (self.terminal) |*t| t.deinit(self.alloc); + self.terminal = null; + self.terminal = try Terminal.init(global.io(), self.alloc, .{ + .cols = self.opts.@"terminal-cols", + .rows = self.opts.@"terminal-rows", + .max_scrollback_bytes = null, + .max_scrollback_lines = null, + }); + const terminal = &self.terminal.?; + + // Feed the input corpus through the standard VT stream. + if (try options.dataFile(self.opts.data)) |data_f| { + defer data_f.close(global.io()); + + var stream = terminal.vtStream(); + defer stream.deinit(); + + var read_buf: [4096]u8 align(std.atomic.cache_line) = undefined; + var f_reader = data_f.reader(global.io(), &read_buf); + const r = &f_reader.interface; + + var buf: [4096]u8 = undefined; + while (true) { + const n = try r.readSliceShort(&buf); + if (n == 0) break; // EOF reached + stream.nextSlice(buf[0..n]); + } + } +} + +fn teardown(ptr: *anyopaque) void { + const self: *TerminalFormatter = @ptrCast(@alignCast(ptr)); + if (self.terminal) |*t| t.deinit(self.alloc); + self.terminal = null; + self.output.shrinkRetainingCapacity(0); + self.pins.clearRetainingCapacity(); +} + +/// Build the screen formatter matching our options. This mirrors how +/// Surface clipboard copy and write_screen_file construct formatters. +fn formatter(self: *TerminalFormatter) ?formatterpkg.ScreenFormatter { + const screen = self.terminal.?.screens.active; + + var f: formatterpkg.ScreenFormatter = .init(screen, .{ + .emit = self.opts.emit.format(), + .unwrap = self.opts.unwrap, + }); + + f.content = switch (self.opts.region) { + .screen => .{ .selection = null }, + + inline .active, .history => |region| content: { + const tag: terminalpkg.point.Tag = switch (region) { + .active => .active, + .history => .history, + .screen => unreachable, + }; + const tl = screen.pages.getTopLeft(tag); + const br = screen.pages.getBottomRight(tag) orelse return null; + break :content .{ .selection = Selection.init(tl, br, false) }; + }, + }; + + return f; +} + +fn stepNoop(ptr: *anyopaque) Benchmark.Error!void { + const self: *TerminalFormatter = @ptrCast(@alignCast(ptr)); + for (0..self.opts.loops) |_| { + std.mem.doNotOptimizeAway(self.output.written()); + } +} + +fn stepFormat(ptr: *anyopaque) Benchmark.Error!void { + const self: *TerminalFormatter = @ptrCast(@alignCast(ptr)); + for (0..self.opts.loops) |_| { + self.output.shrinkRetainingCapacity(0); + + var f = self.formatter() orelse continue; + if (self.opts.@"pin-map") { + self.pins.clearRetainingCapacity(); + f.pin_map = .{ .alloc = self.alloc, .map = &self.pins }; + } + + f.format(&self.output.writer) catch |err| { + log.warn("formatting failed err={}", .{err}); + return error.BenchmarkFailed; + }; + std.mem.doNotOptimizeAway(self.output.written()); + } +} + +/// Print the content dimensions and emitted output size. This shares +/// the formatting code with format mode but deliberately makes no +/// timing claims. Use it to compute cells/s and bytes/s from timings. +fn stepReport(ptr: *anyopaque) Benchmark.Error!void { + const self: *TerminalFormatter = @ptrCast(@alignCast(ptr)); + + self.output.shrinkRetainingCapacity(0); + if (self.formatter()) |f_init| { + var f = f_init; + if (self.opts.@"pin-map") { + self.pins.clearRetainingCapacity(); + f.pin_map = .{ .alloc = self.alloc, .map = &self.pins }; + } + f.format(&self.output.writer) catch |err| { + log.warn("formatting failed err={}", .{err}); + return error.BenchmarkFailed; + }; + } + + // Count the total pages and rows in the pagelist. + const screen = self.terminal.?.screens.active; + var pages: usize = 0; + var rows: usize = 0; + { + var node = screen.pages.pages.first; + while (node) |n| : (node = n.next) { + pages += 1; + rows += n.page().size.rows; + } + } + + // Hash the output (and the pin coordinates, which are stable across + // runs unlike the node pointers) so different implementations can be + // checked for identical output. + const out_hash = std.hash.Wyhash.hash(0, self.output.written()); + var pin_hasher = std.hash.Wyhash.init(0); + for (self.pins.items) |pin| { + pin_hasher.update(std.mem.asBytes(&pin.x)); + pin_hasher.update(std.mem.asBytes(&pin.y)); + } + + std.debug.print( + "terminal-formatter emit={s} region={s} pages={d} rows={d} " ++ + "cols={d} cells={d} out_bytes={d} pin_bytes={d} " ++ + "out_hash={x} pin_hash={x}\n", + .{ + @tagName(self.opts.emit), + @tagName(self.opts.region), + pages, + rows, + self.opts.@"terminal-cols", + rows * self.opts.@"terminal-cols", + self.output.written().len, + self.pins.items.len, + out_hash, + pin_hasher.final(), + }, + ); +} + +test TerminalFormatter { + const testing = std.testing; + const impl: *TerminalFormatter = try .create(testing.allocator, .{}); + defer impl.destroy(testing.allocator); + + const bench = impl.benchmark(); + _ = try bench.run(.once); +} + +test "TerminalFormatter formats all emit formats and regions" { + const testing = std.testing; + + inline for (.{ Emit.plain, Emit.vt, Emit.html }) |emit| { + inline for (.{ Region.screen, Region.active, Region.history }) |region| { + const impl: *TerminalFormatter = try .create(testing.allocator, .{ + .emit = emit, + .region = region, + .loops = 1, + .@"terminal-rows" = 4, + .@"terminal-cols" = 8, + }); + defer impl.destroy(testing.allocator); + + const bench = impl.benchmark(); + _ = try bench.run(.once); + } + } +} + +test "TerminalFormatter pin map" { + const testing = std.testing; + const impl: *TerminalFormatter = try .create(testing.allocator, .{ + .@"pin-map" = true, + .loops = 1, + .@"terminal-rows" = 4, + .@"terminal-cols" = 8, + }); + defer impl.destroy(testing.allocator); + + const bench = impl.benchmark(); + _ = try bench.run(.once); +} diff --git a/src/benchmark/cli.zig b/src/benchmark/cli.zig index f6a31f2c5..f76dfc4b7 100644 --- a/src/benchmark/cli.zig +++ b/src/benchmark/cli.zig @@ -14,6 +14,7 @@ pub const Action = enum { @"page-compression", @"scrollback-compression", @"screen-clone", + @"terminal-formatter", @"terminal-parser", @"terminal-resize", @"terminal-snapshot", @@ -39,6 +40,7 @@ pub const Action = enum { .@"terminal-stream" => @import("TerminalStream.zig"), .@"codepoint-width" => @import("CodepointWidth.zig"), .@"grapheme-break" => @import("GraphemeBreak.zig"), + .@"terminal-formatter" => @import("TerminalFormatter.zig"), .@"terminal-parser" => @import("TerminalParser.zig"), .@"terminal-resize" => @import("TerminalResize.zig"), .@"terminal-snapshot" => @import("TerminalSnapshot.zig"), diff --git a/src/benchmark/main.zig b/src/benchmark/main.zig index 666ffb0f6..2c1f31f52 100644 --- a/src/benchmark/main.zig +++ b/src/benchmark/main.zig @@ -6,6 +6,7 @@ pub const CodepointWidth = @import("CodepointWidth.zig"); pub const GraphemeBreak = @import("GraphemeBreak.zig"); pub const HyperlinkMap = @import("HyperlinkMap.zig"); pub const ScreenClone = @import("ScreenClone.zig"); +pub const TerminalFormatter = @import("TerminalFormatter.zig"); pub const TerminalParser = @import("TerminalParser.zig"); pub const TerminalResize = @import("TerminalResize.zig"); pub const TerminalSnapshot = @import("TerminalSnapshot.zig");