terminal: optimize OSC string reading with SIMD

We now have large OSCs (e.g. Kitty clipboard protocol) on the order
of megabytes. OSC was still byte-at-a-time. This adds a vector-optimized
plus bulk storing path to OSC, similar to APC.

Throughput measured with the terminal-stream benchmark:

| Corpus                   | Before | After | Speedup |
|--------------------------|--------|-------|---------|
| OSC 52, 1MiB payloads    | 446ms  | 14ms  | 32x     |
| OSC 5522, 1MiB payloads  | 446ms  | 15ms  | 30x     |
| OSC 5522, 64KiB payloads | 448ms  | 14ms  | 32x     |
| OSC 5522, 4KiB payloads  | 448ms  | 16ms  | 29x     |
| Tiny titles (~24B each)  | 450ms  | 73ms  | 6.2x    |
| Mixed OSCs (16MiB)       | 595ms  | 531ms | 1.13x   |

Used Fable to help validate this with a barrage of differential tests.
The actual implementation was AI-written but was guided to basically
mimic the APC path and then I hand verified everything too.
This commit is contained in:
Mitchell Hashimoto
2026-08-25 06:36:13 -07:00
parent 046a45a5fc
commit 8c5bc3d29f
3 changed files with 388 additions and 7 deletions

View File

@@ -132,13 +132,7 @@ pub fn TaggedUnion(
/// Returns the value type for the given tag.
pub fn Value(comptime tag: Tag) type {
@setEvalBranchQuota(10000);
inline for (@typeInfo(Union).@"union".fields) |field| {
const field_tag = @field(Tag, field.name);
if (field_tag == tag) return field.type;
}
unreachable;
return @FieldType(Union, @tagName(tag));
}
};
}

View File

