From 062c643ea80bcfca0194a2bc19d784891ad628ec Mon Sep 17 00:00:00 2001 From: ddidderr Date: Mon, 12 Oct 2026 02:08:01 +0200 Subject: [PATCH] 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= and compare with Zig's ghostty-bench --- Cargo.lock | 13 + Cargo.toml | 1 + crates/ghostty-bench/Cargo.toml | 23 + crates/ghostty-bench/src/args.rs | 96 +++ crates/ghostty-bench/src/benches.rs | 565 +++++++++++++ crates/ghostty-bench/src/benchmark.rs | 96 +++ .../src/bin/ghostty-synthetic.rs | 64 ++ crates/ghostty-bench/src/lib.rs | 13 + crates/ghostty-bench/src/main.rs | 49 ++ crates/ghostty-bench/src/synthetic.rs | 746 ++++++++++++++++++ crates/ghostty-terminal/src/page.rs | 220 +++++- crates/ghostty-terminal/src/page_list.rs | 13 + crates/ghostty-terminal/src/render.rs | 6 +- crates/ghostty-terminal/src/terminal.rs | 66 ++ crates/ghostty-vt/src/osc/mod.rs | 18 +- crates/ghostty-vt/src/parser.rs | 106 +-- crates/ghostty-vt/src/simd.rs | 104 ++- docs/rewrite/notes/perf-P1.md | 78 ++ 18 files changed, 2195 insertions(+), 82 deletions(-) create mode 100644 crates/ghostty-bench/Cargo.toml create mode 100644 crates/ghostty-bench/src/args.rs create mode 100644 crates/ghostty-bench/src/benches.rs create mode 100644 crates/ghostty-bench/src/benchmark.rs create mode 100644 crates/ghostty-bench/src/bin/ghostty-synthetic.rs create mode 100644 crates/ghostty-bench/src/lib.rs create mode 100644 crates/ghostty-bench/src/main.rs create mode 100644 crates/ghostty-bench/src/synthetic.rs create mode 100644 docs/rewrite/notes/perf-P1.md diff --git a/Cargo.lock b/Cargo.lock index 4dc3122..b573166 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1245,6 +1245,16 @@ dependencies = [ "wayland-protocols-plasma", ] +[[package]] +name = "ghostty-bench" +version = "0.1.0" +dependencies = [ + "ghostty-terminal", + "ghostty-unicode", + "ghostty-vt", + "log", +] + [[package]] name = "ghostty-cli" version = "0.1.0" @@ -1255,6 +1265,7 @@ dependencies = [ "ghostty-font", "ghostty-input", "ghostty-os", + "ghostty-vt", "log", "ratatui", "tempfile", @@ -1269,6 +1280,7 @@ dependencies = [ "const_format", "ghostty-input", "ghostty-os", + "ghostty-terminal", "ghostty-vt", "log", "onig", @@ -1339,6 +1351,7 @@ name = "ghostty-os" version = "0.1.0" dependencies = [ "gettext-rs", + "ghostty-vt", "log", "nix 0.31.3", "rustix", diff --git a/Cargo.toml b/Cargo.toml index 8f2214c..72737fc 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -43,6 +43,7 @@ ghostty-core = { path = "crates/ghostty-core" } ghostty-apprt-gtk = { path = "crates/ghostty-apprt-gtk" } ghostty-apprt = { path = "crates/ghostty-apprt" } ghostty-cli = { path = "crates/ghostty-cli" } +ghostty-bench = { path = "crates/ghostty-bench" } [workspace.lints.rust] unsafe_code = "forbid" diff --git a/crates/ghostty-bench/Cargo.toml b/crates/ghostty-bench/Cargo.toml new file mode 100644 index 0000000..b0df767 --- /dev/null +++ b/crates/ghostty-bench/Cargo.toml @@ -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 diff --git a/crates/ghostty-bench/src/args.rs b/crates/ghostty-bench/src/args.rs new file mode 100644 index 0000000..20bb855 --- /dev/null +++ b/crates/ghostty-bench/src/args.rs @@ -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>, +} + +impl Args { + /// Parse arguments (skipping `+action` words and the program name). + pub fn parse>(iter: I) -> Result { + 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(&mut self, key: &str, default: T) -> Result { + 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, 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 { + 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>> { + 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 { + 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) +} diff --git a/crates/ghostty-bench/src/benches.rs b/crates/ghostty-bench/src/benches.rs new file mode 100644 index 0000000..5ab7206 --- /dev/null +++ b/crates/ghostty-bench/src/benches.rs @@ -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, + f: Option>, +} + +impl Data { + fn new(path: Option) -> 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, + /// 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 { + 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 { + 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 { + 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 { + 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 { + 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 { + // 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, +} + +impl ScreenClone { + pub fn create(args: &mut Args) -> std::result::Result { + 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 { + fn go( + b: std::result::Result, + args: Args, + ) -> std::result::Result { + 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}")), + } +} diff --git a/crates/ghostty-bench/src/benchmark.rs b/crates/ghostty-bench/src/benchmark.rs new file mode 100644 index 0000000..9ade5a0 --- /dev/null +++ b/crates/ghostty-bench/src/benchmark.rs @@ -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 = std::result::Result; + +/// 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 { + b.setup()?; + let r = run_steps(b, mode); + b.teardown(); + r +} + +fn run_steps(b: &mut dyn Benchmark, mode: RunMode) -> Result { + 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); + } +} diff --git a/crates/ghostty-bench/src/bin/ghostty-synthetic.rs b/crates/ghostty-bench/src/bin/ghostty-synthetic.rs new file mode 100644 index 0000000..83282d4 --- /dev/null +++ b/crates/ghostty-bench/src/bin/ghostty-synthetic.rs @@ -0,0 +1,64 @@ +//! `ghostty-synthetic + [--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 = 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 + } + } +} diff --git a/crates/ghostty-bench/src/lib.rs b/crates/ghostty-bench/src/lib.rs new file mode 100644 index 0000000..fe0b1bf --- /dev/null +++ b/crates/ghostty-bench/src/lib.rs @@ -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 + [--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; diff --git a/crates/ghostty-bench/src/main.rs b/crates/ghostty-bench/src/main.rs new file mode 100644 index 0000000..042b098 --- /dev/null +++ b/crates/ghostty-bench/src/main.rs @@ -0,0 +1,49 @@ +//! `ghostty-bench + [--option=value ...]` (port of `main_bench.zig`). +use ghostty_bench::{args::Args, benches}; +use std::process::ExitCode; + +fn main() -> ExitCode { + let argv: Vec = 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 + } + } +} diff --git a/crates/ghostty-bench/src/synthetic.rs b/crates/ghostty-bench/src/synthetic.rs new file mode 100644 index 0000000..7247728 --- /dev/null +++ b/crates/ghostty-bench/src/synthetic.rs @@ -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 { + (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(&self, rng: &mut Rng, w: &mut W) -> io::Result { + 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(&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(&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, + full_alphabet: Vec, + ascii_alphabet: Vec, + alphabetic_alphabet: Vec, + alphanumeric_alphabet: Vec, +} + +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(&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(&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( + &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( + &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 { + 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: &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: &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: &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: &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) -> io::Result { + 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:?}"); + } + } +} diff --git a/crates/ghostty-terminal/src/page.rs b/crates/ghostty-terminal/src/page.rs index 6f30811..58c7827 100644 --- a/crates/ghostty-terminal/src/page.rs +++ b/crates/ghostty-terminal/src/page.rs @@ -515,6 +515,27 @@ impl From for Error { } } } +/// Hasher for cell-index keys: indices are already well distributed, so a +/// single multiply replaces SipHash on the print path. +#[derive(Debug, Default, Clone, Copy)] +struct IndexHasher(u64); +impl std::hash::Hasher for IndexHasher { + #[inline] + fn finish(&self) -> u64 { + self.0 + } + #[inline] + fn write(&mut self, bytes: &[u8]) { + for &b in bytes { + self.0 = (self.0.rotate_left(5) ^ u64::from(b)).wrapping_mul(0x517c_c1b7_2722_0a95); + } + } + #[inline] + fn write_usize(&mut self, n: usize) { + self.0 = (n as u64).wrapping_mul(0x9e37_79b9_7f4a_7c15); + } +} +type IndexMap = HashMap>; #[derive(Debug, Clone, Copy)] struct Allocation { start: usize, @@ -522,43 +543,91 @@ struct Allocation { } #[derive(Debug, Clone)] struct ChunkAlloc { - used: Vec, + /// Bitmap of used chunks (bit set = used), 64 chunks per word. + used: Vec, + /// Total number of chunks. + chunks: usize, + /// Every word below this index is completely used. + first_free_word: usize, chunk: usize, } impl ChunkAlloc { fn new(layout: AllocLayout, chunk: usize) -> Result { let mut used = Vec::new(); - let count = layout.bitmap_count * 64; - used.try_reserve_exact(count) + used.try_reserve_exact(layout.bitmap_count) .map_err(|_| Error::OutOfMemory)?; - used.resize(count, false); - Ok(Self { used, chunk }) + used.resize(layout.bitmap_count, 0); + Ok(Self { + used, + chunks: layout.bitmap_count * 64, + first_free_word: 0, + chunk, + }) } + /// First-fit allocation of `bytes` (rounded up to whole chunks): the + /// lowest-addressed run of free chunks is used. fn alloc(&mut self, bytes: usize) -> Option { let n = bytes.div_ceil(self.chunk); if n == 0 { return Some(Allocation { start: 0, len: 0 }); } - let mut run = 0; - for i in 0..self.used.len() { - if self.used[i] { + while self.first_free_word < self.used.len() && self.used[self.first_free_word] == u64::MAX + { + self.first_free_word += 1; + } + let mut run = 0usize; + let mut run_start = 0usize; + for w in self.first_free_word..self.used.len() { + let word = self.used[w]; + if word == u64::MAX { run = 0; - } else { - run += 1; + continue; } - if run == n { - let start = i + 1 - n; - self.used[start..=i].fill(true); - return Some(Allocation { start, len: n }); + if word == 0 { + if run == 0 { + run_start = w * 64; + } + run += 64; + if run >= n { + return Some(self.take(run_start, n)); + } + continue; + } + if n == 1 { + let b = word.trailing_ones() as usize; + return Some(self.take(w * 64 + b, 1)); + } + for b in 0..64 { + if word >> b & 1 == 1 { + run = 0; + } else { + if run == 0 { + run_start = w * 64 + b; + } + run += 1; + if run == n { + return Some(self.take(run_start, n)); + } + } } } None } + fn take(&mut self, start: usize, len: usize) -> Allocation { + for i in start..start + len { + self.used[i / 64] |= 1 << (i % 64); + } + Allocation { start, len } + } fn free(&mut self, a: Allocation) { - self.used[a.start..a.start + a.len].fill(false); + for i in a.start..a.start + a.len { + self.used[i / 64] &= !(1 << (i % 64)); + } + self.first_free_word = self.first_free_word.min(a.start / 64); } fn reset(&mut self) { - self.used.fill(false); + self.used.fill(0); + self.first_free_word = 0; } } /// Page-owned rows, cells, and bounded auxiliary storage. @@ -579,9 +648,9 @@ pub struct Page { /// Shared hyperlink values. pub hyperlink_set: hyperlink::Set, // Graphemes share a page arena; copying a cell never allocates a Vec. - grapheme_map: HashMap>, + grapheme_map: IndexMap>, grapheme_data: Vec, - grapheme_allocations: HashMap, + grapheme_allocations: IndexMap, grapheme_alloc: ChunkAlloc, hyperlink_map: hyperlink::Map, string_alloc: ChunkAlloc, @@ -683,16 +752,16 @@ impl Page { cells.resize(count, Cell::default()); let grapheme_alloc = ChunkAlloc::new(l.grapheme_alloc_layout, 16)?; let mut grapheme_data = Vec::new(); - let codepoints = grapheme_alloc.used.len() * 4; + let codepoints = grapheme_alloc.chunks * 4; grapheme_data .try_reserve_exact(codepoints) .map_err(|_| Error::OutOfMemory)?; grapheme_data.resize(codepoints, 0); - let mut grapheme_map = HashMap::new(); + let mut grapheme_map = IndexMap::default(); grapheme_map .try_reserve(l.grapheme_map_layout.capacity) .map_err(|_| Error::OutOfMemory)?; - let mut grapheme_allocations = HashMap::new(); + let mut grapheme_allocations = IndexMap::default(); grapheme_allocations .try_reserve(l.grapheme_map_layout.capacity) .map_err(|_| Error::OutOfMemory)?; @@ -1074,7 +1143,9 @@ impl Page { } let old = self.rows[y]; let full = start == 0 && end == usize::from(self.size.cols) && !protect; + // Only needed when the row stays (possibly) styled, i.e. a partial clear. let styled = old.styled() + && !full && self .get_cells(y) .is_some_and(|cells| cells.iter().any(|c| c.has_styling())); @@ -1087,6 +1158,15 @@ impl Page { self.rows[y].set_kitty_virtual_placeholder(false); } let base = self.rows[y].cells(); + + // Fast path: the row flags are a superset of what its cells carry, so + // with none of them set there is nothing to release per cell. + if !protect && !old.styled() && !old.grapheme() && !old.hyperlink() { + self.cells[base + start..base + end].fill(Cell::default()); + self.rows[y].set_dirty(true); + return Ok(()); + } + for i in base + start..base + end { let c = self.cells[i]; if protect && c.protected() { @@ -1104,10 +1184,18 @@ impl Page { self.cells[i] = Cell::default(); } if old.grapheme() { - self.update_row_grapheme_flag(y); + if full { + self.rows[y].set_grapheme(false); + } else { + self.update_row_grapheme_flag(y); + } } if old.hyperlink() { - self.update_row_hyperlink_flag(y); + if full { + self.rows[y].set_hyperlink(false); + } else { + self.update_row_hyperlink_flag(y); + } } if old.styled() { self.rows[y].set_styled(!full && styled); @@ -1244,10 +1332,26 @@ impl Page { i: usize, x: usize, y: usize, + ) -> Result<(), Error> { + self.clear_cells(y, x, x + 1, false)?; + self.clone_cell_from_cleared(src, i, x, y) + } + /// Like [`Page::clone_cell_from`] but the destination cell must already + /// have been cleared (e.g. its whole row range was cleared up front). + fn clone_cell_from_cleared( + &mut self, + src: &Self, + i: usize, + x: usize, + y: usize, ) -> Result<(), Error> { let cell = src.cells[i]; let dst = self.cell_index(x, y)?; - self.clear_cells(y, x, x + 1, false)?; + // Plain cells (the vast majority) carry no managed memory. + if !cell.has_grapheme() && !cell.hyperlink() && !cell.has_styling() { + self.cells[dst] = cell; + return Ok(()); + } self.cells[dst] = cell; self.cells[dst].set_style_id(0); self.cells[dst].set_hyperlink(false); @@ -1379,9 +1483,17 @@ impl Page { let original_row = *self.get_row(dst_y).ok_or(Error::InvalidCoordinate)?; self.clear_cells(dst_y, dst_x, dst_x + len, false)?; if source_row.managed_memory() { + // Row metadata/cell indices were validated above (`get_row`). + let from = source_row.cells() + src_x; + let to = original_row.cells() + dst_x; for n in 0..len { - let i = src.cell_index(src_x + n, src_y)?; - self.clone_cell_from(src, i, dst_x + n, dst_y)?; + let cell = src.cells[from + n]; + if !cell.has_grapheme() && !cell.hyperlink() && !cell.has_styling() { + // Plain cell: no managed memory to copy. + self.cells[to + n] = cell; + } else { + self.clone_cell_from_cleared(src, from + n, dst_x + n, dst_y)?; + } } } else { let from = source_row.cells() + src_x; @@ -1640,7 +1752,14 @@ impl Page { } } -fn swap_map(map: &mut HashMap, a: usize, b: usize) { +#[cfg(not(any(debug_assertions, test)))] +impl Page { + /// No-op in release builds (the integrity checks are debug-only). + #[inline(always)] + pub fn assert_integrity(&self) {} +} + +fn swap_map(map: &mut HashMap, a: usize, b: usize) { let av = map.remove(&a); let bv = map.remove(&b); if let Some(v) = av { @@ -1653,6 +1772,53 @@ fn swap_map(map: &mut HashMap, a: usize, b: usize) { #[cfg(test)] mod tests { use super::*; + #[test] + fn chunk_alloc_matches_naive_first_fit() { + let mut fast = ChunkAlloc::new( + AllocLayout { + bitmap_count: 5, + ..AllocLayout::default() + }, + 16, + ) + .unwrap(); + let mut naive = vec![false; 5 * 64]; + let mut live: Vec = Vec::new(); + let mut seed = 0x1234_5678_9abc_def0u64; + let mut rnd = move || { + seed ^= seed << 13; + seed ^= seed >> 7; + seed ^= seed << 17; + seed + }; + for _ in 0..20_000 { + if rnd() % 3 != 0 || live.is_empty() { + let n = (rnd() % 70 + 1) as usize; + let expect = (|| { + let mut run = 0; + for (i, &used) in naive.iter().enumerate() { + run = if used { 0 } else { run + 1 }; + if run == n { + return Some(i + 1 - n); + } + } + None + })(); + let got = fast.alloc(n * 16); + assert_eq!(got.map(|a| a.start), expect); + if let Some(a) = got { + naive[a.start..a.start + a.len].fill(true); + live.push(a); + } + } else { + let i = (rnd() as usize) % live.len(); + let a = live.swap_remove(i); + naive[a.start..a.start + a.len].fill(false); + fast.free(a); + } + } + } + use crate::style::{Color, Flags}; fn page() -> Page { Page::init(Capacity { diff --git a/crates/ghostty-terminal/src/page_list.rs b/crates/ghostty-terminal/src/page_list.rs index af8c19b..bae68b0 100644 --- a/crates/ghostty-terminal/src/page_list.rs +++ b/crates/ghostty-terminal/src/page_list.rs @@ -547,6 +547,19 @@ impl PageList { } Ok(()) } + /// Mutable access to a tracked pin's location for hot paths that only + /// change `x` within the same row (cursor movement while printing). + /// Unlike [`PageList::set_tracked_pin`] this performs no node validation + /// and does not invalidate the cached viewport offset, so it must not be + /// used to move the pin to another row or node. + #[inline] + pub fn tracked_pin_x_mut(&mut self, h: TrackedPin) -> Option<&mut Pin> { + self.pins + .get_mut(h.index) + .filter(|s| s.generation == h.generation)? + .pin + .as_mut() + } /// Release a tracked handle. The internal viewport handle remains owned. pub fn untrack_pin(&mut self, h: TrackedPin) { if self.viewport_pin == Some(h) { diff --git a/crates/ghostty-terminal/src/render.rs b/crates/ghostty-terminal/src/render.rs index e1626a3..8048e79 100644 --- a/crates/ghostty-terminal/src/render.rs +++ b/crates/ghostty-terminal/src/render.rs @@ -549,7 +549,6 @@ impl RenderState { continue; } - row.cells.grapheme.resize_with(cols, Vec::new); row.cells.style.resize(cols, Style::default()); for (x, page_cell) in page_cells.iter().enumerate() { // Append assuming its a single-codepoint, styled cell @@ -569,6 +568,11 @@ impl RenderState { if let Ok(idx) = p.cell_index(x, usize::from(row_pin.y)) && let Some(g) = p.lookup_grapheme(idx) { + // Only rows that actually contain graphemes pay + // for the per-cell vectors. + if row.cells.grapheme.is_empty() { + row.cells.grapheme.resize_with(cols, Vec::new); + } row.cells.grapheme[x].extend_from_slice(g); } } diff --git a/crates/ghostty-terminal/src/terminal.rs b/crates/ghostty-terminal/src/terminal.rs index 17ea04b..a5f32d6 100644 --- a/crates/ghostty-terminal/src/terminal.rs +++ b/crates/ghostty-terminal/src/terminal.rs @@ -613,6 +613,12 @@ impl Terminal { // We have a printable character, save it self.previous_char = Some(c); + // Fast path: the overwhelmingly common case of printing a single-width + // character in the middle of a line onto a plain narrow cell. + if width == 1 && !self.modes.get(Mode::Insert) && self.print_fast(c, right_limit) { + return Ok(()); + } + // If we're soft-wrapping, then handle that first. if self.screen().cursor.pending_wrap && self.modes.get(Mode::Wraparound) { self.print_wrap()?; @@ -693,6 +699,66 @@ impl Terminal { Ok(()) } + /// Fast path of `print` for a single-width character that does not hit + /// the cursor's right limit. Performs exactly what the general path does + /// (mark dirty, write the cell, move right) with a single page lookup. + /// Returns `false` without touching anything if any precondition for the + /// shortcut does not hold; the caller then takes the general path. + #[inline] + fn print_fast(&mut self, c: u32, right_limit: size::CellCountInt) -> bool { + let scr = self.screens.active_mut(); + if scr.cursor.pending_wrap + || scr.cursor.hyperlink_id != 0 + || scr.charset.single_shift.is_some() + || c == KITTY_PLACEHOLDER + || usize::from(scr.cursor.x) + 1 >= usize::from(right_limit) + { + return false; + } + let set = scr.charset.charsets.get(scr.charset.gl); + if set != Charset::Utf8 && set != Charset::Ascii { + return false; + } + + let style_id = scr.cursor.style_id; + let protected = scr.cursor.protected; + let semantic = scr.cursor.semantic_content; + let pin_handle = scr.cursor.page_pin; + let Some(pin) = scr.pages.tracked_pin(pin_handle) else { + return false; + }; + let (x, y) = (usize::from(pin.x), usize::from(pin.y)); + let page = scr.page_mut(pin.node); + let Ok(idx) = page.cell_index(x, y) else { + return false; + }; + let old = page.cells[idx]; + if old.wide() != Wide::Narrow + || old.has_grapheme() + || old.hyperlink() + || old.style_id() != style_id + { + return false; + } + + // Same cell value as `print_cell` produces. + let mut new_cell = Cell::init(c); + new_cell.set_content_tag(ContentTag::Codepoint); + new_cell.set_style_id(style_id); + new_cell.set_wide(Wide::Narrow); + new_cell.set_protected(protected); + new_cell.set_semantic_content(semantic); + page.cells[idx] = new_cell; + page.rows[y].set_dirty(true); + + // Move the cursor right (`Screen::cursor_right(1)`). + if let Some(p) = scr.pages.tracked_pin_x_mut(pin_handle) { + p.x += 1; + } + scr.cursor.x += 1; + true + } + /// The grapheme-clustering section of `print`. Returns `true` if the /// character was fully handled (Zig: `return`), `false` if printing should /// continue on the normal path (Zig: `break :grapheme`). diff --git a/crates/ghostty-vt/src/osc/mod.rs b/crates/ghostty-vt/src/osc/mod.rs index ff4a4fa..315df84 100644 --- a/crates/ghostty-vt/src/osc/mod.rs +++ b/crates/ghostty-vt/src/osc/mod.rs @@ -458,6 +458,7 @@ impl Parser { self.writer != Writer::None } + #[inline] fn write_byte(&mut self, c: u8) { match self.writer { Writer::None => unreachable!(), @@ -474,13 +475,14 @@ impl Parser { } /// Consume the next character c and advance the parser state. + /// + /// The payload-accumulation fast path is inlined into callers; the + /// numeric-prefix state machine lives in [`Parser::next_state`]. + #[inline] pub fn next(&mut self, c: u8) { - #[allow(clippy::enum_glob_use)] - use State::*; - // If the state becomes invalid for any reason, just discard // any further input. - if self.state == Invalid { + if self.state == State::Invalid { return; } @@ -491,6 +493,14 @@ impl Parser { return; } + self.next_state(c); + } + + #[inline(never)] + fn next_state(&mut self, c: u8) { + #[allow(clippy::enum_glob_use)] + use State::*; + match self.state { // handled above, so should never be here Invalid => unreachable!(), diff --git a/crates/ghostty-vt/src/parser.rs b/crates/ghostty-vt/src/parser.rs index 5c343cb..3ca7ce3 100644 --- a/crates/ghostty-vt/src/parser.rs +++ b/crates/ghostty-vt/src/parser.rs @@ -301,9 +301,30 @@ impl Parser { /// Next consumes the next character c and returns the actions to execute. /// Up to 3 actions may need to be executed -- in order -- representing /// the state exit, transition, and entry actions. + #[inline] pub fn next(&mut self, c: u8) -> [Option; 3] { let effect = TABLE[c as usize][self.state as usize]; + // Fast path: staying in the same state means there are no exit or + // entry actions, only the transition action. + if self.state == effect.state { + return match effect.action { + TransitionAction::Print => [None, Some(Action::Print(u32::from(c))), None], + TransitionAction::Execute => [None, Some(Action::Execute(c)), None], + action => [None, self.do_action(action, c), None], + }; + } + + self.next_state_change(c, effect) + } + + /// The general path of [`Parser::next`] when the state changes. + #[inline(never)] + fn next_state_change( + &mut self, + c: u8, + effect: crate::parse_table::Transition, + ) -> [Option; 3] { let next_state = effect.state; let action = effect.action; @@ -313,58 +334,49 @@ impl Parser { // 2. transition action // 3. entry action to new state - // Exit depends on current state - let exit = if self.state == next_state { - None - } else { - match self.state { - State::OscString => self.osc_parser.end(Some(c)).map(Action::OscDispatch), - State::DcsPassthrough => Some(Action::DcsUnhook), - State::SosPmApcString => Some(Action::ApcEnd), - _ => None, - } - }; + // The actions are written straight into the result array: building + // the three `Option` values separately and then moving them + // into the array costs a store-forwarding stall per byte. + let mut out: [Option; 3] = [None, None, None]; - let transition = self.do_action(action, c); + // Exit depends on current state (we always change state here). + match self.state { + State::OscString => out[0] = self.osc_parser.end(Some(c)).map(Action::OscDispatch), + State::DcsPassthrough => out[0] = Some(Action::DcsUnhook), + State::SosPmApcString => out[0] = Some(Action::ApcEnd), + _ => {} + } + + out[1] = self.do_action(action, c); // Entry depends on new state - let entry = if self.state == next_state { - None - } else { - match next_state { - State::Escape | State::DcsEntry | State::CsiEntry => { - self.clear(); - None + match next_state { + State::Escape | State::DcsEntry | State::CsiEntry => self.clear(), + State::OscString => self.osc_parser.reset(), + State::DcsPassthrough => 'dcs_hook: { + // Ignore too many parameters + if self.params_idx as usize >= MAX_PARAMS { + break 'dcs_hook; } - State::OscString => { - self.osc_parser.reset(); - None + // Finalize parameters + if self.param_acc_idx > 0 { + self.params[self.params_idx as usize] = self.param_acc; + self.params_idx += 1; } - State::DcsPassthrough => 'dcs_hook: { - // Ignore too many parameters - if self.params_idx as usize >= MAX_PARAMS { - break 'dcs_hook None; - } - // Finalize parameters - if self.param_acc_idx > 0 { - self.params[self.params_idx as usize] = self.param_acc; - self.params_idx += 1; - } - Some(Action::DcsHook(Dcs::new( - &self.intermediates[..self.intermediates_idx as usize], - &self.params[..self.params_idx as usize], - c, - ))) - } - State::SosPmApcString => Some(Action::ApcStart), - _ => None, + out[2] = Some(Action::DcsHook(Dcs::new( + &self.intermediates[..self.intermediates_idx as usize], + &self.params[..self.params_idx as usize], + c, + ))); } - }; + State::SosPmApcString => out[2] = Some(Action::ApcStart), + _ => {} + } // After generating the actions, we set our next state. self.state = next_state; - [exit, transition, entry] + out } #[inline] @@ -378,7 +390,7 @@ impl Parser { self.intermediates_idx += 1; } - #[inline] + #[inline(always)] fn do_action(&mut self, action: TransitionAction, c: u8) -> Option { match action { TransitionAction::None | TransitionAction::Ignore => None, @@ -445,8 +457,12 @@ impl Parser { params_sep: self.params_sep, final_: c, }; - csi.params_buf[..self.params_idx as usize] - .copy_from_slice(&self.params[..self.params_idx as usize]); + // A fixed-size masked copy (vectorizes) instead of a + // variable-length `copy_from_slice`, which is a libc call. + let n = usize::from(self.params_idx); + for (i, (dst, src)) in csi.params_buf.iter_mut().zip(&self.params).enumerate() { + *dst = if i < n { *src } else { 0 }; + } // We only allow colon or mixed separators for the 'm' command. if c != b'm' && self.params_sep.count() > 0 { diff --git a/crates/ghostty-vt/src/simd.rs b/crates/ghostty-vt/src/simd.rs index f68947c..a199ed2 100644 --- a/crates/ghostty-vt/src/simd.rs +++ b/crates/ghostty-vt/src/simd.rs @@ -34,12 +34,45 @@ fn utf8_byte_sequence_length(first: u8) -> Option { } } -/// Decode `bytes` (exactly one sequence) strictly; `None` if ill-formed. +/// Decode `bytes` (exactly one sequence whose length matches its leading +/// byte) strictly; `None` if ill-formed (overlong, surrogate, out of range or +/// a bad continuation byte). Equivalent to `std::str::from_utf8` on one char. +#[inline] fn utf8_decode(bytes: &[u8]) -> Option { - std::str::from_utf8(bytes) - .ok() - .and_then(|s| s.chars().next()) - .map(|c| c as u32) + #[inline] + fn cont(b: u8) -> bool { + b & 0xC0 == 0x80 + } + match *bytes { + [b0] if b0 < 0x80 => Some(u32::from(b0)), + [b0 @ 0xC2..=0xDF, b1] if cont(b1) => { + Some((u32::from(b0 & 0x1F) << 6) | u32::from(b1 & 0x3F)) + } + [b0 @ 0xE0..=0xEF, b1, b2] if cont(b1) && cont(b2) => { + let ok = match b0 { + 0xE0 => b1 >= 0xA0, + 0xED => b1 < 0xA0, + _ => true, + }; + ok.then(|| { + (u32::from(b0 & 0x0F) << 12) | (u32::from(b1 & 0x3F) << 6) | u32::from(b2 & 0x3F) + }) + } + [b0 @ 0xF0..=0xF4, b1, b2, b3] if cont(b1) && cont(b2) && cont(b3) => { + let ok = match b0 { + 0xF0 => b1 >= 0x90, + 0xF4 => b1 < 0x90, + _ => true, + }; + ok.then(|| { + (u32::from(b0 & 0x07) << 18) + | (u32::from(b1 & 0x3F) << 12) + | (u32::from(b2 & 0x3F) << 6) + | u32::from(b3 & 0x3F) + }) + } + _ => None, + } } /// Decode UTF-8 from `input` into `output` until an ESC (0x1B) is found. @@ -59,7 +92,17 @@ pub fn utf8_decode_until_control_seq(input: &[u8], output: &mut [u32]) -> Decode let mut decode_offset = 0; let mut decode_count = 0; while decode_offset < decode.len() { + // ASCII fast path: a run of single-byte sequences. let rem = &decode[decode_offset..]; + if rem[0] < 0x80 { + let run = rem.iter().take_while(|&&b| b < 0x80).count(); + for (o, &b) in output[decode_count..decode_count + run].iter_mut().zip(rem) { + *o = u32::from(b); + } + decode_count += run; + decode_offset += run; + continue; + } let Some(cp_len) = utf8_byte_sequence_length(rem[0]) else { break; }; @@ -93,6 +136,57 @@ pub fn utf8_decode_until_control_seq(input: &[u8], output: &mut [u32]) -> Decode mod tests { use super::*; + #[test] + fn utf8_decode_matches_std() { + fn std_decode(bytes: &[u8]) -> Option { + std::str::from_utf8(bytes) + .ok() + .and_then(|s| s.chars().next()) + .map(|c| c as u32) + } + for b0 in 0..=255u8 { + let Some(len) = utf8_byte_sequence_length(b0) else { + continue; + }; + match len { + 1 => assert_eq!(utf8_decode(&[b0]), std_decode(&[b0])), + 2 => { + for b1 in 0..=255u8 { + assert_eq!( + utf8_decode(&[b0, b1]), + std_decode(&[b0, b1]), + "{b0:x} {b1:x}" + ); + } + } + 3 => { + for b1 in 0..=255u8 { + for b2 in 0..=255u8 { + assert_eq!( + utf8_decode(&[b0, b1, b2]), + std_decode(&[b0, b1, b2]), + "{b0:x} {b1:x} {b2:x}" + ); + } + } + } + _ => { + for b1 in 0..=255u8 { + for b2 in [0x00, 0x7F, 0x80, 0xA0, 0xBF, 0xC0] { + for b3 in [0x00, 0x7F, 0x80, 0xBF, 0xC0] { + assert_eq!( + utf8_decode(&[b0, b1, b2, b3]), + std_decode(&[b0, b1, b2, b3]), + "{b0:x} {b1:x} {b2:x} {b3:x}" + ); + } + } + } + } + } + } + } + #[test] fn index_of_basic() { assert_eq!(index_of(b"hello", b' '), None); diff --git a/docs/rewrite/notes/perf-P1.md b/docs/rewrite/notes/perf-P1.md new file mode 100644 index 0000000..2f09c94 --- /dev/null +++ b/docs/rewrite/notes/perf-P1.md @@ -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 + [--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.