mirror of
https://github.com/ghostty-org/ghostty.git
synced 2026-08-05 15:18:40 +00:00
benchmark: terminal-resize
This commit is contained in:
357
src/benchmark/TerminalResize.zig
Normal file
357
src/benchmark/TerminalResize.zig
Normal file
@@ -0,0 +1,357 @@
|
||||
//! This benchmark tests the performance of Terminal.resize, with a
|
||||
//! primary focus on column resizes that reflow soft-wrapped text.
|
||||
//! Resize happens on the IO thread while holding the terminal lock,
|
||||
//! so a slow resize directly translates into dropped input and a
|
||||
//! frozen-feeling UI while the user drags the window edge (which
|
||||
//! produces a rapid stream of resizes).
|
||||
//!
|
||||
//! The terminal is populated once during setup (synthetic fill and/or
|
||||
//! a data file replayed through the VT stream) and then each step
|
||||
//! ping-pongs the terminal between two sizes. A full cycle returns the
|
||||
//! terminal to its original dimensions so the state reaches a steady
|
||||
//! state after the first cycle and every iteration performs
|
||||
//! equivalent work.
|
||||
const TerminalResize = @This();
|
||||
|
||||
const std = @import("std");
|
||||
const assert = std.debug.assert;
|
||||
const Allocator = std.mem.Allocator;
|
||||
const terminalpkg = @import("../terminal/main.zig");
|
||||
const Benchmark = @import("Benchmark.zig");
|
||||
const options = @import("options.zig");
|
||||
const Terminal = terminalpkg.Terminal;
|
||||
const global = @import("../global.zig");
|
||||
|
||||
const log = std.log.scoped(.@"terminal-resize-bench");
|
||||
|
||||
opts: Options,
|
||||
alloc: Allocator,
|
||||
terminal: Terminal,
|
||||
|
||||
pub const Options = struct {
|
||||
/// The resize pattern to benchmark. See Mode.
|
||||
mode: Mode = .cols,
|
||||
|
||||
/// Multiplier on the number of resize cycles each step runs. This
|
||||
/// is useful to make a benchmark run long enough for profiling.
|
||||
loops: u32 = 1,
|
||||
|
||||
/// The initial size of the terminal. This is also the size that
|
||||
/// every resize cycle returns to.
|
||||
@"terminal-rows": u16 = 80,
|
||||
@"terminal-cols": u16 = 120,
|
||||
|
||||
/// The dimensions to resize to for the cols/rows/both modes. If
|
||||
/// unset, they default to half of the respective terminal
|
||||
/// dimension, which forces every soft-wrapped line to rewrap.
|
||||
@"resize-cols": ?u16 = null,
|
||||
@"resize-rows": ?u16 = null,
|
||||
|
||||
/// The number of synthetic lines written to the terminal during
|
||||
/// setup. The content is deterministic: a mix of short lines,
|
||||
/// soft-wrapped long lines, blank lines, styled cells, and wide
|
||||
/// characters, since all of these hit different reflow paths.
|
||||
/// Set to 0 to only use `data`.
|
||||
@"fill-lines": u32 = 10_000,
|
||||
|
||||
/// The maximum scrollback size in bytes. Defaults to the Ghostty
|
||||
/// application default. Reflow cost scales with the amount of
|
||||
/// scrollback, not just the visible screen.
|
||||
@"scrollback-bytes": usize = 50_000_000,
|
||||
|
||||
/// The data to read as a filepath. If this is "-" then
|
||||
/// we will read stdin. If this is unset, only the synthetic fill
|
||||
/// is used. The data is streamed into the terminal during setup
|
||||
/// (not part of the benchmark) to build the screen contents that
|
||||
/// get resized.
|
||||
data: ?[]const u8 = null,
|
||||
};
|
||||
|
||||
pub const Mode = enum {
|
||||
/// Resize to the current dimensions. This measures the early-exit
|
||||
/// path (mode updates, pixel geometry) and acts as a baseline.
|
||||
noop,
|
||||
|
||||
/// Alternate the column count between `terminal-cols` and
|
||||
/// `resize-cols`. With wraparound enabled (the default), this
|
||||
/// reflows text in both directions: shrinking wraps long lines
|
||||
/// and growing unwraps them. This is the primary reflow benchmark.
|
||||
cols,
|
||||
|
||||
/// Like `cols`, but with wraparound mode disabled so the resize
|
||||
/// does not reflow. Useful as a baseline to isolate the cost of
|
||||
/// reflow itself from the rest of the resize.
|
||||
@"cols-no-reflow",
|
||||
|
||||
/// Alternate the row count between `terminal-rows` and
|
||||
/// `resize-rows`. Column count is unchanged so no text reflows;
|
||||
/// this measures growing/trimming rows against scrollback.
|
||||
rows,
|
||||
|
||||
/// Alternate both dimensions at once, like a diagonal window drag.
|
||||
both,
|
||||
};
|
||||
|
||||
pub fn create(
|
||||
alloc: Allocator,
|
||||
opts: Options,
|
||||
) !*TerminalResize {
|
||||
const ptr = try alloc.create(TerminalResize);
|
||||
errdefer alloc.destroy(ptr);
|
||||
|
||||
ptr.* = .{
|
||||
.opts = opts,
|
||||
.alloc = alloc,
|
||||
.terminal = try .init(global.io(), alloc, .{
|
||||
.rows = opts.@"terminal-rows",
|
||||
.cols = opts.@"terminal-cols",
|
||||
.max_scrollback_bytes = opts.@"scrollback-bytes",
|
||||
}),
|
||||
};
|
||||
|
||||
return ptr;
|
||||
}
|
||||
|
||||
pub fn destroy(self: *TerminalResize, alloc: Allocator) void {
|
||||
self.terminal.deinit(alloc);
|
||||
alloc.destroy(self);
|
||||
}
|
||||
|
||||
pub fn benchmark(self: *TerminalResize) Benchmark {
|
||||
return .init(self, .{
|
||||
.stepFn = switch (self.opts.mode) {
|
||||
.noop => stepNoop,
|
||||
.cols, .@"cols-no-reflow" => stepCols,
|
||||
.rows => stepRows,
|
||||
.both => stepBoth,
|
||||
},
|
||||
.setupFn = setup,
|
||||
});
|
||||
}
|
||||
|
||||
/// The column count used by the cols/both modes.
|
||||
fn targetCols(self: *const TerminalResize) u16 {
|
||||
return self.opts.@"resize-cols" orelse
|
||||
@max(1, self.opts.@"terminal-cols" / 2);
|
||||
}
|
||||
|
||||
/// The row count used by the rows/both modes.
|
||||
fn targetRows(self: *const TerminalResize) u16 {
|
||||
return self.opts.@"resize-rows" orelse
|
||||
@max(1, self.opts.@"terminal-rows" / 2);
|
||||
}
|
||||
|
||||
fn setup(ptr: *anyopaque) Benchmark.Error!void {
|
||||
const self: *TerminalResize = @ptrCast(@alignCast(ptr));
|
||||
|
||||
// Always reset our terminal state. Note this doesn't resize, but
|
||||
// create initializes (and steps return) the terminal to the
|
||||
// requested dimensions so we're always at terminal-rows/cols here.
|
||||
self.terminal.fullReset();
|
||||
assert(self.terminal.cols == self.opts.@"terminal-cols");
|
||||
assert(self.terminal.rows == self.opts.@"terminal-rows");
|
||||
|
||||
// Fill with synthetic content first, then replay the data file on
|
||||
// top if given. Both go through the VT stream so soft wraps,
|
||||
// styles, etc. are all set exactly as they would be in a real
|
||||
// session.
|
||||
self.fill();
|
||||
try self.replayData();
|
||||
|
||||
// Reflow only happens when wraparound mode is set (it is by
|
||||
// default). We only disable it after filling so the fill itself
|
||||
// still soft-wraps identically in every mode.
|
||||
if (self.opts.mode == .@"cols-no-reflow") {
|
||||
self.terminal.modes.set(.wraparound, false);
|
||||
}
|
||||
}
|
||||
|
||||
/// Write deterministic synthetic content to the terminal. The goal is
|
||||
/// content that is representative of a real session so that reflow
|
||||
/// touches its interesting paths: soft-wrapped lines (must rewrap),
|
||||
/// short lines (copied as-is), blank lines, styled cells (styles must
|
||||
/// be moved across pages), and wide characters (can't be split at the
|
||||
/// wrap column).
|
||||
fn fill(self: *TerminalResize) void {
|
||||
if (self.opts.@"fill-lines" == 0) return;
|
||||
|
||||
var s = self.terminal.vtStream();
|
||||
defer s.deinit();
|
||||
|
||||
var prng: std.Random.DefaultPrng = .init(0xB3);
|
||||
const rand = prng.random();
|
||||
|
||||
const cols: usize = self.terminal.cols;
|
||||
var line_buf: [8192]u8 = undefined;
|
||||
|
||||
for (0..self.opts.@"fill-lines") |i| {
|
||||
// Periodically toggle a background style so reflow has to
|
||||
// carry styled cells into new pages.
|
||||
if (i % 64 == 0) s.nextSlice("\x1b[48;2;20;40;60m");
|
||||
if (i % 64 == 32) s.nextSlice("\x1b[m");
|
||||
|
||||
// A small portion of lines are blank.
|
||||
if (i % 16 == 15) {
|
||||
s.nextSlice("\r\n");
|
||||
continue;
|
||||
}
|
||||
|
||||
// Line lengths between ~25% and ~250% of the terminal width
|
||||
// so we get a mix of short lines and soft-wrapped lines.
|
||||
const min = @max(1, cols / 4);
|
||||
const max = @min(line_buf.len, cols * 5 / 2);
|
||||
const len = min + rand.uintLessThan(usize, max - min);
|
||||
|
||||
var j: usize = 0;
|
||||
while (j < len) {
|
||||
// Sprinkle wide characters into every 8th line.
|
||||
if (i % 8 == 7 and j % 16 == 8 and j + 3 <= len) {
|
||||
line_buf[j..][0..3].* = "漢".*;
|
||||
j += 3;
|
||||
continue;
|
||||
}
|
||||
|
||||
// Words of ASCII separated by spaces.
|
||||
line_buf[j] = if (j % 8 == 7)
|
||||
' '
|
||||
else
|
||||
rand.intRangeAtMost(u8, 'a', 'z');
|
||||
j += 1;
|
||||
}
|
||||
|
||||
s.nextSlice(line_buf[0..len]);
|
||||
s.nextSlice("\r\n");
|
||||
}
|
||||
}
|
||||
|
||||
/// Stream the data file (if any) into the terminal.
|
||||
fn replayData(self: *TerminalResize) Benchmark.Error!void {
|
||||
const data_f: std.Io.File = (options.dataFile(
|
||||
self.opts.data,
|
||||
) catch |err| {
|
||||
log.warn("error opening data file err={}", .{err});
|
||||
return error.BenchmarkFailed;
|
||||
}) orelse return;
|
||||
defer data_f.close(global.io());
|
||||
|
||||
var stream = self.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 = r.readSliceShort(&buf) catch {
|
||||
log.warn("error reading data file err={?}", .{f_reader.err});
|
||||
return error.BenchmarkFailed;
|
||||
};
|
||||
if (n == 0) break; // EOF reached
|
||||
stream.nextSlice(buf[0..n]);
|
||||
}
|
||||
}
|
||||
|
||||
fn resizeTerminal(
|
||||
self: *TerminalResize,
|
||||
cols: u16,
|
||||
rows: u16,
|
||||
) Benchmark.Error!void {
|
||||
self.terminal.resize(self.alloc, .{
|
||||
.cols = cols,
|
||||
.rows = rows,
|
||||
// Realistic cell pixel geometry: real apprt resizes always
|
||||
// carry it, and it exercises the pixel dimension updates.
|
||||
.cell_size_px = .{ .width = 10, .height = 20 },
|
||||
}) catch |err| {
|
||||
log.warn("error resizing terminal err={}", .{err});
|
||||
return error.BenchmarkFailed;
|
||||
};
|
||||
std.mem.doNotOptimizeAway(&self.terminal);
|
||||
}
|
||||
|
||||
fn stepNoop(ptr: *anyopaque) Benchmark.Error!void {
|
||||
const self: *TerminalResize = @ptrCast(@alignCast(ptr));
|
||||
|
||||
const cols = self.opts.@"terminal-cols";
|
||||
const rows = self.opts.@"terminal-rows";
|
||||
|
||||
// We loop because it's so fast (a few ns) that a single resize
|
||||
// doesn't properly capture our speeds.
|
||||
for (0..50_000_000 * @as(u64, self.opts.loops)) |_| {
|
||||
try self.resizeTerminal(cols, rows);
|
||||
}
|
||||
}
|
||||
|
||||
fn stepCols(ptr: *anyopaque) Benchmark.Error!void {
|
||||
const self: *TerminalResize = @ptrCast(@alignCast(ptr));
|
||||
|
||||
const cols = self.opts.@"terminal-cols";
|
||||
const rows = self.opts.@"terminal-rows";
|
||||
const target = self.targetCols();
|
||||
|
||||
// Per-cycle cost differs by orders of magnitude between the two
|
||||
// modes (a reflow resize walks the entire scrollback; a non-reflow
|
||||
// resize doesn't), so pick a cycle count that makes each run long
|
||||
// enough to measure well above process startup and setup cost.
|
||||
const cycles: u64 = switch (self.opts.mode) {
|
||||
.cols => 25,
|
||||
.@"cols-no-reflow" => 1_500,
|
||||
else => unreachable,
|
||||
};
|
||||
|
||||
// Each cycle shrinks (rewrapping long lines) and grows back
|
||||
// (unwrapping them), ending at the original size.
|
||||
for (0..cycles * @as(u64, self.opts.loops)) |_| {
|
||||
try self.resizeTerminal(target, rows);
|
||||
try self.resizeTerminal(cols, rows);
|
||||
}
|
||||
}
|
||||
|
||||
fn stepRows(ptr: *anyopaque) Benchmark.Error!void {
|
||||
const self: *TerminalResize = @ptrCast(@alignCast(ptr));
|
||||
|
||||
const cols = self.opts.@"terminal-cols";
|
||||
const rows = self.opts.@"terminal-rows";
|
||||
const target = self.targetRows();
|
||||
|
||||
// Row-only resizes don't reflow so they're much cheaper (tens of
|
||||
// ns); loop a lot more so the measurement isn't dominated by
|
||||
// process overhead.
|
||||
for (0..2_500_000 * @as(u64, self.opts.loops)) |_| {
|
||||
try self.resizeTerminal(cols, target);
|
||||
try self.resizeTerminal(cols, rows);
|
||||
}
|
||||
}
|
||||
|
||||
fn stepBoth(ptr: *anyopaque) Benchmark.Error!void {
|
||||
const self: *TerminalResize = @ptrCast(@alignCast(ptr));
|
||||
|
||||
const cols = self.opts.@"terminal-cols";
|
||||
const rows = self.opts.@"terminal-rows";
|
||||
const target_cols = self.targetCols();
|
||||
const target_rows = self.targetRows();
|
||||
|
||||
for (0..25 * @as(u64, self.opts.loops)) |_| {
|
||||
try self.resizeTerminal(target_cols, target_rows);
|
||||
try self.resizeTerminal(cols, rows);
|
||||
}
|
||||
}
|
||||
|
||||
test TerminalResize {
|
||||
const testing = std.testing;
|
||||
const alloc = testing.allocator;
|
||||
|
||||
// Small dimensions and fill so this is fast in debug builds while
|
||||
// still exercising real reflow in both directions.
|
||||
const impl: *TerminalResize = try .create(alloc, .{
|
||||
.mode = .cols,
|
||||
.@"terminal-rows" = 10,
|
||||
.@"terminal-cols" = 20,
|
||||
.@"fill-lines" = 50,
|
||||
});
|
||||
defer impl.destroy(alloc);
|
||||
|
||||
const bench = impl.benchmark();
|
||||
_ = try bench.run(.once);
|
||||
}
|
||||
@@ -15,6 +15,7 @@ pub const Action = enum {
|
||||
@"scrollback-compression",
|
||||
@"screen-clone",
|
||||
@"terminal-parser",
|
||||
@"terminal-resize",
|
||||
@"terminal-stream",
|
||||
@"is-symbol",
|
||||
@"osc-parser",
|
||||
@@ -38,6 +39,7 @@ pub const Action = enum {
|
||||
.@"codepoint-width" => @import("CodepointWidth.zig"),
|
||||
.@"grapheme-break" => @import("GraphemeBreak.zig"),
|
||||
.@"terminal-parser" => @import("TerminalParser.zig"),
|
||||
.@"terminal-resize" => @import("TerminalResize.zig"),
|
||||
.@"is-symbol" => @import("IsSymbol.zig"),
|
||||
.@"osc-parser" => @import("OscParser.zig"),
|
||||
};
|
||||
|
||||
@@ -7,6 +7,7 @@ pub const GraphemeBreak = @import("GraphemeBreak.zig");
|
||||
pub const HyperlinkMap = @import("HyperlinkMap.zig");
|
||||
pub const ScreenClone = @import("ScreenClone.zig");
|
||||
pub const TerminalParser = @import("TerminalParser.zig");
|
||||
pub const TerminalResize = @import("TerminalResize.zig");
|
||||
pub const IsSymbol = @import("IsSymbol.zig");
|
||||
pub const PageCompression = @import("PageCompression.zig");
|
||||
pub const ScrollbackCompression = @import("ScrollbackCompression.zig");
|
||||
|
||||
Reference in New Issue
Block a user