mirror of
https://github.com/ghostty-org/ghostty.git
synced 2026-08-26 17:11:40 +00:00
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:
@@ -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));
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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();
|
||||
|
||||
Reference in New Issue
Block a user