diff options
| author | sl0p <sl0p@sl0p.foo> | 2026-08-13 22:00:03 +0200 |
|---|---|---|
| committer | sl0p <sl0p@sl0p.foo> | 2026-08-13 22:00:03 +0200 |
| commit | 2c1b855c3cf8cf8841bf25cd939e478a7f0bfac8 (patch) | |
| tree | 7cd6274303ba1da1f8bd57371aec336dd687fb58 /vendor/libghostty-vt/src/benchmark/ScrollbackCompression.zig | |
| download | slosh-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.zig | 344 |
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); +} |
