terminal/snapshot: single-pass style entry codec

Style entries went through roughly five writer or reader vtable calls
each: encode wrote three 4-byte colors and two u16s separately, and
decode read 16 bytes into a stack buffer only to re-parse it through a
nested fixed reader, one small read per field. Style-heavy pages carry
hundreds of entries per page, so encode now assembles each entry in a
16-byte buffer with a single write, and decode parses the fixed-size
entry directly from a byte array. Entries additionally parse straight
from the buffered payload (ID and value together) when it is contiguous.

Inserting a decoded style also hashed twice: an explicit `lookup` before
`add`, even though `add` already returns the existing entry for repeated
values. Insert with `add` alone, taking one reference per accepted table
entry, and surrender those references through the encoded-ID remap after
grid decoding, the same scheme hyperlink entries already use. Refcount
outcomes are identical: each distinct style nets its cell references.

Benchmarks ("prev" is the parent commit):

| wasm      | encode prev | encode   | decode prev | decode   |
|-----------|------------:|---------:|------------:|---------:|
| ascii     |     2.13 ms |  2.12 ms |     2.54 ms |  2.59 ms |
| styled    |     2.29 ms |  2.23 ms |     6.75 ms |  6.43 ms |
| truecolor |     4.95 ms |  3.44 ms |    12.05 ms |  8.35 ms |
| cjk       |     6.23 ms |  5.99 ms |    11.39 ms | 11.39 ms |
| grapheme  |     8.72 ms |  8.33 ms |    11.27 ms | 10.89 ms |

| native | mode   | prev    | this    |
|--------|--------|--------:|--------:|
| ascii  | encode | 24.4 ms | 24.7 ms |
| ascii  | decode | 45.1 ms | 47.5 ms |
| utf8   | encode | 41.9 ms | 41.9 ms |
| utf8   | decode | 58.6 ms | 58.8 ms |
This commit is contained in:
Mitchell Hashimoto
2026-08-15 09:18:20 -07:00
parent 47a5182621
commit 1359973aef
2 changed files with 80 additions and 66 deletions

View File

