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:
1 parent
c6a0c49567
commit
062c643ea8
18 files changed
+2195
-82
No files matched your search
Generated
+13
@@ -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",
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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}")),
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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:?}");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -515,6 +515,27 @@ impl From<AddError> 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<V> = HashMap<usize, V, std::hash::BuildHasherDefault<IndexHasher>>;
|
||||
#[derive(Debug, Clone, Copy)]
|
||||
struct Allocation {
|
||||
start: usize,
|
||||
@@ -522,43 +543,91 @@ struct Allocation {
|
||||
}
|
||||
#[derive(Debug, Clone)]
|
||||
struct ChunkAlloc {
|
||||
used: Vec<bool>,
|
||||
/// Bitmap of used chunks (bit set = used), 64 chunks per word.
|
||||
used: Vec<u64>,
|
||||
/// 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<Self, Error> {
|
||||
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<Allocation> {
|
||||
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<usize, std::ops::Range<usize>>,
|
||||
grapheme_map: IndexMap<std::ops::Range<usize>>,
|
||||
grapheme_data: Vec<u32>,
|
||||
grapheme_allocations: HashMap<usize, Allocation>,
|
||||
grapheme_allocations: IndexMap<Allocation>,
|
||||
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<T>(map: &mut HashMap<usize, T>, 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<T, S: std::hash::BuildHasher>(map: &mut HashMap<usize, T, S>, 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<T>(map: &mut HashMap<usize, T>, 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<Allocation> = 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 {
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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`).
|
||||
|
||||
@@ -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!(),
|
||||
|
||||
@@ -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<Action>; 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<Action>; 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<Action>` values separately and then moving them
|
||||
// into the array costs a store-forwarding stall per byte.
|
||||
let mut out: [Option<Action>; 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<Action> {
|
||||
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 {
|
||||
|
||||
@@ -34,12 +34,45 @@ fn utf8_byte_sequence_length(first: u8) -> Option<usize> {
|
||||
}
|
||||
}
|
||||
|
||||
/// 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<u32> {
|
||||
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<u32> {
|
||||
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);
|
||||
|
||||
@@ -0,0 +1,78 @@
|
||||
# perf-P1: benchmarks and hot-path optimisation
|
||||
|
||||
## What was added
|
||||
- New crate `crates/ghostty-bench` (registered in root `[workspace.dependencies]`):
|
||||
- binary `ghostty-bench +<action> [--opt=value]`, same actions and options as Zig's
|
||||
`ghostty-bench`: `terminal-stream`, `terminal-parser`, `codepoint-width`,
|
||||
`grapheme-break`, `is-symbol`, `osc-parser`, `screen-clone` (options `--data`,
|
||||
`--terminal-rows/-cols`, `--mode`). Like Zig it runs the step exactly once and prints
|
||||
nothing; time the process (hyperfine). Rust-only extras: `--time` (prints the step time
|
||||
to stderr), `terminal-stream --handler=full` (full `stream_readonly` handler).
|
||||
- binary `ghostty-synthetic +ascii|+utf8|+osc [--p-valid --style]` (port of `src/synthetic`,
|
||||
Zig's `ghostty-gen`) plus Rust-only `+sgr` (SGR/CSI-heavy colourised text).
|
||||
- `Benchmark` harness (`benchmark.rs`), generators (`synthetic.rs`, with the Zig
|
||||
generator tests), arg parsing (`args.rs`).
|
||||
- Deviations: `codepoint-width --mode=wcwidth` is a warn+noop (libc FFI is forbidden);
|
||||
`--mode=simd` uses the table; `is-symbol` modes `uucode`/`table` both use the generated
|
||||
table; PRNG is a local xoshiro256++ (seeded from time) instead of Zig's std.Random.
|
||||
- `terminal-parser` drops the returned actions (`drop(p.next(c))`) like Zig's `_ = actions`.
|
||||
|
||||
## Zig baseline
|
||||
Zig 0.16.0 is installed but `build.zig.zon` needs 0.15.2, so 0.15.2 was downloaded
|
||||
(ziglang.org tarball) and the reference tree copied to `/tmp/claude-1000/ghostty-zig-build/src`
|
||||
(reference tree untouched). Build: `zig build -Demit-bench -Doptimize=ReleaseFast
|
||||
-Dapp-runtime=none` (~90 s) gives `ghostty-bench` and `ghostty-gen`. Data was generated with
|
||||
the Zig `ghostty-gen` (ascii 200 MB, utf8 100 MB, osc `--style=parser` 50 MB); the SGR file
|
||||
(100 MB) comes from the Rust `ghostty-synthetic +sgr`. Timings: `hyperfine -N --warmup 1
|
||||
--runs 5`, min wall time of the whole process, same files, same machine, Rust built with
|
||||
`--profile production`. Scripts: `/tmp/claude-1000/ghostty-zig-build/run.sh`.
|
||||
|
||||
## Results (production profile; lower ms is better)
|
||||
| benchmark | data | Zig ms | Rust before ms | Rust after ms | after/Zig |
|
||||
|---|---|---|---|---|---|
|
||||
| terminal-stream ascii | 200 MB | 1008 (198 MB/s) | 2334 (86 MB/s) | 1092 (183 MB/s) | 1.08 |
|
||||
| terminal-stream utf8 | 100 MB | 825 (121 MB/s) | 2061 (49 MB/s) | 797 (125 MB/s) | 0.97 |
|
||||
| terminal-stream sgr/csi | 100 MB | 665 (150 MB/s) | 991 (101 MB/s) | 693 (144 MB/s) | 1.04 |
|
||||
| terminal-parser ascii | 200 MB | 500 | 1914 | 268 | 0.54 |
|
||||
| terminal-parser utf8 | 100 MB | 287 | 954 | 349 | 1.22 |
|
||||
| terminal-parser sgr/csi | 100 MB | 352 | 1068 | 538 | 1.53 |
|
||||
| codepoint-width table | 100 MB utf8 | 326 | 300 | 296 | 0.91 |
|
||||
| codepoint-width simd | 100 MB utf8 | 615 | 298 | 295 | 0.48 |
|
||||
| grapheme-break | 100 MB utf8 | 361 | 313 | 314 | 0.87 |
|
||||
| is-symbol table | 100 MB utf8 | 321 | 274 | 291 | 0.91 |
|
||||
| osc-parser | 50 MB | 27.7 | 79.7 | 52.9 | 1.91 |
|
||||
| screen-clone clone (1000x) | 80x120 | 27.1 | 107 | 70.3 | 2.60 |
|
||||
| screen-clone render (1000x) | 80x120 | 4.5 | 15.9 | 11.2 | 2.49 |
|
||||
|
||||
Release profile (debug-assertions + overflow checks on) `terminal-stream`: ascii 2.6 s,
|
||||
utf8 1.34 s, sgr 1.19 s (about 2x slower than production, as expected).
|
||||
Rust-only `terminal-stream --handler=full` (all actions applied) on 100 MB: ascii(200MB) 1.2 s,
|
||||
utf8 0.85 s, sgr 1.6 s (no Zig counterpart; profile shows no pathology).
|
||||
|
||||
## Optimisations (behaviour-preserving)
|
||||
- `Terminal::print`: single-lookup fast path (`print_fast`) for width-1 characters mid-line
|
||||
on a plain narrow cell (same writes as `cursor_mark_dirty`+`print_cell`+`cursor_right`);
|
||||
new `PageList::tracked_pin_x_mut`. Avoids ~10 tracked-pin/node/row re-resolutions per char.
|
||||
- `Page`: grapheme chunk allocator is now a u64 bitmap with first-fit word scanning (was an
|
||||
O(chunks) scan of a `Vec<bool>` from index 0 each time; 38% of utf8 stream time); randomised
|
||||
test against the naive first-fit. Grapheme maps use a cheap integer hasher instead of SipHash.
|
||||
- `Page::clear_cells`: fast path when row has no styled/grapheme/hyperlink flags; no row
|
||||
rescans for full-row clears; `clone_partial_row_from` skips redundant per-cell clears and
|
||||
bulk-copies plain cells.
|
||||
- `ghostty-vt`: ASCII run fast path and hand-written strict UTF-8 decode (was
|
||||
`str::from_utf8` per codepoint; exhaustive equivalence test); `Parser::next` splits the
|
||||
same-state fast path from the state-change path and builds Print/Execute directly (avoids
|
||||
store-forwarding stalls on the 64-byte `Action` temporaries); CSI param copy without libc
|
||||
memcpy; `osc::Parser::next` inlines the payload accumulate path.
|
||||
- `RenderState::update`: per-cell grapheme vectors only allocated for rows that contain graphemes.
|
||||
- Fix: `Page::assert_integrity` was debug-only but called unconditionally from `terminal.rs`
|
||||
(production/`--release` without debug-assertions failed to build); added a no-op stub.
|
||||
|
||||
## Remaining gaps (> 1.5x)
|
||||
- screen-clone clone/render (2.5-2.6x): dominated by `Page::init` zero-filling and
|
||||
page-faulting freshly allocated `Vec<Cell>` (Zig gets lazily-zeroed mmap memory); fixing
|
||||
needs a calloc-style zeroed allocation, not possible with safe `Vec<Cell>`.
|
||||
- osc-parser (1.9x): per-byte `Vec::push` into the payload buffer (Zig appends to a fixed
|
||||
buffer); `Parser::data()` also clones the buffer.
|
||||
- terminal-parser sgr (1.5x): moving the 64-byte `Option<Action>` array out of the
|
||||
parser; real stream path (`terminal-stream sgr`) is at parity.
|
||||
Reference in new issue
Block a user