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"); diff --git a/src/terminal/PageList.zig b/src/terminal/PageList.zig index b8149544c..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"); @@ -402,6 +403,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 +1426,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 +1449,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; } } @@ -1522,6 +1550,37 @@ 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, + + /// 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, + 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 +1596,9 @@ const ReflowCursor = struct { // Initially whatever size our input node is. .total_rows = node.rows(), + + .style_cache = .invalid, + .cap_memo = null, }; } @@ -1566,12 +1628,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 +1664,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); } } @@ -1610,18 +1681,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) { @@ -1640,11 +1709,30 @@ 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(); 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 +1755,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; }, @@ -1714,6 +1801,246 @@ 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 + /// (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 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 = 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 + /// 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. 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; + + // 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( + cells[len], + placeholder_pattern, + )) 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 @@ -2030,6 +2357,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, @@ -2072,6 +2416,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; } @@ -4005,6 +4357,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, 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.