mirror of
https://github.com/ghostty-org/ghostty.git
synced 2026-07-12 12:19:47 +00:00
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:
155
src/benchmark/HyperlinkMap.zig
Normal file
155
src/benchmark/HyperlinkMap.zig
Normal 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);
|
||||
}
|
||||
}
|
||||
@@ -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"),
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user