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:
1 parent
e2bd4c28da
commit
e9fdce0b26
7 files changed
+107
-17
No files matched your search
Generated
+1
@@ -1399,6 +1399,7 @@ dependencies = [
|
|||||||
name = "ghostty-terminal"
|
name = "ghostty-terminal"
|
||||||
version = "0.1.0"
|
version = "0.1.0"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
|
"bytemuck",
|
||||||
"crossbeam-channel",
|
"crossbeam-channel",
|
||||||
"flate2",
|
"flate2",
|
||||||
"ghostty-config",
|
"ghostty-config",
|
||||||
|
|||||||
@@ -231,9 +231,7 @@ impl Benchmark for OscParser {
|
|||||||
Ok(_) => return Ok(()),
|
Ok(_) => return Ok(()),
|
||||||
Err(_) => return Err(BenchmarkFailed),
|
Err(_) => return Err(BenchmarkFailed),
|
||||||
}
|
}
|
||||||
for &c in &osc_buf[..len] {
|
self.parser.next_slice(&osc_buf[..len]);
|
||||||
self.parser.next(c);
|
|
||||||
}
|
|
||||||
black_box(self.parser.end(Some(0x07)));
|
black_box(self.parser.end(Some(0x07)));
|
||||||
self.parser.reset();
|
self.parser.reset();
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ edition.workspace = true
|
|||||||
license.workspace = true
|
license.workspace = true
|
||||||
|
|
||||||
[dependencies]
|
[dependencies]
|
||||||
|
bytemuck = { version = "1.25.2", features = ["derive", "extern_crate_alloc"] }
|
||||||
crossbeam-channel = "0.5.17"
|
crossbeam-channel = "0.5.17"
|
||||||
flate2 = "1.1.10"
|
flate2 = "1.1.10"
|
||||||
ghostty-datastruct.workspace = true
|
ghostty-datastruct.workspace = true
|
||||||
|
|||||||
@@ -76,7 +76,7 @@ pub enum SemanticPrompt {
|
|||||||
PromptContinuation = 2,
|
PromptContinuation = 2,
|
||||||
}
|
}
|
||||||
/// Eight-byte terminal cell; all-zero bits describe an empty cell.
|
/// 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)]
|
#[repr(transparent)]
|
||||||
pub struct Cell(u64);
|
pub struct Cell(u64);
|
||||||
impl Cell {
|
impl Cell {
|
||||||
@@ -744,12 +744,10 @@ impl Page {
|
|||||||
row.set_cells(usize::from(y) * usize::from(cap.cols));
|
row.set_cells(usize::from(y) * usize::from(cap.cols));
|
||||||
rows.push(row);
|
rows.push(row);
|
||||||
}
|
}
|
||||||
let mut cells = Vec::new();
|
|
||||||
let count = usize::from(cap.cols) * usize::from(cap.rows);
|
let count = usize::from(cap.cols) * usize::from(cap.rows);
|
||||||
cells
|
// Zeroed allocation: large buffers come lazily zeroed from the OS.
|
||||||
.try_reserve_exact(count)
|
let cells: Vec<Cell> =
|
||||||
.map_err(|_| Error::OutOfMemory)?;
|
bytemuck::allocation::try_zeroed_vec(count).map_err(|()| Error::OutOfMemory)?;
|
||||||
cells.resize(count, Cell::default());
|
|
||||||
let grapheme_alloc = ChunkAlloc::new(l.grapheme_alloc_layout, 16)?;
|
let grapheme_alloc = ChunkAlloc::new(l.grapheme_alloc_layout, 16)?;
|
||||||
let mut grapheme_data = Vec::new();
|
let mut grapheme_data = Vec::new();
|
||||||
let codepoints = grapheme_alloc.chunks * 4;
|
let codepoints = grapheme_alloc.chunks * 4;
|
||||||
@@ -1486,15 +1484,18 @@ impl Page {
|
|||||||
// Row metadata/cell indices were validated above (`get_row`).
|
// Row metadata/cell indices were validated above (`get_row`).
|
||||||
let from = source_row.cells() + src_x;
|
let from = source_row.cells() + src_x;
|
||||||
let to = original_row.cells() + dst_x;
|
let to = original_row.cells() + dst_x;
|
||||||
for n in 0..len {
|
let src_cells = &src.cells[from..from + len];
|
||||||
let cell = src.cells[from + n];
|
let mut run = 0;
|
||||||
|
for (n, cell) in src_cells.iter().enumerate() {
|
||||||
if !cell.has_grapheme() && !cell.hyperlink() && !cell.has_styling() {
|
if !cell.has_grapheme() && !cell.hyperlink() && !cell.has_styling() {
|
||||||
// Plain cell: no managed memory to copy.
|
continue;
|
||||||
self.cells[to + n] = cell;
|
}
|
||||||
} else {
|
// 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)?;
|
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 {
|
} else {
|
||||||
let from = source_row.cells() + src_x;
|
let from = source_row.cells() + src_x;
|
||||||
let to = original_row.cells() + dst_x;
|
let to = original_row.cells() + dst_x;
|
||||||
|
|||||||
@@ -153,6 +153,8 @@ pub struct PageList {
|
|||||||
nodes: Vec<Option<Node>>,
|
nodes: Vec<Option<Node>>,
|
||||||
free_nodes: Vec<usize>,
|
free_nodes: Vec<usize>,
|
||||||
page_pool: Vec<Page>,
|
page_pool: Vec<Page>,
|
||||||
|
/// Never-written pages (all cells zero) that need no `reinit` memset.
|
||||||
|
clean_pool: Vec<Page>,
|
||||||
/// Oldest page.
|
/// Oldest page.
|
||||||
pub first: Option<NodeId>,
|
pub first: Option<NodeId>,
|
||||||
/// Last active page.
|
/// Last active page.
|
||||||
@@ -211,6 +213,7 @@ impl PageList {
|
|||||||
nodes: Vec::new(),
|
nodes: Vec::new(),
|
||||||
free_nodes: Vec::new(),
|
free_nodes: Vec::new(),
|
||||||
page_pool: Vec::new(),
|
page_pool: Vec::new(),
|
||||||
|
clean_pool: Vec::new(),
|
||||||
first: None,
|
first: None,
|
||||||
last: None,
|
last: None,
|
||||||
page_serial: 0,
|
page_serial: 0,
|
||||||
@@ -257,7 +260,13 @@ impl PageList {
|
|||||||
return Err(Error::OutOfSpace);
|
return Err(Error::OutOfSpace);
|
||||||
}
|
}
|
||||||
self.nodes.try_reserve(1).map_err(|_| Error::OutOfMemory)?;
|
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);
|
let mut p = self.page_pool.swap_remove(pos);
|
||||||
p.reinit()?;
|
p.reinit()?;
|
||||||
p
|
p
|
||||||
@@ -2737,7 +2746,8 @@ impl PageList {
|
|||||||
let mut result = Self::init(self.cols, self.rows, Some(self.explicit_max_size))?;
|
let mut result = Self::init(self.cols, self.rows, Some(self.explicit_max_size))?;
|
||||||
while let Some(id) = result.first {
|
while let Some(id) = result.first {
|
||||||
let node = result.detach(id).ok_or(Error::InvalidId)?;
|
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.page_serial = 0;
|
||||||
result.total_rows = 0;
|
result.total_rows = 0;
|
||||||
|
|||||||
@@ -496,6 +496,37 @@ impl Parser {
|
|||||||
self.next_state(c);
|
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)]
|
#[inline(never)]
|
||||||
fn next_state(&mut self, c: u8) {
|
fn next_state(&mut self, c: u8) {
|
||||||
#[allow(clippy::enum_glob_use)]
|
#[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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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.
|
buffer); `Parser::data()` also clones the buffer.
|
||||||
- terminal-parser sgr (1.5x): moving the 64-byte `Option<Action>` array out of the
|
- 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.
|
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<Style>` resize (84 B/cell, every
|
||||||
|
row is styled in this benchmark) and cell copy; a lazy style vec did not help here, so reverted.
|
||||||
Reference in new issue
Block a user