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 c6a0c49567
commit 062c643ea8
18 files changed
+2195 -82

No files matched your search

+96
View File
@@ -0,0 +1,96 @@
//! Minimal `--key=value` option parsing (the subset of `cli/args.zig` used by
//! the benchmark and synthetic CLIs).
use std::collections::HashMap;
use std::fs::File;
use std::io::{self, Read};
use std::str::FromStr;
/// Parsed `--key[=value]` arguments.
#[derive(Debug, Default)]
pub struct Args {
map: HashMap<String, Option<String>>,
}
impl Args {
/// Parse arguments (skipping `+action` words and the program name).
pub fn parse<I: IntoIterator<Item = String>>(iter: I) -> Result<Args, String> {
let mut map = HashMap::new();
for arg in iter {
if arg.starts_with('+') {
continue;
}
let Some(rest) = arg.strip_prefix("--") else {
return Err(format!("unexpected argument: {arg}"));
};
match rest.split_once('=') {
Some((k, v)) => map.insert(k.to_owned(), Some(v.to_owned())),
None => map.insert(rest.to_owned(), None),
};
}
Ok(Args { map })
}
/// Take a typed value, falling back to `default`.
pub fn take<T: FromStr>(&mut self, key: &str, default: T) -> Result<T, String> {
match self.map.remove(key) {
None => Ok(default),
Some(None) => Err(format!("option --{key} requires a value")),
Some(Some(v)) => v
.parse()
.map_err(|_| format!("invalid value for --{key}: {v}")),
}
}
pub fn take_opt_string(&mut self, key: &str) -> Result<Option<String>, String> {
match self.map.remove(key) {
None => Ok(None),
Some(None) => Err(format!("option --{key} requires a value")),
Some(Some(v)) => Ok(Some(v)),
}
}
/// Take a boolean flag (`--key`, `--key=true`, `--key=false`).
pub fn take_flag(&mut self, key: &str) -> Result<bool, String> {
match self.map.remove(key) {
None => Ok(false),
Some(None) => Ok(true),
Some(Some(v)) => match v.as_str() {
"true" | "t" | "1" | "yes" => Ok(true),
"false" | "f" | "0" | "no" => Ok(false),
_ => Err(format!("invalid value for --{key}: {v}")),
},
}
}
/// Error on any unconsumed option.
pub fn finish(self) -> Result<(), String> {
match self.map.into_keys().next() {
Some(k) => Err(format!("unknown option --{k}")),
None => Ok(()),
}
}
}
/// Returns the data source for the path: `None` if unset, stdin for `-`,
/// otherwise the opened file (mirrors `benchmark/options.zig`).
pub fn data_file(path: Option<&str>) -> io::Result<Option<Box<dyn Read>>> {
match path {
None => Ok(None),
Some("-") => Ok(Some(Box::new(io::stdin()))),
Some(p) => Ok(Some(Box::new(File::open(p)?))),
}
}
/// Read until `buf` is full or EOF (Zig `readSliceShort`).
pub fn read_short(r: &mut dyn Read, buf: &mut [u8]) -> io::Result<usize> {
let mut n = 0;
while n < buf.len() {
match r.read(&mut buf[n..]) {
Ok(0) => break,
Ok(m) => n += m,
Err(e) if e.kind() == io::ErrorKind::Interrupted => {}
Err(e) => return Err(e),
}
}
Ok(n)
}