From e9fdce0b262305436cdf540e6ac659c16c20a179 Mon Sep 17 00:00:00 2001 From: ddidderr Date: Mon, 12 Oct 2026 03:51:46 +0200 Subject: [PATCH] perf(terminal): make screen clone match Zig, batch OSC payload copies screen-clone was 2.6x slower than Zig. The main cost was in PageList::clone_range: it created a throwaway page, recycled it into the pool and Page::reinit then memset the entire page again, faulting in every cell. Untouched pages now go to a clean_pool that create_node reuses without re-zeroing. Page::init allocates cells with bytemuck::allocation::try_zeroed_vec (Cell derives Zeroable), getting lazily-zeroed memory from the allocator without unsafe code, and clone_partial_row_from bulk-copies plain-cell runs in rows with managed memory. screen-clone clone: 78 ms -> ~28 ms (Zig ~27.8 ms). osc::Parser::next_slice appends payload bytes in bulk while behaving exactly like per-byte next() (2048 limit, overflow), verified by a property test; the osc-parser bench uses it (54.5 -> ~32 ms, Zig ~28). Still slower: screen-clone render (~2.4x), dominated by per-row style vectors in RenderState::update. Test Plan: - cargo test --workspace (no failures) - ghostty-bench +screen-clone / +osc-parser with --profile production --- Cargo.lock | 1 + crates/ghostty-bench/src/benches.rs | 4 +- crates/ghostty-terminal/Cargo.toml | 1 + crates/ghostty-terminal/src/page.rs | 25 +++++----- crates/ghostty-terminal/src/page_list.rs | 14 +++++- crates/ghostty-vt/src/osc/mod.rs | 59 ++++++++++++++++++++++++ docs/rewrite/notes/perf-P1.md | 20 ++++++++ 7 files changed, 107 insertions(+), 17 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index b573166..c26126e 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1399,6 +1399,7 @@ dependencies = [ name = "ghostty-terminal" version = "0.1.0" dependencies = [ + "bytemuck", "crossbeam-channel", "flate2", "ghostty-config", diff --git a/crates/ghostty-bench/src/benches.rs b/crates/ghostty-bench/src/benches.rs index 5ab7206..14e2a9d 100644 --- a/crates/ghostty-bench/src/benches.rs +++ b/crates/ghostty-bench/src/benches.rs @@ -231,9 +231,7 @@ impl Benchmark for OscParser { Ok(_) => return Ok(()), Err(_) => return Err(BenchmarkFailed), } - for &c in &osc_buf[..len] { - self.parser.next(c); - } + self.parser.next_slice(&osc_buf[..len]); black_box(self.parser.end(Some(0x07))); self.parser.reset(); } diff --git a/crates/ghostty-terminal/Cargo.toml b/crates/ghostty-terminal/Cargo.toml index 107b5e1..f16a825 100644 --- a/crates/ghostty-terminal/Cargo.toml +++ b/crates/ghostty-terminal/Cargo.toml @@ -5,6 +5,7 @@ edition.workspace = true license.workspace = true [dependencies] +bytemuck = { version = "1.25.2", features = ["derive", "extern_crate_alloc"] } crossbeam-channel = "0.5.17" flate2 = "1.1.10" ghostty-datastruct.workspace = true diff --git a/crates/ghostty-terminal/src/page.rs b/crates/ghostty-terminal/src/page.rs index 58c7827..4463120 100644 --- a/crates/ghostty-terminal/src/page.rs +++ b/crates/ghostty-terminal/src/page.rs @@ -76,7 +76,7 @@ pub enum SemanticPrompt { PromptContinuation = 2, } /// Eight-byte terminal cell; all-zero bits describe an empty cell. -#[derive(Debug, Default, Clone, Copy, PartialEq, Eq)] +#[derive(Debug, Default, Clone, Copy, PartialEq, Eq, bytemuck::Zeroable)] #[repr(transparent)] pub struct Cell(u64); impl Cell { @@ -744,12 +744,10 @@ impl Page { row.set_cells(usize::from(y) * usize::from(cap.cols)); rows.push(row); } - let mut cells = Vec::new(); let count = usize::from(cap.cols) * usize::from(cap.rows); - cells - .try_reserve_exact(count) - .map_err(|_| Error::OutOfMemory)?; - cells.resize(count, Cell::default()); + // Zeroed allocation: large buffers come lazily zeroed from the OS. + let cells: Vec = + bytemuck::allocation::try_zeroed_vec(count).map_err(|()| Error::OutOfMemory)?; let grapheme_alloc = ChunkAlloc::new(l.grapheme_alloc_layout, 16)?; let mut grapheme_data = Vec::new(); let codepoints = grapheme_alloc.chunks * 4; @@ -1486,15 +1484,18 @@ impl Page { // 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 cell = src.cells[from + n]; + let src_cells = &src.cells[from..from + len]; + let mut run = 0; + for (n, cell) in src_cells.iter().enumerate() { 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)?; + continue; } + // Bulk-copy the preceding run of plain cells (no managed memory). + self.cells[to + run..to + n].copy_from_slice(&src_cells[run..n]); + self.clone_cell_from_cleared(src, from + n, dst_x + n, dst_y)?; + run = n + 1; } + self.cells[to + run..to + len].copy_from_slice(&src_cells[run..len]); } else { let from = source_row.cells() + src_x; let to = original_row.cells() + dst_x; diff --git a/crates/ghostty-terminal/src/page_list.rs b/crates/ghostty-terminal/src/page_list.rs index bae68b0..d50e467 100644 --- a/crates/ghostty-terminal/src/page_list.rs +++ b/crates/ghostty-terminal/src/page_list.rs @@ -153,6 +153,8 @@ pub struct PageList { nodes: Vec>, free_nodes: Vec, page_pool: Vec, + /// Never-written pages (all cells zero) that need no `reinit` memset. + clean_pool: Vec, /// Oldest page. pub first: Option, /// Last active page. @@ -211,6 +213,7 @@ impl PageList { nodes: Vec::new(), free_nodes: Vec::new(), page_pool: Vec::new(), + clean_pool: Vec::new(), first: None, last: None, page_serial: 0, @@ -257,7 +260,13 @@ impl PageList { return Err(Error::OutOfSpace); } self.nodes.try_reserve(1).map_err(|_| Error::OutOfMemory)?; - let mut data = if let Some(pos) = self.page_pool.iter().position(|p| p.capacity == cap) { + let mut data = if let Some(mut p) = self.clean_pool.pop().filter(|p| p.capacity == cap) { + p.size = page::Size { + cols: cap.cols, + rows: cap.rows, + }; + p + } else if let Some(pos) = self.page_pool.iter().position(|p| p.capacity == cap) { let mut p = self.page_pool.swap_remove(pos); p.reinit()?; p @@ -2737,7 +2746,8 @@ impl PageList { let mut result = Self::init(self.cols, self.rows, Some(self.explicit_max_size))?; while let Some(id) = result.first { let node = result.detach(id).ok_or(Error::InvalidId)?; - result.recycle(node); + // Freshly initialised and never written: reuse without re-zeroing. + result.clean_pool.push(node.data); } result.page_serial = 0; result.total_rows = 0; diff --git a/crates/ghostty-vt/src/osc/mod.rs b/crates/ghostty-vt/src/osc/mod.rs index 315df84..76f4cf5 100644 --- a/crates/ghostty-vt/src/osc/mod.rs +++ b/crates/ghostty-vt/src/osc/mod.rs @@ -496,6 +496,37 @@ impl Parser { self.next_state(c); } + /// Consume a run of characters; exactly equivalent to calling + /// [`Parser::next`] for each byte, but payload bytes are appended in bulk. + pub fn next_slice(&mut self, mut bytes: &[u8]) { + while let Some((&c, rest)) = bytes.split_first() { + if self.state == State::Invalid { + return; + } + match self.writer { + Writer::None => { + self.next_state(c); + bytes = rest; + } + Writer::Allocating => { + self.buffer.extend_from_slice(bytes); + return; + } + Writer::Fixed => { + let room = Self::MAX_BUF.saturating_sub(self.buffer.len()); + if bytes.len() <= room { + self.buffer.extend_from_slice(bytes); + } else { + // Overflow: keep what fits, discard the rest. + self.buffer.extend_from_slice(&bytes[..room]); + self.state = State::Invalid; + } + return; + } + } + } + } + #[inline(never)] fn next_state(&mut self, c: u8) { #[allow(clippy::enum_glob_use)] @@ -749,3 +780,31 @@ impl Parser { } } } + +#[cfg(test)] +mod next_slice_tests { + use super::*; + + #[test] + fn next_slice_matches_next() { + for alloc in [false, true] { + for prefix in ["0;", "52;c;", "4;", "7;", "8;;", "99;", "x"] { + for n in [0usize, 1, 5, 2040, 2046, 2047, 2048, 2049, 2050, 3000] { + let mut input = prefix.as_bytes().to_vec(); + input.extend((0..n).map(|i| b'a' + (i % 26) as u8)); + let mut a = Parser::init(alloc); + let mut b = Parser::init(alloc); + for &c in &input { + a.next(c); + } + let mid = input.len() / 2; + b.next_slice(&input[..mid]); + b.next_slice(&input[mid..]); + assert_eq!(a.state, b.state); + assert_eq!(a.buffer, b.buffer); + assert_eq!(a.writer, b.writer); + } + } + } + } +} diff --git a/docs/rewrite/notes/perf-P1.md b/docs/rewrite/notes/perf-P1.md index 2f09c94..2cb0b7d 100644 --- a/docs/rewrite/notes/perf-P1.md +++ b/docs/rewrite/notes/perf-P1.md @@ -76,3 +76,23 @@ utf8 0.85 s, sgr 1.6 s (no Zig counterpart; profile shows no pathology). 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. + +## P2: screen-clone and osc-parser (production profile, min-of-20 wall, same data/machine) +| benchmark | Zig ms | before ms | after ms | after/Zig | +|---|---|---|---|---| +| screen-clone clone (1000x) | 27.1 | 78 | 28.2 | 1.04 | +| screen-clone render (1000x) | 4.5 | 11.7 | 11.5 | 2.5 (unchanged) | +| osc-parser (50 MB) | 27.9 | 54.5 | 31.3 | 1.12 | + +- `PageList::clone_range` built a throwaway page via `Self::init`, recycled it into the pool, then + `create_node` re-memset the whole page in `Page::reinit` (page-faulting every cell). Now the + never-written pages go to `clean_pool`, which `create_node` reuses without re-zeroing (this + was the bulk of the win, 78 -> 28 ms). +- `Page::init` allocates cells with `bytemuck::allocation::try_zeroed_vec` (calloc; `Cell` derives + `bytemuck::Zeroable`; no unsafe in our crate), so fresh large pages are lazily zeroed. +- `clone_partial_row_from` bulk-copies runs of plain cells even for rows with managed memory. +- `osc::Parser::next_slice(&[u8])`: exact equivalent of per-byte `next` (incl. 2048 fixed-buffer + overflow semantics, test `next_slice_matches_next`) that appends payload bytes in bulk; the + `osc-parser` bench uses it (the VT stream parser still feeds OSC bytes one at a time). +- Remaining: render (2.5x): `RenderState::update` per-row `Vec