# perf-P1: benchmarks and hot-path optimisation ## What was added - New crate `crates/ghostty-bench` (registered in root `[workspace.dependencies]`): - binary `ghostty-bench + [--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` 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` (Zig gets lazily-zeroed mmap memory); fixing needs a calloc-style zeroed allocation, not possible with safe `Vec`. - 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` array out of the parser; real stream path (`terminal-stream sgr`) is at parity.