From dc52c248e73386fef496c3bbe8643d6276b7fbfc Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Fri, 31 Jul 2026 19:59:34 -0700 Subject: [PATCH 1/9] benchmark: terminal-resize --- src/benchmark/TerminalResize.zig | 357 +++++++++++++++++++++++++++++++ src/benchmark/cli.zig | 2 + src/benchmark/main.zig | 1 + 3 files changed, 360 insertions(+) create mode 100644 src/benchmark/TerminalResize.zig diff --git a/src/benchmark/TerminalResize.zig b/src/benchmark/TerminalResize.zig new file mode 100644 index 000000000..61b908c83 --- /dev/null +++ b/src/benchmark/TerminalResize.zig @@ -0,0 +1,357 @@ +//! This benchmark tests the performance of Terminal.resize, with a +//! primary focus on column resizes that reflow soft-wrapped text. +//! Resize happens on the IO thread while holding the terminal lock, +//! so a slow resize directly translates into dropped input and a +//! frozen-feeling UI while the user drags the window edge (which +//! produces a rapid stream of resizes). +//! +//! The terminal is populated once during setup (synthetic fill and/or +//! a data file replayed through the VT stream) and then each step +//! ping-pongs the terminal between two sizes. A full cycle returns the +//! terminal to its original dimensions so the state reaches a steady +//! state after the first cycle and every iteration performs +//! equivalent work. +const TerminalResize = @This(); + +const std = @import("std"); +const assert = std.debug.assert; +const Allocator = std.mem.Allocator; +const terminalpkg = @import("../terminal/main.zig"); +const Benchmark = @import("Benchmark.zig"); +const options = @import("options.zig"); +const Terminal = terminalpkg.Terminal; +const global = @import("../global.zig"); + +const log = std.log.scoped(.@"terminal-resize-bench"); + +opts: Options, +alloc: Allocator, +terminal: Terminal, + +pub const Options = struct { + /// The resize pattern to benchmark. See Mode. + mode: Mode = .cols, + + /// Multiplier on the number of resize cycles each step runs. This + /// is useful to make a benchmark run long enough for profiling. + loops: u32 = 1, + + /// The initial size of the terminal. This is also the size that + /// every resize cycle returns to. + @"terminal-rows": u16 = 80, + @"terminal-cols": u16 = 120, + + /// The dimensions to resize to for the cols/rows/both modes. If + /// unset, they default to half of the respective terminal + /// dimension, which forces every soft-wrapped line to rewrap. + @"resize-cols": ?u16 = null, + @"resize-rows": ?u16 = null, + + /// The number of synthetic lines written to the terminal during + /// setup. The content is deterministic: a mix of short lines, + /// soft-wrapped long lines, blank lines, styled cells, and wide + /// characters, since all of these hit different reflow paths. + /// Set to 0 to only use `data`. + @"fill-lines": u32 = 10_000, + + /// The maximum scrollback size in bytes. Defaults to the Ghostty + /// application default. Reflow cost scales with the amount of + /// scrollback, not just the visible screen. + @"scrollback-bytes": usize = 50_000_000, + + /// The data to read as a filepath. If this is "-" then + /// we will read stdin. If this is unset, only the synthetic fill + /// is used. The data is streamed into the terminal during setup + /// (not part of the benchmark) to build the screen contents that + /// get resized. + data: ?[]const u8 = null, +}; + +pub const Mode = enum { + /// Resize to the current dimensions. This measures the early-exit + /// path (mode updates, pixel geometry) and acts as a baseline. + noop, + + /// Alternate the column count between `terminal-cols` and + /// `resize-cols`. With wraparound enabled (the default), this + /// reflows text in both directions: shrinking wraps long lines + /// and growing unwraps them. This is the primary reflow benchmark. + cols, + + /// Like `cols`, but with wraparound mode disabled so the resize + /// does not reflow. Useful as a baseline to isolate the cost of + /// reflow itself from the rest of the resize. + @"cols-no-reflow", + + /// Alternate the row count between `terminal-rows` and + /// `resize-rows`. Column count is unchanged so no text reflows; + /// this measures growing/trimming rows against scrollback. + rows, + + /// Alternate both dimensions at once, like a diagonal window drag. + both, +}; + +pub fn create( + alloc: Allocator, + opts: Options, +) !*TerminalResize { + const ptr = try alloc.create(TerminalResize); + errdefer alloc.destroy(ptr); + + ptr.* = .{ + .opts = opts, + .alloc = alloc, + .terminal = try .init(global.io(), alloc, .{ + .rows = opts.@"terminal-rows", + .cols = opts.@"terminal-cols", + .max_scrollback_bytes = opts.@"scrollback-bytes", + }), + }; + + return ptr; +} + +pub fn destroy(self: *TerminalResize, alloc: Allocator) void { + self.terminal.deinit(alloc); + alloc.destroy(self); +} + +pub fn benchmark(self: *TerminalResize) Benchmark { + return .init(self, .{ + .stepFn = switch (self.opts.mode) { + .noop => stepNoop, + .cols, .@"cols-no-reflow" => stepCols, + .rows => stepRows, + .both => stepBoth, + }, + .setupFn = setup, + }); +} + +/// The column count used by the cols/both modes. +fn targetCols(self: *const TerminalResize) u16 { + return self.opts.@"resize-cols" orelse + @max(1, self.opts.@"terminal-cols" / 2); +} + +/// The row count used by the rows/both modes. +fn targetRows(self: *const TerminalResize) u16 { + return self.opts.@"resize-rows" orelse + @max(1, self.opts.@"terminal-rows" / 2); +} + +fn setup(ptr: *anyopaque) Benchmark.Error!void { + const self: *TerminalResize = @ptrCast(@alignCast(ptr)); + + // Always reset our terminal state. Note this doesn't resize, but + // create initializes (and steps return) the terminal to the + // requested dimensions so we're always at terminal-rows/cols here. + self.terminal.fullReset(); + assert(self.terminal.cols == self.opts.@"terminal-cols"); + assert(self.terminal.rows == self.opts.@"terminal-rows"); + + // Fill with synthetic content first, then replay the data file on + // top if given. Both go through the VT stream so soft wraps, + // styles, etc. are all set exactly as they would be in a real + // session. + self.fill(); + try self.replayData(); + + // Reflow only happens when wraparound mode is set (it is by + // default). We only disable it after filling so the fill itself + // still soft-wraps identically in every mode. + if (self.opts.mode == .@"cols-no-reflow") { + self.terminal.modes.set(.wraparound, false); + } +} + +/// Write deterministic synthetic content to the terminal. The goal is +/// content that is representative of a real session so that reflow +/// touches its interesting paths: soft-wrapped lines (must rewrap), +/// short lines (copied as-is), blank lines, styled cells (styles must +/// be moved across pages), and wide characters (can't be split at the +/// wrap column). +fn fill(self: *TerminalResize) void { + if (self.opts.@"fill-lines" == 0) return; + + var s = self.terminal.vtStream(); + defer s.deinit(); + + var prng: std.Random.DefaultPrng = .init(0xB3); + const rand = prng.random(); + + const cols: usize = self.terminal.cols; + var line_buf: [8192]u8 = undefined; + + for (0..self.opts.@"fill-lines") |i| { + // Periodically toggle a background style so reflow has to + // carry styled cells into new pages. + if (i % 64 == 0) s.nextSlice("\x1b[48;2;20;40;60m"); + if (i % 64 == 32) s.nextSlice("\x1b[m"); + + // A small portion of lines are blank. + if (i % 16 == 15) { + s.nextSlice("\r\n"); + continue; + } + + // Line lengths between ~25% and ~250% of the terminal width + // so we get a mix of short lines and soft-wrapped lines. + const min = @max(1, cols / 4); + const max = @min(line_buf.len, cols * 5 / 2); + const len = min + rand.uintLessThan(usize, max - min); + + var j: usize = 0; + while (j < len) { + // Sprinkle wide characters into every 8th line. + if (i % 8 == 7 and j % 16 == 8 and j + 3 <= len) { + line_buf[j..][0..3].* = "漢".*; + j += 3; + continue; + } + + // Words of ASCII separated by spaces. + line_buf[j] = if (j % 8 == 7) + ' ' + else + rand.intRangeAtMost(u8, 'a', 'z'); + j += 1; + } + + s.nextSlice(line_buf[0..len]); + s.nextSlice("\r\n"); + } +} + +/// Stream the data file (if any) into the terminal. +fn replayData(self: *TerminalResize) Benchmark.Error!void { + const data_f: std.Io.File = (options.dataFile( + self.opts.data, + ) catch |err| { + log.warn("error opening data file err={}", .{err}); + return error.BenchmarkFailed; + }) orelse return; + defer data_f.close(global.io()); + + var stream = self.terminal.vtStream(); + defer stream.deinit(); + + var read_buf: [4096]u8 align(std.atomic.cache_line) = undefined; + var f_reader = data_f.reader(global.io(), &read_buf); + const r = &f_reader.interface; + + var buf: [4096]u8 = undefined; + while (true) { + const n = r.readSliceShort(&buf) catch { + log.warn("error reading data file err={?}", .{f_reader.err}); + return error.BenchmarkFailed; + }; + if (n == 0) break; // EOF reached + stream.nextSlice(buf[0..n]); + } +} + +fn resizeTerminal( + self: *TerminalResize, + cols: u16, + rows: u16, +) Benchmark.Error!void { + self.terminal.resize(self.alloc, .{ + .cols = cols, + .rows = rows, + // Realistic cell pixel geometry: real apprt resizes always + // carry it, and it exercises the pixel dimension updates. + .cell_size_px = .{ .width = 10, .height = 20 }, + }) catch |err| { + log.warn("error resizing terminal err={}", .{err}); + return error.BenchmarkFailed; + }; + std.mem.doNotOptimizeAway(&self.terminal); +} + +fn stepNoop(ptr: *anyopaque) Benchmark.Error!void { + const self: *TerminalResize = @ptrCast(@alignCast(ptr)); + + const cols = self.opts.@"terminal-cols"; + const rows = self.opts.@"terminal-rows"; + + // We loop because it's so fast (a few ns) that a single resize + // doesn't properly capture our speeds. + for (0..50_000_000 * @as(u64, self.opts.loops)) |_| { + try self.resizeTerminal(cols, rows); + } +} + +fn stepCols(ptr: *anyopaque) Benchmark.Error!void { + const self: *TerminalResize = @ptrCast(@alignCast(ptr)); + + const cols = self.opts.@"terminal-cols"; + const rows = self.opts.@"terminal-rows"; + const target = self.targetCols(); + + // Per-cycle cost differs by orders of magnitude between the two + // modes (a reflow resize walks the entire scrollback; a non-reflow + // resize doesn't), so pick a cycle count that makes each run long + // enough to measure well above process startup and setup cost. + const cycles: u64 = switch (self.opts.mode) { + .cols => 25, + .@"cols-no-reflow" => 1_500, + else => unreachable, + }; + + // Each cycle shrinks (rewrapping long lines) and grows back + // (unwrapping them), ending at the original size. + for (0..cycles * @as(u64, self.opts.loops)) |_| { + try self.resizeTerminal(target, rows); + try self.resizeTerminal(cols, rows); + } +} + +fn stepRows(ptr: *anyopaque) Benchmark.Error!void { + const self: *TerminalResize = @ptrCast(@alignCast(ptr)); + + const cols = self.opts.@"terminal-cols"; + const rows = self.opts.@"terminal-rows"; + const target = self.targetRows(); + + // Row-only resizes don't reflow so they're much cheaper (tens of + // ns); loop a lot more so the measurement isn't dominated by + // process overhead. + for (0..2_500_000 * @as(u64, self.opts.loops)) |_| { + try self.resizeTerminal(cols, target); + try self.resizeTerminal(cols, rows); + } +} + +fn stepBoth(ptr: *anyopaque) Benchmark.Error!void { + const self: *TerminalResize = @ptrCast(@alignCast(ptr)); + + const cols = self.opts.@"terminal-cols"; + const rows = self.opts.@"terminal-rows"; + const target_cols = self.targetCols(); + const target_rows = self.targetRows(); + + for (0..25 * @as(u64, self.opts.loops)) |_| { + try self.resizeTerminal(target_cols, target_rows); + try self.resizeTerminal(cols, rows); + } +} + +test TerminalResize { + const testing = std.testing; + const alloc = testing.allocator; + + // Small dimensions and fill so this is fast in debug builds while + // still exercising real reflow in both directions. + const impl: *TerminalResize = try .create(alloc, .{ + .mode = .cols, + .@"terminal-rows" = 10, + .@"terminal-cols" = 20, + .@"fill-lines" = 50, + }); + defer impl.destroy(alloc); + + const bench = impl.benchmark(); + _ = try bench.run(.once); +} diff --git a/src/benchmark/cli.zig b/src/benchmark/cli.zig index 19db37a3d..1dc089b71 100644 --- a/src/benchmark/cli.zig +++ b/src/benchmark/cli.zig @@ -15,6 +15,7 @@ pub const Action = enum { @"scrollback-compression", @"screen-clone", @"terminal-parser", + @"terminal-resize", @"terminal-stream", @"is-symbol", @"osc-parser", @@ -38,6 +39,7 @@ pub const Action = enum { .@"codepoint-width" => @import("CodepointWidth.zig"), .@"grapheme-break" => @import("GraphemeBreak.zig"), .@"terminal-parser" => @import("TerminalParser.zig"), + .@"terminal-resize" => @import("TerminalResize.zig"), .@"is-symbol" => @import("IsSymbol.zig"), .@"osc-parser" => @import("OscParser.zig"), }; diff --git a/src/benchmark/main.zig b/src/benchmark/main.zig index f22891c71..fb038c0a3 100644 --- a/src/benchmark/main.zig +++ b/src/benchmark/main.zig @@ -7,6 +7,7 @@ pub const GraphemeBreak = @import("GraphemeBreak.zig"); pub const HyperlinkMap = @import("HyperlinkMap.zig"); pub const ScreenClone = @import("ScreenClone.zig"); pub const TerminalParser = @import("TerminalParser.zig"); +pub const TerminalResize = @import("TerminalResize.zig"); pub const IsSymbol = @import("IsSymbol.zig"); pub const PageCompression = @import("PageCompression.zig"); pub const ScrollbackCompression = @import("ScrollbackCompression.zig"); From 4a88cc5948295019d85f09ad77bcc303b7aba69a Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Fri, 31 Jul 2026 20:44:52 -0700 Subject: [PATCH 2/9] terminal: skip reflow pin scans for rows without pins Reflow scanned the full tracked pin list for every source cell it copied, twice per cell in the wide-character case, even though pins are rare and at most a handful exist. Each check also went through node.page(), which can restore a compressed page just to compare pointers. reflowRow now determines once per row whether any tracked pin is on the source row and skips the per-cell pin scans entirely when there is none, which is the overwhelmingly common case. The comparisons use node identity instead of pages: a node owns exactly one page, so they are equivalent, and this avoids the restore hazard. 1.09x faster on ghostty-bench +terminal-resize --mode=cols (120x80 terminal, 10k-line scrollback, shrink/grow column reflow cycles). --- src/terminal/PageList.zig | 28 ++++++++++++++++++---------- 1 file changed, 18 insertions(+), 10 deletions(-) diff --git a/src/terminal/PageList.zig b/src/terminal/PageList.zig index b8149544c..532e26fea 100644 --- a/src/terminal/PageList.zig +++ b/src/terminal/PageList.zig @@ -1566,12 +1566,21 @@ const ReflowCursor = struct { if (cols_len == 0 and src_row.semantic_prompt != .none) cols_len = 1; } - // Handle tracked pin adjustments. + // Handle tracked pin adjustments. We also note whether any + // tracked pin is on this row at all so that the per-cell loop + // below can skip pin scans entirely for the overwhelmingly + // common case of a row with no pins. Note we compare nodes + // rather than pages since a node owns exactly one page; this + // is cheaper and avoids `page()` restoring unrelated + // compressed nodes purely for a comparison. + var row_has_pins = false; { const pin_keys = list.tracked_pins.keys(); for (pin_keys) |p| { - if (p.node.page() != src_page or - p.y != src_y) continue; + if (p.node != row.node or p.y != src_y) continue; + + // This row has pins + row_has_pins = true; if (cursor_pin != null and p == cursor_pin.?) continue; @@ -1593,7 +1602,7 @@ const ReflowCursor = struct { // If the cursor is after blanks on the right, those cells are still // before the next write and must reflow with it. if (cursor_pin) |p| { - if (p.node.page() == src_page and p.y == src_y) { + if (p.node == row.node and p.y == src_y) { cols_len = @max(cols_len, p.x + 1); } } @@ -1641,10 +1650,10 @@ const ReflowCursor = struct { } // Move any tracked pins from the source. - { + if (row_has_pins) { const pin_keys = list.tracked_pins.keys(); for (pin_keys) |p| { - if (p.node.page() != src_page or + if (p.node != row.node or p.y != src_y or p.x != x) continue; @@ -1667,16 +1676,15 @@ const ReflowCursor = struct { .skip_next => { // Remap any tracked pins at the skipped position (x+1) // since we won't process that cell in the loop. - const pin_keys = list.tracked_pins.keys(); - for (pin_keys) |p| { - if (p.node.page() != src_page or + if (row_has_pins) for (list.tracked_pins.keys()) |p| { + if (p.node != row.node or p.y != src_y or p.x != x + 1) continue; p.node = self.node; p.x = self.x; p.y = self.y; - } + }; x += 2; }, From c5ca2db1b6ef2d8a160767cb0c13ec2d2061e83f Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Fri, 31 Jul 2026 20:47:52 -0700 Subject: [PATCH 3/9] terminal: memoize style id mapping during reflow Memoize the most recent style mapping and when there is a reuse bump the ref with `use()`. This avoids a lookup (`addWithId`) on every single styled cell. 1.25x faster on ghostty-bench +terminal-resize --mode=cols (120x80 terminal, 10k-line scrollback, shrink/grow column reflow cycles). --- src/terminal/PageList.zig | 49 +++++++++++++++++++++++++++++++++++++++ 1 file changed, 49 insertions(+) diff --git a/src/terminal/PageList.zig b/src/terminal/PageList.zig index 532e26fea..d1af5d2a7 100644 --- a/src/terminal/PageList.zig +++ b/src/terminal/PageList.zig @@ -1522,6 +1522,28 @@ const ReflowCursor = struct { /// This is the final row count of the reflowed pages. total_rows: usize, + /// Memoizes the most recent source-to-destination style id + /// mapping. Styled cells come in long runs sharing the same style + /// so this lets writeCell bump the destination ref count directly + /// instead of performing a set lookup for every styled cell. + /// + /// The destination id is only valid for the current destination + /// page, which is why this lives on the cursor: every destination + /// page change goes through init() which resets this. + style_cache: StyleCache, + + const StyleCache = struct { + src_page: ?*const Page, + src_id: stylepkg.Id, + dst_id: stylepkg.Id, + + const invalid: StyleCache = .{ + .src_page = null, + .src_id = stylepkg.default_id, + .dst_id = stylepkg.default_id, + }; + }; + fn init(node: *List.Node) ReflowCursor { const page = node.page(); const rows = page.rows.ptr(page.memory); @@ -1537,6 +1559,8 @@ const ReflowCursor = struct { // Initially whatever size our input node is. .total_rows = node.rows(), + + .style_cache = .invalid, }; } @@ -2038,6 +2062,23 @@ const ReflowCursor = struct { // Copy style data. if (cell.hasStyling()) style: { + // Fast path: styled cells come in long runs sharing the + // same style. If this source style was just mapped into + // the current destination page, bump the ref count + // directly and skip the set lookup. The destination id is + // guaranteed alive because a previously written cell in + // this page holds a reference, and the cache is reset + // whenever the destination page changes (see init). + if (self.style_cache.src_page == src_page and + self.style_cache.src_id == cell.style_id) + { + const id = self.style_cache.dst_id; + self.page.styles.use(self.page.memory, id); + self.page_row.styled = true; + self.page_cell.style_id = id; + break :style; + } + const style = src_page.styles.get( src_page.memory, cell.style_id, @@ -2080,6 +2121,14 @@ const ReflowCursor = struct { }; } orelse cell.style_id; + // Update our style cache with the latest style set so runs + // of cells with the same style are faster to write. + self.style_cache = .{ + .src_page = src_page, + .src_id = cell.style_id, + .dst_id = id, + }; + self.page_row.styled = true; self.page_cell.style_id = id; } From c249b9de3496bc3e8c4128686ba64553e17409a8 Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Fri, 31 Jul 2026 20:48:19 -0700 Subject: [PATCH 4/9] terminal: bulk-copy runs of simple cells during reflow Reflow copied every cell through a per-cell state machine (writeCell) that dispatches on content tag, wide property, grapheme, hyperlink, and style handling, and advances the destination cursor one cell at a time. The vast majority of cells in practice are narrow text or bg-color cells with no managed memory that share a single style across long runs. reflowRow now scans ahead for the run of such cells bounded by the remaining space in the destination row, copies the run with a single memcpy, and adjusts the style ref count once for the whole run via useMultiple. Wide characters, spacers, graphemes, hyperlinks, Kitty placeholders, and rows containing tracked pins all take the original per-cell path, and a style set failure falls back to writeCell which handles growing page capacity. 2.19x faster on ghostty-bench +terminal-resize --mode=cols (120x80 terminal, 10k-line scrollback, shrink/grow column reflow cycles). --- src/terminal/PageList.zig | 167 ++++++++++++++++++++++++++++++++++++++ 1 file changed, 167 insertions(+) diff --git a/src/terminal/PageList.zig b/src/terminal/PageList.zig index d1af5d2a7..1ace0613f 100644 --- a/src/terminal/PageList.zig +++ b/src/terminal/PageList.zig @@ -1673,6 +1673,25 @@ const ReflowCursor = struct { self.page_row.wrap_continuation = true; } + // Fast path: bulk-copy a run of simple cells directly + // into the destination row. The vast majority of cells + // are narrow, have no managed memory (graphemes, + // hyperlinks), and share a single style in long runs, so + // this avoids the per-cell state machine below for most + // of the work. Rows with tracked pins take the slow path + // so pin remapping behaves identically. + if (!row_has_pins) { + const max_run = @min( + cols_len - x, + @as(usize, self.page.size.cols) - self.x, + ); + const run = bulkRunLength(cells[x..][0..max_run]); + if (run > 0 and self.copyRun(cells[x..][0..run], src_page)) { + x += run; + continue; + } + } + // Move any tracked pins from the source. if (row_has_pins) { const pin_keys = list.tracked_pins.keys(); @@ -1746,6 +1765,154 @@ const ReflowCursor = struct { } } + /// True if this cell can be copied verbatim as part of a bulk + /// run: a narrow plain-text or bg-color cell with no managed + /// memory (graphemes, hyperlinks) and no special reflow handling + /// (wide characters, spacers, Kitty virtual placeholders). For + /// these cells writeCell reduces to a copy of the raw cell plus + /// a style ref count adjustment. + inline fn bulkCopyable(cell: pagepkg.Cell) bool { + return switch (cell.content_tag) { + .codepoint => copyable: { + if (cell.wide != .narrow) break :copyable false; + if (cell.hyperlink) break :copyable false; + if (comptime build_options.kitty_graphics) { + // Placeholders must set a row flag, so they take + // the slow path. + if (cell.content.codepoint.data == + kitty.graphics.unicode.placeholder) + { + break :copyable false; + } + } + break :copyable true; + }, + + // Grapheme data must be cloned cell-by-cell. + .codepoint_grapheme => false, + + // These are guaranteed to have no style or grapheme data + // (see writeCell) so they are pure copies. The style + // check is defensive so that a bg cell can never join or + // extend a styled run. + .bg_color_palette, + .bg_color_rgb, + => cell.style_id == stylepkg.default_id, + }; + } + + /// The length of the prefix of cells that can be copied at once + /// with copyRun: bulk-copyable cells sharing a single style. + fn bulkRunLength(cells: []const pagepkg.Cell) usize { + if (cells.len == 0) return 0; + const first = cells[0]; + if (!bulkCopyable(first)) return 0; + + var len: usize = 1; + while (len < cells.len) : (len += 1) { + const cell = cells[len]; + if (!bulkCopyable(cell) or + cell.style_id != first.style_id) break; + } + + return len; + } + + /// Copy a run of bulk-copyable cells (see bulkCopyable) sharing + /// one style into the destination row at the current position, + /// then advance the cursor. The run must fit in the remaining + /// columns of the destination row. + /// + /// Returns false without any state change if the style could not + /// be mapped into the destination page without a capacity + /// change; the caller should fall back to writeCell which + /// handles growing capacity. + fn copyRun( + self: *ReflowCursor, + src_cells: []const pagepkg.Cell, + src_page: *const Page, + ) bool { + assert(!self.pending_wrap); + assert(src_cells.len >= 1); + assert(src_cells.len <= self.page.size.cols - self.x); + + const style_id = src_cells[0].style_id; + const n: u16 = @intCast(src_cells.len); + + // Resolve the destination style id for this run and take one + // reference per cell. This mirrors the per-cell style logic + // in writeCell, including the memoization (see StyleCache). + const dst_style_id: stylepkg.Id = if (style_id == stylepkg.default_id) + stylepkg.default_id + else dst: { + if (self.style_cache.src_page == @as(?*const Page, src_page) and + self.style_cache.src_id == style_id) + { + const id = self.style_cache.dst_id; + self.page.styles.useMultiple(self.page.memory, id, n); + break :dst id; + } + + const style = src_page.styles.get( + src_page.memory, + style_id, + ).*; + + // Any error here (set full or needs rehash) is handled + // by falling back to the slow path, which grows capacity. + // No state has been modified yet at this point. + const id = (self.page.styles.addWithId( + self.page.memory, + style, + style_id, + ) catch return false) orelse style_id; + + // addWithId took one reference, take the rest. + if (n > 1) self.page.styles.useMultiple( + self.page.memory, + id, + n - 1, + ); + + self.style_cache = .{ + .src_page = src_page, + .src_id = style_id, + .dst_id = id, + }; + + break :dst id; + }; + + // Copy the raw cell contents. + const dst_cells: []pagepkg.Cell = @as( + [*]pagepkg.Cell, + @ptrCast(self.page_cell), + )[0..src_cells.len]; + @memcpy(dst_cells, src_cells); + + // If the style resolved to a different id in the destination + // page then rewrite the copied cells to point at it. + if (dst_style_id != style_id) { + for (dst_cells) |*cell| cell.style_id = dst_style_id; + } + if (dst_style_id != stylepkg.default_id) self.page_row.styled = true; + + // Advance the cursor, matching what repeated cursorForward + // calls after each cell write would have done. + const cols = self.page.size.cols; + const cell_ptr: [*]pagepkg.Cell = @ptrCast(self.page_cell); + if (self.x + n == cols) { + self.x = cols - 1; + self.page_cell = @ptrCast(cell_ptr + n - 1); + self.pending_wrap = true; + } else { + self.x += n; + self.page_cell = @ptrCast(cell_ptr + n); + } + + return true; + } + /// Write a cell. On error, this will not unwrite the cell but /// the cell may be incomplete (but valid). For example, if the source /// cell is styled and we failed to allocate space for styles, the From 179161c081199d49c6b1238418b0a8712855e2f3 Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Fri, 31 Jul 2026 20:51:50 -0700 Subject: [PATCH 5/9] terminal: memoize reflow new-page capacity adjustment reflowRow computed the capacity for prospective destination pages on every source row via Capacity.adjust, which performs a full page layout calculation to find the available grid space. The result only depends on the source page, and reflow visits source pages sequentially and never revisits one, so memoize the adjustment per source page so we only do this once. 1.06x faster on ghostty-bench +terminal-resize --mode=cols (120x80 terminal, 10k-line scrollback, shrink/grow column reflow cycles). --- src/terminal/PageList.zig | 55 ++++++++++++++++++++++++++++++--------- 1 file changed, 43 insertions(+), 12 deletions(-) diff --git a/src/terminal/PageList.zig b/src/terminal/PageList.zig index 1ace0613f..dc7b11497 100644 --- a/src/terminal/PageList.zig +++ b/src/terminal/PageList.zig @@ -1532,6 +1532,15 @@ const ReflowCursor = struct { /// page change goes through init() which resets this. style_cache: StyleCache, + /// Memoizes the capacity adjustment for new destination pages + /// (see reflowRow). It only depends on the source page so this is + /// keyed by the source page pointer, which reflow visits + /// sequentially and never revisits. + cap_memo: ?struct { + src_page: *const Page, + cap: Capacity, + }, + const StyleCache = struct { src_page: ?*const Page, src_id: stylepkg.Id, @@ -1561,6 +1570,7 @@ const ReflowCursor = struct { .total_rows = node.rows(), .style_cache = .invalid, + .cap_memo = null, }; } @@ -1643,18 +1653,16 @@ const ReflowCursor = struct { // Inherit increased styles or grapheme bytes from the src page // we're reflowing from for new pages. - const cap = src_page.capacity.adjust( - .{ .cols = self.page.size.cols }, - ) catch |err| err: { - comptime assert(@TypeOf(err) == error{OutOfMemory}); - - var cap = src_page.capacity; - cap.cols = self.page.size.cols; - // We're already a non-standard page. We don't want to - // inherit a massive set of rows, so cap it at our std size. - cap.rows = @min(src_page.size.rows, std_capacity.rows); - break :err cap; - }; + // + // This only depends on the source page, which we process row + // by row, so memoize it: computing the adjustment requires a + // full page layout calculation which is much too expensive to + // do for every row. + const cap: Capacity = if (self.cap_memo) |memo| cap: { + if (memo.src_page == src_page) break :cap memo.cap; + // Source page changed, fall through to recompute. + break :cap self.computeAndMemoizeCap(src_page); + } else self.computeAndMemoizeCap(src_page); // Our row isn't blank, write any new rows we deferred. while (self.new_rows > 0) { @@ -1765,6 +1773,29 @@ const ReflowCursor = struct { } } + /// Compute and memoize the new-page capacity for the given source + /// page. See the call site in reflowRow for details. + fn computeAndMemoizeCap( + self: *ReflowCursor, + src_page: *const Page, + ) Capacity { + const cap = src_page.capacity.adjust( + .{ .cols = self.page.size.cols }, + ) catch |err| err: { + comptime assert(@TypeOf(err) == error{OutOfMemory}); + + var cap = src_page.capacity; + cap.cols = self.page.size.cols; + // We're already a non-standard page. We don't want to + // inherit a massive set of rows, so cap it at our std size. + cap.rows = @min(src_page.size.rows, std_capacity.rows); + break :err cap; + }; + + self.cap_memo = .{ .src_page = src_page, .cap = cap }; + return cap; + } + /// True if this cell can be copied verbatim as part of a bulk /// run: a narrow plain-text or bg-color cell with no managed /// memory (graphemes, hyperlinks) and no special reflow handling From 46276d046ced8930501c8a7a056d96fecf9aa789 Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Fri, 31 Jul 2026 20:53:18 -0700 Subject: [PATCH 6/9] terminal: recycle pages within a column reflow In `resizeCols`, stash the most recently finished source node instead of destroying it, so we can recycle it without a bunch of syscalls. 1.30x faster on ghostty-bench +terminal-resize --mode=cols (120x80 terminal, 10k-line scrollback, shrink/grow column reflow cycles), with system time dropping from 34ms to 8ms per run. --- src/terminal/PageList.zig | 76 ++++++++++++++++++++++++++++++++++++--- 1 file changed, 71 insertions(+), 5 deletions(-) diff --git a/src/terminal/PageList.zig b/src/terminal/PageList.zig index dc7b11497..9c99b99d1 100644 --- a/src/terminal/PageList.zig +++ b/src/terminal/PageList.zig @@ -402,6 +402,15 @@ page_size: usize, /// in the state struct. page_compression: IncrementalCompressionState = .{}, +/// A node available for immediate reuse by createPage, bypassing the +/// memory pool round trip. This is only set during column reflow +/// (resizeCols), which destroys one source page for roughly every +/// destination page it creates: returning a page buffer to the pool +/// decommits it and taking one back recommits it, and that syscall +/// pair per page is a significant part of reflow cost. Always null +/// outside of an in-progress reflow. +recycle_node: ?*List.Node = null, + /// Limits for scrollback. limits: Limits, @@ -1416,6 +1425,14 @@ fn resizeCols( } } + // Reflowed source pages are stashed for reuse as destination + // pages (see recycle_node). Whether we succeed or fail, a stashed + // node must not outlive the reflow. + defer if (self.recycle_node) |node| { + self.recycle_node = null; + self.destroyNode(node); + }; + // Set our new page as the only page. This orphans the existing pages // in the list, but that's fine since we're gonna delete them anyway. self.pages.first = first_rewritten_node; @@ -1431,11 +1448,21 @@ fn resizeCols( if (preserved_cursor) |c| c.tracked_pin else null, ); - // Once we're done reflowing a page, destroy it immediately. - // This frees memory and makes it more likely in memory - // constrained environments that the next reflow will work. - if (row.y == row.node.rows() - 1) { - self.destroyNode(row.node); + // Once we're done reflowing a page, we're done with it, so + // make it available for reuse (or destroy it). Making it + // immediately available frees memory and makes it more + // likely in memory constrained environments that the next + // reflow will work. + if (row.y == row.node.rows() - 1) destroy_node: { + if (self.recycle_node != null or + row.node.owned != .pool or + row.node.data != .resident) + { + self.destroyNode(row.node); + break :destroy_node; + } + + self.recycle_node = row.node; } } @@ -4260,6 +4287,45 @@ inline fn createPage( opts: CreatePage, ) Allocator.Error!*List.Node { // log.debug("create page cap={}", .{opts.cap}); + + // If we have a node available for recycling (only during reflow, + // see recycle_node), reuse it directly rather than going through + // the memory pool. + if (self.recycle_node) |node| recycle: { + // Only a standard pool-owned resident node can be rebuilt + // in place for a standard-size layout. + if (opts.exact_size) break :recycle; + if (node.owned != .pool) break :recycle; + if (node.data != .resident) break :recycle; + const layout = Page.layout(opts.cap); + if (layout.total_size > std_size) break :recycle; + + self.recycle_node = null; + + // The pool guarantees that buffers it hands out are zeroed. + // A pool-owned page dirties only its Page.memory prefix of + // the underlying standard-size item, so zeroing that prefix + // re-establishes the guarantee (this mirrors destroyNodeExt, + // minus the decommit). + const page = &node.data.resident; + const item: *align(std.heap.page_size_min) [std_size]u8 = + @ptrCast(@alignCast(page.memory.ptr)); + @memset(page.memory, 0); + + // Accounting: a pool-owned node always accounts for a full + // pool item in page_size, so destroying the node and + // creating a new pooled one is a net zero. + + node.* = .{ + .data = .{ .resident = .initBuf(.init(item), layout) }, + .serial = self.page_serial, + .owned = .pool, + }; + node.page().size.rows = 0; + self.page_serial += 1; + return node; + } + return try createPageExt( &self.pool, opts, From 0fb3565c768ea6a07cfa5d3d71d106a126af667a Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Fri, 31 Jul 2026 21:20:23 -0700 Subject: [PATCH 7/9] terminal: support nested field paths and eqlAny in Mask Two small extensions to the Mask helper, both motivated by the reflow bulk run scan in the next commits. fieldMask now accepts dot-separated field paths so a mask can cover a nested field of a packed struct or packed union member, e.g. "content.codepoint.data" covers exactly the codepoint bits of a cell without its padding. Packed union members all share bit offset zero. Mask gains eqlAny, the "any" counterpart to eql: it returns whether any value in a group has masked fields equal to the expected pattern. This supports run scans that must stop when a sentinel value appears anywhere in a group, such as the Kitty virtual placeholder codepoint which requires slow-path handling. --- src/terminal/page.zig | 105 +++++++++++++++++++++++++++++++++++++++--- 1 file changed, 98 insertions(+), 7 deletions(-) diff --git a/src/terminal/page.zig b/src/terminal/page.zig index dae019c1f..a06a1dae7 100644 --- a/src/terminal/page.zig +++ b/src/terminal/page.zig @@ -2239,6 +2239,12 @@ pub const Cell = packed struct(u64) { /// struct T, used for masked compares of raw backing-integer values /// (e.g. `Row.Backing`, `Cell.Backing`). This is an implementation /// detail of `Mask`, which is the public API built on top of this. +/// +/// A field may be a dot-separated path (e.g. "content.codepoint.data") +/// to cover only a nested field of a packed struct or packed union +/// member. This allows a mask to be more precise than a whole +/// top-level field, e.g. covering the codepoint bits of a cell without +/// its padding. fn fieldMask( comptime T: type, comptime fields: []const []const u8, @@ -2246,16 +2252,33 @@ fn fieldMask( // Backing int of the packed struct const Int = @typeInfo(T).@"struct".backing_integer.?; - var mask: Int = 0; - inline for (fields) |field| { + comptime var mask: Int = 0; + inline for (fields) |path| { + // Walk the path to find the total bit offset and the type of + // the (possibly nested) field. + comptime var offset = 0; + comptime var Field = T; + comptime var it = std.mem.splitScalar(u8, path, '.'); + inline while (comptime it.next()) |name| { + offset += switch (@typeInfo(Field)) { + .@"struct" => @bitOffsetOf(Field, name), + + // Packed union members all share bit offset zero. + .@"union" => |u| offset: { + comptime assert(u.layout == .@"packed"); + break :offset 0; + }, + + else => @compileError("invalid field path: " ++ path), + }; + Field = @FieldType(Field, name); + } + // The type that fits all the bits we need to set. - const Ones = std.meta.Int( - .unsigned, - @bitSizeOf(@FieldType(T, field)), - ); + const Ones = std.meta.Int(.unsigned, @bitSizeOf(Field)); // Mask out the ones - mask |= @as(Int, std.math.maxInt(Ones)) << @bitOffsetOf(T, field); + mask |= @as(Int, std.math.maxInt(Ones)) << offset; } return mask; @@ -2378,6 +2401,21 @@ pub fn Mask( return pattern(v) == expected; } + /// Returns true if any value in the group of group_len values + /// starting at index i has masked fields equal to the expected + /// pattern (see `pattern`). This is the "any" counterpart to + /// `eql`: use it to detect the presence of a specific value + /// within a group, e.g. a run scan that must stop when it + /// encounters a sentinel codepoint anywhere in the group. + pub inline fn eqlAny( + values: []const T, + i: usize, + expected: Backing, + ) bool { + const masked = load(values, i) & @as(Group, @splat(mask)); + return @reduce(.Or, masked == @as(Group, @splat(expected))); + } + /// Like `eql` but returns the number of leading values whose /// masked fields equal the expected pattern, i.e. group_len if /// the entire group matches. This is useful for early-exit run @@ -2497,6 +2535,59 @@ test "Mask" { try testing.expectEqual(M.strip(styled), M.strip(styled_other)); try testing.expect(M.strip(styled) != M.strip(styled2)); } + + // eqlAny: presence of a matching value anywhere in the group + { + const expected = M.pattern(styled); + var cells: [4]Cell = .{ plain, plain, plain, plain }; + try testing.expect(!M.eqlAny(&cells, 0, expected)); + + cells[2] = styled; + try testing.expect(M.eqlAny(&cells, 0, expected)); + + // Masked compare: same masked fields with a different + // codepoint still matches. + cells[2] = styled2; + try testing.expect(M.eqlAny(&cells, 0, expected)); + } +} + +test "Mask nested field path" { + // Mask only the codepoint data bits of the content field, not + // the padding next to it or any other field. + const M = Mask(Cell, &.{"content.codepoint.data"}, 4); + + const a: Cell = .init('A'); + var b: Cell = .init('A'); + b.style_id = 5; + b.wide = .wide; + const c: Cell = .init('C'); + + // Same codepoint matches regardless of other fields. + const expected = M.pattern(a); + try testing.expect(M.eqlScalar(b, expected)); + try testing.expect(!M.eqlScalar(c, expected)); + + // The mask must cover exactly the codepoint data bits. + const cp_offset = @bitOffsetOf(Cell, "content"); + try testing.expectEqual( + @as(u64, std.math.maxInt(u21)) << cp_offset, + comptime fieldMask(Cell, &.{"content.codepoint.data"}), + ); + + // Group variants + { + var cells: [4]Cell = .{ a, b, a, b }; + try testing.expect(M.eql(&cells, 0, expected)); + try testing.expect(M.eqlAny(&cells, 0, expected)); + + cells[1] = c; + try testing.expect(!M.eql(&cells, 0, expected)); + try testing.expect(M.eqlAny(&cells, 0, expected)); + + const none: [4]Cell = .{ c, c, c, c }; + try testing.expect(!M.eqlAny(&none, 0, expected)); + } } // Uncomment this when you want to do some math. From d4e446c4803d2ad6dd3a3eb40a1dd9ad6037fc21 Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Fri, 31 Jul 2026 20:56:58 -0700 Subject: [PATCH 8/9] terminal: reduce reflow run scan to masked compares Finding the length of a bulk-copyable cell run evaluated the field-wise bulkCopyable predicate plus a style compare per cell, which compiles to a chain of extracts and branches and had become the hottest loop in a column reflow. Once the first cell passes the full predicate, a cell continues the run iff it matches the first cell in content tag, style id, wide property, and hyperlink flag, so the continuation test is now a masked compare of the raw cell bits via the Mask helper, plus a masked equality test against the Kitty virtual placeholder codepoint for text runs (placeholders must set a row flag so they take the slow path). This is slightly stricter than the predicate (a bg-color cell no longer extends an unstyled text run), which only splits a copy into multiple runs and remains correct. 1.21x faster on ghostty-bench +terminal-resize --mode=cols (120x80 terminal, 10k-line scrollback, shrink/grow column reflow cycles). --- src/terminal/PageList.zig | 61 ++++++++++++++++++++++++++++++++++++--- 1 file changed, 57 insertions(+), 4 deletions(-) diff --git a/src/terminal/PageList.zig b/src/terminal/PageList.zig index 9c99b99d1..453c589f8 100644 --- a/src/terminal/PageList.zig +++ b/src/terminal/PageList.zig @@ -1859,18 +1859,71 @@ const ReflowCursor = struct { }; } + /// The group length for the masked-compare helpers below. This + /// matches the group length used by other cell scans (e.g. the + /// render state scans). + const bulk_group_len = 8; + + /// Masked compare helper covering every cell field that a run + /// must share to be copied by copyRun: given that the first cell + /// of a run passed the full bulkCopyable predicate, equality on + /// these fields implies the same for every subsequent cell, with + /// the same style. + /// + /// Note this is slightly stricter than bulkCopyable (e.g. a + /// bg-color cell won't extend an unstyled text run even though it + /// is copyable): that only splits the copy into multiple runs, + /// which is still correct. + const BulkRunMask = pagepkg.Mask(pagepkg.Cell, &.{ + "content_tag", + "style_id", + "wide", + "hyperlink", + }, bulk_group_len); + + /// Masked compare helper for detecting the Kitty virtual + /// placeholder codepoint in text cells. Placeholders take the + /// slow path (they must set a row flag), so they terminate a run. + const PlaceholderMask = pagepkg.Mask(pagepkg.Cell, &.{ + "content.codepoint.data", + }, bulk_group_len); + /// The length of the prefix of cells that can be copied at once - /// with copyRun: bulk-copyable cells sharing a single style. + /// with copyRun: bulk-copyable cells sharing a single style. The + /// scan uses masked compares of the raw cell bits (see + /// BulkRunMask), which is significantly cheaper than the + /// field-wise predicate for this hot loop. fn bulkRunLength(cells: []const pagepkg.Cell) usize { if (cells.len == 0) return 0; const first = cells[0]; if (!bulkCopyable(first)) return 0; + const run_pattern = BulkRunMask.pattern(first); + + // Only text cells can contain a placeholder; for bg color + // tags the content bits are a color, so we skip the check for + // those (the tag is part of BulkRunMask, making tags uniform + // per run). + const check_placeholder = build_options.kitty_graphics and + first.content_tag == .codepoint; + const placeholder_pattern = comptime pattern: { + // Never used without kitty graphics (check_placeholder + // is comptime-false), but it must still compile and the + // placeholder codepoint doesn't exist in that build. + if (!build_options.kitty_graphics) break :pattern 0; + + break :pattern PlaceholderMask.pattern(.init( + kitty.graphics.unicode.placeholder, + )); + }; + var len: usize = 1; while (len < cells.len) : (len += 1) { - const cell = cells[len]; - if (!bulkCopyable(cell) or - cell.style_id != first.style_id) break; + if (!BulkRunMask.eqlScalar(cells[len], run_pattern)) break; + if (check_placeholder and PlaceholderMask.eqlScalar( + cells[len], + placeholder_pattern, + )) break; } return len; From ec5b369611ae59675d7c78168dd25600b85d105f Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Fri, 31 Jul 2026 20:58:35 -0700 Subject: [PATCH 9/9] terminal: vectorize reflow run scan The masked-compare scan that finds bulk-copyable cell runs still processed one cell per iteration and remained the largest single cost in a column reflow. Scan whole groups of cells at a time using the group variants of the Mask helper: a group that fully matches the run pattern (and, for text runs, contains no Kitty virtual placeholder, via eqlAny) extends the run by the whole group, and any mismatch falls through to the scalar loop which finds the exact end of the run within it. The group length comes from the shared simd.lanes helper where the target has SIMD support and falls back to a plain unrolled group elsewhere. 1.19x faster on ghostty-bench +terminal-resize --mode=cols (120x80 terminal, 10k-line scrollback, shrink/grow column reflow cycles). Combined with the preceding reflow optimizations, resize with reflow is 5.8x faster than before the series. --- src/terminal/PageList.zig | 23 ++++++++++++++++++++--- 1 file changed, 20 insertions(+), 3 deletions(-) diff --git a/src/terminal/PageList.zig b/src/terminal/PageList.zig index 453c589f8..dc1686119 100644 --- a/src/terminal/PageList.zig +++ b/src/terminal/PageList.zig @@ -9,6 +9,7 @@ const build_options = @import("terminal_options"); const Allocator = std.mem.Allocator; const assert = @import("../quirks.zig").inlineAssert; const fastmem = @import("../fastmem.zig"); +const simd = @import("../simd/main.zig"); const tripwire = @import("../tripwire.zig"); const DoublyLinkedList = @import("../datastruct/main.zig").IntrusiveDoublyLinkedList; const color = @import("color.zig"); @@ -1859,10 +1860,11 @@ const ReflowCursor = struct { }; } - /// The group length for the masked-compare helpers below. This - /// matches the group length used by other cell scans (e.g. the + /// The group length for the vectorized bulk run scan below: the + /// SIMD lane count where the target supports it, otherwise a + /// plain unrolled group like other cell scans use (e.g. the /// render state scans). - const bulk_group_len = 8; + const bulk_group_len = simd.lanes(u64) orelse 8; /// Masked compare helper covering every cell field that a run /// must share to be copied by copyRun: given that the first cell @@ -1918,6 +1920,21 @@ const ReflowCursor = struct { }; var len: usize = 1; + + // Vectorized scan: check whole groups of cells at once. If a + // group fully matches, the run extends by the whole group; + // otherwise fall through to the scalar loop below, which + // finds the exact end of the run within it. + while (cells.len - len >= bulk_group_len) { + if (!BulkRunMask.eql(cells, len, run_pattern)) break; + if (check_placeholder and PlaceholderMask.eqlAny( + cells, + len, + placeholder_pattern, + )) break; + len += bulk_group_len; + } + while (len < cells.len) : (len += 1) { if (!BulkRunMask.eqlScalar(cells[len], run_pattern)) break; if (check_placeholder and PlaceholderMask.eqlScalar(