mirror of
https://github.com/ghostty-org/ghostty.git
synced 2026-08-24 16:11:43 +00:00
libghostty: faster render state reads and updates on wasm targets
This makes the `ghostty_render_state_*` C API significantly faster on wasm32-freestanding, measured in V8 via Node for Chrome. Also verified in `jsc` for Safari. The major change is a new bulk row read API that makes full-screen cell reads roughly 10x faster for wasm embedders. This should help any embedder with high FFI overhead, such as Go, Python, etc. too. Non-wasm performance is not impacted, all benchmarks were run on my mac too w/ no regressions (two of the changes are native wins as well). ## Changes * color: the "vectorized" palette conversion loop was silently scalarized by LLVM into per-byte ops because it loaded/stored through array-typed pointers. Zig 0.16 disables the LLVM loop vectorizer, so manually vectorized loops must go through vector-typed pointers. * C styles: major optimizations to converting Zig styles to C styles. This is a heavy operation for render state. * render: `endUpdate`'s style-run fill (`@memset` with a struct value) re-loaded its source every iteration and stored field by field. Now manually vectorized. * render: new `GHOSTTY_RENDER_STATE_ROW_DATA_CELLS_RAW` returns a borrowed `GhosttyCellsView` of the current row's raw cell values, valid until the next update. One call per row instead of 3-6 calls per cell. ## Benchmarks | Benchmark | Before | After | Speedup | |---|---|---|---| | colors_get | 114 ns | 35 ns | 3.3x | | style get, per styled cell | 7.8 ns | 6.7 ns | 1.2x | | raw+style read, per cell | 8.6 ns | 7.7 ns | 1.1x | | full-screen text read, per cell | 7.5 ns | 0.7 ns | 10.7x | | full-screen text+style read, per cell | 8.6 ns | 1.7 ns | 5.1x | | render state update, styled full frame | 3.4 us | 2.6 us | 1.3x | **AI usage:** Fable did the implementation and benchmarking and drafted this message. Comments were partially rewritten by me.
This commit is contained in:
@@ -236,6 +236,19 @@ typedef enum GHOSTTY_ENUM_TYPED {
|
||||
|
||||
/** Row-local selected cell range (GhosttyRenderStateRowSelection). */
|
||||
GHOSTTY_RENDER_STATE_ROW_DATA_SELECTION = 4,
|
||||
|
||||
/** A borrowed view of the raw cell values for the current row
|
||||
* (GhosttyCellsView). One value per column, identical to querying
|
||||
* GHOSTTY_RENDER_STATE_ROW_CELLS_DATA_RAW for each cell. The view
|
||||
* is only valid as long as the underlying render state is not
|
||||
* updated; it is unsafe to use after updating the render state.
|
||||
*
|
||||
* This is the bulk alternative to iterating cells one at a time.
|
||||
* It lets callers with expensive call boundaries (e.g. WebAssembly
|
||||
* embedders) read an entire row with a single call, then drill
|
||||
* into the cells iterator only for cells that need managed data
|
||||
* (styles, graphemes). */
|
||||
GHOSTTY_RENDER_STATE_ROW_DATA_CELLS_RAW = 5,
|
||||
GHOSTTY_RENDER_STATE_ROW_DATA_MAX_VALUE = GHOSTTY_ENUM_MAX_VALUE,
|
||||
} GhosttyRenderStateRowData;
|
||||
|
||||
|
||||
@@ -50,6 +50,23 @@ typedef uint64_t GhosttyCell;
|
||||
*/
|
||||
typedef uint64_t GhosttyRow;
|
||||
|
||||
/**
|
||||
* A borrowed view of contiguous raw cell values.
|
||||
*
|
||||
* The memory is not owned by this struct. The pointer is only valid
|
||||
* for the lifetime documented by the API that produces it. Each value
|
||||
* is queried via ghostty_cell_get() like any other GhosttyCell.
|
||||
*
|
||||
* @ingroup screen
|
||||
*/
|
||||
typedef struct {
|
||||
/** Pointer to len contiguous cell values. */
|
||||
const GhosttyCell* ptr;
|
||||
|
||||
/** Number of cells. */
|
||||
size_t len;
|
||||
} GhosttyCellsView;
|
||||
|
||||
/**
|
||||
* Cell content tag.
|
||||
*
|
||||
|
||||
@@ -10,6 +10,12 @@ const Result = @import("result.zig").Result;
|
||||
/// C: GhosttyCell
|
||||
pub const CCell = u64;
|
||||
|
||||
/// C: GhosttyCellsView
|
||||
pub const CellsView = extern struct {
|
||||
ptr: ?[*]const CCell,
|
||||
len: usize,
|
||||
};
|
||||
|
||||
/// C: GhosttyCellContentTag
|
||||
pub const ContentTag = enum(c_int) {
|
||||
codepoint = 0,
|
||||
|
||||
@@ -12,6 +12,7 @@ const terminal_c = @import("terminal.zig");
|
||||
const ZigTerminal = @import("../Terminal.zig");
|
||||
const renderpkg = @import("../render.zig");
|
||||
const Result = @import("result.zig").Result;
|
||||
const cell_c = @import("cell.zig");
|
||||
const row = @import("row.zig");
|
||||
const style_c = @import("style.zig");
|
||||
|
||||
@@ -614,10 +615,11 @@ fn rowCellsGetTypedInner(
|
||||
switch (data) {
|
||||
.invalid => return .invalid_value,
|
||||
.raw => out.* = cell.cval(),
|
||||
.style => out.* = if (cell.hasStyling())
|
||||
style_c.Style.fromStyle(cells.styles[x])
|
||||
else
|
||||
comptime style_c.Style.fromStyle(.{}),
|
||||
.style => if (cell.hasStyling()) {
|
||||
style_c.Style.write(cells.styles[x], out);
|
||||
} else {
|
||||
out.* = style_c.Style.default;
|
||||
},
|
||||
.graphemes_len => {
|
||||
if (!cell.hasText()) {
|
||||
out.* = 0;
|
||||
@@ -739,6 +741,7 @@ pub const RowData = enum(c_int) {
|
||||
raw = 2,
|
||||
cells = 3,
|
||||
selection = 4,
|
||||
cells_raw = 5,
|
||||
|
||||
/// Output type expected for querying the data of the given kind.
|
||||
pub fn OutType(comptime self: RowData) type {
|
||||
@@ -748,6 +751,7 @@ pub const RowData = enum(c_int) {
|
||||
.raw => row.CRow,
|
||||
.cells => RowCells,
|
||||
.selection => RowSelection,
|
||||
.cells_raw => cell_c.CellsView,
|
||||
};
|
||||
}
|
||||
};
|
||||
@@ -864,6 +868,13 @@ fn rowGetTyped(
|
||||
out.start_x = sel[0];
|
||||
out.end_x = sel[1];
|
||||
},
|
||||
.cells_raw => {
|
||||
const raws: []const page.Cell = it.cells[y].items(.raw);
|
||||
out.* = .{
|
||||
.ptr = @ptrCast(raws.ptr),
|
||||
.len = raws.len,
|
||||
};
|
||||
},
|
||||
}
|
||||
|
||||
return .success;
|
||||
@@ -1326,6 +1337,68 @@ test "render: row get selection" {
|
||||
try testing.expectEqual(Result.no_value, row_get(it, .selection, @ptrCast(&sel)));
|
||||
}
|
||||
|
||||
test "render: row get cells_raw" {
|
||||
var terminal: terminal_c.Terminal = null;
|
||||
try testing.expectEqual(Result.success, terminal_c.new(
|
||||
&lib.alloc.test_allocator,
|
||||
&terminal,
|
||||
10,
|
||||
3,
|
||||
));
|
||||
defer terminal_c.free(terminal);
|
||||
|
||||
terminal_c.vt_write(terminal, "AB", 2);
|
||||
|
||||
var state: RenderState = null;
|
||||
try testing.expectEqual(Result.success, new(
|
||||
&lib.alloc.test_allocator,
|
||||
&state,
|
||||
));
|
||||
defer free(state);
|
||||
|
||||
try testing.expectEqual(Result.success, update(state, terminal));
|
||||
|
||||
var it: RowIterator = null;
|
||||
try testing.expectEqual(Result.success, row_iterator_new(
|
||||
&lib.alloc.test_allocator,
|
||||
&it,
|
||||
));
|
||||
defer row_iterator_free(it);
|
||||
|
||||
try testing.expectEqual(Result.success, get(state, .row_iterator, @ptrCast(&it)));
|
||||
|
||||
// Not positioned on a row yet.
|
||||
var view: cell_c.CellsView = undefined;
|
||||
try testing.expectEqual(Result.invalid_value, row_get(it, .cells_raw, @ptrCast(&view)));
|
||||
|
||||
try testing.expect(row_iterator_next(it));
|
||||
try testing.expectEqual(Result.success, row_get(it, .cells_raw, @ptrCast(&view)));
|
||||
try testing.expectEqual(@as(usize, 10), view.len);
|
||||
|
||||
// The view must match the per-cell raw reads.
|
||||
var cells: RowCells = null;
|
||||
try testing.expectEqual(Result.success, row_cells_new(
|
||||
&lib.alloc.test_allocator,
|
||||
&cells,
|
||||
));
|
||||
defer row_cells_free(cells);
|
||||
try testing.expectEqual(Result.success, row_get(it, .cells, @ptrCast(&cells)));
|
||||
const ptr = view.ptr.?;
|
||||
var x: u16 = 0;
|
||||
while (x < view.len) : (x += 1) {
|
||||
var raw: page.Cell.C = undefined;
|
||||
try testing.expectEqual(Result.success, row_cells_select(cells, x));
|
||||
try testing.expectEqual(Result.success, row_cells_get(cells, .raw, @ptrCast(&raw)));
|
||||
try testing.expectEqual(raw, ptr[x]);
|
||||
}
|
||||
|
||||
// Contents sanity: first two cells hold our text.
|
||||
const first: page.Cell = @bitCast(ptr[0]);
|
||||
const second: page.Cell = @bitCast(ptr[1]);
|
||||
try testing.expectEqual(@as(u21, 'A'), first.codepoint());
|
||||
try testing.expectEqual(@as(u21, 'B'), second.codepoint());
|
||||
}
|
||||
|
||||
test "render: row cells get selected" {
|
||||
var terminal: terminal_c.Terminal = null;
|
||||
try testing.expectEqual(Result.success, terminal_c.new(
|
||||
|
||||
@@ -25,20 +25,32 @@ pub const Color = extern struct {
|
||||
tag: ColorTag,
|
||||
value: ColorValue,
|
||||
|
||||
// fromColor converts the tag as a plain integer, which requires
|
||||
// the internal and C tag values to line up.
|
||||
comptime {
|
||||
const Tag = std.meta.Tag(style.Style.Color);
|
||||
for (@typeInfo(Tag).@"enum".fields) |f| {
|
||||
assert(f.value == @intFromEnum(@field(ColorTag, f.name)));
|
||||
}
|
||||
}
|
||||
|
||||
pub fn fromColor(c: style.Style.Color) Color {
|
||||
return switch (c) {
|
||||
.none => .{
|
||||
.tag = .none,
|
||||
.value = .{ ._padding = 0 },
|
||||
},
|
||||
.palette => |idx| .{
|
||||
.tag = .palette,
|
||||
.value = .{ .palette = idx },
|
||||
},
|
||||
.rgb => |rgb| .{
|
||||
.tag = .rgb,
|
||||
.value = .{ .rgb = rgb.cval() },
|
||||
},
|
||||
// The value is built as a single integer rather than through
|
||||
// the per-variant union fields: the C union layout puts
|
||||
// .palette at byte 0 and .rgb at bytes 0-2, which is exactly
|
||||
// the internal payload representation, so each variant is the
|
||||
// payload zero-extended to the u64 union backing. This keeps
|
||||
// the conversion to two stores (tag + value) per color, which
|
||||
// matters for the per-cell style reads in the render state
|
||||
// API.
|
||||
const value: u64 = switch (c) {
|
||||
.none => 0,
|
||||
.palette => |idx| idx,
|
||||
.rgb => |rgb| @as(u24, @bitCast(rgb)),
|
||||
};
|
||||
return .{
|
||||
.tag = @enumFromInt(@intFromEnum(std.meta.activeTag(c))),
|
||||
.value = .{ ._padding = value },
|
||||
};
|
||||
}
|
||||
};
|
||||
@@ -59,27 +71,83 @@ pub const Style = extern struct {
|
||||
overline: bool,
|
||||
underline: c_int,
|
||||
|
||||
pub fn fromStyle(s: style.Style) Style {
|
||||
return .{
|
||||
.fg_color = .fromColor(s.fg_color),
|
||||
.bg_color = .fromColor(s.bg_color),
|
||||
.underline_color = .fromColor(s.underline_color),
|
||||
.bold = s.flags.bold,
|
||||
.italic = s.flags.italic,
|
||||
.faint = s.flags.faint,
|
||||
.blink = s.flags.blink,
|
||||
.inverse = s.flags.inverse,
|
||||
.invisible = s.flags.invisible,
|
||||
.strikethrough = s.flags.strikethrough,
|
||||
.overline = s.flags.overline,
|
||||
.underline = @intFromEnum(s.flags.underline),
|
||||
/// The default (empty) style in C representation. C
|
||||
pub const default: Style = .{
|
||||
.fg_color = .{ .tag = .none, .value = .{ ._padding = 0 } },
|
||||
.bg_color = .{ .tag = .none, .value = .{ ._padding = 0 } },
|
||||
.underline_color = .{ .tag = .none, .value = .{ ._padding = 0 } },
|
||||
.bold = false,
|
||||
.italic = false,
|
||||
.faint = false,
|
||||
.blink = false,
|
||||
.inverse = false,
|
||||
.invisible = false,
|
||||
.strikethrough = false,
|
||||
.overline = false,
|
||||
.underline = 0,
|
||||
};
|
||||
|
||||
// fromStyle writes the eight bool fields as one 8-byte store,
|
||||
// which requires them to be consecutive and to match the low
|
||||
// eight flag bits in order.
|
||||
comptime {
|
||||
const flag_names = [8][:0]const u8{
|
||||
"bold", "italic", "faint", "blink",
|
||||
"inverse", "invisible", "strikethrough", "overline",
|
||||
};
|
||||
const base = @offsetOf(Style, "bold");
|
||||
for (flag_names, 0..) |name, i| {
|
||||
assert(@offsetOf(Style, name) == base + i);
|
||||
assert(@bitOffsetOf(@FieldType(style.Style, "flags"), name) == i);
|
||||
}
|
||||
}
|
||||
|
||||
/// Write the C representation of the given style directly through
|
||||
/// the out pointer. The hot per-cell style read in the render
|
||||
/// state API uses this rather than `out.* = fromStyle(s)`: the
|
||||
/// by-value form materializes a stack temporary plus a
|
||||
/// @sizeOf(Style)-byte copy that LLVM does not elide.
|
||||
pub fn write(s: style.Style, out: *Style) void {
|
||||
out.size = @sizeOf(Style);
|
||||
out.fg_color = .fromColor(s.fg_color);
|
||||
out.bg_color = .fromColor(s.bg_color);
|
||||
out.underline_color = .fromColor(s.underline_color);
|
||||
|
||||
// I know this looks scary, but this results in truly measurable
|
||||
// performance improvements due to how often styles are written
|
||||
// especially in render loops. We vectorize our 8-bit to 8-byte
|
||||
// write.
|
||||
//
|
||||
// We have to align(1) because we're ptrCasting a u8 pointer.
|
||||
@as(*align(1) u64, @ptrCast(&out.bold)).* = bytes: {
|
||||
// Spread the low eight flag bits (the eight bool fields, in
|
||||
// order) into one byte each and write them with a single
|
||||
// 8-byte store.
|
||||
const flag_bits: u8 = @truncate(@as(u16, @bitCast(s.flags)));
|
||||
const masks: @Vector(8, u8) = .{ 1, 2, 4, 8, 16, 32, 64, 128 };
|
||||
const hits = (@as(@Vector(8, u8), @splat(flag_bits)) & masks) == masks;
|
||||
const bytes: @Vector(8, u8) = @select(
|
||||
u8,
|
||||
hits,
|
||||
@as(@Vector(8, u8), @splat(1)),
|
||||
@as(@Vector(8, u8), @splat(0)),
|
||||
);
|
||||
break :bytes @bitCast(bytes);
|
||||
};
|
||||
|
||||
out.underline = @intFromEnum(s.flags.underline);
|
||||
}
|
||||
|
||||
pub fn fromStyle(s: style.Style) Style {
|
||||
var result: Style = undefined;
|
||||
write(s, &result);
|
||||
return result;
|
||||
}
|
||||
};
|
||||
|
||||
/// Returns the default style.
|
||||
pub fn default_style(result: *Style) callconv(lib.calling_conv) void {
|
||||
result.* = .fromStyle(.{});
|
||||
result.* = .default;
|
||||
assert(result.size == @sizeOf(Style));
|
||||
}
|
||||
|
||||
|
||||
@@ -155,16 +155,27 @@ pub fn paletteCvalSlice(src: []const RGB, dst: []RGB.C) void {
|
||||
// (pointer cast) because a value-level @bitCast of packed
|
||||
// structs operates on the 24-bit value bits, not the 4-byte
|
||||
// in-memory representation.
|
||||
const V = @Vector(16, u8);
|
||||
const dst_bytes: [*]u8 = @ptrCast(dst.ptr);
|
||||
while (i + 6 <= src.len) : (i += 4) {
|
||||
const in: @Vector(16, u8) = @as(
|
||||
*const [16]u8,
|
||||
@ptrCast(src[i..][0..4]),
|
||||
).*;
|
||||
const out: [16]u8 = @shuffle(u8, in, undefined, [16]i32{
|
||||
0, 1, 2, 4, 5, 6, 8, 9, 10, 12, 13, 14, 3, 7, 11, 15,
|
||||
// All intermediaries have to also go through a Vector type
|
||||
// to ensure LLVM lowers as vectorized ops. In Zig 0.16 the
|
||||
// LLVM auto-vectorizer is disabled so this is required.
|
||||
const in: V = @as(*align(4) const V, @ptrCast(src[i..][0..4])).*;
|
||||
const out: V = @shuffle(u8, in, undefined, [16]i32{
|
||||
0, 1, 2,
|
||||
4, 5, 6,
|
||||
8, 9, 10,
|
||||
12, 13, 14,
|
||||
|
||||
// Overflow we don't care about
|
||||
3, 7, 11,
|
||||
15,
|
||||
});
|
||||
dst_bytes[i * 3 ..][0..16].* = out;
|
||||
|
||||
// Scary looking but safe, mapping a byte pointer to the full
|
||||
// vector type (hence align 1).
|
||||
@as(*align(1) V, @ptrCast(dst_bytes[i * 3 ..][0..16])).* = out;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -793,7 +793,7 @@ pub const RenderState = struct {
|
||||
const end = @min(run.end, styles.len);
|
||||
const start = @min(run.start, end);
|
||||
|
||||
@memset(styles[start..end], run.style);
|
||||
fillStyles(styles[start..end], run.style);
|
||||
}
|
||||
|
||||
// Record what we applied so the next rebuild of this row
|
||||
@@ -810,6 +810,36 @@ pub const RenderState = struct {
|
||||
self.pending_styles.clearRetainingCapacity();
|
||||
}
|
||||
|
||||
/// Fill a slice of styles with one value.
|
||||
///
|
||||
/// This is equivalent to `@memset(dst, value)` but manually vectorized:
|
||||
/// `@memset` with a struct value lowers to a per-element field copy
|
||||
/// that reloads the source at every iteration because LLVM cannot
|
||||
/// prove the destination doesn't alias it. And, Zig 0.16 disables
|
||||
/// auto-vectorization due to an LLVM bug.
|
||||
///
|
||||
/// This complexity is justified by accounting for ~10% of the endUpdate
|
||||
/// times under heavily styled cases.
|
||||
fn fillStyles(dst: []Style, value: Style) void {
|
||||
// Each element is written as two overlapping 16-byte vector
|
||||
// stores held in registers, which requires 16 <= size <= 32.
|
||||
const elem_size = @sizeOf(Style);
|
||||
comptime assert(elem_size >= 16 and elem_size <= 32);
|
||||
|
||||
const V = @Vector(16, u8);
|
||||
const src: *align(@alignOf(Style)) const [elem_size]u8 = @ptrCast(&value);
|
||||
const lo = @as(*align(4) const V, @ptrCast(src[0..16])).*;
|
||||
const hi = @as(*align(1) const V, @ptrCast(src[elem_size - 16 ..][0..16])).*;
|
||||
|
||||
const dst_bytes = std.mem.sliceAsBytes(dst);
|
||||
var off: usize = 0;
|
||||
for (0..dst.len) |_| {
|
||||
@as(*align(1) V, @ptrCast(dst_bytes[off..][0..16])).* = lo;
|
||||
@as(*align(1) V, @ptrCast(dst_bytes[off + elem_size - 16 ..][0..16])).* = hi;
|
||||
off += elem_size;
|
||||
}
|
||||
}
|
||||
|
||||
/// Returns true if the two style run lists denormalize to
|
||||
/// identical per-cell style data. This is a semantic comparison
|
||||
/// (styles are compared field-wise, never by bytes, since padding
|
||||
|
||||
Reference in New Issue
Block a user