terminal/snapshot: vectorize grid row encoding

Row encoding previously made two scalar passes over every row (a backward
scan for the encoded cell count and a validation pass accumulating the
width-selection OR), then wrote a 3-byte header and per-width chunked
cells through separate writer calls.

Three changes, all bulk-codec only with the portable path unchanged:

  - scanRow computes the count and word-OR in @Vector(4, u64) strides.
    Trailing default cells are all-zero words, so the OR over the whole
    row equals the OR over the encoded prefix.
  - The per-cell wide-pair validation loop is skipped entirely when the
    OR carries no wide bits, which is every row of plain text.
  - Rows are emitted with a single reservation in the destination's spare
    buffer capacity (header plus cells, no writer calls), using explicit
    i8x16.shuffle truncation for the 1/2/4-byte cell widths. Zig 0.16
    disables loop auto-vectorization, so the previous "vectorizable"
    truncating loop was actually scalar. Destinations without buffered
    capacity (counting writers, a still-growing scratch) fall through to
    the streaming path.

Benchmarks ("prev" is the parent commit):

| wasm      | encode prev | encode   | decode prev | decode   |
|-----------|------------:|---------:|------------:|---------:|
| ascii     |     3.50 ms |  2.09 ms |     2.68 ms |  2.67 ms |
| styled    |     3.15 ms |  2.23 ms |     7.04 ms |  7.00 ms |
| truecolor |     5.27 ms |  4.95 ms |    12.04 ms | 11.85 ms |
| cjk       |     5.92 ms |  6.03 ms |    11.89 ms | 11.67 ms |
| grapheme  |    14.33 ms | 13.08 ms |    13.14 ms | 13.28 ms |

| native | mode   | prev    | this    |
|--------|--------|--------:|--------:|
| ascii  | encode | 41.8 ms | 24.5 ms |
| ascii  | decode | 52.4 ms | 53.4 ms |
| utf8   | encode | 47.4 ms | 47.6 ms |
| utf8   | decode | 61.2 ms | 60.9 ms |
This commit is contained in:
Mitchell Hashimoto
2026-08-15 08:57:29 -07:00
parent c1a61fddda
commit 973f619a23

View File

