libghostty: much faster grapheme-heavy IO throughput (#13826)

Processing grapheme-heavy input (ZWJ sequences, emoji modifiers, flags,
combining marks) through is now almost 3x faster.

### Primary Change: PageList Capacity Projection

This workload was heavily bound by `PageList.increaseCapacity` because
pathological cases of single-dimensional growth cause repeated page
capacity doublings which get increasingly expensive because each time we
do a full allocation + clone.

So the major change is that for grapheme bytes in particular, when we
reach a capacity limit, we take the current usage for the current set of
rows and project it out to the remaining capacity of rows. Basically, we
assume that a similar workload will continue. So rather than doubling,
we're _guessing_ how much you're going to need.

In the real world, I'm not really sure if this matters at all. There are
no regressions on any regular corpus streams (asciinema, wikipedia
dumps, etc.).

### Other Changes

There are some other changes here, all found on the path to improving
grapheme IO throughput:

* The bitmap allocator now maintains `search_start` hint we update on
every allocation so that future free-scans are much faster. This is the
lowest possible place we don't have a full bitmap.

* For wasm32, we use an alternate hashing structure for small keys since
Wyhash's 64bit * 64bit multiplication is very very slow because wasm has
no widening instruction.

* Terminal `printSlice` now checks the fast path compatibility once up
front rather than on every fast-path attempt.

### Benchmarks

Data: ZWJ family/profession sequences, skin-tone modifiers, flags, and
combining marks streamed in 64 KiB chunks into an 80x24 terminal,
default modes.

| Benchmark | Before | After | Speedup |
|---|---|---|---|
| wasm, V8, 16 MiB stream | 52 MB/s | 151 MB/s | 2.9x | 
| native, terminal-stream, 64 MiB | 894 ms | 305 ms | 2.9x |

Sorry the native stuff is in ms, that's how our native `ghostty-bench`
does things versus the custom little V8 harness.

**AI usage:** Developed alongside Fable: profiling, implementation, and
benchmarks. All human language messages written myself. Validated
myself.
This commit is contained in:
Mitchell Hashimoto
2026-08-14 13:18:18 -07:00
committed by GitHub
5 changed files with 417 additions and 41 deletions

View File

@@ -4168,6 +4168,64 @@ pub fn increaseCapacity(
if (layout.total_size > size.max_page_size) {
return error.OutOfSpace;
}
// Doubling alone has a really bad behavior in that if you're
// in a pathological scenario with only one dimension of cap
// increase, you have to pay repeat double costs. Each time you
// double we have to reclone the entire page. As the page gets
// bigger this gets more expensive.
//
// Instead, for some dimensions, we do something else: we look
// at the current utilization, and project that to every row
// in the page capacity (if it fits). We just assume that your
// future workload will look the one you're currently filling.
// If we're wrong, its some wasted capacity but future growth
// still works in other dimensions.
//
// This only applies to the current page being grown. I previously
// tried a high-water-mark style solution to preallocate pages,
// which does work really well, but its not clear when you reset
// the mark.
project: {
// The dimensions we project must measure their current
// usage in the same units as the capacity field.
const used: u64 = switch (comptime tag) {
.grapheme_bytes => page.grapheme_alloc.usedBytes(page.memory),
// Living item count. Note the capacity field is a
// requested item count that Layout.init rounds in
// both directions (table to the next power of two,
// items to the load factor of that), so this is an
// approximation in capacity units; the headroom
// below absorbs the error and an undershoot only
// costs one more (non-ladder) growth event.
.styles => page.styles.count(),
else => break :project,
};
if (used == 0 or page.size.rows == 0) break :project;
// Full-page need at current density, plus 25% headroom
// for chunk rounding and fragmentation.
const density = used * @as(u64, cap.rows) / page.size.rows;
const projected_raw = density + density / 4;
// Bound the jump to 32× the pre-growth capacity. Arbitrary
// choice until we can show otherwise.
const projected = @min(
projected_raw,
@as(u64, old) * 32,
std.math.maxInt(Int),
);
if (projected <= @field(cap, field_name)) break :project;
// Only take the projection if the resulting page still fits.
var proj_cap = cap;
@field(proj_cap, field_name) = @intCast(projected);
if (Page.layout(proj_cap).total_size > size.max_page_size)
break :project;
cap = proj_cap;
}
},
};
@@ -11734,6 +11792,68 @@ test "PageList increaseCapacity to increase styles" {
}
}
test "PageList increaseCapacity styles projects capacity from page density" {
const testing = std.testing;
const alloc = testing.allocator;
var s = try init(alloc, .{ .cols = 2, .rows = 2, .max_size = 0 });
defer s.deinit();
const original_cap = s.pages.first.?.capacity().styles;
// Write styled cells so the page has a measurable per-row style
// density (unlike the plain doubling test above, which grows a
// page with no styles in use).
const bold: stylepkg.Style = .{ .flags = .{ .bold = true } };
{
try testing.expect(s.pages.first == s.pages.last);
const page = s.pages.first.?.page();
for (0..s.rows) |y| {
for (0..s.cols) |x| {
const rac = page.getRowAndCell(x, y);
const style_id = try page.styles.add(page.memory, bold);
rac.row.styled = true;
rac.cell.* = .{
.content_tag = .codepoint,
.content = .{ .codepoint = .{ .data = @intCast(x + 1) } },
.style_id = style_id,
};
}
}
}
_ = try s.increaseCapacity(s.pages.first.?, .styles);
{
try testing.expect(s.pages.first == s.pages.last);
const page = s.pages.first.?.page();
// The page uses only two active rows out of thousands of rows
// of capacity, so the projected full-page need saturates the
// 32x-per-event growth bound instead of merely doubling.
try testing.expectEqual(
original_cap * 32,
page.capacity.styles,
);
// All cell content and styles are preserved by the growth.
for (0..s.rows) |y| {
for (0..s.cols) |x| {
const rac = page.getRowAndCell(x, y);
try testing.expectEqual(
@as(u21, @intCast(x + 1)),
rac.cell.content.codepoint.data,
);
try testing.expect(rac.cell.style_id != stylepkg.default_id);
try testing.expect(bold.eql(
page.styles.get(page.memory, rac.cell.style_id).*,
));
}
}
}
}
test "PageList increaseCapacity to increase graphemes" {
const testing = std.testing;
const alloc = testing.allocator;
@@ -11778,6 +11898,70 @@ test "PageList increaseCapacity to increase graphemes" {
}
}
test "PageList increaseCapacity graphemes projects capacity from page density" {
const testing = std.testing;
const alloc = testing.allocator;
var s = try init(alloc, .{ .cols = 2, .rows = 2, .max_size = 0 });
defer s.deinit();
const original_cap = s.pages.first.?.capacity().grapheme_bytes;
// Write cells with grapheme data so the page has a measurable
// per-row grapheme density (unlike the plain doubling test above,
// which grows a page with no grapheme usage).
{
try testing.expect(s.pages.first == s.pages.last);
const page = s.pages.first.?.page();
for (0..s.rows) |y| {
for (0..s.cols) |x| {
const rac = page.getRowAndCell(x, y);
rac.cell.* = .{
.content_tag = .codepoint,
.content = .{ .codepoint = .{ .data = @intCast(x + 1) } },
};
try page.appendGrapheme(rac.row, rac.cell, 0x0301);
try page.appendGrapheme(rac.row, rac.cell, 0x0302);
}
}
}
_ = try s.increaseCapacity(s.pages.first.?, .grapheme_bytes);
{
try testing.expect(s.pages.first == s.pages.last);
const page = s.pages.first.?.page();
// The page uses only two active rows out of thousands of rows
// of capacity, so the projected full-page need saturates the
// 32x-per-event growth bound instead of merely doubling.
try testing.expectEqual(
original_cap * 32,
page.capacity.grapheme_bytes,
);
// All cell and grapheme content is preserved by the growth.
try testing.expectEqual(
@as(usize, s.rows * s.cols),
page.graphemeCount(),
);
for (0..s.rows) |y| {
for (0..s.cols) |x| {
const rac = page.getRowAndCell(x, y);
try testing.expectEqual(
@as(u21, @intCast(x + 1)),
rac.cell.content.codepoint.data,
);
const cps = page.lookupGrapheme(rac.cell).?;
try testing.expectEqual(@as(usize, 2), cps.len);
try testing.expectEqual(@as(u21, 0x0301), cps[0]);
try testing.expectEqual(@as(u21, 0x0302), cps[1]);
}
}
}
}
test "PageList increaseCapacity to increase hyperlinks" {
const testing = std.testing;
const alloc = testing.allocator;

View File

@@ -522,11 +522,55 @@ pub fn printRepeat(self: *Terminal, count_req: usize) !void {
/// slower per-codepoint path. They're less common and this is optimized
/// for the aforementioned cases.
pub fn printSlice(self: *Terminal, cps: []const u32) !void {
// Check if we can do the fast path up front. If we can't
// we need to go back to scalar `print`.
const fast = fast: {
// Only the main display is supported.
if (self.status_display != .main) break :fast false;
// Modes that require per-codepoint handling in print().
// Wraparound is required (its the default) so that our
// row-fill logic below can assume soft-wrap semantics. Insert
// mode shifts cells per print.
if (self.modes.get(.insert)) break :fast false;
if (!self.modes.get(.wraparound)) break :fast false;
// Charset must map ASCII as-is (true unless a DEC special
// charset is actively invoked, which is rare).
const screen: *Screen = self.screens.active;
if (screen.charset.single_shift != null) break :fast false;
switch (screen.charset.charsets.get(screen.charset.gl)) {
.utf8, .ascii => {},
else => break :fast false,
}
// Hyperlinks require per-cell map bookkeeping.
if (screen.cursor.hyperlink_id != 0) break :fast false;
break :fast true;
};
if (!fast) {
for (cps) |cp| try self.print(@intCast(cp));
return;
}
const grapheme_cluster = self.modes.get(.grapheme_cluster);
// When grapheme clustering is enabled and a left margin is set,
// print() consults the cell left of the margin after wrapping,
// which we can't reason about here. Restrict the fast path to
// the [0x10, 0xFF] range in that case (those never cluster).
const allow_unicode = !grapheme_cluster or self.scrolling_region.left == 0;
var i: usize = 0;
while (i < cps.len) {
// Try the fast-path print first. This will return the number of
// codepoints it consumed.
const consumed = try self.printSliceFast(cps[i..]);
const consumed = try self.printSliceFast(
cps[i..],
grapheme_cluster,
allow_unicode,
);
if (consumed > 0) {
i += consumed;
continue;
@@ -548,32 +592,16 @@ pub fn printSlice(self: *Terminal, cps: []const u32) !void {
///
/// The fast path handles runs of narrow (width 1) and wide (width 2)
/// codepoints being written to simple cells. Everything else (zero
/// width codepoints, grapheme cluster continuations, insert mode,
/// charset mapping, hyperlinks, complex cells, etc.) is rejected so
/// `print` can handle it with full generality.
fn printSliceFast(self: *Terminal, cps: []const u32) !usize {
// Only the main display is supported.
if (self.status_display != .main) return 0;
// Modes that require per-codepoint handling in print(). Wraparound
// is required (its the default) so that our row-fill logic below can
// assume soft-wrap semantics. Insert mode shifts cells per print.
if (self.modes.get(.insert)) return 0;
if (!self.modes.get(.wraparound)) return 0;
/// width codepoints, grapheme cluster continuations, complex cells,
/// etc.) is rejected so `print` can handle it with full generality.
fn printSliceFast(
self: *Terminal,
cps: []const u32,
grapheme_cluster: bool,
allow_unicode: bool,
) !usize {
const screen: *Screen = self.screens.active;
// Charset must map ASCII as-is (true unless a DEC special charset
// is actively invoked, which is rare).
if (screen.charset.single_shift != null) return 0;
switch (screen.charset.charsets.get(screen.charset.gl)) {
.utf8, .ascii => {},
else => return 0,
}
// Hyperlinks require per-cell map bookkeeping.
if (screen.cursor.hyperlink_id != 0) return 0;
// Codepoints in [0x10, 0xFF] are always narrow (width 1, matching
// the c <= 0xFF fast path in print) and can never interact with
// grapheme clustering (which requires a codepoint > 0xFF).
@@ -584,13 +612,6 @@ fn printSliceFast(self: *Terminal, cps: []const u32) !usize {
// 2027) is enabled, if they are a grapheme break from the
// previously printed codepoint (so print would never attach them
// to the previous cell).
const grapheme_cluster = self.modes.get(.grapheme_cluster);
// When grapheme clustering is enabled and a left margin is set,
// print() consults the cell left of the margin after wrapping,
// which we can't reason about here. Restrict the fast path to
// the [0x10, 0xFF] range in that case (those never cluster).
const allow_unicode = !grapheme_cluster or self.scrolling_region.left == 0;
// Codepoints in [0x10, 0xFF] are always narrow: print()
// hardcodes width 1 for c <= 0xFF (no width table lookup).
@@ -619,14 +640,46 @@ fn printSliceFast(self: *Terminal, cps: []const u32) !usize {
}
// The first codepoint requires care when grapheme clustering is
// enabled: print() examines the previous *cell* which can hold
// state (grapheme data) that we can't cheaply reason about here.
// Note this includes the pending-wrap state: print() may attach
// to the pending cell *instead of wrapping*. We only take the
// first codepoint if the cursor is at column zero with no pending
// wrap, where print() skips clustering entirely.
if (grapheme_cluster) {
if (screen.cursor.pending_wrap or screen.cursor.x != 0) return 0;
// enabled: print() may attach it to the previous *cell* instead
// of writing a new one. Take the first codepoint only when we can
// determine — computing exactly what print() would — that it
// starts a new cluster. At column zero with no pending wrap,
// print() skips clustering entirely. Otherwise resolve the
// previous cell the way print() does and check for a break.
//
// Note the pending-wrap rejection: print() may attach to the
// pending cell *instead of wrapping*, which we can't model here.
if (grapheme_cluster and screen.cursor.x != 0) gate: {
if (screen.cursor.pending_wrap) return 0;
// Resolve the content cell to our left exactly like print():
// if the immediate left cell is a wide spacer tail, the
// content lives one further left. (A spacer tail can never
// be at column zero — its wide half would have to be in the
// previous row — so the second cursorCellLeft is in bounds.)
const immediate = screen.cursorCellLeft(1);
const prev: *Cell = switch (immediate.wide) {
.spacer_tail => screen.cursorCellLeft(2),
else => immediate,
};
// An empty previous cell is necessarily a grapheme break.
if (prev.codepoint() == 0) break :gate;
// Grapheme data on the previous cell requires the full
// cluster state machine replay; only print() can do that.
if (prev.hasGrapheme()) return 0;
// A simple single-codepoint previous cell: print() would run
// exactly this break check from the default state.
var state: uucode.grapheme.BreakState = .default;
if (!unicode.graphemeBreak(
prev.content.codepoint.data,
@intCast(cp0),
&state,
)) return 0;
} else if (grapheme_cluster) {
if (screen.cursor.pending_wrap) return 0;
}
// The width lookup is a runtime value while printSliceFill is

View File

@@ -44,6 +44,11 @@ pub fn BitmapAllocator(comptime chunk_size: comptime_int) type {
bitmap: Offset(u64),
bitmap_count: usize,
/// Lowest bitmap word index that may contain a free bit; words
/// below it are fully allocated, so alloc scans can start here
/// instead of at zero.
search_start: usize = 0,
/// The contiguous buffer of chunks.
chunks: Offset(u8),
@@ -95,9 +100,21 @@ pub fn BitmapAllocator(comptime chunk_size: comptime_int) type {
return error.OutOfMemory;
// Find the index of the free chunk. This also marks it as used.
// Words below search_start have no free bits, so no free span
// can start in or cross them and it is safe to skip them.
const bitmaps = self.bitmap.ptr(base);
const idx = findFreeChunks(bitmaps[0..self.bitmap_count], chunk_count) orelse
const start = @min(self.search_start, self.bitmap_count);
const rel = findFreeChunks(bitmaps[start..self.bitmap_count], chunk_count) orelse
return error.OutOfMemory;
const idx = start * bitmap_bit_size + rel;
// Advance past any words the allocation just filled so the
// next scan starts at the first word with a free bit.
self.search_start = start: {
var new_start = start;
while (new_start < self.bitmap_count and bitmaps[new_start] == 0) new_start += 1;
break :start new_start;
};
const chunks = self.chunks.ptr(base);
const ptr: [*]T = @ptrCast(@alignCast(&chunks[idx * chunk_size]));
@@ -116,6 +133,12 @@ pub fn BitmapAllocator(comptime chunk_size: comptime_int) type {
const chunks = self.chunks.ptr(base);
const chunk_idx = @divExact(@intFromPtr(slice.ptr) - @intFromPtr(chunks), chunk_size);
// The freed word gains free bits, so scans must not skip it.
self.search_start = @min(
self.search_start,
@divFloor(chunk_idx, bitmap_bit_size),
);
const bitmaps = self.bitmap.ptr(base);
// Current bitmap index.
@@ -659,6 +682,90 @@ test "BitmapAllocator alloc and free one bitmap" {
);
}
test "BitmapAllocator search hint skips full words" {
const Alloc = BitmapAllocator(1);
// Capacity such that we'll have 3 bitmaps.
const cap = Alloc.bitmap_bit_size * 3;
const testing = std.testing;
const alloc = testing.allocator;
const layout = Alloc.layout(cap);
const buf = try alloc.alignedAlloc(u8, Alloc.base_align, layout.total_size);
defer alloc.free(buf);
var bm = Alloc.init(.init(buf), layout);
try testing.expectEqual(@as(usize, 0), bm.search_start);
// Fill the first bitmap word exactly with single-chunk allocations.
var first: []u8 = undefined;
for (0..Alloc.bitmap_bit_size) |i| {
const slice = try bm.alloc(u8, buf, 1);
if (i == 0) first = slice;
}
// The first word is exhausted so scans start at the second word.
try testing.expectEqual(@as(usize, 1), bm.search_start);
// The next allocation is the first chunk of the second word, so
// the skipped-word index arithmetic must still yield the right
// chunk address.
const next = try bm.alloc(u8, buf, 1);
try testing.expectEqual(
@intFromPtr(first.ptr) + Alloc.bitmap_bit_size,
@intFromPtr(next.ptr),
);
// Freeing a chunk in the first word lowers the hint so the freed
// space is found again.
bm.free(buf, first);
try testing.expectEqual(@as(usize, 0), bm.search_start);
const again = try bm.alloc(u8, buf, 1);
try testing.expectEqual(@intFromPtr(first.ptr), @intFromPtr(again.ptr));
}
test "BitmapAllocator search hint does not skip partial words" {
const Alloc = BitmapAllocator(1);
// Capacity such that we'll have 2 bitmaps.
const cap = Alloc.bitmap_bit_size * 2;
const testing = std.testing;
const alloc = testing.allocator;
const layout = Alloc.layout(cap);
const buf = try alloc.alignedAlloc(u8, Alloc.base_align, layout.total_size);
defer alloc.free(buf);
var bm = Alloc.init(.init(buf), layout);
// Allocate all but 4 chunks of the first word.
var first: []u8 = undefined;
for (0..Alloc.bitmap_bit_size - 4) |i| {
const slice = try bm.alloc(u8, buf, 1);
if (i == 0) first = slice;
}
// An 8-chunk run can't fit the 4 remaining bits (small runs never
// span words), so it comes from the second word — but the hint
// must stay at the first word, which still has free bits.
const big = try bm.alloc(u8, buf, 8);
try testing.expectEqual(
@intFromPtr(first.ptr) + Alloc.bitmap_bit_size,
@intFromPtr(big.ptr),
);
try testing.expectEqual(@as(usize, 0), bm.search_start);
// A 4-chunk run fits the first word's remaining bits exactly; a
// hint that skipped the partial word would wrongly place this in
// the second word (or report OutOfMemory once that filled).
const small = try bm.alloc(u8, buf, 4);
try testing.expectEqual(
@intFromPtr(first.ptr) + Alloc.bitmap_bit_size - 4,
@intFromPtr(small.ptr),
);
// Now the first word is full and the hint advances past it.
try testing.expectEqual(@as(usize, 1), bm.search_start);
}
test "BitmapAllocator alloc and free half bitmap" {
const Alloc = BitmapAllocator(1);
// Capacity such that we'll have 3 bitmaps.

View File

@@ -40,6 +40,7 @@
//! by any removal.
const std = @import("std");
const builtin = @import("builtin");
const assert = @import("../quirks.zig").inlineAssert;
const mem = std.mem;
const Allocator = mem.Allocator;
@@ -74,6 +75,29 @@ fn AutoContext(comptime K: type) type {
pub fn hash(_: @This(), key: K) u64 {
if (comptime std.meta.hasUniqueRepresentation(K)) {
// On wasm32, Wyhash's 64x64 -> 128 multiplies lower to
// __multi3 libcalls (wasm has no widening multiply
// instruction), which makes the hash itself the
// dominant cost of small-key map operations.
//
// Keys here fit in a u64, so use a splitmix64-style finalizer
// instead: two native i64.mul with full avalanche in
// both the low bits (slot index) and high bits
// (metadata fingerprint).
if (comptime builtin.cpu.arch.isWasm() and @sizeOf(K) <= 8) {
var x: u64 = 0;
@memcpy(
std.mem.asBytes(&x)[0..@sizeOf(K)],
std.mem.asBytes(&key),
);
x ^= x >> 33;
x *%= 0xff51afd7ed558ccd;
x ^= x >> 33;
x *%= 0xc4ceb9fe1a85ec53;
x ^= x >> 33;
return x;
}
// LLVM 21 (Zig 0.16) failed to inline this which resulted
// in a measurable almost 2x slowdown on our hyperlink map
// benchmark. So, force it.

View File

@@ -90,6 +90,14 @@ pub const Style = struct {
/// True if the style is the default style.
pub inline fn default(self: Style) bool {
// On wasm, eql converts both sides to packed form; the default
// side is comptime-known, so bake it and convert only self.
// This is called on every SGR change so it's worth it.
if (comptime builtin.cpu.arch.isWasm()) {
const d: u128 = comptime @bitCast(PackedStyle.fromStyle(.{}));
return @as(u128, @bitCast(PackedStyle.fromStyle(self))) == d;
}
return self.eql(.{});
}