terminal/snapshot: slicing-by-16 software CRC32C

The software CRC32C fallback (WebAssembly and any other target without a
dedicated instruction) was the std byte-at-a-time table walk, which
profiled at ~65% of snapshot encode and ~70% of decode self-time in V8.
Replace it with slicing-by-16: sixteen bytes fold per iteration through
comptime per-position tables, so the serial dependency advances one block
at a time instead of one byte.

The hardware backends (aarch64 CRC, x86_64 SSE4.2) are unchanged, so
native is expected to be unaffected; its deltas below are run-to-run
noise.

Benchmarks: wasm is V8 (node 25), ReleaseFast + wasm-opt -O3, 80x24
terminal, 2 MiB VT corpus per workload, complete snapshot including
scrollback, best-of-5. Native is aarch64 macOS, hyperfine mean,
ghostty-bench +terminal-snapshot --loops=20. "base" is the parent commit.

| wasm      | encode base | encode   | decode base | decode   |
|-----------|------------:|---------:|------------:|---------:|
| ascii     |     7.07 ms |  3.43 ms |     6.45 ms |  2.81 ms |
| styled    |    10.54 ms |  3.14 ms |    14.37 ms |  7.12 ms |
| truecolor |    15.23 ms |  5.33 ms |    21.76 ms | 12.11 ms |
| cjk       |    25.41 ms |  5.95 ms |    30.83 ms | 12.04 ms |
| grapheme  |    27.67 ms | 14.18 ms |    27.00 ms | 13.16 ms |

| native | mode   | base    | this    |
|--------|--------|--------:|--------:|
| ascii  | encode | 40.6 ms | 41.7 ms |
| ascii  | decode | 51.2 ms | 53.2 ms |
| utf8   | encode | 45.2 ms | 47.2 ms |
| utf8   | decode | 59.8 ms | 61.6 ms |
This commit is contained in:
Mitchell Hashimoto
2026-08-15 08:50:19 -07:00
parent e3939d0f62
commit 36d8e3f777

View File