@@ -468,25 +468,21 @@ pub fn encode(
const row = page.getRow(y);
const cells = page.getCells(row);
// Trailing default cells decode implicitly. Wide/spacer pairs and
// Trailing default cells decode implicitly: wide/spacer pairs and
// hyperlinked or styled cells are always nonzero, so eliding the
// zero suffix never drops encoded state.
const count: usize = count: {
var i: usize = cells.len;
while (i > 0) : (i -= 1) {
if (!cells[i - 1].isZero()) break :count i;
}
break :count 0;
};
// zero suffix never drops encoded state. The scan also accumulates
// the OR of the row's cell words to select its encoded cell width.
const count: usize, const word_or: u64 = scanRow(cells);
// Validate the wide state of every encoded cell so we don't encode
// corrupt data, and accumulate the OR of the row's cell words to
// select its encoded cell width. Trailing default cells are narrow,
// so checking the encoded prefix against the full row width covers
// every pair.
var word_or: u64 = 0;
for (cells[0..count], 0..) |*cell, x| {
switch (cell.wide) {
// corrupt data. The width bits of the OR word witness whether any
// encoded cell is non-narrow at all; rows without them, the common
// case, satisfy every pair rule vacuously. Trailing default cells
// are narrow, so checking the encoded prefix against the full row
// width covers every pair.
const wide_mask: u64 = comptime @bitCast(Cell{ .width = 3 });
if (word_or & wide_mask != 0) {
for (cells[0..count], 0..) |*cell, x| switch (cell.wide) {
.narrow => {},
.wide => if (x + 1 == cells.len or
cells[x + 1].wide != .spacer_tail)
@@ -501,21 +497,66 @@ pub fn encode(
.spacer_head => if (x + 1 != cells.len or !row.wrap) {
return error.InvalidWideCell;
},
}
word_or |= classifyWord(cell);
};
}
// Canonical rows use the smallest admissible width.
const cell_width: Cell.EncodedWidth = .select(word_or);
const row_header: Row = .{
.wrap = row.wrap,
.wrap_continuation = row.wrap_continuation,
.semantic_prompt = @intFromEnum(row.semantic_prompt),
.cell_width = cell_width,
};
// The bulk codec emits the row header and its encoded cells directly
// into the destination's spare buffer capacity, so a row costs no
// writer call at all instead of one for the header and one per cell
// chunk.
if (comptime bulk_codec) emit: {
const words: [*]const u64 = @ptrCast(cells.ptr);
switch (cell_width) {
inline .one, .two, .four => |width| {
const size = comptime width.size();
const needed = 3 + count * size;
if (writer.unusedCapacityLen() < needed) break :emit;
const out = writer.unusedCapacitySlice()[0..needed];
out[0] = @bitCast(row_header);
std.mem.writeInt(u16, out[1..3], @intCast(count), .little);
encodeNarrowInto(width, words, count, out[3..]);
writer.advance(needed);
continue;
},
.eight => {
// Hyperlink IDs live in a native side table, but we
// embed them in ours, so if we have any hyperlinks we
// need to fall back to the loop below.
const witness: Cell = @bitCast(word_or);
if (!witness.hyperlink and witness.hyperlink_id == 0) {
const needed = 3 + count * 8;
if (writer.unusedCapacityLen() < needed) break :emit;
const out = writer.unusedCapacitySlice()[0..needed];
out[0] = @bitCast(row_header);
std.mem.writeInt(
u16,
out[1..3],
@intCast(count),
.little,
);
@memcpy(
out[3..],
std.mem.sliceAsBytes(cells[0..count]),
);
writer.advance(needed);
continue;
}
},
}
}
// Row header: flags then the encoded cell count.
{
const row_header: Row = .{
.wrap = row.wrap,
.wrap_continuation = row.wrap_continuation,
.semantic_prompt = @intFromEnum(row.semantic_prompt),
.cell_width = cell_width,
};
var header_bytes: [3]u8 = undefined;
header_bytes[0] = @bitCast(row_header);
std.mem.writeInt(u16, header_bytes[1..3], @intCast(count), .little);
@@ -559,6 +600,47 @@ pub fn encode(
try encodeGraphemes(page, writer);
}
/// The encoded cell count (through the last nonzero cell) and the bitwise
/// OR of every encoded cell word for one row.
fn scanRow(cells: []const TerminalCell) struct { usize, u64 } {
if (comptime bulk_codec) {
const words: [*]const u64 = @ptrCast(cells.ptr);
const V = @Vector(4, u64);
const VPtr = *align(@alignOf(u64)) const V;
// Count the zero cells using vectorized instructions
var count = cells.len;
while (count >= 4) {
const tail = @as(VPtr, @ptrCast(words + count - 4)).*;
if (@reduce(.Or, tail) != 0) break;
count -= 4;
}
while (count > 0 and words[count - 1] == 0) count -= 1;
// Accumulate the OR of the classifaction vectorized, with the
// scalar tail continuing from where the vector loop stopped.
const word_or: u64 = word_or: {
var acc: V = @splat(0);
var i: usize = 0;
while (i + 4 <= count) : (i += 4) {
acc |= @as(VPtr, @ptrCast(words + i)).*;
}
var word_or: u64 = @reduce(.Or, acc);
while (i < count) : (i += 1) word_or |= words[i];
break :word_or word_or;
};
return .{ count, word_or };
}
// Scalar path, count backwards
var count: usize = cells.len;
while (count > 0 and cells[count - 1].isZero()) count -= 1;
var word_or: u64 = 0;
for (cells[0..count]) |*cell| word_or |= classifyWord(cell);
return .{ count, word_or };
}
/// The word used to select a row's encoded cell width. This is the cell's
/// wire word with the hyperlink flag reflecting the native cell, so linked
/// cells and nonzero native padding disqualify every narrow width.
@@ -584,35 +666,90 @@ fn encodeNarrowCells(
var i: usize = 0;
while (i < cells.len) {
const n = @min(cells.len - i, chunk.len / size);
for (cells[i..][0..n], 0..) |*cell, j| {
std.mem.writeInt(
width.Int(),
chunk[j * size ..][0..size],
width.truncate(classifyWord(cell)),
.little,
);
}
try writer.writeAll(chunk[0 .. n * size]);
i += n;
}
}
if (comptime native_matches_wire) {
// A pure truncating loop over integers that the compiler can
// vectorize.
const words: [*]const u64 = @ptrCast(cells.ptr);
for (0..n) |j| {
std.mem.writeInt(
width.Int(),
chunk[j * size ..][0..size],
width.truncate(words[i + j]),
.little,
);
/// Truncate one row's cell words into `out` at the given encoded width.
fn encodeNarrowInto(
comptime width: Cell.EncodedWidth,
words: [*]const u64,
count: usize,
out: []u8,
) void {
comptime assert(bulk_codec);
const size = comptime width.size();
const V = @Vector(2, u64);
const shift: V = @splat(comptime switch (width) {
.one, .two => @bitOffsetOf(Cell, "content"),
.four => 0,
.eight => unreachable,
});
const mask: @Vector(size * 4, i32) = comptime mask: {
var mask: [size * 4]i32 = undefined;
for (0..2) |lane| {
for (0..size) |byte| {
mask[lane * size + byte] = @intCast(lane * 8 + byte);
mask[(lane + 2) * size + byte] =
~@as(i32, @intCast(lane * 8 + byte));
}
}
break :mask mask;
};
var j: usize = 0;
while (j + 4 <= count) : (j += 4) encodeNarrowStep(
width,
words,
j,
out,
shift,
mask,
);
if (j < count) {
if (count >= 4) {
encodeNarrowStep(width, words, count - 4, out, shift, mask);
} else {
for (cells[i..][0..n], 0..) |*cell, j| {
while (j < count) : (j += 1) {
std.mem.writeInt(
width.Int(),
chunk[j * size ..][0..size],
width.truncate(classifyWord(cell)),
out[j * size ..][0..size],
width.truncate(words[j]),
.little,
);
}
}
try writer.writeAll(chunk[0 .. n * size]);
i += n;
}
}
/// Emit four truncated cell words starting at cell index `j`.
inline fn encodeNarrowStep(
comptime width: Cell.EncodedWidth,
words: [*]const u64,
j: usize,
out: []u8,
shift: @Vector(2, u64),
mask: @Vector(width.size() * 4, i32),
) void {
const size = comptime width.size();
const VPtr = *align(@alignOf(u64)) const @Vector(2, u64);
const lo: @Vector(16, u8) = @bitCast(@as(VPtr, @ptrCast(words + j)).* >> shift);
const hi: @Vector(16, u8) = @bitCast(@as(VPtr, @ptrCast(words + j + 2)).* >> shift);
@as(
*align(1) @Vector(size * 4, u8),
@ptrCast(out[j * size ..].ptr),
).* = @shuffle(u8, lo, hi, mask);
}
/// Encode the grapheme suffix section for every kind 1 cell in the grid.
fn encodeGraphemes(
page: *const TerminalPage,