mirror of
https://github.com/ghostty-org/ghostty.git
synced 2026-08-26 09:01:44 +00:00
benchmark: formatter benchmark
This commit is contained in:
380
src/benchmark/TerminalFormatter.zig
Normal file
380
src/benchmark/TerminalFormatter.zig
Normal file
@@ -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);
|
||||
}
|
||||
@@ -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"),
|
||||
|
||||
@@ -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");
|
||||
|
||||
Reference in New Issue
Block a user