perf: replace dirty bitset with a flag on each row

This is much faster for most operations since the row is often already
loaded when we have to mark it as dirty.
pull/9645/head
Qwerasd 2025-11-18 12:10:47 -07:00
parent 212598ed66
commit 30472c0077
5 changed files with 74 additions and 138 deletions

View File

@ -1191,12 +1191,13 @@ pub fn Renderer(comptime GraphicsAPI: type) type {
{ {
var it = state.terminal.screens.active.pages.pageIterator( var it = state.terminal.screens.active.pages.pageIterator(
.right_down, .right_down,
.{ .screen = .{} }, .{ .viewport = .{} },
null, null,
); );
while (it.next()) |chunk| { while (it.next()) |chunk| {
var dirty_set = chunk.node.data.dirtyBitSet(); for (chunk.rows()) |*row| {
dirty_set.unsetAll(); row.dirty = false;
}
} }
} }

View File

@ -2683,10 +2683,9 @@ pub fn eraseRow(
// If we have a pinned viewport, we need to adjust for active area. // If we have a pinned viewport, we need to adjust for active area.
self.fixupViewport(1); self.fixupViewport(1);
{ // Set all the rows as dirty in this page, starting at the erased row.
// Set all the rows as dirty in this page for (rows[pn.y..node.data.size.rows]) |*row| {
var dirty = node.data.dirtyBitSet(); row.dirty = true;
dirty.setRangeValue(.{ .start = pn.y, .end = node.data.size.rows }, true);
} }
// We iterate through all of the following pages in order to move their // We iterate through all of the following pages in order to move their
@ -2721,8 +2720,9 @@ pub fn eraseRow(
fastmem.rotateOnce(Row, rows[0..node.data.size.rows]); fastmem.rotateOnce(Row, rows[0..node.data.size.rows]);
// Set all the rows as dirty // Set all the rows as dirty
var dirty = node.data.dirtyBitSet(); for (rows[0..node.data.size.rows]) |*row| {
dirty.setRangeValue(.{ .start = 0, .end = node.data.size.rows }, true); row.dirty = true;
}
// Our tracked pins for this page need to be updated. // Our tracked pins for this page need to be updated.
// If the pin is in row 0 that means the corresponding row has // If the pin is in row 0 that means the corresponding row has
@ -2774,8 +2774,9 @@ pub fn eraseRowBounded(
fastmem.rotateOnce(Row, rows[pn.y..][0 .. limit + 1]); fastmem.rotateOnce(Row, rows[pn.y..][0 .. limit + 1]);
// Set all the rows as dirty // Set all the rows as dirty
var dirty = node.data.dirtyBitSet(); for (rows[pn.y..][0..limit]) |*row| {
dirty.setRangeValue(.{ .start = pn.y, .end = pn.y + limit }, true); row.dirty = true;
}
// If our viewport is a pin and our pin is within the erased // If our viewport is a pin and our pin is within the erased
// region we need to maybe shift our cache up. We do this here instead // region we need to maybe shift our cache up. We do this here instead
@ -2813,9 +2814,8 @@ pub fn eraseRowBounded(
fastmem.rotateOnce(Row, rows[pn.y..node.data.size.rows]); fastmem.rotateOnce(Row, rows[pn.y..node.data.size.rows]);
// All the rows in the page are dirty below the erased row. // All the rows in the page are dirty below the erased row.
{ for (rows[pn.y..node.data.size.rows]) |*row| {
var dirty = node.data.dirtyBitSet(); row.dirty = true;
dirty.setRangeValue(.{ .start = pn.y, .end = node.data.size.rows }, true);
} }
// We need to keep track of how many rows we've shifted so that we can // We need to keep track of how many rows we've shifted so that we can
@ -2872,8 +2872,9 @@ pub fn eraseRowBounded(
fastmem.rotateOnce(Row, rows[0 .. shifted_limit + 1]); fastmem.rotateOnce(Row, rows[0 .. shifted_limit + 1]);
// Set all the rows as dirty // Set all the rows as dirty
var dirty = node.data.dirtyBitSet(); for (rows[0..shifted_limit]) |*row| {
dirty.setRangeValue(.{ .start = 0, .end = shifted_limit }, true); row.dirty = true;
}
// See the other places we do something similar in this function // See the other places we do something similar in this function
// for a detailed explanation. // for a detailed explanation.
@ -2904,8 +2905,9 @@ pub fn eraseRowBounded(
fastmem.rotateOnce(Row, rows[0..node.data.size.rows]); fastmem.rotateOnce(Row, rows[0..node.data.size.rows]);
// Set all the rows as dirty // Set all the rows as dirty
var dirty = node.data.dirtyBitSet(); for (rows[0..node.data.size.rows]) |*row| {
dirty.setRangeValue(.{ .start = 0, .end = node.data.size.rows }, true); row.dirty = true;
}
// Account for the rows shifted in this node. // Account for the rows shifted in this node.
shifted += node.data.size.rows; shifted += node.data.size.rows;
@ -2993,6 +2995,9 @@ pub fn eraseRows(
const old_dst = dst.*; const old_dst = dst.*;
dst.* = src.*; dst.* = src.*;
src.* = old_dst; src.* = old_dst;
// Mark the moved row as dirty.
dst.dirty = true;
} }
// Clear our remaining cells that we didn't shift or swapped // Clear our remaining cells that we didn't shift or swapped
@ -3022,10 +3027,6 @@ pub fn eraseRows(
// Our new size is the amount we scrolled // Our new size is the amount we scrolled
chunk.node.data.size.rows = @intCast(scroll_amount); chunk.node.data.size.rows = @intCast(scroll_amount);
erased += chunk.end; erased += chunk.end;
// Set all the rows as dirty
var dirty = chunk.node.data.dirtyBitSet();
dirty.setRangeValue(.{ .start = 0, .end = chunk.node.data.size.rows }, true);
} }
// Update our total row count // Update our total row count
@ -3881,10 +3882,10 @@ fn growRows(self: *PageList, n: usize) !void {
/// traverses the entire list of pages. This is used for testing/debugging. /// traverses the entire list of pages. This is used for testing/debugging.
pub fn clearDirty(self: *PageList) void { pub fn clearDirty(self: *PageList) void {
var page = self.pages.first; var page = self.pages.first;
while (page) |p| { while (page) |p| : (page = p.next) {
var set = p.data.dirtyBitSet(); for (p.data.rows.ptr(p.data.memory)[0..p.data.size.rows]) |*row| {
set.unsetAll(); row.dirty = false;
page = p.next; }
} }
} }
@ -3965,13 +3966,12 @@ pub const Pin = struct {
/// Check if this pin is dirty. /// Check if this pin is dirty.
pub inline fn isDirty(self: Pin) bool { pub inline fn isDirty(self: Pin) bool {
return self.node.data.isRowDirty(self.y); return self.rowAndCell().row.dirty;
} }
/// Mark this pin location as dirty. /// Mark this pin location as dirty.
pub inline fn markDirty(self: Pin) void { pub inline fn markDirty(self: Pin) void {
var set = self.node.data.dirtyBitSet(); self.rowAndCell().row.dirty = true;
set.set(self.y);
} }
/// Returns true if the row of this pin should never have its background /// Returns true if the row of this pin should never have its background
@ -4375,7 +4375,7 @@ const Cell = struct {
/// This is not very performant this is primarily used for assertions /// This is not very performant this is primarily used for assertions
/// and testing. /// and testing.
pub fn isDirty(self: Cell) bool { pub fn isDirty(self: Cell) bool {
return self.node.data.isRowDirty(self.row_idx); return self.row.dirty;
} }
/// Get the cell style. /// Get the cell style.

View File

@ -786,9 +786,7 @@ pub fn cursorDownScroll(self: *Screen) !void {
self.cursor.page_row, self.cursor.page_row,
page.getCells(self.cursor.page_row), page.getCells(self.cursor.page_row),
); );
self.cursorMarkDirty();
var dirty = page.dirtyBitSet();
dirty.set(0);
} else { } else {
// The call to `eraseRow` will move the tracked cursor pin up by one // The call to `eraseRow` will move the tracked cursor pin up by one
// row, but we don't actually want that, so we keep the old pin and // row, but we don't actually want that, so we keep the old pin and
@ -880,7 +878,7 @@ pub fn cursorScrollAbove(self: *Screen) !void {
// the cursor always changes page rows inside this function, and // the cursor always changes page rows inside this function, and
// when that happens it can mean the text in the old row needs to // when that happens it can mean the text in the old row needs to
// be re-shaped because the cursor splits runs to break ligatures. // be re-shaped because the cursor splits runs to break ligatures.
self.cursor.page_pin.markDirty(); self.cursorMarkDirty();
// If the cursor is on the bottom of the screen, its faster to use // If the cursor is on the bottom of the screen, its faster to use
// our specialized function for that case. // our specialized function for that case.
@ -926,8 +924,9 @@ pub fn cursorScrollAbove(self: *Screen) !void {
fastmem.rotateOnceR(Row, rows[pin.y..page.size.rows]); fastmem.rotateOnceR(Row, rows[pin.y..page.size.rows]);
// Mark all our rotated rows as dirty. // Mark all our rotated rows as dirty.
var dirty = page.dirtyBitSet(); for (rows[pin.y..page.size.rows]) |*row| {
dirty.setRangeValue(.{ .start = pin.y, .end = page.size.rows }, true); row.dirty = true;
}
// Setup our cursor caches after the rotation so it points to the // Setup our cursor caches after the rotation so it points to the
// correct data // correct data
@ -993,8 +992,9 @@ fn cursorScrollAboveRotate(self: *Screen) !void {
); );
// All rows we rotated are dirty // All rows we rotated are dirty
var dirty = cur_page.dirtyBitSet(); for (cur_rows[0..cur_page.size.rows]) |*row| {
dirty.setRangeValue(.{ .start = 0, .end = cur_page.size.rows }, true); row.dirty = true;
}
} }
// Our current is our cursor page, we need to rotate down from // Our current is our cursor page, we need to rotate down from
@ -1010,11 +1010,9 @@ fn cursorScrollAboveRotate(self: *Screen) !void {
); );
// Set all the rows we rotated and cleared dirty // Set all the rows we rotated and cleared dirty
var dirty = cur_page.dirtyBitSet(); for (cur_rows[self.cursor.page_pin.y..cur_page.size.rows]) |*row| {
dirty.setRangeValue( row.dirty = true;
.{ .start = self.cursor.page_pin.y, .end = cur_page.size.rows }, }
true,
);
// Setup cursor cache data after all the rotations so our // Setup cursor cache data after all the rotations so our
// row is valid. // row is valid.
@ -1105,7 +1103,7 @@ inline fn cursorChangePin(self: *Screen, new: Pin) void {
// we must mark the old and new page dirty. We do this as long // we must mark the old and new page dirty. We do this as long
// as the pins are not equal // as the pins are not equal
if (!self.cursor.page_pin.eql(new)) { if (!self.cursor.page_pin.eql(new)) {
self.cursor.page_pin.markDirty(); self.cursorMarkDirty();
new.markDirty(); new.markDirty();
} }
@ -1175,7 +1173,7 @@ inline fn cursorChangePin(self: *Screen, new: Pin) void {
/// Mark the cursor position as dirty. /// Mark the cursor position as dirty.
/// TODO: test /// TODO: test
pub inline fn cursorMarkDirty(self: *Screen) void { pub inline fn cursorMarkDirty(self: *Screen) void {
self.cursor.page_pin.markDirty(); self.cursor.page_row.dirty = true;
} }
/// Reset the cursor row's soft-wrap state and the cursor's pending wrap. /// Reset the cursor row's soft-wrap state and the cursor's pending wrap.
@ -1303,10 +1301,6 @@ pub fn clearRows(
var it = self.pages.pageIterator(.right_down, tl, bl); var it = self.pages.pageIterator(.right_down, tl, bl);
while (it.next()) |chunk| { while (it.next()) |chunk| {
// Mark everything in this chunk as dirty
var dirty = chunk.node.data.dirtyBitSet();
dirty.setRangeValue(.{ .start = chunk.start, .end = chunk.end }, true);
for (chunk.rows()) |*row| { for (chunk.rows()) |*row| {
const cells_offset = row.cells; const cells_offset = row.cells;
const cells_multi: [*]Cell = row.cells.ptr(chunk.node.data.memory); const cells_multi: [*]Cell = row.cells.ptr(chunk.node.data.memory);
@ -1322,6 +1316,8 @@ pub fn clearRows(
self.clearCells(&chunk.node.data, row, cells); self.clearCells(&chunk.node.data, row, cells);
row.* = .{ .cells = cells_offset }; row.* = .{ .cells = cells_offset };
} }
row.dirty = true;
} }
} }
} }

View File

@ -1672,6 +1672,9 @@ pub fn insertLines(self: *Terminal, count: usize) void {
dst_row.* = src_row.*; dst_row.* = src_row.*;
src_row.* = dst; src_row.* = dst;
// Make sure the row is marked as dirty though.
dst_row.dirty = true;
// Ensure what we did didn't corrupt the page // Ensure what we did didn't corrupt the page
cur_p.node.data.assertIntegrity(); cur_p.node.data.assertIntegrity();
} else { } else {
@ -1867,6 +1870,9 @@ pub fn deleteLines(self: *Terminal, count: usize) void {
dst_row.* = src_row.*; dst_row.* = src_row.*;
src_row.* = dst; src_row.* = dst;
// Make sure the row is marked as dirty though.
dst_row.dirty = true;
// Ensure what we did didn't corrupt the page // Ensure what we did didn't corrupt the page
cur_p.node.data.assertIntegrity(); cur_p.node.data.assertIntegrity();
} else { } else {

View File

@ -136,44 +136,6 @@ pub const Page = struct {
hyperlink_map: hyperlink.Map, hyperlink_map: hyperlink.Map,
hyperlink_set: hyperlink.Set, hyperlink_set: hyperlink.Set,
/// The offset to the first mask of dirty bits in the page.
///
/// The dirty bits is a contiguous array of usize where each bit represents
/// a row in the page, in order. If the bit is set, then the row is dirty
/// and requires a redraw. Dirty status is only ever meant to convey that
/// a cell has changed visually. A cell which changes in a way that doesn't
/// affect the visual representation may not be marked as dirty.
///
/// Dirty tracking may have false positives but should never have false
/// negatives. A false negative would result in a visual artifact on the
/// screen.
///
/// Dirty bits are only ever unset by consumers of a page. The page
/// structure itself does not unset dirty bits since the page does not
/// know when a cell has been redrawn.
///
/// As implementation background: it may seem that dirty bits should be
/// stored elsewhere and not on the page itself, because the only data
/// that could possibly change is in the active area of a terminal
/// historically and that area is small compared to the typical scrollback.
/// My original thinking was to put the dirty bits on Screen instead and
/// have them only track the active area. However, I decided to put them
/// into the page directly for a few reasons:
///
/// 1. It's simpler. The page is a self-contained unit and it's nice
/// to have all the data for a page in one place.
///
/// 2. It's cheap. Even a very large page might have 1000 rows and
/// that's only ~128 bytes of 64-bit integers to track all the dirty
/// bits. Compared to the hundreds of kilobytes a typical page
/// consumes, this is nothing.
///
/// 3. It's more flexible. If we ever want to implement new terminal
/// features that allow non-active area to be dirty, we can do that
/// with minimal dirty-tracking work.
///
dirty: Offset(usize),
/// The current dimensions of the page. The capacity may be larger /// The current dimensions of the page. The capacity may be larger
/// than this. This allows us to allocate a larger page than necessary /// than this. This allows us to allocate a larger page than necessary
/// and also to resize a page smaller without reallocating. /// and also to resize a page smaller without reallocating.
@ -238,7 +200,6 @@ pub const Page = struct {
.memory = @alignCast(buf.start()[0..l.total_size]), .memory = @alignCast(buf.start()[0..l.total_size]),
.rows = rows, .rows = rows,
.cells = cells, .cells = cells,
.dirty = buf.member(usize, l.dirty_start),
.styles = StyleSet.init( .styles = StyleSet.init(
buf.add(l.styles_start), buf.add(l.styles_start),
l.styles_layout, l.styles_layout,
@ -686,11 +647,8 @@ pub const Page = struct {
const other_rows = other.rows.ptr(other.memory)[y_start..y_end]; const other_rows = other.rows.ptr(other.memory)[y_start..y_end];
const rows = self.rows.ptr(self.memory)[0 .. y_end - y_start]; const rows = self.rows.ptr(self.memory)[0 .. y_end - y_start];
const other_dirty_set = other.dirtyBitSet(); for (rows, other_rows) |*dst_row, *src_row| {
var dirty_set = self.dirtyBitSet();
for (rows, 0.., other_rows, y_start..) |*dst_row, dst_y, *src_row, src_y| {
try self.cloneRowFrom(other, dst_row, src_row); try self.cloneRowFrom(other, dst_row, src_row);
if (other_dirty_set.isSet(src_y)) dirty_set.set(dst_y);
} }
// We should remain consistent // We should remain consistent
@ -752,6 +710,7 @@ pub const Page = struct {
copy.grapheme = dst_row.grapheme; copy.grapheme = dst_row.grapheme;
copy.hyperlink = dst_row.hyperlink; copy.hyperlink = dst_row.hyperlink;
copy.styled = dst_row.styled; copy.styled = dst_row.styled;
copy.dirty |= dst_row.dirty;
} }
// Our cell offset remains the same // Our cell offset remains the same
@ -1501,30 +1460,12 @@ pub const Page = struct {
return self.grapheme_map.map(self.memory).capacity(); return self.grapheme_map.map(self.memory).capacity();
} }
/// Returns the bitset for the dirty bits on this page. /// Returns true if this page is dirty at all.
///
/// The returned value is a DynamicBitSetUnmanaged but it is NOT
/// actually dynamic; do NOT call resize on this. It is safe to
/// read and write but do not resize it.
pub inline fn dirtyBitSet(self: *const Page) std.DynamicBitSetUnmanaged {
return .{
.bit_length = self.capacity.rows,
.masks = self.dirty.ptr(self.memory),
};
}
/// Returns true if the given row is dirty. This is NOT very
/// efficient if you're checking many rows and you should use
/// dirtyBitSet directly instead.
pub inline fn isRowDirty(self: *const Page, y: usize) bool {
return self.dirtyBitSet().isSet(y);
}
/// Returns true if this page is dirty at all. If you plan on
/// checking any additional rows, you should use dirtyBitSet and
/// check this on your own so you have the set available.
pub inline fn isDirty(self: *const Page) bool { pub inline fn isDirty(self: *const Page) bool {
return self.dirtyBitSet().findFirstSet() != null; for (self.rows.ptr(self.memory)[0..self.size.rows]) |row| {
if (row.dirty) return true;
}
return false;
} }
pub const Layout = struct { pub const Layout = struct {
@ -1533,8 +1474,6 @@ pub const Page = struct {
rows_size: usize, rows_size: usize,
cells_start: usize, cells_start: usize,
cells_size: usize, cells_size: usize,
dirty_start: usize,
dirty_size: usize,
styles_start: usize, styles_start: usize,
styles_layout: StyleSet.Layout, styles_layout: StyleSet.Layout,
grapheme_alloc_start: usize, grapheme_alloc_start: usize,
@ -1561,19 +1500,8 @@ pub const Page = struct {
const cells_start = alignForward(usize, rows_end, @alignOf(Cell)); const cells_start = alignForward(usize, rows_end, @alignOf(Cell));
const cells_end = cells_start + (cells_count * @sizeOf(Cell)); const cells_end = cells_start + (cells_count * @sizeOf(Cell));
// The division below cannot fail because our row count cannot
// exceed the maximum value of usize.
const dirty_bit_length: usize = rows_count;
const dirty_usize_length: usize = std.math.divCeil(
usize,
dirty_bit_length,
@bitSizeOf(usize),
) catch unreachable;
const dirty_start = alignForward(usize, cells_end, @alignOf(usize));
const dirty_end: usize = dirty_start + (dirty_usize_length * @sizeOf(usize));
const styles_layout: StyleSet.Layout = .init(cap.styles); const styles_layout: StyleSet.Layout = .init(cap.styles);
const styles_start = alignForward(usize, dirty_end, StyleSet.base_align.toByteUnits()); const styles_start = alignForward(usize, cells_end, StyleSet.base_align.toByteUnits());
const styles_end = styles_start + styles_layout.total_size; const styles_end = styles_start + styles_layout.total_size;
const grapheme_alloc_layout = GraphemeAlloc.layout(cap.grapheme_bytes); const grapheme_alloc_layout = GraphemeAlloc.layout(cap.grapheme_bytes);
@ -1614,8 +1542,6 @@ pub const Page = struct {
.rows_size = rows_end - rows_start, .rows_size = rows_end - rows_start,
.cells_start = cells_start, .cells_start = cells_start,
.cells_size = cells_end - cells_start, .cells_size = cells_end - cells_start,
.dirty_start = dirty_start,
.dirty_size = dirty_end - dirty_start,
.styles_start = styles_start, .styles_start = styles_start,
.styles_layout = styles_layout, .styles_layout = styles_layout,
.grapheme_alloc_start = grapheme_alloc_start, .grapheme_alloc_start = grapheme_alloc_start,
@ -1707,11 +1633,9 @@ pub const Capacity = struct {
// The size per row is: // The size per row is:
// - The row metadata itself // - The row metadata itself
// - The cells per row (n=cols) // - The cells per row (n=cols)
// - 1 bit for dirty tracking
const bits_per_row: usize = size: { const bits_per_row: usize = size: {
var bits: usize = @bitSizeOf(Row); // Row metadata var bits: usize = @bitSizeOf(Row); // Row metadata
bits += @bitSizeOf(Cell) * @as(usize, @intCast(cols)); // Cells (n=cols) bits += @bitSizeOf(Cell) * @as(usize, @intCast(cols)); // Cells (n=cols)
bits += 1; // The dirty bit
break :size bits; break :size bits;
}; };
const available_bits: usize = styles_start * 8; const available_bits: usize = styles_start * 8;
@ -1775,7 +1699,20 @@ pub const Row = packed struct(u64) {
// everything throughout the same. // everything throughout the same.
kitty_virtual_placeholder: bool = false, kitty_virtual_placeholder: bool = false,
_padding: u23 = 0, /// True if this row is dirty and requires a redraw. This is set to true
/// by any operation that modifies the row's contents or position, and
/// consumers of the page are expected to clear it when they redraw.
///
/// Dirty status is only ever meant to convey that one or more cells in
/// the row have changed visually. A cell which changes in a way that
/// doesn't affect the visual representation may not be marked as dirty.
///
/// Dirty tracking may have false positives but should never have false
/// negatives. A false negative would result in a visual artifact on the
/// screen.
dirty: bool = false,
_padding: u22 = 0,
/// Semantic prompt type. /// Semantic prompt type.
pub const SemanticPrompt = enum(u3) { pub const SemanticPrompt = enum(u3) {
@ -2079,10 +2016,6 @@ test "Page init" {
.styles = 32, .styles = 32,
}); });
defer page.deinit(); defer page.deinit();
// Dirty set should be empty
const dirty = page.dirtyBitSet();
try std.testing.expectEqual(@as(usize, 0), dirty.count());
} }
test "Page read and write cells" { test "Page read and write cells" {