perf: add ghostty-bench and bring terminal throughput to Zig parity

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
This commit is contained in:
ddidderr committed 2026-10-12 02:08:01 +02:00
1 parent 8048ee6a05
commit 0c4f0ee412
18 files changed
+2195 -82

No files matched your search

+78
View File
@@ -0,0 +1,78 @@
# 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.