From 0f50fdf6ced846679b0ff4d5beb855d5af74ab86 Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Sat, 11 Jul 2026 21:25:21 -0700 Subject: [PATCH] terminal: bound grapheme map probe lengths The grapheme map previously allowed its open-addressed table to fill completely. Inserts and misses therefore degraded into full-table scans as grapheme storage approached capacity. Limit usable slots to eighty percent of the raw table. Reaching the limit grows the page before probes become pathological without doubling grapheme map metadata on pages that contain no graphemes. The benchmark caps its resident set at the same production limit. Exact row compaction now derives its minimum byte request from the same allocator-to-map layout helper used by Page.layout. This prevents layout policy changes from under-sizing a compacted map. A sweep over 1 through 4,096 entries pins the relationship. ReleaseFast medians use eleven sequential runs after two warmups and report user CPU time. Rows above the 80% boundary compare the same resident entry count before and after the new policy grows the page. | pre-growth occupancy | before | after growth | change | |---:|---:|---:|---:| | 80% | 79.622 ms | 78.454 ms | -1.5% | | 90% | 155.778 ms | 26.572 ms | -82.9% | | 94% | 216.450 ms | 15.096 ms | -93.0% | | 100% | 688.143 ms | 5.754 ms | -99.2% | Real VT medians use seven sequential runs after two warmups. | workload | before | after | change | |---|---:|---:|---:| | ASCII, 219 MiB | 225.927 ms | 227.741 ms | +0.8% | | grapheme, 1 extra codepoint | 361.388 ms | 368.598 ms | +2.0% | | grapheme, 5 extra codepoints | 449.764 ms | 456.409 ms | +1.5% | | grapheme, 32 extra codepoints | 2.024 s | 2.024 s | +0.0% | A standard production page retains 512 raw map slots, a 10,768-byte map buffer, and a 409,600-byte total layout. Its usable map limit moves from 512 to 409 entries, so capacity grows before the final allocator chunks are consumed. --- src/benchmark/GraphemeMap.zig | 34 ++--- src/terminal/page.zig | 244 ++++++++++++++++++++++++++++++++-- 2 files changed, 252 insertions(+), 26 deletions(-) diff --git a/src/benchmark/GraphemeMap.zig b/src/benchmark/GraphemeMap.zig index 70b10564f..717d6ec82 100644 --- a/src/benchmark/GraphemeMap.zig +++ b/src/benchmark/GraphemeMap.zig @@ -33,7 +33,8 @@ pub const Options = struct { cps: u8 = 1, /// Percentage of the entries populated before the timed operation. - /// Values above 100 are treated as 100. + /// Values above 100 are treated as 100. The resident count is capped + /// by the page's production grapheme capacity. @"load-percent": u8 = 100, /// Number of complete passes over the populated cells per step. @@ -68,8 +69,9 @@ pub fn create(alloc: Allocator, opts: Options) !*GraphemeMap { errdefer alloc.free(cps); for (cps, 0..) |*cp, i| cp.* = @intCast(0x1F600 + i); - // Size the allocator to exactly fit the working set so that at 100% - // load we exercise a full allocator, the worst case for chunk searches. + // Size the allocator to exactly fit the requested working set. The + // production map load limit may cap the resident set before every + // allocator chunk can be occupied. const chunks_per_entry = std.math.divCeil( usize, @as(usize, opts.cps) * @sizeOf(u21), @@ -85,10 +87,10 @@ pub fn create(alloc: Allocator, opts: Options) !*GraphemeMap { errdefer page.deinit(); const load = @min(opts.@"load-percent", 100); - const entry_count = @max( - 1, + const entry_count = @max(1, @min( + page.graphemeCapacity(), @divFloor(@as(usize, opts.entries) * load, 100), - ); + )); for (0..entry_count) |x| { const rac = page.getRowAndCell(x, 0); rac.cell.* = .{ @@ -151,15 +153,17 @@ fn stepChurn(ptr: *anyopaque) Benchmark.Error!void { test GraphemeMap { const alloc = std.testing.allocator; - inline for (.{ Mode.lookup, Mode.churn }) |mode| { - const impl = try GraphemeMap.create(alloc, .{ - .entries = 64, - .cps = 5, - .mode = mode, - }); - defer impl.destroy(alloc); + inline for (.{ @as(u8, 1), 5 }) |cps| { + inline for (.{ Mode.lookup, Mode.churn }) |mode| { + const impl = try GraphemeMap.create(alloc, .{ + .entries = 64, + .cps = cps, + .mode = mode, + }); + defer impl.destroy(alloc); - const bench = impl.benchmark(); - _ = try bench.run(.once); + const bench = impl.benchmark(); + _ = try bench.run(.once); + } } } diff --git a/src/terminal/page.zig b/src/terminal/page.zig index 69d690e0e..0819925ac 100644 --- a/src/terminal/page.zig +++ b/src/terminal/page.zig @@ -92,11 +92,14 @@ pub const grapheme_chunk = grapheme_chunk_len * @sizeOf(u21); const GraphemeAlloc = BitmapAllocator(grapheme_chunk); const grapheme_count_default = GraphemeAlloc.bitmap_bit_size; pub const grapheme_bytes_default = grapheme_count_default * grapheme_chunk; +/// Keep spare slots in the grapheme map so missed lookups and insertions +/// cannot degrade into full-table scans as grapheme storage fills. +const grapheme_map_max_load_percentage = 80; const GraphemeMap = OffsetHashMap( Offset(Cell), Offset(u21).Slice, hash_map.OffsetContext(Cell), - hash_map.default_max_load_percentage, + grapheme_map_max_load_percentage, ); /// The allocator used for shared utf8-encoded strings within a page. @@ -695,6 +698,71 @@ pub const Page = struct { HyperlinkError || GraphemeError; + /// Derive the grapheme map layout from an allocator layout. Both + /// Page.layout and exact capacity sizing use this helper so changes to + /// their shared rounding policy cannot drift apart. + fn graphemeMapLayoutForAllocator( + alloc_layout: GraphemeAlloc.Layout, + ) GraphemeMap.Layout { + // Every allocator bit can describe one grapheme entry. Page.layout + // rounds that raw entry count to the map's power-of-two capacity. + const grapheme_count = alloc_layout.bitmap_count * + GraphemeAlloc.bitmap_bit_size; + const map_capacity = if (grapheme_count == 0) + 0 + else + std.math.ceilPowerOfTwo(usize, grapheme_count) catch unreachable; + return GraphemeMap.layoutForCapacity(@intCast(map_capacity)); + } + + /// Return the smallest byte request that accommodates both the occupied + /// grapheme chunks and the map entries under Page.layout's policy. + fn minimumGraphemeBytes( + self: *const Page, + storage_bytes: usize, + entry_count: usize, + ) usize { + assert(storage_bytes % grapheme_chunk == 0); + if (entry_count == 0) { + assert(storage_bytes == 0); + return 0; + } + + // Ask the map for the raw capacity needed to keep entry_count within + // its load limit rather than duplicating that load-factor arithmetic. + const required_map_capacity = + GraphemeMap.layout(@intCast(entry_count)).capacity; + + // The occupied chunks are the smallest possible allocation. The + // source page's current capacity is a known-sufficient upper bound. + var lower = @divExact(storage_bytes, grapheme_chunk); + var upper = @divExact( + @as(usize, self.capacity.grapheme_bytes), + grapheme_chunk, + ); + assert(lower <= upper); + assert(graphemeMapLayoutForAllocator( + GraphemeAlloc.layout(upper * grapheme_chunk), + ).capacity >= required_map_capacity); + + // Capacity is monotonic in requested chunks, so binary-search the + // real layout policy instead of inverting its current power-of-two + // and bitmap-word rounding algebra here. + while (lower < upper) { + const mid = lower + (upper - lower) / 2; + const map_layout = graphemeMapLayoutForAllocator( + GraphemeAlloc.layout(mid * grapheme_chunk), + ); + if (map_layout.capacity >= required_map_capacity) { + upper = mid; + } else { + lower = mid + 1; + } + } + + return lower * grapheme_chunk; + } + /// Compute the exact capacity required to store a range of rows from /// this page. /// @@ -723,6 +791,7 @@ pub const Page = struct { // Accumulators var id_set: CellCountSet = .initEmpty(); + var grapheme_entries: usize = 0; var grapheme_bytes: usize = 0; var string_bytes: usize = 0; @@ -737,6 +806,7 @@ pub const Page = struct { if (cell.hasGrapheme()) { if (self.lookupGrapheme(cell)) |cps| { + grapheme_entries += 1; grapheme_bytes += GraphemeAlloc.bytesRequired(u21, cps.len); } } @@ -744,6 +814,11 @@ pub const Page = struct { } const styles_cap = StyleSet.capacityForCount(id_set.count()); + const grapheme_cap = self.minimumGraphemeBytes( + grapheme_bytes, + grapheme_entries, + ); + // Second pass: count hyperlinks and string bytes // We count both unique hyperlinks (for hyperlink_set) and total // hyperlink cells (for hyperlink_map capacity). @@ -797,7 +872,7 @@ pub const Page = struct { .cols = self.size.cols, .rows = @intCast(y_end - y_start), .styles = @intCast(styles_cap), - .grapheme_bytes = @intCast(grapheme_bytes), + .grapheme_bytes = @intCast(grapheme_cap), .hyperlink_bytes = @intCast(hyperlink_cap * @sizeOf(hyperlink.Set.Item)), .string_bytes = @intCast(string_bytes), }; @@ -1695,7 +1770,10 @@ pub const Page = struct { /// Returns the grapheme capacity for the page. This isn't the byte /// size but the number of unique cells that can have grapheme data. pub inline fn graphemeCapacity(self: *const Page) usize { - return self.grapheme_map.map(self.memory).capacity(); + const map_capacity = self.grapheme_map.map(self.memory).maxLoad(); + const alloc_capacity = + self.grapheme_alloc.capacityBytes() / grapheme_chunk; + return @min(map_capacity, alloc_capacity); } /// Checks if the row contains any styles and sets @@ -1766,14 +1844,8 @@ pub const Page = struct { // Size the map for every chunk the allocator can address. Using the // allocator layout makes Page.layout idempotent after capacity // rounding and avoids applying a separate rounding policy here. - const grapheme_count = grapheme_alloc_layout.bitmap_count * - GraphemeAlloc.bitmap_bit_size; - const grapheme_map_capacity = if (grapheme_count == 0) - 0 - else - std.math.ceilPowerOfTwo(usize, grapheme_count) catch unreachable; - const grapheme_map_layout = GraphemeMap.layoutForCapacity( - @intCast(grapheme_map_capacity), + const grapheme_map_layout = graphemeMapLayoutForAllocator( + grapheme_alloc_layout, ); const grapheme_map_start = alignForward(usize, grapheme_alloc_end, GraphemeMap.base_align.toByteUnits()); const grapheme_map_end = grapheme_map_start + grapheme_map_layout.total_size; @@ -2746,6 +2818,122 @@ test "Page grapheme map covers rounded allocator" { } } +test "Page grapheme map reserves probe headroom" { + const Case = struct { + requested_chunks: usize, + map_slots: u32, + }; + for ([_]Case{ + .{ .requested_chunks = 0, .map_slots = 0 }, + .{ .requested_chunks = 1, .map_slots = 64 }, + .{ .requested_chunks = 65, .map_slots = 128 }, + .{ .requested_chunks = 129, .map_slots = 256 }, + .{ .requested_chunks = 192, .map_slots = 256 }, + .{ .requested_chunks = 512, .map_slots = 512 }, + }) |case| { + const cap: Capacity = .{ + .cols = 1, + .rows = 1, + .grapheme_bytes = @intCast( + case.requested_chunks * grapheme_chunk, + ), + }; + const layout = Page.layout(cap); + try testing.expectEqual( + case.map_slots, + layout.grapheme_map_layout.capacity, + ); + + var page = try Page.init(cap); + defer page.deinit(); + const alloc_chunks = + page.grapheme_alloc.capacityBytes() / grapheme_chunk; + const map_capacity = + page.grapheme_map.map(page.memory).maxLoad(); + try testing.expectEqual( + @min(map_capacity, alloc_chunks), + page.graphemeCapacity(), + ); + } +} + +test "Page minimum grapheme bytes follow map layout policy" { + const max_entries = 4096; + const source_chunks = GraphemeMap.layout(max_entries).capacity; + var page = try Page.init(.{ + .cols = 1, + .rows = 1, + .grapheme_bytes = source_chunks * grapheme_chunk, + }); + defer page.deinit(); + + for (1..max_entries + 1) |entry_count| { + const storage_bytes = entry_count * grapheme_chunk; + const required_bytes = page.minimumGraphemeBytes( + storage_bytes, + entry_count, + ); + const required_map = GraphemeMap.layout(@intCast(entry_count)); + const layout = Page.layout(.{ + .cols = 1, + .rows = 1, + .grapheme_bytes = @intCast(required_bytes), + }); + + // Matching the raw capacity selected by layout(entry_count) means + // the resulting map's configured maximum load covers every entry. + try testing.expect( + layout.grapheme_map_layout.capacity >= required_map.capacity, + ); + try testing.expect(required_bytes >= storage_bytes); + + // If map headroom increased the request, one fewer chunk must be + // insufficient. This also verifies that the helper stays exact. + if (required_bytes > storage_bytes) { + const previous = Page.layout(.{ + .cols = 1, + .rows = 1, + .grapheme_bytes = @intCast( + required_bytes - grapheme_chunk, + ), + }); + try testing.expect( + previous.grapheme_map_layout.capacity < required_map.capacity, + ); + } + } +} + +test "Page grapheme capacity is bounded by storage" { + const entries = GraphemeAlloc.bitmap_bit_size; + var page = try Page.init(.{ + .cols = entries, + .rows = 1, + .grapheme_bytes = entries * grapheme_chunk, + }); + defer page.deinit(); + + // The map keeps headroom by limiting usable slots rather than doubling + // its raw storage. A full map therefore grows the page before all + // allocator chunks are consumed. + const capacity = page.graphemeCapacity(); + try testing.expect(capacity < entries); + + for (0..capacity) |x| { + const rac = page.getRowAndCell(x, 0); + rac.cell.* = .init('A'); + try page.setGraphemes(rac.row, rac.cell, &.{0x0301}); + } + try testing.expectEqual(capacity, page.graphemeCount()); + + const rac = page.getRowAndCell(capacity, 0); + rac.cell.* = .init('A'); + try testing.expectError( + error.GraphemeMapOutOfMemory, + page.setGraphemes(rac.row, rac.cell, &.{0x0301}), + ); +} + test "Page read and write cells" { var page = try Page.init(.{ .cols = 10, @@ -4124,6 +4312,40 @@ test "Page exactRowCapacity grapheme_bytes larger than chunk" { try testing.expectEqual(cap, cloned_cap); } +test "Page exactRowCapacity includes grapheme map headroom" { + const entry_count = 103; + var page = try Page.init(.{ + .cols = entry_count, + .rows = 1, + // 129 requested chunks round to 192 allocator chunks and a + // 256-slot map. This source can hold all 103 entries at 80% load. + .grapheme_bytes = 129 * grapheme_chunk, + }); + defer page.deinit(); + + for (0..entry_count) |x| { + const rac = page.getRowAndCell(x, 0); + rac.cell.* = .init('a'); + try page.appendGrapheme(rac.row, rac.cell, 0x0301); + } + + // Merely summing the 103 occupied chunks would round to a 128-slot + // map, whose maximum load is 102. The exact capacity must force the + // next map size so cloning the final entry cannot fail. + const cap = page.exactRowCapacity(0, 1); + try testing.expectEqual(129 * grapheme_chunk, cap.grapheme_bytes); + + var cloned = try Page.init(cap); + defer cloned.deinit(); + try cloned.cloneFrom(&page, 0, 1); + try testing.expectEqual(entry_count, cloned.graphemeCount()); + + for (0..entry_count) |x| { + const cell = cloned.getRowAndCell(x, 0).cell; + try testing.expect(cloned.lookupGrapheme(cell) != null); + } +} + test "Page exactRowCapacity hyperlinks" { var page = try Page.init(.{ .cols = 10,