terminal: handle CSI entry bytes inline in consumeUntilGround

Profiling terminal-stream on a 2.6 GB recording of real terminal
sessions showed ~7% of time in nextNonUtf8 self, and most calls
were for the structural bytes of CSI sequences: the '[' after ESC
and the single byte spent in the csi_entry state (a digit, private
marker, or final byte). Real streams contain tens of millions of
CSI sequences, and each paid two to three function calls just to
advance the parser through those states before the bulk parameter
loop could take over.

This lifts both transitions into the consumeUntilGround loop: the
"ESC [" prefix is matched directly, and the csi_entry byte is
handled by a shared csiEntryByte helper that both the loop and
nextNonUtf8 use (the logic previously lived only in nextNonUtf8).
A typical CSI sequence now parses entirely within
consumeUntilGround/consumeCsiParams without any per-byte calls.
Handlers with a vtRaw hook keep the general path since csiEntryByte
dispatches finals directly.

Measured with ghostty-bench terminal-stream (120x80, M4 Max,
ReleaseFast, hyperfine means of 5 runs). nextNonUtf8 self time
drops from ~7% to ~3% of the profile:

| stream                     | before  | after   | change |
|----------------------------|---------|---------|--------|
| real 2.6 GB session corpus | 9.097 s | 8.854 s | +2.7%  |
| csi mix (SGR/CUP, 100 MB)  | 695 ms  | 674 ms  | +3.1%  |
This commit is contained in:
Mitchell Hashimoto
2026-07-06 12:35:59 -07:00
parent 083d9709be
commit 300f42c7a9

View File

@@ -612,10 +612,27 @@ pub fn Stream(comptime H: type) type {
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).
// Fast path for CSI entry: "ESC [" is by far the most
// common escape sequence prefix, so handle the '[' and
// the byte that follows it here rather than paying a
// nextNonUtf8 call for each.
if (self.parser.state == .escape and input[offset] == '[') {
self.parser.state = .csi_entry;
offset += 1;
continue;
}
if (comptime !@hasDecl(T, "vtRaw")) {
if (self.parser.state == .csi_entry) {
if (self.csiEntryByte(input[offset])) {
offset += 1;
continue;
}
}
// 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 (self.parser.state == .csi_param) {
offset += self.consumeCsiParams(input[offset..]);
if (offset >= input.len) return input.len;
@@ -632,6 +649,47 @@ pub fn Stream(comptime H: type) type {
return offset;
}
/// Fast path for a byte in the csi_entry state, 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. Returns true if the byte was fully handled;
/// false means the caller must process it through the general
/// state machine.
///
/// Must not be used by handlers with a vtRaw hook because the
/// final byte case dispatches the CSI directly.
inline fn csiEntryByte(self: *Self, c: u8) bool {
comptime assert(!@hasDecl(T, "vtRaw"));
assert(self.parser.state == .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 => return false,
}
return true;
}
/// 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
@@ -828,38 +886,9 @@ pub fn Stream(comptime H: type) type {
}
// 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;
if (self.parser.state == .csi_entry) {
if (self.csiEntryByte(c)) return;
}
}