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:
Mitchell Hashimoto
2026-07-06 14:04:22 -07:00
parent 634957c8e6
commit 446f80f4ed
9 changed files with 1346 additions and 207 deletions

View File

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