From 4c5b1d5d52a5c8a79be2db897c8fbddaad64f1d2 Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Mon, 6 Jul 2026 12:25:19 -0700 Subject: [PATCH 1/7] bench: terminal-stream reads 64KiB chunks to match the IO thread MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The terminal-stream benchmark fed the stream in 4KiB chunks while the real IO thread reads from the pty into 64KiB buffers (see buffer_capacity in termio/Exec.zig) and hands those to the stream whole. Chunk size affects measurement in two ways: it determines how often the stream crosses a chunk boundary (partial UTF-8 sequences, escape sequences split mid-parse) and how many read syscalls the harness itself performs (a 2.6 GB corpus is ~636k pread calls at 4KiB versus ~40k at 64KiB). This bumps the benchmark read and dispatch buffers to 64KiB so the stream is exercised with realistic chunk sizes. Measured with ghostty-bench terminal-stream on a 2.6 GB recording of real terminal sessions (120x80, M4 Max, ReleaseFast, hyperfine means): | harness | time | throughput | |--------------|-----------------|------------| | 4KiB chunks | 9.651 s ± 0.013 | 270 MB/s | | 64KiB chunks | 9.582 s ± 0.101 | 272 MB/s | The stream itself is barely chunk-size sensitive (most time is in parsing and terminal state updates), but the harness now matches what the IO thread actually does, and later commits are measured against this configuration. --- src/benchmark/TerminalStream.zig | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/src/benchmark/TerminalStream.zig b/src/benchmark/TerminalStream.zig index e0cab9033..a5302005b 100644 --- a/src/benchmark/TerminalStream.zig +++ b/src/benchmark/TerminalStream.zig @@ -112,11 +112,14 @@ fn step(ptr: *anyopaque) Benchmark.Error!void { // aren't currently IO bound. const f = self.data_f orelse return; - var read_buf: [4096]u8 align(std.atomic.cache_line) = undefined; + var read_buf: [64 * 1024]u8 align(std.atomic.cache_line) = undefined; var f_reader = f.reader(&read_buf); const r = &f_reader.interface; - var buf: [4096]u8 = undefined; + // This buffer size matches the read buffer size used by the + // real IO thread (see termio Exec.zig buffer_capacity) so that + // the benchmark exercises the stream with realistic chunk sizes. + var buf: [64 * 1024]u8 = undefined; while (true) { const n = r.readSliceShort(&buf) catch { log.warn("error reading data file err={?}", .{f_reader.err}); From cb4c49fbf206ce0c474493a1354629ebba43e2b9 Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Mon, 6 Jul 2026 12:25:57 -0700 Subject: [PATCH 2/7] terminal: scalar UTF-8 decode consumes partial sequences cut off by ESC The scalar fallback of utf8DecodeUntilControlSeq (used when SIMD is disabled, e.g. wasm builds) treated a valid-so-far but incomplete UTF-8 sequence at the end of its decode region as pending input in all cases: it stopped without consuming the bytes so a future chunk could complete the sequence. That is correct when the region ends at the end of the input, but the region can also be bounded by an ESC byte. In that case the sequence can never be completed (the next byte is already known to be ESC), and the SIMD implementation, via simdutf, replaces the ill-formed prefix with U+FFFD and consumes up to the ESC. The two implementations disagreed on both the consumed count and the decoded output for inputs like "\xC2\x1B[0m". The divergence is invisible at the stream level (the pending bytes take the scalar nextUtf8 path which also emits a replacement character once it sees the ESC) but it means the scalar decoder is not a faithful reference for the SIMD one. This makes the scalar decoder treat a partial sequence bounded by an ESC as a maximal subpart per Unicode 3-7: one U+FFFD, consumed through the end of the region. Truncation at the true end of input still leaves the bytes pending. Also adds a differential fuzz test that runs 10k random mixtures of ASCII, escapes, controls, and valid/invalid UTF-8 through both implementations and requires identical results, which is what caught this. --- src/simd/vt.zig | 94 ++++++++++++++++++++++++++++++++++++++++++++++--- 1 file changed, 90 insertions(+), 4 deletions(-) diff --git a/src/simd/vt.zig b/src/simd/vt.zig index 4230665f4..807285f71 100644 --- a/src/simd/vt.zig +++ b/src/simd/vt.zig @@ -80,13 +80,18 @@ fn utf8DecodeUntilControlSeqScalar( var valid: usize = 1; // lead byte is valid for (0..seq.len - 1) |ci| { if (decode_offset + valid >= decode.len) { - // Truncated at end of buffer: treat as incomplete - // input that may be completed later. Stop decoding - // without consuming these bytes. - return .{ + // The sequence is cut off by the end of the decode + // region. If the region ends at the true end of the + // input then it may be completed by future input, so + // stop without consuming these bytes. If the region + // was bounded by an ESC then the sequence can never + // be completed; the valid-so-far prefix is a maximal + // subpart which maps to a single U+FFFD below. + if (decode.len == input.len) return .{ .consumed = decode_offset, .decoded = decode_count, }; + break; } const cb = decode[decode_offset + valid]; if (cb < seq.ranges[ci][0] or cb > seq.ranges[ci][1]) { @@ -148,6 +153,58 @@ fn utf8SeqInfo(lead: u8) Utf8SeqInfo { }; } +// Differential test: the SIMD implementation must agree with the +// scalar implementation on any input. Exercises random mixtures of +// ASCII, escapes, controls, valid and invalid UTF-8, at various +// lengths (including chunk-boundary straddling cases). +test "decode simd matches scalar" { + if (comptime !options.simd) return error.SkipZigTest; + + const testing = std.testing; + var prng = std.Random.DefaultPrng.init(0xf00dface); + const rand = prng.random(); + + var input: [257]u8 = undefined; + var out_simd: [input.len]u32 = undefined; + var out_scalar: [input.len]u32 = undefined; + + for (0..10_000) |_| { + const len = rand.intRangeAtMost(usize, 0, input.len); + const style = rand.intRangeAtMost(u8, 0, 2); + for (input[0..len]) |*b| { + b.* = switch (style) { + // Mostly ASCII with occasional specials. + 0 => switch (rand.intRangeAtMost(u8, 0, 20)) { + 0 => 0x1B, + 1 => rand.intRangeAtMost(u8, 0, 0x1F), + 2 => rand.int(u8), + else => rand.intRangeAtMost(u8, 0x20, 0x7E), + }, + // Heavy multi-byte/invalid UTF-8. + 1 => switch (rand.intRangeAtMost(u8, 0, 3)) { + 0 => rand.intRangeAtMost(u8, 0x80, 0xBF), + 1 => rand.intRangeAtMost(u8, 0xC0, 0xFF), + 2 => 0x1B, + else => rand.intRangeAtMost(u8, 0x20, 0x7E), + }, + // Fully random bytes. + else => rand.int(u8), + }; + } + + const res_simd = utf8DecodeUntilControlSeq(input[0..len], &out_simd); + const res_scalar = utf8DecodeUntilControlSeqScalar(input[0..len], &out_scalar); + errdefer std.debug.print("input={x}\n", .{input[0..len]}); + try testing.expectEqual(res_scalar.consumed, res_simd.consumed); + try testing.expectEqual(res_scalar.decoded, res_simd.decoded); + try testing.expectEqualSlices( + u32, + out_scalar[0..res_scalar.decoded], + out_simd[0..res_simd.decoded], + ); + } +} + test "decode no escape" { const testing = std.testing; @@ -399,6 +456,35 @@ test "decode valid multibyte surrounded by invalid" { } } +test "decode partial UTF-8 before escape" { + const testing = std.testing; + + // A valid-so-far but incomplete sequence cut off by an ESC can + // never be completed, so it is consumed and replaced by a single + // U+FFFD (maximal subpart) rather than left pending. Only + // sequences cut off by the true end of input are left pending. + var output: [64]u32 = undefined; + + // 2-byte lead cut off by ESC. + { + const str = "hi\xc2\x1b[0m"; + const result = utf8DecodeUntilControlSeq(str, &output); + try testing.expectEqual(@as(usize, 3), result.consumed); + try testing.expectEqual(@as(usize, 3), result.decoded); + try testing.expectEqual(@as(u32, 0xFFFD), output[2]); + } + + // 3-byte lead plus one valid continuation cut off by ESC: + // the whole prefix is one maximal subpart, one U+FFFD. + { + const str = "\xe0\xa0\x1bX"; + const result = utf8DecodeUntilControlSeq(str, &output); + try testing.expectEqual(@as(usize, 2), result.consumed); + try testing.expectEqual(@as(usize, 1), result.decoded); + try testing.expectEqual(@as(u32, 0xFFFD), output[0]); + } +} + test "decode invalid byte before escape" { const testing = std.testing; From 083d9709be0dc19dbd2392718288d5b6b578ea1d Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Mon, 6 Jul 2026 12:27:31 -0700 Subject: [PATCH 3/7] terminal: decode ASCII inline in the SIMD scan for ESC Profiling terminal-stream on a 2.6 GB recording of real terminal sessions showed ~9% of total time inside the UTF-8 decode stage, and most of it was not the decode itself: real streams contain an escape sequence every ~18 bytes, so utf8DecodeUntilControlSeq is called on short printable runs, and each call paid simdutf setup plus its scalar rewind_and_convert_with_errors tail (which handles the last partial SIMD block of every conversion) for only a handful of bytes. The scalar tail alone accounted for ~3.4% of total time. Terminal input is also overwhelmingly ASCII, for which UTF-8 to UTF-32 "decoding" is just widening each byte to 32 bits. This fuses the two passes: while scanning each chunk for ESC we also check for bytes >= 0x80 and widen pure-ASCII chunks straight into the output vector via PromoteTo, never touching simdutf. The first non-ASCII byte hands the remainder of the run (up to the next ESC) to the existing simdutf-based path, so non-ASCII text takes exactly the same code as before. Inputs shorter than one vector are handled by a scalar byte loop that likewise skips simdutf for ASCII. The widening store needs a dedicated path for the HWY_SCALAR fallback target (compiled on targets without guaranteed SIMD, e.g. arm-linux-androideabi): its single-lane vectors cannot be halved so the one lane is widened directly. The new differential fuzz test verifies the SIMD implementation still matches the scalar reference exactly. Measured with ghostty-bench terminal-stream (2.6 GB real-session corpus, 87% printable ASCII / 5.5% ESC / 5.6% UTF-8, 120x80, M4 Max, ReleaseFast, hyperfine means): | stream | before | after | change | |-------------------|-----------------|-----------------|--------| | real 2.6 GB corpus | 9.582 s (272 MB/s) | 9.090 s (287 MB/s) | +5.4% | --- src/simd/vt.cpp | 171 ++++++++++++++++++++++++++++++++++++------------ 1 file changed, 130 insertions(+), 41 deletions(-) diff --git a/src/simd/vt.cpp b/src/simd/vt.cpp index 5bf4147d5..15d46966a 100644 --- a/src/simd/vt.cpp +++ b/src/simd/vt.cpp @@ -198,6 +198,92 @@ size_t DecodeUTF8(const uint8_t* HWY_RESTRICT input, return static_cast(out - output); } +// Widen the N uint8 lanes of v into N uint32 values stored at out. +// This is the UTF-8 to UTF-32 "decode" for ASCII bytes. +template +static HWY_INLINE void WidenAsciiStore(D d, + hn::Vec v, + char32_t* HWY_RESTRICT out) { + uint32_t* HWY_RESTRICT out32 = reinterpret_cast(out); +#if HWY_TARGET == HWY_SCALAR + // The scalar fallback target has single-lane vectors, which cannot + // be halved; widen the one lane directly. + (void)d; + out32[0] = hn::GetLane(v); +#else + const hn::Half dh; + const hn::Half> dq; + const hn::Rebind d32; + const size_t N4 = hn::Lanes(dq); + const auto lo = hn::LowerHalf(dh, v); + const auto hi = hn::UpperHalf(dh, v); + hn::StoreU(hn::PromoteTo(d32, hn::LowerHalf(dq, lo)), d32, out32 + 0 * N4); + hn::StoreU(hn::PromoteTo(d32, hn::UpperHalf(dq, lo)), d32, out32 + 1 * N4); + hn::StoreU(hn::PromoteTo(d32, hn::LowerHalf(dq, hi)), d32, out32 + 2 * N4); + hn::StoreU(hn::PromoteTo(d32, hn::UpperHalf(dq, hi)), d32, out32 + 3 * N4); +#endif +} + +// The general (non-ASCII) portion of DecodeUTF8UntilControlSeqImpl. +// Continues scanning for ESC starting at byte offset `base` and decodes +// input[base..stop) via simdutf. The caller must have already decoded +// input[0..base) as ASCII into output[0..base) (one codepoint per byte). +template +static HWY_NOINLINE size_t DecodeNonAsciiUntilControlSeq( + D d, + const T* HWY_RESTRICT input, + size_t count, + size_t base, + char32_t* output, + size_t* output_count) { + const size_t N = hn::Lanes(d); + const hn::Vec esc_vec = Set(d, 0x1B); + + // Compare N elements at a time. + size_t i = base; + for (; i + N <= count; i += N) { + // Load the N elements from our input into a vector. + const hn::Vec input_vec = hn::LoadU(d, input + i); + + // If we don't have any escapes we keep going. We want to accumulate + // the largest possible valid UTF-8 sequence before decoding. + const size_t esc_idx = IndexOfChunk(d, esc_vec, input_vec); + if (esc_idx == kNotFound) { + continue; + } + + // We have an ESC char, decode up to this point. We start by assuming + // a valid UTF-8 sequence and slow-path into error handling if we find + // an invalid sequence. + *output_count = base + DecodeUTF8(input + base, i + esc_idx - base, + output + base); + return i + esc_idx; + } + + // If we have leftover input then we scan it one byte at a time (slow!) + // using pretty much the same logic as above. + for (; i < count; ++i) { + if (input[i] == 0x1B) { + *output_count = base + DecodeUTF8(input + base, i - base, output + base); + return i; + } + } + + // If we reached this point, its possible for our input to have an + // incomplete sequence because we're consuming the full input. We need + // to trim any incomplete sequences from the end of the input. + // + // We use our own trim instead of simdutf::trim_partial_utf8 because + // we only want to trim sequences that are valid-so-far (true partial + // sequences that may be completed by future input). Invalid bytes + // like C0, C1, F5-FF should NOT be trimmed — they should be passed + // through to DecodeUTF8 which will replace them with U+FFFD per the + // maximal subpart algorithm. + const size_t trimmed_len = TrimValidPartialUTF8(input + base, count - base); + *output_count = base + DecodeUTF8(input + base, trimmed_len, output + base); + return base + trimmed_len; +} + /// Decode the UTF-8 text in input into output until an escape /// character is found. This returns the number of bytes consumed /// from input and writes the number of decoded characters into @@ -217,59 +303,62 @@ size_t DecodeUTF8UntilControlSeqImpl(D d, // Create a vector containing ESC since that denotes a control sequence. const hn::Vec esc_vec = Set(d, 0x1B); + // Any byte >= 0x80 is part of a multi-byte UTF-8 sequence. + const hn::Vec high_vec = Set(d, 0x80); - // Compare N elements at a time. + // ASCII fast path: terminal input is overwhelmingly ASCII, for which + // UTF-8 decoding is a simple widening of each byte to 32 bits. We + // fuse the ESC scan with the decode, one chunk at a time, and only + // fall back to the full UTF-8 decoder (simdutf) when we encounter a + // non-ASCII byte. This avoids a second pass over the input and, for + // the common short runs between escape sequences, avoids the fixed + // overhead of the general-purpose decoder. size_t i = 0; for (; i + N <= count; i += N) { - // Load the N elements from our input into a vector. const hn::Vec input_vec = hn::LoadU(d, input + i); - // If we don't have any escapes we keep going. We want to accumulate - // the largest possible valid UTF-8 sequence before decoding. - // TODO(mitchellh): benchmark this vs decoding every time - const size_t esc_idx = IndexOfChunk(d, esc_vec, input_vec); - if (esc_idx == kNotFound) { - continue; + // Find the first byte that stops the ASCII fast path: an ESC or + // any non-ASCII byte. + const hn::Mask stop_mask = + hn::Or(hn::Eq(input_vec, esc_vec), hn::Ge(input_vec, high_vec)); + const intptr_t stop = hn::FindFirstTrue(d, stop_mask); + + // Widen the whole chunk unconditionally: output is guaranteed to + // be at least as large as input, and if we stop mid-chunk only + // the prefix is reported (the rest is scratch that the caller + // never reads). + WidenAsciiStore(d, input_vec, output + i); + if (stop < 0) continue; + + const size_t stop_idx = i + static_cast(stop); + if (input[stop_idx] == 0x1B) { + // ESC: everything before it was ASCII, one codepoint per byte. + *output_count = stop_idx; + return stop_idx; } - // We have an ESC char, decode up to this point. We start by assuming - // a valid UTF-8 sequence and slow-path into error handling if we find - // an invalid sequence. - *output_count = DecodeUTF8(input, i + esc_idx, output); - return i + esc_idx; + // Non-ASCII: decode the rest (up to an ESC) with the full decoder. + return DecodeNonAsciiUntilControlSeq(d, input, count, stop_idx, output, + output_count); } - // If we have leftover input then we decode it one byte at a time (slow!) - // using pretty much the same logic as above. - if (i != count) { - const hn::CappedTag d1; - using D1 = decltype(d1); - const hn::Vec esc1 = Set(d1, hn::GetLane(esc_vec)); - for (; i < count; ++i) { - const hn::Vec input_vec = hn::LoadU(d1, input + i); - const size_t esc_idx = IndexOfChunk(d1, esc1, input_vec); - if (esc_idx == kNotFound) { - continue; - } - - *output_count = DecodeUTF8(input, i + esc_idx, output); - return i + esc_idx; + // Leftover input (< N bytes): process one byte at a time. + for (; i < count; ++i) { + const T b = input[i]; + if (b == 0x1B) { + *output_count = i; + return i; } + if (b >= 0x80) { + return DecodeNonAsciiUntilControlSeq(d, input, count, i, output, + output_count); + } + output[i] = b; } - // If we reached this point, its possible for our input to have an - // incomplete sequence because we're consuming the full input. We need - // to trim any incomplete sequences from the end of the input. - // - // We use our own trim instead of simdutf::trim_partial_utf8 because - // we only want to trim sequences that are valid-so-far (true partial - // sequences that may be completed by future input). Invalid bytes - // like C0, C1, F5-FF should NOT be trimmed — they should be passed - // through to DecodeUTF8 which will replace them with U+FFFD per the - // maximal subpart algorithm. - const size_t trimmed_len = TrimValidPartialUTF8(input, i); - *output_count = DecodeUTF8(input, trimmed_len, output); - return trimmed_len; + // The entire input was ASCII (no ESC, no partial sequences possible). + *output_count = count; + return count; } size_t DecodeUTF8UntilControlSeq(const uint8_t* HWY_RESTRICT input, From 300f42c7a970dfbbb313fd6456d4d0eb81e8efbd Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Mon, 6 Jul 2026 12:35:59 -0700 Subject: [PATCH 4/7] 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% | --- src/terminal/stream.zig | 97 ++++++++++++++++++++++++++--------------- 1 file changed, 63 insertions(+), 34 deletions(-) diff --git a/src/terminal/stream.zig b/src/terminal/stream.zig index 15a372b2e..95c070cb4 100644 --- a/src/terminal/stream.zig +++ b/src/terminal/stream.zig @@ -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; } } From cb2d78587194d2cc451b5078412b2612ecb2371a Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Mon, 6 Jul 2026 12:41:27 -0700 Subject: [PATCH 5/7] terminal: fill style-only cell runs in bulk in printSliceFill Profiling terminal-stream on a 2.6 GB recording of real terminal sessions showed printSliceFill as the single largest item (~25% of total time), and disassembly showed the time split across three scalar loops: the run-eligibility scan over codepoints, the simple-cell check that guards the branch-free fill, and the general path that fixes up style ref counts one cell at a time. The store loop itself was already auto-vectorized by LLVM, but the two scans are early-exit search loops that LLVM does not vectorize, and the general path turns out to be the common case in real traffic: styled text constantly overwrites cells holding a different style (TUI redraws, scrolling colored output), so every such cell failed the simple check and paid a release/use pair. Three changes, which only pay off together (vectorizing the scans without the bulk path makes mismatch-heavy rows slower because the wider check re-runs for every cell the general path consumes): The run-eligibility scan handles the narrow class, codepoints in [0x10, 0xFF], eight lanes at a time. The simple-cell check compares four masked cells per iteration. And a new bulk path handles runs of cells that differ from the expected simple cell only by style id: one vector scan finds the extent of the uniformly-styled run, the ref counts are fixed with a single releaseMultiple/useMultiple pair, and the cells are filled with the same branch-free store loop as the simple case. Cells with graphemes, hyperlinks, or wide content still fall back to print(). Measured with ghostty-bench terminal-stream (120x80, M4 Max, ReleaseFast, hyperfine means of 5 runs). The redraw corpus is a full-screen 80-row styled repaint whose span color rotates every frame, so every cell is overwritten with a different style: | stream | before | after | change | |----------------------------|---------|---------|--------| | real 2.6 GB session corpus | 8.826 s | 7.955 s | +11% | | TUI redraw (100 MB) | 348 ms | 287 ms | +21% | --- src/terminal/Terminal.zig | 117 ++++++++++++++++++++++++++++++++++++-- 1 file changed, 112 insertions(+), 5 deletions(-) diff --git a/src/terminal/Terminal.zig b/src/terminal/Terminal.zig index 19656805d..83a6df60b 100644 --- a/src/terminal/Terminal.zig +++ b/src/terminal/Terminal.zig @@ -528,7 +528,32 @@ fn printSliceFill( // in the run is always written as a fresh, single-codepoint cell, // so the grapheme break check against it is exact. const run_len: usize = run: { - for (1..cps.len) |idx| { + var idx: usize = 1; + + // Vectorized scan for the narrow class: codepoints in + // [0x10, 0xFF] are always eligible with no further checks + // and dominate real-world input, so scan for the first + // codepoint outside that range several lanes at a time. + // Anything else (including eligible unicode) proceeds via + // the scalar loop below. + if (comptime width == .narrow) { + const lanes = 8; + const V = @Vector(lanes, u32); + const lo: V = @splat(0x10); + const hi: V = @splat(0xFF); + while (idx + lanes <= cps.len) { + const v: V = cps[idx..][0..lanes].*; + const in_range = (v >= lo) & (v <= hi); + if (!@reduce(.And, in_range)) { + const bits: std.meta.Int(.unsigned, lanes) = @bitCast(in_range); + idx += @ctz(~bits); + break; + } + idx += lanes; + } + } + + while (idx < cps.len) : (idx += 1) { const cp = cps[idx]; if (comptime width == .narrow) { if (cp >= 0x10 and cp <= 0xFF) continue; @@ -630,11 +655,31 @@ fn printSliceFill( var k: usize = 0; // cells written fill: while (k < cell_count) { // Find the run of simple cells so the store loop below is - // branch-free (and vectorizable). + // branch-free (and vectorizable). This is an early-exit + // search loop that LLVM won't auto-vectorize, and reused + // rows typically match the whole way through, so scan + // several cells at a time manually. var simple = k; - while (simple < cell_count) : (simple += 1) { - const bits: u64 = @bitCast(cells[simple]); - if ((bits & simple_mask) != check_expected) break; + simple: { + const lanes = 4; + const V = @Vector(lanes, u64); + const mask_v: V = @splat(simple_mask); + const expect_v: V = @splat(check_expected); + const cells64: [*]const u64 = @ptrCast(cells); + while (simple + lanes <= cell_count) { + const v: V = cells64[simple..][0..lanes].*; + const ok = (v & mask_v) == expect_v; + if (!@reduce(.And, ok)) { + const bits: std.meta.Int(.unsigned, lanes) = @bitCast(ok); + simple += @ctz(~bits); + break :simple; + } + simple += lanes; + } + while (simple < cell_count) : (simple += 1) { + const bits: u64 = @bitCast(cells[simple]); + if ((bits & simple_mask) != check_expected) break; + } } if (comptime width == .wide) { @@ -665,6 +710,68 @@ fn printSliceFill( } if (k >= cell_count) break; + // Bulk path for runs of cells that differ from the + // expected simple cell only by their style: this is the + // common case when styled text overwrites previously + // styled (or default-styled) rows, e.g. TUI redraws. + // These runs are handled wholesale: one scan to find the + // run of identical old styles, two ref-count updates, + // and a branch-free fill. + if (comptime width == .narrow) bulk: { + const cells64: [*]const u64 = @ptrCast(cells); + const first = cells64[k] & simple_mask; + + // The old cell must be a plain narrow codepoint cell + // with no hyperlink whose only difference is the + // style id (see printSliceCheckExpected: every other + // masked field must be zero). + const style_shift = @bitOffsetOf(Cell, "style_id"); + const old_style: style.Id = @truncate(first >> style_shift); + if (first != printSliceCheckExpected(old_style)) break :bulk; + assert(old_style != style_id); // it failed the simple check + + // Find the run of cells with identical masked bits. + var m = k + 1; + scan: { + const lanes = 4; + const V = @Vector(lanes, u64); + const mask_v: V = @splat(simple_mask); + const first_v: V = @splat(first); + while (m + lanes <= cell_count) { + const v: V = cells64[m..][0..lanes].*; + const ok = (v & mask_v) == first_v; + if (!@reduce(.And, ok)) { + const bits: std.meta.Int(.unsigned, lanes) = @bitCast(ok); + m += @ctz(~bits); + break :scan; + } + m += lanes; + } + while (m < cell_count) : (m += 1) { + if ((cells64[m] & simple_mask) != first) break; + } + } + + // Fix up the style ref counts for the whole run at + // once. Each of the old cells held a reference to + // old_style so the release is safe by construction. + const n = m - k; + if (old_style != style.default_id) { + page.styles.releaseMultiple(page.memory, old_style, @intCast(n)); + } + if (style_id != style.default_id) { + page.styles.useMultiple(page.memory, style_id, @intCast(n)); + } + + for (k..m) |idx| { + cells[idx] = @bitCast( + template_bits | (@as(u64, cps[printed + idx]) << cp_shift), + ); + } + k = m; + continue :fill; + } + // General path for cells that failed the masked check: // style-only mismatches are handled inline; anything that // needs cleanup (wide chars and their spacers, grapheme From 8d663a76e935d046198256698d2bd79d35f55a40 Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Mon, 6 Jul 2026 12:50:03 -0700 Subject: [PATCH 6/7] terminal: release style refs per run instead of per cell in clearCells clearCells released the style reference of every styled cell individually: an array index, a ref decrement, and a liveness check per cell. Styled cells overwhelmingly come in runs sharing the same style id (a colored status bar, a highlighted region, a full row painted in one color), so most of that work is repeated bookkeeping on the same style entry. This groups consecutive cells with the same style id and releases each run with a single releaseMultiple call. Rows with alternating styles degrade to the same per-cell cost as before; uniform rows, the common case, do one ref-count update per run. The releaseMultiple assertion that the ref count is at least the run length holds by construction since every cell in the run held a reference. Measured with ghostty-bench terminal-stream (120x80, M4 Max, ReleaseFast, hyperfine means of 5 runs). The erase corpus paints a full screen of styled rows and erases it with ED 2 in a loop, which is the pattern full-screen TUIs produce on clear/redraw: | stream | before | after | change | |----------------------------|---------|---------|--------| | real 2.6 GB session corpus | 8.055 s | 7.965 s | +1.1% | | styled paint + ED 2 (100 MB) | 260 ms | 123 ms | 2.1x | --- src/terminal/Screen.zig | 18 +++++++++++++++--- 1 file changed, 15 insertions(+), 3 deletions(-) diff --git a/src/terminal/Screen.zig b/src/terminal/Screen.zig index c9cabfd27..003ae6bb9 100644 --- a/src/terminal/Screen.zig +++ b/src/terminal/Screen.zig @@ -1432,9 +1432,21 @@ pub fn clearCells( } if (row.styled) { - for (cells) |*cell| { - if (cell.hasStyling()) - page.styles.release(page.memory, cell.style_id); + // Styled cells overwhelmingly come in runs sharing the same + // style (e.g. a colored status bar or a highlighted region), + // so group them and release each run with a single ref-count + // update rather than per cell. + var i: usize = 0; + while (i < cells.len) { + const id = cells[i].style_id; + if (id == style.default_id) { + i += 1; + continue; + } + var j = i + 1; + while (j < cells.len and cells[j].style_id == id) j += 1; + page.styles.releaseMultiple(page.memory, id, @intCast(j - i)); + i = j; } // If we have no left/right scroll region we can be sure From b5053153f40991558cccdc369761d68be17037fe Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Mon, 6 Jul 2026 13:14:06 -0700 Subject: [PATCH 7/7] terminal: log unsupported-input messages once per distinct value Profiling terminal-stream on a 2.6 GB recording of real terminal sessions showed ~5% of total time under writev, all of it log output: the recording triggers ~120k warnings, dominated by a few repeated messages ("unimplemented mode: 34", "invalid device attributes command", "invalid C0 character") that some program in the recorded session re-emitted on every frame or every prompt. Each occurrence pays formatting plus a blocking write syscall, and repeats add no diagnostic value beyond the first: the message already includes the offending value. These messages are emitted in response to input that the terminal application controls, so a misbehaving or merely chatty program can flood the log indefinitely. This adds a logUnsupportedOnce helper that suppresses repeats per (call site, value): each site tracks the distinct keys it has logged (the mode number, final byte, or first parameter, depending on the site) in a small fixed table of 16 u32 slots, 64 bytes per site. Real streams only ever produce a handful of distinct unsupported values per site, so if a table fills, new values are suppressed too; by then the log already shows the problem class and unbounded distinct values would flood it anyway. Slots are claimed with 32-bit atomics (native on wasm32) and never change afterwards, so lookups are a lock-free scan and the worst case race is a duplicate message. The OSC 1 change-icon message moves from info to warn to match the other unsupported-input messages the helper covers. Measured with ghostty-bench terminal-stream (2.6 GB real-session corpus, 120x80, M4 Max, ReleaseFast, hyperfine means of 5 runs, stderr to /dev/null which undersells the cost of a real log sink): | stream | before | after | change | |----------------------------|---------|---------|--------| | real 2.6 GB session corpus | 7.916 s | 7.674 s | +3.2% | System time drops from 0.49 s to 0.22 s from the eliminated writev calls. --- src/terminal/stream.zig | 95 ++++++++++++++++++++++++++++++++++++----- 1 file changed, 85 insertions(+), 10 deletions(-) diff --git a/src/terminal/stream.zig b/src/terminal/stream.zig index 95c070cb4..6c5a5f365 100644 --- a/src/terminal/stream.zig +++ b/src/terminal/stream.zig @@ -1011,7 +1011,7 @@ pub fn Stream(comptime H: type) type { .SO => self.handler.vt(.invoke_charset, .{ .bank = .GL, .charset = .G1, .locking = false }), .SI => self.handler.vt(.invoke_charset, .{ .bank = .GL, .charset = .G0, .locking = false }), - else => log.warn("invalid C0 character, ignoring: 0x{x}", .{c}), + else => logUnsupportedOnce("invalid C0 character, ignoring: 0x{x}", .{c}, c), } } @@ -1529,7 +1529,11 @@ pub fn Stream(comptime H: type) type { if (req) |r| { self.handler.vt(.device_attributes, r); } else { - log.warn("invalid device attributes command: {f}", .{input}); + logUnsupportedOnce( + "invalid device attributes command: {f}", + .{input}, + if (input.params.len > 0) input.params[0] else 0, + ); return; } }, @@ -1615,7 +1619,7 @@ pub fn Stream(comptime H: type) type { if (modes.modeFromInt(mode_int, ansi_mode)) |mode| { self.handler.vt(.set_mode, .{ .mode = mode }); } else { - log.warn("unimplemented mode: {}", .{mode_int}); + logUnsupportedOnce("unimplemented mode: {}", .{mode_int}, mode_int); } } }, @@ -1636,7 +1640,7 @@ pub fn Stream(comptime H: type) type { if (modes.modeFromInt(mode_int, ansi_mode)) |mode| { self.handler.vt(.reset_mode, .{ .mode = mode }); } else { - log.warn("unimplemented mode: {}", .{mode_int}); + logUnsupportedOnce("unimplemented mode: {}", .{mode_int}, mode_int); } } }, @@ -1705,9 +1709,10 @@ pub fn Stream(comptime H: type) type { self.handler.vt(.modify_key_format, format); }, - else => log.warn( + else => logUnsupportedOnce( "unknown CSI m with intermediate: {}", .{input.intermediates[0]}, + input.intermediates[0], ), }, @@ -2039,13 +2044,15 @@ pub fn Stream(comptime H: type) type { 23 => self.handler.vt(.title_pop, index), else => @compileError("unreachable"), } - } else log.warn( + } else logUnsupportedOnce( "ignoring CSI 22/23 t with extra parameters: {f}", .{input}, + input.params[0], ), - else => log.warn( + else => logUnsupportedOnce( "ignoring CSI t with unimplemented parameter: {f}", .{input}, + input.params[0], ), } } else log.err( @@ -2220,7 +2227,11 @@ pub fn Stream(comptime H: type) type { .change_window_icon => |icon| { @branchHint(.likely); - log.info("OSC 1 (change icon) received and ignored icon={s}", .{icon}); + logUnsupportedOnce( + "OSC 1 (change icon) received and ignored icon={s}", + .{icon}, + 0, + ); }, .clipboard_contents => |clip| { @@ -2412,7 +2423,11 @@ pub fn Stream(comptime H: type) type { else => {}, // fall through } - log.warn("unimplemented ESC action: {f}", .{action}); + logUnsupportedOnce( + "unimplemented ESC action: {f}", + .{action}, + action.final, + ); }, // IND - Index @@ -2606,12 +2621,72 @@ pub fn Stream(comptime H: type) type { @branchHint(.likely); }, - else => log.warn("unimplemented ESC action: {f}", .{action}), + else => logUnsupportedOnce( + "unimplemented ESC action: {f}", + .{action}, + action.final, + ), } } }; } +/// Logs an unsupported-input message at most once per distinct key +/// per process. +/// +/// These messages are emitted in response to input that the terminal +/// application controls, so a misbehaving (or merely chatty) program +/// can trigger the same message millions of times, e.g. by toggling +/// an unimplemented mode on every frame. Each log call has a real +/// throughput cost (formatting plus a blocking write per message) +/// while adding no diagnostic value beyond the first occurrence. +/// +/// The keys seen so far are tracked in a small fixed table (64 bytes) +/// instantiated per (format, argument type) tuple, i.e. roughly per +/// call site. Real streams only ever produce a handful of distinct +/// unsupported values per site, so if the table ever fills, messages +/// for further new values are suppressed as well: by that point the +/// log already shows this class of problem and unbounded distinct +/// values would flood it anyway. +fn logUnsupportedOnce( + comptime format: []const u8, + args: anytype, + key: u16, +) void { + // u32 slots so every u16 key is representable alongside an empty + // sentinel and so 32-bit targets (e.g. wasm32) have native + // atomics. + const empty = std.math.maxInt(u32); + const Static = struct { + var seen: [16]u32 = @splat(empty); + }; + + // The atomics make concurrent streams safe: slots are only ever + // claimed, never changed, so the scan can stop at the first empty + // slot. The worst case race is a benign duplicate message. + for (&Static.seen) |*slot| { + const cur = @atomicLoad(u32, slot, .acquire); + if (cur == key) return; // already logged + if (cur != empty) continue; // other key, keep scanning + + // Empty slot: claim it for this key and log below. + const actual = @cmpxchgStrong( + u32, + slot, + empty, + key, + .acq_rel, + .acquire, + ) orelse break; + + // Lost the race: suppress if it was to the same key, keep + // scanning otherwise. + if (actual == key) return; + } else return; // table full: suppress new values too + + log.warn(format, args); +} + test Action { // Forces the C type to be reified when the target is C, ensuring // all our types are C ABI compatible.