From 36d8e3f77779939a4413ddcd72c05ab08aeae57d Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Sat, 15 Aug 2026 08:50:19 -0700 Subject: [PATCH] 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 | --- src/crc32c.zig | 118 +++++++++++++++++++++++++++++++++++++++++++------ 1 file changed, 104 insertions(+), 14 deletions(-) diff --git a/src/crc32c.zig b/src/crc32c.zig index 8b8ebd1b2..dbfca7ef2 100644 --- a/src/crc32c.zig +++ b/src/crc32c.zig @@ -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), ); }