aboutsummaryrefslogtreecommitdiffstats
path: root/vendor/libghostty-vt/src/benchmark/TerminalResize.zig
blob: 61b908c83734e399f5da5c73f5bef3174ce862b2 (plain) (blame)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
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);
}