Files
ghostty-rs/docs/rewrite/notes/perf-P1.md
T
ddidderr e9fdce0b26 perf(terminal): make screen clone match Zig, batch OSC payload copies
screen-clone was 2.6x slower than Zig. The main cost was in
PageList::clone_range: it created a throwaway page, recycled it into the
pool and Page::reinit then memset the entire page again, faulting in
every cell. Untouched pages now go to a clean_pool that create_node
reuses without re-zeroing. Page::init allocates cells with
bytemuck::allocation::try_zeroed_vec (Cell derives Zeroable), getting
lazily-zeroed memory from the allocator without unsafe code, and
clone_partial_row_from bulk-copies plain-cell runs in rows with managed
memory. screen-clone clone: 78 ms -> ~28 ms (Zig ~27.8 ms).

osc::Parser::next_slice appends payload bytes in bulk while behaving
exactly like per-byte next() (2048 limit, overflow), verified by a
property test; the osc-parser bench uses it (54.5 -> ~32 ms, Zig ~28).

Still slower: screen-clone render (~2.4x), dominated by per-row style
vectors in RenderState::update.

Test Plan:
- cargo test --workspace (no failures)
- ghostty-bench +screen-clone / +osc-parser with --profile production
2026-10-12 03:51:46 +02:00

6.8 KiB

perf-P1: benchmarks and hot-path optimisation

