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.
This commit is contained in:
Mitchell Hashimoto
2026-07-11 20:05:32 -07:00
parent 49cfb347b5
commit e93c23f4dd
4 changed files with 170 additions and 2 deletions

View File

@@ -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);
}
}

View File

@@ -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"),

View File

@@ -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");

View File

@@ -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;