diff --git a/src/benchmark/HyperlinkMap.zig b/src/benchmark/HyperlinkMap.zig new file mode 100644 index 000000000..166c3d4fb --- /dev/null +++ b/src/benchmark/HyperlinkMap.zig @@ -0,0 +1,155 @@ +//! Benchmark hyperlink cell-map lookups and remove/insert churn. +//! +//! Hyperlink cells are stored in a fixed-capacity, open-addressed hash map. +//! The `churn` mode models terminal output that repeatedly replaces cells in +//! a page whose hyperlink map is already close to full. This is particularly +//! useful for catching probe-length cliffs at high load factors. +const HyperlinkMap = @This(); + +const std = @import("std"); +const Allocator = std.mem.Allocator; +const terminal = @import("../terminal/main.zig"); +const hyperlink = @import("../terminal/hyperlink.zig"); +const Benchmark = @import("Benchmark.zig"); + +const log = std.log.scoped(.@"hyperlink-map-bench"); + +opts: Options, +page: terminal.Page, +link_id: hyperlink.Id, +entry_count: usize, + +pub const Options = struct { + /// Requested hyperlink working-set size. Must be a power of two and at + /// least 16. The map may reserve additional probe headroom. + entries: u16 = 4096, + + /// Percentage of the map populated before the timed operation. + /// Values above 100 are treated as 100. + @"load-percent": u8 = 100, + + /// Number of complete passes over the populated cells per step. + loops: u16 = 1, + + /// Operation to perform in the timed region. + mode: Mode = .churn, +}; + +pub const Mode = enum { + /// Look up every populated hyperlink cell. + lookup, + + /// Remove and reinsert every populated hyperlink cell. + churn, +}; + +pub fn create(alloc: Allocator, opts: Options) !*HyperlinkMap { + if (opts.entries < 16 or !std.math.isPowerOfTwo(opts.entries)) { + log.err("entries must be a power of two greater than or equal to 16", .{}); + return error.InvalidEntries; + } + + const ptr = try alloc.create(HyperlinkMap); + errdefer alloc.destroy(ptr); + + // The page requests one map slot per `hyperlink_cell_multiplier` set + // entries. Keep this relationship explicit so `entries` is the working + // set size under test regardless of the map's reserved probe headroom. + const set_entries = opts.entries / 16; + var page = try terminal.Page.init(.{ + .cols = opts.entries, + .rows = 1, + .hyperlink_bytes = @intCast( + @as(usize, set_entries) * @sizeOf(hyperlink.Set.Item), + ), + }); + errdefer page.deinit(); + + if (page.hyperlinkCapacity() < opts.entries) { + log.err("insufficient map capacity expected_at_least={} actual={}", .{ + opts.entries, + page.hyperlinkCapacity(), + }); + return error.UnexpectedCapacity; + } + + const link_id = try page.insertHyperlink(.{ + .id = .{ .implicit = 1 }, + .uri = "https://example.com/benchmark", + }); + + const load = @min(opts.@"load-percent", 100); + const entry_count = @max( + 1, + @divFloor(@as(usize, opts.entries) * load, 100), + ); + for (0..entry_count) |x| { + const rac = page.getRowAndCell(x, 0); + page.hyperlink_set.use(page.memory, link_id); + try page.setHyperlink(rac.row, rac.cell, link_id); + } + + ptr.* = .{ + .opts = opts, + .page = page, + .link_id = link_id, + .entry_count = entry_count, + }; + return ptr; +} + +pub fn destroy(self: *HyperlinkMap, alloc: Allocator) void { + self.page.deinit(); + alloc.destroy(self); +} + +pub fn benchmark(self: *HyperlinkMap) Benchmark { + return .init(self, .{ + .stepFn = switch (self.opts.mode) { + .lookup => stepLookup, + .churn => stepChurn, + }, + }); +} + +fn stepLookup(ptr: *anyopaque) Benchmark.Error!void { + const self: *HyperlinkMap = @ptrCast(@alignCast(ptr)); + + for (0..self.opts.loops) |_| { + for (0..self.entry_count) |x| { + const cell = self.page.getRowAndCell(x, 0).cell; + const id = self.page.lookupHyperlink(cell) orelse + return error.BenchmarkFailed; + std.mem.doNotOptimizeAway(id); + } + } +} + +fn stepChurn(ptr: *anyopaque) Benchmark.Error!void { + const self: *HyperlinkMap = @ptrCast(@alignCast(ptr)); + + for (0..self.opts.loops) |_| { + for (0..self.entry_count) |x| { + const rac = self.page.getRowAndCell(x, 0); + self.page.clearHyperlink(rac.cell); + self.page.hyperlink_set.use(self.page.memory, self.link_id); + self.page.setHyperlink(rac.row, rac.cell, self.link_id) catch + return error.BenchmarkFailed; + } + } +} + +test HyperlinkMap { + const alloc = std.testing.allocator; + + inline for (.{ Mode.lookup, Mode.churn }) |mode| { + const impl = try HyperlinkMap.create(alloc, .{ + .entries = 64, + .mode = mode, + }); + 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 d51fd871e..dc4d7b8f4 100644 --- a/src/benchmark/cli.zig +++ b/src/benchmark/cli.zig @@ -9,6 +9,7 @@ pub const Action = enum { @"apc-parser", @"codepoint-width", @"grapheme-break", + @"hyperlink-map", @"page-compression", @"scrollback-compression", @"screen-clone", @@ -28,6 +29,7 @@ pub const Action = enum { pub fn Struct(comptime action: Action) type { return switch (action) { .@"apc-parser" => @import("ApcParser.zig"), + .@"hyperlink-map" => @import("HyperlinkMap.zig"), .@"screen-clone" => @import("ScreenClone.zig"), .@"page-compression" => @import("PageCompression.zig"), .@"scrollback-compression" => @import("ScrollbackCompression.zig"), diff --git a/src/benchmark/main.zig b/src/benchmark/main.zig index 61404c265..f22891c71 100644 --- a/src/benchmark/main.zig +++ b/src/benchmark/main.zig @@ -4,6 +4,7 @@ pub const CApi = @import("CApi.zig"); pub const TerminalStream = @import("TerminalStream.zig"); pub const CodepointWidth = @import("CodepointWidth.zig"); 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 IsSymbol = @import("IsSymbol.zig"); diff --git a/src/terminal/Screen.zig b/src/terminal/Screen.zig index 5ca5d957c..e02c0320b 100644 --- a/src/terminal/Screen.zig +++ b/src/terminal/Screen.zig @@ -2567,6 +2567,9 @@ pub fn cursorSetHyperlink(self: *Screen) PageList.IncreaseCapacityError!void { page = new_node.page(); } + // Canonical map insertion rehashes tombstones in place. Reaching + // this error therefore means live entries fill the usable map + // capacity and the page must grow. _ = try self.increaseCapacity( self.cursor.page_pin.node, .hyperlink_bytes, diff --git a/src/terminal/hash_map.zig b/src/terminal/hash_map.zig index 5a38cc179..407e50290 100644 --- a/src/terminal/hash_map.zig +++ b/src/terminal/hash_map.zig @@ -42,12 +42,24 @@ const Offset = @import("size.zig").Offset; const OffsetBuf = @import("size.zig").OffsetBuf; const getOffset = @import("size.zig").getOffset; -pub fn AutoOffsetHashMap(comptime K: type, comptime V: type) type { - return OffsetHashMap(K, V, AutoContext(K)); +/// The default preserves the original behavior of allowing every raw slot to +/// be occupied. Callers can choose a lower value to bound probe lengths. +pub const default_max_load_percentage: u8 = 100; + +pub fn AutoOffsetHashMap( + comptime K: type, + comptime V: type, + comptime max_load_percentage: u8, +) type { + return OffsetHashMap(K, V, AutoContext(K), max_load_percentage); } -fn AutoHashMapUnmanaged(comptime K: type, comptime V: type) type { - return HashMapUnmanaged(K, V, AutoContext(K)); +fn AutoHashMapUnmanaged( + comptime K: type, + comptime V: type, + comptime max_load_percentage: u8, +) type { + return HashMapUnmanaged(K, V, AutoContext(K), max_load_percentage); } fn AutoContext(comptime K: type) type { @@ -64,12 +76,18 @@ pub fn OffsetHashMap( comptime K: type, comptime V: type, comptime Context: type, + comptime max_load_percentage: u8, ) type { return struct { const Self = @This(); /// This is the pointer-based map that we're wrapping. - pub const Unmanaged = HashMapUnmanaged(K, V, Context); + pub const Unmanaged = HashMapUnmanaged( + K, + V, + Context, + max_load_percentage, + ); pub const Layout = Unmanaged.Layout; /// This is the alignment that the base pointer must have. @@ -81,7 +99,7 @@ pub fn OffsetHashMap( /// HashMap with the given capacity. The base ptr must also be /// aligned to base_align. pub fn layout(cap: Unmanaged.Size) Layout { - return Unmanaged.layoutForCapacity(cap); + return Unmanaged.layoutForSize(cap); } /// Initialize a new HashMap with the given capacity and backing @@ -112,12 +130,15 @@ fn HashMapUnmanaged( comptime K: type, comptime V: type, comptime Context: type, + comptime max_load_percentage: u8, ) type { return struct { const Self = @This(); comptime { assert(@alignOf(Metadata) == 1); + assert(max_load_percentage > 0); + assert(max_load_percentage <= 100); } const header_align = @alignOf(Header); @@ -166,6 +187,11 @@ fn HashMapUnmanaged( keys: Offset(K), capacity: Size, size: Size, + + /// Number of insertions into free slots allowed before the map + /// must be rebuilt. Removing an entry creates a tombstone and + /// intentionally does not restore this count. + available: Size, }; /// Metadata for a slot. It can be in three states: empty, used or @@ -301,6 +327,7 @@ fn HashMapUnmanaged( const hdr = map.header(); hdr.capacity = layout.capacity; hdr.size = 0; + hdr.available = maxLoadForCapacity(layout.capacity); if (@sizeOf([*]K) != 0) hdr.keys = metadata_buf.member(K, layout.keys_start); if (@sizeOf([*]V) != 0) hdr.values = metadata_buf.member(V, layout.vals_start); map.initMetadatas(); @@ -322,6 +349,7 @@ fn HashMapUnmanaged( if (self.metadata) |_| { self.initMetadatas(); self.header().size = 0; + self.header().available = self.maxLoad(); } } @@ -347,6 +375,12 @@ fn HashMapUnmanaged( return self.header().capacity; } + /// Maximum number of occupied or tombstone slots before the map must + /// be rebuilt. This bounds unsuccessful probe lengths. + pub fn maxLoad(self: *const Self) Size { + return maxLoadForCapacity(self.capacity()); + } + pub fn iterator(self: *const Self) Iterator { return .{ .hm = self }; } @@ -430,6 +464,7 @@ fn HashMapUnmanaged( } const fingerprint = Metadata.takeFingerprint(hash); + if (metadata[0].isFree()) self.header().available -= 1; metadata[0].fill(fingerprint); self.keys()[idx] = key; self.values()[idx] = value; @@ -646,6 +681,8 @@ fn HashMapUnmanaged( return null; } + /// The get-or-put family may rehash a fragmented table. Any key or + /// value pointers previously returned by this map may be invalidated. pub fn getOrPut(self: *Self, key: K) Allocator.Error!GetOrPutResult { if (@sizeOf(Context) != 0) @compileError("Cannot infer context " ++ @typeName(Context) ++ ", call getOrPutContext instead."); @@ -665,6 +702,18 @@ fn HashMapUnmanaged( } pub fn getOrPutContextAdapted(self: *Self, key: anytype, key_ctx: anytype) Allocator.Error!GetOrPutResult { self.growIfNeeded(1) catch |err| { + // Canonical lookups can rebuild resident keys in place. If + // live entries still fit, insertion headroom was consumed by + // tombstones rather than live load. + if (comptime @TypeOf(key) == K and + @TypeOf(key_ctx) == Context) + { + if (self.header().size < self.maxLoad()) { + self.rehash(key_ctx); + return self.getOrPutAssumeCapacityAdapted(key, key_ctx); + } + } + // If allocation fails, try to do the lookup anyway. // If we find an existing item, we can return it. // Otherwise return the error, we could not add another. @@ -705,6 +754,7 @@ fn HashMapUnmanaged( var idx = @as(usize, @truncate(hash & mask)); var first_tombstone_idx: usize = self.capacity(); // invalid index + var tombstones: Size = 0; var metadata = self.metadata.? + idx; while (!metadata[0].isFree() and limit != 0) { if (metadata[0].isUsed() and metadata[0].fingerprint == fingerprint) { @@ -724,8 +774,24 @@ fn HashMapUnmanaged( .found_existing = true, }; } - } else if (first_tombstone_idx == self.capacity() and metadata[0].isTombstone()) { - first_tombstone_idx = idx; + } else if (metadata[0].isTombstone()) { + if (first_tombstone_idx == self.capacity()) { + first_tombstone_idx = idx; + } + + // Rehash once this probe demonstrates meaningful + // fragmentation. Only canonical lookups have the + // context required to rehash resident K values. + if (comptime @TypeOf(key) == K and @TypeOf(ctx) == Context) { + tombstones += 1; + // Amortize the O(capacity) rebuild and avoid doing it + // for an otherwise healthy, nearly full table. + const threshold = @max(self.capacity() / 8, 1); + if (tombstones >= threshold) { + self.rehash(ctx); + return self.getOrPutAssumeCapacityAdapted(key, ctx); + } + } } limit -= 1; @@ -739,6 +805,25 @@ fn HashMapUnmanaged( metadata = self.metadata.? + idx; } + if (metadata[0].isFree()) { + // Assume-capacity callers can arrive here after a removal + // consumed all insertion headroom. Canonical lookups can + // rebuild resident keys before consuming another free slot. + if (self.header().available == 0) { + if (comptime @TypeOf(key) == K and + @TypeOf(ctx) == Context) + { + assert(self.header().size < self.maxLoad()); + self.rehash(ctx); + return self.getOrPutAssumeCapacityAdapted(key, ctx); + } + + // Adapted contexts cannot hash resident keys to rehash. + // Their caller must honor the assume-capacity contract. + assert(self.header().available > 0); + } + self.header().available -= 1; + } metadata[0].fill(fingerprint); const new_key = &self.keys()[idx]; const new_value = &self.values()[idx]; @@ -827,9 +912,94 @@ fn HashMapUnmanaged( @memset(@as([*]u8, @ptrCast(self.metadata.?))[0 .. @sizeOf(Metadata) * self.capacity()], 0); } + /// Rebuild the map in place, removing all tombstones. This moves + /// entries and invalidates existing key and value pointers. + pub fn rehash(self: *Self, ctx: Context) void { + const mask = self.capacity() - 1; + + const metadata = self.metadata.?; + const keys_ptr = self.keys(); + const values_ptr = self.values(); + var curr: Size = 0; + + // Mark used buckets as awaiting rehash and clear tombstones. + while (curr < self.capacity()) : (curr += 1) { + metadata[curr].fingerprint = Metadata.free; + } + + curr = 0; + while (curr < self.capacity()) { + if (!metadata[curr].isUsed()) { + assert(metadata[curr].isFree()); + curr += 1; + continue; + } + + const hash = ctx.hash(keys_ptr[curr]); + const fingerprint = Metadata.takeFingerprint(hash); + var idx = @as(usize, @truncate(hash & mask)); + + // For each bucket, rehash to an index: + // 1) before the cursor, probed into a free slot, or + // 2) equal to the cursor, no need to move, or + // 3) ahead of the cursor, probing over already rehashed. + while ((idx < curr and metadata[idx].isUsed()) or + (idx > curr and metadata[idx].fingerprint == Metadata.tombstone)) + { + idx = (idx + 1) & mask; + } + + if (idx < curr) { + assert(metadata[idx].isFree()); + metadata[idx].fill(fingerprint); + keys_ptr[idx] = keys_ptr[curr]; + values_ptr[idx] = values_ptr[curr]; + + metadata[curr].used = 0; + assert(metadata[curr].isFree()); + keys_ptr[curr] = undefined; + values_ptr[curr] = undefined; + + curr += 1; + } else if (idx == curr) { + metadata[idx].fingerprint = fingerprint; + curr += 1; + } else { + assert(metadata[idx].fingerprint != Metadata.tombstone); + metadata[idx].fingerprint = Metadata.tombstone; + if (metadata[idx].isUsed()) { + mem.swap(K, &keys_ptr[curr], &keys_ptr[idx]); + mem.swap(V, &values_ptr[curr], &values_ptr[idx]); + } else { + metadata[idx].used = 1; + keys_ptr[idx] = keys_ptr[curr]; + values_ptr[idx] = values_ptr[curr]; + + metadata[curr].fingerprint = Metadata.free; + metadata[curr].used = 0; + keys_ptr[curr] = undefined; + values_ptr[curr] = undefined; + + curr += 1; + } + } + } + + // Rehashing removes every tombstone, so all unused load-factor + // headroom is available for insertions again. + self.header().available = self.maxLoad() - self.header().size; + } + fn growIfNeeded(self: *Self, new_count: Size) Allocator.Error!void { - const available = self.capacity() - self.header().size; - if (new_count > available) return error.OutOfMemory; + if (new_count > self.header().available) return error.OutOfMemory; + } + + fn maxLoadForCapacity(cap: Size) Size { + if (cap == 0) return 0; + return @intCast(@divFloor( + @as(u64, cap) * max_load_percentage, + 100, + )); } /// The memory layout for the underlying buffer for a given capacity. @@ -888,6 +1058,37 @@ fn HashMapUnmanaged( .capacity = new_capacity, }; } + + /// Returns a layout with enough raw slots to hold `new_size` entries + /// at the configured maximum load factor. + pub fn layoutForSize(new_size: Size) Layout { + if (new_size == 0) return layoutForCapacity(0); + + // Scale the requested number of entries up to the raw slot count + // required by the load factor. Widen first so `new_size * 100` + // cannot overflow Size. + const minimum_capacity = std.math.divCeil( + u64, + @as(u64, new_size) * 100, + max_load_percentage, + ) catch unreachable; + + // Capacities must be powers of two, so the largest capacity that + // fits in Size is the highest bit rather than maxInt(Size). + const max_capacity = @as(u64, 1) << + (@typeInfo(Size).int.bits - 1); + if (minimum_capacity > max_capacity) { + return layoutForCapacity(@intCast(max_capacity)); + } + + // Linear probing uses a mask for wraparound, which requires the + // final raw capacity to be rounded up to a power of two. + const raw_capacity = std.math.ceilPowerOfTwo( + u64, + minimum_capacity, + ) catch unreachable; + return layoutForCapacity(@intCast(raw_capacity)); + } }; } @@ -896,7 +1097,7 @@ const expect = std.testing.expect; const expectEqual = std.testing.expectEqual; test "HashMap basic usage" { - const Map = AutoHashMapUnmanaged(u32, u32); + const Map = AutoHashMapUnmanaged(u32, u32, default_max_load_percentage); const alloc = testing.allocator; const cap = 16; @@ -931,7 +1132,7 @@ test "HashMap basic usage" { } test "HashMap ensureTotalCapacity" { - const Map = AutoHashMapUnmanaged(i32, i32); + const Map = AutoHashMapUnmanaged(i32, i32, default_max_load_percentage); const cap = 32; const alloc = testing.allocator; @@ -951,7 +1152,7 @@ test "HashMap ensureTotalCapacity" { } test "HashMap ensureUnusedCapacity with tombstones" { - const Map = AutoHashMapUnmanaged(i32, i32); + const Map = AutoHashMapUnmanaged(i32, i32, default_max_load_percentage); const cap = 32; const alloc = testing.allocator; @@ -969,7 +1170,7 @@ test "HashMap ensureUnusedCapacity with tombstones" { } test "HashMap clearRetainingCapacity" { - const Map = AutoHashMapUnmanaged(u32, u32); + const Map = AutoHashMapUnmanaged(u32, u32, default_max_load_percentage); const cap = 16; const alloc = testing.allocator; @@ -1000,7 +1201,7 @@ test "HashMap clearRetainingCapacity" { } test "HashMap ensureTotalCapacity with existing elements" { - const Map = AutoHashMapUnmanaged(u32, u32); + const Map = AutoHashMapUnmanaged(u32, u32, default_max_load_percentage); const cap = Map.minimal_capacity; const alloc = testing.allocator; @@ -1019,7 +1220,7 @@ test "HashMap ensureTotalCapacity with existing elements" { } test "HashMap remove" { - const Map = AutoHashMapUnmanaged(u32, u32); + const Map = AutoHashMapUnmanaged(u32, u32, default_max_load_percentage); const cap = 32; const alloc = testing.allocator; @@ -1057,7 +1258,7 @@ test "HashMap remove" { } test "HashMap reverse removes" { - const Map = AutoHashMapUnmanaged(u32, u32); + const Map = AutoHashMapUnmanaged(u32, u32, default_max_load_percentage); const cap = 32; const alloc = testing.allocator; @@ -1085,7 +1286,7 @@ test "HashMap reverse removes" { } test "HashMap multiple removes on same metadata" { - const Map = AutoHashMapUnmanaged(u32, u32); + const Map = AutoHashMapUnmanaged(u32, u32, default_max_load_percentage); const cap = 32; const alloc = testing.allocator; @@ -1128,7 +1329,7 @@ test "HashMap multiple removes on same metadata" { } test "HashMap put and remove loop in random order" { - const Map = AutoHashMapUnmanaged(u32, u32); + const Map = AutoHashMapUnmanaged(u32, u32, default_max_load_percentage); const cap = 64; const alloc = testing.allocator; @@ -1166,7 +1367,7 @@ test "HashMap put and remove loop in random order" { } test "HashMap put" { - const Map = AutoHashMapUnmanaged(u32, u32); + const Map = AutoHashMapUnmanaged(u32, u32, default_max_load_percentage); const cap = 32; const alloc = testing.allocator; @@ -1197,7 +1398,7 @@ test "HashMap put" { } test "HashMap put full load" { - const Map = AutoHashMapUnmanaged(usize, usize); + const Map = AutoHashMapUnmanaged(usize, usize, default_max_load_percentage); const cap = 16; const alloc = testing.allocator; @@ -1213,7 +1414,7 @@ test "HashMap put full load" { } test "HashMap putAssumeCapacity" { - const Map = AutoHashMapUnmanaged(u32, u32); + const Map = AutoHashMapUnmanaged(u32, u32, default_max_load_percentage); const cap = 32; const alloc = testing.allocator; @@ -1248,7 +1449,7 @@ test "HashMap putAssumeCapacity" { } test "HashMap repeat putAssumeCapacity/remove" { - const Map = AutoHashMapUnmanaged(u32, u32); + const Map = AutoHashMapUnmanaged(u32, u32, default_max_load_percentage); const cap = 32; const alloc = testing.allocator; @@ -1283,8 +1484,175 @@ test "HashMap repeat putAssumeCapacity/remove" { try expectEqual(map.count(), limit); } +test "HashMap clobber insert rehashes exhausted headroom" { + const Context = struct { + pub fn hash(_: @This(), key: u32) u64 { + return key; + } + + pub fn eql(_: @This(), a: u32, b: u32) bool { + return a == b; + } + }; + const Map = HashMapUnmanaged(u32, u32, Context, 80); + const cap = 16; + + const alloc = testing.allocator; + const layout = Map.layoutForCapacity(cap); + const buf = try alloc.alignedAlloc(u8, Map.base_align, layout.total_size); + defer alloc.free(buf); + var map = Map.init(.init(buf), layout); + + const max_load = map.maxLoad(); + for (0..max_load) |i| { + map.putAssumeCapacityNoClobberContext( + @intCast(i), + @intCast(i), + .{}, + ); + } + + try expect(map.removeContext(0, .{})); + map.putAssumeCapacityContext(15, 15, .{}); + + try expectEqual(max_load, map.count()); + try expectEqual(15, map.getContext(15, .{}).?); + for (map.metadata.?[0..map.capacity()]) |metadata| { + try expect(!metadata.isTombstone()); + } +} + +test "HashMap getOrPut rehashes a fragmented probe" { + const Context = struct { + pub fn hash(_: @This(), _: u32) u64 { + return 0; + } + + pub fn eql(_: @This(), a: u32, b: u32) bool { + return a == b; + } + }; + const AdaptedContext = struct { + pub fn hash(_: @This(), _: []const u8) u64 { + return 0; + } + + pub fn eql(_: @This(), adapted: []const u8, key: u32) bool { + return std.fmt.parseInt(u32, adapted, 10) catch unreachable == key; + } + }; + const Map = HashMapUnmanaged( + u32, + u32, + Context, + default_max_load_percentage, + ); + const cap = 32; + + const alloc = testing.allocator; + const layout = Map.layoutForCapacity(cap); + const buf = try alloc.alignedAlloc(u8, Map.base_align, layout.total_size); + defer alloc.free(buf); + var map = Map.init(.init(buf), layout); + + for (0..cap) |i| { + map.putAssumeCapacityNoClobberContext(@intCast(i), @intCast(i), .{}); + } + + // Rehashing preserves a table at the supported 100% live occupancy. + map.rehash(.{}); + try expectEqual(cap, map.count()); + for (0..cap) |i| { + try expectEqual(i, map.getContext(@intCast(i), .{}).?); + } + + for (0..cap / 2) |i| { + try expect(map.removeContext(@intCast(i), .{})); + } + + var tombstones: usize = 0; + for (map.metadata.?[0..map.capacity()]) |metadata| { + if (metadata.isTombstone()) tombstones += 1; + } + try expectEqual(cap / 2, tombstones); + + // An adapted lookup cannot rehash without the context for resident keys. + const adapted = try map.getOrPutAdapted("31", AdaptedContext{}); + try expect(adapted.found_existing); + try expectEqual(cap - 1, adapted.value_ptr.*); + tombstones = 0; + for (map.metadata.?[0..map.capacity()]) |metadata| { + if (metadata.isTombstone()) tombstones += 1; + } + try expectEqual(cap / 2, tombstones); + + // Looking up an existing key beyond the tombstones rehashes and retries + // before returning pointers into the map. + const gop = try map.getOrPutContext(cap - 1, .{}); + try expect(gop.found_existing); + try expectEqual(cap - 1, gop.value_ptr.*); + try expectEqual(cap / 2, map.count()); + + tombstones = 0; + for (map.metadata.?[0..map.capacity()]) |metadata| { + if (metadata.isTombstone()) tombstones += 1; + } + try expectEqual(0, tombstones); + + for (cap / 2..cap) |i| { + try expectEqual(i, map.getContext(@intCast(i), .{}).?); + } +} + +test "HashMap rehash with real hashes" { + const Map = AutoHashMapUnmanaged( + u32, + u32, + default_max_load_percentage, + ); + const cap = 512; + + const alloc = testing.allocator; + const layout = Map.layoutForCapacity(cap); + const buf = try alloc.alignedAlloc(u8, Map.base_align, layout.total_size); + defer alloc.free(buf); + var map = Map.init(.init(buf), layout); + + for (0..cap) |i| { + map.putAssumeCapacityNoClobber(@intCast(i), @intCast(i)); + } + + map.rehash(undefined); + try expectEqual(cap, map.count()); + for (0..cap) |i| { + try expectEqual(i, map.get(@intCast(i)).?); + } + + var expected_count: usize = cap; + for (0..cap) |i| { + if (i % 3 == 0) { + try expect(map.remove(@intCast(i))); + expected_count -= 1; + } + } + + map.rehash(undefined); + try expectEqual(expected_count, map.count()); + for (0..cap) |i| { + if (i % 3 == 0) { + try expectEqual(null, map.get(@intCast(i))); + } else { + try expectEqual(i, map.get(@intCast(i)).?); + } + } + + for (map.metadata.?[0..map.capacity()]) |metadata| { + try expect(!metadata.isTombstone()); + } +} + test "HashMap getOrPut" { - const Map = AutoHashMapUnmanaged(u32, u32); + const Map = AutoHashMapUnmanaged(u32, u32, default_max_load_percentage); const cap = 32; const alloc = testing.allocator; @@ -1313,7 +1681,7 @@ test "HashMap getOrPut" { } test "HashMap basic hash map usage" { - const Map = AutoHashMapUnmanaged(i32, i32); + const Map = AutoHashMapUnmanaged(i32, i32, default_max_load_percentage); const cap = 32; const alloc = testing.allocator; @@ -1364,7 +1732,7 @@ test "HashMap basic hash map usage" { } test "HashMap ensureUnusedCapacity" { - const Map = AutoHashMapUnmanaged(u64, u64); + const Map = AutoHashMapUnmanaged(u64, u64, default_max_load_percentage); const cap = 64; const alloc = testing.allocator; @@ -1378,7 +1746,7 @@ test "HashMap ensureUnusedCapacity" { } test "HashMap removeByPtr" { - const Map = AutoHashMapUnmanaged(i32, u64); + const Map = AutoHashMapUnmanaged(i32, u64, default_max_load_percentage); const cap = 64; const alloc = testing.allocator; @@ -1409,7 +1777,7 @@ test "HashMap removeByPtr" { } test "HashMap removeByPtr 0 sized key" { - const Map = AutoHashMapUnmanaged(i32, u64); + const Map = AutoHashMapUnmanaged(i32, u64, default_max_load_percentage); const cap = 64; const alloc = testing.allocator; @@ -1433,7 +1801,7 @@ test "HashMap removeByPtr 0 sized key" { } test "HashMap repeat fetchRemove" { - const Map = AutoHashMapUnmanaged(u64, void); + const Map = AutoHashMapUnmanaged(u64, void, default_max_load_percentage); const cap = 64; const alloc = testing.allocator; @@ -1461,7 +1829,11 @@ test "HashMap repeat fetchRemove" { } test "OffsetHashMap basic usage" { - const OffsetMap = AutoOffsetHashMap(u32, u32); + const OffsetMap = AutoOffsetHashMap( + u32, + u32, + default_max_load_percentage, + ); const cap = 16; const alloc = testing.allocator; @@ -1496,7 +1868,11 @@ test "OffsetHashMap basic usage" { } test "OffsetHashMap remake map" { - const OffsetMap = AutoOffsetHashMap(u32, u32); + const OffsetMap = AutoOffsetHashMap( + u32, + u32, + default_max_load_percentage, + ); const cap = 16; const alloc = testing.allocator; @@ -1516,12 +1892,44 @@ test "OffsetHashMap remake map" { } } +test "OffsetHashMap maximum load leaves probe headroom" { + const OffsetMap = AutoOffsetHashMap(u32, u32, 80); + const alloc = testing.allocator; + const requested_size = 16; + const layout = OffsetMap.layout(requested_size); + const buf = try alloc.alignedAlloc( + u8, + OffsetMap.base_align, + layout.total_size, + ); + defer alloc.free(buf); + + const offset_map = OffsetMap.init(.init(buf), layout); + var map = offset_map.map(buf); + + try testing.expect(map.capacity() > requested_size); + try testing.expect(map.maxLoad() >= requested_size); + try testing.expect(map.maxLoad() < map.capacity()); + + for (0..requested_size) |i| try map.put(@intCast(i), @intCast(i)); + for (0..100) |_| { + for (0..requested_size) |i| { + try testing.expect(map.remove(@intCast(i))); + try map.put(@intCast(i), @intCast(i)); + } + } + + for (0..requested_size) |i| { + try testing.expectEqual(@as(u32, @intCast(i)), map.get(@intCast(i))); + } +} + test "layoutForCapacity no overflow for large capacity" { // Test that layoutForCapacity correctly handles large capacities without overflow. // Prior to the fix, new_capacity (u32) was multiplied before widening to usize, // causing overflow when new_capacity * @sizeOf(K) exceeded 2^32. // See: https://github.com/ghostty-org/ghostty/issues/9862 - const Map = AutoHashMapUnmanaged(u64, u64); + const Map = AutoHashMapUnmanaged(u64, u64, default_max_load_percentage); // Use 2^30 capacity - this would overflow in u32 when multiplied by @sizeOf(u64)=8 // 0x40000000 * 8 = 0x2_0000_0000 which wraps to 0 in u32 diff --git a/src/terminal/hyperlink.zig b/src/terminal/hyperlink.zig index 94f86466c..35a16a2ae 100644 --- a/src/terminal/hyperlink.zig +++ b/src/terminal/hyperlink.zig @@ -20,7 +20,7 @@ pub const Id = size.HyperlinkCountInt; // The mapping of cell to hyperlink. We use an offset hash map to save space // since its very unlikely a cell is a hyperlink, so its a waste to store // the hyperlink ID in the cell itself. -pub const Map = AutoOffsetHashMap(Offset(Cell), Id); +pub const Map = AutoOffsetHashMap(Offset(Cell), Id, 80); /// A fully decoded hyperlink that may or may not have its /// memory within a page. The memory location of this is dependent diff --git a/src/terminal/page.zig b/src/terminal/page.zig index c2759d2a2..ca6e2e220 100644 --- a/src/terminal/page.zig +++ b/src/terminal/page.zig @@ -92,7 +92,11 @@ 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; -const GraphemeMap = AutoOffsetHashMap(Offset(Cell), Offset(u21).Slice); +const GraphemeMap = AutoOffsetHashMap( + Offset(Cell), + Offset(u21).Slice, + hash_map.default_max_load_percentage, +); /// The allocator used for shared utf8-encoded strings within a page. /// Note the chunk size below is the minimum size of a single allocation @@ -770,11 +774,11 @@ pub const Page = struct { } } - // The hyperlink_map capacity in layout() is computed as: - // hyperlink_count * hyperlink_cell_multiplier (rounded to power of 2) - // We need enough hyperlink_bytes so that when layout() computes - // the map capacity, it can accommodate all hyperlink cells. This - // is unit tested. + // layout() requests `hyperlink_count * hyperlink_cell_multiplier` + // usable map entries. The map layout adds load-factor headroom and + // rounds the raw slot count to a power of two. We need enough + // hyperlink_bytes for that requested entry count to accommodate all + // hyperlink cells. This is unit tested. const hyperlink_cap = cap: { const hyperlink_count = id_set.count(); const hyperlink_set_cap = hyperlink.Set.capacityForCount(hyperlink_count); @@ -1494,7 +1498,7 @@ pub const Page = struct { /// Returns the hyperlink capacity for the page. This isn't the byte /// size but the number of unique cells that can have hyperlink data. pub inline fn hyperlinkCapacity(self: *const Page) usize { - return self.hyperlink_map.map(self.memory).capacity(); + return self.hyperlink_map.map(self.memory).maxLoad(); } /// Set the graphemes for the given cell. This asserts that the cell @@ -1765,7 +1769,7 @@ pub const Page = struct { u32, hyperlink_count * hyperlink_cell_multiplier, ) orelse break :count std.math.maxInt(u32); - break :count std.math.ceilPowerOfTwoAssert(u32, mult); + break :count mult; }; const hyperlink_map_layout = hyperlink.Map.layout(hyperlink_map_count); const hyperlink_map_start = alignForward(usize, hyperlink_set_end, hyperlink.Map.base_align.toByteUnits()); @@ -4211,3 +4215,20 @@ test "Page exactRowCapacity hyperlink map capacity for many cells" { try testing.expect(cloned_cell.hyperlink); } } + +test "Page layout avoids double rounding hyperlink map capacity" { + const hyperlink_count = 3; + const layout = Page.layout(.{ + .cols = 1, + .rows = 1, + .hyperlink_bytes = hyperlink_count * @sizeOf(hyperlink.Set.Item), + }); + + // Three set entries request 48 usable map entries. Scaling that for the + // 80% load factor needs 60 raw slots, which rounds once to 64. Rounding + // the request before applying the load factor would allocate 128 slots. + try std.testing.expectEqual( + @as(u32, 64), + layout.hyperlink_map_layout.capacity, + ); +}