@@ -540,6 +540,39 @@ pub const Parser = struct {
try self.writer.writeByte(byte);
}
/// Append a slice without permitting the backing allocation to
/// grow beyond max_bytes. This matches the byte-at-a-time
/// semantics of writeByte: bytes are retained up to exactly
/// max_bytes and the first byte that doesn't fit fails the
/// write.
pub fn writeSlice(
self: *Capture,
bytes: []const u8,
) error{WriteFailed}!void {
const avail = self.max_bytes - self.writer.buffered().len;
const n = @min(bytes.len, avail);
switch (self.backing) {
.fixed => {},
.allocating => |*w| {
const needed = w.writer.end + n;
if (needed > w.writer.buffer.len) {
const new_capacity = @min(
self.max_bytes,
@max(w.writer.buffer.len *| 2, needed),
);
w.writer.buffer = w.allocator.realloc(
w.writer.buffer,
new_capacity,
) catch return error.WriteFailed;
}
},
}
try self.writer.writeAll(bytes[0..n]);
if (n < bytes.len) return error.WriteFailed;
}
pub fn deinit(self: *Capture) void {
switch (self.backing) {
.fixed => {},
@@ -594,6 +627,33 @@ pub const Parser = struct {
}
}
/// Consume a slice of bytes, advancing the parser state. This is
/// equivalent to calling `next` for each byte in order, but is much
/// faster once a data capture is active because the remaining bytes
/// are appended to the capture in bulk.
pub fn nextSlice(self: *Parser, input: []const u8) void {
if (self.state == .invalid) return;
// Run the state machine byte-at-a-time until a capture begins.
// The command prefix before a capture starts is only a handful
// of bytes so this loop is short in practice.
var offset: usize = 0;
while (self.capture == null) {
if (offset >= input.len) return;
self.next(input[offset]);
offset += 1;
if (self.state == .invalid) return;
}
const rem = input[offset..];
if (rem.len == 0) return;
self.capture.?.writeSlice(rem) catch |err| switch (err) {
// We have overflowed our buffer or had some other error, set
// the state to invalid so that we discard any further input.
error.WriteFailed => self.state = .invalid,
};
}
/// Consume the next character c and advance the parser state.
pub fn next(self: *Parser, c: u8) void {
// If the state becomes invalid for any reason, just discard
@@ -934,6 +994,70 @@ test "Parser allocating captures have a hard limit" {
}
}
test "Parser nextSlice allocating captures have a hard limit" {
const testing = std.testing;
const limit = Parser.MAX_BUF + 1;
var p: Parser = .init(testing.allocator);
defer p.deinit();
p.max_allocating_bytes = limit;
const data = try testing.allocator.alloc(u8, limit);
defer testing.allocator.free(data);
@memset(data, 'a');
// Exactly at the limit stays valid and bounded.
p.nextSlice("52;");
p.nextSlice(data);
const cap = &p.capture.?;
try testing.expect(p.state != .invalid);
try testing.expectEqual(@as(usize, limit), cap.trailing().len);
try testing.expectEqual(@as(usize, limit), cap.writer.buffer.len);
// One more byte overflows: the state becomes invalid and the
// retained bytes and allocation stay bounded.
p.nextSlice("a");
try testing.expectEqual(Parser.State.invalid, p.state);
try testing.expectEqual(@as(usize, limit), cap.trailing().len);
try testing.expectEqual(@as(usize, limit), cap.writer.buffer.len);
try testing.expect(p.end(null) == null);
}
test "Parser nextSlice overflowing slice is truncated at the limit" {
const testing = std.testing;
var p: Parser = .init(testing.allocator);
defer p.deinit();
p.max_allocating_bytes = 4;
p.nextSlice("52;abcdef");
try testing.expectEqual(Parser.State.invalid, p.state);
try testing.expect(p.end(null) == null);
const cap = &p.capture.?;
try testing.expectEqualStrings("abcd", cap.trailing());
try testing.expectEqual(@as(usize, 4), cap.writer.buffer.len);
}
test "Parser nextSlice matches per-byte parsing" {
const testing = std.testing;
const input = "52;c;aGVsbG8=";
// Every two-way split of the input must parse identically to
// the byte-at-a-time path.
for (0..input.len + 1) |split| {
var p: Parser = .init(testing.allocator);
defer p.deinit();
p.nextSlice(input[0..split]);
p.nextSlice(input[split..]);
const cmd = p.end(null).?.*;
try testing.expect(cmd == .clipboard_contents);
try testing.expectEqual(@as(u8, 'c'), cmd.clipboard_contents.kind);
try testing.expectEqualStrings("aGVsbG8=", cmd.clipboard_contents.data);
}
}
test "Parser allocating capture limit includes parser-added bytes" {
const testing = std.testing;
var p: Parser = .init(testing.allocator);

View File

@@ -857,6 +857,47 @@ pub fn Stream(comptime H: type) type {
// Aborting transitions need the scalar path so the
// handler can distinguish them from terminators.
}
// Bulk-consume OSC string bytes into the OSC parser.
// OSC payloads (e.g. OSC 52 clipboard operations and
// the kitty clipboard protocol) can be megabytes of
// base64 data, so per-byte dispatch is far too slow.
// This can't be used for handlers with a vtRaw hook
// because the bytes never produce osc_put actions.
if (self.parser.state == .osc_string) {
offset += self.consumeOscString(input[offset..]);
if (offset >= input.len) return input.len;
// Fast-path normal string termination. This matches
// Parser.next's exit and entry actions while avoiding
// the generic action loop for every completed OSC.
switch (input[offset]) {
// The sequence should be terminated by a full
// ST ("ESC \"); the "\" that should follow is
// dispatched as a normal escape sequence.
std.ascii.control_code.esc => {
if (self.parser.osc_parser.end(
std.ascii.control_code.esc,
)) |cmd| self.oscDispatch(cmd.*);
self.parser.clear();
self.parser.state = .escape;
offset += 1;
continue;
},
// BEL terminates the string directly.
std.ascii.control_code.bel => {
if (self.parser.osc_parser.end(
std.ascii.control_code.bel,
)) |cmd| self.oscDispatch(cmd.*);
self.parser.state = .ground;
offset += 1;
continue;
},
// CAN/SUB abort and ignored C0 bytes go
// through the state machine below.
else => {},
}
}
}
self.nextNonUtf8(input[offset]);
@@ -1011,6 +1052,43 @@ pub fn Stream(comptime H: type) type {
return end;
}
/// Bulk-consume OSC string bytes into the OSC parser. Returns
/// the number of bytes consumed. Stops at the first byte that
/// is not an osc_put byte in the parse table, leaving it for
/// the caller to process through the state machine. Every byte
/// >= 0x20 is an osc_put byte; bytes below either terminate or
/// abort the string (BEL, CAN, SUB, ESC) or are ignored.
///
/// Must not be used by handlers with a vtRaw hook because the
/// consumed bytes never produce osc_put actions.
fn consumeOscString(self: *Self, input: []const u8) usize {
comptime assert(!@hasDecl(T, "vtRaw"));
assert(self.parser.state == .osc_string);
var end: usize = 0;
if (comptime std.simd.suggestVectorLength(u8)) |vector_len| {
const ByteVector = @Vector(vector_len, u8);
while (end + vector_len <= input.len) {
const bytes: ByteVector = input[end..][0..vector_len].*;
const stop = bytes < @as(ByteVector, @splat(0x20));
if (@reduce(.Or, stop)) break;
end += vector_len;
}
}
while (end < input.len) {
switch (input[end]) {
// Not osc_put bytes: BEL/CAN/SUB/ESC terminate or
// abort the state; other C0 bytes are ignored by it.
0x00...0x1F => break,
// Everything else is an osc_put byte.
else => end += 1,
}
}
if (end > 0) self.parser.osc_parser.nextSlice(input[0..end]);
return end;
}
/// Like nextSlice but takes one byte and is necessarily a scalar
/// operation that can't use SIMD. Prefer nextSlice if you can and
/// try to get multiple bytes at once.
@@ -3534,6 +3612,191 @@ test "stream: change window title with invalid utf-8" {
}
}
test "stream: osc 52 large payload in chunks" {
const alloc = testing.allocator;
const H = struct {
const Self = @This();
data: std.ArrayListUnmanaged(u8) = .empty,
kind: u8 = 0,
terminator: ?osc.Terminator = null,
count: usize = 0,
pub fn vt(
self: *Self,
comptime action: Action.Tag,
value: Action.Value(action),
) void {
switch (action) {
.clipboard_contents => {
self.count += 1;
self.kind = value.kind;
self.terminator = value.terminator;
self.data.appendSlice(
testing.allocator,
value.data,
) catch @panic("OOM");
},
else => {},
}
}
};
// A payload that far exceeds the fixed buffer so the allocating
// capture is used, fed in chunk sizes that don't align with the
// sequence so the bulk path sees every kind of boundary.
const prefix = "\x1b]52;c;";
const payload_len = 150_000;
var input: std.ArrayListUnmanaged(u8) = .empty;
defer input.deinit(alloc);
try input.appendSlice(alloc, prefix);
for (0..payload_len) |i| try input.append(
alloc,
'A' + @as(u8, @intCast(i % 26)),
);
try input.appendSlice(alloc, "\x1b\\");
var s: Stream(H) = .init(.{ .handler = .{}, .allocator = alloc });
defer s.parser.deinit();
defer s.handler.data.deinit(alloc);
var i: usize = 0;
while (i < input.items.len) {
const end = @min(i + 4093, input.items.len);
s.nextSlice(input.items[i..end]);
i = end;
}
try testing.expectEqual(@as(usize, 1), s.handler.count);
try testing.expectEqual(@as(u8, 'c'), s.handler.kind);
try testing.expectEqual(osc.Terminator.st, s.handler.terminator.?);
try testing.expectEqualSlices(
u8,
input.items[prefix.len .. prefix.len + payload_len],
s.handler.data.items,
);
}
test "stream: osc bulk path matches per-byte path" {
const alloc = testing.allocator;
// Records every dispatch relevant to OSC processing in a
// normalized text form so streams fed different ways can be
// compared byte-for-byte.
const H = struct {
const Self = @This();
alloc: Allocator,
journal: std.ArrayListUnmanaged(u8) = .empty,
fn record(self: *Self, comptime fmt: []const u8, args: anytype) void {
const s = std.fmt.allocPrint(
self.alloc,
fmt,
args,
) catch @panic("OOM");
defer self.alloc.free(s);
self.journal.appendSlice(self.alloc, s) catch @panic("OOM");
}
pub fn vt(
self: *Self,
comptime action: Action.Tag,
value: Action.Value(action),
) void {
switch (action) {
.clipboard_contents => self.record(
"clip kind={c} term={s} data={s}\n",
.{ value.kind, @tagName(value.terminator), value.data },
),
.kitty_clipboard => self.record(
"kitty meta={s} payload={?s}\n",
.{ value.metadata, value.payload },
),
.window_title => self.record(
"title {s}\n",
.{value.title},
),
// Normalize prints to per-codepoint so the per-byte
// and slice paths journal identically.
.print => self.record("print {u}\n", .{value.cp}),
.print_slice => for (value.cps) |cp| self.record(
"print {u}\n",
.{@as(u21, @intCast(cp))},
),
else => {},
}
}
};
const cases = [_][]const u8{
"\x1b]52;c;aGVsbG8=\x1b\\",
"\x1b]52;c;aGVsbG8=\x07",
"\x1b]52;;aGVsbG8=\x07",
// Ignored C0 byte embedded in the payload.
"\x1b]52;c;aGVs\x01bG8=\x07",
// CAN and SUB aborts.
"\x1b]52;c;aGVsbG8=\x18",
"\x1b]52;c;aGVsbG8=\x1a",
// C1 byte embedded in the payload is data.
"\x1b]0;ab\x9ccd\x07",
// Terminated with trailing printable text.
"\x1b]0;a title\x07x",
// Back-to-back sequences.
"\x1b]2;another title\x1b\\\x1b]0;t2\x07",
"\x1b]5522;type=write;aGVsbG8=\x1b\\",
// Invalid OSC number.
"\x1b]999;junk\x07",
// Exceeds the fixed buffer: allocating capture.
"\x1b]52;c;" ++ "y" ** 3000 ++ "\x1b\\",
// Exceeds the fixed buffer: overflow, no dispatch.
"\x1b]0;" ++ "x" ** 3000 ++ "\x07",
};
for (cases) |case| {
// Reference: byte-at-a-time.
var ref: Stream(H) = .init(.{
.handler = .{ .alloc = alloc },
.allocator = alloc,
});
defer ref.parser.deinit();
defer ref.handler.journal.deinit(alloc);
for (case) |c| ref.next(c);
// The whole slice at once.
{
var s: Stream(H) = .init(.{
.handler = .{ .alloc = alloc },
.allocator = alloc,
});
defer s.parser.deinit();
defer s.handler.journal.deinit(alloc);
s.nextSlice(case);
try testing.expectEqualSlices(
u8,
ref.handler.journal.items,
s.handler.journal.items,
);
}
// Split into two slices at every possible boundary.
for (0..case.len + 1) |split| {
var s: Stream(H) = .init(.{
.handler = .{ .alloc = alloc },
.allocator = alloc,
});
defer s.parser.deinit();
defer s.handler.journal.deinit(alloc);
s.nextSlice(case[0..split]);
s.nextSlice(case[split..]);
try testing.expectEqualSlices(
u8,
ref.handler.journal.items,
s.handler.journal.items,
);
}
}
}
test "stream: insert characters" {
const H = struct {
const Self = @This();