aboutsummaryrefslogtreecommitdiff
path: root/vendor/libghostty-vt/src/benchmark
diff options
context:
space:
mode:
authorOgulcan Celik <ogulcancelik@gmail.com>2026-07-16 23:28:05 +0300
committerOgulcan Celik <ogulcancelik@gmail.com>2026-07-16 23:29:28 +0300
commit4a3302d19a2baa3a33e910e0088c24e780905b90 (patch)
treedcaf76fae1c82102d933c9225b17ca7ac5d3a847 /vendor/libghostty-vt/src/benchmark
parent550e9f8cc085f428429f5cf2da0bb2dba897e8b8 (diff)
chore: update vendored libghostty-vt
refs #276
Diffstat (limited to 'vendor/libghostty-vt/src/benchmark')
-rw-r--r--vendor/libghostty-vt/src/benchmark/ApcParser.zig143
-rw-r--r--vendor/libghostty-vt/src/benchmark/HyperlinkMap.zig155
-rw-r--r--vendor/libghostty-vt/src/benchmark/PageCompression.zig519
-rw-r--r--vendor/libghostty-vt/src/benchmark/ScreenClone.zig113
-rw-r--r--vendor/libghostty-vt/src/benchmark/ScrollbackCompression.zig343
-rw-r--r--vendor/libghostty-vt/src/benchmark/TerminalStream.zig49
-rw-r--r--vendor/libghostty-vt/src/benchmark/cli.zig8
-rw-r--r--vendor/libghostty-vt/src/benchmark/main.zig3
8 files changed, 1297 insertions, 36 deletions
diff --git a/vendor/libghostty-vt/src/benchmark/ApcParser.zig b/vendor/libghostty-vt/src/benchmark/ApcParser.zig
new file mode 100644
index 00000000..73b7c57a
--- /dev/null
+++ b/vendor/libghostty-vt/src/benchmark/ApcParser.zig
@@ -0,0 +1,143 @@
+//! This benchmark tests the throughput of APC sequence parsing
+//! through the terminal stream: VT state machine dispatch, APC
+//! protocol identification, and the protocol command parsers
+//! (e.g. Kitty graphics). Completed commands are parsed and then
+//! discarded; command execution (image decoding, storage) is not
+//! included so this isolates the parsing path.
+const ApcParser = @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 log = std.log.scoped(.@"apc-parser-bench");
+
+opts: Options,
+stream: Stream,
+
+/// The file, opened in the setup function.
+data_f: ?std.fs.File = null,
+
+pub const Options = struct {
+ /// The data to read as a filepath. If this is "-" then
+ /// we will read stdin. If this is unset, then we will
+ /// do nothing (benchmark is a noop). It'd be more unixy to
+ /// use stdin by default but I find that a hanging CLI command
+ /// with no interaction is a bit annoying.
+ data: ?[]const u8 = null,
+};
+
+const Stream = terminalpkg.Stream(Handler);
+
+/// A stream handler that only processes APC actions, parsing and
+/// immediately discarding completed commands.
+const Handler = struct {
+ alloc: Allocator,
+ apc: terminalpkg.apc.Handler = .{},
+
+ pub fn deinit(self: *Handler) void {
+ self.apc.deinit();
+ }
+
+ pub fn vt(
+ self: *Handler,
+ comptime action: Stream.Action.Tag,
+ value: Stream.Action.Value(action),
+ ) void {
+ switch (action) {
+ .apc_start => self.apc.start(),
+ .apc_put => self.apc.feed(self.alloc, value),
+ .apc_put_slice => self.apc.feedSlice(self.alloc, value.bytes),
+ .apc_end => if (self.apc.end()) |cmd| {
+ var c = cmd;
+ std.mem.doNotOptimizeAway(&c);
+ c.deinit(self.alloc);
+ },
+ else => {},
+ }
+ }
+};
+
+/// Create a new APC parser benchmark for the given arguments.
+pub fn create(
+ alloc: Allocator,
+ opts: Options,
+) !*ApcParser {
+ const ptr = try alloc.create(ApcParser);
+ errdefer alloc.destroy(ptr);
+ ptr.* = .{
+ .opts = opts,
+ .stream = .init(.{ .alloc = alloc }),
+ };
+ return ptr;
+}
+
+pub fn destroy(self: *ApcParser, alloc: Allocator) void {
+ self.stream.deinit();
+ alloc.destroy(self);
+}
+
+pub fn benchmark(self: *ApcParser) Benchmark {
+ return .init(self, .{
+ .stepFn = step,
+ .setupFn = setup,
+ .teardownFn = teardown,
+ });
+}
+
+fn setup(ptr: *anyopaque) Benchmark.Error!void {
+ const self: *ApcParser = @ptrCast(@alignCast(ptr));
+
+ // Open our data file to prepare for reading. We can do more
+ // validation here eventually.
+ assert(self.data_f == null);
+ self.data_f = options.dataFile(self.opts.data) catch |err| {
+ log.warn("error opening data file err={}", .{err});
+ return error.BenchmarkFailed;
+ };
+}
+
+fn teardown(ptr: *anyopaque) void {
+ const self: *ApcParser = @ptrCast(@alignCast(ptr));
+ if (self.data_f) |f| {
+ f.close();
+ self.data_f = null;
+ }
+}
+
+fn step(ptr: *anyopaque) Benchmark.Error!void {
+ const self: *ApcParser = @ptrCast(@alignCast(ptr));
+
+ const f = self.data_f orelse return;
+
+ var read_buf: [64 * 1024]u8 align(std.atomic.cache_line) = undefined;
+ var f_reader = f.reader(&read_buf);
+ const r = &f_reader.interface;
+
+ // This buffer size matches the read buffer size used by the
+ // real IO thread (see termio Exec.zig buffer_capacity) so that
+ // the benchmark exercises the stream with realistic chunk sizes.
+ var buf: [64 * 1024]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
+ self.stream.nextSlice(buf[0..n]);
+ }
+}
+
+test ApcParser {
+ const testing = std.testing;
+ const alloc = testing.allocator;
+
+ const impl: *ApcParser = try .create(alloc, .{});
+ defer impl.destroy(alloc);
+
+ const bench = impl.benchmark();
+ _ = try bench.run(.once);
+}
diff --git a/vendor/libghostty-vt/src/benchmark/HyperlinkMap.zig b/vendor/libghostty-vt/src/benchmark/HyperlinkMap.zig
new file mode 100644
index 00000000..166c3d4f
--- /dev/null
+++ b/vendor/libghostty-vt/src/benchmark/HyperlinkMap.zig
@@ -0,0 +1,155 @@
+//! Benchmark hyperlink cell-map lookups and remove/insert churn.
+//!
+//! Hyperlink cells are stored in a fixed-capacity, open-addressed hash map.
+//! The `churn` mode models terminal output that repeatedly replaces cells in
+//! a page whose hyperlink map is already close to full. This is particularly
+//! useful for catching probe-length cliffs at high load factors.
+const HyperlinkMap = @This();
+
+const std = @import("std");
+const Allocator = std.mem.Allocator;
+const terminal = @import("../terminal/main.zig");
+const hyperlink = @import("../terminal/hyperlink.zig");
+const Benchmark = @import("Benchmark.zig");
+
+const log = std.log.scoped(.@"hyperlink-map-bench");
+
+opts: Options,
+page: terminal.Page,
+link_id: hyperlink.Id,
+entry_count: usize,
+
+pub const Options = struct {
+ /// Requested hyperlink working-set size. Must be a power of two and at
+ /// least 16. The map may reserve additional probe headroom.
+ entries: u16 = 4096,
+
+ /// Percentage of the map populated before the timed operation.
+ /// Values above 100 are treated as 100.
+ @"load-percent": u8 = 100,
+
+ /// Number of complete passes over the populated cells per step.
+ loops: u16 = 1,
+
+ /// Operation to perform in the timed region.
+ mode: Mode = .churn,
+};
+
+pub const Mode = enum {
+ /// Look up every populated hyperlink cell.
+ lookup,
+
+ /// Remove and reinsert every populated hyperlink cell.
+ churn,
+};
+
+pub fn create(alloc: Allocator, opts: Options) !*HyperlinkMap {
+ if (opts.entries < 16 or !std.math.isPowerOfTwo(opts.entries)) {
+ log.err("entries must be a power of two greater than or equal to 16", .{});
+ return error.InvalidEntries;
+ }
+
+ const ptr = try alloc.create(HyperlinkMap);
+ errdefer alloc.destroy(ptr);
+
+ // The page requests one map slot per `hyperlink_cell_multiplier` set
+ // entries. Keep this relationship explicit so `entries` is the working
+ // set size under test regardless of the map's reserved probe headroom.
+ const set_entries = opts.entries / 16;
+ var page = try terminal.Page.init(.{
+ .cols = opts.entries,
+ .rows = 1,
+ .hyperlink_bytes = @intCast(
+ @as(usize, set_entries) * @sizeOf(hyperlink.Set.Item),
+ ),
+ });
+ errdefer page.deinit();
+
+ if (page.hyperlinkCapacity() < opts.entries) {
+ log.err("insufficient map capacity expected_at_least={} actual={}", .{
+ opts.entries,
+ page.hyperlinkCapacity(),
+ });
+ return error.UnexpectedCapacity;
+ }
+
+ const link_id = try page.insertHyperlink(.{
+ .id = .{ .implicit = 1 },
+ .uri = "https://example.com/benchmark",
+ });
+
+ const load = @min(opts.@"load-percent", 100);
+ const entry_count = @max(
+ 1,
+ @divFloor(@as(usize, opts.entries) * load, 100),
+ );
+ for (0..entry_count) |x| {
+ const rac = page.getRowAndCell(x, 0);
+ page.hyperlink_set.use(page.memory, link_id);
+ try page.setHyperlink(rac.row, rac.cell, link_id);
+ }
+
+ ptr.* = .{
+ .opts = opts,
+ .page = page,
+ .link_id = link_id,
+ .entry_count = entry_count,
+ };
+ return ptr;
+}
+
+pub fn destroy(self: *HyperlinkMap, alloc: Allocator) void {
+ self.page.deinit();
+ alloc.destroy(self);
+}
+
+pub fn benchmark(self: *HyperlinkMap) Benchmark {
+ return .init(self, .{
+ .stepFn = switch (self.opts.mode) {
+ .lookup => stepLookup,
+ .churn => stepChurn,
+ },
+ });
+}
+
+fn stepLookup(ptr: *anyopaque) Benchmark.Error!void {
+ const self: *HyperlinkMap = @ptrCast(@alignCast(ptr));
+
+ for (0..self.opts.loops) |_| {
+ for (0..self.entry_count) |x| {
+ const cell = self.page.getRowAndCell(x, 0).cell;
+ const id = self.page.lookupHyperlink(cell) orelse
+ return error.BenchmarkFailed;
+ std.mem.doNotOptimizeAway(id);
+ }
+ }
+}
+
+fn stepChurn(ptr: *anyopaque) Benchmark.Error!void {
+ const self: *HyperlinkMap = @ptrCast(@alignCast(ptr));
+
+ for (0..self.opts.loops) |_| {
+ for (0..self.entry_count) |x| {
+ const rac = self.page.getRowAndCell(x, 0);
+ self.page.clearHyperlink(rac.cell);
+ self.page.hyperlink_set.use(self.page.memory, self.link_id);
+ self.page.setHyperlink(rac.row, rac.cell, self.link_id) catch
+ return error.BenchmarkFailed;
+ }
+ }
+}
+
+test HyperlinkMap {
+ const alloc = std.testing.allocator;
+
+ inline for (.{ Mode.lookup, Mode.churn }) |mode| {
+ const impl = try HyperlinkMap.create(alloc, .{
+ .entries = 64,
+ .mode = mode,
+ });
+ defer impl.destroy(alloc);
+
+ const bench = impl.benchmark();
+ _ = try bench.run(.once);
+ }
+}
diff --git a/vendor/libghostty-vt/src/benchmark/PageCompression.zig b/vendor/libghostty-vt/src/benchmark/PageCompression.zig
new file mode 100644
index 00000000..21153659
--- /dev/null
+++ b/vendor/libghostty-vt/src/benchmark/PageCompression.zig
@@ -0,0 +1,519 @@
+//! Benchmarks raw LZ4 compression and decompression on page-sized byte
+//! buffers.
+//!
+//! This benchmark is intentionally independent of terminal page ownership and
+//! lifecycle. It treats its input as opaque bytes and calls only the standalone
+//! LZ4 block codec. In particular, it does not compress pages owned by a live
+//! terminal or measure the scheduling and state transitions used by automatic
+//! scrollback compression.
+//!
+//! ## Input
+//!
+//! `--data` names a pre-generated raw byte corpus. The corpus is divided into
+//! `--page-size` byte chunks, with a final short chunk retained when the file
+//! size is not an exact multiple. The default page size is 400 KiB, matching a
+//! standard terminal page in ReleaseFast builds on the current target.
+//!
+//! A raw dump of actual page backing memory is the most representative input:
+//! it includes cells, rows, styles, graphemes, hyperlinks, allocator metadata,
+//! and unused capacity exactly as the codec would see them. Keep such corpora
+//! outside the repository and reuse the same file when comparing branches.
+//! Arbitrary files are accepted too, but their compression ratios should not be
+//! interpreted as terminal scrollback ratios.
+//!
+//! ## Modes
+//!
+//! * `noop` walks the input chunks without invoking the codec. This measures the
+//! benchmark loop's minimum overhead.
+//! * `compress` compresses every input chunk into a reusable output buffer.
+//! * `store` additionally copies each useful result into an exact-sized
+//! allocation. It retains the most recent `--retained-pages` blocks so that
+//! allocation, eviction, and allocator reuse resemble bounded scrollback.
+//! * `decompress` prepares compressed blocks during setup, then decompresses
+//! every block into a reusable output buffer.
+//! * `report` compresses each chunk once and prints raw and encoded sizes. It is
+//! for inspecting ratios, not timing comparisons.
+//!
+//! Dataset loading, output allocation, and preparation of blocks for
+//! decompression happen in `setup` and are outside `Benchmark`'s timed region.
+//! The `compress` and `decompress` steps perform no allocation. `store`
+//! deliberately performs encoded-block allocations and frees inside the timed
+//! step; only its reusable workspace and ring metadata are prepared in setup.
+//! `hyperfine` still measures full process lifetime, so use `--loops` to
+//! amortize setup and teardown when comparing small corpora.
+//!
+//! ## Examples
+//!
+//! Build benchmarks in ReleaseFast mode:
+//!
+//! zig build -Demit-bench -Doptimize=ReleaseFast -Demit-macos-app=false
+//!
+//! Inspect the compression ratio of a page corpus:
+//!
+//! ghostty-bench +page-compression --mode=report --data=/tmp/pages.raw
+//!
+//! Measure the cost of exact encoded storage relative to the codec alone:
+//!
+//! hyperfine --warmup 3 \
+//! 'ghostty-bench +page-compression --mode=compress --loops=100 --data=/tmp/pages.raw' \
+//! 'ghostty-bench +page-compression --mode=store --loops=100 --data=/tmp/pages.raw'
+const PageCompression = @This();
+
+const std = @import("std");
+const assert = std.debug.assert;
+const Allocator = std.mem.Allocator;
+const Benchmark = @import("Benchmark.zig");
+const options = @import("options.zig");
+const compress = @import("../terminal/compress.zig");
+const CompressedPage = compress.Page;
+const lz4 = compress.lz4;
+
+const log = std.log.scoped(.@"page-compression-bench");
+
+/// Prevent a malformed or accidentally enormous corpus from consuming
+/// unbounded memory during benchmark setup.
+const max_data_size = 64 * 1024 * 1024;
+
+alloc: Allocator,
+opts: Options,
+
+/// Complete contents of the input corpus. Individual pages are slices into
+/// this allocation, so it remains alive until teardown.
+data: []u8 = &.{},
+
+/// Compressed blocks prepared during setup for `decompress` mode.
+encoded: std.ArrayList(Encoded) = .empty,
+
+/// Exact encoded allocations retained by `store` mode. Null entries have not
+/// been reached yet; once full, `stored_next` identifies the oldest block.
+stored: []?[]u8 = &.{},
+stored_next: usize = 0,
+
+/// Reused by compression and report modes. Its length is the compression
+/// bound of the largest input chunk.
+compression_output: []u8 = &.{},
+
+/// Reused by decompression mode. Its length is at least one input chunk.
+decompression_output: []u8 = &.{},
+
+/// Fixed 16 KiB scratch table required by the compressor.
+table: lz4.HashTable = undefined,
+
+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 = .compress,
+
+ /// Repeat the complete corpus this many times per benchmark step. Increase
+ /// this when the corpus is too small for stable `hyperfine` measurements.
+ loops: u32 = 1,
+
+ /// Number of bytes treated as one independent LZ4 block. Real page dumps
+ /// should use the exact backing-memory size of the pages being measured.
+ @"page-size": usize = 400 * 1024,
+
+ /// Number of exact encoded allocations retained by `store` mode before
+ /// the oldest allocation is freed and replaced. The default approximates
+ /// a 10 MB scrollback composed of standard 400 KiB terminal pages.
+ @"retained-pages": usize = 25,
+
+ /// Pre-generated input corpus. `-` reads stdin, although a regular file is
+ /// recommended so identical bytes can be reused across benchmark runs.
+ /// When unset, all modes are no-ops.
+ data: ?[]const u8 = null,
+
+ pub fn deinit(self: *Options) void {
+ if (self._arena) |arena| arena.deinit();
+ self.* = undefined;
+ }
+};
+
+pub const Mode = enum {
+ /// Walk page boundaries and establish the benchmark loop overhead.
+ noop,
+
+ /// Compress each raw page into a reusable compression-bound buffer.
+ compress,
+
+ /// Compress and retain exact-sized encoded blocks in a bounded ring.
+ store,
+
+ /// Decompress blocks prepared before the timed region.
+ decompress,
+
+ /// Print per-page and aggregate encoded sizes. Not a timing benchmark.
+ report,
+};
+
+const Encoded = struct {
+ /// Exact compressed block bytes.
+ bytes: []u8,
+
+ /// Exact output length expected by the raw block decoder.
+ raw_len: usize,
+};
+
+/// Allocate benchmark state. Input data is intentionally loaded later by
+/// `setup` so construction is cheap and follows the other benchmarks.
+pub fn create(
+ alloc: Allocator,
+ opts: Options,
+) !*PageCompression {
+ const ptr = try alloc.create(PageCompression);
+ ptr.* = .{
+ .alloc = alloc,
+ .opts = opts,
+ };
+ return ptr;
+}
+
+/// Release allocations retained across benchmark steps.
+pub fn destroy(self: *PageCompression, alloc: Allocator) void {
+ self.clearPreparedData();
+ alloc.destroy(self);
+}
+
+/// Select one operation for the benchmark harness to time.
+pub fn benchmark(self: *PageCompression) Benchmark {
+ return .init(self, .{
+ .stepFn = switch (self.opts.mode) {
+ .noop => stepNoop,
+ .compress => stepCompress,
+ .store => stepStore,
+ .decompress => stepDecompress,
+ .report => stepReport,
+ },
+ .setupFn = setup,
+ .teardownFn = teardown,
+ });
+}
+
+/// Load and partition the input corpus. For decompression mode this also
+/// creates the encoded blocks, keeping compression outside the timed region.
+fn setup(ptr: *anyopaque) Benchmark.Error!void {
+ const self: *PageCompression = @ptrCast(@alignCast(ptr));
+ assert(self.data.len == 0);
+ assert(self.encoded.items.len == 0);
+
+ self.setupData() catch |err| {
+ log.warn("failed to prepare page compression benchmark err={}", .{err});
+ return error.BenchmarkFailed;
+ };
+}
+
+fn setupData(self: *PageCompression) !void {
+ if (self.opts.loops == 0) return error.InvalidLoops;
+ if (self.opts.@"page-size" == 0) return error.InvalidPageSize;
+ if (self.opts.mode == .store and self.opts.@"retained-pages" == 0)
+ return error.InvalidRetainedPages;
+
+ const data_file = try options.dataFile(self.opts.data) orelse return;
+ defer data_file.close();
+
+ self.data = try data_file.readToEndAlloc(self.alloc, max_data_size);
+ errdefer {
+ self.alloc.free(self.data);
+ self.data = &.{};
+ }
+ if (self.data.len == 0) return;
+ if (self.opts.mode == .noop) return;
+
+ const largest_page = @min(self.opts.@"page-size", self.data.len);
+ self.compression_output = try self.alloc.alloc(
+ u8,
+ try lz4.compressBound(largest_page),
+ );
+ errdefer {
+ self.alloc.free(self.compression_output);
+ self.compression_output = &.{};
+ }
+
+ if (self.opts.mode == .store) try self.prepareStored();
+ if (self.opts.mode == .decompress) try self.prepareEncoded();
+}
+
+/// Allocate only the ring metadata for `store` mode. The encoded blocks which
+/// populate it are intentionally allocated by the timed benchmark step.
+fn prepareStored(self: *PageCompression) !void {
+ self.stored = try self.alloc.alloc(?[]u8, self.opts.@"retained-pages");
+ @memset(self.stored, null);
+ self.stored_next = 0;
+}
+
+/// Precompress every input page and verify one decode before benchmarking.
+/// This catches corpus or codec problems before the timer starts.
+fn prepareEncoded(self: *PageCompression) !void {
+ self.decompression_output = try self.alloc.alloc(
+ u8,
+ @min(self.opts.@"page-size", self.data.len),
+ );
+ errdefer {
+ self.alloc.free(self.decompression_output);
+ self.decompression_output = &.{};
+ }
+
+ var it = self.pages();
+ while (it.next()) |page| {
+ const encoded_len = try lz4.compress(
+ page,
+ self.compression_output,
+ &self.table,
+ );
+ const encoded = try self.alloc.dupe(
+ u8,
+ self.compression_output[0..encoded_len],
+ );
+ self.encoded.append(self.alloc, .{
+ .bytes = encoded,
+ .raw_len = page.len,
+ }) catch |err| {
+ self.alloc.free(encoded);
+ return err;
+ };
+ }
+
+ var page_it = self.pages();
+ for (self.encoded.items) |block| {
+ const page = page_it.next().?;
+ const output = self.decompression_output[0..block.raw_len];
+ _ = try lz4.decompress(block.bytes, output);
+ if (!std.mem.eql(u8, page, output)) return error.RoundTripMismatch;
+ }
+}
+
+/// Release everything created by setup. This is shared by teardown and
+/// destroy so errors and direct unit-test use remain leak-free.
+fn clearPreparedData(self: *PageCompression) void {
+ for (self.encoded.items) |block| self.alloc.free(block.bytes);
+ self.encoded.deinit(self.alloc);
+ self.encoded = .empty;
+
+ for (self.stored) |block| if (block) |bytes| self.alloc.free(bytes);
+ if (self.stored.len > 0) self.alloc.free(self.stored);
+ self.stored = &.{};
+ self.stored_next = 0;
+
+ if (self.compression_output.len > 0)
+ self.alloc.free(self.compression_output);
+ self.compression_output = &.{};
+
+ if (self.decompression_output.len > 0)
+ self.alloc.free(self.decompression_output);
+ self.decompression_output = &.{};
+
+ if (self.data.len > 0) self.alloc.free(self.data);
+ self.data = &.{};
+}
+
+fn teardown(ptr: *anyopaque) void {
+ const self: *PageCompression = @ptrCast(@alignCast(ptr));
+ self.clearPreparedData();
+}
+
+/// Baseline mode: traverse exactly the same page boundaries as compression
+/// without invoking the codec.
+fn stepNoop(ptr: *anyopaque) Benchmark.Error!void {
+ const self: *PageCompression = @ptrCast(@alignCast(ptr));
+ for (0..self.opts.loops) |_| {
+ var it = self.pages();
+ while (it.next()) |page| std.mem.doNotOptimizeAway(page);
+ }
+}
+
+/// Compress all pages into one reusable output buffer. Only the returned
+/// encoded length is consumed because retaining output pages would measure
+/// allocation and ownership rather than codec throughput.
+fn stepCompress(ptr: *anyopaque) Benchmark.Error!void {
+ const self: *PageCompression = @ptrCast(@alignCast(ptr));
+ for (0..self.opts.loops) |_| {
+ var it = self.pages();
+ while (it.next()) |page| {
+ const encoded_len = lz4.compress(
+ page,
+ self.compression_output,
+ &self.table,
+ ) catch |err| {
+ log.warn("page compression failed err={}", .{err});
+ return error.BenchmarkFailed;
+ };
+ std.mem.doNotOptimizeAway(encoded_len);
+ }
+ }
+}
+
+/// Compress pages and retain their exact encoded allocations in a bounded
+/// ring. This models the part of `compress.Page.init` which differs from the
+/// allocation-free codec benchmark: copying the result, allocating its
+/// persistent storage, and freeing an old block when scrollback is full.
+fn stepStore(ptr: *anyopaque) Benchmark.Error!void {
+ const self: *PageCompression = @ptrCast(@alignCast(ptr));
+ if (self.data.len == 0) return;
+ assert(self.stored.len > 0);
+
+ for (0..self.opts.loops) |_| {
+ var it = self.pages();
+ while (it.next()) |page| {
+ const output_len = CompressedPage.requiredScratch(page.len) catch |err| {
+ log.warn("failed to size stored page output err={}", .{err});
+ return error.BenchmarkFailed;
+ };
+ if (output_len == 0) continue;
+
+ const encoded_len = lz4.compress(
+ page,
+ self.compression_output[0..output_len],
+ &self.table,
+ ) catch |err| switch (err) {
+ // This is the same profitability outcome as
+ // compress.Page.init: a block which exceeds the useful output
+ // limit remains resident and consumes no encoded allocation.
+ error.OutputTooSmall => continue,
+ error.InputTooLarge => {
+ log.warn("stored page compression failed err={}", .{err});
+ return error.BenchmarkFailed;
+ },
+ };
+
+ const encoded = self.alloc.dupe(
+ u8,
+ self.compression_output[0..encoded_len],
+ ) catch |err| {
+ log.warn("stored page allocation failed err={}", .{err});
+ return error.BenchmarkFailed;
+ };
+
+ const slot = &self.stored[self.stored_next];
+ if (slot.*) |previous| self.alloc.free(previous);
+ slot.* = encoded;
+ self.stored_next = (self.stored_next + 1) % self.stored.len;
+ std.mem.doNotOptimizeAway(encoded);
+ }
+ }
+}
+
+/// Decompress blocks prepared by setup. The output allocation is reused so
+/// this measures only decoding and the required memory writes.
+fn stepDecompress(ptr: *anyopaque) Benchmark.Error!void {
+ const self: *PageCompression = @ptrCast(@alignCast(ptr));
+ for (0..self.opts.loops) |_| {
+ for (self.encoded.items) |block| {
+ const output = self.decompression_output[0..block.raw_len];
+ _ = lz4.decompress(block.bytes, output) catch |err| {
+ log.warn("page decompression failed err={}", .{err});
+ return error.BenchmarkFailed;
+ };
+ std.mem.doNotOptimizeAway(output);
+ }
+ }
+}
+
+/// Print size information for evaluating compression ratio. This shares the
+/// input and codec paths with compression mode but deliberately makes no
+/// timing claims.
+fn stepReport(ptr: *anyopaque) Benchmark.Error!void {
+ const self: *PageCompression = @ptrCast(@alignCast(ptr));
+ if (self.data.len == 0) return;
+
+ var page_index: usize = 0;
+ var raw_total: usize = 0;
+ var encoded_total: usize = 0;
+ var it = self.pages();
+ while (it.next()) |page| : (page_index += 1) {
+ const encoded_len = lz4.compress(
+ page,
+ self.compression_output,
+ &self.table,
+ ) catch |err| {
+ log.warn("page compression report failed err={}", .{err});
+ return error.BenchmarkFailed;
+ };
+ raw_total += page.len;
+ encoded_total += encoded_len;
+ std.debug.print(
+ "page-compression page={d} raw={d} encoded={d} ratio={d:.2}%\n",
+ .{ page_index, page.len, encoded_len, percentage(encoded_len, page.len) },
+ );
+ }
+
+ std.debug.print(
+ "page-compression total pages={d} raw={d} encoded={d} ratio={d:.2}% " ++
+ "workspace={d} output_bound={d}\n",
+ .{
+ page_index,
+ raw_total,
+ encoded_total,
+ percentage(encoded_total, raw_total),
+ @sizeOf(lz4.HashTable),
+ self.compression_output.len,
+ },
+ );
+}
+
+/// Iterate fixed-size page chunks without allocating an index table.
+fn pages(self: *const PageCompression) PageIterator {
+ return .{
+ .data = self.data,
+ .page_size = self.opts.@"page-size",
+ };
+}
+
+const PageIterator = struct {
+ data: []const u8,
+ page_size: usize,
+ offset: usize = 0,
+
+ fn next(self: *PageIterator) ?[]const u8 {
+ if (self.offset >= self.data.len) return null;
+ const len = @min(self.page_size, self.data.len - self.offset);
+ const end = self.offset + len;
+ defer self.offset = end;
+ return self.data[self.offset..end];
+ }
+};
+
+fn percentage(part: usize, whole: usize) f64 {
+ if (whole == 0) return 0;
+ return @as(f64, @floatFromInt(part)) * 100 /
+ @as(f64, @floatFromInt(whole));
+}
+
+test PageCompression {
+ const testing = std.testing;
+ const impl: *PageCompression = try .create(testing.allocator, .{});
+ defer impl.destroy(testing.allocator);
+
+ const bench = impl.benchmark();
+ _ = try bench.run(.once);
+}
+
+test "PageCompression store retains exact encoded allocations" {
+ const testing = std.testing;
+ const page_size = 1024;
+ const impl: *PageCompression = try .create(testing.allocator, .{
+ .mode = .store,
+ .@"page-size" = page_size,
+ .@"retained-pages" = 2,
+ });
+ defer impl.destroy(testing.allocator);
+
+ impl.data = try testing.allocator.alloc(u8, 3 * page_size);
+ @memset(impl.data, 0);
+ impl.compression_output = try testing.allocator.alloc(
+ u8,
+ try lz4.compressBound(page_size),
+ );
+ try impl.prepareStored();
+
+ try stepStore(impl);
+ try testing.expectEqual(@as(usize, 1), impl.stored_next);
+ for (impl.stored) |block| {
+ const encoded = block.?;
+ try testing.expect(encoded.len < page_size);
+
+ var decoded: [page_size]u8 = undefined;
+ _ = try lz4.decompress(encoded, &decoded);
+ try testing.expect(std.mem.allEqual(u8, &decoded, 0));
+ }
+}
diff --git a/vendor/libghostty-vt/src/benchmark/ScreenClone.zig b/vendor/libghostty-vt/src/benchmark/ScreenClone.zig
index 108eaa0c..5176ce26 100644
--- a/vendor/libghostty-vt/src/benchmark/ScreenClone.zig
+++ b/vendor/libghostty-vt/src/benchmark/ScreenClone.zig
@@ -21,6 +21,10 @@ pub const Options = struct {
/// The type of codepoint width calculation to use.
mode: Mode = .clone,
+ /// Multiplier on the number of iterations each step runs. This is
+ /// useful to make a benchmark run long enough for profiling.
+ loops: u32 = 1,
+
/// The size of the terminal. This affects benchmarking when
/// dealing with soft line wrapping and the memory impact
/// of page sizes.
@@ -48,6 +52,22 @@ pub const Mode = enum {
/// RenderState rather than a screen clone.
render,
+
+ /// Like render, but only the portion of the render state update
+ /// that requires holding a terminal lock (beginUpdate). The
+ /// deferred work (endUpdate) is excluded since it happens outside
+ /// of any locks.
+ @"render-locked",
+
+ /// RenderState update with no changes to the terminal. This is
+ /// the common case for a renderer that is redrawing frames (e.g.
+ /// cursor blink, mouse movement) without terminal changes.
+ @"render-clean",
+
+ /// RenderState update where a single row is dirty. This models the
+ /// common case of a shell prompt or TUI updating a small portion
+ /// of the screen between frames.
+ @"render-partial",
};
pub fn create(
@@ -79,6 +99,9 @@ pub fn benchmark(self: *ScreenClone) Benchmark {
.noop => stepNoop,
.clone => stepClone,
.render => stepRender,
+ .@"render-locked" => stepRenderLocked,
+ .@"render-clean" => stepRenderClean,
+ .@"render-partial" => stepRenderPartial,
},
.setupFn = setup,
.teardownFn = teardown,
@@ -178,16 +201,102 @@ fn stepRender(ptr: *anyopaque) Benchmark.Error!void {
// We loop because its so fast that a single benchmark run doesn't
// properly capture our speeds.
- for (0..1000) |_| {
+ for (0..50_000 * @as(u64, self.opts.loops)) |_| {
// Forces a full rebuild because it thinks our screen changed
state.screen = .alternate;
state.update(alloc, &self.terminal) catch |err| {
log.warn("error cloning screen err={}", .{err});
return error.BenchmarkFailed;
};
- std.mem.doNotOptimizeAway(state);
+ std.mem.doNotOptimizeAway(&state);
+
+ // Note: we purposely do not free memory because we don't want
+ // to benchmark that. We'll free when the benchmark exits.
+ }
+}
+
+fn stepRenderLocked(ptr: *anyopaque) Benchmark.Error!void {
+ const self: *ScreenClone = @ptrCast(@alignCast(ptr));
+
+ // We do this once out of the loop because a significant slowdown
+ // on the first run is allocation. After that first run, even with
+ // a full rebuild, it is much faster. Let's ignore that first run
+ // slowdown.
+ const alloc = self.terminal.screens.active.alloc;
+ var state: terminalpkg.RenderState = .empty;
+ state.update(alloc, &self.terminal) catch |err| {
+ log.warn("error cloning screen err={}", .{err});
+ return error.BenchmarkFailed;
+ };
+
+ // We loop because its so fast that a single benchmark run doesn't
+ // properly capture our speeds.
+ for (0..50_000 * @as(u64, self.opts.loops)) |_| {
+ // Forces a full rebuild because it thinks our screen changed
+ state.screen = .alternate;
+ state.beginUpdate(alloc, &self.terminal) catch |err| {
+ log.warn("error cloning screen err={}", .{err});
+ return error.BenchmarkFailed;
+ };
+ std.mem.doNotOptimizeAway(&state);
// Note: we purposely do not free memory because we don't want
// to benchmark that. We'll free when the benchmark exits.
}
}
+
+fn stepRenderClean(ptr: *anyopaque) Benchmark.Error!void {
+ const self: *ScreenClone = @ptrCast(@alignCast(ptr));
+
+ // Initial update so that subsequent updates are clean (nothing
+ // dirty, no rebuilds).
+ const alloc = self.terminal.screens.active.alloc;
+ var state: terminalpkg.RenderState = .empty;
+ state.update(alloc, &self.terminal) catch |err| {
+ log.warn("error cloning screen err={}", .{err});
+ return error.BenchmarkFailed;
+ };
+
+ // We loop because its so fast that a single benchmark run doesn't
+ // properly capture our speeds.
+ for (0..3_000_000 * @as(u64, self.opts.loops)) |_| {
+ state.update(alloc, &self.terminal) catch |err| {
+ log.warn("error cloning screen err={}", .{err});
+ return error.BenchmarkFailed;
+ };
+ std.mem.doNotOptimizeAway(&state);
+ }
+}
+
+fn stepRenderPartial(ptr: *anyopaque) Benchmark.Error!void {
+ const self: *ScreenClone = @ptrCast(@alignCast(ptr));
+
+ // Initial update so that subsequent updates are incremental.
+ const alloc = self.terminal.screens.active.alloc;
+ var state: terminalpkg.RenderState = .empty;
+ state.update(alloc, &self.terminal) catch |err| {
+ log.warn("error cloning screen err={}", .{err});
+ return error.BenchmarkFailed;
+ };
+
+ // Grab a pin roughly in the middle of the active area that we
+ // dirty on every iteration to simulate a small screen update.
+ const pages = &self.terminal.screens.active.pages;
+ const pin = pages.pin(.{ .active = .{
+ .x = 0,
+ .y = self.terminal.rows / 2,
+ } }).?;
+
+ // We loop because its so fast that a single benchmark run doesn't
+ // properly capture our speeds.
+ for (0..2_000_000 * @as(u64, self.opts.loops)) |_| {
+ // Mark a single row dirty. `update` clears this so each
+ // iteration rebuilds exactly one row.
+ pin.markDirty();
+ state.update(alloc, &self.terminal) catch |err| {
+ log.warn("error cloning screen err={}", .{err});
+ return error.BenchmarkFailed;
+ };
+ std.mem.doNotOptimizeAway(&state);
+ }
+}
diff --git a/vendor/libghostty-vt/src/benchmark/ScrollbackCompression.zig b/vendor/libghostty-vt/src/benchmark/ScrollbackCompression.zig
new file mode 100644
index 00000000..40ec20f4
--- /dev/null
+++ b/vendor/libghostty-vt/src/benchmark/ScrollbackCompression.zig
@@ -0,0 +1,343 @@
+//! 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 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(alloc, .{
+ .rows = opts.@"terminal-rows",
+ .cols = opts.@"terminal-cols",
+ .max_scrollback = 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();
+
+ 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(&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);
+}
diff --git a/vendor/libghostty-vt/src/benchmark/TerminalStream.zig b/vendor/libghostty-vt/src/benchmark/TerminalStream.zig
index 1cac656e..a5302005 100644
--- a/vendor/libghostty-vt/src/benchmark/TerminalStream.zig
+++ b/vendor/libghostty-vt/src/benchmark/TerminalStream.zig
@@ -2,15 +2,13 @@
//! handler from input to terminal state update. This is useful to
//! test general throughput of VT parsing and handling.
//!
-//! Note that the handler used for this benchmark isn't the full
-//! terminal handler, since that requires a significant amount of
-//! state. This is a simplified version that only handles specific
-//! terminal operations like printing characters. We should expand
-//! this to include more operations to improve the accuracy of the
-//! benchmark.
+//! This uses the full readonly terminal stream handler
+//! (terminal.TerminalStream) so every escape sequence updates real
+//! terminal state (styles, cursor movement, erases, modes, etc.).
+//! This closely mirrors the work done by the real IO thread.
//!
-//! It is a fairly broad benchmark that can be used to determine
-//! if we need to optimize something more specific (e.g. the parser).
+//! For more isolated measurements see the terminal-parser and
+//! osc-parser benchmarks.
const TerminalStream = @This();
const std = @import("std");
@@ -20,13 +18,12 @@ const terminalpkg = @import("../terminal/main.zig");
const Benchmark = @import("Benchmark.zig");
const options = @import("options.zig");
const Terminal = terminalpkg.Terminal;
-const Stream = terminalpkg.Stream(*Handler);
+const Stream = terminalpkg.TerminalStream;
const log = std.log.scoped(.@"terminal-stream-bench");
opts: Options,
terminal: Terminal,
-handler: Handler,
stream: Stream,
/// The file, opened in the setup function.
@@ -61,14 +58,15 @@ pub fn create(
.rows = opts.@"terminal-rows",
.cols = opts.@"terminal-cols",
}),
- .handler = .{ .t = &ptr.terminal },
- .stream = .init(&ptr.handler),
+ .stream = undefined,
};
+ ptr.stream = .initAlloc(alloc, .init(&ptr.terminal));
return ptr;
}
pub fn destroy(self: *TerminalStream, alloc: Allocator) void {
+ self.stream.deinit();
self.terminal.deinit(alloc);
alloc.destroy(self);
}
@@ -114,11 +112,14 @@ fn step(ptr: *anyopaque) Benchmark.Error!void {
// aren't currently IO bound.
const f = self.data_f orelse return;
- var read_buf: [4096]u8 align(std.atomic.cache_line) = undefined;
+ var read_buf: [64 * 1024]u8 align(std.atomic.cache_line) = undefined;
var f_reader = f.reader(&read_buf);
const r = &f_reader.interface;
- var buf: [4096]u8 = undefined;
+ // This buffer size matches the read buffer size used by the
+ // real IO thread (see termio Exec.zig buffer_capacity) so that
+ // the benchmark exercises the stream with realistic chunk sizes.
+ var buf: [64 * 1024]u8 = undefined;
while (true) {
const n = r.readSliceShort(&buf) catch {
log.warn("error reading data file err={?}", .{f_reader.err});
@@ -129,26 +130,6 @@ fn step(ptr: *anyopaque) Benchmark.Error!void {
}
}
-/// Implements the handler interface for the terminal.Stream.
-/// We should expand this to include more operations to make
-/// our benchmark more realistic.
-const Handler = struct {
- t: *Terminal,
-
- pub fn vt(
- self: *Handler,
- comptime action: Stream.Action.Tag,
- value: Stream.Action.Value(action),
- ) void {
- switch (action) {
- .print => self.t.print(value.cp) catch |err| {
- log.warn("error processing benchmark print err={}", .{err});
- },
- else => {},
- }
- }
-};
-
test TerminalStream {
const testing = std.testing;
const alloc = testing.allocator;
diff --git a/vendor/libghostty-vt/src/benchmark/cli.zig b/vendor/libghostty-vt/src/benchmark/cli.zig
index 13f07077..dc4d7b8f 100644
--- a/vendor/libghostty-vt/src/benchmark/cli.zig
+++ b/vendor/libghostty-vt/src/benchmark/cli.zig
@@ -6,8 +6,12 @@ const cli = @import("../cli.zig");
/// benchmarks. View docs for each individual one in the predictably
/// named files.
pub const Action = enum {
+ @"apc-parser",
@"codepoint-width",
@"grapheme-break",
+ @"hyperlink-map",
+ @"page-compression",
+ @"scrollback-compression",
@"screen-clone",
@"terminal-parser",
@"terminal-stream",
@@ -24,7 +28,11 @@ pub const Action = enum {
/// See TerminalStream for an example.
pub fn Struct(comptime action: Action) type {
return switch (action) {
+ .@"apc-parser" => @import("ApcParser.zig"),
+ .@"hyperlink-map" => @import("HyperlinkMap.zig"),
.@"screen-clone" => @import("ScreenClone.zig"),
+ .@"page-compression" => @import("PageCompression.zig"),
+ .@"scrollback-compression" => @import("ScrollbackCompression.zig"),
.@"terminal-stream" => @import("TerminalStream.zig"),
.@"codepoint-width" => @import("CodepointWidth.zig"),
.@"grapheme-break" => @import("GraphemeBreak.zig"),
diff --git a/vendor/libghostty-vt/src/benchmark/main.zig b/vendor/libghostty-vt/src/benchmark/main.zig
index 5673044f..f22891c7 100644
--- a/vendor/libghostty-vt/src/benchmark/main.zig
+++ b/vendor/libghostty-vt/src/benchmark/main.zig
@@ -4,9 +4,12 @@ pub const CApi = @import("CApi.zig");
pub const TerminalStream = @import("TerminalStream.zig");
pub const CodepointWidth = @import("CodepointWidth.zig");
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 IsSymbol = @import("IsSymbol.zig");
+pub const PageCompression = @import("PageCompression.zig");
+pub const ScrollbackCompression = @import("ScrollbackCompression.zig");
test {
@import("std").testing.refAllDecls(@This());