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
@@ -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);
|
||||
|
||||
Reference in new issue
Block a user