aboutsummaryrefslogtreecommitdiff
path: root/vendor/libghostty-vt/src/benchmark/PageCompression.zig
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/PageCompression.zig
parent550e9f8cc085f428429f5cf2da0bb2dba897e8b8 (diff)
chore: update vendored libghostty-vt
refs #276
Diffstat (limited to 'vendor/libghostty-vt/src/benchmark/PageCompression.zig')
-rw-r--r--vendor/libghostty-vt/src/benchmark/PageCompression.zig519
1 files changed, 519 insertions, 0 deletions
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));
+ }
+}