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
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'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.
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_rangebuilt a throwaway page viaSelf::init, recycled it into the pool, thencreate_nodere-memset the whole page inPage::reinit(page-faulting every cell). Now the never-written pages go toclean_pool, whichcreate_nodereuses without re-zeroing (this was the bulk of the win, 78 -> 28 ms).Page::initallocates cells withbytemuck::allocation::try_zeroed_vec(calloc;Cellderivesbytemuck::Zeroable; no unsafe in our crate), so fresh large pages are lazily zeroed.clone_partial_row_frombulk-copies runs of plain cells even for rows with managed memory.osc::Parser::next_slice(&[u8]): exact equivalent of per-bytenext(incl. 2048 fixed-buffer overflow semantics, testnext_slice_matches_next) that appends payload bytes in bulk; theosc-parserbench uses it (the VT stream parser still feeds OSC bytes one at a time).- Remaining: render (2.5x):
RenderState::updateper-rowVec<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.