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

+23
View File
@@ -0,0 +1,23 @@
[package]
name = "ghostty-bench"
version.workspace = true
edition.workspace = true
license.workspace = true
description = "Port of Ghostty's src/benchmark and src/synthetic (ghostty-bench / ghostty-synthetic)"
[[bin]]
name = "ghostty-bench"
path = "src/main.rs"
[[bin]]
name = "ghostty-synthetic"
path = "src/bin/ghostty-synthetic.rs"
[dependencies]
ghostty-terminal.workspace = true
ghostty-unicode.workspace = true
ghostty-vt.workspace = true
log = "0.4.34"
[lints]
workspace = true
+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)
}
+565
View File
@@ -0,0 +1,565 @@
//! The individual benchmarks (ports of `benchmark/*.zig`) and the CLI action
//! dispatch (`benchmark/cli.zig`).
use crate::args::{Args, data_file, read_short};
use crate::benchmark::{Benchmark, BenchmarkFailed, Result, RunMode, RunResult, run};
use ghostty_terminal::{
point::{Coordinate, Point},
render::RenderState,
screen_set::Key as ScreenKey,
terminal::{Options as TermOptions, Terminal},
};
use ghostty_vt::{
osc,
parser::Parser,
stream::{Action, Handler, Stream},
utf8_decoder::Utf8Decoder,
};
use std::hint::black_box;
use std::io::Read;
const CHUNK: usize = 4096;
/// The available actions (benchmarks) of the CLI.
pub const ACTIONS: &[&str] = &[
"codepoint-width",
"grapheme-break",
"screen-clone",
"terminal-parser",
"terminal-stream",
"is-symbol",
"osc-parser",
];
fn warn(what: &str, e: &dyn std::fmt::Display) -> BenchmarkFailed {
log::warn!("{what} err={e}");
BenchmarkFailed
}
/// Shared data-file handling: opened in setup, closed in teardown.
struct Data {
path: Option<String>,
f: Option<Box<dyn Read>>,
}
impl Data {
fn new(path: Option<String>) -> Data {
Data { path, f: None }
}
fn open(&mut self) -> Result {
self.f =
data_file(self.path.as_deref()).map_err(|e| warn("error opening data file", &e))?;
Ok(())
}
/// Feed every 4096-byte chunk of the file to `func`.
fn for_each_chunk(&mut self, mut func: impl FnMut(&[u8]) -> Result) -> Result {
let Some(f) = self.f.as_mut() else {
return Ok(());
};
let mut buf = [0u8; CHUNK];
loop {
let n = read_short(f.as_mut(), &mut buf)
.map_err(|e| warn("error reading data file", &e))?;
if n == 0 {
return Ok(());
}
func(&buf[..n])?;
}
}
}
// ---------------------------------------------------------------------------
// terminal-stream
struct PrintHandler {
t: Terminal,
}
impl Handler for PrintHandler {
type Error = ghostty_terminal::terminal::Error;
#[inline]
fn vt(&mut self, action: Action<'_>) -> std::result::Result<(), Self::Error> {
if let Action::Print(p) = action {
self.t.print(p.cp)?;
}
Ok(())
}
}
pub struct TerminalStream {
data: Data,
stream: Stream<PrintHandler>,
/// Rust-only: use the full `stream_readonly` handler (applies every
/// terminal-modifying action) instead of the print-only handler.
full: bool,
}
impl TerminalStream {
pub fn create(args: &mut Args) -> std::result::Result<Self, String> {
let rows = args.take("terminal-rows", 80u16)?;
let cols = args.take("terminal-cols", 120u16)?;
let data = args.take_opt_string("data")?;
let full = match args.take_opt_string("handler")?.as_deref() {
None | Some("print") => false,
Some("full") => true,
Some(m) => return Err(format!("invalid value for --handler: {m}")),
};
let t = Terminal::new(TermOptions::new(cols, rows)).map_err(|e| format!("{e:?}"))?;
Ok(TerminalStream {
data: Data::new(data),
stream: Stream::new(PrintHandler { t }),
full,
})
}
/// Total bytes processed so far is not tracked; see `data_len`.
pub fn terminal(&self) -> &Terminal {
&self.stream.handler.t
}
}
impl Benchmark for TerminalStream {
fn setup(&mut self) -> Result {
self.stream.handler.t.full_reset();
self.data.open()
}
fn teardown(&mut self) {
self.data.f = None;
}
fn step(&mut self) -> Result {
if self.full {
let mut s = self.stream.handler.t.vt_stream();
return self.data.for_each_chunk(|chunk| {
s.next_slice(chunk)
.map_err(|e| warn("error processing data file chunk", &format!("{e:?}")))
});
}
let stream = &mut self.stream;
self.data.for_each_chunk(|chunk| {
stream
.next_slice(chunk)
.map_err(|e| warn("error processing data file chunk", &format!("{e:?}")))
})
}
}
// ---------------------------------------------------------------------------
// terminal-parser
pub struct TerminalParser {
data: Data,
}
impl TerminalParser {
pub fn create(args: &mut Args) -> std::result::Result<Self, String> {
Ok(TerminalParser {
data: Data::new(args.take_opt_string("data")?),
})
}
}
impl Benchmark for TerminalParser {
fn setup(&mut self) -> Result {
self.data.open()
}
fn teardown(&mut self) {
self.data.f = None;
}
fn step(&mut self) -> Result {
let mut p = Parser::new();
self.data.for_each_chunk(|chunk| {
for &c in chunk {
drop(p.next(c));
}
Ok(())
})
}
}
// ---------------------------------------------------------------------------
// osc-parser
pub struct OscParser {
data: Data,
parser: osc::Parser,
}
impl OscParser {
pub fn create(args: &mut Args) -> std::result::Result<Self, String> {
Ok(OscParser {
data: Data::new(args.take_opt_string("data")?),
parser: osc::Parser::init(true),
})
}
}
impl Benchmark for OscParser {
fn setup(&mut self) -> Result {
self.data.open()?;
self.parser.reset();
Ok(())
}
fn teardown(&mut self) {
self.data.f = None;
}
fn step(&mut self) -> Result {
let Some(f) = self.data.f.as_mut() else {
return Ok(());
};
let mut osc_buf = [0u8; 4096];
loop {
let mut len_buf = [0u8; 8];
match read_short(f.as_mut(), &mut len_buf) {
Ok(8) => {}
Ok(_) => return Ok(()),
Err(_) => return Err(BenchmarkFailed),
}
let len = usize::from_le_bytes(len_buf);
if len > osc_buf.len() {
return Err(BenchmarkFailed);
}
match read_short(f.as_mut(), &mut osc_buf[..len]) {
Ok(n) if n == len => {}
Ok(_) => return Ok(()),
Err(_) => return Err(BenchmarkFailed),
}
for &c in &osc_buf[..len] {
self.parser.next(c);
}
black_box(self.parser.end(Some(0x07)));
self.parser.reset();
}
}
}
// ---------------------------------------------------------------------------
// codepoint-width
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum WidthMode {
Noop,
Wcwidth,
Simd,
Table,
}
pub struct CodepointWidth {
data: Data,
mode: WidthMode,
}
impl CodepointWidth {
pub fn create(args: &mut Args) -> std::result::Result<Self, String> {
let mode = match args.take_opt_string("mode")?.as_deref() {
None | Some("noop") => WidthMode::Noop,
Some("wcwidth") => WidthMode::Wcwidth,
Some("simd") => WidthMode::Simd,
Some("table") => WidthMode::Table,
Some(m) => return Err(format!("invalid value for --mode: {m}")),
};
Ok(CodepointWidth {
data: Data::new(args.take_opt_string("data")?),
mode,
})
}
}
/// Decode `chunk` and call `func` for each codepoint.
#[inline]
fn decode_each(d: &mut Utf8Decoder, chunk: &[u8], mut func: impl FnMut(u32)) {
for &c in chunk {
let (cp, consumed) = d.next(c);
debug_assert!(consumed);
if let Some(cp) = cp {
func(cp);
}
}
}
impl Benchmark for CodepointWidth {
fn setup(&mut self) -> Result {
self.data.open()
}
fn teardown(&mut self) {
self.data.f = None;
}
fn step(&mut self) -> Result {
match self.mode {
WidthMode::Noop => Ok(()),
WidthMode::Wcwidth => {
// libc wcwidth needs FFI, which this workspace forbids.
log::warn!("wcwidth mode is not available in the Rust port");
Ok(())
}
// The Rust port has no separate SIMD width function: both use
// the lookup table.
WidthMode::Simd | WidthMode::Table => {
let mut d = Utf8Decoder::new();
let table = ghostty_unicode::table();
self.data.for_each_chunk(|chunk| {
decode_each(&mut d, chunk, |cp| {
// Same trick as terminal.rs.
black_box(if cp <= 0xFF { 1 } else { table.get(cp).width });
});
Ok(())
})
}
}
}
}
// ---------------------------------------------------------------------------
// grapheme-break
pub struct GraphemeBreak {
data: Data,
noop: bool,
}
impl GraphemeBreak {
pub fn create(args: &mut Args) -> std::result::Result<Self, String> {
let noop = match args.take_opt_string("mode")?.as_deref() {
None | Some("table") => false,
Some("noop") => true,
Some(m) => return Err(format!("invalid value for --mode: {m}")),
};
Ok(GraphemeBreak {
data: Data::new(args.take_opt_string("data")?),
noop,
})
}
}
impl Benchmark for GraphemeBreak {
fn setup(&mut self) -> Result {
self.data.open()
}
fn teardown(&mut self) {
self.data.f = None;
}
fn step(&mut self) -> Result {
let mut d = Utf8Decoder::new();
if self.noop {
return self.data.for_each_chunk(|chunk| {
for &c in chunk {
black_box(d.next(c));
}
Ok(())
});
}
let mut state = ghostty_unicode::BreakState::Default;
let mut cp1 = 0u32;
self.data.for_each_chunk(|chunk| {
decode_each(&mut d, chunk, |cp2| {
black_box(ghostty_unicode::grapheme_break(cp1, cp2, &mut state));
cp1 = cp2;
});
Ok(())
})
}
}
// ---------------------------------------------------------------------------
// is-symbol
pub struct IsSymbol {
data: Data,
}
impl IsSymbol {
pub fn create(args: &mut Args) -> std::result::Result<Self, String> {
// Modes `uucode` and `table` both use the generated table here.
match args.take_opt_string("mode")?.as_deref() {
None | Some("uucode" | "table") => {}
Some(m) => return Err(format!("invalid value for --mode: {m}")),
}
Ok(IsSymbol {
data: Data::new(args.take_opt_string("data")?),
})
}
}
impl Benchmark for IsSymbol {
fn setup(&mut self) -> Result {
self.data.open()
}
fn teardown(&mut self) {
self.data.f = None;
}
fn step(&mut self) -> Result {
let mut d = Utf8Decoder::new();
let table = ghostty_unicode::symbols_table::table();
self.data.for_each_chunk(|chunk| {
decode_each(&mut d, chunk, |cp| {
black_box(table.get(cp));
});
Ok(())
})
}
}
// ---------------------------------------------------------------------------
// screen-clone
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum CloneMode {
Noop,
Clone,
Render,
}
pub struct ScreenClone {
data: Data,
mode: CloneMode,
terminal: Terminal,
/// Clones are kept alive so freeing is not benchmarked.
keep: Vec<ghostty_terminal::screen::Screen>,
}
impl ScreenClone {
pub fn create(args: &mut Args) -> std::result::Result<Self, String> {
let mode = match args.take_opt_string("mode")?.as_deref() {
None | Some("clone") => CloneMode::Clone,
Some("noop") => CloneMode::Noop,
Some("render") => CloneMode::Render,
Some(m) => return Err(format!("invalid value for --mode: {m}")),
};
let rows = args.take("terminal-rows", 80u16)?;
let cols = args.take("terminal-cols", 120u16)?;
let data = args.take_opt_string("data")?;
let terminal = Terminal::new(TermOptions::new(cols, rows)).map_err(|e| format!("{e:?}"))?;
Ok(ScreenClone {
data: Data::new(data),
mode,
terminal,
keep: Vec::new(),
})
}
}
impl Benchmark for ScreenClone {
fn setup(&mut self) -> Result {
self.terminal.full_reset();
// Force a style on every single row.
let rows = self.terminal.rows;
let mut s = self.terminal.vt_stream();
let fail = |e: &dyn std::fmt::Debug| warn("error processing setup data", &format!("{e:?}"));
s.next_slice(b"\x1b[48;2;20;40;60m").map_err(|e| fail(&e))?;
for _ in 0..rows - 1 {
s.next_slice(b"hello\r\n").map_err(|e| fail(&e))?;
}
s.next_slice(b"hello").map_err(|e| fail(&e))?;
// Setup our terminal state from the data file (not benchmarked).
self.data.open()?;
self.data.for_each_chunk(|chunk| {
s.next_slice(chunk)
.map_err(|e| warn("error processing data file chunk", &format!("{e:?}")))
})?;
self.data.f = None;
Ok(())
}
fn step(&mut self) -> Result {
// We loop because it's so fast that a single run doesn't capture it.
match self.mode {
CloneMode::Noop => {
for _ in 0..1000 {
black_box(self.terminal.screens.active());
}
}
CloneMode::Clone => {
for _ in 0..1000 {
let s = self.terminal.screens.active();
let copy = s
.clone_screen(Point::Viewport(Coordinate { x: 0, y: 0 }), None)
.map_err(|e| warn("error cloning screen", &format!("{e:?}")))?;
self.keep.push(black_box(copy));
}
}
CloneMode::Render => {
let mut state = RenderState::new();
let fail = |e: &dyn std::fmt::Debug| {
warn("error updating render state", &format!("{e:?}"))
};
// The first run is dominated by allocation; ignore it.
state.update(&mut self.terminal).map_err(|e| fail(&e))?;
for _ in 0..1000 {
// Forces a full rebuild because it thinks our screen changed.
state.screen = ScreenKey::Alternate;
state.update(&mut self.terminal).map_err(|e| fail(&e))?;
black_box(&state);
}
}
}
Ok(())
}
fn teardown(&mut self) {
self.keep.clear();
}
}
// ---------------------------------------------------------------------------
// CLI
/// Run the named action with the given (already `+action`-free) arguments.
/// When `time` is set the measured result is returned.
pub fn main_action(action: &str, mut args: Args) -> std::result::Result<RunResult, String> {
fn go<B: Benchmark>(
b: std::result::Result<B, String>,
args: Args,
) -> std::result::Result<RunResult, String> {
let mut b = b?;
args.finish()?;
run(&mut b, RunMode::Once).map_err(|e| e.to_string())
}
// Options are consumed by `create`; leftovers are errors.
let a = &mut args;
match action {
"screen-clone" => {
let b = ScreenClone::create(a);
go(b, args)
}
"terminal-stream" => {
let b = TerminalStream::create(a);
go(b, args)
}
"codepoint-width" => {
let b = CodepointWidth::create(a);
go(b, args)
}
"grapheme-break" => {
let b = GraphemeBreak::create(a);
go(b, args)
}
"terminal-parser" => {
let b = TerminalParser::create(a);
go(b, args)
}
"is-symbol" => {
let b = IsSymbol::create(a);
go(b, args)
}
"osc-parser" => {
let b = OscParser::create(a);
go(b, args)
}
_ => Err(format!("invalid action: +{action}")),
}
}
+96
View File
@@ -0,0 +1,96 @@
//! A single benchmark case (port of `benchmark/Benchmark.zig`).
use std::time::{Duration, Instant};
/// The only error a benchmark can report: it failed and the run ends.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct BenchmarkFailed;
impl std::fmt::Display for BenchmarkFailed {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.write_str("benchmark failed")
}
}
impl std::error::Error for BenchmarkFailed {}
pub type Result<T = ()> = std::result::Result<T, BenchmarkFailed>;
/// How to run: once, or repeatedly for a duration.
#[derive(Debug, Clone, Copy)]
pub enum RunMode {
Once,
Duration(Duration),
}
#[derive(Debug, Clone, Copy, Default)]
pub struct RunResult {
pub iterations: u32,
pub duration: Duration,
}
/// The benchmark interface (Zig `Benchmark.VTable`).
pub trait Benchmark {
/// A single step; the work under test.
fn step(&mut self) -> Result;
/// Called once before the first step; not measured.
fn setup(&mut self) -> Result {
Ok(())
}
/// Called once after the last step.
fn teardown(&mut self) {}
}
/// Run the benchmark.
pub fn run(b: &mut dyn Benchmark, mode: RunMode) -> Result<RunResult> {
b.setup()?;
let r = run_steps(b, mode);
b.teardown();
r
}
fn run_steps(b: &mut dyn Benchmark, mode: RunMode) -> Result<RunResult> {
let mut result = RunResult::default();
let start = Instant::now();
loop {
b.step()?;
result.iterations += 1;
let elapsed = start.elapsed();
let exit = match mode {
RunMode::Once => true,
RunMode::Duration(d) => elapsed >= d,
};
if exit {
result.duration = elapsed;
return Ok(result);
}
}
}
#[cfg(test)]
mod tests {
use super::*;
#[derive(Default)]
struct Simple {
setup_i: usize,
step_i: usize,
}
impl Benchmark for Simple {
fn step(&mut self) -> Result {
self.step_i += 1;
Ok(())
}
fn setup(&mut self) -> Result {
self.setup_i += 1;
Ok(())
}
}
#[test]
fn benchmark_once() {
let mut s = Simple::default();
let r = run(&mut s, RunMode::Once).unwrap();
assert_eq!(s.setup_i, 1);
assert_eq!(s.step_i, 1);
assert_eq!(r.iterations, 1);
}
}
@@ -0,0 +1,64 @@
//! `ghostty-synthetic +<ascii|utf8|osc> [--option=value ...]`: endless
//! synthetic data on stdout (port of `synthetic/main.zig`).
use ghostty_bench::{
args::Args,
synthetic::{OscStyle, Rng, run_ascii, run_osc, run_sgr, run_utf8},
};
use std::io::{BufWriter, Write};
use std::process::ExitCode;
fn run() -> Result<(), String> {
let argv: Vec<String> = std::env::args().skip(1).collect();
let mut action = None;
for a in &argv {
if let Some(name) = a.strip_prefix('+') {
if action.is_some() {
return Err("multiple actions specified".into());
}
action = Some(name.to_owned());
}
}
let action = action.ok_or("no action specified; available: ascii, osc, sgr, utf8")?;
let mut args = Args::parse(argv)?;
let mut rng = Rng::from_time();
let mut out = BufWriter::with_capacity(2048, std::io::stdout().lock());
let res = match action.as_str() {
"ascii" => {
args.finish()?;
run_ascii(&mut out, &mut rng)
}
"utf8" => {
args.finish()?;
run_utf8(&mut out, &mut rng)
}
"sgr" => {
args.finish()?;
run_sgr(&mut out, &mut rng)
}
"osc" => {
let p_valid = args.take("p-valid", 0.5f64)?;
let style = match args.take_opt_string("style")?.as_deref() {
None | Some("streaming") => OscStyle::Streaming,
Some("parser") => OscStyle::Parser,
Some(s) => return Err(format!("invalid value for --style: {s}")),
};
args.finish()?;
run_osc(&mut out, &mut rng, p_valid, style)
}
a => return Err(format!("invalid action: +{a}")),
};
res.map_err(|e| e.to_string())?;
// Broken pipe on flush is a normal end.
let _ = out.flush();
Ok(())
}
fn main() -> ExitCode {
match run() {
Ok(()) => ExitCode::SUCCESS,
Err(e) => {
eprintln!("error: {e}");
ExitCode::FAILURE
}
}
}
+13
View File
@@ -0,0 +1,13 @@
//! Port of `ZIG/src/benchmark` (the `ghostty-bench` CLI) and
//! `ZIG/src/synthetic` (the `ghostty-synthetic` data generators).
//!
//! Like the Zig binary, `ghostty-bench +<action> [--opt=value ...]` runs the
//! selected benchmark exactly once and prints nothing; time the process with
//! `hyperfine`/`perf stat`. As a Rust-side convenience `--time` prints the
//! measured step duration and throughput to stderr.
#![cfg_attr(test, allow(clippy::unwrap_used))]
pub mod args;
pub mod benches;
pub mod benchmark;
pub mod synthetic;
+49
View File
@@ -0,0 +1,49 @@
//! `ghostty-bench +<action> [--option=value ...]` (port of `main_bench.zig`).
use ghostty_bench::{args::Args, benches};
use std::process::ExitCode;
fn main() -> ExitCode {
let argv: Vec<String> = std::env::args().skip(1).collect();
let mut action = None;
for a in &argv {
if let Some(name) = a.strip_prefix('+') {
if action.is_some() {
eprintln!("error: multiple actions specified");
return ExitCode::FAILURE;
}
action = Some(name.to_owned());
}
}
let Some(action) = action else {
eprintln!(
"error: no action specified; available: {}",
benches::ACTIONS.join(", ")
);
return ExitCode::FAILURE;
};
let mut args = match Args::parse(argv) {
Ok(a) => a,
Err(e) => {
eprintln!("error: {e}");
return ExitCode::FAILURE;
}
};
// Rust-only convenience: print the measured step time to stderr.
let time = args.take_flag("time").unwrap_or(false);
match benches::main_action(&action, args) {
Ok(r) => {
if time {
eprintln!(
"iterations={} duration_ns={}",
r.iterations,
r.duration.as_nanos()
);
}
ExitCode::SUCCESS
}
Err(e) => {
eprintln!("error: {e}");
ExitCode::FAILURE
}
}
}
+746
View File
@@ -0,0 +1,746 @@
//! Synthetic data generators (port of `ZIG/src/synthetic`): random bytes,
//! UTF-8 and OSC sequences, used to feed the benchmarks.
use ghostty_vt::string_encoding::url_percent_encode;
use std::io::{self, Write};
/// xoshiro256++ (what Zig's `std.Random.DefaultPrng` is).
#[derive(Debug, Clone)]
pub struct Rng {
s: [u64; 4],
}
impl Rng {
pub fn new(seed: u64) -> Rng {
// splitmix64 seeding, as in Zig.
let mut x = seed;
let mut s = [0u64; 4];
for v in &mut s {
x = x.wrapping_add(0x9e37_79b9_7f4a_7c15);
let mut z = x;
z = (z ^ (z >> 30)).wrapping_mul(0xbf58_476d_1ce4_e5b9);
z = (z ^ (z >> 27)).wrapping_mul(0x94d0_49bb_1331_11eb);
*v = z ^ (z >> 31);
}
Rng { s }
}
pub fn from_time() -> Rng {
let nanos = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.map_or(0, |d| d.as_nanos());
Rng::new(nanos as u64)
}
pub fn next_u64(&mut self) -> u64 {
let s = &mut self.s;
let result = s[0].wrapping_add(s[3]).rotate_left(23).wrapping_add(s[0]);
let t = s[1] << 17;
s[2] ^= s[0];
s[3] ^= s[1];
s[1] ^= s[2];
s[0] ^= s[3];
s[2] ^= t;
s[3] = s[3].rotate_left(45);
result
}
pub fn bytes(&mut self, out: &mut [u8]) {
for chunk in out.chunks_mut(8) {
let v = self.next_u64().to_le_bytes();
chunk.copy_from_slice(&v[..chunk.len()]);
}
}
pub fn boolean(&mut self) -> bool {
self.next_u64() & 1 == 1
}
pub fn float(&mut self) -> f64 {
(self.next_u64() >> 11) as f64 / (1u64 << 53) as f64
}
/// Uniform-ish (biased) integer in `min..=max`.
pub fn int_range_at_most(&mut self, min: u64, max: u64) -> u64 {
debug_assert!(min <= max);
let span = u128::from(max - min) + 1;
min + ((u128::from(self.next_u64()) * span) >> 64) as u64
}
pub fn usize_range(&mut self, min: usize, max: usize) -> usize {
self.int_range_at_most(min as u64, max as u64) as usize
}
/// Index chosen with probability proportional to `weights`.
pub fn weighted_index(&mut self, weights: &[f64]) -> usize {
let sum: f64 = weights.iter().sum();
let mut v = self.float() * sum;
for (i, w) in weights.iter().enumerate() {
if v < *w {
return i;
}
v -= w;
}
weights.len() - 1
}
}
/// Alphabet of bytes for which `f` is true.
pub fn generate_alphabet(f: impl Fn(u8) -> bool) -> Vec<u8> {
(0..=255u8).filter(|&c| f(c)).collect()
}
/// Generates bytes.
#[derive(Debug, Clone)]
pub struct Bytes<'a> {
pub min_len: usize,
pub max_len: usize,
pub alphabet: Option<&'a [u8]>,
}
impl Default for Bytes<'_> {
fn default() -> Self {
Bytes {
min_len: 1,
max_len: usize::MAX,
alphabet: None,
}
}
}
impl<'a> Bytes<'a> {
#[must_use]
pub fn new_alphabet(&self, alphabet: Option<&'a [u8]>) -> Bytes<'a> {
Bytes {
alphabet,
..self.clone()
}
}
#[must_use]
pub fn at_most(&self, new_max: usize) -> Bytes<'a> {
Bytes {
alphabet: self.alphabet,
min_len: self.min_len.min(self.max_len.min(new_max)),
max_len: self.max_len.min(new_max),
}
}
/// Write random data, returning the number of bytes written.
pub fn write<W: Write>(&self, rng: &mut Rng, w: &mut W) -> io::Result<usize> {
debug_assert!(self.min_len >= 1);
debug_assert!(self.max_len >= self.min_len);
let len = rng.usize_range(self.min_len, self.max_len);
let mut buf = [0u8; 8];
let mut remaining = len;
while remaining > 0 {
let data = &mut buf[..remaining.min(8)];
rng.bytes(data);
if let Some(a) = self.alphabet {
for b in data.iter_mut() {
*b = a[*b as usize % a.len()];
}
}
w.write_all(data)?;
remaining -= data.len();
}
Ok(len)
}
/// `next` of the Zig generator: at most `max_len`.
pub fn next<W: Write>(&self, rng: &mut Rng, w: &mut W, max_len: usize) -> io::Result<()> {
self.at_most(max_len).write(rng, w).map(|_| ())
}
}
/// Generates UTF-8 with a configurable distribution of encoding lengths.
#[derive(Debug, Clone)]
pub struct Utf8 {
pub min_len: usize,
pub max_len: usize,
/// Relative weights for 1, 2, 3 and 4 byte encodings.
pub p_length: [f64; 4],
}
impl Default for Utf8 {
fn default() -> Self {
Utf8 {
min_len: 1,
max_len: usize::MAX,
p_length: [1.0; 4],
}
}
}
impl Utf8 {
pub fn next<W: Write>(&self, rng: &mut Rng, w: &mut W, max_len: usize) -> io::Result<()> {
let len = rng.usize_range(self.min_len, self.max_len).min(max_len);
let mut rem = len;
while rem > 0 {
let mut n = rng.weighted_index(&self.p_length) + 1;
while n > rem {
n -= 1;
}
let cp = match n {
1 => rng.int_range_at_most(0x00, 0x7F),
2 => rng.int_range_at_most(0x80, 0x7FF),
3 => rng.int_range_at_most(0x800, 0xFFFF),
_ => rng.int_range_at_most(0x10000, 0x10FFFF),
} as u32;
// Surrogates can't be encoded; redo the loop.
let Some(ch) = char::from_u32(cp) else {
continue;
};
let mut buf = [0u8; 4];
let s = ch.encode_utf8(&mut buf);
w.write_all(s.as_bytes())?;
rem -= s.len();
}
Ok(())
}
}
/// Valid OSC request kinds.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum ValidKind {
ChangeWindowTitle,
PromptStart,
PromptEnd,
EndOfInput,
EndOfCommand,
RxvtNotify,
MouseShape,
ClipboardOperation,
ReportPwd,
HyperlinkStart,
HyperlinkEnd,
ConemuProgress,
Iterm2Notification,
}
const VALID_KINDS: [ValidKind; 13] = [
ValidKind::ChangeWindowTitle,
ValidKind::PromptStart,
ValidKind::PromptEnd,
ValidKind::EndOfInput,
ValidKind::EndOfCommand,
ValidKind::RxvtNotify,
ValidKind::MouseShape,
ValidKind::ClipboardOperation,
ValidKind::ReportPwd,
ValidKind::HyperlinkStart,
ValidKind::HyperlinkEnd,
ValidKind::ConemuProgress,
ValidKind::Iterm2Notification,
];
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum InvalidKind {
/// Literally random bytes. Might even be valid, but probably not.
Random,
/// A good prefix, but ultimately invalid format.
GoodPrefix,
}
const INVALID_KINDS: [InvalidKind; 2] = [InvalidKind::Random, InvalidKind::GoodPrefix];
/// Generates random terminal OSC requests.
#[derive(Debug, Clone)]
pub struct Osc {
/// Probability of a valid OSC sequence being generated.
pub p_valid: f64,
pub p_valid_kind: [f64; 13],
pub p_invalid_kind: [f64; 2],
kv_alphabet: Vec<u8>,
full_alphabet: Vec<u8>,
ascii_alphabet: Vec<u8>,
alphabetic_alphabet: Vec<u8>,
alphanumeric_alphabet: Vec<u8>,
}
impl Default for Osc {
fn default() -> Self {
Osc::new(1.0)
}
}
impl Osc {
pub fn new(p_valid: f64) -> Osc {
Osc {
p_valid,
p_valid_kind: [1.0; 13],
p_invalid_kind: [1.0; 2],
kv_alphabet: generate_alphabet(|c| {
!matches!(c, 0x1b | 0x07 | b';' | b'=') && (0x20..0x7f).contains(&c)
}),
full_alphabet: generate_alphabet(|c| !matches!(c, 0x1b | 0x07)),
ascii_alphabet: generate_alphabet(|c| (0x20..0x7f).contains(&c)),
alphabetic_alphabet: generate_alphabet(|c| c.is_ascii_alphabetic()),
alphanumeric_alphabet: generate_alphabet(|c| c.is_ascii_alphanumeric()),
}
}
fn bytes(&self) -> Bytes<'_> {
Bytes {
alphabet: Some(&self.full_alphabet),
..Bytes::default()
}
}
/// Get the next OSC request, prefixed `ESC ]` and terminated by `BEL`.
/// `max_len` must be at least 3.
pub fn next<W: Write>(&self, rng: &mut Rng, w: &mut W, max_len: usize) -> io::Result<()> {
assert!(max_len >= 3);
w.write_all(b"\x1b]")?;
self.next_unwrapped(rng, w, max_len - 3)?;
w.write_all(b"\x07")
}
fn next_unwrapped<W: Write>(&self, rng: &mut Rng, w: &mut W, max_len: usize) -> io::Result<()> {
if rng.float() > self.p_valid {
let k = INVALID_KINDS[rng.weighted_index(&self.p_invalid_kind)];
self.invalid_exact(rng, w, k, max_len)
} else {
let k = VALID_KINDS[rng.weighted_index(&self.p_valid_kind)];
self.valid_exact(rng, w, k, max_len)
}
}
#[allow(clippy::too_many_lines)]
fn valid_exact<W: Write>(
&self,
rng: &mut Rng,
w: &mut W,
k: ValidKind,
max_len: usize,
) -> io::Result<()> {
match k {
ValidKind::ChangeWindowTitle => {
if max_len < 3 {
return Ok(());
}
w.write_all(b"0;")?;
self.bytes().at_most(max_len - 3).write(rng, w)?;
}
ValidKind::PromptStart => {
if max_len < 4 {
return Ok(());
}
let mut remaining = max_len;
w.write_all(b"133;A")?;
remaining -= 4;
if rng.boolean() && remaining >= 6 {
w.write_all(b";aid=")?;
remaining -= 5;
remaining -= self
.bytes()
.new_alphabet(Some(&self.kv_alphabet))
.at_most(remaining.min(16))
.write(rng, w)?;
}
if rng.boolean() && remaining >= 9 {
w.write_all(b";redraw=")?;
w.write_all(if rng.boolean() { b"1" } else { b"0" })?;
}
}
ValidKind::PromptEnd => {
if max_len >= 4 {
w.write_all(b"133;B")?;
}
}
ValidKind::EndOfInput => {
if max_len < 5 {
return Ok(());
}
let mut remaining = max_len;
w.write_all(b"133;C")?;
remaining -= 5;
if rng.boolean() {
let prefix = b";cmdline_url=";
if remaining < prefix.len() + 1 {
return Ok(());
}
w.write_all(prefix)?;
remaining -= prefix.len();
let mut tmp = Vec::new();
self.bytes()
.new_alphabet(Some(&self.ascii_alphabet))
.at_most(remaining.min(128))
.write(rng, &mut tmp)?;
url_percent_encode(w, &tmp)?;
}
}
ValidKind::EndOfCommand => {
if max_len < 4 {
return Ok(());
}
w.write_all(b"133;D")?;
if rng.boolean() && max_len >= 7 {
write!(w, ";{}", rng.next_u64() as u8)?;
}
}
ValidKind::MouseShape => {
if max_len < 4 {
return Ok(());
}
w.write_all(b"22;")?;
self.bytes()
.new_alphabet(Some(&self.alphabetic_alphabet))
.at_most(32.min(max_len - 3))
.write(rng, w)?;
}
ValidKind::RxvtNotify => {
let prefix = b"777;notify;";
if max_len < prefix.len() {
return Ok(());
}
let mut remaining = max_len;
w.write_all(prefix)?;
remaining -= prefix.len();
remaining -= self
.bytes()
.new_alphabet(Some(&self.kv_alphabet))
.at_most(remaining.saturating_sub(2).clamp(1, 32))
.write(rng, w)?;
w.write_all(b";")?;
remaining -= 1;
if remaining >= 1 {
self.bytes().at_most(remaining).write(rng, w)?;
}
}
ValidKind::ClipboardOperation => {
w.write_all(b"52;")?;
let mut remaining = max_len.saturating_sub(3);
if rng.boolean() {
remaining -= self
.bytes()
.new_alphabet(Some(&self.alphabetic_alphabet))
.at_most(1)
.write(rng, w)?;
}
w.write_all(b";")?;
remaining = remaining.saturating_sub(1);
if rng.boolean() && remaining >= 1 {
self.bytes().at_most(remaining).write(rng, w)?;
}
}
ValidKind::ReportPwd => {
let prefix = b"7;file://localhost";
if max_len < prefix.len() {
return Ok(());
}
let mut remaining = max_len;
w.write_all(prefix)?;
remaining -= prefix.len();
for _ in 0..rng.usize_range(2, 5) {
w.write_all(b"/")?;
remaining = remaining.saturating_sub(1);
if remaining == 0 {
break;
}
remaining -= self
.bytes()
.new_alphabet(Some(&self.alphanumeric_alphabet))
.at_most(remaining.min(16))
.write(rng, w)?;
}
}
ValidKind::HyperlinkStart => {
w.write_all(b"8;")?;
if rng.boolean() {
w.write_all(b"id=")?;
self.alnum().at_most(16).write(rng, w)?;
}
w.write_all(b";https://localhost")?;
for _ in 0..rng.usize_range(2, 5) {
w.write_all(b"/")?;
self.alnum().at_most(16).write(rng, w)?;
}
}
ValidKind::HyperlinkEnd => {
if max_len >= 3 {
w.write_all(b"8;;")?;
}
}
ValidKind::ConemuProgress => {
w.write_all(b"9;")?;
let c = rng.int_range_at_most(0, 4);
match c {
0 | 3 => write!(w, ";{c}")?,
_ => {
if rng.boolean() {
write!(w, ";{c}")?;
} else {
write!(w, ";{c};{}", rng.int_range_at_most(0, 100))?;
}
}
}
}
ValidKind::Iterm2Notification => {
if max_len < 3 {
return Ok(());
}
// Prefix ensures this is not interpreted as a ConEmu OSC.
w.write_all(b"9;_")?;
self.bytes()
.new_alphabet(Some(&self.ascii_alphabet))
.at_most(max_len - 3)
.write(rng, w)?;
}
}
Ok(())
}
fn alnum(&self) -> Bytes<'_> {
self.bytes().new_alphabet(Some(&self.alphanumeric_alphabet))
}
fn invalid_exact<W: Write>(
&self,
rng: &mut Rng,
w: &mut W,
k: InvalidKind,
max_len: usize,
) -> io::Result<()> {
match k {
InvalidKind::Random => {
self.bytes().at_most(max_len).write(rng, w)?;
}
InvalidKind::GoodPrefix => {
w.write_all(b"133;")?;
self.bytes()
.at_most(max_len.saturating_sub(4).max(1))
.write(rng, w)?;
}
}
Ok(())
}
}
/// A fixed-capacity writer that fails when full (Zig `Writer.fixed`).
pub struct Fixed<'a> {
buf: &'a mut [u8],
len: usize,
}
impl<'a> Fixed<'a> {
pub fn new(buf: &'a mut [u8]) -> Self {
Fixed { buf, len: 0 }
}
pub fn buffered(&self) -> &[u8] {
&self.buf[..self.len]
}
}
impl Write for Fixed<'_> {
fn write(&mut self, data: &[u8]) -> io::Result<usize> {
let n = data.len().min(self.buf.len() - self.len);
self.buf[self.len..self.len + n].copy_from_slice(&data[..n]);
self.len += n;
if n == 0 && !data.is_empty() {
return Err(io::Error::new(
io::ErrorKind::WriteZero,
"fixed buffer full",
));
}
Ok(n)
}
fn flush(&mut self) -> io::Result<()> {
Ok(())
}
}
/// `ghostty-synthetic +ascii`: endless printable-ASCII (no space) stream.
pub fn run_ascii<W: Write>(w: &mut W, rng: &mut Rng) -> io::Result<()> {
let alphabet = generate_alphabet(|c| c != b' ' && (0x20..0x7f).contains(&c));
let generator = Bytes {
min_len: 1024,
max_len: 1024,
alphabet: Some(&alphabet),
};
loop {
if finished(generator.write(rng, w))? {
return Ok(());
}
}
}
/// `ghostty-synthetic +utf8`: endless UTF-8 stream.
pub fn run_utf8<W: Write>(w: &mut W, rng: &mut Rng) -> io::Result<()> {
let generator = Utf8::default();
loop {
if finished(generator.next(rng, w, 1024).map(|()| 0))? {
return Ok(());
}
}
}
/// `ghostty-synthetic +sgr` (Rust-only): endless SGR/CSI-heavy text that
/// resembles colorized program output: colored words, cursor movement,
/// erase sequences and newlines.
pub fn run_sgr<W: Write>(w: &mut W, rng: &mut Rng) -> io::Result<()> {
let words = generate_alphabet(|c| c.is_ascii_lowercase());
let mut line = Vec::with_capacity(512);
loop {
line.clear();
for _ in 0..rng.usize_range(4, 12) {
match rng.usize_range(0, 9) {
0 => write!(line, "\x1b[{}m", rng.usize_range(30, 37))?,
1 => write!(
line,
"\x1b[38;2;{};{};{}m",
rng.next_u64() as u8,
rng.next_u64() as u8,
rng.next_u64() as u8
)?,
2 => write!(
line,
"\x1b[1;{};{}m",
rng.usize_range(30, 37),
rng.usize_range(40, 47)
)?,
3 => line.extend_from_slice(b"\x1b[0m"),
4 => write!(line, "\x1b[38;5;{}m", rng.next_u64() as u8)?,
5 => line.extend_from_slice(b"\x1b[K"),
6 => write!(line, "\x1b[{}C", rng.usize_range(1, 4))?,
7 => line.extend_from_slice(b"\x1b[4m"),
_ => line.extend_from_slice(b"\x1b[m"),
}
let n = rng.usize_range(1, 12);
for _ in 0..n {
line.push(words[rng.usize_range(0, words.len() - 1)]);
}
line.push(b' ');
}
line.extend_from_slice(b"\x1b[0m\r\n");
if finished(w.write_all(&line).map(|()| 0))? {
return Ok(());
}
}
}
/// How `+osc` frames its output.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum OscStyle {
/// Write all OSC data, including `ESC ]` and ST.
Streaming,
/// Only write data, prefixed with a little-endian usize length; used for
/// testing just the OSC parser.
Parser,
}
/// `ghostty-synthetic +osc`: endless OSC stream.
pub fn run_osc<W: Write>(
w: &mut W,
rng: &mut Rng,
p_valid: f64,
style: OscStyle,
) -> io::Result<()> {
let generator = Osc::new(p_valid);
let mut buf = [0u8; 1024];
loop {
let mut fixed = Fixed::new(&mut buf);
generator.next(rng, &mut fixed, 1024)?;
let data = fixed.buffered();
let res = match style {
OscStyle::Streaming => w.write_all(data),
OscStyle::Parser => w
.write_all(&(data.len() - 3).to_le_bytes())
.and_then(|()| w.write_all(&data[2..data.len() - 1])),
};
if finished(res.map(|()| 0))? {
return Ok(());
}
}
}
/// Broken pipe (stdout closed) ends the generator successfully.
fn finished(r: io::Result<usize>) -> io::Result<bool> {
match r {
Ok(_) => Ok(false),
Err(e) if e.kind() == io::ErrorKind::BrokenPipe => Ok(true),
Err(e) => Err(e),
}
}
#[cfg(test)]
mod tests {
use super::*;
use ghostty_vt::osc::Parser;
const SEED: u64 = 0xC0FFEEEEEEEEEEEE;
#[test]
fn bytes() {
let mut rng = Rng::new(0);
let mut out = Vec::new();
let v = Bytes {
min_len: 256,
max_len: 256,
alphabet: None,
};
v.next(&mut rng, &mut out, 256).unwrap();
assert_eq!(out.len(), 256);
}
#[test]
fn utf8() {
let mut rng = Rng::new(0);
let mut out = Vec::new();
let v = Utf8 {
min_len: 256,
max_len: 256,
..Utf8::default()
};
v.next(&mut rng, &mut out, 256).unwrap();
assert_eq!(out.len(), 256);
assert!(std::str::from_utf8(&out).is_ok());
}
#[test]
fn osc_generator() {
let mut rng = Rng::new(SEED);
let mut buf = [0u8; 256];
let generator = Osc::new(1.0);
for _ in 0..50 {
let mut w = Fixed::new(&mut buf);
generator.next(&mut rng, &mut w, 256).unwrap();
assert_ne!(w.buffered(), b"");
}
}
#[test]
fn osc_generator_valid() {
let mut rng = Rng::new(SEED);
let mut buf = [0u8; 256];
let generator = Osc::new(1.0);
for _ in 0..50 {
let mut w = Fixed::new(&mut buf);
generator.next(&mut rng, &mut w, 256).unwrap();
let seq = w.buffered();
let mut p = Parser::init(false);
for &c in &seq[2..seq.len() - 1] {
p.next(c);
}
assert!(p.end(None).is_some(), "{seq:?}");
}
}
#[test]
fn osc_generator_invalid() {
let mut rng = Rng::new(SEED);
let mut buf = [0u8; 256];
let generator = Osc::new(0.0);
for _ in 0..50 {
let mut w = Fixed::new(&mut buf);
generator.next(&mut rng, &mut w, 256).unwrap();
let seq = w.buffered();
let mut p = Parser::init(false);
for &c in &seq[2..seq.len() - 1] {
p.next(c);
}
assert!(p.end(None).is_none(), "{seq:?}");
}
}
}