mirror of
https://github.com/ghostty-org/ghostty.git
synced 2026-07-11 11:49:40 +00:00
terminal: render state update optimizations (~2.7x to ~11x less lock hold)
This optimizes `RenderState.update`, the per-frame call that snapshots terminal state for the renderer and is the main reason the renderer thread holds the terminal lock. Lock hold time is reduced ~2.7x to ~11x depending on the frame. ## The changes 1. iterate page chunks instead of rows in `update` 2. classify cells with masked vector compares. 3. split the update into `beginUpdate`/`endUpdate` phases. There's a lot to be gained by accumulating data with the lock held and then processing it out of the lock. 4. generalize the masked-compare scans into `page.Mask`. This is just a really common pattern we're doing now and it yields a ton of great value. Its error prone so lets make it a tested helper. ## Benchmarks Measured with the new `ghostty-bench +screen-clone` modes (`render`, `render-locked`, `render-clean`, `render-partial`), 120x80 terminal, M4 Max, macOS 26, ReleaseFast, hyperfine means of 10+ runs, per-update times derived from fixed-count update loops with process startup subtracted. "Lock held" is the time the terminal lock must be held per update; "before" held the lock for the entire update. | scenario | before (lock held) | after (lock held) | after (total) | lock change | |----------|--------------------|-------------------|---------------|-------------| | clean frame (nothing dirty) | 202 ns | 19 ns | 19 ns | 10.9x | | partial frame (1 dirty row) | 290 ns | 54 ns | 54 ns | 5.4x | | full rebuild, lightly styled | 6.9 µs | 2.5 µs | 3.0 µs | 2.7x | | full rebuild, fully styled | 9.3 µs | 2.4 µs | 8.0 µs | 3.8x | | full rebuild, fully styled, 250x150 | 49.9 µs | 9.4 µs | 31.6 µs | 5.3x | | full rebuild, plain text | 1.9 µs | 1.9 µs | 1.9 µs | 1.0x (memcpy floor) | The clean and partial cases are the steady-state frame costs (cursor blink, mouse movement, typing). The full-rebuild cases are the contended ones: colored scrolling output (build logs, htop, vim) moves the viewport pin every frame, forcing a full rebuild exactly when the IO thread is busiest, so that row of the table is where lock contention actually hurts. Plain text was already at the memcpy floor and is unchanged. ## LLM Notes This work was driven by Fable 5: benchmarks, optimizations, the property test, and the measurements above. I reviewed every line, simplified the design in a few places (API naming, the Mask helper shape), and re-ran the verifications myself.
This commit is contained in:
@@ -39,6 +39,18 @@ extern "C" {
|
||||
* 2. Update it from a terminal instance whenever you need.
|
||||
* 3. Read from the render state to get the data needed to draw your frame.
|
||||
*
|
||||
* ## Two-Phase Updates
|
||||
*
|
||||
* For callers that synchronize terminal access (e.g. a renderer thread
|
||||
* sharing a lock with an IO thread), the update can be split into two
|
||||
* phases to minimize the time the terminal must be held exclusively:
|
||||
* ghostty_render_state_begin_update requires terminal access, while
|
||||
* ghostty_render_state_end_update completes any deferred work using only
|
||||
* memory owned by the render state. A typical renderer would lock, begin
|
||||
* the update, unlock, and then end the update while the IO thread is free
|
||||
* to continue modifying the terminal. ghostty_render_state_update is a
|
||||
* convenience that performs both phases in one call.
|
||||
*
|
||||
* ## Dirty Tracking
|
||||
*
|
||||
* Dirty tracking is a key feature of the render state that allows renderers
|
||||
@@ -331,6 +343,12 @@ GHOSTTY_API void ghostty_render_state_free(GhosttyRenderState state);
|
||||
* This consumes terminal/screen dirty state in the same way as the internal
|
||||
* render state update path.
|
||||
*
|
||||
* This is a convenience function that performs a full update in one call,
|
||||
* equivalent to ghostty_render_state_begin_update immediately followed by
|
||||
* ghostty_render_state_end_update. Callers that hold a lock over the
|
||||
* terminal state should prefer calling the two phases directly so that the
|
||||
* lock is only held for the begin phase.
|
||||
*
|
||||
* @param state The render state handle (NULL returns GHOSTTY_INVALID_VALUE)
|
||||
* @param terminal The terminal handle to read from (NULL returns GHOSTTY_INVALID_VALUE)
|
||||
* @return GHOSTTY_SUCCESS on success, GHOSTTY_INVALID_VALUE if `state` or
|
||||
@@ -342,6 +360,54 @@ GHOSTTY_API void ghostty_render_state_free(GhosttyRenderState state);
|
||||
GHOSTTY_API GhosttyResult ghostty_render_state_update(GhosttyRenderState state,
|
||||
GhosttyTerminal terminal);
|
||||
|
||||
/**
|
||||
* Begin an update of a render state instance from a terminal.
|
||||
*
|
||||
* Every begin must be completed with a ghostty_render_state_end_update call
|
||||
* before the render state is read.
|
||||
*
|
||||
* This two-phase structure exists for callers that synchronize access to the
|
||||
* terminal state (e.g. with a lock shared with an IO thread): only this
|
||||
* function requires terminal access, so a caller can hold its lock for this
|
||||
* call only and then call ghostty_render_state_end_update after releasing
|
||||
* it. The end phase exclusively reads and writes memory owned by the render
|
||||
* state, so it is safe to call while the terminal is being modified.
|
||||
*
|
||||
* Work that doesn't require terminal access may be deferred to the end phase
|
||||
* to keep this call (and therefore lock hold time) as short as possible.
|
||||
* Callers must treat the render state as incomplete until
|
||||
* ghostty_render_state_end_update is called.
|
||||
*
|
||||
* This consumes terminal/screen dirty state in the same way as the internal
|
||||
* render state update path.
|
||||
*
|
||||
* @param state The render state handle (NULL returns GHOSTTY_INVALID_VALUE)
|
||||
* @param terminal The terminal handle to read from (NULL returns GHOSTTY_INVALID_VALUE)
|
||||
* @return GHOSTTY_SUCCESS on success, GHOSTTY_INVALID_VALUE if `state` or
|
||||
* `terminal` is NULL, GHOSTTY_OUT_OF_MEMORY if updating the state requires
|
||||
* allocation and that allocation fails
|
||||
*
|
||||
* @ingroup render
|
||||
*/
|
||||
GHOSTTY_API GhosttyResult ghostty_render_state_begin_update(GhosttyRenderState state,
|
||||
GhosttyTerminal terminal);
|
||||
|
||||
/**
|
||||
* Complete a prior ghostty_render_state_begin_update call by performing any
|
||||
* deferred work.
|
||||
*
|
||||
* This only reads and writes memory owned by the render state, so it is safe
|
||||
* to call while the terminal is being modified (no terminal synchronization
|
||||
* is required). Calling this without a prior begin is a safe no-op.
|
||||
*
|
||||
* @param state The render state handle (NULL returns GHOSTTY_INVALID_VALUE)
|
||||
* @return GHOSTTY_SUCCESS on success, GHOSTTY_INVALID_VALUE if `state` is
|
||||
* NULL
|
||||
*
|
||||
* @ingroup render
|
||||
*/
|
||||
GHOSTTY_API GhosttyResult ghostty_render_state_end_update(GhosttyRenderState state);
|
||||
|
||||
/**
|
||||
* Get a value from a render state.
|
||||
*
|
||||
|
||||
@@ -21,6 +21,10 @@ pub const Options = struct {
|
||||
/// The type of codepoint width calculation to use.
|
||||
mode: Mode = .clone,
|
||||
|
||||
/// Multiplier on the number of iterations each step runs. This is
|
||||
/// useful to make a benchmark run long enough for profiling.
|
||||
loops: u32 = 1,
|
||||
|
||||
/// The size of the terminal. This affects benchmarking when
|
||||
/// dealing with soft line wrapping and the memory impact
|
||||
/// of page sizes.
|
||||
@@ -48,6 +52,22 @@ pub const Mode = enum {
|
||||
|
||||
/// RenderState rather than a screen clone.
|
||||
render,
|
||||
|
||||
/// Like render, but only the portion of the render state update
|
||||
/// that requires holding a terminal lock (beginUpdate). The
|
||||
/// deferred work (endUpdate) is excluded since it happens outside
|
||||
/// of any locks.
|
||||
@"render-locked",
|
||||
|
||||
/// RenderState update with no changes to the terminal. This is
|
||||
/// the common case for a renderer that is redrawing frames (e.g.
|
||||
/// cursor blink, mouse movement) without terminal changes.
|
||||
@"render-clean",
|
||||
|
||||
/// RenderState update where a single row is dirty. This models the
|
||||
/// common case of a shell prompt or TUI updating a small portion
|
||||
/// of the screen between frames.
|
||||
@"render-partial",
|
||||
};
|
||||
|
||||
pub fn create(
|
||||
@@ -79,6 +99,9 @@ pub fn benchmark(self: *ScreenClone) Benchmark {
|
||||
.noop => stepNoop,
|
||||
.clone => stepClone,
|
||||
.render => stepRender,
|
||||
.@"render-locked" => stepRenderLocked,
|
||||
.@"render-clean" => stepRenderClean,
|
||||
.@"render-partial" => stepRenderPartial,
|
||||
},
|
||||
.setupFn = setup,
|
||||
.teardownFn = teardown,
|
||||
@@ -178,16 +201,102 @@ fn stepRender(ptr: *anyopaque) Benchmark.Error!void {
|
||||
|
||||
// We loop because its so fast that a single benchmark run doesn't
|
||||
// properly capture our speeds.
|
||||
for (0..1000) |_| {
|
||||
for (0..50_000 * @as(u64, self.opts.loops)) |_| {
|
||||
// Forces a full rebuild because it thinks our screen changed
|
||||
state.screen = .alternate;
|
||||
state.update(alloc, &self.terminal) catch |err| {
|
||||
log.warn("error cloning screen err={}", .{err});
|
||||
return error.BenchmarkFailed;
|
||||
};
|
||||
std.mem.doNotOptimizeAway(state);
|
||||
std.mem.doNotOptimizeAway(&state);
|
||||
|
||||
// Note: we purposely do not free memory because we don't want
|
||||
// to benchmark that. We'll free when the benchmark exits.
|
||||
}
|
||||
}
|
||||
|
||||
fn stepRenderLocked(ptr: *anyopaque) Benchmark.Error!void {
|
||||
const self: *ScreenClone = @ptrCast(@alignCast(ptr));
|
||||
|
||||
// We do this once out of the loop because a significant slowdown
|
||||
// on the first run is allocation. After that first run, even with
|
||||
// a full rebuild, it is much faster. Let's ignore that first run
|
||||
// slowdown.
|
||||
const alloc = self.terminal.screens.active.alloc;
|
||||
var state: terminalpkg.RenderState = .empty;
|
||||
state.update(alloc, &self.terminal) catch |err| {
|
||||
log.warn("error cloning screen err={}", .{err});
|
||||
return error.BenchmarkFailed;
|
||||
};
|
||||
|
||||
// We loop because its so fast that a single benchmark run doesn't
|
||||
// properly capture our speeds.
|
||||
for (0..50_000 * @as(u64, self.opts.loops)) |_| {
|
||||
// Forces a full rebuild because it thinks our screen changed
|
||||
state.screen = .alternate;
|
||||
state.beginUpdate(alloc, &self.terminal) catch |err| {
|
||||
log.warn("error cloning screen err={}", .{err});
|
||||
return error.BenchmarkFailed;
|
||||
};
|
||||
std.mem.doNotOptimizeAway(&state);
|
||||
|
||||
// Note: we purposely do not free memory because we don't want
|
||||
// to benchmark that. We'll free when the benchmark exits.
|
||||
}
|
||||
}
|
||||
|
||||
fn stepRenderClean(ptr: *anyopaque) Benchmark.Error!void {
|
||||
const self: *ScreenClone = @ptrCast(@alignCast(ptr));
|
||||
|
||||
// Initial update so that subsequent updates are clean (nothing
|
||||
// dirty, no rebuilds).
|
||||
const alloc = self.terminal.screens.active.alloc;
|
||||
var state: terminalpkg.RenderState = .empty;
|
||||
state.update(alloc, &self.terminal) catch |err| {
|
||||
log.warn("error cloning screen err={}", .{err});
|
||||
return error.BenchmarkFailed;
|
||||
};
|
||||
|
||||
// We loop because its so fast that a single benchmark run doesn't
|
||||
// properly capture our speeds.
|
||||
for (0..3_000_000 * @as(u64, self.opts.loops)) |_| {
|
||||
state.update(alloc, &self.terminal) catch |err| {
|
||||
log.warn("error cloning screen err={}", .{err});
|
||||
return error.BenchmarkFailed;
|
||||
};
|
||||
std.mem.doNotOptimizeAway(&state);
|
||||
}
|
||||
}
|
||||
|
||||
fn stepRenderPartial(ptr: *anyopaque) Benchmark.Error!void {
|
||||
const self: *ScreenClone = @ptrCast(@alignCast(ptr));
|
||||
|
||||
// Initial update so that subsequent updates are incremental.
|
||||
const alloc = self.terminal.screens.active.alloc;
|
||||
var state: terminalpkg.RenderState = .empty;
|
||||
state.update(alloc, &self.terminal) catch |err| {
|
||||
log.warn("error cloning screen err={}", .{err});
|
||||
return error.BenchmarkFailed;
|
||||
};
|
||||
|
||||
// Grab a pin roughly in the middle of the active area that we
|
||||
// dirty on every iteration to simulate a small screen update.
|
||||
const pages = &self.terminal.screens.active.pages;
|
||||
const pin = pages.pin(.{ .active = .{
|
||||
.x = 0,
|
||||
.y = self.terminal.rows / 2,
|
||||
} }).?;
|
||||
|
||||
// We loop because its so fast that a single benchmark run doesn't
|
||||
// properly capture our speeds.
|
||||
for (0..2_000_000 * @as(u64, self.opts.loops)) |_| {
|
||||
// Mark a single row dirty. `update` clears this so each
|
||||
// iteration rebuilds exactly one row.
|
||||
pin.markDirty();
|
||||
state.update(alloc, &self.terminal) catch |err| {
|
||||
log.warn("error cloning screen err={}", .{err});
|
||||
return error.BenchmarkFailed;
|
||||
};
|
||||
std.mem.doNotOptimizeAway(&state);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -235,6 +235,8 @@ comptime {
|
||||
@export(&c.terminal_selection_format_alloc, .{ .name = "ghostty_terminal_selection_format_alloc" });
|
||||
@export(&c.render_state_new, .{ .name = "ghostty_render_state_new" });
|
||||
@export(&c.render_state_update, .{ .name = "ghostty_render_state_update" });
|
||||
@export(&c.render_state_begin_update, .{ .name = "ghostty_render_state_begin_update" });
|
||||
@export(&c.render_state_end_update, .{ .name = "ghostty_render_state_end_update" });
|
||||
@export(&c.render_state_get, .{ .name = "ghostty_render_state_get" });
|
||||
@export(&c.render_state_get_multi, .{ .name = "ghostty_render_state_get_multi" });
|
||||
@export(&c.render_state_set, .{ .name = "ghostty_render_state_set" });
|
||||
|
||||
@@ -1199,8 +1199,15 @@ pub fn Renderer(comptime GraphicsAPI: type) type {
|
||||
state.terminal.scrollViewport(.bottom);
|
||||
}
|
||||
|
||||
// Update our terminal state
|
||||
try self.terminal_state.update(self.alloc, state.terminal);
|
||||
// Begin the update of our terminal state. Work that
|
||||
// doesn't require terminal access (e.g. style
|
||||
// denormalization) is deferred to the endUpdate call
|
||||
// outside of this critical section, keeping our lock
|
||||
// hold time as short as possible.
|
||||
try self.terminal_state.beginUpdate(
|
||||
self.alloc,
|
||||
state.terminal,
|
||||
);
|
||||
|
||||
// If our terminal state is dirty at all we need to redo
|
||||
// the viewport search.
|
||||
@@ -1279,6 +1286,11 @@ pub fn Renderer(comptime GraphicsAPI: type) type {
|
||||
};
|
||||
};
|
||||
|
||||
// Outside the critical area, complete the update we began
|
||||
// within it. This must be done before anything reads the
|
||||
// render state (e.g. rebuildCells).
|
||||
self.terminal_state.endUpdate();
|
||||
|
||||
// Outside the critical area we can update our links to contain
|
||||
// our regex results.
|
||||
self.config.links.renderCellMap(
|
||||
|
||||
@@ -512,12 +512,12 @@ fn printSliceFill(
|
||||
// a codepoint cell (no grapheme data or bg-color tag), narrow, and
|
||||
// not a hyperlink. The mask covers every field that must match
|
||||
// the expected value (see printSliceCheckExpected) exactly.
|
||||
const simple_mask = comptime fieldMask(Cell, &.{
|
||||
const SimpleMask = pagepkg.Mask(Cell, &.{
|
||||
"content_tag",
|
||||
"style_id",
|
||||
"wide",
|
||||
"hyperlink",
|
||||
});
|
||||
}, 4);
|
||||
|
||||
// The bit offset of the codepoint content within a Cell, used to
|
||||
// construct cell values from a template without field assignments.
|
||||
@@ -619,8 +619,7 @@ fn printSliceFill(
|
||||
// spacer head (or a blank narrow cell if we're inside
|
||||
// a right margin) and wraps. We require a simple cell,
|
||||
// otherwise fall back to print() for the cleanup.
|
||||
const bits: u64 = @bitCast(cells[0]);
|
||||
if ((bits & simple_mask) != check_expected) break;
|
||||
if (!SimpleMask.eqlScalar(cells[0], check_expected)) break;
|
||||
|
||||
var spacer = template;
|
||||
if (right_limit == self.cols) {
|
||||
@@ -661,24 +660,20 @@ fn printSliceFill(
|
||||
// several cells at a time manually.
|
||||
var simple = k;
|
||||
simple: {
|
||||
const lanes = 4;
|
||||
const V = @Vector(lanes, u64);
|
||||
const mask_v: V = @splat(simple_mask);
|
||||
const expect_v: V = @splat(check_expected);
|
||||
const cells64: [*]const u64 = @ptrCast(cells);
|
||||
while (simple + lanes <= cell_count) {
|
||||
const v: V = cells64[simple..][0..lanes].*;
|
||||
const ok = (v & mask_v) == expect_v;
|
||||
if (!@reduce(.And, ok)) {
|
||||
const bits: std.meta.Int(.unsigned, lanes) = @bitCast(ok);
|
||||
simple += @ctz(~bits);
|
||||
break :simple;
|
||||
}
|
||||
simple += lanes;
|
||||
while (simple + SimpleMask.group_len <= cell_count) {
|
||||
const p = SimpleMask.eqlPrefix(
|
||||
cells[0..cell_count],
|
||||
simple,
|
||||
check_expected,
|
||||
);
|
||||
simple += p;
|
||||
if (p != SimpleMask.group_len) break :simple;
|
||||
}
|
||||
while (simple < cell_count) : (simple += 1) {
|
||||
const bits: u64 = @bitCast(cells[simple]);
|
||||
if ((bits & simple_mask) != check_expected) break;
|
||||
if (!SimpleMask.eqlScalar(
|
||||
cells[simple],
|
||||
check_expected,
|
||||
)) break;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -718,8 +713,7 @@ fn printSliceFill(
|
||||
// run of identical old styles, two ref-count updates,
|
||||
// and a branch-free fill.
|
||||
if (comptime width == .narrow) bulk: {
|
||||
const cells64: [*]const u64 = @ptrCast(cells);
|
||||
const first = cells64[k] & simple_mask;
|
||||
const first = SimpleMask.pattern(cells[k]);
|
||||
|
||||
// The old cell must be a plain narrow codepoint cell
|
||||
// with no hyperlink whose only difference is the
|
||||
@@ -733,22 +727,17 @@ fn printSliceFill(
|
||||
// Find the run of cells with identical masked bits.
|
||||
var m = k + 1;
|
||||
scan: {
|
||||
const lanes = 4;
|
||||
const V = @Vector(lanes, u64);
|
||||
const mask_v: V = @splat(simple_mask);
|
||||
const first_v: V = @splat(first);
|
||||
while (m + lanes <= cell_count) {
|
||||
const v: V = cells64[m..][0..lanes].*;
|
||||
const ok = (v & mask_v) == first_v;
|
||||
if (!@reduce(.And, ok)) {
|
||||
const bits: std.meta.Int(.unsigned, lanes) = @bitCast(ok);
|
||||
m += @ctz(~bits);
|
||||
break :scan;
|
||||
}
|
||||
m += lanes;
|
||||
while (m + SimpleMask.group_len <= cell_count) {
|
||||
const p = SimpleMask.eqlPrefix(
|
||||
cells[0..cell_count],
|
||||
m,
|
||||
first,
|
||||
);
|
||||
m += p;
|
||||
if (p != SimpleMask.group_len) break :scan;
|
||||
}
|
||||
while (m < cell_count) : (m += 1) {
|
||||
if ((cells64[m] & simple_mask) != first) break;
|
||||
if (!SimpleMask.eqlScalar(cells[m], first)) break;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -835,9 +824,9 @@ fn printSliceFill(
|
||||
return printed;
|
||||
}
|
||||
|
||||
/// The expected value of a simple cell (per the check mask built from
|
||||
/// fieldMask in printSliceFill) that already has the given style
|
||||
/// (so no ref-counting is needed).
|
||||
/// The expected value of a simple cell (per SimpleMask in
|
||||
/// printSliceFill) that already has the given style (so no
|
||||
/// ref-counting is needed).
|
||||
inline fn printSliceCheckExpected(style_id: style.Id) u64 {
|
||||
var e: Cell = @bitCast(@as(u64, 0));
|
||||
e.style_id = style_id;
|
||||
@@ -3725,30 +3714,6 @@ fn clearDirty(t: *Terminal) void {
|
||||
t.screens.active.pages.clearDirty();
|
||||
}
|
||||
|
||||
/// Returns a mask with all bits set for the given fields of the packed
|
||||
/// struct T, used for masked compares of raw backing-integer values.
|
||||
fn fieldMask(
|
||||
comptime T: type,
|
||||
comptime fields: []const []const u8,
|
||||
) @typeInfo(T).@"struct".backing_integer.? {
|
||||
// Backing int of the packed struct
|
||||
const Int = @typeInfo(T).@"struct".backing_integer.?;
|
||||
|
||||
var mask: Int = 0;
|
||||
inline for (fields) |field| {
|
||||
// The type that fits all the bits we need to set.
|
||||
const Ones = std.meta.Int(
|
||||
.unsigned,
|
||||
@bitSizeOf(@FieldType(T, field)),
|
||||
);
|
||||
|
||||
// Mask out the ones
|
||||
mask |= @as(Int, std.math.maxInt(Ones)) << @bitOffsetOf(T, field);
|
||||
}
|
||||
|
||||
return mask;
|
||||
}
|
||||
|
||||
test "Terminal: input with no control characters" {
|
||||
const alloc = testing.allocator;
|
||||
var t = try init(alloc, .{ .cols = 40, .rows = 40 });
|
||||
|
||||
@@ -83,6 +83,8 @@ pub const formatter_free = formatter.free;
|
||||
pub const render_state_new = render.new;
|
||||
pub const render_state_free = render.free;
|
||||
pub const render_state_update = render.update;
|
||||
pub const render_state_begin_update = render.begin_update;
|
||||
pub const render_state_end_update = render.end_update;
|
||||
pub const render_state_get = render.get;
|
||||
pub const render_state_get_multi = render.get_multi;
|
||||
pub const render_state_set = render.set;
|
||||
|
||||
@@ -188,6 +188,25 @@ pub fn update(
|
||||
return .success;
|
||||
}
|
||||
|
||||
pub fn begin_update(
|
||||
state_: RenderState,
|
||||
terminal_: terminal_c.Terminal,
|
||||
) callconv(lib.calling_conv) Result {
|
||||
const state = state_ orelse return .invalid_value;
|
||||
const t: *ZigTerminal = (terminal_ orelse return .invalid_value).terminal;
|
||||
|
||||
state.state.beginUpdate(state.alloc, t) catch return .out_of_memory;
|
||||
return .success;
|
||||
}
|
||||
|
||||
pub fn end_update(
|
||||
state_: RenderState,
|
||||
) callconv(lib.calling_conv) Result {
|
||||
const state = state_ orelse return .invalid_value;
|
||||
state.state.endUpdate();
|
||||
return .success;
|
||||
}
|
||||
|
||||
pub fn get(
|
||||
state_: RenderState,
|
||||
data: Data,
|
||||
@@ -787,6 +806,61 @@ test "render: update invalid value" {
|
||||
try testing.expectEqual(Result.invalid_value, update(state, null));
|
||||
}
|
||||
|
||||
test "render: begin/end update invalid value" {
|
||||
var state: RenderState = null;
|
||||
try testing.expectEqual(Result.success, new(
|
||||
&lib.alloc.test_allocator,
|
||||
&state,
|
||||
));
|
||||
defer free(state);
|
||||
|
||||
try testing.expectEqual(Result.invalid_value, begin_update(null, null));
|
||||
try testing.expectEqual(Result.invalid_value, begin_update(state, null));
|
||||
try testing.expectEqual(Result.invalid_value, end_update(null));
|
||||
|
||||
// End without a begin is safe.
|
||||
try testing.expectEqual(Result.success, end_update(state));
|
||||
}
|
||||
|
||||
test "render: begin/end update" {
|
||||
var terminal: terminal_c.Terminal = null;
|
||||
try testing.expectEqual(Result.success, terminal_c.new(
|
||||
&lib.alloc.test_allocator,
|
||||
&terminal,
|
||||
.{
|
||||
.cols = 10,
|
||||
.rows = 3,
|
||||
.max_scrollback = 10_000,
|
||||
},
|
||||
));
|
||||
defer terminal_c.free(terminal);
|
||||
|
||||
// Write some styled text so that the update has deferred work.
|
||||
const t = terminal.?.terminal;
|
||||
var s = t.vtStream();
|
||||
defer s.deinit();
|
||||
s.nextSlice("\x1b[1mAB"); // Bold
|
||||
|
||||
var state: RenderState = null;
|
||||
try testing.expectEqual(Result.success, new(
|
||||
&lib.alloc.test_allocator,
|
||||
&state,
|
||||
));
|
||||
defer free(state);
|
||||
|
||||
// Begin should record pending work, end should complete it.
|
||||
try testing.expectEqual(Result.success, begin_update(state, terminal));
|
||||
try testing.expect(state.?.state.pending_styles.items.len > 0);
|
||||
try testing.expectEqual(Result.success, end_update(state));
|
||||
try testing.expectEqual(0, state.?.state.pending_styles.items.len);
|
||||
|
||||
// The cell styles should be complete.
|
||||
const row_data = state.?.state.row_data.slice();
|
||||
const cells = row_data.items(.cells);
|
||||
try testing.expect(cells[0].get(0).style.flags.bold);
|
||||
try testing.expect(cells[0].get(1).style.flags.bold);
|
||||
}
|
||||
|
||||
test "render: get invalid value" {
|
||||
var cols: size.CellCountInt = 0;
|
||||
try testing.expectEqual(Result.invalid_value, get(null, .cols, @ptrCast(&cols)));
|
||||
|
||||
@@ -1988,6 +1988,11 @@ pub const Row = packed struct(u64) {
|
||||
prompt_continuation = 2,
|
||||
};
|
||||
|
||||
/// The backing integer of this packed struct. Prefer this over
|
||||
/// hardcoding the integer type so that code is resilient to the
|
||||
/// size changing.
|
||||
pub const Backing = @typeInfo(Row).@"struct".backing_integer.?;
|
||||
|
||||
/// C ABI type.
|
||||
pub const C = u64;
|
||||
|
||||
@@ -2100,6 +2105,11 @@ pub const Cell = packed struct(u64) {
|
||||
prompt = 2,
|
||||
};
|
||||
|
||||
/// The backing integer of this packed struct. Prefer this over
|
||||
/// hardcoding the integer type so that code is resilient to the
|
||||
/// size changing.
|
||||
pub const Backing = @typeInfo(Cell).@"struct".backing_integer.?;
|
||||
|
||||
/// C ABI type.
|
||||
pub const C = u64;
|
||||
|
||||
@@ -2194,6 +2204,270 @@ pub const Cell = packed struct(u64) {
|
||||
}
|
||||
};
|
||||
|
||||
/// Returns a mask with all bits set for the given fields of the packed
|
||||
/// struct T, used for masked compares of raw backing-integer values
|
||||
/// (e.g. `Row.Backing`, `Cell.Backing`). This is an implementation
|
||||
/// detail of `Mask`, which is the public API built on top of this.
|
||||
fn fieldMask(
|
||||
comptime T: type,
|
||||
comptime fields: []const []const u8,
|
||||
) @typeInfo(T).@"struct".backing_integer.? {
|
||||
// Backing int of the packed struct
|
||||
const Int = @typeInfo(T).@"struct".backing_integer.?;
|
||||
|
||||
var mask: Int = 0;
|
||||
inline for (fields) |field| {
|
||||
// The type that fits all the bits we need to set.
|
||||
const Ones = std.meta.Int(
|
||||
.unsigned,
|
||||
@bitSizeOf(@FieldType(T, field)),
|
||||
);
|
||||
|
||||
// Mask out the ones
|
||||
mask |= @as(Int, std.math.maxInt(Ones)) << @bitOffsetOf(T, field);
|
||||
}
|
||||
|
||||
return mask;
|
||||
}
|
||||
|
||||
/// A comptime-generated helper for classifying and comparing packed
|
||||
/// struct values (e.g. Row, Cell) in bulk, using masked compares of
|
||||
/// their raw backing integers.
|
||||
///
|
||||
/// Masked compares are the key to making bulk row/cell processing fast.
|
||||
/// Rows and cells are small packed structs specifically so that a single
|
||||
/// integer load observes every field at once. A masked compare can then
|
||||
/// answer a multi-field question with one AND and one compare, instead
|
||||
/// of extracting and branching on each field individually (each packed
|
||||
/// field access compiles to its own shift/mask). Just as importantly,
|
||||
/// the integer form vectorizes trivially: `@splat` the mask and expected
|
||||
/// value, and whole groups of rows or cells can be classified with a
|
||||
/// few SIMD instructions.
|
||||
///
|
||||
/// Some real examples of this in use:
|
||||
///
|
||||
/// - Terminal print fast path: a cell can be overwritten by the
|
||||
/// simple/fast path only if its content tag is a plain codepoint,
|
||||
/// it has no style, isn't wide, and isn't a hyperlink. Masking
|
||||
/// with those fields and comparing against a template answers all
|
||||
/// four questions in one compare per cell.
|
||||
///
|
||||
/// - Render state updates: a cell whose masked
|
||||
/// `{content_tag, style_id}` bits are zero is a plain cell that
|
||||
/// needs no managed-memory handling, so a vector OR-reduce can
|
||||
/// skip entire groups of plain cells at once. Similarly, cells
|
||||
/// whose masked bits equal the first cell's form a run sharing one
|
||||
/// style, letting the update record one style lookup per run
|
||||
/// rather than per cell.
|
||||
///
|
||||
/// - Dirty scans: OR-reducing groups of rows against the `dirty`
|
||||
/// field mask finds whether any row in the group needs a rebuild
|
||||
/// without touching each row's flag individually.
|
||||
///
|
||||
/// T is the packed struct type, fields are the fields covered by the
|
||||
/// mask, and group_len is the number of values processed at once by
|
||||
/// the group (vectorized) operations. Callers typically scan a slice
|
||||
/// with the group operations and fall back to the scalar variants for
|
||||
/// the remainder and for pinpointing values within a matched group.
|
||||
pub fn Mask(
|
||||
comptime T: type,
|
||||
comptime fields: []const []const u8,
|
||||
comptime group_len_param: comptime_int,
|
||||
) type {
|
||||
return struct {
|
||||
const Backing = @typeInfo(T).@"struct".backing_integer.?;
|
||||
const mask: Backing = fieldMask(T, fields);
|
||||
|
||||
/// The number of values processed at once by group operations.
|
||||
pub const group_len = group_len_param;
|
||||
|
||||
/// A group of raw values for the vectorized operations.
|
||||
const Group = @Vector(group_len, Backing);
|
||||
|
||||
/// Load a group of values from the slice starting at index i.
|
||||
/// Asserts that at least group_len values are available.
|
||||
inline fn load(values: []const T, i: usize) Group {
|
||||
return @bitCast(values[i..][0..group_len].*);
|
||||
}
|
||||
|
||||
/// Returns the raw backing bits of a single value.
|
||||
pub inline fn bits(v: T) Backing {
|
||||
return @bitCast(v);
|
||||
}
|
||||
|
||||
/// Returns the masked bits of a single value: the bits of the
|
||||
/// masked fields with all other fields zeroed. Use this to
|
||||
/// build the expected value for the eql functions.
|
||||
pub inline fn pattern(v: T) Backing {
|
||||
return bits(v) & mask;
|
||||
}
|
||||
|
||||
/// Returns the backing bits of a single value with the masked
|
||||
/// fields zeroed: the complement of `pattern`. Use this to
|
||||
/// compare values while ignoring the masked fields.
|
||||
pub inline fn strip(v: T) Backing {
|
||||
return bits(v) & ~mask;
|
||||
}
|
||||
|
||||
/// Returns true if every value in the group of group_len
|
||||
/// values starting at index i matches, where a value matches
|
||||
/// when none of the masked fields have any bits set: false
|
||||
/// for bools, zero for ints, the zero tag for enums, and so
|
||||
/// on. Asserts that at least group_len values are available.
|
||||
pub inline fn match(values: []const T, i: usize) bool {
|
||||
return @reduce(.Or, load(values, i)) & mask == 0;
|
||||
}
|
||||
|
||||
/// Scalar variant of `match` for a single value.
|
||||
pub inline fn matchScalar(v: T) bool {
|
||||
return bits(v) & mask == 0;
|
||||
}
|
||||
|
||||
/// Returns true if the masked fields of every value in the
|
||||
/// group of group_len values starting at index i equal the
|
||||
/// expected pattern (see `pattern`).
|
||||
///
|
||||
/// This is a masked compare: fields outside the mask may vary
|
||||
/// freely. Use this to detect runs of values that share the
|
||||
/// masked field contents while other fields differ, e.g. a run
|
||||
/// of cells with the same style ID but different codepoints.
|
||||
/// If the result you derive from a run depends on fields
|
||||
/// outside the mask, use `eqlExact` instead.
|
||||
pub inline fn eql(
|
||||
values: []const T,
|
||||
i: usize,
|
||||
expected: Backing,
|
||||
) bool {
|
||||
const masked = load(values, i) & @as(Group, @splat(mask));
|
||||
return @reduce(.And, masked == @as(Group, @splat(expected)));
|
||||
}
|
||||
|
||||
/// Scalar variant of `eql` for a single value.
|
||||
pub inline fn eqlScalar(v: T, expected: Backing) bool {
|
||||
return pattern(v) == expected;
|
||||
}
|
||||
|
||||
/// Like `eql` but returns the number of leading values whose
|
||||
/// masked fields equal the expected pattern, i.e. group_len if
|
||||
/// the entire group matches. This is useful for early-exit run
|
||||
/// scans that need to pinpoint exactly where a run ends rather
|
||||
/// than only whether the whole group matches.
|
||||
pub inline fn eqlPrefix(
|
||||
values: []const T,
|
||||
i: usize,
|
||||
expected: Backing,
|
||||
) usize {
|
||||
const masked = load(values, i) & @as(Group, @splat(mask));
|
||||
const ok = masked == @as(Group, @splat(expected));
|
||||
|
||||
// Test the whole group before extracting the prefix
|
||||
// count: turning a vector compare into a scalar bitmask
|
||||
// is expensive on some targets (e.g. NEON has no movemask
|
||||
// instruction) and run scans overwhelmingly see fully
|
||||
// matching groups, so we only pay for the extraction on
|
||||
// the final group of a run.
|
||||
if (@reduce(.And, ok)) {
|
||||
@branchHint(.likely);
|
||||
return group_len;
|
||||
}
|
||||
|
||||
const ok_bits: std.meta.Int(
|
||||
.unsigned,
|
||||
group_len,
|
||||
) = @bitCast(ok);
|
||||
return @ctz(~ok_bits);
|
||||
}
|
||||
|
||||
/// Returns true if every value in the group of group_len
|
||||
/// values starting at index i is bit-identical to the expected
|
||||
/// value. Note: this compares entire values; it is NOT
|
||||
/// affected by the field mask.
|
||||
///
|
||||
/// This exists alongside `eql` for run detection where the
|
||||
/// derived result depends on fields outside the mask, so a
|
||||
/// masked compare would incorrectly extend the run. For
|
||||
/// example, the background color of a bg-color cell lives in
|
||||
/// the content field: two such cells only share a background
|
||||
/// if their content bits are identical, not merely their
|
||||
/// content tag. Values in such runs are typically produced by
|
||||
/// bulk fills (e.g. erase with a pending background) and are
|
||||
/// bit-identical in practice, so exact equality is both
|
||||
/// correct and cheap.
|
||||
pub inline fn eqlExact(
|
||||
values: []const T,
|
||||
i: usize,
|
||||
expected: Backing,
|
||||
) bool {
|
||||
const group = load(values, i);
|
||||
return @reduce(.And, group == @as(Group, @splat(expected)));
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
test "Mask" {
|
||||
const M = Mask(Cell, &.{ "content_tag", "style_id" }, 4);
|
||||
|
||||
const plain: Cell = .init('A');
|
||||
var styled: Cell = .init('B');
|
||||
styled.style_id = 5;
|
||||
var styled2: Cell = .init('C');
|
||||
styled2.style_id = 5;
|
||||
var other: Cell = .init('D');
|
||||
other.style_id = 6;
|
||||
|
||||
// match: plain cells only
|
||||
{
|
||||
var cells: [4]Cell = .{ plain, plain, plain, plain };
|
||||
try testing.expect(M.match(&cells, 0));
|
||||
try testing.expect(M.matchScalar(plain));
|
||||
|
||||
cells[2] = styled;
|
||||
try testing.expect(!M.match(&cells, 0));
|
||||
try testing.expect(!M.matchScalar(styled));
|
||||
}
|
||||
|
||||
// eql: runs of matching masked fields, other fields may vary
|
||||
{
|
||||
const expected = M.pattern(styled);
|
||||
var cells: [4]Cell = .{ styled, styled2, styled, styled2 };
|
||||
try testing.expect(M.eql(&cells, 0, expected));
|
||||
try testing.expect(M.eqlScalar(styled2, expected));
|
||||
|
||||
cells[1] = other;
|
||||
try testing.expect(!M.eql(&cells, 0, expected));
|
||||
try testing.expect(!M.eqlScalar(other, expected));
|
||||
}
|
||||
|
||||
// eqlPrefix: count of leading values matching the pattern
|
||||
{
|
||||
const expected = M.pattern(styled);
|
||||
var cells: [4]Cell = .{ styled, styled2, other, styled };
|
||||
try testing.expectEqual(2, M.eqlPrefix(&cells, 0, expected));
|
||||
|
||||
cells[2] = styled;
|
||||
try testing.expectEqual(4, M.eqlPrefix(&cells, 0, expected));
|
||||
}
|
||||
|
||||
// eqlExact: bit-identical values only
|
||||
{
|
||||
const expected = M.bits(styled);
|
||||
var cells: [4]Cell = .{ styled, styled, styled, styled };
|
||||
try testing.expect(M.eqlExact(&cells, 0, expected));
|
||||
|
||||
// Same masked fields but different codepoint is not exact.
|
||||
cells[3] = styled2;
|
||||
try testing.expect(!M.eqlExact(&cells, 0, expected));
|
||||
}
|
||||
|
||||
// strip: compare values while ignoring the masked fields
|
||||
{
|
||||
var styled_other: Cell = .init('B');
|
||||
styled_other.style_id = 6;
|
||||
try testing.expectEqual(M.strip(styled), M.strip(styled_other));
|
||||
try testing.expect(M.strip(styled) != M.strip(styled2));
|
||||
}
|
||||
}
|
||||
|
||||
// Uncomment this when you want to do some math.
|
||||
// test "Page size calculator" {
|
||||
// const total_size = alignForward(
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user