diff options
| author | Can Celik <ogulcancelik@gmail.com> | 2026-09-10 21:51:57 +0300 |
|---|---|---|
| committer | GitHub <noreply@github.com> | 2026-09-10 21:51:57 +0300 |
| commit | 425c86179791cd32e2d4ce0ae7f269940b4b0663 (patch) | |
| tree | e52c4383a6d69216cbe2a32638bf90841d7e2189 /vendor/libghostty-vt/src/benchmark/TerminalSnapshot.zig | |
| parent | 90e947a6e26e02f10ec7f1694c4eb5b78a86406b (diff) | |
chore: upgrade libghostty and preserve terminal behavior (#3906)
Diffstat (limited to 'vendor/libghostty-vt/src/benchmark/TerminalSnapshot.zig')
| -rw-r--r-- | vendor/libghostty-vt/src/benchmark/TerminalSnapshot.zig | 350 |
1 files changed, 350 insertions, 0 deletions
diff --git a/vendor/libghostty-vt/src/benchmark/TerminalSnapshot.zig b/vendor/libghostty-vt/src/benchmark/TerminalSnapshot.zig new file mode 100644 index 00000000..aea5e1f9 --- /dev/null +++ b/vendor/libghostty-vt/src/benchmark/TerminalSnapshot.zig @@ -0,0 +1,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); +} |
