ghostty/src/datastruct/cache_table.zig
Qwerasd 6f7e9d5bea code style: use @splat where possible
As of Zig 0.14.0, `@splat` can be used for array types, which eliminates
a lot of redundant syntax and makes things generally cleaner.

I've explicitly avoided applying this change in the renderer files for
now since it would just create rebasing conflicts in my renderer rework
branch which I'll be PR-ing pretty soon.
2025-05-27 21:55:28 -06:00

182 lines
6.4 KiB
Zig

const fastmem = @import("../fastmem.zig");
const std = @import("std");
const assert = std.debug.assert;
/// An associative data structure used for efficiently storing and
/// retrieving values which are able to be recomputed if necessary.
///
/// This structure is effectively a hash table with fixed-sized buckets.
///
/// When inserting an item in to a full bucket, the least recently used
/// item is replaced.
///
/// To achieve this, when an item is accessed, it's moved to the end of
/// the bucket, and the rest of the items are moved over to fill the gap.
///
/// This should provide very good query performance and keep frequently
/// accessed items cached indefinitely.
///
/// Parameters:
///
/// `Context`
/// A type containing methods to define CacheTable behaviors.
/// - `fn hash(*Context, K) u64` - Return a hash for a key.
/// - `fn eql(*Context, K, K) bool` - Check two keys for equality.
///
/// - `fn evicted(*Context, K, V) void` - [OPTIONAL] Eviction callback.
/// If present, called whenever an item is evicted from the cache.
///
/// `bucket_count`
/// Should ideally be close to the median number of important items that
/// you expect to be cached at any given point. This is required to be a
/// power of 2 since performance suffers if it's not and there's no good
/// reason to allow it to be anything else.
///
/// `bucket_size`
/// should be larger if you expect a large number of unimportant items to
/// enter the cache at a time. Having larger buckets will avoid important
/// items being dropped from the cache prematurely.
///
pub fn CacheTable(
comptime K: type,
comptime V: type,
comptime Context: type,
comptime bucket_count: usize,
comptime bucket_size: u8,
) type {
return struct {
const Self = @This();
const KV = struct {
key: K,
value: V,
};
comptime {
assert(std.math.isPowerOfTwo(bucket_count));
assert(bucket_count <= std.math.maxInt(usize));
}
/// `bucket_count` buckets containing `bucket_size` KV pairs each.
///
/// We don't need to initialize this memory because we don't use it
/// unless it's within a bucket's stored length, which will guarantee
/// that we put actual items there.
buckets: [bucket_count][bucket_size]KV = undefined,
/// We use this array to keep track of how many slots in each bucket
/// have actual items in them. Once all the buckets fill up this will
/// become a pointless check, but hopefully branch prediction picks
/// up on it at that point. The memory cost isn't too bad since it's
/// just bytes, so should be a fraction the size of the main table.
lengths: [bucket_count]u8 = @splat(0),
/// An instance of the context structure.
/// Must be initialized before calling any operations.
context: Context,
/// Adds an item to the cache table. If an old value was removed to
/// make room then it is returned in a struct with its key and value.
pub fn put(self: *Self, key: K, value: V) ?KV {
const kv: KV = .{ .key = key, .value = value };
const idx: usize = @intCast(self.context.hash(key) % bucket_count);
// If we have space available in the bucket then we just append
if (self.lengths[idx] < bucket_size) {
self.buckets[idx][self.lengths[idx]] = kv;
self.lengths[idx] += 1;
return null;
}
assert(self.lengths[idx] == bucket_size);
// Append our new item and return the oldest
const evicted = fastmem.rotateIn(KV, &self.buckets[idx], kv);
// The Context is allowed to register an eviction hook.
if (comptime @hasDecl(Context, "evicted")) self.context.evicted(
evicted.key,
evicted.value,
);
return evicted;
}
/// Retrieves an item from the cache table.
///
/// Returns null if no item is found with the provided key.
pub fn get(self: *Self, key: K) ?V {
const idx: usize = @intCast(self.context.hash(key) % bucket_count);
const len = self.lengths[idx];
var i: usize = len;
while (i > 0) {
i -= 1;
if (self.context.eql(key, self.buckets[idx][i].key)) {
defer fastmem.rotateOnce(KV, self.buckets[idx][i..len]);
return self.buckets[idx][i].value;
}
}
return null;
}
/// Removes all items from the cache table.
///
/// If your `Context` has an `evicted` method,
/// it will be called with all removed items.
pub fn clear(self: *Self) void {
if (comptime @hasDecl(Context, "evicted")) {
for (self.buckets, self.lengths) |b, l| {
for (b[0..l]) |kv| {
self.context.evicted(kv.key, kv.value);
}
}
}
@memset(&self.lengths, 0);
}
};
}
/// Creates a Context automatically for the given key type. This uses the
/// same logic as std.hash_map.AutoContext today since the API matches.
fn AutoContext(comptime K: type) type {
return std.hash_map.AutoContext(K);
}
test CacheTable {
const testing = std.testing;
// Construct a table that purposely has a predictable hash so we can
// test all edge cases.
const T = CacheTable(u32, u32, struct {
pub fn hash(self: *const @This(), key: u32) u64 {
_ = self;
return @intCast(key);
}
pub fn eql(self: *const @This(), a: u32, b: u32) bool {
_ = self;
return a == b;
}
}, 2, 2);
var t: T = .{ .context = .{} };
// Fill the table
try testing.expect(t.put(0, 0) == null);
try testing.expect(t.put(1, 0) == null);
try testing.expect(t.put(2, 0) == null);
try testing.expect(t.put(3, 0) == null);
// It should now be full, so any insert should evict the oldest item.
// NOTE: For the sake of this test, we're assuming that the first item
// is evicted but we don't need to promise this.
try testing.expectEqual(T.KV{
.key = 0,
.value = 0,
}, t.put(4, 0).?);
// The first item should now be gone
try testing.expect(t.get(0) == null);
}