terminal: bulk-parse CSI parameter bytes at the slice level

After the CSI dispatch fast paths, profiling showed the remaining
escape-sequence cost was the per-byte plumbing itself: for every
parameter byte of a sequence like "ESC [ 38;2;10;20;30 m" the
stream re-entered nextNonUtf8, re-checked the parser state, and
re-dispatched through the fast-path switch, paying call and state
check overhead per digit.

consumeUntilGround now hands whole input slices to a new
consumeCsiParams loop whenever the parser is in the csi_param
state. It consumes runs of digits and separators with the parser
accumulator state held in locals, dispatches directly when it
reaches the final byte, and returns to the general path on the
first byte it doesn't understand (C0 controls, intermediates,
etc.), guaranteeing byte-for-byte identical semantics with the
per-byte fast path it hoists. Like the dispatch fast paths, this is
disabled at comptime for handlers that declare vtRaw so the
inspector continues to observe every action.

Throughput measured with ghostty-bench terminal-stream (full
terminal handler, 100 MB deterministic corpora, 120x80, M4 Max,
ReleaseFast, hyperfine means of 10 runs):

| stream | before | after  | change |
|--------|--------|--------|--------|
| csi    | 525 ms | 407 ms | +29%   |
| sgr    | 414 ms | 294 ms | +41%   |

Combined with the previous commit, CSI-heavy streams are 1.5-1.7x
faster end to end than before this series.
This commit is contained in:
Mitchell Hashimoto
2026-07-06 06:21:31 -07:00
parent 1a88f3622b
commit 253e4f9c3c

View File

@@ -611,12 +611,88 @@ pub fn Stream(comptime H: type) type {
var offset: usize = 0;
while (self.parser.state != .ground) {
if (offset >= input.len) return input.len;
// Bulk-consume CSI parameter bytes. This can't be used
// for handlers with a vtRaw hook because it dispatches
// the CSI directly (see nextNonUtf8).
if (comptime !@hasDecl(T, "vtRaw")) {
if (self.parser.state == .csi_param) {
offset += self.consumeCsiParams(input[offset..]);
if (offset >= input.len) return input.len;
// If we're still in csi_param then the next byte
// isn't a parameter byte; let nextNonUtf8 below
// handle it. Otherwise re-check our state.
if (self.parser.state != .csi_param) continue;
}
}
self.nextNonUtf8(input[offset]);
offset += 1;
}
return offset;
}
/// Bulk-consume CSI parameter bytes (digits and separators)
/// and, if reached, the final byte (dispatching the CSI).
/// Returns the number of bytes consumed. Stops at the first
/// byte that isn't handled here, leaving the parser in the
/// csi_param state so the caller can process that byte.
fn consumeCsiParams(self: *Self, input: []const u8) usize {
const p = &self.parser;
assert(p.state == .csi_param);
// Accumulate parser state in locals for the hot loop.
var acc = p.param_acc;
var acc_idx = p.param_acc_idx;
var idx = p.params_idx;
var offset: usize = 0;
while (offset < input.len) {
const c = input[offset];
switch (c) {
// A parameter digit.
'0'...'9' => {
if (idx < Parser.MAX_PARAMS) {
acc *|= 10;
acc +|= c - '0';
acc_idx |= 1;
}
offset += 1;
},
// A parameter separator.
':', ';' => {
if (idx < Parser.MAX_PARAMS) {
p.params[idx] = acc;
if (c == ':') p.params_sep.set(idx);
idx += 1;
acc = 0;
acc_idx = 0;
}
offset += 1;
},
// A final byte: dispatch the CSI.
0x40...0x7E => {
p.param_acc = acc;
p.param_acc_idx = acc_idx;
p.params_idx = idx;
self.csiDispatchFinal(c);
return offset + 1;
},
// Anything else (C0 controls, intermediates, etc.)
// is handled by the caller.
else => break,
}
}
p.param_acc = acc;
p.param_acc_idx = acc_idx;
p.params_idx = idx;
return offset;
}
/// 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.