aboutsummaryrefslogtreecommitdiffstats
path: root/vendor/libghostty-vt/src/benchmark/ScrollbackCompression.zig
diff options
context:
space:
mode:
authorsl0p <sl0p@sl0p.foo>2026-08-13 22:00:03 +0200
committersl0p <sl0p@sl0p.foo>2026-08-13 22:00:03 +0200
commit2c1b855c3cf8cf8841bf25cd939e478a7f0bfac8 (patch)
tree7cd6274303ba1da1f8bd57371aec336dd687fb58 /vendor/libghostty-vt/src/benchmark/ScrollbackCompression.zig
downloadslosh-2c1b855c3cf8cf8841bf25cd939e478a7f0bfac8.tar.gz
slosh-2c1b855c3cf8cf8841bf25cd939e478a7f0bfac8.tar.xz
slosh-2c1b855c3cf8cf8841bf25cd939e478a7f0bfac8.zip
sl0ptty: M0 — libghostty-vt vendored, one pane, compositor, diff renderer
Design contract in DESIGN.md. libghostty-vt vendored fresh from ghostty main@bedb6f2 (zig 0.16), built to a 14MB static lib. M0: pty spawn (no libutil), a lib-vt terminal per pane, a screen cell buffer, a diff emitter that repaints only what changed, raw mode, SIGWINCH, and a ~120Hz frame cap that coalesces pty reads. Also the seed of the headless harness: --headless dumps the composited screen without a tty, so composition is testable in milliseconds. tests/live_m0.py covers what headless cannot: raw mode, the diff emitter, resize propagation, and the quit path. 6/6 green.
Diffstat (limited to 'vendor/libghostty-vt/src/benchmark/ScrollbackCompression.zig')
-rw-r--r--vendor/libghostty-vt/src/benchmark/ScrollbackCompression.zig344
1 files changed, 344 insertions, 0 deletions
diff --git a/vendor/libghostty-vt/src/benchmark/ScrollbackCompression.zig b/vendor/libghostty-vt/src/benchmark/ScrollbackCompression.zig
new file mode 100644
index 0000000..d971c55
--- /dev/null
+++ b/vendor/libghostty-vt/src/benchmark/ScrollbackCompression.zig
@@ -0,0 +1,344 @@
+//! Benchmarks cold-history compression and restoration on pages owned by a
+//! live terminal.
+//!
+//! Unlike `page-compression`, which measures the standalone codec against raw
+//! byte chunks, this benchmark first parses a VT corpus into a real `Terminal`.
+//! Its timed operations therefore include the PageList state transition,
+//! exact-sized encoded allocation, retained-mapping reclamation, and transparent
+//! restoration through `PageList.Node.page`.
+//!
+//! ## Input
+//!
+//! `--data` names a pre-generated VT byte stream. Parsing happens during setup
+//! and is not part of the benchmark's timed region. Use the same saved corpus,
+//! terminal dimensions, and scrollback limit when comparing revisions. In
+//! particular, do not pipe a generator into this benchmark: generation and
+//! pipe scheduling add noise which can overwhelm the operation being measured.
+//!
+//! The benchmark always operates on the primary screen's PageList. This keeps
+//! the result focused on scrollback even if malformed or incomplete input
+//! leaves the terminal in its alternate screen at end of file. `--max-scrollback`
+//! is expressed in bytes and defaults to 10 MB.
+//!
+//! ## Modes
+//!
+//! * `noop` parses the corpus but performs no timed PageList operation. This is
+//! the common process and setup baseline for the other modes.
+//! * `compress` times one complete `compress` invocation.
+//! * `incremental` reaches the same final representation through
+//! `compress(.drain)`. It drains the candidate-bounded steps and final
+//! no-work verification pass in the timed region, making cursor and repeated
+//! traversal overhead directly comparable with `compress`.
+//! * `restore` compresses cold history during setup, outside the timed region,
+//! then visits every fully historical node through `Node.page`. That public
+//! content-access boundary transparently restores compressed nodes.
+//! * `report` performs one compression pass and prints compressed page count,
+//! encoded ratio, and estimated resident-byte savings. It is intended for
+//! inspecting a corpus rather than timing comparisons.
+//!
+//! A fully historical page is a node strictly before the node containing the
+//! top of the active area. The boundary node is deliberately excluded because
+//! it can contain both history and active rows. Pages intersecting the current
+//! viewport are also excluded so visible contents remain resident. Normal
+//! terminal execution compresses eligible pages incrementally after activity
+//! becomes idle. The benchmark invokes PageList operations directly so its
+//! timed regions exclude the production scheduler's idle delay and
+//! renderer-thread coordination.
+//!
+//! ## Examples
+//!
+//! Build the benchmark in ReleaseFast mode:
+//!
+//! zig build -Demit-bench -Doptimize=ReleaseFast -Demit-macos-app=false
+//!
+//! Inspect the memory reduction for a saved VT corpus:
+//!
+//! ghostty-bench +scrollback-compression --mode=report \
+//! --data=/tmp/scrollback.vt --terminal-cols=120 --terminal-rows=80
+//!
+//! Compare PageList compression and restoration cost. Setup still contributes
+//! to full process time, so use a sufficiently large corpus and compare against
+//! `noop` with identical arguments:
+//!
+//! hyperfine --warmup 3 \
+//! 'ghostty-bench +scrollback-compression --mode=noop --data=/tmp/scrollback.vt' \
+//! 'ghostty-bench +scrollback-compression --mode=compress --data=/tmp/scrollback.vt' \
+//! 'ghostty-bench +scrollback-compression --mode=incremental --data=/tmp/scrollback.vt' \
+//! 'ghostty-bench +scrollback-compression --mode=restore --data=/tmp/scrollback.vt'
+const ScrollbackCompression = @This();
+
+const std = @import("std");
+const Allocator = std.mem.Allocator;
+const terminalpkg = @import("../terminal/main.zig");
+const Benchmark = @import("Benchmark.zig");
+const options = @import("options.zig");
+const PageList = terminalpkg.PageList;
+const Terminal = terminalpkg.Terminal;
+const global = @import("../global.zig");
+
+const log = std.log.scoped(.@"scrollback-compression-bench");
+
+opts: Options,
+terminal: Terminal,
+
+pub const Options = struct {
+ /// Set by the shared CLI parser so the `data` string remains valid for the
+ /// lifetime of the benchmark implementation.
+ _arena: ?std.heap.ArenaAllocator = null,
+
+ /// Select the PageList operation performed inside the timed benchmark.
+ mode: Mode = .compress,
+
+ /// Dimensions used to construct the terminal which consumes the corpus.
+ /// These affect wrapping and therefore the number and contents of pages.
+ @"terminal-rows": u16 = 80,
+ @"terminal-cols": u16 = 120,
+
+ /// Maximum primary-screen scrollback allocation in bytes. PageList rounds
+ /// this as required by its page allocation policy.
+ @"max-scrollback": usize = 10_000_000,
+
+ /// Pre-generated VT corpus. `-` reads stdin, although a regular file is
+ /// strongly recommended so comparisons can reuse identical input bytes.
+ /// When unset, every mode operates on the initial empty terminal.
+ data: ?[]const u8 = null,
+
+ pub fn deinit(self: *Options) void {
+ if (self._arena) |arena| arena.deinit();
+ self.* = undefined;
+ }
+};
+
+pub const Mode = enum {
+ /// Establish process and setup overhead without a PageList operation.
+ noop,
+
+ /// Compress every eligible fully historical page once.
+ compress,
+
+ /// Compress every eligible page through bounded resumable steps.
+ incremental,
+
+ /// Restore pages compressed outside the timed region.
+ restore,
+
+ /// Compress once and print aggregate memory statistics.
+ report,
+};
+
+pub fn create(
+ alloc: Allocator,
+ opts: Options,
+) !*ScrollbackCompression {
+ const ptr = try alloc.create(ScrollbackCompression);
+ errdefer alloc.destroy(ptr);
+
+ ptr.* = .{
+ .opts = opts,
+ .terminal = try .init(global.io(), alloc, .{
+ .rows = opts.@"terminal-rows",
+ .cols = opts.@"terminal-cols",
+ .max_scrollback_bytes = opts.@"max-scrollback",
+ }),
+ };
+ return ptr;
+}
+
+pub fn destroy(self: *ScrollbackCompression, alloc: Allocator) void {
+ self.terminal.deinit(alloc);
+ alloc.destroy(self);
+}
+
+pub fn benchmark(self: *ScrollbackCompression) Benchmark {
+ return .init(self, .{
+ .stepFn = switch (self.opts.mode) {
+ .noop => stepNoop,
+ .compress => stepCompress,
+ .incremental => stepIncremental,
+ .restore => stepRestore,
+ .report => stepReport,
+ },
+ .setupFn = setup,
+ });
+}
+
+/// Reset the terminal and consume the complete VT corpus before timing starts.
+/// Restore mode also prepares its compressed representation here so its timed
+/// step contains decoding and mapping writes, but not encoding.
+fn setup(ptr: *anyopaque) Benchmark.Error!void {
+ const self: *ScrollbackCompression = @ptrCast(@alignCast(ptr));
+ self.terminal.fullReset();
+
+ self.loadCorpus() catch |err| {
+ log.warn("failed to prepare scrollback compression benchmark err={}", .{err});
+ return error.BenchmarkFailed;
+ };
+
+ if (self.opts.mode == .restore) {
+ _ = self.pages().compress(.full);
+ }
+}
+
+/// Feed the corpus in the same 64 KiB chunks used by the real IO thread and
+/// terminal-stream benchmark. Parser and file IO costs remain in setup.
+fn loadCorpus(self: *ScrollbackCompression) !void {
+ const data_file = try options.dataFile(self.opts.data) orelse return;
+ defer data_file.close(global.io());
+
+ var stream = self.terminal.vtStream();
+ defer stream.deinit();
+
+ var read_buf: [64 * 1024]u8 align(std.atomic.cache_line) = undefined;
+ var file_reader = data_file.reader(global.io(), &read_buf);
+ const reader = &file_reader.interface;
+
+ var buf: [64 * 1024]u8 = undefined;
+ while (true) {
+ const n = reader.readSliceShort(&buf) catch
+ return file_reader.err orelse error.ReadFailed;
+ if (n == 0) return;
+ stream.nextSlice(buf[0..n]);
+ }
+}
+
+/// Return the primary screen because it is the terminal screen which owns
+/// scrollback. A corpus ending in the alternate screen must not turn this into
+/// an alternate-screen allocation benchmark.
+fn pages(self: *ScrollbackCompression) *PageList {
+ return &self.terminal.screens.get(.primary).?.pages;
+}
+
+fn stepNoop(ptr: *anyopaque) Benchmark.Error!void {
+ const self: *ScrollbackCompression = @ptrCast(@alignCast(ptr));
+ std.mem.doNotOptimizeAway(&self.terminal);
+}
+
+fn stepCompress(ptr: *anyopaque) Benchmark.Error!void {
+ const self: *ScrollbackCompression = @ptrCast(@alignCast(ptr));
+ _ = self.pages().compress(.full);
+ std.mem.doNotOptimizeAway(&self.terminal);
+}
+
+fn stepIncremental(ptr: *anyopaque) Benchmark.Error!void {
+ const self: *ScrollbackCompression = @ptrCast(@alignCast(ptr));
+ _ = self.pages().compress(.drain);
+ std.mem.doNotOptimizeAway(&self.terminal);
+}
+
+fn stepRestore(ptr: *anyopaque) Benchmark.Error!void {
+ const self: *ScrollbackCompression = @ptrCast(@alignCast(ptr));
+ std.mem.doNotOptimizeAway(self.visitColdPages());
+}
+
+/// Visit all nodes which were eligible for the setup compression pass.
+///
+/// `page` is intentionally used instead of inspecting the union payload so the
+/// benchmark follows the same transparent restoration boundary as consumers.
+/// Compressible nodes restore here; resident candidates which failed the
+/// opportunistic pass are harmlessly visited through the same boundary.
+fn visitColdPages(self: *ScrollbackCompression) usize {
+ const page_list = self.pages();
+ const active_node = page_list.getTopLeft(.active).node;
+ var visited: usize = 0;
+ var node_ = page_list.pages.first;
+ while (node_) |node| : (node_ = node.next) {
+ if (node == active_node) break;
+ std.mem.doNotOptimizeAway(node.page());
+ visited += 1;
+ }
+ return visited;
+}
+
+fn stepReport(ptr: *anyopaque) Benchmark.Error!void {
+ const self: *ScrollbackCompression = @ptrCast(@alignCast(ptr));
+ _ = self.pages().compress(.full);
+ const memory = self.pages().memoryStats();
+
+ std.debug.print(
+ "scrollback-compression compressed={d} raw={d} " ++
+ "encoded={d} ratio={d:.2}% savings={d}\n",
+ .{
+ memory.compressed_pages,
+ memory.decommitted_raw_bytes,
+ memory.encoded_bytes,
+ percentage(
+ memory.encoded_bytes,
+ memory.decommitted_raw_bytes,
+ ),
+ memory.estimatedSavings(),
+ },
+ );
+}
+
+fn percentage(part: usize, whole: usize) f64 {
+ if (whole == 0) return 0;
+ return @as(f64, @floatFromInt(part)) * 100 /
+ @as(f64, @floatFromInt(whole));
+}
+
+test ScrollbackCompression {
+ const testing = std.testing;
+ const impl: *ScrollbackCompression = try .create(testing.allocator, .{});
+ defer impl.destroy(testing.allocator);
+
+ const bench = impl.benchmark();
+ _ = try bench.run(.once);
+}
+
+test "ScrollbackCompression restores cold terminal pages" {
+ const testing = std.testing;
+ const impl: *ScrollbackCompression = try .create(testing.allocator, .{
+ .mode = .restore,
+ .@"terminal-rows" = 4,
+ // Standard-width pages hold 215 rows. Keeping that row capacity here
+ // makes this test corpus small while still producing cold history.
+ .@"terminal-cols" = 215,
+ .@"max-scrollback" = 1_000_000,
+ });
+ defer impl.destroy(testing.allocator);
+
+ var stream = impl.terminal.vtStream();
+ defer stream.deinit();
+ for (0..256) |_| stream.nextSlice("aaaa\r\n");
+
+ _ = impl.pages().compress(.full);
+ const compressed = impl.pages().memoryStats();
+ try testing.expect(compressed.compressed_pages > 0);
+ try testing.expect(impl.visitColdPages() >= compressed.compressed_pages);
+
+ // Restored historical pages are resident and therefore eligible for a
+ // later explicit pass. This also verifies that the benchmark traversal
+ // went through Node.page rather than merely inspecting page metadata.
+ _ = impl.pages().compress(.full);
+ const recompressed = impl.pages().memoryStats();
+ try testing.expectEqual(
+ compressed.compressed_pages,
+ recompressed.compressed_pages,
+ );
+}
+
+test "ScrollbackCompression drains incremental compression steps" {
+ const testing = std.testing;
+ const impl: *ScrollbackCompression = try .create(testing.allocator, .{
+ .mode = .incremental,
+ .@"terminal-rows" = 4,
+ .@"terminal-cols" = 215,
+ .@"max-scrollback" = 1_000_000,
+ });
+ defer impl.destroy(testing.allocator);
+
+ var stream = impl.terminal.vtStream();
+ defer stream.deinit();
+ for (0..256) |_| stream.nextSlice("aaaa\r\n");
+
+ _ = impl.pages().compress(.drain);
+ const incremental = impl.pages().memoryStats();
+ try testing.expect(incremental.compressed_pages > 0);
+
+ // Restore the same pages and compare against the monolithic operation.
+ // Both paths should produce the same final storage representation.
+ _ = impl.visitColdPages();
+ _ = impl.pages().compress(.full);
+ const monolithic = impl.pages().memoryStats();
+ try testing.expectEqual(monolithic, incremental);
+}