From 258de36d152522476b9f2443e9f37aad8cc6f79b Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Mon, 6 Jul 2026 06:10:15 -0700 Subject: [PATCH 1/5] benchmark: terminal-stream uses the full terminal handler The terminal-stream benchmark previously used a simplified handler that handles print actions and drops everything else. That was originally intended to isolate parse and print throughput, but it understates the cost of escape-heavy streams: no terminal state is updated for CSI/OSC/ESC sequences, and because actions are dispatched at comptime, the unhandled action arms are eliminated entirely, so the benchmark measures dispatch code that doesn't exist in the real app. This switches the benchmark to the full readonly terminal stream handler (terminal.TerminalStream). Every escape sequence now updates real terminal state (styles, cursor movement, erases, modes, etc.), closely mirroring the work the real IO thread does per byte. This is the handler used to measure the VT throughput changes in the following commits. Parser-in-isolation measurement remains covered by the separate terminal-parser and osc-parser benchmarks, and print throughput is identical under both handlers since printing flows into the same Terminal call either way. --- src/benchmark/TerminalStream.zig | 42 ++++++++------------------------ 1 file changed, 10 insertions(+), 32 deletions(-) diff --git a/src/benchmark/TerminalStream.zig b/src/benchmark/TerminalStream.zig index 1cac656e2..e0cab9033 100644 --- a/src/benchmark/TerminalStream.zig +++ b/src/benchmark/TerminalStream.zig @@ -2,15 +2,13 @@ //! handler from input to terminal state update. This is useful to //! test general throughput of VT parsing and handling. //! -//! Note that the handler used for this benchmark isn't the full -//! terminal handler, since that requires a significant amount of -//! state. This is a simplified version that only handles specific -//! terminal operations like printing characters. We should expand -//! this to include more operations to improve the accuracy of the -//! benchmark. +//! This uses the full readonly terminal stream handler +//! (terminal.TerminalStream) so every escape sequence updates real +//! terminal state (styles, cursor movement, erases, modes, etc.). +//! This closely mirrors the work done by the real IO thread. //! -//! It is a fairly broad benchmark that can be used to determine -//! if we need to optimize something more specific (e.g. the parser). +//! For more isolated measurements see the terminal-parser and +//! osc-parser benchmarks. const TerminalStream = @This(); const std = @import("std"); @@ -20,13 +18,12 @@ const terminalpkg = @import("../terminal/main.zig"); const Benchmark = @import("Benchmark.zig"); const options = @import("options.zig"); const Terminal = terminalpkg.Terminal; -const Stream = terminalpkg.Stream(*Handler); +const Stream = terminalpkg.TerminalStream; const log = std.log.scoped(.@"terminal-stream-bench"); opts: Options, terminal: Terminal, -handler: Handler, stream: Stream, /// The file, opened in the setup function. @@ -61,14 +58,15 @@ pub fn create( .rows = opts.@"terminal-rows", .cols = opts.@"terminal-cols", }), - .handler = .{ .t = &ptr.terminal }, - .stream = .init(&ptr.handler), + .stream = undefined, }; + ptr.stream = .initAlloc(alloc, .init(&ptr.terminal)); return ptr; } pub fn destroy(self: *TerminalStream, alloc: Allocator) void { + self.stream.deinit(); self.terminal.deinit(alloc); alloc.destroy(self); } @@ -129,26 +127,6 @@ fn step(ptr: *anyopaque) Benchmark.Error!void { } } -/// Implements the handler interface for the terminal.Stream. -/// We should expand this to include more operations to make -/// our benchmark more realistic. -const Handler = struct { - t: *Terminal, - - pub fn vt( - self: *Handler, - comptime action: Stream.Action.Tag, - value: Stream.Action.Value(action), - ) void { - switch (action) { - .print => self.t.print(value.cp) catch |err| { - log.warn("error processing benchmark print err={}", .{err}); - }, - else => {}, - } - } -}; - test TerminalStream { const testing = std.testing; const alloc = testing.allocator; From 47e26df60f53471f2e210b5c43a965bf195faa42 Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Mon, 6 Jul 2026 06:11:15 -0700 Subject: [PATCH 2/5] terminal: batch printed codepoint runs into direct row fills #13209 After #13209 the IO pipeline delivers the parse thread's full measured capacity, so IO throughput is now bound by VT processing. Profiling `terminal-stream` on plain text showed ~85% of wall time inside Terminal.print: every printable codepoint paid the full per-character cost (right margin computation, grapheme clustering checks, width lookup, wrap/insert mode checks, charset mapping, per-cell style bookkeeping, dirty marking, cursor advance) even though for typical bulk output every one of those answers is the same for thousands of consecutive characters. This adds a new print_slice stream action carrying a run of printable codepoints, emitted whenever the SIMD ground-state path decodes multiple codepoints at once, plus Terminal.printSlice which processes such runs in batch. Since action dispatch is comptime, delivering a slice through the existing vt handler interface has the same codegen as a dedicated entry point; handlers that don't care about batching can simply loop and treat each codepoint as a print action. printSlice hoists all run-invariant checks (status display, insert and wraparound modes, charset state, hyperlink state) out of the loop and then fills cells row by row. A single masked u64 compare classifies each destination cell as "simple" (plain codepoint cell, narrow, no hyperlink, style already matching the cursor); runs of simple cells are written with a branch-free store loop, style-only mismatches are handled inline with the same ref-counting printCell does, and anything needing real cleanup (wide spacers, grapheme data, hyperlinks) exits the fast path with the cursor positioned on the offending cell so print() handles that one codepoint with full generality. Dirty marking, previous_char, and cursor advancement happen once per row instead of once per character. The fast path handles both narrow and wide codepoints (CJK/emoji are written as wide+spacer_tail pair fills, including spacer-head handling at the right edge) and stays exact under grapheme clustering (mode 2027): a codepoint only joins a run if it is width 1 or 2 and is a grapheme break from the previously written codepoint, so print() would never have attached it to the previous cell. The first codepoint of a batch defers to print() whenever the previous cell could carry cluster state we can't cheaply reason about (including a pending wrap, where print attaches to the pending cell instead of wrapping). Correctness is verified by a new differential fuzz test that runs the same operations through per-codepoint print and randomly chunked printSlice, comparing full screen dumps, cursor state, and page integrity (style refcounts, grapheme maps) after every operation, across wraps, margins, mode toggles, hyperlinks, charsets, and wide/combining/ZWJ/RI/jamo codepoints. Throughput measured with ghostty-bench terminal-stream (full terminal handler, 100 MB deterministic corpora, 120x80, M4 Max, ReleaseFast, hyperfine means of 10 runs; ~15ms process startup included in all numbers): | stream | before | after | change | |---------------------------|--------|--------|--------| | ascii (no newlines) | 784 ms | 138 ms | 5.7x | | ascii lines | 833 ms | 198 ms | 4.2x | | unicode mixed-script | 779 ms | 320 ms | 2.4x | | CJK (all wide) | 424 ms | 126 ms | 3.4x | | unicode, mode 2027 on | 807 ms | 367 ms | 2.2x | | CJK, mode 2027 on | 495 ms | 198 ms | 2.5x | --- src/terminal/Terminal.zig | 675 +++++++++++++++++++++++++++++++ src/terminal/stream.zig | 49 ++- src/terminal/stream_terminal.zig | 1 + src/termio/stream_handler.zig | 4 + 4 files changed, 726 insertions(+), 3 deletions(-) diff --git a/src/terminal/Terminal.zig b/src/terminal/Terminal.zig index ce066692b..19656805d 100644 --- a/src/terminal/Terminal.zig +++ b/src/terminal/Terminal.zig @@ -321,6 +321,422 @@ pub fn printRepeat(self: *Terminal, count_req: usize) !void { } } +/// Print multiple codepoints to the terminal at once. This is +/// semantically identical to calling `print` for each codepoint in +/// order, but is much faster because it can batch cell writes and +/// hoist per-codepoint checks out of the hot loop. +/// +/// The codepoints must all be printable: it is illegal for any +/// codepoint in this slice to be a C0 control character. Therefore, +/// this should only be called as a result of a proper VT parser +/// (like our own). +/// +/// This is optimized for the common case: ASCII, soft-wrap, etc. +/// Sequences of codepoints that require special handling (e.g. wide characters, +/// grapheme clustering) are handled correctly but fall back to the +/// slower per-codepoint path. They're less common and this is optimized +/// for the aforementioned cases. +pub fn printSlice(self: *Terminal, cps: []const u32) !void { + var i: usize = 0; + while (i < cps.len) { + // Try the fast-path print first. This will return the number of + // codepoints it consumed. + const consumed = try self.printSliceFast(cps[i..]); + if (consumed > 0) { + i += consumed; + continue; + } + + // Consuming zero bytes means that the fast path can't handle + // the next codepoint or the terminal is in a state we can't + // fast-path. Fall back to the slow cp-by-cp print then try + // fast paths again. + try self.print(@intCast(cps[i])); + i += 1; + } +} + +/// Attempt to print a prefix of `cps` using a batched fast path that +/// writes cells directly. Returns the number of codepoints consumed. +/// A return value of zero means the caller must print the first +/// codepoint via the normal `print` path. +/// +/// The fast path handles runs of narrow (width 1) and wide (width 2) +/// codepoints being written to simple cells. Everything else (zero +/// width codepoints, grapheme cluster continuations, insert mode, +/// charset mapping, hyperlinks, complex cells, etc.) is rejected so +/// `print` can handle it with full generality. +fn printSliceFast(self: *Terminal, cps: []const u32) !usize { + // Only the main display is supported. + if (self.status_display != .main) return 0; + + // Modes that require per-codepoint handling in print(). Wraparound + // is required (its the default) so that our row-fill logic below can + // assume soft-wrap semantics. Insert mode shifts cells per print. + if (self.modes.get(.insert)) return 0; + if (!self.modes.get(.wraparound)) return 0; + + const screen: *Screen = self.screens.active; + + // Charset must map ASCII as-is (true unless a DEC special charset + // is actively invoked, which is rare). + if (screen.charset.single_shift != null) return 0; + switch (screen.charset.charsets.get(screen.charset.gl)) { + .utf8, .ascii => {}, + else => return 0, + } + + // Hyperlinks require per-cell map bookkeeping. + if (screen.cursor.hyperlink_id != 0) return 0; + + // Codepoints in [0x10, 0xFF] are always narrow (width 1, matching + // the c <= 0xFF fast path in print) and can never interact with + // grapheme clustering (which requires a codepoint > 0xFF). + // + // Codepoints above 0xFF are batchable if their width is 1 or 2 + // (excluding zero-width characters such as combining marks, ZWJ, + // and variation selectors) and, when grapheme clustering (mode + // 2027) is enabled, if they are a grapheme break from the + // previously printed codepoint (so print would never attach them + // to the previous cell). + const grapheme_cluster = self.modes.get(.grapheme_cluster); + + // When grapheme clustering is enabled and a left margin is set, + // print() consults the cell left of the margin after wrapping, + // which we can't reason about here. Restrict the fast path to + // the [0x10, 0xFF] range in that case (those never cluster). + const allow_unicode = !grapheme_cluster or self.scrolling_region.left == 0; + + // Codepoints in [0x10, 0xFF] are always narrow: print() + // hardcodes width 1 for c <= 0xFF (no width table lookup). + // They also can never interact with grapheme clustering, + // which print() only performs for c > 0xFF, so they're + // immediately eligible for the narrow fill with no further + // checks. + const cp0 = cps[0]; + if (cp0 <= 0xFF) { + // C0 control characters (0x00-0x0F) aren't printable. The + // stream never sends these (they're routed to execute), but + // printSlice is a public API so defer to print() for safety. + if (cp0 < 0x10) return 0; + return self.printSliceFill( + .narrow, + cps, + grapheme_cluster, + allow_unicode, + ); + } + + if (!allow_unicode) return 0; + if (comptime build_options.kitty_graphics) { + // The Kitty graphics placeholder requires row bookkeeping. + if (cp0 == kitty.graphics.unicode.placeholder) return 0; + } + + // The first codepoint requires care when grapheme clustering is + // enabled: print() examines the previous *cell* which can hold + // state (grapheme data) that we can't cheaply reason about here. + // Note this includes the pending-wrap state: print() may attach + // to the pending cell *instead of wrapping*. We only take the + // first codepoint if the cursor is at column zero with no pending + // wrap, where print() skips clustering entirely. + if (grapheme_cluster) { + if (screen.cursor.pending_wrap or screen.cursor.x != 0) return 0; + } + + // The width lookup is a runtime value while printSliceFill is + // specialized at comptime by width class, so this switch selects + // between the two instantiations rather than passing the width + // through as an argument. + return switch (unicode.table.get(@intCast(cp0)).width) { + 1 => self.printSliceFill( + .narrow, + cps, + grapheme_cluster, + allow_unicode, + ), + 2 => self.printSliceFill( + .wide, + cps, + grapheme_cluster, + allow_unicode, + ), + else => 0, + }; +} + +/// The width class of a printSlice batch. Each batch contains only +/// codepoints of a single width class because they fill cells +/// differently: wide codepoints occupy a (wide, spacer_tail) cell +/// pair while narrow codepoints occupy a single cell. +const PrintSliceWidth = enum(u1) { + narrow, + wide, + + /// The number of cells each codepoint of this width class occupies. + fn cellsPerCp(comptime self: PrintSliceWidth) usize { + return switch (self) { + .narrow => 1, + .wide => 2, + }; + } +}; + +/// Whether a codepoint above 0xFF is eligible for the batched print +/// fast path with the given width class. +inline fn printSliceEligible(cp: u32, comptime width: PrintSliceWidth) bool { + assert(cp > 0xFF); + if (comptime build_options.kitty_graphics) { + if (cp == kitty.graphics.unicode.placeholder) return false; + } + + return unicode.table.get(@intCast(cp)).width == comptime @as(u2, switch (width) { + .narrow => 1, + .wide => 2, + }); +} + +/// The row-filling portion of the printSlice fast path, specialized by +/// width class. The first codepoint must already be validated by the +/// caller (printSliceFast). +fn printSliceFill( + self: *Terminal, + comptime width: PrintSliceWidth, + cps: []const u32, + grapheme_cluster: bool, + allow_unicode: bool, +) !usize { + const screen: *Screen = self.screens.active; + + // Our fast path can only handle "simple" cells. A simple cell is + // a codepoint cell (no grapheme data or bg-color tag), narrow, and + // not a hyperlink. The mask covers every field that must match + // the expected value (see printSliceCheckExpected) exactly. + const simple_mask = comptime fieldMask(Cell, &.{ + "content_tag", + "style_id", + "wide", + "hyperlink", + }); + + // The bit offset of the codepoint content within a Cell, used to + // construct cell values from a template without field assignments. + const cp_shift = @bitOffsetOf(Cell, "content"); + + // Determine the run of codepoints in the same width class that we + // can batch. For codepoints after the first, the previous codepoint + // 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| { + const cp = cps[idx]; + if (comptime width == .narrow) { + if (cp >= 0x10 and cp <= 0xFF) continue; + } + if (cp > 0xFF and allow_unicode and printSliceEligible(cp, width)) { + if (!grapheme_cluster) continue; + var state: uucode.grapheme.BreakState = .default; + if (unicode.graphemeBreak(@intCast(cps[idx - 1]), @intCast(cp), &state)) continue; + } + break :run idx; + } + break :run cps.len; + }; + assert(run_len > 0); + + // After doing any printing, wrapping, scrolling, etc. we want to + // ensure that our screen remains in a consistent state. + defer screen.assertIntegrity(); + + // The number of cells each codepoint occupies. + const cells_per_cp: usize = comptime width.cellsPerCp(); + + var printed: usize = 0; + outer: while (printed < run_len) { + // If we're soft-wrapping, handle that first so that our cursor + // is in the row/column that will receive the next codepoint. + if (screen.cursor.pending_wrap) try self.printWrap(); + + // Our right margin depends on where our cursor is now, + // matching the logic in print(). + const right_limit: usize = if (screen.cursor.x > self.scrolling_region.right) + self.cols + else + self.scrolling_region.right + 1; + + // A degenerate 1-wide region can't hold a wide char; print() + // has special handling so fall back to it. + if (comptime width == .wide) { + if (right_limit - self.scrolling_region.left <= 1) break; + } + + const cursor = &screen.cursor; + const avail: usize = right_limit - cursor.x; + assert(avail > 0); + + const page = &cursor.page_pin.node.data; + const cells: [*]Cell = @ptrCast(cursor.page_cell); + const style_id = cursor.style_id; + const template: Cell = .{ + .content_tag = .codepoint, + .content = .{ .codepoint = 0 }, + .style_id = style_id, + .wide = .narrow, + .protected = cursor.protected, + .semantic_content = cursor.semantic_content, + }; + const template_bits: u64 = @bitCast(template); + const check_expected: u64 = printSliceCheckExpected(style_id); + + if (comptime width == .wide) { + if (avail == 1) { + // Only one cell left in the row: print() writes a + // spacer head (or a blank narrow cell if we're inside + // a right margin) and wraps. We require a simple cell, + // otherwise fall back to print() for the cleanup. + const bits: u64 = @bitCast(cells[0]); + if ((bits & simple_mask) != check_expected) break; + + var spacer = template; + if (right_limit == self.cols) { + cursor.page_row.wrap = true; + spacer.wide = .spacer_head; + } + cursor.page_row.dirty = true; + if (style_id != style.default_id) cursor.page_row.styled = true; + cells[0] = spacer; + try self.printWrap(); + continue :outer; + } + } + + // Number of codepoints and cells we're writing to this row. + const count = @min(avail / cells_per_cp, run_len - printed); + assert(count > 0); + const cell_count = count * cells_per_cp; + + // Wide cells always come in (wide, spacer_tail) pairs. + const spacer_bits: u64 = if (comptime width == .wide) spacer: { + var spacer = template; + spacer.wide = .spacer_tail; + break :spacer @bitCast(spacer); + } else undefined; + const wide_bits: u64 = if (comptime width == .wide) wb: { + var w = template; + w.wide = .wide; + break :wb @bitCast(w); + } else undefined; + + 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). + var simple = k; + while (simple < cell_count) : (simple += 1) { + const bits: u64 = @bitCast(cells[simple]); + if ((bits & simple_mask) != check_expected) break; + } + + if (comptime width == .wide) { + // We can only write whole (wide, spacer) pairs. + const pair_end = k + (simple - k) / 2 * 2; + var idx = k; + while (idx < pair_end) : (idx += 2) { + cells[idx] = @bitCast( + wide_bits | (@as(u64, cps[printed + idx / 2]) << cp_shift), + ); + cells[idx + 1] = @bitCast(spacer_bits); + } + // If the simple run ended mid-pair we stop at the pair + // boundary and handle the offending cell below. + k = pair_end; + if (simple != pair_end) { + // The first cell of the next pair is simple but the + // second isn't; handle both via the general path. + simple = pair_end; + } + } else { + for (k..simple) |idx| { + cells[idx] = @bitCast( + template_bits | (@as(u64, cps[printed + idx]) << cp_shift), + ); + } + k = simple; + } + if (k >= cell_count) break; + + // 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 + // data, hyperlinks) falls back to print(). + const general_count: usize = cells_per_cp; + for (0..general_count) |offset| { + const cell = &cells[k + offset]; + if (cell.wide != .narrow or + cell.hasGrapheme() or + cell.hyperlink) break :fill; + } + for (0..general_count) |offset| { + const cell = &cells[k + offset]; + if (cell.style_id != style_id) { + if (cell.style_id != style.default_id) { + page.styles.release(page.memory, cell.style_id); + } + if (style_id != style.default_id) { + page.styles.use(page.memory, style_id); + } + } + } + if (comptime width == .wide) { + cells[k] = @bitCast( + wide_bits | (@as(u64, cps[printed + k / 2]) << cp_shift), + ); + cells[k + 1] = @bitCast(spacer_bits); + } else { + cells[k] = @bitCast( + template_bits | (@as(u64, cps[printed + k]) << cp_shift), + ); + } + k += cells_per_cp; + } + + if (k > 0) { + assert(k % cells_per_cp == 0); + cursor.page_row.dirty = true; + if (style_id != style.default_id) cursor.page_row.styled = true; + self.previous_char = @intCast(cps[printed + k / cells_per_cp - 1]); + printed += k / cells_per_cp; + + // Advance the cursor. If we filled through the right limit + // then the cursor stays on the last cell with the pending + // wrap flag set, matching print(). + if (cursor.x + k >= right_limit) { + assert(cursor.x + k == right_limit); + screen.cursorRight(@intCast(k - 1)); + cursor.pending_wrap = true; + } else { + screen.cursorRight(@intCast(k)); + } + } + + // We hit a cell that requires the slow path. The cursor is + // exactly at that cell so return and let the caller print the + // next codepoint via print(). + if (k < cell_count) break; + } + + return printed; +} + +/// The expected value of a simple cell (per the check mask built from +/// fieldMask in printSliceFill) that already has the given style +/// (so no ref-counting is needed). +inline fn printSliceCheckExpected(style_id: style.Id) u64 { + var e: Cell = @bitCast(@as(u64, 0)); + e.style_id = style_id; + return @bitCast(e); +} + pub fn print(self: *Terminal, c: u21) !void { // log.debug("print={x} y={} x={}", .{ c, self.screens.active.cursor.y, self.screens.active.cursor.x }); @@ -3202,6 +3618,30 @@ fn clearDirty(t: *Terminal) void { t.screens.active.pages.clearDirty(); } +/// Returns a mask with all bits set for the given fields of the packed +/// struct T, used for masked compares of raw backing-integer values. +fn fieldMask( + comptime T: type, + comptime fields: []const []const u8, +) @typeInfo(T).@"struct".backing_integer.? { + // Backing int of the packed struct + const Int = @typeInfo(T).@"struct".backing_integer.?; + + var mask: Int = 0; + inline for (fields) |field| { + // The type that fits all the bits we need to set. + const Ones = std.meta.Int( + .unsigned, + @bitSizeOf(@FieldType(T, field)), + ); + + // Mask out the ones + mask |= @as(Int, std.math.maxInt(Ones)) << @bitOffsetOf(T, field); + } + + return mask; +} + test "Terminal: input with no control characters" { const alloc = testing.allocator; var t = try init(alloc, .{ .cols = 40, .rows = 40 }); @@ -11592,6 +12032,241 @@ test "Terminal: printRepeat no previous character" { } } +test "Terminal: printSlice simple ascii" { + const alloc = testing.allocator; + var t = try init(alloc, .{ .cols = 10, .rows = 3 }); + defer t.deinit(alloc); + + try t.printSlice(&.{ 'h', 'e', 'l', 'l', 'o' }); + try testing.expectEqual(@as(usize, 5), t.screens.active.cursor.x); + try testing.expectEqual(@as(u21, 'o'), t.previous_char.?); + try testing.expect(t.isDirty(.{ .active = .{ .x = 0, .y = 0 } })); + + { + const str = try t.plainString(testing.allocator); + defer testing.allocator.free(str); + try testing.expectEqualStrings("hello", str); + } +} + +test "Terminal: printSlice wraps and scrolls" { + const alloc = testing.allocator; + var t = try init(alloc, .{ .cols = 5, .rows = 2 }); + defer t.deinit(alloc); + + // 12 chars: fills row 1 (5), row 2 (5), wraps+scrolls, 2 more. + try t.printSlice(&.{ 'a', 'b', 'c', 'd', 'e', 'f', 'g', 'h', 'i', 'j', 'k', 'l' }); + + { + const str = try t.plainString(testing.allocator); + defer testing.allocator.free(str); + try testing.expectEqualStrings("fghij\nkl", str); + } + try testing.expectEqual(@as(usize, 2), t.screens.active.cursor.x); + try testing.expect(!t.screens.active.cursor.pending_wrap); +} + +test "Terminal: printSlice pending wrap state" { + const alloc = testing.allocator; + var t = try init(alloc, .{ .cols = 5, .rows = 2 }); + defer t.deinit(alloc); + + try t.printSlice(&.{ 'a', 'b', 'c', 'd', 'e' }); + try testing.expectEqual(@as(usize, 4), t.screens.active.cursor.x); + try testing.expect(t.screens.active.cursor.pending_wrap); + + { + const str = try t.plainString(testing.allocator); + defer testing.allocator.free(str); + try testing.expectEqualStrings("abcde", str); + } +} + +/// Differential testing helper: applies the same logical print +/// operations to two terminals, one using per-codepoint print() and +/// the other using printSlice() with random chunking, verifying that +/// the results are identical. +fn testPrintSliceDifferential( + alloc: Allocator, + rand: std.Random, + ops: usize, + cols: size.CellCountInt, + rows: size.CellCountInt, +) !void { + var t1 = try init(alloc, .{ + .cols = cols, + .rows = rows, + }); + defer t1.deinit(alloc); + var t2 = try init(alloc, .{ + .cols = cols, + .rows = rows, + }); + defer t2.deinit(alloc); + + // Alphabet of interesting codepoints: ascii, latin-1, combining + // marks, CJK (wide), emoji (wide), ZWJ, variation selectors. + const alphabet = [_]u21{ + 'a', 'b', 'Z', '0', ' ', 0x10, 0x1F, 0x7F, + 'é', 0xFF, 0x301, 0x4E00, 0x4E01, 0x1F600, 0x200D, 0xFE0F, + 'x', 'y', 0x1F9D1, 0x0308, 0xAD, 0x3042, 0xAC00, 'q', + 'r', 's', 't', 'u', 'v', 'w', '1', '2', + 0x1F1E6, 0x1F1E7, 0x1100, 0x1161, 0x11A8, 0x200C, 0x0430, 0x03B1, + }; + + var cps_buf: [64]u32 = undefined; + var last_n: usize = 0; + + for (0..ops) |_| { + switch (rand.intRangeAtMost(u8, 0, 20)) { + // Print a run of codepoints (most common op). + 0...9 => { + const n = rand.intRangeAtMost(usize, 1, cps_buf.len); + last_n = n; + for (cps_buf[0..n]) |*cp| { + cp.* = alphabet[rand.intRangeLessThan(usize, 0, alphabet.len)]; + } + + // t1: per-codepoint print + for (cps_buf[0..n]) |cp| try t1.print(@intCast(cp)); + + // t2: printSlice with random chunking + var i: usize = 0; + while (i < n) { + const chunk = rand.intRangeAtMost(usize, 1, n - i); + try t2.printSlice(cps_buf[i..][0..chunk]); + i += chunk; + } + }, + 10 => { + t1.carriageReturn(); + t2.carriageReturn(); + try t1.linefeed(); + try t2.linefeed(); + }, + 11 => { + const row = rand.intRangeAtMost(usize, 1, rows); + const col = rand.intRangeAtMost(usize, 1, cols); + t1.setCursorPos(row, col); + t2.setCursorPos(row, col); + }, + 12 => { + const attr: sgr.Attribute = switch (rand.intRangeAtMost(u8, 0, 3)) { + 0 => .{ .unset = {} }, + 1 => .{ .bold = {} }, + 2 => .{ .direct_color_fg = .{ + .r = rand.int(u8), + .g = rand.int(u8), + .b = rand.int(u8), + } }, + 3 => .{ .@"8_fg" = .red }, + else => unreachable, + }; + try t1.setAttribute(attr); + try t2.setAttribute(attr); + }, + 13 => { + const v = rand.boolean(); + t1.modes.set(.insert, v); + t2.modes.set(.insert, v); + }, + 14 => { + const v = rand.boolean(); + t1.modes.set(.wraparound, v); + t2.modes.set(.wraparound, v); + }, + 15 => { + const v = rand.boolean(); + // Erase the display first: grapheme clusters created + // while mode 2027 was off can trip a pre-existing + // debug assert in print()'s cluster walk when the mode + // is toggled on (unrelated to printSlice; it reproduces + // with per-codepoint print alone). + t1.eraseDisplay(.complete, false); + t2.eraseDisplay(.complete, false); + t1.modes.set(.grapheme_cluster, v); + t2.modes.set(.grapheme_cluster, v); + }, + 16 => { + // Margins. + t1.modes.set(.enable_left_and_right_margin, true); + t2.modes.set(.enable_left_and_right_margin, true); + const left = rand.intRangeAtMost(usize, 1, cols / 2); + const right = rand.intRangeAtMost(usize, cols / 2, cols); + t1.setLeftAndRightMargin(left, right); + t2.setLeftAndRightMargin(left, right); + }, + 17 => { + t1.setLeftAndRightMargin(0, 0); + t2.setLeftAndRightMargin(0, 0); + }, + 18 => { + try t1.screens.active.startHyperlink("http://example.com", null); + try t2.screens.active.startHyperlink("http://example.com", null); + }, + 19 => { + t1.screens.active.endHyperlink(); + t2.screens.active.endHyperlink(); + }, + 20 => { + const set: charsets.Charset = if (rand.boolean()) + .dec_special + else + .utf8; + t1.configureCharset(.G0, set); + t2.configureCharset(.G0, set); + }, + else => unreachable, + } + + // Cursor state must match exactly after every op. + try testing.expectEqual(t1.screens.active.cursor.x, t2.screens.active.cursor.x); + try testing.expectEqual(t1.screens.active.cursor.y, t2.screens.active.cursor.y); + try testing.expectEqual( + t1.screens.active.cursor.pending_wrap, + t2.screens.active.cursor.pending_wrap, + ); + + // Full screen contents must match after every op. On failure, + // dump diagnostics that make the failure reproducible. + { + const str1 = try t1.screens.active.dumpStringAlloc(alloc, .{ .screen = .{} }); + defer alloc.free(str1); + const str2 = try t2.screens.active.dumpStringAlloc(alloc, .{ .screen = .{} }); + defer alloc.free(str2); + testing.expectEqualStrings(str1, str2) catch |err| { + std.debug.print("last print cps: {any}\n", .{cps_buf[0..last_n]}); + std.debug.print("modes: 2027={} insert={} wrap={} sr.left={} sr.right={} cols={}\n", .{ + t1.modes.get(.grapheme_cluster), + t1.modes.get(.insert), + t1.modes.get(.wraparound), + t1.scrolling_region.left, + t1.scrolling_region.right, + cols, + }); + return err; + }; + } + } + + // Page integrity (styles refcounts, grapheme maps, etc.) must hold. + try t1.screens.active.cursor.page_pin.node.data.verifyIntegrity(alloc); + try t2.screens.active.cursor.page_pin.node.data.verifyIntegrity(alloc); +} + +test "Terminal: printSlice differential fuzz vs print" { + const alloc = testing.allocator; + + // Multiple seeds and terminal sizes for coverage, including a + // tiny terminal to stress wrap/scroll edge cases. + var prng = std.Random.DefaultPrng.init(0xC0FFEE); + const rand = prng.random(); + try testPrintSliceDifferential(alloc, rand, 500, 80, 24); + try testPrintSliceDifferential(alloc, rand, 500, 10, 4); + try testPrintSliceDifferential(alloc, rand, 500, 5, 2); + try testPrintSliceDifferential(alloc, rand, 200, 2, 2); +} + test "Terminal: printAttributes" { const alloc = testing.allocator; var t = try init(alloc, .{ .rows = 5, .cols = 5 }); diff --git a/src/terminal/stream.zig b/src/terminal/stream.zig index 8453b3453..d19e86932 100644 --- a/src/terminal/stream.zig +++ b/src/terminal/stream.zig @@ -33,6 +33,7 @@ const debug = false; /// function for handling. pub const Action = union(Key) { print: Print, + print_slice: PrintSlice, print_repeat: usize, bell, backspace, @@ -130,6 +131,7 @@ pub const Action = union(Key) { lib.target, &.{ "print", + "print_slice", "print_repeat", "bell", "backspace", @@ -252,6 +254,27 @@ pub const Action = union(Key) { } }; + /// A run of printable codepoints. This is emitted instead of + /// individual print actions when the stream can decode multiple + /// printable codepoints at once, so handlers can process them in + /// batch with per-run rather than per-codepoint overhead (see + /// Terminal.printSlice). A naive handler can simply loop and + /// handle each codepoint like a print action. + /// + /// The slice is only valid for the duration of the handler call. + pub const PrintSlice = struct { + cps: []const u32, + + pub const C = extern struct { + cps: [*]const u32, + len: usize, + }; + + pub fn cval(self: PrintSlice) PrintSlice.C { + return .{ .cps = self.cps.ptr, .len = self.cps.len }; + } + }; + pub const InvokeCharset = lib.Struct(lib.target, struct { bank: charsets.ActiveSlot, charset: charsets.Slots, @@ -411,6 +434,11 @@ pub const Action = union(Key) { /// about in its pursuit of implementing a terminal emulator or other /// functionality. /// +/// Note that printable text is delivered via `print_slice` actions +/// (runs of codepoints) whenever the stream can decode multiple +/// codepoints at once, and via `print` actions otherwise. Handlers +/// that care about text must handle both. +/// /// The Handler type must also have a `deinit` function. /// /// The "comptime" key is on purpose (vs. a standard Zig tagged union) @@ -521,12 +549,25 @@ pub fn Stream(comptime H: type) type { // up to that point are just UTF-8. while (self.parser.state == .ground and offset < input.len) { const res = simd.vt.utf8DecodeUntilControlSeq(input[offset..], cp_buf); - for (cp_buf[0..res.decoded]) |cp| { + const cps = cp_buf[0..res.decoded]; + + // Hand runs of printable codepoints to the handler as + // print_slice actions so it can process them with + // per-run rather than per-codepoint overhead. + var i: usize = 0; + while (i < cps.len) { + const cp = cps[i]; if (cp <= 0xF) { + @branchHint(.unlikely); self.execute(@intCast(cp)); - } else { - self.print(@intCast(cp)); + i += 1; + continue; } + + var end = i + 1; + while (end < cps.len and cps[end] > 0xF) end += 1; + self.handler.vt(.print_slice, .{ .cps = cps[i..end] }); + i = end; } // Consume the bytes we just processed. offset += res.consumed; @@ -2415,6 +2456,7 @@ test "simd: print invalid utf-8" { ) void { switch (action) { .print => self.c = value.cp, + .print_slice => self.c = @intCast(value.cps[value.cps.len - 1]), else => {}, } } @@ -2436,6 +2478,7 @@ test "simd: complete incomplete utf-8" { ) void { switch (action) { .print => self.c = value.cp, + .print_slice => self.c = @intCast(value.cps[value.cps.len - 1]), else => {}, } } diff --git a/src/terminal/stream_terminal.zig b/src/terminal/stream_terminal.zig index 929e4ccd4..a0cb88012 100644 --- a/src/terminal/stream_terminal.zig +++ b/src/terminal/stream_terminal.zig @@ -137,6 +137,7 @@ pub const Handler = struct { ) !void { switch (action) { .print => try self.terminal.print(value.cp), + .print_slice => try self.terminal.printSlice(value.cps), .print_repeat => try self.terminal.printRepeat(value), .backspace => self.terminal.backspace(), .carriage_return => self.terminal.carriageReturn(), diff --git a/src/termio/stream_handler.zig b/src/termio/stream_handler.zig index 657ca25c7..307c72686 100644 --- a/src/termio/stream_handler.zig +++ b/src/termio/stream_handler.zig @@ -205,6 +205,10 @@ pub const StreamHandler = struct { @branchHint(.likely); try self.terminal.print(value.cp); }, + .print_slice => { + @branchHint(.likely); + try self.terminal.printSlice(value.cps); + }, .print_repeat => try self.terminal.printRepeat(value), .bell => self.bell(), .backspace => self.terminal.backspace(), From 1a88f3622b50e8d82d3d3ef6c6a56fdbddb895c9 Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Mon, 6 Jul 2026 06:16:52 -0700 Subject: [PATCH 3/5] 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% | --- src/terminal/stream.zig | 89 +++++++++++++++++++++++++++++++++++++++++ 1 file changed, 89 insertions(+) diff --git a/src/terminal/stream.zig b/src/terminal/stream.zig index d19e86932..24a72a44a 100644 --- a/src/terminal/stream.zig +++ b/src/terminal/stream.zig @@ -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 }); } From 253e4f9c3c439f241e93336940fe4bd200d4a7e2 Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Mon, 6 Jul 2026 06:21:31 -0700 Subject: [PATCH 4/5] 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. --- src/terminal/stream.zig | 76 +++++++++++++++++++++++++++++++++++++++++ 1 file changed, 76 insertions(+) diff --git a/src/terminal/stream.zig b/src/terminal/stream.zig index 24a72a44a..15a372b2e 100644 --- a/src/terminal/stream.zig +++ b/src/terminal/stream.zig @@ -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. From cee35cabf69d9cf2501c945fe6ee23811552b024 Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Mon, 6 Jul 2026 06:25:23 -0700 Subject: [PATCH 5/5] terminal: skip style map update when SGR leaves style unchanged Profiling the csi benchmark showed ~20% of time in the style ref-counted set (hash, probe, release/use churn) driven by manualStyleUpdate, which runs after every SGR attribute even when the attribute didn't actually change the cursor style. Real programs re-assert the same style constantly (per span, per line, or on every refresh of a mostly static screen), so a large share of these updates are no-ops. Screen.setAttribute already snapshots the old style to restore it on failure, so this compares the style after applying the attribute and returns early when it's unchanged: the current style ID is already correct and no release/lookup/use is needed. The tradeoff is one extra Style.eql on every style-changing attribute. Measured with ghostty-bench terminal-stream (full terminal handler, 100 MB deterministic corpora, 120x80, M4 Max, ReleaseFast, hyperfine means of 10 runs) across corpora with different repeated style rates (the csi/sgr corpora draw random colors from a palette so nearly every SGR changes the style, which is the worst case for this change; the redraw corpora model TUI refreshes that re-assert the current style for 70% / 95% of SGRs): | stream | before | after | change | |---------------------|--------|--------|--------| | redraw (95% same) | 277 ms | 260 ms | +7% | | redraw (70% same) | 302 ms | 291 ms | +4% | | csi (~0% same) | 407 ms | 414 ms | -2% | | sgr (~0% same) | 295 ms | 303 ms | -3% | Real-world SGR traffic is far closer to the redraw corpora than to the adversarial random-color ones, so this trades a small worst case regression for a solid win on the common pattern. --- src/terminal/Screen.zig | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/src/terminal/Screen.zig b/src/terminal/Screen.zig index 5c39be02f..c9cabfd27 100644 --- a/src/terminal/Screen.zig +++ b/src/terminal/Screen.zig @@ -1992,6 +1992,12 @@ pub fn setAttribute( .unknown => return, } + // If the attribute didn't change our style then we can skip the + // style update entirely: our current style ID is already correct. + // This is a common case in the wild where programs re-assert the + // same style repeatedly (e.g. per span or per line). + if (self.cursor.style.eql(old_style)) return; + try self.manualStyleUpdate(); }