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
This commit is contained in:
ddidderr committed 2026-10-12 03:51:46 +02:00
1 parent bd48c39d5f
commit e423dd5586
7 files changed
+107 -17

No files matched your search

+1 -3
View File
@@ -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();
}
+1
View File
@@ -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
+13 -12
View File
@@ -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<Cell> =
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;
+12 -2
View File
@@ -153,6 +153,8 @@ pub struct PageList {
nodes: Vec<Option<Node>>,
free_nodes: Vec<usize>,
page_pool: Vec<Page>,
/// Never-written pages (all cells zero) that need no `reinit` memset.
clean_pool: Vec<Page>,
/// Oldest page.
pub first: Option<NodeId>,
/// 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;
+59
View File
@@ -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);
}
}
}
}
}