Adds crates/ghostty-bench, a port of ZIG/src/benchmark and src/synthetic: the ghostty-bench binary (terminal-stream, terminal-parser, codepoint-width, grapheme-break, is-symbol, osc-parser, screen-clone with Zig's CLI) and ghostty-synthetic data generators (+ascii, +utf8, +osc, plus a Rust-only +sgr mode). Measured against Ghostty's own ghostty-bench built with Zig 0.15.2 (ReleaseFast) vs --profile production, the hot paths were optimized without behaviour changes: - Terminal::print single-lookup fast path for width-1 chars mid-line - page grapheme allocator: bitmap first-fit instead of a linear scan (was 38% of utf8 stream time; randomized equivalence test added) - plain cells skip per-cell work in clear_cells and row cloning - ASCII fast path + strict hand-written UTF-8 decoder (exhaustive equivalence test against std) - parser/OSC parser split into inlined fast and state-change paths - RenderState allocates grapheme vectors only for grapheme rows - Page::assert_integrity no-op stub so the production profile builds terminal-stream is now at parity (ascii 1.08x, utf8 0.97x, SGR 1.04x of Zig's time). Still slower: screen-clone (~2.6x, zero-filling fresh cell Vecs vs Zig's lazily-zeroed mmap), osc-parser (1.9x), and the raw parser on SGR data (1.5x). Full table in docs/rewrite/notes/perf-P1.md. Test Plan: - cargo test --workspace (no failures) - cargo run --profile production -p ghostty-bench -- +terminal-stream --data=<file> and compare with Zig's ghostty-bench
5.4 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'sghostty-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(fullstream_readonlyhandler). - binary
ghostty-synthetic +ascii|+utf8|+osc [--p-valid --style](port ofsrc/synthetic, Zig'sghostty-gen) plus Rust-only+sgr(SGR/CSI-heavy colourised text). Benchmarkharness (benchmark.rs), generators (synthetic.rs, with the Zig generator tests), arg parsing (args.rs).- Deviations:
codepoint-width --mode=wcwidthis a warn+noop (libc FFI is forbidden);--mode=simduses the table;is-symbolmodesuucode/tableboth use the generated table; PRNG is a local xoshiro256++ (seeded from time) instead of Zig's std.Random.
- binary
terminal-parserdrops 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 ascursor_mark_dirty+print_cell+cursor_right); newPageList::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 aVec<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_fromskips redundant per-cell clears and bulk-copies plain cells.ghostty-vt: ASCII run fast path and hand-written strict UTF-8 decode (wasstr::from_utf8per codepoint; exhaustive equivalence test);Parser::nextsplits the same-state fast path from the state-change path and builds Print/Execute directly (avoids store-forwarding stalls on the 64-byteActiontemporaries); CSI param copy without libc memcpy;osc::Parser::nextinlines the payload accumulate path.RenderState::update: per-cell grapheme vectors only allocated for rows that contain graphemes.- Fix:
Page::assert_integritywas debug-only but called unconditionally fromterminal.rs(production/--releasewithout 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::initzero-filling and page-faulting freshly allocatedVec<Cell>(Zig gets lazily-zeroed mmap memory); fixing needs a calloc-style zeroed allocation, not possible with safeVec<Cell>. - osc-parser (1.9x): per-byte
Vec::pushinto 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.