terminal/snapshot: batch grapheme suffix codec

Grapheme suffix encoding made two full passes over the grid (a counting
pass, then an emit pass) and issued three writer calls per entry plus one
per codepoint. Every grapheme cell owns exactly one entry in the page's
grapheme map, so the section count now comes straight from
page.graphemeCount() with no counting pass, and entries are batched
through a 4 KB buffer with one writer call per flush. The per-entry size
check moved into the emit loop; an error still cancels the whole record
before any of it is emitted, so error behavior is unchanged.

Decoding similarly parsed entry headers and codepoints with one reader
call per integer. Fully buffered payloads (the common case after the
borrowed-payload commit) now parse entry headers and codepoint runs
directly from the buffered bytes, and entries whose target cell cannot
carry a suffix discard their codepoints in bulk.

Benchmarks ("prev" is the parent commit):

| wasm      | encode prev | encode   | decode prev | decode   |
|-----------|------------:|---------:|------------:|---------:|
| ascii     |     2.09 ms |  2.07 ms |     2.67 ms |  2.69 ms |
| styled    |     2.23 ms |  2.29 ms |     7.00 ms |  7.06 ms |
| truecolor |     4.95 ms |  4.91 ms |    11.85 ms | 11.99 ms |
| cjk       |     6.03 ms |  6.14 ms |    11.67 ms | 11.83 ms |
| grapheme  |    13.08 ms |  8.26 ms |    13.28 ms | 11.18 ms |

| native | mode   | prev    | this    |
|--------|--------|--------:|--------:|
| ascii  | encode | 24.5 ms | 24.3 ms |
| ascii  | decode | 53.4 ms | 50.9 ms |
| utf8   | encode | 47.6 ms | 41.2 ms |
| utf8   | decode | 60.9 ms | 59.5 ms |
This commit is contained in:
Mitchell Hashimoto
2026-08-15 08:59:48 -07:00
parent 973f619a23
commit 593762cfa1

View File

