diff --git a/src/terminal/PageList.zig b/src/terminal/PageList.zig index 429a3633b..f68733f8c 100644 --- a/src/terminal/PageList.zig +++ b/src/terminal/PageList.zig @@ -4168,6 +4168,64 @@ pub fn increaseCapacity( if (layout.total_size > size.max_page_size) { return error.OutOfSpace; } + + // Doubling alone has a really bad behavior in that if you're + // in a pathological scenario with only one dimension of cap + // increase, you have to pay repeat double costs. Each time you + // double we have to reclone the entire page. As the page gets + // bigger this gets more expensive. + // + // Instead, for some dimensions, we do something else: we look + // at the current utilization, and project that to every row + // in the page capacity (if it fits). We just assume that your + // future workload will look the one you're currently filling. + // If we're wrong, its some wasted capacity but future growth + // still works in other dimensions. + // + // This only applies to the current page being grown. I previously + // tried a high-water-mark style solution to preallocate pages, + // which does work really well, but its not clear when you reset + // the mark. + project: { + // The dimensions we project must measure their current + // usage in the same units as the capacity field. + const used: u64 = switch (comptime tag) { + .grapheme_bytes => page.grapheme_alloc.usedBytes(page.memory), + + // Living item count. Note the capacity field is a + // requested item count that Layout.init rounds in + // both directions (table to the next power of two, + // items to the load factor of that), so this is an + // approximation in capacity units; the headroom + // below absorbs the error and an undershoot only + // costs one more (non-ladder) growth event. + .styles => page.styles.count(), + + else => break :project, + }; + if (used == 0 or page.size.rows == 0) break :project; + + // Full-page need at current density, plus 25% headroom + // for chunk rounding and fragmentation. + const density = used * @as(u64, cap.rows) / page.size.rows; + const projected_raw = density + density / 4; + + // Bound the jump to 32× the pre-growth capacity. Arbitrary + // choice until we can show otherwise. + const projected = @min( + projected_raw, + @as(u64, old) * 32, + std.math.maxInt(Int), + ); + if (projected <= @field(cap, field_name)) break :project; + + // Only take the projection if the resulting page still fits. + var proj_cap = cap; + @field(proj_cap, field_name) = @intCast(projected); + if (Page.layout(proj_cap).total_size > size.max_page_size) + break :project; + cap = proj_cap; + } }, }; @@ -11734,6 +11792,68 @@ test "PageList increaseCapacity to increase styles" { } } +test "PageList increaseCapacity styles projects capacity from page density" { + const testing = std.testing; + const alloc = testing.allocator; + + var s = try init(alloc, .{ .cols = 2, .rows = 2, .max_size = 0 }); + defer s.deinit(); + + const original_cap = s.pages.first.?.capacity().styles; + + // Write styled cells so the page has a measurable per-row style + // density (unlike the plain doubling test above, which grows a + // page with no styles in use). + const bold: stylepkg.Style = .{ .flags = .{ .bold = true } }; + { + try testing.expect(s.pages.first == s.pages.last); + const page = s.pages.first.?.page(); + + for (0..s.rows) |y| { + for (0..s.cols) |x| { + const rac = page.getRowAndCell(x, y); + const style_id = try page.styles.add(page.memory, bold); + rac.row.styled = true; + rac.cell.* = .{ + .content_tag = .codepoint, + .content = .{ .codepoint = .{ .data = @intCast(x + 1) } }, + .style_id = style_id, + }; + } + } + } + + _ = try s.increaseCapacity(s.pages.first.?, .styles); + + { + try testing.expect(s.pages.first == s.pages.last); + const page = s.pages.first.?.page(); + + // The page uses only two active rows out of thousands of rows + // of capacity, so the projected full-page need saturates the + // 32x-per-event growth bound instead of merely doubling. + try testing.expectEqual( + original_cap * 32, + page.capacity.styles, + ); + + // All cell content and styles are preserved by the growth. + for (0..s.rows) |y| { + for (0..s.cols) |x| { + const rac = page.getRowAndCell(x, y); + try testing.expectEqual( + @as(u21, @intCast(x + 1)), + rac.cell.content.codepoint.data, + ); + try testing.expect(rac.cell.style_id != stylepkg.default_id); + try testing.expect(bold.eql( + page.styles.get(page.memory, rac.cell.style_id).*, + )); + } + } + } +} + test "PageList increaseCapacity to increase graphemes" { const testing = std.testing; const alloc = testing.allocator; @@ -11778,6 +11898,70 @@ test "PageList increaseCapacity to increase graphemes" { } } +test "PageList increaseCapacity graphemes projects capacity from page density" { + const testing = std.testing; + const alloc = testing.allocator; + + var s = try init(alloc, .{ .cols = 2, .rows = 2, .max_size = 0 }); + defer s.deinit(); + + const original_cap = s.pages.first.?.capacity().grapheme_bytes; + + // Write cells with grapheme data so the page has a measurable + // per-row grapheme density (unlike the plain doubling test above, + // which grows a page with no grapheme usage). + { + try testing.expect(s.pages.first == s.pages.last); + const page = s.pages.first.?.page(); + + for (0..s.rows) |y| { + for (0..s.cols) |x| { + const rac = page.getRowAndCell(x, y); + rac.cell.* = .{ + .content_tag = .codepoint, + .content = .{ .codepoint = .{ .data = @intCast(x + 1) } }, + }; + try page.appendGrapheme(rac.row, rac.cell, 0x0301); + try page.appendGrapheme(rac.row, rac.cell, 0x0302); + } + } + } + + _ = try s.increaseCapacity(s.pages.first.?, .grapheme_bytes); + + { + try testing.expect(s.pages.first == s.pages.last); + const page = s.pages.first.?.page(); + + // The page uses only two active rows out of thousands of rows + // of capacity, so the projected full-page need saturates the + // 32x-per-event growth bound instead of merely doubling. + try testing.expectEqual( + original_cap * 32, + page.capacity.grapheme_bytes, + ); + + // All cell and grapheme content is preserved by the growth. + try testing.expectEqual( + @as(usize, s.rows * s.cols), + page.graphemeCount(), + ); + for (0..s.rows) |y| { + for (0..s.cols) |x| { + const rac = page.getRowAndCell(x, y); + try testing.expectEqual( + @as(u21, @intCast(x + 1)), + rac.cell.content.codepoint.data, + ); + const cps = page.lookupGrapheme(rac.cell).?; + try testing.expectEqual(@as(usize, 2), cps.len); + try testing.expectEqual(@as(u21, 0x0301), cps[0]); + try testing.expectEqual(@as(u21, 0x0302), cps[1]); + } + } + } +} + test "PageList increaseCapacity to increase hyperlinks" { const testing = std.testing; const alloc = testing.allocator; diff --git a/src/terminal/Terminal.zig b/src/terminal/Terminal.zig index b157994f5..ce22ae084 100644 --- a/src/terminal/Terminal.zig +++ b/src/terminal/Terminal.zig @@ -522,11 +522,55 @@ pub fn printRepeat(self: *Terminal, count_req: usize) !void { /// 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 { + // Check if we can do the fast path up front. If we can't + // we need to go back to scalar `print`. + const fast = fast: { + // Only the main display is supported. + if (self.status_display != .main) break :fast false; + + // 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)) break :fast false; + if (!self.modes.get(.wraparound)) break :fast false; + + // Charset must map ASCII as-is (true unless a DEC special + // charset is actively invoked, which is rare). + const screen: *Screen = self.screens.active; + if (screen.charset.single_shift != null) break :fast false; + switch (screen.charset.charsets.get(screen.charset.gl)) { + .utf8, .ascii => {}, + else => break :fast false, + } + + // Hyperlinks require per-cell map bookkeeping. + if (screen.cursor.hyperlink_id != 0) break :fast false; + + break :fast true; + }; + if (!fast) { + for (cps) |cp| try self.print(@intCast(cp)); + return; + } + + 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; + 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..]); + const consumed = try self.printSliceFast( + cps[i..], + grapheme_cluster, + allow_unicode, + ); if (consumed > 0) { i += consumed; continue; @@ -548,32 +592,16 @@ pub fn printSlice(self: *Terminal, cps: []const u32) !void { /// /// 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; - +/// width codepoints, grapheme cluster continuations, complex cells, +/// etc.) is rejected so `print` can handle it with full generality. +fn printSliceFast( + self: *Terminal, + cps: []const u32, + grapheme_cluster: bool, + allow_unicode: bool, +) !usize { 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). @@ -584,13 +612,6 @@ fn printSliceFast(self: *Terminal, cps: []const u32) !usize { // 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). @@ -619,14 +640,46 @@ fn printSliceFast(self: *Terminal, cps: []const u32) !usize { } // 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; + // enabled: print() may attach it to the previous *cell* instead + // of writing a new one. Take the first codepoint only when we can + // determine — computing exactly what print() would — that it + // starts a new cluster. At column zero with no pending wrap, + // print() skips clustering entirely. Otherwise resolve the + // previous cell the way print() does and check for a break. + // + // Note the pending-wrap rejection: print() may attach to the + // pending cell *instead of wrapping*, which we can't model here. + if (grapheme_cluster and screen.cursor.x != 0) gate: { + if (screen.cursor.pending_wrap) return 0; + + // Resolve the content cell to our left exactly like print(): + // if the immediate left cell is a wide spacer tail, the + // content lives one further left. (A spacer tail can never + // be at column zero — its wide half would have to be in the + // previous row — so the second cursorCellLeft is in bounds.) + const immediate = screen.cursorCellLeft(1); + const prev: *Cell = switch (immediate.wide) { + .spacer_tail => screen.cursorCellLeft(2), + else => immediate, + }; + + // An empty previous cell is necessarily a grapheme break. + if (prev.codepoint() == 0) break :gate; + + // Grapheme data on the previous cell requires the full + // cluster state machine replay; only print() can do that. + if (prev.hasGrapheme()) return 0; + + // A simple single-codepoint previous cell: print() would run + // exactly this break check from the default state. + var state: uucode.grapheme.BreakState = .default; + if (!unicode.graphemeBreak( + prev.content.codepoint.data, + @intCast(cp0), + &state, + )) return 0; + } else if (grapheme_cluster) { + if (screen.cursor.pending_wrap) return 0; } // The width lookup is a runtime value while printSliceFill is diff --git a/src/terminal/bitmap_allocator.zig b/src/terminal/bitmap_allocator.zig index 787f0ace0..a300cf2e8 100644 --- a/src/terminal/bitmap_allocator.zig +++ b/src/terminal/bitmap_allocator.zig @@ -44,6 +44,11 @@ pub fn BitmapAllocator(comptime chunk_size: comptime_int) type { bitmap: Offset(u64), bitmap_count: usize, + /// Lowest bitmap word index that may contain a free bit; words + /// below it are fully allocated, so alloc scans can start here + /// instead of at zero. + search_start: usize = 0, + /// The contiguous buffer of chunks. chunks: Offset(u8), @@ -95,9 +100,21 @@ pub fn BitmapAllocator(comptime chunk_size: comptime_int) type { return error.OutOfMemory; // Find the index of the free chunk. This also marks it as used. + // Words below search_start have no free bits, so no free span + // can start in or cross them and it is safe to skip them. const bitmaps = self.bitmap.ptr(base); - const idx = findFreeChunks(bitmaps[0..self.bitmap_count], chunk_count) orelse + const start = @min(self.search_start, self.bitmap_count); + const rel = findFreeChunks(bitmaps[start..self.bitmap_count], chunk_count) orelse return error.OutOfMemory; + const idx = start * bitmap_bit_size + rel; + + // Advance past any words the allocation just filled so the + // next scan starts at the first word with a free bit. + self.search_start = start: { + var new_start = start; + while (new_start < self.bitmap_count and bitmaps[new_start] == 0) new_start += 1; + break :start new_start; + }; const chunks = self.chunks.ptr(base); const ptr: [*]T = @ptrCast(@alignCast(&chunks[idx * chunk_size])); @@ -116,6 +133,12 @@ pub fn BitmapAllocator(comptime chunk_size: comptime_int) type { const chunks = self.chunks.ptr(base); const chunk_idx = @divExact(@intFromPtr(slice.ptr) - @intFromPtr(chunks), chunk_size); + // The freed word gains free bits, so scans must not skip it. + self.search_start = @min( + self.search_start, + @divFloor(chunk_idx, bitmap_bit_size), + ); + const bitmaps = self.bitmap.ptr(base); // Current bitmap index. @@ -659,6 +682,90 @@ test "BitmapAllocator alloc and free one bitmap" { ); } +test "BitmapAllocator search hint skips full words" { + const Alloc = BitmapAllocator(1); + // Capacity such that we'll have 3 bitmaps. + const cap = Alloc.bitmap_bit_size * 3; + + const testing = std.testing; + const alloc = testing.allocator; + const layout = Alloc.layout(cap); + const buf = try alloc.alignedAlloc(u8, Alloc.base_align, layout.total_size); + defer alloc.free(buf); + + var bm = Alloc.init(.init(buf), layout); + try testing.expectEqual(@as(usize, 0), bm.search_start); + + // Fill the first bitmap word exactly with single-chunk allocations. + var first: []u8 = undefined; + for (0..Alloc.bitmap_bit_size) |i| { + const slice = try bm.alloc(u8, buf, 1); + if (i == 0) first = slice; + } + + // The first word is exhausted so scans start at the second word. + try testing.expectEqual(@as(usize, 1), bm.search_start); + + // The next allocation is the first chunk of the second word, so + // the skipped-word index arithmetic must still yield the right + // chunk address. + const next = try bm.alloc(u8, buf, 1); + try testing.expectEqual( + @intFromPtr(first.ptr) + Alloc.bitmap_bit_size, + @intFromPtr(next.ptr), + ); + + // Freeing a chunk in the first word lowers the hint so the freed + // space is found again. + bm.free(buf, first); + try testing.expectEqual(@as(usize, 0), bm.search_start); + const again = try bm.alloc(u8, buf, 1); + try testing.expectEqual(@intFromPtr(first.ptr), @intFromPtr(again.ptr)); +} + +test "BitmapAllocator search hint does not skip partial words" { + const Alloc = BitmapAllocator(1); + // Capacity such that we'll have 2 bitmaps. + const cap = Alloc.bitmap_bit_size * 2; + + const testing = std.testing; + const alloc = testing.allocator; + const layout = Alloc.layout(cap); + const buf = try alloc.alignedAlloc(u8, Alloc.base_align, layout.total_size); + defer alloc.free(buf); + + var bm = Alloc.init(.init(buf), layout); + + // Allocate all but 4 chunks of the first word. + var first: []u8 = undefined; + for (0..Alloc.bitmap_bit_size - 4) |i| { + const slice = try bm.alloc(u8, buf, 1); + if (i == 0) first = slice; + } + + // An 8-chunk run can't fit the 4 remaining bits (small runs never + // span words), so it comes from the second word — but the hint + // must stay at the first word, which still has free bits. + const big = try bm.alloc(u8, buf, 8); + try testing.expectEqual( + @intFromPtr(first.ptr) + Alloc.bitmap_bit_size, + @intFromPtr(big.ptr), + ); + try testing.expectEqual(@as(usize, 0), bm.search_start); + + // A 4-chunk run fits the first word's remaining bits exactly; a + // hint that skipped the partial word would wrongly place this in + // the second word (or report OutOfMemory once that filled). + const small = try bm.alloc(u8, buf, 4); + try testing.expectEqual( + @intFromPtr(first.ptr) + Alloc.bitmap_bit_size - 4, + @intFromPtr(small.ptr), + ); + + // Now the first word is full and the hint advances past it. + try testing.expectEqual(@as(usize, 1), bm.search_start); +} + test "BitmapAllocator alloc and free half bitmap" { const Alloc = BitmapAllocator(1); // Capacity such that we'll have 3 bitmaps. diff --git a/src/terminal/hash_map.zig b/src/terminal/hash_map.zig index abef87e3b..6f3b8b3aa 100644 --- a/src/terminal/hash_map.zig +++ b/src/terminal/hash_map.zig @@ -40,6 +40,7 @@ //! by any removal. const std = @import("std"); +const builtin = @import("builtin"); const assert = @import("../quirks.zig").inlineAssert; const mem = std.mem; const Allocator = mem.Allocator; @@ -74,6 +75,29 @@ fn AutoContext(comptime K: type) type { pub fn hash(_: @This(), key: K) u64 { if (comptime std.meta.hasUniqueRepresentation(K)) { + // On wasm32, Wyhash's 64x64 -> 128 multiplies lower to + // __multi3 libcalls (wasm has no widening multiply + // instruction), which makes the hash itself the + // dominant cost of small-key map operations. + // + // Keys here fit in a u64, so use a splitmix64-style finalizer + // instead: two native i64.mul with full avalanche in + // both the low bits (slot index) and high bits + // (metadata fingerprint). + if (comptime builtin.cpu.arch.isWasm() and @sizeOf(K) <= 8) { + var x: u64 = 0; + @memcpy( + std.mem.asBytes(&x)[0..@sizeOf(K)], + std.mem.asBytes(&key), + ); + x ^= x >> 33; + x *%= 0xff51afd7ed558ccd; + x ^= x >> 33; + x *%= 0xc4ceb9fe1a85ec53; + x ^= x >> 33; + return x; + } + // LLVM 21 (Zig 0.16) failed to inline this which resulted // in a measurable almost 2x slowdown on our hyperlink map // benchmark. So, force it. diff --git a/src/terminal/style.zig b/src/terminal/style.zig index ce20e4fac..701356e98 100644 --- a/src/terminal/style.zig +++ b/src/terminal/style.zig @@ -90,6 +90,14 @@ pub const Style = struct { /// True if the style is the default style. pub inline fn default(self: Style) bool { + // On wasm, eql converts both sides to packed form; the default + // side is comptime-known, so bake it and convert only self. + // This is called on every SGR change so it's worth it. + if (comptime builtin.cpu.arch.isWasm()) { + const d: u128 = comptime @bitCast(PackedStyle.fromStyle(.{})); + return @as(u128, @bitCast(PackedStyle.fromStyle(self))) == d; + } + return self.eql(.{}); }