From e93c23f4dd926ef07a452a155805fb95cd13a6b5 Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Sat, 11 Jul 2026 20:05:32 -0700 Subject: [PATCH] benchmark: add grapheme map workloads Add page-backed lookup and churn workloads for grapheme storage. The benchmark exercises the cell offset map together with the bitmap allocator, including configurable allocator load and multi-chunk grapheme values. Expose the grapheme chunk dimensions so the harness can size page storage to an exact working set without duplicating production constants. --- src/benchmark/GraphemeMap.zig | 165 ++++++++++++++++++++++++++++++++++ src/benchmark/cli.zig | 2 + src/benchmark/main.zig | 1 + src/terminal/page.zig | 4 +- 4 files changed, 170 insertions(+), 2 deletions(-) create mode 100644 src/benchmark/GraphemeMap.zig diff --git a/src/benchmark/GraphemeMap.zig b/src/benchmark/GraphemeMap.zig new file mode 100644 index 000000000..70b10564f --- /dev/null +++ b/src/benchmark/GraphemeMap.zig @@ -0,0 +1,165 @@ +//! Benchmark grapheme storage on a page: the bitmap allocator that holds +//! multi-codepoint grapheme data plus the cell -> codepoints offset hash map. +//! +//! The `churn` mode models emoji-heavy terminal output that repeatedly +//! overwrites cells on a page whose grapheme allocator is close to full: +//! every overwritten cell frees its chunks and map entry, then allocates +//! and inserts again. This is the per-cell cost of printing a grapheme +//! over an existing one, and is sensitive to both allocator scan cost and +//! map probe/hash cost. +const GraphemeMap = @This(); + +const std = @import("std"); +const Allocator = std.mem.Allocator; +const terminal = @import("../terminal/main.zig"); +const pagepkg = @import("../terminal/page.zig"); +const Benchmark = @import("Benchmark.zig"); + +const log = std.log.scoped(.@"grapheme-map-bench"); + +opts: Options, +page: terminal.Page, +entry_count: usize, +cps: []const u21, + +pub const Options = struct { + /// Requested grapheme working-set size in cells. Must be a power of + /// two and at least 16. + entries: u16 = 4096, + + /// Number of extra codepoints attached to each cell. Each chunk of + /// grapheme storage holds 4 codepoints, so values above 4 exercise + /// multi-chunk allocations. + cps: u8 = 1, + + /// Percentage of the entries 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 the grapheme data for every populated cell. + lookup, + + /// Clear and re-append the grapheme data for every populated cell. + churn, +}; + +pub fn create(alloc: Allocator, opts: Options) !*GraphemeMap { + 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; + } + if (opts.cps < 1) { + log.err("cps must be at least 1", .{}); + return error.InvalidCps; + } + + const ptr = try alloc.create(GraphemeMap); + errdefer alloc.destroy(ptr); + + const cps = try alloc.alloc(u21, opts.cps); + errdefer alloc.free(cps); + for (cps, 0..) |*cp, i| cp.* = @intCast(0x1F600 + i); + + // Size the allocator to exactly fit the working set so that at 100% + // load we exercise a full allocator, the worst case for chunk searches. + const chunks_per_entry = std.math.divCeil( + usize, + @as(usize, opts.cps) * @sizeOf(u21), + pagepkg.grapheme_chunk, + ) catch unreachable; + var page = try terminal.Page.init(.{ + .cols = opts.entries, + .rows = 1, + .grapheme_bytes = @intCast( + opts.entries * chunks_per_entry * pagepkg.grapheme_chunk, + ), + }); + errdefer page.deinit(); + + 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); + rac.cell.* = .{ + .content_tag = .codepoint, + .content = .{ .codepoint = 'A' }, + }; + try page.setGraphemes(rac.row, rac.cell, cps); + } + + ptr.* = .{ + .opts = opts, + .page = page, + .entry_count = entry_count, + .cps = cps, + }; + return ptr; +} + +pub fn destroy(self: *GraphemeMap, alloc: Allocator) void { + self.page.deinit(); + alloc.free(self.cps); + alloc.destroy(self); +} + +pub fn benchmark(self: *GraphemeMap) Benchmark { + return .init(self, .{ + .stepFn = switch (self.opts.mode) { + .lookup => stepLookup, + .churn => stepChurn, + }, + }); +} + +fn stepLookup(ptr: *anyopaque) Benchmark.Error!void { + const self: *GraphemeMap = @ptrCast(@alignCast(ptr)); + + for (0..self.opts.loops) |_| { + for (0..self.entry_count) |x| { + const cell = self.page.getRowAndCell(x, 0).cell; + const data = self.page.lookupGrapheme(cell) orelse + return error.BenchmarkFailed; + std.mem.doNotOptimizeAway(data); + } + } +} + +fn stepChurn(ptr: *anyopaque) Benchmark.Error!void { + const self: *GraphemeMap = @ptrCast(@alignCast(ptr)); + + for (0..self.opts.loops) |_| { + for (0..self.entry_count) |x| { + const rac = self.page.getRowAndCell(x, 0); + self.page.clearGrapheme(rac.cell); + self.page.setGraphemes(rac.row, rac.cell, self.cps) catch + return error.BenchmarkFailed; + } + } +} + +test GraphemeMap { + const alloc = std.testing.allocator; + + inline for (.{ Mode.lookup, Mode.churn }) |mode| { + const impl = try GraphemeMap.create(alloc, .{ + .entries = 64, + .cps = 5, + .mode = mode, + }); + defer impl.destroy(alloc); + + const bench = impl.benchmark(); + _ = try bench.run(.once); + } +} diff --git a/src/benchmark/cli.zig b/src/benchmark/cli.zig index 5d9519910..2bd95a898 100644 --- a/src/benchmark/cli.zig +++ b/src/benchmark/cli.zig @@ -9,6 +9,7 @@ pub const Action = enum { @"apc-parser", @"codepoint-width", @"grapheme-break", + @"grapheme-map", @"hyperlink-map", @"page-compression", @"scrollback-compression", @@ -30,6 +31,7 @@ pub const Action = enum { pub fn Struct(comptime action: Action) type { return switch (action) { .@"apc-parser" => @import("ApcParser.zig"), + .@"grapheme-map" => @import("GraphemeMap.zig"), .@"hyperlink-map" => @import("HyperlinkMap.zig"), .@"screen-clone" => @import("ScreenClone.zig"), .@"style-set" => @import("StyleSet.zig"), diff --git a/src/benchmark/main.zig b/src/benchmark/main.zig index f899c6d61..53687a1d5 100644 --- a/src/benchmark/main.zig +++ b/src/benchmark/main.zig @@ -4,6 +4,7 @@ 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 GraphemeMap = @import("GraphemeMap.zig"); pub const HyperlinkMap = @import("HyperlinkMap.zig"); pub const ScreenClone = @import("ScreenClone.zig"); pub const StyleSet = @import("StyleSet.zig"); diff --git a/src/terminal/page.zig b/src/terminal/page.zig index 636a348fe..1242d9735 100644 --- a/src/terminal/page.zig +++ b/src/terminal/page.zig @@ -87,8 +87,8 @@ const AllocWindows = struct { /// is that most skin-tone emoji are <= 4 codepoints, letter combiners /// are usually <= 4 codepoints, and 4 codepoints is a nice power of two /// for alignment. -const grapheme_chunk_len = 4; -const grapheme_chunk = grapheme_chunk_len * @sizeOf(u21); +pub const grapheme_chunk_len = 4; +pub const grapheme_chunk = grapheme_chunk_len * @sizeOf(u21); const GraphemeAlloc = BitmapAllocator(grapheme_chunk); const grapheme_count_default = GraphemeAlloc.bitmap_bit_size; pub const grapheme_bytes_default = grapheme_count_default * grapheme_chunk;