perf: introduce RefCountedSet structure, use it for Style.Set

This commit is contained in:
Qwerasd
2024-06-13 22:48:15 -04:00
parent 8d76b5f283
commit 9741b3a18c
6 changed files with 871 additions and 403 deletions

View File

@ -1158,17 +1158,17 @@ fn reflowPage(
// If the source cell has a style, we need to copy it.
if (src_cursor.page_cell.style_id != stylepkg.default_id) {
const src_style = src_cursor.page.styles.lookupId(
const src_style = src_cursor.page.styles.get(
src_cursor.page.memory,
src_cursor.page_cell.style_id,
).?.*;
const dst_md = try dst_cursor.page.styles.upsert(
).*;
if (try dst_cursor.page.styles.addWithId(
dst_cursor.page.memory,
src_style,
);
dst_md.ref += 1;
dst_cursor.page_cell.style_id = dst_md.id;
src_cursor.page_cell.style_id,
)) |id| {
dst_cursor.page_cell.style_id = id;
}
dst_cursor.page_row.styled = true;
}
}
@ -1905,18 +1905,6 @@ pub fn adjustCapacity(
return new_page;
}
/// Compact a page, reallocating to minimize the amount of memory
/// required for the page. This is useful when we've overflowed ID
/// spaces, are archiving a page, etc.
///
/// Note today: this doesn't minimize the memory usage, but it does
/// fix style ID overflow. A future update can shrink the memory
/// allocation.
pub fn compact(self: *PageList, page: *List.Node) !*List.Node {
// Adjusting capacity with no adjustments forces a reallocation.
return try self.adjustCapacity(page, .{});
}
/// Create a new page node. This does not add it to the list and this
/// does not do any memory size accounting with max_size/page_size.
fn createPage(
@ -3039,10 +3027,10 @@ pub const Pin = struct {
/// Returns the style for the given cell in this pin.
pub fn style(self: Pin, cell: *pagepkg.Cell) stylepkg.Style {
if (cell.style_id == stylepkg.default_id) return .{};
return self.page.data.styles.lookupId(
return self.page.data.styles.get(
self.page.data.memory,
cell.style_id,
).?.*;
).*;
}
/// Check if this pin is dirty.
@ -3302,10 +3290,10 @@ const Cell = struct {
/// Not meant for non-test usage since this is inefficient.
pub fn style(self: Cell) stylepkg.Style {
if (self.cell.style_id == stylepkg.default_id) return .{};
return self.page.data.styles.lookupId(
return self.page.data.styles.get(
self.page.data.memory,
self.cell.style_id,
).?.*;
).*;
}
/// Gets the screen point for the given cell.
@ -7363,18 +7351,20 @@ test "PageList resize reflow less cols copy style" {
// Create a style
const style: stylepkg.Style = .{ .flags = .{ .bold = true } };
const style_md = try page.styles.upsert(page.memory, style);
const style_id = try page.styles.add(page.memory, style);
for (0..s.cols - 1) |x| {
const rac = page.getRowAndCell(x, 0);
rac.cell.* = .{
.content_tag = .codepoint,
.content = .{ .codepoint = @intCast(x) },
.style_id = style_md.id,
.style_id = style_id,
};
style_md.ref += 1;
page.styles.use(page.memory, style_id);
}
// We're over-counted by 1 because `add` implies `use`.
page.styles.release(page.memory, style_id);
}
// Resize
@ -7391,10 +7381,10 @@ test "PageList resize reflow less cols copy style" {
const style_id = rac.cell.style_id;
try testing.expect(style_id != 0);
const style = offset.page.data.styles.lookupId(
const style = offset.page.data.styles.get(
offset.page.data.memory,
style_id,
).?;
);
try testing.expect(style.flags.bold);
const row = rac.row;

View File

@ -100,7 +100,6 @@ pub const Cursor = struct {
/// we change pages we need to ensure that we update that page with
/// our style when used.
style_id: style.Id = style.default_id,
style_ref: ?*size.CellCountInt = null,
/// The pointers into the page list where the cursor is currently
/// located. This makes it faster to move the cursor.
@ -202,31 +201,6 @@ pub fn assertIntegrity(self: *const Screen) void {
) orelse unreachable;
assert(self.cursor.x == pt.active.x);
assert(self.cursor.y == pt.active.y);
if (self.cursor.style_id == style.default_id) {
// If our style is default, we should have no refs.
assert(self.cursor.style.default());
assert(self.cursor.style_ref == null);
} else {
// If our style is not default, we should have a ref.
assert(!self.cursor.style.default());
assert(self.cursor.style_ref != null);
// Further, the ref should be valid within the current page.
const page = &self.cursor.page_pin.page.data;
const page_style = page.styles.lookupId(
page.memory,
self.cursor.style_id,
).?.*;
assert(self.cursor.style.eql(page_style));
// The metadata pointer should be equal to the ref.
const md = page.styles.upsert(
page.memory,
page_style,
) catch unreachable;
assert(&md.ref == self.cursor.style_ref.?);
}
}
}
@ -524,14 +498,6 @@ pub fn cursorReload(self: *Screen) void {
// If we have a style, we need to ensure it is in the page because this
// method may also be called after a page change.
if (self.cursor.style_id != style.default_id) {
// We set our ref to null because manualStyleUpdate will refresh it.
// If we had a valid ref and it was zero before, then manualStyleUpdate
// will reload the same ref.
//
// We want to avoid the scenario this was non-null but the pointer
// is now invalid because it pointed to a page that no longer exists.
self.cursor.style_ref = null;
self.manualStyleUpdate() catch |err| {
// This failure should not happen because manualStyleUpdate
// handles page splitting, overflow, and more. This should only
@ -540,7 +506,6 @@ pub fn cursorReload(self: *Screen) void {
log.err("failed to update style on cursor reload err={}", .{err});
self.cursor.style = .{};
self.cursor.style_id = 0;
self.cursor.style_ref = null;
};
}
}
@ -595,7 +560,6 @@ pub fn cursorDownScroll(self: *Screen) !void {
log.err("failed to update style on cursor scroll err={}", .{err});
self.cursor.style = .{};
self.cursor.style_id = 0;
self.cursor.style_ref = null;
};
}
} else {
@ -683,7 +647,6 @@ pub fn cursorCopy(self: *Screen, other: Cursor) !void {
// invalid.
self.cursor.style = .{};
self.cursor.style_id = 0;
self.cursor.style_ref = null;
// We need to keep our old x/y because that is our cursorAbsolute
// will fix up our pointers.
@ -698,7 +661,6 @@ pub fn cursorCopy(self: *Screen, other: Cursor) !void {
// We keep the old style ref so manualStyleUpdate can clean our old style up.
self.cursor.style = other.style;
self.cursor.style_id = old.style_id;
self.cursor.style_ref = old.style_ref;
try self.manualStyleUpdate();
}
@ -744,7 +706,6 @@ fn cursorChangePin(self: *Screen, new: Pin) void {
log.err("failed to update style on cursor change err={}", .{err});
self.cursor.style = .{};
self.cursor.style_id = 0;
self.cursor.style_ref = null;
};
}
@ -889,33 +850,7 @@ pub fn clearCells(
if (row.styled) {
for (cells) |*cell| {
if (cell.style_id == style.default_id) continue;
// Fast-path, the style ID matches, in this case we just update
// our own ref and continue. We never delete because our style
// is still active.
if (page == &self.cursor.page_pin.page.data and
cell.style_id == self.cursor.style_id)
{
self.cursor.style_ref.?.* -= 1;
continue;
}
// Slow path: we need to lookup this style so we can decrement
// the ref count. Since we've already loaded everything, we also
// just go ahead and GC it if it reaches zero, too.
if (page.styles.lookupId(
page.memory,
cell.style_id,
)) |prev_style| {
// Below upsert can't fail because it should already be present
const md = page.styles.upsert(
page.memory,
prev_style.*,
) catch unreachable;
assert(md.ref > 0);
md.ref -= 1;
if (md.ref == 0) page.styles.remove(page.memory, cell.style_id);
}
page.styles.release(page.memory, cell.style_id);
}
// If we have no left/right scroll region we can be sure that
@ -1044,17 +979,37 @@ pub fn resizeWithoutReflow(
}
/// Resize the screen.
// TODO: replace resize and resizeWithoutReflow with this.
fn resizeInternal(
self: *Screen,
cols: size.CellCountInt,
rows: size.CellCountInt,
reflow: bool,
) !void {
defer self.assertIntegrity();
// No matter what we mark our image state as dirty
self.kitty_images.dirty = true;
// Perform the resize operation. This will update cursor by reference.
// Release the cursor style while resizing just
// in case the cursor ends up on a different page.
const cursor_style = self.cursor.style;
self.cursor.style = .{};
self.manualStyleUpdate() catch unreachable;
defer {
// Restore the cursor style.
self.cursor.style = cursor_style;
self.manualStyleUpdate() catch |err| {
// This failure should not happen because manualStyleUpdate
// handles page splitting, overflow, and more. This should only
// happen if we're out of RAM. In this case, we'll just degrade
// gracefully back to the default style.
log.err("failed to update style on cursor reload err={}", .{err});
self.cursor.style = .{};
self.cursor.style_id = 0;
};
}
// Perform the resize operation.
try self.pages.resize(.{
.rows = rows,
.cols = cols,
@ -1072,7 +1027,6 @@ fn resizeInternal(
// If our cursor was updated, we do a full reload so all our cursor
// state is correct.
self.cursorReload();
self.assertIntegrity();
}
/// Set a style attribute for the current cursor.
@ -1223,21 +1177,14 @@ pub fn manualStyleUpdate(self: *Screen) !void {
// std.log.warn("active styles={}", .{page.styles.count(page.memory)});
// Remove our previous style if is unused.
if (self.cursor.style_ref) |ref| {
if (ref.* == 0) {
page.styles.remove(page.memory, self.cursor.style_id);
}
// Reset our ID and ref to null since the ref is now invalid.
self.cursor.style_id = 0;
self.cursor.style_ref = null;
// Release our previous style if it was not default.
if (self.cursor.style_id != style.default_id) {
page.styles.release(page.memory, self.cursor.style_id);
}
// If our new style is the default, just reset to that
if (self.cursor.style.default()) {
self.cursor.style_id = 0;
self.cursor.style_ref = null;
return;
}
@ -1251,42 +1198,29 @@ pub fn manualStyleUpdate(self: *Screen) !void {
// if that makes a meaningful difference. Our priority is to keep print
// fast because setting a ton of styles that do nothing is uncommon
// and weird.
const md = page.styles.upsert(
const id = page.styles.add(
page.memory,
self.cursor.style,
) catch |err| md: {
switch (err) {
// Our style map is full. Let's allocate a new page by doubling
// the size and then try again.
error.OutOfMemory => {
const node = try self.pages.adjustCapacity(
self.cursor.page_pin.page,
.{ .styles = page.capacity.styles * 2 },
);
) catch id: {
// Our style map is full. Let's allocate a new
// page by doubling the size and then try again.
const node = try self.pages.adjustCapacity(
self.cursor.page_pin.page,
.{ .styles = page.capacity.styles * 2 },
);
page = &node.data;
},
// We've run out of style IDs. This is fixed by doing a page
// compaction.
error.Overflow => {
const node = try self.pages.compact(
self.cursor.page_pin.page,
);
page = &node.data;
},
}
page = &node.data;
// Since this modifies our cursor page, we need to reload
cursor_reload = true;
break :md try page.styles.upsert(
break :id try page.styles.add(
page.memory,
self.cursor.style,
);
};
self.cursor.style_id = md.id;
self.cursor.style_ref = &md.ref;
self.cursor.style_id = id;
if (cursor_reload) self.cursorReload();
self.assertIntegrity();
}
@ -2271,8 +2205,9 @@ pub fn testWriteString(self: *Screen, text: []const u8) !void {
};
// If we have a ref-counted style, increase.
if (self.cursor.style_ref) |ref| {
ref.* += 1;
if (self.cursor.style_id != style.default_id) {
const page = self.cursor.page_pin.page.data;
page.styles.use(page.memory, self.cursor.style_id);
self.cursor.page_row.styled = true;
}
},
@ -2310,6 +2245,14 @@ pub fn testWriteString(self: *Screen, text: []const u8) !void {
.wide = .spacer_tail,
.protected = self.cursor.protected,
};
// If we have a ref-counted style, increase twice.
if (self.cursor.style_id != style.default_id) {
const page = self.cursor.page_pin.page.data;
page.styles.use(page.memory, self.cursor.style_id);
page.styles.use(page.memory, self.cursor.style_id);
self.cursor.page_row.styled = true;
}
},
else => unreachable,
@ -2792,10 +2735,10 @@ test "Screen: cursorDown across pages preserves style" {
try s.setAttribute(.{ .bold = {} });
{
const page = &s.cursor.page_pin.page.data;
const styleval = page.styles.lookupId(
const styleval = page.styles.get(
page.memory,
s.cursor.style_id,
).?;
);
try testing.expect(styleval.flags.bold);
}
@ -2803,10 +2746,10 @@ test "Screen: cursorDown across pages preserves style" {
s.cursorDown(1);
{
const page = &s.cursor.page_pin.page.data;
const styleval = page.styles.lookupId(
const styleval = page.styles.get(
page.memory,
s.cursor.style_id,
).?;
);
try testing.expect(styleval.flags.bold);
}
}
@ -2835,10 +2778,10 @@ test "Screen: cursorUp across pages preserves style" {
try s.setAttribute(.{ .bold = {} });
{
const page = &s.cursor.page_pin.page.data;
const styleval = page.styles.lookupId(
const styleval = page.styles.get(
page.memory,
s.cursor.style_id,
).?;
);
try testing.expect(styleval.flags.bold);
}
@ -2848,10 +2791,10 @@ test "Screen: cursorUp across pages preserves style" {
const page = &s.cursor.page_pin.page.data;
try testing.expect(start_page == page);
const styleval = page.styles.lookupId(
const styleval = page.styles.get(
page.memory,
s.cursor.style_id,
).?;
);
try testing.expect(styleval.flags.bold);
}
}
@ -2880,10 +2823,10 @@ test "Screen: cursorAbsolute across pages preserves style" {
try s.setAttribute(.{ .bold = {} });
{
const page = &s.cursor.page_pin.page.data;
const styleval = page.styles.lookupId(
const styleval = page.styles.get(
page.memory,
s.cursor.style_id,
).?;
);
try testing.expect(styleval.flags.bold);
}
@ -2893,10 +2836,10 @@ test "Screen: cursorAbsolute across pages preserves style" {
const page = &s.cursor.page_pin.page.data;
try testing.expect(start_page == page);
const styleval = page.styles.lookupId(
const styleval = page.styles.get(
page.memory,
s.cursor.style_id,
).?;
);
try testing.expect(styleval.flags.bold);
}
}
@ -3013,10 +2956,10 @@ test "Screen: scrolling across pages preserves style" {
const page = &s.pages.pages.last.?.data;
try testing.expect(start_page != page);
const styleval = page.styles.lookupId(
const styleval = page.styles.get(
page.memory,
s.cursor.style_id,
).?;
);
try testing.expect(styleval.flags.bold);
}

View File

@ -600,8 +600,19 @@ fn printCell(
);
}
// Keep track of the previous style so we can decrement the ref count
const prev_style_id = cell.style_id;
// We don't need to update the style refs unless the
// cell's new style will be different after writing.
const style_changed = cell.style_id != self.screen.cursor.style_id;
if (style_changed) {
var page = &self.screen.cursor.page_pin.page.data;
// Release the old style.
if (cell.style_id != style.default_id) {
assert(self.screen.cursor.page_row.styled);
page.styles.release(page.memory, cell.style_id);
}
}
// Write
cell.* = .{
@ -612,50 +623,12 @@ fn printCell(
.protected = self.screen.cursor.protected,
};
if (comptime std.debug.runtime_safety) {
// We've had bugs around this, so let's add an assertion: every
// style we use should be present in the style table.
if (self.screen.cursor.style_id != style.default_id) {
const page = &self.screen.cursor.page_pin.page.data;
if (page.styles.lookupId(
page.memory,
self.screen.cursor.style_id,
) == null) {
log.err("can't find style page={X} id={}", .{
@intFromPtr(&self.screen.cursor.page_pin.page.data),
self.screen.cursor.style_id,
});
@panic("style not found");
}
}
}
if (style_changed) {
var page = &self.screen.cursor.page_pin.page.data;
// Handle the style ref count handling
style_ref: {
if (prev_style_id != style.default_id) {
const row = self.screen.cursor.page_row;
assert(row.styled);
// If our previous cell had the same style ID as us currently,
// then we don't bother with any ref counts because we're the same.
if (prev_style_id == self.screen.cursor.style_id) break :style_ref;
// Slow path: we need to lookup this style so we can decrement
// the ref count. Since we've already loaded everything, we also
// just go ahead and GC it if it reaches zero, too.
var page = &self.screen.cursor.page_pin.page.data;
if (page.styles.lookupId(page.memory, prev_style_id)) |prev_style| {
// Below upsert can't fail because it should already be present
const md = page.styles.upsert(page.memory, prev_style.*) catch unreachable;
assert(md.ref > 0);
md.ref -= 1;
if (md.ref == 0) page.styles.remove(page.memory, prev_style_id);
}
}
// If we have a ref-counted style, increase.
if (self.screen.cursor.style_ref) |ref| {
ref.* += 1;
// Use the new style.
if (cell.style_id != style.default_id) {
page.styles.use(page.memory, cell.style_id);
self.screen.cursor.page_row.styled = true;
}
}
@ -2190,8 +2163,12 @@ pub fn decaln(self: *Terminal) !void {
});
// If we have a ref-counted style, increase
if (self.screen.cursor.style_ref) |ref| {
ref.* += @intCast(cells.len);
if (self.screen.cursor.style_id != style.default_id) {
page.styles.useMultiple(
page.memory,
self.screen.cursor.style_id,
@intCast(cells.len),
);
row.styled = true;
}
@ -7066,7 +7043,8 @@ test "Terminal: bold style" {
const cell = list_cell.cell;
try testing.expectEqual(@as(u21, 'A'), cell.content.codepoint);
try testing.expect(cell.style_id != 0);
try testing.expect(t.screen.cursor.style_ref.?.* > 0);
const page = t.screen.cursor.page_pin.page.data;
try testing.expect(page.styles.refCount(page.memory, t.screen.cursor.style_id) > 1);
}
}

View File

@ -197,6 +197,7 @@ pub const Page = struct {
.styles = style.Set.init(
buf.add(l.styles_start),
l.styles_layout,
style.StyleSetContext{},
),
.grapheme_alloc = GraphemeAlloc.init(
buf.add(l.grapheme_alloc_start),
@ -324,17 +325,11 @@ pub const Page = struct {
if (cell.style_id != style.default_id) {
// If a cell has a style, it must be present in the styles
// set.
_ = self.styles.lookupId(
// set. Accessing it with `get` asserts that.
_ = self.styles.get(
self.memory,
cell.style_id,
) orelse {
log.warn(
"page integrity violation y={} x={} style missing id={}",
.{ y, x, cell.style_id },
);
return IntegrityError.MissingStyle;
};
);
if (!row.styled) {
log.warn(
@ -424,12 +419,11 @@ pub const Page = struct {
{
var it = styles_seen.iterator();
while (it.next()) |entry| {
const style_val = self.styles.lookupId(self.memory, entry.key_ptr.*).?.*;
const md = self.styles.upsert(self.memory, style_val) catch unreachable;
if (md.ref < entry.value_ptr.*) {
const ref_count = self.styles.refCount(self.memory, entry.key_ptr.*);
if (ref_count < entry.value_ptr.*) {
log.warn(
"page integrity violation style ref count mismatch id={} expected={} actual={}",
.{ entry.key_ptr.*, entry.value_ptr.*, md.ref },
.{ entry.key_ptr.*, entry.value_ptr.*, ref_count },
);
return IntegrityError.MismatchedStyleRef;
}
@ -474,7 +468,7 @@ pub const Page = struct {
return result;
}
pub const CloneFromError = Allocator.Error || style.Set.UpsertError;
pub const CloneFromError = Allocator.Error || error{OutOfMemory};
/// Clone the contents of another page into this page. The capacities
/// can be different, but the size of the other page must fit into
@ -586,11 +580,22 @@ pub const Page = struct {
for (cps) |cp| try self.appendGrapheme(dst_row, dst_cell, cp);
}
if (src_cell.style_id != style.default_id) {
const other_style = other.styles.lookupId(other.memory, src_cell.style_id).?.*;
const md = try self.styles.upsert(self.memory, other_style);
md.ref += 1;
dst_cell.style_id = md.id;
dst_row.styled = true;
if (other == self) {
// If it's the same page we don't have to worry about
// copying the style, we can use the style ID directly.
dst_cell.style_id = src_cell.style_id;
self.styles.use(self.memory, dst_cell.style_id);
continue;
}
// Slow path: Get the style from the other
// page and add it to this page's style set.
const other_style = other.styles.get(other.memory, src_cell.style_id).*;
if (try self.styles.addWithId(self.memory, other_style, src_cell.style_id)) |id| {
dst_cell.style_id = id;
}
}
}
}
@ -767,13 +772,7 @@ pub const Page = struct {
for (cells) |*cell| {
if (cell.style_id == style.default_id) continue;
if (self.styles.lookupId(self.memory, cell.style_id)) |prev_style| {
// Below upsert can't fail because it should already be present
const md = self.styles.upsert(self.memory, prev_style.*) catch unreachable;
assert(md.ref > 0);
md.ref -= 1;
if (md.ref == 0) self.styles.remove(self.memory, cell.style_id);
}
self.styles.release(self.memory, cell.style_id);
}
if (cells.len == self.size.cols) row.styled = false;
@ -2112,7 +2111,7 @@ test "Page verifyIntegrity styles good" {
defer page.deinit();
// Upsert a style we'll use
const md = try page.styles.upsert(page.memory, .{ .flags = .{
const id = try page.styles.add(page.memory, .{ .flags = .{
.bold = true,
} });
@ -2123,11 +2122,15 @@ test "Page verifyIntegrity styles good" {
rac.cell.* = .{
.content_tag = .codepoint,
.content = .{ .codepoint = @intCast(x + 1) },
.style_id = md.id,
.style_id = id,
};
md.ref += 1;
page.styles.use(page.memory, id);
}
// The original style add would have incremented the
// ref count too, so release it to balance that out.
page.styles.release(page.memory, id);
try page.verifyIntegrity(testing.allocator);
}
@ -2140,7 +2143,7 @@ test "Page verifyIntegrity styles ref count mismatch" {
defer page.deinit();
// Upsert a style we'll use
const md = try page.styles.upsert(page.memory, .{ .flags = .{
const id = try page.styles.add(page.memory, .{ .flags = .{
.bold = true,
} });
@ -2151,13 +2154,17 @@ test "Page verifyIntegrity styles ref count mismatch" {
rac.cell.* = .{
.content_tag = .codepoint,
.content = .{ .codepoint = @intCast(x + 1) },
.style_id = md.id,
.style_id = id,
};
md.ref += 1;
page.styles.use(page.memory, id);
}
// The original style add would have incremented the
// ref count too, so release it to balance that out.
page.styles.release(page.memory, id);
// Miss a ref
md.ref -= 1;
page.styles.release(page.memory, id);
try testing.expectError(
Page.IntegrityError.MismatchedStyleRef,

View File

@ -0,0 +1,573 @@
const size = @import("size.zig");
const Offset = size.Offset;
const OffsetBuf = size.OffsetBuf;
const fastmem = @import("../fastmem.zig");
const std = @import("std");
const assert = std.debug.assert;
/// A reference counted set.
///
/// This set is created with some capacity in mind. You can determine
/// the exact memory requirement of a given capacity by calling `layout`
/// and checking the total size.
///
/// When the set exceeds capacity, `error.OutOfMemory` is returned from
/// any memory-using methods. The caller is responsible for determining
/// a path forward.
///
/// This set is reference counted. Each item in the set has an associated
/// reference count. The caller is responsible for calling release for an
/// item when it is no longer being used. Items with 0 references will be
/// kept until another item is written to their bucket. This allows items
/// to be ressurected if they are re-added before they get overwritten.
///
/// The backing data structure of this set is an open addressed hash table
/// with linear probing and Robin Hood hashing, and a flat array of items.
///
/// The table maps values to item IDs, which are indices in the item array
/// which contain the item's value and its reference count. Item IDs can be
/// used to efficiently access an item and update its reference count after
/// it has been added to the table, to avoid having to use the hash map to
/// look the value back up.
///
/// ID 0 is reserved and will never be assigned.
///
/// Parameters:
///
/// `Context`
/// A type containing methods to define behaviors.
/// - `fn hash(*Context, T) u64` - Return a hash for an item.
/// - `fn eql(*Context, T, T) bool` - Check two items for equality.
///
/// - `fn deleted(*Context, T) void` - [OPTIONAL] Deletion callback.
/// If present, called whenever an item is finally deleted.
/// Useful if the item has memory that needs to be freed.
///
pub fn RefCountedSet(
comptime T: type,
comptime Id: type,
comptime RefCountInt: type,
comptime Context: type,
) type {
return struct {
const Self = RefCountedSet(T, Id, RefCountInt, Context);
pub const base_align = @max(
@alignOf(Context),
@alignOf(Layout),
@alignOf(Item),
@alignOf(Id),
);
/// Set item
pub const Item = struct {
/// The value this item represents.
value: T = undefined,
/// Metadata for this item.
meta: Metadata = .{},
pub const Metadata = struct {
/// The bucket in the hash table where this item
/// is referenced.
bucket: Id = std.math.maxInt(Id),
/// The length of the probe sequence between this
/// item's starting bucket and the bucket it's in,
/// used for Robin Hood hashing.
psl: Id = 0,
/// The reference count for this item.
ref: RefCountInt = 0,
};
};
/// A hash table of item indices
table: Offset(Id),
/// By keeping track of the max probe sequence length
/// we can bail out early when looking up values that
/// aren't present.
max_psl: Id = 0,
/// We keep track of how many items have a PSL of any
/// given length, so that we can shrink max_psl when
/// we delete items.
///
/// A probe sequence of length 32 or more is astronomically
/// unlikely. Roughly a (1/table_cap)^32 -- with any normal
/// table capacity that is so unlikely that it's not worth
/// handling.
psl_stats: [32]Id = [_]Id{0} ** 32,
/// The backing store of items
items: Offset(Item),
/// The next index to store an item at.
/// Id 0 is reserved for unused items.
next_id: Id = 1,
layout: Layout,
/// An instance of the context structure.
context: Context,
/// Returns the memory layout for the given base offset and
/// desired capacity. The layout can be used by the caller to
/// determine how much memory to allocate, and the layout must
/// be used to initialize the set so that the set knows all
/// the offsets for the various buffers.
///
/// The capacity passed for cap will be used for the hash table,
/// which has a load factor of `0.8125` (13/16), so the number of
/// items which can actually be stored in the set will be smaller.
///
/// The laid out capacity will be at least `cap`, but may be higher,
/// since it is rounded up to the next power of 2 for efficiency.
///
/// The returned layout `cap` property will be 1 more than the number
/// of items that the set can actually store, since ID 0 is reserved.
pub fn layout(cap: Id) Layout {
// Experimentally, this load factor works quite well.
const load_factor = 0.8125;
const table_cap: Id = std.math.ceilPowerOfTwoAssert(Id, cap);
const table_mask: Id = (@as(Id, 1) << std.math.log2_int(Id, table_cap)) - 1;
const items_cap: Id = @intFromFloat(load_factor * @as(f64, @floatFromInt(table_cap)));
const table_start = 0;
const table_end = table_start + table_cap * @sizeOf(Id);
const items_start = std.mem.alignForward(usize, table_end, @alignOf(Item));
const items_end = items_start + items_cap * @sizeOf(Item);
const total_size = items_end;
return .{
.cap = items_cap,
.table_cap = table_cap,
.table_mask = table_mask,
.table_start = table_start,
.items_start = items_start,
.total_size = total_size,
};
}
pub const Layout = struct {
cap: Id,
table_cap: Id,
table_mask: Id,
table_start: usize,
items_start: usize,
total_size: usize,
};
pub fn init(base: OffsetBuf, l: Layout, context: Context) Self {
const table = base.member(Id, l.table_start);
const items = base.member(Item, l.items_start);
@memset(table.ptr(base)[0..l.table_cap], 0);
@memset(items.ptr(base)[0..l.cap], .{});
return .{
.table = table,
.items = items,
.layout = l,
.context = context,
};
}
/// Add an item to the set if not present
/// and increment its reference count.
///
/// Returns the item's ID.
///
/// If the set has no more room, then an
/// OutOfMemory error is returned instead.
pub fn add(self: *Self, base: anytype, value: T) error{OutOfMemory}!Id {
const items = self.items.ptr(base);
// Trim dead items from the end of the list.
while (self.next_id > 1 and items[self.next_id - 1].meta.ref == 0) {
self.next_id -= 1;
self.deleteItem(base, self.next_id);
}
// If we still don't have an available ID, we're out of memory.
if (self.next_id >= self.layout.cap) {
return error.OutOfMemory;
}
const id = self.upsert(base, value, self.next_id);
items[id].meta.ref += 1;
if (id == self.next_id) {
self.next_id += 1;
}
return id;
}
/// Add an item to the set if not present
/// and increment its reference count.
/// If possible, use the provided ID.
///
/// Returns the item's ID, or null
/// if the provided ID was used.
///
/// If the set has no more room, then an
/// OutOfMemory error is returned instead.
pub fn addWithId(self: *Self, base: anytype, value: T, id: Id) error{OutOfMemory}!?Id {
const items = self.items.ptr(base);
if (id < self.next_id) {
if (items[id].meta.ref == 0) {
self.deleteItem(base, id);
const added_id = self.upsert(base, value, id);
items[added_id].meta.ref += 1;
return if (added_id == id) null else added_id;
} else if (self.context.eql(value, items[id].value)) {
items[id].meta.ref += 1;
return null;
}
}
return try self.add(base, value);
}
/// Increment an item's reference count by 1.
///
/// Asserts that the item's reference count is greater than 0.
pub fn use(self: *const Self, base: anytype, id: Id) void {
assert(id > 0);
assert(id < self.layout.cap);
const items = self.items.ptr(base);
const item = &items[id];
// If `use` is being called on an item with 0 references, then
// either someone forgot to call it before, released too early
// or lied about releasing. In any case something is wrong and
// shouldn't be allowed.
assert(item.meta.ref > 0);
item.meta.ref += 1;
}
/// Increment an item's reference count by a specified number.
///
/// Asserts that the item's reference count is greater than 0.
pub fn useMultiple(self: *const Self, base: anytype, id: Id, n: RefCountInt) void {
assert(id > 0);
assert(id < self.layout.cap);
const items = self.items.ptr(base);
const item = &items[id];
// If `use` is being called on an item with 0 references, then
// either someone forgot to call it before, released too early
// or lied about releasing. In any case something is wrong and
// shouldn't be allowed.
assert(item.meta.ref > 0);
item.meta.ref += n;
}
/// Get an item by its ID without incrementing its reference count.
///
/// Asserts that the item's reference count is greater than 0.
pub fn get(self: *const Self, base: anytype, id: Id) *T {
assert(id > 0);
assert(id < self.layout.cap);
const items = self.items.ptr(base);
const item = &items[id];
assert(item.meta.ref > 0);
return @ptrCast(&item.value);
}
/// Releases a reference to an item by its ID.
///
/// Asserts that the item's reference count is greater than 0.
pub fn release(self: *Self, base: anytype, id: Id) void {
assert(id > 0);
assert(id < self.layout.cap);
const items = self.items.ptr(base);
const item = &items[id];
assert(item.meta.ref > 0);
item.meta.ref -= 1;
}
/// Release a specified number of references to an item by its ID.
///
/// Asserts that the item's reference count is at least `n`.
pub fn releaseMultiple(self: *Self, base: anytype, id: Id, n: Id) void {
assert(id > 0);
assert(id < self.layout.cap);
const items = self.items.ptr(base);
const item = &items[id];
assert(item.meta.ref >= n);
item.meta.ref -= n;
}
/// Get the ref count for an item by its ID.
pub fn refCount(self: *const Self, base: anytype, id: Id) RefCountInt {
assert(id > 0);
assert(id < self.layout.cap);
const items = self.items.ptr(base);
const item = &items[id];
return item.meta.ref;
}
/// Get the current number of non-dead items in the set.
///
/// NOT DESIGNED TO BE USED OUTSIDE OF TESTING, this is a very slow
/// operation, since it traverses the entire structure to count.
///
/// Additionally, because this is a testing method, it does extra
/// work to verify the integrity of the structure when called.
pub fn count(self: *const Self, base: anytype) usize {
const table = self.table.ptr(base);
const items = self.items.ptr(base);
// The number of items accessible through the table.
var tb_ct: usize = 0;
for (table[0..self.layout.table_cap]) |id| {
if (id != 0) {
const item = items[id];
if (item.meta.ref > 0) {
tb_ct += 1;
}
}
}
// The number of items accessible through the backing store.
// The two counts should always match- it shouldn't be possible
// to have untracked items in the backing store.
var it_ct: usize = 0;
for (items[0..self.layout.cap]) |it| {
if (it.meta.ref > 0) {
it_ct += 1;
}
}
assert(tb_ct == it_ct);
return tb_ct;
}
//================================================//
// The functions below are all internal functions //
// for performing operations on the hash table. //
//================================================//
/// Delete an item, removing any references from
/// the table, and freeing its ID to be re-used.
fn deleteItem(self: *Self, base: anytype, id: Id) void {
const table = self.table.ptr(base);
const items = self.items.ptr(base);
const item = items[id];
if (item.meta.bucket > self.layout.table_cap) {
return;
}
if (table[item.meta.bucket] != id) {
return;
}
if (comptime @hasDecl(Context, "deleted")) {
// Inform the context struct that we're
// deleting the dead item's value for good.
self.context.deleted(item.value);
}
self.psl_stats[item.meta.psl] -= 1;
table[item.meta.bucket] = 0;
items[id] = .{};
var p: Id = item.meta.bucket;
var n: Id = (p + 1) & self.layout.table_mask;
while (table[n] != 0 and items[table[n]].meta.psl > 0) {
items[table[n]].meta.bucket = p;
self.psl_stats[items[table[n]].meta.psl] -= 1;
items[table[n]].meta.psl -= 1;
self.psl_stats[items[table[n]].meta.psl] += 1;
table[p] = table[n];
p = n;
n = (n + 1) & self.layout.table_mask;
}
while (self.max_psl > 0 and self.psl_stats[self.max_psl] == 0) {
self.max_psl -= 1;
}
table[p] = 0;
}
/// Find an item in the table and return its ID.
/// If the item does not exist in the table, null is returned.
fn lookup(self: *Self, base: anytype, value: T) ?Id {
const table = self.table.ptr(base);
const items = self.items.ptr(base);
const hash: u64 = self.context.hash(value);
for (0..self.max_psl + 1) |i| {
const p = (hash + i) & self.layout.table_mask;
const id = table[p];
// Empty bucket, our item cannot have probed to
// any point after this, meaning it's not present.
if (id == 0) {
return null;
}
const item = items[id];
// An item with a shorter probe sequence length would never
// end up in the middle of another sequence, since it would
// be swapped out if inserted before the new sequence, and
// would not be swapped in if inserted afterwards.
//
// As such, our item cannot be present.
if (item.meta.psl < i) {
return null;
}
// We don't bother checking dead items.
if (item.meta.ref == 0) {
continue;
}
// If the item is a part of the same probe sequence,
// we check if it matches the value we're looking for.
if (item.meta.psl == i and
self.context.eql(value, item.value))
{
return id;
}
}
return null;
}
/// Find the provided value in the hash table, or add a new item
/// for it if not present. If a new item is added, `new_id` will
/// be used as the ID.
fn upsert(self: *Self, base: anytype, value: T, new_id: Id) Id {
// If the item already exists, return it.
if (self.lookup(base, value)) |id| {
return id;
}
const table = self.table.ptr(base);
const items = self.items.ptr(base);
// The new item that we'll put in to the table.
var new_item: Item = .{
.value = value,
.meta = .{
.psl = 0,
.ref = 0,
},
};
const hash: u64 = self.context.hash(value);
var held_id: Id = new_id;
var held_item: *Item = &new_item;
var chosen_p: ?Id = null;
var chosen_id: Id = new_id;
for (0..self.layout.table_cap - 1) |i| {
const p: Id = @intCast((hash + i) & self.layout.table_mask);
const id = table[p];
// Empty bucket, put our held item in to it and break.
if (id == 0) {
table[p] = held_id;
held_item.meta.bucket = p;
self.psl_stats[held_item.meta.psl] += 1;
self.max_psl = @max(self.max_psl, held_item.meta.psl);
break;
}
const item = &items[id];
// If there's a dead item then we resurrect it
// for our value so that we can re-use its ID.
if (item.meta.ref == 0) {
if (comptime @hasDecl(Context, "deleted")) {
// Inform the context struct that we're
// deleting the dead item's value for good.
self.context.deleted(item.value);
}
chosen_id = id;
held_item.meta.bucket = p;
self.psl_stats[held_item.meta.psl] += 1;
self.max_psl = @max(self.max_psl, held_item.meta.psl);
// If we're not still holding our new item then we
// need to make sure that we put the re-used ID in
// the right place, where we previously put new_id.
if (chosen_p) |c| {
table[c] = id;
table[p] = held_id;
} else {
// If we're still holding our new item then we
// don't actually have to do anything, because
// the table already has the correct ID here.
}
break;
}
// This item has a lower PSL, swap it out with our held item.
if (item.meta.psl < held_item.meta.psl) {
if (held_id == new_id) {
chosen_p = p;
new_item.meta.bucket = p;
}
table[p] = held_id;
items[held_id].meta.bucket = p;
self.psl_stats[held_item.meta.psl] += 1;
self.max_psl = @max(self.max_psl, held_item.meta.psl);
held_id = id;
held_item = item;
self.psl_stats[item.meta.psl] -= 1;
}
// Advance to the next probe position for our held item.
held_item.meta.psl += 1;
}
items[chosen_id] = new_item;
return chosen_id;
}
};
}

View File

@ -6,8 +6,11 @@ const page = @import("page.zig");
const size = @import("size.zig");
const Offset = size.Offset;
const OffsetBuf = size.OffsetBuf;
const hash_map = @import("hash_map.zig");
const AutoOffsetHashMap = hash_map.AutoOffsetHashMap;
const Wyhash = std.hash.Wyhash;
const autoHash = std.hash.autoHash;
const RefCountedSet = @import("ref_counted_set.zig").RefCountedSet;
/// The unique identifier for a style. This is at most the number of cells
/// that can fit into a terminal page.
@ -43,11 +46,34 @@ pub const Style = struct {
none: void,
palette: u8,
rgb: color.RGB,
/// Formatting to make debug logs easier to read
/// by only including non-default attributes.
pub fn format(
self: Color,
comptime fmt: []const u8,
options: std.fmt.FormatOptions,
writer: anytype,
) !void {
_ = fmt;
_ = options;
switch (self) {
.none => {
_ = try writer.write("Color.none");
},
.palette => |p| {
_ = try writer.print("Color.palette{{ {} }}", .{ p });
},
.rgb => |rgb| {
_ = try writer.print("Color.rgb{{ {}, {}, {} }}", .{ rgb.r, rgb.g, rgb.b });
}
}
}
};
/// True if the style is the default style.
pub fn default(self: Style) bool {
return std.meta.eql(self, .{});
return self.eql(.{});
}
/// True if the style is equal to another style.
@ -133,6 +159,83 @@ pub const Style = struct {
};
}
/// Formatting to make debug logs easier to read
/// by only including non-default attributes.
pub fn format(
self: Style,
comptime fmt: []const u8,
options: std.fmt.FormatOptions,
writer: anytype,
) !void {
_ = fmt;
_ = options;
const dflt: Style = .{};
_ = try writer.write("Style{ ");
var started = false;
inline for (std.meta.fields(Style)) |f| {
if (std.mem.eql(u8, f.name, "flags")) {
if (started) {
_ = try writer.write(", ");
}
_ = try writer.write("flags={ ");
started = false;
inline for (std.meta.fields(@TypeOf(self.flags))) |ff| {
const v = @as(ff.type, @field(self.flags, ff.name));
const d = @as(ff.type, @field(dflt.flags, ff.name));
if (ff.type == bool) {
if (v) {
if (started) {
_ = try writer.write(", ");
}
_ = try writer.print("{s}", .{ff.name});
started = true;
}
} else if (!std.meta.eql(v, d)) {
if (started) {
_ = try writer.write(", ");
}
_ = try writer.print(
"{s}={any}",
.{ ff.name, v },
);
started = true;
}
}
_ = try writer.write(" }");
started = true;
comptime continue;
}
const value = @as(f.type, @field(self, f.name));
const d_val = @as(f.type, @field(dflt, f.name));
if (!std.meta.eql(value, d_val)) {
if (started) {
_ = try writer.write(", ");
}
_ = try writer.print(
"{s}={any}",
.{ f.name, value },
);
started = true;
}
}
_ = try writer.write(" }");
}
pub fn hash(self: *const Style) u64 {
var hasher = Wyhash.init(0);
autoHash(&hasher, self.*);
return hasher.final();
}
test {
// The size of the struct so we can be aware of changes.
const testing = std.testing;
@ -140,170 +243,24 @@ pub const Style = struct {
}
};
/// A set of styles.
///
/// This set is created with some capacity in mind. You can determine
/// the exact memory requirement for a capacity by calling `layout`
/// and checking the total size.
///
/// When the set exceeds capacity, `error.OutOfMemory` is returned
/// from memory-using methods. The caller is responsible for determining
/// a path forward.
///
/// The general idea behind this structure is that it is optimized for
/// the scenario common in terminals where there aren't many unique
/// styles, and many cells are usually drawn with a single style before
/// changing styles.
///
/// Callers should call `upsert` when a new style is set. This will
/// return a stable pointer to metadata. You should use this metadata
/// to keep a ref count of the style usage. When it falls to zero you
/// can remove it.
pub const Set = struct {
pub const base_align = @max(MetadataMap.base_align, IdMap.base_align);
/// The mapping of a style to associated metadata. This is
/// the map that contains the actual style definitions
/// (in the form of the key).
styles: MetadataMap,
/// The mapping from ID to style.
id_map: IdMap,
/// The next ID to use for a style that isn't in the set.
/// When this overflows we'll begin returning an IdOverflow
/// error and the caller must manually compact the style
/// set.
///
/// Id zero is reserved and always is the default style. The
/// default style isn't present in the map, its dependent on
/// the terminal configuration.
next_id: Id = 1,
/// Maps a style definition to metadata about that style.
const MetadataMap = AutoOffsetHashMap(Style, Metadata);
/// Maps the unique style ID to the concrete style definition.
const IdMap = AutoOffsetHashMap(Id, Offset(Style));
/// Returns the memory layout for the given base offset and
/// desired capacity. The layout can be used by the caller to
/// determine how much memory to allocate, and the layout must
/// be used to initialize the set so that the set knows all
/// the offsets for the various buffers.
pub fn layout(cap: usize) Layout {
const md_layout = MetadataMap.layout(@intCast(cap));
const md_start = 0;
const md_end = md_start + md_layout.total_size;
const id_layout = IdMap.layout(@intCast(cap));
const id_start = std.mem.alignForward(usize, md_end, IdMap.base_align);
const id_end = id_start + id_layout.total_size;
const total_size = id_end;
return .{
.md_start = md_start,
.md_layout = md_layout,
.id_start = id_start,
.id_layout = id_layout,
.total_size = total_size,
};
pub const StyleSetContext = struct {
pub fn hash(self: *StyleSetContext, style: Style) u64 {
_ = self;
return style.hash();
}
pub const Layout = struct {
md_start: usize,
md_layout: MetadataMap.Layout,
id_start: usize,
id_layout: IdMap.Layout,
total_size: usize,
};
pub fn init(base: OffsetBuf, l: Layout) Set {
const styles_buf = base.add(l.md_start);
const id_buf = base.add(l.id_start);
return .{
.styles = MetadataMap.init(styles_buf, l.md_layout),
.id_map = IdMap.init(id_buf, l.id_layout),
};
}
/// Possible errors for upsert.
pub const UpsertError = error{
/// No more space in the backing buffer. Remove styles or
/// grow and reinitialize.
OutOfMemory,
/// No more available IDs. Perform a garbage collection
/// operation to compact ID space.
Overflow,
};
/// Upsert a style into the set and return a pointer to the metadata
/// for that style. The pointer is valid for the lifetime of the set
/// so long as the style is not removed.
///
/// The ref count for new styles is initialized to zero and
/// for existing styles remains unmodified.
pub fn upsert(self: *Set, base: anytype, style: Style) UpsertError!*Metadata {
// If we already have the style in the map, this is fast.
var map = self.styles.map(base);
const gop = try map.getOrPut(style);
if (gop.found_existing) return gop.value_ptr;
// New style, we need to setup all the metadata. First thing,
// let's get the ID we'll assign, because if we're out of space
// we need to fail early.
errdefer map.removeByPtr(gop.key_ptr);
const id = self.next_id;
self.next_id = try std.math.add(Id, self.next_id, 1);
errdefer self.next_id -= 1;
gop.value_ptr.* = .{ .id = id };
// Setup our ID mapping
var id_map = self.id_map.map(base);
const id_gop = try id_map.getOrPut(id);
errdefer id_map.removeByPtr(id_gop.key_ptr);
assert(!id_gop.found_existing);
id_gop.value_ptr.* = size.getOffset(Style, base, gop.key_ptr);
return gop.value_ptr;
}
/// Lookup a style by its unique identifier.
pub fn lookupId(self: *const Set, base: anytype, id: Id) ?*Style {
const id_map = self.id_map.map(base);
const offset = id_map.get(id) orelse return null;
return @ptrCast(offset.ptr(base));
}
/// Remove a style by its id.
pub fn remove(self: *Set, base: anytype, id: Id) void {
// Lookup by ID, if it doesn't exist then we return. We use
// getEntry so that we can make removal faster later by using
// the entry's key pointer.
var id_map = self.id_map.map(base);
const id_entry = id_map.getEntry(id) orelse return;
var style_map = self.styles.map(base);
const style_ptr: *Style = @ptrCast(id_entry.value_ptr.ptr(base));
id_map.removeByPtr(id_entry.key_ptr);
style_map.removeByPtr(style_ptr);
}
/// Return the number of styles currently in the set.
pub fn count(self: *const Set, base: anytype) usize {
return self.id_map.map(base).count();
pub fn eql(self: *StyleSetContext, a: Style, b: Style) bool {
_ = self;
return a.eql(b);
}
};
/// Metadata about a style. This is used to track the reference count
/// and the unique identifier for a style. The unique identifier is used
/// to track the style in the full style map.
pub const Metadata = struct {
ref: size.CellCountInt = 0,
id: Id = 0,
};
pub const Set = RefCountedSet(
Style,
Id,
size.CellCountInt,
StyleSetContext,
);
test "Set basic usage" {
const testing = std.testing;
@ -313,29 +270,49 @@ test "Set basic usage" {
defer alloc.free(buf);
const style: Style = .{ .flags = .{ .bold = true } };
const style2: Style = .{ .flags = .{ .italic = true } };
var set = Set.init(OffsetBuf.init(buf), layout);
var set = Set.init(OffsetBuf.init(buf), layout, StyleSetContext{});
// Upsert
const meta = try set.upsert(buf, style);
try testing.expect(meta.id > 0);
// Add style
const id = try set.add(buf, style);
try testing.expect(id > 0);
// Second upsert should return the same metadata.
// Second add should return the same metadata.
{
const meta2 = try set.upsert(buf, style);
try testing.expectEqual(meta.id, meta2.id);
const id2 = try set.add(buf, style);
try testing.expectEqual(id, id2);
}
// Look it up
{
const v = set.lookupId(buf, meta.id).?;
const v = set.get(buf, id);
try testing.expect(v.flags.bold);
const v2 = set.lookupId(buf, meta.id).?;
const v2 = set.get(buf, id);
try testing.expectEqual(v, v2);
}
// Removal
set.remove(buf, meta.id);
try testing.expect(set.lookupId(buf, meta.id) == null);
// Add a second style
const id2 = try set.add(buf, style2);
// Look it up
{
const v = set.get(buf, id2);
try testing.expect(v.flags.italic);
}
// Ref count
try testing.expect(set.refCount(buf, id) == 2);
try testing.expect(set.refCount(buf, id2) == 1);
// Release
set.release(buf, id);
try testing.expect(set.refCount(buf, id) == 1);
set.release(buf, id2);
try testing.expect(set.refCount(buf, id2) == 0);
// We added the first one twice, so
set.release(buf, id);
try testing.expect(set.refCount(buf, id) == 0);
}