terminal/snapshot: resolve wide pairs in a per-row pass

Cell decoding ran wide-pair normalization inline for every decoded cell:
two neighbor loads and a switch per cell, even though the overwhelming
majority of rows contain no wide cells at all. Normalization is defined
against already-stored predecessors, so running it as an ordered pass
over the stored row afterward is exactly equivalent to interleaving it.

The word-cell decoders now accumulate the bitwise OR of the row's wire
words as they apply cells, and the pass is gated on it: rows without
wide bits are already normalized (every cell narrow), and width-four and
narrower transports cannot encode wide bits at all, so their rows skip
the check at comptime. That removes the per-cell neighbor traffic from
all styled text, which decodes through the four-byte width.

Benchmarks ("prev" is the parent commit):

| wasm      | encode prev | encode   | decode prev | decode   |
|-----------|------------:|---------:|------------:|---------:|
| ascii     |     2.18 ms |  2.06 ms |     2.68 ms |  2.70 ms |
| styled    |     2.28 ms |  2.41 ms |     6.93 ms |  6.56 ms |
| truecolor |     4.93 ms |  5.02 ms |    11.91 ms | 11.86 ms |
| cjk       |     6.11 ms |  6.03 ms |    11.72 ms | 11.60 ms |
| grapheme  |     8.28 ms |  8.36 ms |    11.38 ms | 11.39 ms |

| native | mode   | prev    | this    |
|--------|--------|--------:|--------:|
| ascii  | encode | 24.3 ms | 25.6 ms |
| ascii  | decode | 46.7 ms | 48.1 ms |
| utf8   | encode | 41.0 ms | 41.7 ms |
| utf8   | decode | 59.5 ms | 58.5 ms |
This commit is contained in:
Mitchell Hashimoto
2026-08-15 09:10:48 -07:00
parent 2aaad3ca99
commit 7c1014ef66

View File

@@ -893,7 +893,7 @@ pub fn decode(
reader,
cells[0..count],
),
.four => try decodeWordCells(
.four => _ = try decodeWordCells(
.four,
page,
row,
@@ -913,7 +913,9 @@ pub fn decode(
try reader.readSliceAll(
std.mem.sliceAsBytes(cells[0..count]),
);
var row_or: u64 = 0;
for (0..count) |x| {
row_or |= words[x];
applyCell(
page,
row,
@@ -924,8 +926,9 @@ pub fn decode(
hyperlink_remap,
);
}
normalizeWideRow(.eight, row, cells, count, row_or);
} else {
try decodeWordCells(
_ = try decodeWordCells(
.eight,
page,
row,
@@ -1102,6 +1105,9 @@ inline fn widenValue(
/// Decode one row of width-four or fallback full-width cells through the
/// complete per-cell normalization path.
///
/// Returns the bitwise OR of every decoded wire word so callers can gate
/// the trailing wide-pair resolution pass without a second scan.
fn decodeWordCells(
comptime width: Cell.EncodedWidth,
page: *TerminalPage,
@@ -1111,8 +1117,9 @@ fn decodeWordCells(
reader: *std.Io.Reader,
style_remap: *const StyleRemap,
hyperlink_remap: *const HyperlinkRemap,
) DecodeError!void {
) DecodeError!u64 {
const size = comptime width.size();
var row_or: u64 = 0;
// The staged payload path has the complete row buffered.
const total = count * size;
@@ -1124,6 +1131,7 @@ fn decodeWordCells(
bytes[x * size ..][0..size],
.little,
));
row_or |= bits;
applyCell(
page,
row,
@@ -1135,12 +1143,14 @@ fn decodeWordCells(
);
}
reader.toss(total);
return;
normalizeWideRow(width, row, cells, count, row_or);
return row_or;
}
// Streaming sources fall back to per-cell reads.
for (0..count) |x| {
const bits = width.extend(try io.readInt(reader, width.Int()));
row_or |= bits;
applyCell(
page,
row,
@@ -1151,6 +1161,28 @@ fn decodeWordCells(
hyperlink_remap,
);
}
normalizeWideRow(width, row, cells, count, row_or);
return row_or;
}
/// Resolve wide-pair relationships for one decoded row.
///
/// Cell decoding stores every cell unresolved, so this pass applies
/// `normalizeWide` in order, which is equivalent to interleaving it with
/// the stores. Rows whose word OR carries no wide bits are already
/// normalized: every cell is narrow. Width four and narrower transports
/// cannot encode wide bits at all, so those rows skip the check entirely.
inline fn normalizeWideRow(
comptime width: Cell.EncodedWidth,
row: *const TerminalRow,
cells: []TerminalCell,
count: usize,
row_or: u64,
) void {
if (comptime width != .eight) return;
const wide_mask: u64 = comptime @bitCast(Cell{ .width = 3 });
if (row_or & wide_mask == 0) return;
for (0..count) |x| normalizeWide(row, cells, x);
}
/// Whether the value is a valid Unicode scalar value.
@@ -1160,10 +1192,10 @@ inline fn validScalar(cp: u32) bool {
/// Normalize one encoded cell word and store it at `cells[x]`.
///
/// This owns every per-cell decode rule except grapheme suffixes: content
/// validation, reserved-value degradation, style and hyperlink remapping
/// with reference counting, and wide-pair normalization against already
/// decoded neighbors.
/// This owns every per-cell decode rule except grapheme suffixes and
/// wide-pair resolution: content validation, reserved-value degradation,
/// and style and hyperlink remapping with reference counting. Callers run
/// `normalizeWideRow` over the stored row afterward.
fn applyCell(
page: *TerminalPage,
row: *TerminalRow,
@@ -1179,7 +1211,6 @@ fn applyCell(
// reference counting, or table lookups.
if (bits_wire == 0) {
storeCell(cell, 0);
normalizeWide(row, cells, x);
return;
}
@@ -1247,8 +1278,6 @@ fn applyCell(
page.hyperlink_set.release(page.memory, link_native);
};
}
normalizeWide(row, cells, x);
}
/// Resolve wide-pair relationships for the cell at `x` against its already