terminal: bound page map probe lengths

#13292

Page maps previously allowed a 100% load factor. Once live entries
and tombstones filled every slot, a missing-key lookup or insertion
could scan the entire map.

Reserve 20% of each offset hash map as insertion headroom and track
that budget separately from the live count. Port the allocation-free
in-place rehash from Zig HashMapUnmanaged so canonical insertion can
rebuild fragmented probes and restore tombstone-exhausted headroom
without growing the page. Assumed-capacity insertion now applies the
same guard, preventing the headroom counter from wrapping.

Pass the exact requested hyperlink count into map layout before
load-factor scaling, avoiding a redundant power-of-two rounding step.
Screen-level recovery now grows only when live hyperlinks actually fill
usable capacity.

ReleaseFast terminal benchmarks compare main with the combined map
changes:

| workload | before | after | speedup |
|---|---:|---:|---:|
| map churn | 1.253 s | 12.5 ms | 100x |
| full OSC 8 stream | 3.576 s | 75.6 ms | 47x |
| clear and redraw | 299.1 ms | 118.3 ms | 2.5x |

Co-authored-by: Tim Culverhouse <tim@timculverhouse.com>
This commit is contained in:
Mitchell Hashimoto
2026-07-11 09:21:27 -07:00
parent 53bd14fecf
commit 7e14347c13
7 changed files with 631 additions and 41 deletions

View File

@@ -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);
}
}

View File

@@ -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"),

View File

@@ -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");

View File

@@ -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,

View File

@@ -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

View File

@@ -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

View File

@@ -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,
);
}