@@ -382,23 +382,36 @@ fn decodePayloadBody(
.empty;
defer hyperlink_remap.deinit(alloc);
// Styles
// Styles. The complete fixed-size entry is parsed from the buffered
// payload when possible so each entry costs no reader calls.
const style_entry_len = @sizeOf(TerminalStyleId) + style.len;
for (0..header.style_count) |_| {
const native_id = try io.readInt(reader, TerminalStyleId);
const value = try style.decodeOrNull(reader);
const native_id: TerminalStyleId, const value = entry: {
if (reader.bufferedLen() >= style_entry_len) {
const bytes = reader.buffered()[0..style_entry_len];
defer reader.toss(style_entry_len);
break :entry .{
std.mem.readInt(TerminalStyleId, bytes[0..2], .little),
style.parseOrNull(bytes[2..][0..style.len]),
};
}
break :entry .{
try io.readInt(reader, TerminalStyleId),
try style.decodeOrNull(reader),
};
};
// Zero is reserved for the implicit default. For a duplicate encoded
// ID, the first entry wins and this complete entry is simply ignored.
if (native_id == 0 or style_remap.contains(native_id)) continue;
// Invalid/default styles map to the native default. Repeated concrete
// values share the existing native entry, while capacity failure also
// degrades only this style.
// Invalid/default styles map to the native default. `add` returns
// the existing entry for a repeated concrete value, taking one
// reference either way, while capacity failure degrades only this
// style. The references are surrendered through the remap below
// once every cell reference is installed.
const decoded_id: TerminalStyleId = if (value) |valid| decoded: {
if (valid.default()) break :decoded 0;
if (page.styles.lookup(page.memory, valid)) |existing| {
break :decoded existing;
}
break :decoded page.styles.add(
page.memory,
valid,
@@ -434,16 +447,17 @@ fn decodePayloadBody(
&hyperlink_remap,
);
// A newly inserted table value starts with one reference so grid decoding
// can safely attach it to any number of cells. Unlike organically built
// pages, that initial reference does not itself represent a cell. Release
// it once per distinct live style after every cell reference is installed;
// unused entries then become dead and disappear from canonical re-encoding.
for (1..@as(usize, page.styles.next_id)) |raw_id| {
const id: TerminalStyleId = @intCast(raw_id);
if (page.styles.refCount(page.memory, id) > 0) {
page.styles.release(page.memory, id);
}
// Every accepted table entry took one reference through `add` so grid
// decoding can safely attach its style to any number of cells. Unlike
// organically built pages, those references do not themselves represent
// cells. Release through the encoded-ID remap, so duplicate values
// which deduplicated to the same native ID each surrender their own
// reference; unused entries then become dead and disappear from
// canonical re-encoding.
var style_it = style_remap.seen.iterator(.{});
while (style_it.next()) |encoded_id| {
const id = style_remap.entries[encoded_id];
if (id != 0) page.styles.release(page.memory, id);
}
// Hyperlink insertion likewise creates one temporary reference for every

View File

@@ -47,19 +47,14 @@
const std = @import("std");
const test_fixture = @import("fixture.zig");
const io = @import("io.zig");
const sgr = @import("../sgr.zig");
const terminal_style = @import("../style.zig");
/// Number of bytes written by `encode`, calculated using the encoder itself
/// so this remains synchronized with the field-by-field wire format.
pub const len = computeLen();
comptime {
// This size is part of the wire format. If it changes, the snapshot
// version and golden fixtures must also change.
std.debug.assert(len == 16);
}
/// Number of bytes in one encoded style entry. This size is part of the
/// wire format: the codec reads and writes fixed offsets within an entry
/// of exactly this size, so if the layout changes, the snapshot version
/// and golden fixtures must also change.
pub const len = 16;
const Flags = packed struct(u16) {
bold: bool = false,
@@ -80,8 +75,8 @@ const ColorKind = enum(u8) {
rgb = 2,
};
/// Errors possible while decoding one style entry.
pub const DecodeError = std.Io.Reader.Error || error{
/// Semantic validation errors for one complete style entry buffer.
const ParseError = error{
/// A color kind is not defined by snapshot version 1.
InvalidColorKind,
@@ -98,14 +93,21 @@ pub const DecodeError = std.Io.Reader.Error || error{
InvalidReserved,
};
/// Errors possible while decoding one style entry.
pub const DecodeError = std.Io.Reader.Error || ParseError;
/// Encode one terminal style as a fixed-size snapshot style entry.
///
/// The entry is assembled in a fixed buffer and written once, so hot
/// encoders perform a single writer call per style.
pub fn encode(
value: terminal_style.Style,
writer: *std.Io.Writer,
) std.Io.Writer.Error!void {
try encodeColor(value.fg_color, writer);
try encodeColor(value.bg_color, writer);
try encodeColor(value.underline_color, writer);
var encoded: [len]u8 = @splat(0);
encodeColorBuf(encoded[0..4], value.fg_color);
encodeColorBuf(encoded[4..8], value.bg_color);
encodeColorBuf(encoded[8..12], value.underline_color);
const flags: Flags = .{
.bold = value.flags.bold,
@@ -118,17 +120,26 @@ pub fn encode(
.overline = value.flags.overline,
.underline = @intFromEnum(value.flags.underline),
};
try io.writeInt(writer, u16, @bitCast(flags));
try io.writeInt(writer, u16, 0);
std.mem.writeInt(u16, encoded[12..14], @bitCast(flags), .little);
try writer.writeAll(&encoded);
}
/// Decode and validate one fixed-size snapshot style entry.
pub fn decode(reader: *std.Io.Reader) DecodeError!terminal_style.Style {
const fg_color = try decodeColor(reader);
const bg_color = try decodeColor(reader);
const underline_color = try decodeColor(reader);
var encoded: [len]u8 = undefined;
try reader.readSliceAll(&encoded);
return parse(&encoded);
}
const flags: Flags = @bitCast(try io.readInt(reader, u16));
/// Decode and validate one complete fixed-size style entry buffer.
fn parse(encoded: *const [len]u8) ParseError!terminal_style.Style {
const fg_color = try parseColor(encoded[0..4]);
const bg_color = try parseColor(encoded[4..8]);
const underline_color = try parseColor(encoded[8..12]);
const flags: Flags = @bitCast(
std.mem.readInt(u16, encoded[12..14], .little),
);
if (flags.reserved != 0) return error.InvalidFlags;
const underline = std.enums.fromInt(
@@ -136,7 +147,7 @@ pub fn decode(reader: *std.Io.Reader) DecodeError!terminal_style.Style {
flags.underline,
) orelse return error.InvalidUnderline;
const reserved = try io.readInt(reader, u16);
const reserved = std.mem.readInt(u16, encoded[14..16], .little);
if (reserved != 0) return error.InvalidReserved;
return .{
@@ -167,9 +178,7 @@ pub fn decodeOrDiscard(
) DecodeError!terminal_style.Style {
var encoded: [len]u8 = undefined;
try reader.readSliceAll(&encoded);
var source: std.Io.Reader = .fixed(&encoded);
return decode(&source);
return parse(&encoded);
}
/// Decode one complete entry, returning null for invalid semantic contents.
@@ -193,11 +202,16 @@ pub fn decodeOrNull(
};
}
fn encodeColor(
/// `decodeOrNull` over one already-buffered entry, for enclosing codecs
/// that parse many entries from a flat payload without reader calls.
pub fn parseOrNull(encoded: *const [len]u8) ?terminal_style.Style {
return parse(encoded) catch null;
}
fn encodeColorBuf(
encoded: *[4]u8,
value: terminal_style.Style.Color,
writer: *std.Io.Writer,
) std.Io.Writer.Error!void {
var encoded: [4]u8 = @splat(0);
) void {
switch (value) {
.none => encoded[0] = @intFromEnum(ColorKind.none),
.palette => |index| {
@@ -211,23 +225,18 @@ fn encodeColor(
encoded[3] = rgb.b;
},
}
try writer.writeAll(&encoded);
}
fn decodeColor(
reader: *std.Io.Reader,
) DecodeError!terminal_style.Style.Color {
// Colors are always 4 bytes
var encoded: [4]u8 = undefined;
try reader.readSliceAll(&encoded);
fn parseColor(
encoded: *const [4]u8,
) ParseError!terminal_style.Style.Color {
// Kind must be something we know about.
const kind = std.enums.fromInt(ColorKind, encoded[0]) orelse {
return error.InvalidColorKind;
};
return switch (kind) {
.none => if (std.mem.eql(u8, encoded[1..], &.{ 0, 0, 0 }))
.none => if (encoded[1] == 0 and encoded[2] == 0 and encoded[3] == 0)
.none
else
error.InvalidColor,
@@ -243,15 +252,6 @@ fn decodeColor(
};
}
fn computeLen() usize {
comptime {
var buf: [128]u8 = undefined;
var writer: std.Io.Writer = .fixed(&buf);
encode(.{}, &writer) catch unreachable;
return writer.end;
}
}
const test_golden_fixture = test_fixture.parse(@embedFile("testdata/style-v1.hex"));
test "golden encoding and decoding" {