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
|
//! Benchmarks the terminal binary snapshot codecs (`terminal/snapshot`).
//!
//! Encoding and decoding a snapshot are the hot paths for terminal
//! persistence and handoff, so both directions are measured against the
//! same terminal state.
//!
//! ## Input
//!
//! `--data` names a pre-generated VT byte stream (for example from
//! `ghostty-gen ascii`). The stream is fed to a terminal of the requested
//! dimensions during setup, outside the timed region. The resulting screen
//! and scrollback contents are what each step encodes or decodes.
//!
//! ## Modes
//!
//! * `noop` performs no codec work and establishes loop overhead.
//! * `encode` encodes one complete snapshot per loop into a reusable
//! buffer. Buffer growth happens on the first iteration only.
//! * `decode` restores one complete snapshot per loop from bytes prepared
//! during setup. The restored terminal is destroyed inside the step, so
//! this measures the complete restore lifecycle.
//! * `decode-ready` restores only the renderable prefix per loop using the
//! incremental decoder and stops at READY, skipping history. Compared
//! against `decode`, this is the time-to-interactive win of applying
//! history off the critical path.
//! * `report` encodes once and prints total and per-record-tag sizes. It
//! is for inspecting the wire shape, 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 both directions:
//!
//! ghostty-gen ascii | head -c 1000000 > /tmp/ascii.vt
//! hyperfine --warmup 3 \
//! 'ghostty-bench +terminal-snapshot --mode=encode --loops=20 --data=/tmp/ascii.vt' \
//! 'ghostty-bench +terminal-snapshot --mode=decode --loops=20 --data=/tmp/ascii.vt'
const TerminalSnapshot = @This();
const std = @import("std");
const assert = std.debug.assert;
const Allocator = std.mem.Allocator;
const terminalpkg = @import("../terminal/main.zig");
const snapshot = terminalpkg.snapshot;
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-snapshot-bench");
alloc: Allocator,
opts: Options,
terminal: ?Terminal = null,
/// Reused across encode steps so buffer growth is a one-time setup cost.
encoded: std.Io.Writer.Allocating,
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 = .encode,
/// Number of codec operations per benchmark step. Increase this when
/// the state is too small for stable `hyperfine` measurements.
loops: u32 = 1,
/// The size of the terminal. This affects wrapping, page sizes, and
/// the amount of state per encoded page.
@"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 overhead.
noop,
/// Encode one complete snapshot per loop.
encode,
/// Restore one complete snapshot per loop, including its teardown.
decode,
/// Restore one renderable READY prefix per loop, including teardown.
@"decode-ready",
/// Print encoded sizes by record tag. Not a timing benchmark.
report,
};
pub fn create(
alloc: Allocator,
opts: Options,
) !*TerminalSnapshot {
const ptr = try alloc.create(TerminalSnapshot);
errdefer alloc.destroy(ptr);
ptr.* = .{
.alloc = alloc,
.opts = opts,
.encoded = .init(alloc),
};
return ptr;
}
pub fn destroy(self: *TerminalSnapshot, alloc: Allocator) void {
if (self.terminal) |*t| t.deinit(self.alloc);
self.encoded.deinit();
alloc.destroy(self);
}
pub fn benchmark(self: *TerminalSnapshot) Benchmark {
return .init(self, .{
.stepFn = switch (self.opts.mode) {
.noop => stepNoop,
.encode => stepEncode,
.decode => stepDecode,
.@"decode-ready" => stepDecodeReady,
.report => stepReport,
},
.setupFn = setup,
.teardownFn = teardown,
});
}
/// Build the terminal state every mode shares and, for decode mode, the
/// encoded snapshot it restores. All of this is outside the timed region.
fn setup(ptr: *anyopaque) Benchmark.Error!void {
const self: *TerminalSnapshot = @ptrCast(@alignCast(ptr));
self.setupImpl() catch |err| {
log.warn("failed to prepare snapshot benchmark err={}", .{err});
return error.BenchmarkFailed;
};
}
fn setupImpl(self: *TerminalSnapshot) !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]);
}
}
// Decode restores the same bytes every step. Encode mode also uses
// this buffer, in which case this is its one-time growth.
self.encoded.shrinkRetainingCapacity(0);
try snapshot.encode(
self.alloc,
&self.encoded.writer,
terminal,
.{ .continuation = .ground },
);
}
fn teardown(ptr: *anyopaque) void {
const self: *TerminalSnapshot = @ptrCast(@alignCast(ptr));
if (self.terminal) |*t| t.deinit(self.alloc);
self.terminal = null;
self.encoded.shrinkRetainingCapacity(0);
}
fn stepNoop(ptr: *anyopaque) Benchmark.Error!void {
const self: *TerminalSnapshot = @ptrCast(@alignCast(ptr));
for (0..self.opts.loops) |_| {
std.mem.doNotOptimizeAway(self.encoded.written());
}
}
fn stepEncode(ptr: *anyopaque) Benchmark.Error!void {
const self: *TerminalSnapshot = @ptrCast(@alignCast(ptr));
const terminal = &self.terminal.?;
for (0..self.opts.loops) |_| {
self.encoded.shrinkRetainingCapacity(0);
snapshot.encode(
self.alloc,
&self.encoded.writer,
terminal,
.{ .continuation = .ground },
) catch |err| {
log.warn("snapshot encoding failed err={}", .{err});
return error.BenchmarkFailed;
};
std.mem.doNotOptimizeAway(self.encoded.written());
}
}
fn stepDecode(ptr: *anyopaque) Benchmark.Error!void {
const self: *TerminalSnapshot = @ptrCast(@alignCast(ptr));
const bytes = self.encoded.written();
for (0..self.opts.loops) |_| {
var reader: std.Io.Reader = .fixed(bytes);
var decoded = snapshot.decode(
self.alloc,
global.io(),
&reader,
.{ .max_continuation_bytes = 1024 * 1024 },
) catch |err| {
log.warn("snapshot decoding failed err={}", .{err});
return error.BenchmarkFailed;
};
std.mem.doNotOptimizeAway(&decoded);
decoded.deinit(self.alloc);
}
}
/// The renderable-prefix half of decode: everything through READY, leaving
/// all history unread. The gap between this and `stepDecode` is the latency
/// class the incremental decoder removes from time-to-interactive.
fn stepDecodeReady(ptr: *anyopaque) Benchmark.Error!void {
const self: *TerminalSnapshot = @ptrCast(@alignCast(ptr));
const bytes = self.encoded.written();
for (0..self.opts.loops) |_| {
var reader: std.Io.Reader = .fixed(bytes);
var decoder: snapshot.Decoder = .init(&reader);
var decoded = decoder.ready(
self.alloc,
global.io(),
.{ .max_continuation_bytes = 1024 * 1024 },
) catch |err| {
log.warn("snapshot READY decoding failed err={}", .{err});
return error.BenchmarkFailed;
};
std.mem.doNotOptimizeAway(&decoded);
decoded.deinit(self.alloc);
}
}
/// Print the encoded size grouped by record tag. This shares the encoder
/// with encode mode but deliberately makes no timing claims.
fn stepReport(ptr: *anyopaque) Benchmark.Error!void {
const self: *TerminalSnapshot = @ptrCast(@alignCast(ptr));
const bytes = self.encoded.written();
var payload_totals = std.enums.EnumArray(
snapshot.record.Tag,
u64,
).initFill(0);
var record_counts = std.enums.EnumArray(
snapshot.record.Tag,
u64,
).initFill(0);
var record_total: u64 = 0;
var offset: usize = snapshot.envelope.encoded_len;
while (offset + snapshot.record.Header.len <= bytes.len) {
const tag_raw = std.mem.readInt(u16, bytes[offset..][0..2], .little);
const payload_len = std.mem.readInt(
u32,
bytes[offset + 2 ..][0..4],
.little,
);
const tag = std.enums.fromInt(snapshot.record.Tag, tag_raw) orelse {
log.warn("unknown record tag {}", .{tag_raw});
return error.BenchmarkFailed;
};
payload_totals.getPtr(tag).* += payload_len;
record_counts.getPtr(tag).* += 1;
record_total += 1;
offset += snapshot.record.Header.len + payload_len;
}
var it = payload_totals.iterator();
while (it.next()) |entry| {
std.debug.print("terminal-snapshot tag={s} records={d} payload={d}\n", .{
@tagName(entry.key),
record_counts.get(entry.key),
entry.value.*,
});
}
std.debug.print(
"terminal-snapshot total records={d} framing={d} encoded={d}\n",
.{
record_total,
record_total * snapshot.record.Header.len +
snapshot.envelope.encoded_len,
bytes.len,
},
);
}
test TerminalSnapshot {
const testing = std.testing;
const impl: *TerminalSnapshot = try .create(testing.allocator, .{});
defer impl.destroy(testing.allocator);
const bench = impl.benchmark();
_ = try bench.run(.once);
}
test "TerminalSnapshot decode round trip" {
const testing = std.testing;
const impl: *TerminalSnapshot = try .create(testing.allocator, .{
.mode = .decode,
.@"terminal-rows" = 4,
.@"terminal-cols" = 8,
});
defer impl.destroy(testing.allocator);
const bench = impl.benchmark();
_ = try bench.run(.once);
}
test "TerminalSnapshot decode READY prefix" {
const testing = std.testing;
const impl: *TerminalSnapshot = try .create(testing.allocator, .{
.mode = .@"decode-ready",
.@"terminal-rows" = 4,
.@"terminal-cols" = 8,
});
defer impl.destroy(testing.allocator);
const bench = impl.benchmark();
_ = try bench.run(.once);
}
|