libghostty: faster render state reads and updates on wasm targets (#13825)

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:
Mitchell Hashimoto
2026-08-14 11:53:04 -07:00
committed by GitHub
7 changed files with 258 additions and 40 deletions

View File

@@ -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;

View File

@@ -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.
*

View File

@@ -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,

View File

@@ -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(

View File

@@ -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));
}

View File

@@ -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;
}
}

View File

@@ -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