@@ -4,8 +4,11 @@
//! lookup (as of Zig 0.16), which is more than an order of magnitude slower
//! than the dedicated CRC32C instructions available on aarch64 (CRC
//! extension) and x86_64 (SSE4.2). This module selects the best backend at
//! compile time and falls back to the standard library elsewhere, including
//! WebAssembly.
//! compile time.
//!
//! Targets without a dedicated instruction, such as WebAssembly, use a
//! slicing-by-16 table implementation that processes sixteen bytes per
//! iteration instead of one.
//!
//! The resulting value is identical across all backends: this is the
//! iSCSI CRC32C parameter set (reflected, initial and final XOR
@@ -14,10 +17,6 @@
const std = @import("std");
const builtin = @import("builtin");
/// The standard-library implementation of the same parameter set. This is
/// both the portable fallback and the reference the tests compare against.
const Software = std.hash.crc.Crc32Iscsi;
const Backend = enum {
aarch64_crc,
x86_64_sse42,
@@ -58,11 +57,7 @@ pub const Crc32c = struct {
pub fn update(self: *Crc32c, bytes: []const u8) void {
self.crc = switch (comptime backend) {
.aarch64_crc, .x86_64_sse42 => updateHardware(self.crc, bytes),
.software => software: {
var crc: Software = .{ .crc = self.crc };
crc.update(bytes);
break :software crc.crc;
},
.software => Software.update(self.crc, bytes),
};
}
@@ -148,6 +143,101 @@ inline fn step(comptime T: type, crc: u32, value: T) u32 {
};
}
/// The portable software backend, used by targets without a dedicated
/// CRC32C instruction.
const Software = struct {
/// The reflected CRC32C (Castagnoli) polynomial.
const reflected_poly: u32 = 0x82F63B78;
/// Slicing tables: `tables[i][b]` is the CRC of byte `b` followed
/// by `i` zero bytes. Table zero is the classic one-byte-per-step table;
/// the higher tables let one iteration fold sixteen input bytes with
/// sixteen independent lookups instead of a sixteen-step dependency chain.
const tables: [16][256]u32 = tables: {
@setEvalBranchQuota(100_000);
var result: [16][256]u32 = undefined;
for (0..256) |n| {
var crc: u32 = n;
for (0..8) |_| {
crc = (crc >> 1) ^ (reflected_poly * (crc & 1));
}
result[0][n] = crc;
}
for (1..16) |i| {
for (0..256) |n| {
const prev = result[i - 1][n];
result[i][n] = (prev >> 8) ^ result[0][prev & 0xFF];
}
}
break :tables result;
};
/// One update pass using slicing-by-16: each iteration XORs the running
/// CRC into the first of four little-endian words and folds all sixteen
/// bytes through per-position tables. The remainder finishes one byte per
/// step through table zero.
fn update(initial: u32, bytes: []const u8) u32 {
const t = &tables;
var crc = initial;
var remaining = bytes;
while (remaining.len >= 16) : (remaining = remaining[16..]) {
const a = std.mem.readInt(u32, remaining[0..4], .little) ^ crc;
const b = std.mem.readInt(u32, remaining[4..8], .little);
const c = std.mem.readInt(u32, remaining[8..12], .little);
const d = std.mem.readInt(u32, remaining[12..16], .little);
crc = t[15][a & 0xFF] ^ t[14][(a >> 8) & 0xFF] ^
t[13][(a >> 16) & 0xFF] ^ t[12][a >> 24] ^
t[11][b & 0xFF] ^ t[10][(b >> 8) & 0xFF] ^
t[9][(b >> 16) & 0xFF] ^ t[8][b >> 24] ^
t[7][c & 0xFF] ^ t[6][(c >> 8) & 0xFF] ^
t[5][(c >> 16) & 0xFF] ^ t[4][c >> 24] ^
t[3][d & 0xFF] ^ t[2][(d >> 8) & 0xFF] ^
t[1][(d >> 16) & 0xFF] ^ t[0][d >> 24];
}
for (remaining) |byte| {
crc = (crc >> 8) ^ t[0][(crc ^ byte) & 0xFF];
}
return crc;
}
};
/// The standard-library implementation of the same parameter set. This is
/// the reference the tests compare against.
const Reference = std.hash.crc.Crc32Iscsi;
test "software slicing matches the standard library" {
// The selected backend may be hardware, so cover the sliced software
// path directly: every length around the sixteen-byte boundary, several
// alignments, and continuation across arbitrary split points.
var bytes: [512 + 19]u8 = undefined;
var prng = std.Random.DefaultPrng.init(0x511C);
prng.random().bytes(&bytes);
for (0..64 + 1) |len| {
for (0..4) |offset| {
const input = bytes[offset..][0..len];
var reference: Reference = .{ .crc = 0xFFFF_FFFF };
reference.update(input);
try std.testing.expectEqual(
reference.crc,
Software.update(0xFFFF_FFFF, input),
);
}
}
const long = bytes[0..512];
var reference: Reference = .{ .crc = 0xFFFF_FFFF };
reference.update(long);
for ([_]usize{ 0, 1, 15, 16, 17, 100, 511, 512 }) |split| {
const first = Software.update(0xFFFF_FFFF, long[0..split]);
try std.testing.expectEqual(
reference.crc,
Software.update(first, long[split..]),
);
}
}
test "matches the check value" {
// The catalog check value for CRC-32/ISCSI.
try std.testing.expectEqual(
@@ -164,7 +254,7 @@ test "matches the standard library at every length and split" {
for (0..bytes.len + 1) |len| {
const input = bytes[0..len];
try std.testing.expectEqual(
Software.hash(input),
Reference.hash(input),
Crc32c.hash(input),
);
@@ -174,7 +264,7 @@ test "matches the standard library at every length and split" {
split.update(input[0 .. len / 3]);
split.update(input[len / 3 .. len - len / 3]);
split.update(input[len - len / 3 ..]);
try std.testing.expectEqual(Software.hash(input), split.final());
try std.testing.expectEqual(Reference.hash(input), split.final());
}
}
@@ -186,7 +276,7 @@ test "matches the standard library at every alignment" {
for (0..16) |offset| {
const input = bytes[offset..][0..64];
try std.testing.expectEqual(
Software.hash(input),
Reference.hash(input),
Crc32c.hash(input),
);
}