diff --git a/src/benchmark/PageCompression.zig b/src/benchmark/PageCompression.zig index 61bbd220d..04dc7ef32 100644 --- a/src/benchmark/PageCompression.zig +++ b/src/benchmark/PageCompression.zig @@ -25,6 +25,9 @@ //! * `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 @@ -32,7 +35,9 @@ //! //! 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. +//! 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. //! @@ -46,11 +51,11 @@ //! //! ghostty-bench +page-compression --mode=report --data=/tmp/pages.raw //! -//! Compare compression and decompression with `hyperfine`: +//! 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=decompress --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"); @@ -58,7 +63,9 @@ const assert = std.debug.assert; const Allocator = std.mem.Allocator; const Benchmark = @import("Benchmark.zig"); const options = @import("options.zig"); -const lz4 = @import("../terminal/compress/lz4.zig"); +const compress = @import("../terminal/compress.zig"); +const CompressedPage = compress.Page; +const lz4 = compress.lz4; const log = std.log.scoped(.@"page-compression-bench"); @@ -76,6 +83,11 @@ 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 = &.{}, @@ -101,6 +113,11 @@ pub const Options = struct { /// 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. @@ -119,6 +136,9 @@ pub const Mode = enum { /// 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, @@ -160,6 +180,7 @@ pub fn benchmark(self: *PageCompression) Benchmark { .stepFn = switch (self.opts.mode) { .noop => stepNoop, .compress => stepCompress, + .store => stepStore, .decompress => stepDecompress, .report => stepReport, }, @@ -184,6 +205,8 @@ fn setup(ptr: *anyopaque) Benchmark.Error!void { 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(); @@ -206,9 +229,18 @@ fn setupData(self: *PageCompression) !void { 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 { @@ -257,6 +289,11 @@ fn clearPreparedData(self: *PageCompression) void { 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 = &.{}; @@ -305,6 +342,56 @@ fn stepCompress(ptr: *anyopaque) Benchmark.Error!void { } } +/// 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 { @@ -399,3 +486,33 @@ test PageCompression { 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/src/terminal/compress.zig b/src/terminal/compress.zig new file mode 100644 index 000000000..3b670ed75 --- /dev/null +++ b/src/terminal/compress.zig @@ -0,0 +1,15 @@ +//! Compression primitives used by the terminal. +//! +//! This namespace contains only the representation and codecs. Policy about +//! which pages to compress, when to decommit their resident memory, and when +//! to restore them belongs to `PageList`. + +/// The raw LZ4 block codec used for terminal page memory. +pub const lz4 = @import("compress/lz4.zig"); + +/// A compressed terminal page which retains its original virtual mapping. +pub const Page = @import("compress/Page.zig"); + +test { + @import("std").testing.refAllDecls(@This()); +} diff --git a/src/terminal/compress/Page.zig b/src/terminal/compress/Page.zig new file mode 100644 index 000000000..6736b950a --- /dev/null +++ b/src/terminal/compress/Page.zig @@ -0,0 +1,337 @@ +//! A compressed terminal page which retains its resident virtual mapping. +//! +//! Terminal pages have two kinds of state: the large `Page.memory` allocation +//! and a comparatively small `Page` value containing offsets, dimensions, +//! dirty state, and allocator metadata. Not all of the latter state lives in +//! the backing memory, so preserving only the memory bytes is insufficient. +//! We preserve the complete `Page` value instead. This follows the same model +//! as `Page.cloneBuf`: page internals use offsets, so a shallow page copy remains +//! valid when its memory contents are restored at the same address. +//! +//! The resident memory is deliberately not freed by this type. A future +//! `PageList` integration will keep the virtual range allocated while asking +//! the operating system to discard its physical pages. Keeping the range has +//! two useful properties: the embedded page never contains a dangling pointer, +//! and restoring the page does not require a fallible allocation. +//! +//! The intended state transition is: +//! +//! 1. Create this value while the source page is resident. +//! 2. Ask the OS to decommit the source page's memory. +//! 3. Replace the PageList node's resident state with this value. +//! 4. To restore, recommit the retained range and call `restore`. +//! 5. After committing the resident state, call `deinit` to free the encoded +//! bytes. +//! +//! If decommit is unavailable or fails, the caller should deinitialize the +//! compressed candidate and leave the source page resident. This type does not +//! perform any virtual-memory operations itself. +//! +//! The planned native implementation uses `MADV_DONTNEED` on Linux and pairs +//! `MADV_FREE_REUSABLE` with `MADV_FREE_REUSE` on Darwin. Targets without a +//! reliable retained-mapping decommit operation should leave page compression +//! disabled rather than free and later reallocate the resident memory. +const Page = @This(); + +const std = @import("std"); +const assert = std.debug.assert; +const Allocator = std.mem.Allocator; +const TerminalPage = @import("../page.zig").Page; +const lz4 = @import("lz4.zig"); + +/// Complete page metadata together with the retained resident mapping. +/// +/// The bytes in `page.memory` may have been discarded by the OS while this +/// value is in compressed state. They must not be read until `restore` has +/// successfully decoded the page. +page: TerminalPage, + +/// Exact raw LZ4 block for `page.memory`. +encoded: []u8, + +/// Return the largest scratch buffer that can produce a useful compressed +/// representation for `raw_len` bytes. +/// +/// This is deliberately smaller than the general LZ4 compression bound. The +/// compressed representation includes an additional slice compared to a +/// resident terminal page, so an encoded block which fills more than this +/// buffer cannot reduce resident memory. Limiting the output here lets a +/// future PageList caller borrow a standard page-pool item as scratch instead +/// of retaining a larger, compression-bound allocation. +/// +/// Callers are expected to reuse the scratch memory when practical. The +/// scratch buffer and hash table are never retained by this type. +pub fn requiredScratch(raw_len: usize) lz4.CompressError!usize { + // Validate the codec's input limit before doing arithmetic based on the + // raw size. The bound itself is intentionally not returned; see above. + _ = try lz4.compressBound(raw_len); + + const representation_overhead = @sizeOf(Page) - @sizeOf(TerminalPage); + if (raw_len <= representation_overhead) return 0; + + // The savings comparison is strict, so reserve one fewer byte than the + // break-even encoded size. + return raw_len - representation_overhead - 1; +} + +/// Create a compressed representation of `source`. +/// +/// `source` is not modified and continues to own its backing allocation. The +/// scratch buffer must be at least `requiredScratch(source.memory.len)` bytes. +/// It must not overlap the source memory. On success, the returned value aliases +/// the source's resident mapping and owns only its exact-sized `encoded` +/// allocation. +/// +/// Returns null if retaining the compressed representation would not use less +/// memory than the resident representation. An `OutputTooSmall` result from +/// the codec also means the encoding crossed that break-even point and is +/// therefore returned as null. This comparison includes the in-memory size of +/// both representation structs but does not include allocator metadata or the +/// retained virtual address range. +pub fn init( + alloc: Allocator, + source: *const TerminalPage, + scratch: []u8, + table: *lz4.HashTable, +) (Allocator.Error || lz4.CompressError)!?Page { + const required = try requiredScratch(source.memory.len); + if (scratch.len < required) return error.OutputTooSmall; + if (required == 0) return null; + + const encoded_len = lz4.compress( + source.memory, + scratch[0..required], + table, + ) catch |err| switch (err) { + // The scratch limit is the largest representation worth retaining. + // Running out of room therefore means compression cannot save memory, + // rather than that the caller failed to provide the documented size. + error.OutputTooSmall => return null, + error.InputTooLarge => return err, + }; + + // requiredScratch already accounts for the representation structs. The + // retained virtual range contributes no resident bytes after PageList + // decommits it. + assert(@sizeOf(Page) + encoded_len < + @sizeOf(TerminalPage) + source.memory.len); + + return .{ + .page = source.*, + .encoded = try alloc.dupe(u8, scratch[0..encoded_len]), + }; +} + +/// Restore the embedded terminal page into its retained resident mapping. +/// +/// The caller must recommit `page.memory` before calling this on platforms +/// which require an explicit recommit operation. The compressed value remains +/// valid whether decoding succeeds or fails, allowing the caller to retry or +/// discard it. On success the returned page aliases the same resident mapping; +/// it does not own a new allocation. +pub fn restore(self: *const Page) lz4.DecompressError!TerminalPage { + const result = self.page; + _ = try lz4.decompress(self.encoded, result.memory); + return result; +} + +/// Free the encoded block. +/// +/// This intentionally does not free `page.memory`. The PageList node which +/// supplied the source page continues to own that pool or heap allocation. +pub fn deinit(self: *Page, alloc: Allocator) void { + alloc.free(self.encoded); + self.* = undefined; +} + +test "compressed Page retained mapping round trip" { + const testing = std.testing; + + var resident = try TerminalPage.init(.{ + .cols = 12, + .rows = 9, + .styles = 8, + .grapheme_bytes = 128, + .string_bytes = 128, + }); + defer resident.deinit(); + resident.size = .{ .cols = 10, .rows = 7 }; + resident.dirty = true; + + // Put state in both the backing memory and the Page value. In particular, + // the style and hyperlink sets retain live counters outside Page.memory. + const rac = resident.getRowAndCell(2, 3); + rac.cell.* = .init('A'); + try resident.appendGrapheme(rac.row, rac.cell, 0x0301); + + const style_id = try resident.styles.add(resident.memory, .{ .flags = .{ + .bold = true, + } }); + rac.cell.style_id = style_id; + rac.row.styled = true; + + const hyperlink_id = try resident.insertHyperlink(.{ + .id = .{ .explicit = "compressed-page" }, + .uri = "https://ghostty.org/docs", + }); + try resident.setHyperlink(rac.row, rac.cell, hyperlink_id); + try resident.verifyIntegrity(testing.allocator); + + const expected = try testing.allocator.dupe(u8, resident.memory); + defer testing.allocator.free(expected); + const memory_ptr = resident.memory.ptr; + const memory_len = resident.memory.len; + + const scratch = try testing.allocator.alloc( + u8, + try requiredScratch(resident.memory.len), + ); + defer testing.allocator.free(scratch); + var table: lz4.HashTable = undefined; + var compressed = (try Page.init( + testing.allocator, + &resident, + scratch, + &table, + )).?; + defer compressed.deinit(testing.allocator); + + try testing.expectEqual(memory_ptr, compressed.page.memory.ptr); + try testing.expectEqual(memory_len, compressed.page.memory.len); + try testing.expect(compressed.encoded.len < resident.memory.len); + + // Virtual-memory operations belong to PageList, so clearing the contents + // models a successful decommit: none of the resident bytes remain. + @memset(resident.memory, 0); + + const restored = try compressed.restore(); + try testing.expectEqual(memory_ptr, restored.memory.ptr); + try testing.expectEqual(memory_len, restored.memory.len); + try testing.expectEqualSlices(u8, expected, restored.memory); + try testing.expectEqual(resident.size, restored.size); + try testing.expect(restored.dirty); + try restored.verifyIntegrity(testing.allocator); + + const restored_rac = restored.getRowAndCell(2, 3); + try testing.expectEqualSlices( + u21, + &.{0x0301}, + restored.lookupGrapheme(restored_rac.cell).?, + ); + try testing.expect(restored.styles.get( + restored.memory, + restored_rac.cell.style_id, + ).flags.bold); + + const restored_hyperlink_id = restored.lookupHyperlink(restored_rac.cell).?; + const restored_hyperlink = restored.hyperlink_set.get( + restored.memory, + restored_hyperlink_id, + ); + try testing.expectEqualStrings( + "https://ghostty.org/docs", + restored_hyperlink.uri.slice(restored.memory), + ); +} + +test "compressed Page requires the maximum useful scratch" { + const testing = std.testing; + + var resident = try TerminalPage.init(.{ .cols = 4, .rows = 4 }); + defer resident.deinit(); + const expected = try testing.allocator.dupe(u8, resident.memory); + defer testing.allocator.free(expected); + + const required = try requiredScratch(resident.memory.len); + try testing.expectEqual( + resident.memory.len - (@sizeOf(Page) - @sizeOf(TerminalPage)) - 1, + required, + ); + try testing.expect(required < try lz4.compressBound(resident.memory.len)); + + const scratch = try testing.allocator.alloc(u8, required - 1); + defer testing.allocator.free(scratch); + var table: lz4.HashTable = undefined; + + try testing.expectError(error.OutputTooSmall, Page.init( + testing.allocator, + &resident, + scratch, + &table, + )); + try testing.expectEqualSlices(u8, expected, resident.memory); +} + +test "compressed Page rejects a representation without savings" { + const testing = std.testing; + + var resident = try TerminalPage.init(.{ + .cols = 4, + .rows = 4, + .styles = 0, + .grapheme_bytes = 0, + .string_bytes = 0, + .hyperlink_bytes = 0, + }); + defer resident.deinit(); + + var prng = std.Random.DefaultPrng.init(0x4C5A_3402); + prng.random().bytes(resident.memory); + + const scratch = try testing.allocator.alloc( + u8, + try requiredScratch(resident.memory.len), + ); + defer testing.allocator.free(scratch); + var table: lz4.HashTable = undefined; + var failing = testing.FailingAllocator.init(testing.allocator, .{ + .fail_index = 0, + }); + + try testing.expect((try Page.init( + failing.allocator(), + &resident, + scratch, + &table, + )) == null); + try testing.expect(!failing.has_induced_failure); +} + +test "compressed Page can retry after malformed encoded data" { + const testing = std.testing; + + var resident = try TerminalPage.init(.{ .cols = 8, .rows = 8 }); + defer resident.deinit(); + const expected = try testing.allocator.dupe(u8, resident.memory); + defer testing.allocator.free(expected); + + const scratch = try testing.allocator.alloc( + u8, + try requiredScratch(resident.memory.len), + ); + defer testing.allocator.free(scratch); + var table: lz4.HashTable = undefined; + var compressed = (try Page.init( + testing.allocator, + &resident, + scratch, + &table, + )).?; + defer compressed.deinit(testing.allocator); + + const full_encoded = compressed.encoded; + const first_byte = full_encoded[0]; + defer { + compressed.encoded = full_encoded; + compressed.encoded[0] = first_byte; + } + compressed.encoded = full_encoded[0..1]; + compressed.encoded[0] = 0xF0; + try testing.expectError(error.TruncatedInput, compressed.restore()); + + compressed.encoded = full_encoded; + compressed.encoded[0] = first_byte; + @memset(resident.memory, 0); + const restored = try compressed.restore(); + try testing.expectEqualSlices(u8, expected, restored.memory); +} diff --git a/src/terminal/main.zig b/src/terminal/main.zig index 3e12c6165..12a43083a 100644 --- a/src/terminal/main.zig +++ b/src/terminal/main.zig @@ -83,7 +83,7 @@ test { // Internals _ = @import("bitmap_allocator.zig"); - _ = @import("compress/lz4.zig"); + _ = @import("compress.zig"); _ = @import("hash_map.zig"); _ = @import("ref_counted_set.zig"); _ = @import("size.zig");