terminal: dispatch CSI finals directly from stream fast paths

Profiling escape-heavy streams showed the dominant remaining cost
was Parser.next: every byte routed through it copies a [3]?Action
return value that is ~240 bytes (the action union is sized by
osc.Command). A typical CSI sequence paid this twice: once for the
first byte after "ESC [" (csi_entry has no fast path, so even the
first parameter digit went through the table machine) and once for
the final byte that dispatches the sequence.

This extends the existing stream fast paths to cover both. The
csi_param fast path now handles final bytes (0x40-0x7E) by
finalizing parameters and dispatching the CSI directly via a new
csiDispatchFinal, which replicates the parser's csi_dispatch action
(MAX_PARAMS overflow drop, trailing parameter finalization, and the
colon-separator validation for non-'m' finals) without constructing
the action array. A new csi_entry fast path handles the byte right
after "ESC [": first parameter digit, empty first parameter,
private markers (0x3C-0x3F), and parameterless finals. Everything
else (C0 controls, intermediates, the csi_entry colon edge case)
still defers to the state machine.

Because these paths dispatch without going through Parser.next,
they would bypass a handler's vtRaw hook, so they are disabled at
comptime for handlers that declare one (the inspector). Those
handlers keep the exact previous behavior.

Throughput measured with ghostty-bench terminal-stream (full
terminal handler, 100 MB deterministic corpora, 120x80, M4 Max,
ReleaseFast, hyperfine means of 10 runs). The csi corpus is a
realistic mix of SGR, cursor movement, erases, and mode changes
with short text runs; sgr is a doom-fire-like stream of truecolor
SGRs and cell pairs:

| stream | before | after  | change |
|--------|--------|--------|--------|
| csi    | 618 ms | 525 ms | +18%   |
| sgr    | 486 ms | 414 ms | +17%   |
This commit is contained in:
Mitchell Hashimoto
2026-07-06 06:16:52 -07:00
parent 47e26df60f
commit 1a88f3622b

View File

@@ -695,6 +695,13 @@ pub fn Stream(comptime H: type) type {
self.parser.state = .csi_entry;
return;
}
// The fast paths below dispatch actions directly rather than
// going through Parser.next, so they'd bypass a handler's
// vtRaw hook. Handlers with vtRaw (e.g. the inspector) use
// the general path for anything that produces an action.
const has_vt_raw = comptime @hasDecl(T, "vtRaw");
// Fast path for CSI params.
if (self.parser.state == .csi_param) csi_param: {
// csi_param is the most common parser state
@@ -731,6 +738,10 @@ pub fn Stream(comptime H: type) type {
self.parser.param_acc = 0;
self.parser.param_acc_idx = 0;
},
// A final byte: dispatch the CSI directly.
0x40...0x7E => if (comptime !has_vt_raw) {
self.csiDispatchFinal(c);
} else break :csi_param,
// Explicitly ignored:
0x7F => {},
// Defer to the state machine to
@@ -740,6 +751,42 @@ pub fn Stream(comptime H: type) type {
return;
}
// Fast path for CSI entry, the state right after "ESC [".
// Virtually every CSI sequence spends exactly one byte in
// this state, on either a digit, a private marker, or a
// final byte.
if (comptime !has_vt_raw) {
if (self.parser.state == .csi_entry) csi_entry: {
switch (c) {
// First parameter digit.
'0'...'9' => {
self.parser.state = .csi_param;
// param_acc is zero (cleared on escape entry)
// so accumulating is just the digit value.
self.parser.param_acc = c - '0';
self.parser.param_acc_idx = 1;
},
// An empty first parameter.
';' => {
self.parser.state = .csi_param;
self.parser.params[0] = 0;
self.parser.params_idx = 1;
},
// Private marker (e.g. '?' in "ESC [ ? 2004 h").
0x3C...0x3F => {
self.parser.state = .csi_param;
self.parser.collect(c);
},
// A final byte: a parameterless CSI.
0x40...0x7E => self.csiDispatchFinal(c),
// Defer to the state machine for anything else
// (C0 controls, intermediates, colon).
else => break :csi_entry,
}
return;
}
}
// We explicitly inline this call here for performance reasons.
//
// We do this rather than mark Parser.next as inline because doing
@@ -785,6 +832,48 @@ pub fn Stream(comptime H: type) type {
}
}
/// Finalize and dispatch a CSI directly from parser state for
/// the fast paths in nextNonUtf8, without going through
/// Parser.next. This must match the behavior of the parser's
/// csi_dispatch action.
fn csiDispatchFinal(self: *Self, c: u8) void {
const p = &self.parser;
p.state = .ground;
// Ignore sequences with too many parameters, matching the
// parser's behavior of dropping the dispatch entirely.
if (p.params_idx >= Parser.MAX_PARAMS) {
@branchHint(.unlikely);
return;
}
// Finalize the last parameter if we have one.
if (p.param_acc_idx > 0) {
p.params[p.params_idx] = p.param_acc;
p.params_idx += 1;
}
const action: Parser.Action.CSI = .{
.intermediates = p.intermediates[0..p.intermediates_idx],
.params = p.params[0..p.params_idx],
.params_sep = p.params_sep,
.final = c,
};
// We only allow colon or mixed separators for the 'm' command.
if (c != 'm' and p.params_sep.count() > 0) {
@branchHint(.cold);
log.warn(
"CSI colon or mixed separators only allowed for 'm' command, got: {f}",
.{action},
);
return;
}
if (comptime debug) log.info("action: {f}", .{Parser.Action{ .csi_dispatch = action }});
self.csiDispatch(action);
}
inline fn print(self: *Self, c: u21) void {
self.handler.vt(.print, .{ .cp = c });
}