@@ -755,36 +755,57 @@ fn encodeGraphemes(
page: *const TerminalPage,
writer: *std.Io.Writer,
) EncodeError!void {
// Count and validate entries before the section header so the count is
// always exact. Rows without the native grapheme hint contain no
// grapheme cells in any intact page.
var entries: u32 = 0;
for (0..page.size.rows) |y| {
const row = page.getRow(y);
if (!row.grapheme) continue;
for (page.getCells(row)) |*cell| {
if (!cell.hasGrapheme()) continue;
const cps = page.lookupGrapheme(cell) orelse unreachable;
if (cps.len > std.math.maxInt(u16)) return error.TooManyGraphemes;
entries += 1;
}
}
try io.writeInt(writer, u32, entries);
// Every grapheme cell owns exactly one entry in the page's grapheme
// map, so the section header comes straight from the page without a
// counting pass over the grid. Rows without the native grapheme hint
// contain no grapheme cells in any intact page.
const entries = page.graphemeCount();
try io.writeInt(writer, u32, @intCast(entries));
if (entries == 0) return;
// Entries are batched into a local buffer so hot pages perform one
// writer call per flush instead of several per entry.
var buffer: [4096]u8 = undefined;
var used: usize = 0;
var emitted: usize = 0;
for (0..page.size.rows) |y| {
const row = page.getRow(y);
if (!row.grapheme) continue;
for (page.getCells(row), 0..) |*cell, x| {
if (!cell.hasGrapheme()) continue;
const cps = page.lookupGrapheme(cell) orelse unreachable;
try io.writeInt(writer, u16, @intCast(y));
try io.writeInt(writer, u16, @intCast(x));
try io.writeInt(writer, u16, @intCast(cps.len));
for (cps) |cp| try io.writeInt(writer, u32, cp);
if (cps.len > std.math.maxInt(u16)) return error.TooManyGraphemes;
emitted += 1;
const needed = 6 + cps.len * 4;
if (buffer.len - used < needed) {
try writer.writeAll(buffer[0..used]);
used = 0;
}
if (needed > buffer.len) {
// An entry larger than the whole buffer streams directly.
try io.writeInt(writer, u16, @intCast(y));
try io.writeInt(writer, u16, @intCast(x));
try io.writeInt(writer, u16, @intCast(cps.len));
for (cps) |cp| try io.writeInt(writer, u32, cp);
continue;
}
std.mem.writeInt(u16, buffer[used..][0..2], @intCast(y), .little);
std.mem.writeInt(u16, buffer[used + 2 ..][0..2], @intCast(x), .little);
std.mem.writeInt(u16, buffer[used + 4 ..][0..2], @intCast(cps.len), .little);
used += 6;
for (cps) |cp| {
std.mem.writeInt(u32, buffer[used..][0..4], cp, .little);
used += 4;
}
}
}
try writer.writeAll(buffer[0..used]);
// The declared count is trusted by decoders for framing, so the grid
// must have produced exactly that many entries.
assert(emitted == entries);
}
pub const DecodeError = std.Io.Reader.Error || error{
@@ -1183,9 +1204,23 @@ fn decodeGraphemes(
) DecodeError!void {
const entries = try io.readInt(reader, u32);
for (0..entries) |_| {
const y = try io.readInt(reader, u16);
const x = try io.readInt(reader, u16);
const cp_count = try io.readInt(reader, u16);
// The staged and borrowed payload paths have every header buffered.
const y: u16, const x: u16, const cp_count: u16 = header: {
if (reader.bufferedLen() >= 6) {
const bytes = reader.buffered()[0..6];
defer reader.toss(6);
break :header .{
std.mem.readInt(u16, bytes[0..2], .little),
std.mem.readInt(u16, bytes[2..4], .little),
std.mem.readInt(u16, bytes[4..6], .little),
};
}
break :header .{
try io.readInt(reader, u16),
try io.readInt(reader, u16),
try io.readInt(reader, u16),
};
};
// Resolve the target cell. Entries whose target cannot carry a
// suffix are optional detail: their codepoints are consumed to
@@ -1207,31 +1242,67 @@ fn decodeGraphemes(
break :target .{ .row = row, .cell = cell };
};
// Always consume every declared codepoint. Invalid scalars and NUL are
// not meaningful grapheme suffix components and are ignored. If native
// capacity is exhausted, remove any prefix already attached so the
// cell never exposes a truncated cluster.
var accept = target != null;
for (0..cp_count) |_| {
const cp = try io.readInt(reader, u32);
if (!accept) continue;
if (cp == 0 or !validScalar(cp)) continue;
// Always consume every declared codepoint. Invalid scalars and NUL
// are not meaningful grapheme suffix components and are ignored. A
// dropped entry's codepoints are discarded in bulk.
const resolved = target orelse {
try reader.discardAll(@as(usize, cp_count) * 4);
continue;
};
page.appendGrapheme(
target.?.row,
target.?.cell,
@intCast(cp),
) catch {
if (target.?.cell.hasGrapheme()) {
page.clearGrapheme(target.?.cell);
page.updateRowGraphemeFlag(target.?.row);
}
accept = false;
};
var accept = true;
var index: usize = 0;
while (index < cp_count) {
const buffered = reader.buffered();
if (buffered.len >= 4) {
const n = @min(cp_count - index, buffered.len / 4);
for (0..n) |i| applyGraphemeSuffix(
page,
resolved.row,
resolved.cell,
&accept,
std.mem.readInt(u32, buffered[i * 4 ..][0..4], .little),
);
reader.toss(n * 4);
index += n;
} else {
applyGraphemeSuffix(
page,
resolved.row,
resolved.cell,
&accept,
try io.readInt(reader, u32),
);
index += 1;
}
}
}
}
/// Attach one decoded suffix codepoint to its resolved target cell.
///
/// If native capacity is exhausted, any prefix already attached is removed
/// so the cell never exposes a truncated cluster, and `accept` latches
/// false so the entry's remaining codepoints are consumed but dropped.
fn applyGraphemeSuffix(
page: *TerminalPage,
row: *TerminalRow,
cell: *TerminalCell,
accept: *bool,
cp: u32,
) void {
if (!accept.*) return;
if (cp == 0 or !validScalar(cp)) return;
page.appendGrapheme(row, cell, @intCast(cp)) catch {
if (cell.hasGrapheme()) {
page.clearGrapheme(cell);
page.updateRowGraphemeFlag(row);
}
accept.* = false;
};
}
/// The encoded word for one native cell and its hyperlink ID.
fn cellBits(cell: TerminalCell, link_id: TerminalHyperlinkId) u64 {
if (comptime native_matches_wire) {