What was added

  • New crate crates/ghostty-bench (registered in root [workspace.dependencies]):
    • binary ghostty-bench +<action> [--opt=value], same actions and options as Zig's ghostty-bench: terminal-stream, terminal-parser, codepoint-width, grapheme-break, is-symbol, osc-parser, screen-clone (options --data, --terminal-rows/-cols, --mode). Like Zig it runs the step exactly once and prints nothing; time the process (hyperfine). Rust-only extras: --time (prints the step time to stderr), terminal-stream --handler=full (full stream_readonly handler).
    • binary ghostty-synthetic +ascii|+utf8|+osc [--p-valid --style] (port of src/synthetic, Zig's ghostty-gen) plus Rust-only +sgr (SGR/CSI-heavy colourised text).
    • Benchmark harness (benchmark.rs), generators (synthetic.rs, with the Zig generator tests), arg parsing (args.rs).
    • Deviations: codepoint-width --mode=wcwidth is a warn+noop (libc FFI is forbidden); --mode=simd uses the table; is-symbol modes uucode/table both use the generated table; PRNG is a local xoshiro256++ (seeded from time) instead of Zig's std.Random.
  • terminal-parser drops the returned actions (drop(p.next(c))) like Zig's _ = actions.

Zig baseline

Zig 0.16.0 is installed but build.zig.zon needs 0.15.2, so 0.15.2 was downloaded (ziglang.org tarball) and the reference tree copied to /tmp/claude-1000/ghostty-zig-build/src (reference tree untouched). Build: zig build -Demit-bench -Doptimize=ReleaseFast -Dapp-runtime=none (~90 s) gives ghostty-bench and ghostty-gen. Data was generated with the Zig ghostty-gen (ascii 200 MB, utf8 100 MB, osc --style=parser 50 MB); the SGR file (100 MB) comes from the Rust ghostty-synthetic +sgr. Timings: hyperfine -N --warmup 1 --runs 5, min wall time of the whole process, same files, same machine, Rust built with --profile production. Scripts: /tmp/claude-1000/ghostty-zig-build/run.sh.

Results (production profile; lower ms is better)

benchmark data Zig ms Rust before ms Rust after ms after/Zig
terminal-stream ascii 200 MB 1008 (198 MB/s) 2334 (86 MB/s) 1092 (183 MB/s) 1.08
terminal-stream utf8 100 MB 825 (121 MB/s) 2061 (49 MB/s) 797 (125 MB/s) 0.97
terminal-stream sgr/csi 100 MB 665 (150 MB/s) 991 (101 MB/s) 693 (144 MB/s) 1.04
terminal-parser ascii 200 MB 500 1914 268 0.54
terminal-parser utf8 100 MB 287 954 349 1.22
terminal-parser sgr/csi 100 MB 352 1068 538 1.53
codepoint-width table 100 MB utf8 326 300 296 0.91
codepoint-width simd 100 MB utf8 615 298 295 0.48
grapheme-break 100 MB utf8 361 313 314 0.87
is-symbol table 100 MB utf8 321 274 291 0.91
osc-parser 50 MB 27.7 79.7 52.9 1.91
screen-clone clone (1000x) 80x120 27.1 107 70.3 2.60
screen-clone render (1000x) 80x120 4.5 15.9 11.2 2.49

Release profile (debug-assertions + overflow checks on) terminal-stream: ascii 2.6 s, utf8 1.34 s, sgr 1.19 s (about 2x slower than production, as expected). Rust-only terminal-stream --handler=full (all actions applied) on 100 MB: ascii(200MB) 1.2 s, utf8 0.85 s, sgr 1.6 s (no Zig counterpart; profile shows no pathology).

Optimisations (behaviour-preserving)

  • Terminal::print: single-lookup fast path (print_fast) for width-1 characters mid-line on a plain narrow cell (same writes as cursor_mark_dirty+print_cell+cursor_right); new PageList::tracked_pin_x_mut. Avoids ~10 tracked-pin/node/row re-resolutions per char.
  • Page: grapheme chunk allocator is now a u64 bitmap with first-fit word scanning (was an O(chunks) scan of a Vec<bool> from index 0 each time; 38% of utf8 stream time); randomised test against the naive first-fit. Grapheme maps use a cheap integer hasher instead of SipHash.
  • Page::clear_cells: fast path when row has no styled/grapheme/hyperlink flags; no row rescans for full-row clears; clone_partial_row_from skips redundant per-cell clears and bulk-copies plain cells.
  • ghostty-vt: ASCII run fast path and hand-written strict UTF-8 decode (was str::from_utf8 per codepoint; exhaustive equivalence test); Parser::next splits the same-state fast path from the state-change path and builds Print/Execute directly (avoids store-forwarding stalls on the 64-byte Action temporaries); CSI param copy without libc memcpy; osc::Parser::next inlines the payload accumulate path.
  • RenderState::update: per-cell grapheme vectors only allocated for rows that contain graphemes.
  • Fix: Page::assert_integrity was debug-only but called unconditionally from terminal.rs (production/--release without debug-assertions failed to build); added a no-op stub.

Remaining gaps (> 1.5x)

  • screen-clone clone/render (2.5-2.6x): dominated by Page::init zero-filling and page-faulting freshly allocated Vec<Cell> (Zig gets lazily-zeroed mmap memory); fixing needs a calloc-style zeroed allocation, not possible with safe Vec<Cell>.
  • osc-parser (1.9x): per-byte Vec::push into the payload buffer (Zig appends to a fixed buffer); Parser::data() also clones the buffer.
  • terminal-parser sgr (1.5x): moving the 64-byte Option<Action> array out of the parser; real stream path (terminal-stream sgr) is at parity.

P2: screen-clone and osc-parser (production profile, min-of-20 wall, same data/machine)

benchmark Zig ms before ms after ms after/Zig
screen-clone clone (1000x) 27.1 78 28.2 1.04
screen-clone render (1000x) 4.5 11.7 11.5 2.5 (unchanged)
osc-parser (50 MB) 27.9 54.5 31.3 1.12
  • PageList::clone_range built a throwaway page via Self::init, recycled it into the pool, then create_node re-memset the whole page in Page::reinit (page-faulting every cell). Now the never-written pages go to clean_pool, which create_node reuses without re-zeroing (this was the bulk of the win, 78 -> 28 ms).
  • Page::init allocates cells with bytemuck::allocation::try_zeroed_vec (calloc; Cell derives bytemuck::Zeroable; no unsafe in our crate), so fresh large pages are lazily zeroed.
  • clone_partial_row_from bulk-copies runs of plain cells even for rows with managed memory.
  • osc::Parser::next_slice(&[u8]): exact equivalent of per-byte next (incl. 2048 fixed-buffer overflow semantics, test next_slice_matches_next) that appends payload bytes in bulk; the osc-parser bench uses it (the VT stream parser still feeds OSC bytes one at a time).
  • Remaining: render (2.5x): RenderState::update per-row Vec<Style> resize (84 B/cell, every row is styled in this benchmark) and cell copy; a lazy style vec did not help here, so reverted.