aboutsummaryrefslogtreecommitdiffstats
path: root/vendor/libghostty-vt/src/benchmark/ScrollbackCompression.zig
blob: d971c551d1d85119d1ba2b3a60a7f8860f87b36b (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
//! Benchmarks cold-history compression and restoration on pages owned by a
//! live terminal.
//!
//! Unlike `page-compression`, which measures the standalone codec against raw
//! byte chunks, this benchmark first parses a VT corpus into a real `Terminal`.
//! Its timed operations therefore include the PageList state transition,
//! exact-sized encoded allocation, retained-mapping reclamation, and transparent
//! restoration through `PageList.Node.page`.
//!
//! ## Input
//!
//! `--data` names a pre-generated VT byte stream. Parsing happens during setup
//! and is not part of the benchmark's timed region. Use the same saved corpus,
//! terminal dimensions, and scrollback limit when comparing revisions. In
//! particular, do not pipe a generator into this benchmark: generation and
//! pipe scheduling add noise which can overwhelm the operation being measured.
//!
//! The benchmark always operates on the primary screen's PageList. This keeps
//! the result focused on scrollback even if malformed or incomplete input
//! leaves the terminal in its alternate screen at end of file. `--max-scrollback`
//! is expressed in bytes and defaults to 10 MB.
//!
//! ## Modes
//!
//! * `noop` parses the corpus but performs no timed PageList operation. This is
//!   the common process and setup baseline for the other modes.
//! * `compress` times one complete `compress` invocation.
//! * `incremental` reaches the same final representation through
//!   `compress(.drain)`. It drains the candidate-bounded steps and final
//!   no-work verification pass in the timed region, making cursor and repeated
//!   traversal overhead directly comparable with `compress`.
//! * `restore` compresses cold history during setup, outside the timed region,
//!   then visits every fully historical node through `Node.page`. That public
//!   content-access boundary transparently restores compressed nodes.
//! * `report` performs one compression pass and prints compressed page count,
//!   encoded ratio, and estimated resident-byte savings. It is intended for
//!   inspecting a corpus rather than timing comparisons.
//!
//! A fully historical page is a node strictly before the node containing the
//! top of the active area. The boundary node is deliberately excluded because
//! it can contain both history and active rows. Pages intersecting the current
//! viewport are also excluded so visible contents remain resident. Normal
//! terminal execution compresses eligible pages incrementally after activity
//! becomes idle. The benchmark invokes PageList operations directly so its
//! timed regions exclude the production scheduler's idle delay and
//! renderer-thread coordination.
//!
//! ## Examples
//!
//! Build the benchmark in ReleaseFast mode:
//!
//!     zig build -Demit-bench -Doptimize=ReleaseFast -Demit-macos-app=false
//!
//! Inspect the memory reduction for a saved VT corpus:
//!
//!     ghostty-bench +scrollback-compression --mode=report \
//!       --data=/tmp/scrollback.vt --terminal-cols=120 --terminal-rows=80
//!
//! Compare PageList compression and restoration cost. Setup still contributes
//! to full process time, so use a sufficiently large corpus and compare against
//! `noop` with identical arguments:
//!
//!     hyperfine --warmup 3 \
//!       'ghostty-bench +scrollback-compression --mode=noop --data=/tmp/scrollback.vt' \
//!       'ghostty-bench +scrollback-compression --mode=compress --data=/tmp/scrollback.vt' \
//!       'ghostty-bench +scrollback-compression --mode=incremental --data=/tmp/scrollback.vt' \
//!       'ghostty-bench +scrollback-compression --mode=restore --data=/tmp/scrollback.vt'
const ScrollbackCompression = @This();

const std = @import("std");
const Allocator = std.mem.Allocator;
const terminalpkg = @import("../terminal/main.zig");
const Benchmark = @import("Benchmark.zig");
const options = @import("options.zig");
const PageList = terminalpkg.PageList;
const Terminal = terminalpkg.Terminal;
const global = @import("../global.zig");

const log = std.log.scoped(.@"scrollback-compression-bench");

opts: Options,
terminal: Terminal,

pub const Options = struct {
    /// Set by the shared CLI parser so the `data` string remains valid for the
    /// lifetime of the benchmark implementation.
    _arena: ?std.heap.ArenaAllocator = null,

    /// Select the PageList operation performed inside the timed benchmark.
    mode: Mode = .compress,

    /// Dimensions used to construct the terminal which consumes the corpus.
    /// These affect wrapping and therefore the number and contents of pages.
    @"terminal-rows": u16 = 80,
    @"terminal-cols": u16 = 120,

    /// Maximum primary-screen scrollback allocation in bytes. PageList rounds
    /// this as required by its page allocation policy.
    @"max-scrollback": usize = 10_000_000,

    /// Pre-generated VT corpus. `-` reads stdin, although a regular file is
    /// strongly recommended so comparisons can reuse identical input bytes.
    /// When unset, every mode operates on the initial empty terminal.
    data: ?[]const u8 = null,

    pub fn deinit(self: *Options) void {
        if (self._arena) |arena| arena.deinit();
        self.* = undefined;
    }
};

pub const Mode = enum {
    /// Establish process and setup overhead without a PageList operation.
    noop,

    /// Compress every eligible fully historical page once.
    compress,

    /// Compress every eligible page through bounded resumable steps.
    incremental,

    /// Restore pages compressed outside the timed region.
    restore,

    /// Compress once and print aggregate memory statistics.
    report,
};

pub fn create(
    alloc: Allocator,
    opts: Options,
) !*ScrollbackCompression {
    const ptr = try alloc.create(ScrollbackCompression);
    errdefer alloc.destroy(ptr);

    ptr.* = .{
        .opts = opts,
        .terminal = try .init(global.io(), alloc, .{
            .rows = opts.@"terminal-rows",
            .cols = opts.@"terminal-cols",
            .max_scrollback_bytes = opts.@"max-scrollback",
        }),
    };
    return ptr;
}

pub fn destroy(self: *ScrollbackCompression, alloc: Allocator) void {
    self.terminal.deinit(alloc);
    alloc.destroy(self);
}

pub fn benchmark(self: *ScrollbackCompression) Benchmark {
    return .init(self, .{
        .stepFn = switch (self.opts.mode) {
            .noop => stepNoop,
            .compress => stepCompress,
            .incremental => stepIncremental,
            .restore => stepRestore,
            .report => stepReport,
        },
        .setupFn = setup,
    });
}

/// Reset the terminal and consume the complete VT corpus before timing starts.
/// Restore mode also prepares its compressed representation here so its timed
/// step contains decoding and mapping writes, but not encoding.
fn setup(ptr: *anyopaque) Benchmark.Error!void {
    const self: *ScrollbackCompression = @ptrCast(@alignCast(ptr));
    self.terminal.fullReset();

    self.loadCorpus() catch |err| {
        log.warn("failed to prepare scrollback compression benchmark err={}", .{err});
        return error.BenchmarkFailed;
    };

    if (self.opts.mode == .restore) {
        _ = self.pages().compress(.full);
    }
}

/// Feed the corpus in the same 64 KiB chunks used by the real IO thread and
/// terminal-stream benchmark. Parser and file IO costs remain in setup.
fn loadCorpus(self: *ScrollbackCompression) !void {
    const data_file = try options.dataFile(self.opts.data) orelse return;
    defer data_file.close(global.io());

    var stream = self.terminal.vtStream();
    defer stream.deinit();

    var read_buf: [64 * 1024]u8 align(std.atomic.cache_line) = undefined;
    var file_reader = data_file.reader(global.io(), &read_buf);
    const reader = &file_reader.interface;

    var buf: [64 * 1024]u8 = undefined;
    while (true) {
        const n = reader.readSliceShort(&buf) catch
            return file_reader.err orelse error.ReadFailed;
        if (n == 0) return;
        stream.nextSlice(buf[0..n]);
    }
}

/// Return the primary screen because it is the terminal screen which owns
/// scrollback. A corpus ending in the alternate screen must not turn this into
/// an alternate-screen allocation benchmark.
fn pages(self: *ScrollbackCompression) *PageList {
    return &self.terminal.screens.get(.primary).?.pages;
}

fn stepNoop(ptr: *anyopaque) Benchmark.Error!void {
    const self: *ScrollbackCompression = @ptrCast(@alignCast(ptr));
    std.mem.doNotOptimizeAway(&self.terminal);
}

fn stepCompress(ptr: *anyopaque) Benchmark.Error!void {
    const self: *ScrollbackCompression = @ptrCast(@alignCast(ptr));
    _ = self.pages().compress(.full);
    std.mem.doNotOptimizeAway(&self.terminal);
}

fn stepIncremental(ptr: *anyopaque) Benchmark.Error!void {
    const self: *ScrollbackCompression = @ptrCast(@alignCast(ptr));
    _ = self.pages().compress(.drain);
    std.mem.doNotOptimizeAway(&self.terminal);
}

fn stepRestore(ptr: *anyopaque) Benchmark.Error!void {
    const self: *ScrollbackCompression = @ptrCast(@alignCast(ptr));
    std.mem.doNotOptimizeAway(self.visitColdPages());
}

/// Visit all nodes which were eligible for the setup compression pass.
///
/// `page` is intentionally used instead of inspecting the union payload so the
/// benchmark follows the same transparent restoration boundary as consumers.
/// Compressible nodes restore here; resident candidates which failed the
/// opportunistic pass are harmlessly visited through the same boundary.
fn visitColdPages(self: *ScrollbackCompression) usize {
    const page_list = self.pages();
    const active_node = page_list.getTopLeft(.active).node;
    var visited: usize = 0;
    var node_ = page_list.pages.first;
    while (node_) |node| : (node_ = node.next) {
        if (node == active_node) break;
        std.mem.doNotOptimizeAway(node.page());
        visited += 1;
    }
    return visited;
}

fn stepReport(ptr: *anyopaque) Benchmark.Error!void {
    const self: *ScrollbackCompression = @ptrCast(@alignCast(ptr));
    _ = self.pages().compress(.full);
    const memory = self.pages().memoryStats();

    std.debug.print(
        "scrollback-compression compressed={d} raw={d} " ++
            "encoded={d} ratio={d:.2}% savings={d}\n",
        .{
            memory.compressed_pages,
            memory.decommitted_raw_bytes,
            memory.encoded_bytes,
            percentage(
                memory.encoded_bytes,
                memory.decommitted_raw_bytes,
            ),
            memory.estimatedSavings(),
        },
    );
}

fn percentage(part: usize, whole: usize) f64 {
    if (whole == 0) return 0;
    return @as(f64, @floatFromInt(part)) * 100 /
        @as(f64, @floatFromInt(whole));
}

test ScrollbackCompression {
    const testing = std.testing;
    const impl: *ScrollbackCompression = try .create(testing.allocator, .{});
    defer impl.destroy(testing.allocator);

    const bench = impl.benchmark();
    _ = try bench.run(.once);
}

test "ScrollbackCompression restores cold terminal pages" {
    const testing = std.testing;
    const impl: *ScrollbackCompression = try .create(testing.allocator, .{
        .mode = .restore,
        .@"terminal-rows" = 4,
        // Standard-width pages hold 215 rows. Keeping that row capacity here
        // makes this test corpus small while still producing cold history.
        .@"terminal-cols" = 215,
        .@"max-scrollback" = 1_000_000,
    });
    defer impl.destroy(testing.allocator);

    var stream = impl.terminal.vtStream();
    defer stream.deinit();
    for (0..256) |_| stream.nextSlice("aaaa\r\n");

    _ = impl.pages().compress(.full);
    const compressed = impl.pages().memoryStats();
    try testing.expect(compressed.compressed_pages > 0);
    try testing.expect(impl.visitColdPages() >= compressed.compressed_pages);

    // Restored historical pages are resident and therefore eligible for a
    // later explicit pass. This also verifies that the benchmark traversal
    // went through Node.page rather than merely inspecting page metadata.
    _ = impl.pages().compress(.full);
    const recompressed = impl.pages().memoryStats();
    try testing.expectEqual(
        compressed.compressed_pages,
        recompressed.compressed_pages,
    );
}

test "ScrollbackCompression drains incremental compression steps" {
    const testing = std.testing;
    const impl: *ScrollbackCompression = try .create(testing.allocator, .{
        .mode = .incremental,
        .@"terminal-rows" = 4,
        .@"terminal-cols" = 215,
        .@"max-scrollback" = 1_000_000,
    });
    defer impl.destroy(testing.allocator);

    var stream = impl.terminal.vtStream();
    defer stream.deinit();
    for (0..256) |_| stream.nextSlice("aaaa\r\n");

    _ = impl.pages().compress(.drain);
    const incremental = impl.pages().memoryStats();
    try testing.expect(incremental.compressed_pages > 0);

    // Restore the same pages and compare against the monolithic operation.
    // Both paths should produce the same final storage representation.
    _ = impl.visitColdPages();
    _ = impl.pages().compress(.full);
    const monolithic = impl.pages().memoryStats();
    try testing.expectEqual(monolithic, incremental);
}