Files
ghostty/src/benchmark/GraphemeMap.zig
Mitchell Hashimoto 0f50fdf6ce terminal: bound grapheme map probe lengths
The grapheme map previously allowed its open-addressed table to fill
completely. Inserts and misses therefore degraded into full-table scans
as grapheme storage approached capacity.

Limit usable slots to eighty percent of the raw table. Reaching the
limit grows the page before probes become pathological without doubling
grapheme map metadata on pages that contain no graphemes. The benchmark
caps its resident set at the same production limit.

Exact row compaction now derives its minimum byte request from the same
allocator-to-map layout helper used by Page.layout. This prevents layout
policy changes from under-sizing a compacted map. A sweep over 1 through
4,096 entries pins the relationship.

ReleaseFast medians use eleven sequential runs after two warmups and
report user CPU time. Rows above the 80% boundary compare the same
resident entry count before and after the new policy grows the page.

| pre-growth occupancy | before | after growth | change |
|---:|---:|---:|---:|
| 80% | 79.622 ms | 78.454 ms | -1.5% |
| 90% | 155.778 ms | 26.572 ms | -82.9% |
| 94% | 216.450 ms | 15.096 ms | -93.0% |
| 100% | 688.143 ms | 5.754 ms | -99.2% |

Real VT medians use seven sequential runs after two warmups.

| workload | before | after | change |
|---|---:|---:|---:|
| ASCII, 219 MiB | 225.927 ms | 227.741 ms | +0.8% |
| grapheme, 1 extra codepoint | 361.388 ms | 368.598 ms | +2.0% |
| grapheme, 5 extra codepoints | 449.764 ms | 456.409 ms | +1.5% |
| grapheme, 32 extra codepoints | 2.024 s | 2.024 s | +0.0% |

A standard production page retains 512 raw map slots, a 10,768-byte
map buffer, and a 409,600-byte total layout. Its usable map limit moves
from 512 to 409 entries, so capacity grows before the final allocator
chunks are consumed.
2026-07-12 07:01:55 -07:00

170 lines
5.2 KiB
Zig

//! 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. The resident count is capped
/// by the page's production grapheme capacity.
@"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 requested working set. The
// production map load limit may cap the resident set before every
// allocator chunk can be occupied.
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, @min(
page.graphemeCapacity(),
@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 (.{ @as(u8, 1), 5 }) |cps| {
inline for (.{ Mode.lookup, Mode.churn }) |mode| {
const impl = try GraphemeMap.create(alloc, .{
.entries = 64,
.cps = cps,
.mode = mode,
});
defer impl.destroy(alloc);
const bench = impl.benchmark();
_ = try bench.run(.once);
}
}
}