Skip to content

Commit

Permalink
perf(vt100): increase render performance for tui_term codepath (#9123)
Browse files Browse the repository at this point in the history
### Description

The crate `tui_term` renders the `vt100::Screen` by [fetching each
cell](https://github.com/a-kenji/tui-term/blob/development/src/state.rs#L27)
and then filling the `ratatui::buffer:Cell` [with the vt100 cell's
contents](https://github.com/a-kenji/tui-term/blob/development/src/vt100_imp.rs#L37).

This leads to the following functions being called once for each cell on
the users terminal:
-
[`Cell::contents`](https://github.com/vercel/turborepo/blob/main/crates/turborepo-vt100/src/cell.rs#L90)
which unnecessarily allocates on every call even though there's a fixed
limit to the size of the `str` that could be returned. This PR changes
this method to return a `&str` that is constructed from the content
bytes. The new helper function `append_char` is basically
[`String::push`](https://doc.rust-lang.org/src/alloc/string.rs.html#1358)
-
[`Grid::visible_row`](https://github.com/vercel/turborepo/blob/main/crates/turborepo-vt100/src/grid.rs#L200)
which iterates through the scrollback rows and screen rows. This PR
changes this to instead index directly into the rows

### Testing Instructions

Tested by profiling this basic script that render a tui_term 1000 times.
```rust
use std::io;

use ratatui::{
    prelude::CrosstermBackend,
    style::{Style, Stylize},
    widgets::{Block, Borders, Widget},
    Terminal,
};
use tui_term::widget::PseudoTerminal;

pub fn main() {
    let input = include_bytes!("turbo-build.log");
    let mut terminal =
        Terminal::new(CrosstermBackend::new(io::stdout())).unwrap();
    terminal.hide_cursor().unwrap();
    let size = terminal.size().unwrap();

    let mut parser =
        turborepo_vt100::Parser::new(size.height, size.width, 1024);
    parser.process(input);

    let screen = parser.screen();

    for _ in 0..1000 {
        let block = Block::default()
            .borders(Borders::ALL)
            .border_style(Style::default().blue());
        let term = PseudoTerminal::new(screen).block(block);
        terminal
            .draw(|frame| {
                let area = frame.size();
                term.render(area, frame.buffer_mut())
            })
            .unwrap();
    }
}

```

The two functions that this targets:

`fill_buf_cell` codepath
Before
<img width="1329" alt="Screenshot 2024-09-06 at 4 23 47 PM"
src="https://github.com/user-attachments/assets/6b5e27c6-31d6-4f71-842b-3efd37fa606e">

After
<img width="1327" alt="Screenshot 2024-09-06 at 4 24 32 PM"
src="https://github.com/user-attachments/assets/bf966a03-b093-48e3-8848-5d725c22b637">

`visible_cell` codepath
Before

<img width="1511" alt="Screenshot 2024-09-06 at 4 25 09 PM"
src="https://github.com/user-attachments/assets/c0052806-98e7-4d0a-94c9-6f2aff1867e2">
After
<img width="1396" alt="Screenshot 2024-09-06 at 4 25 41 PM"
src="https://github.com/user-attachments/assets/f1360722-82ea-4ff1-8d5e-2035dc774ccb">
  • Loading branch information
chris-olszewski authored Jan 15, 2025
1 parent e0a3297 commit 92d0a5e
Show file tree
Hide file tree
Showing 6 changed files with 94 additions and 21 deletions.
60 changes: 45 additions & 15 deletions crates/turborepo-vt100/src/cell.rs
Original file line number Diff line number Diff line change
@@ -1,34 +1,39 @@
use unicode_width::UnicodeWidthChar as _;

const CODEPOINTS_IN_CELL: usize = 6;
const BYTES_IN_CHAR: usize = 4;

/// Represents a single terminal cell.
#[derive(Clone, Debug, Eq)]
pub struct Cell {
contents: [char; CODEPOINTS_IN_CELL],
contents: [u8; CODEPOINTS_IN_CELL * BYTES_IN_CHAR],
// Number of bytes needed to represent all characters
num_bytes: u8,
// Length of characters
len: u8,
attrs: crate::attrs::Attrs,
selected: bool,
}

impl PartialEq<Self> for Cell {
fn eq(&self, other: &Self) -> bool {
if self.len != other.len {
if self.len != other.len || self.num_bytes != other.num_bytes {
return false;
}
if self.attrs != other.attrs {
return false;
}
let len = self.len();
let num_bytes = self.num_bytes();
// self.len() always returns a valid value
self.contents[..len] == other.contents[..len]
self.contents[..num_bytes] == other.contents[..num_bytes]
}
}

impl Cell {
pub(crate) fn new() -> Self {
Self {
contents: Default::default(),
num_bytes: 0,
len: 0,
attrs: crate::attrs::Attrs::default(),
selected: false,
Expand All @@ -40,9 +45,15 @@ impl Cell {
usize::from(self.len & 0x0f)
}

#[inline]
fn num_bytes(&self) -> usize {
usize::from(self.num_bytes & 0x0f)
}

pub(crate) fn set(&mut self, c: char, a: crate::attrs::Attrs) {
self.contents[0] = c;
self.len = 1;
self.num_bytes = 0;
self.len = 0;
self.append_char(0, c);
// strings in this context should always be an arbitrary character
// followed by zero or more zero-width characters, so we should only
// have to look at the first character
Expand All @@ -52,23 +63,44 @@ impl Cell {

pub(crate) fn append(&mut self, c: char) {
let len = self.len();
// This implies that len is at most 5 meaning num_bytes is at most 20
// with still enough room for a 4 byte char.
if len >= CODEPOINTS_IN_CELL {
return;
}
if len == 0 {
// 0 is always less than 6
self.contents[0] = ' ';
self.contents[0] = b' ';
self.num_bytes += 1;
self.len += 1;
}

let len = self.len();
let num_bytes = self.num_bytes();
// we already checked that len < CODEPOINTS_IN_CELL
self.contents[len] = c;
self.append_char(num_bytes, c);
}

#[allow(clippy::cast_possible_truncation, clippy::as_conversions)]
#[inline]
// Writes bytes representing c at start
// Requires caller to verify start <= CODEPOINTS_IN_CELL * 4
fn append_char(&mut self, start: usize, c: char) {
match c.len_utf8() {
1 => {
self.contents[start] = c as u8;
self.num_bytes += 1;
}
n => {
c.encode_utf8(&mut self.contents[start..]);
self.num_bytes += n as u8;
}
}
self.len += 1;
}

pub(crate) fn clear(&mut self, attrs: crate::attrs::Attrs) {
self.len = 0;
self.num_bytes = 0;
self.attrs = attrs;
self.selected = false;
}
Expand All @@ -87,12 +119,10 @@ impl Cell {
/// used, but will contain at most one character with a non-zero character
/// width.
#[must_use]
pub fn contents(&self) -> String {
let mut s = String::with_capacity(CODEPOINTS_IN_CELL * 4);
for c in self.contents.iter().take(self.len()) {
s.push(*c);
}
s
pub fn contents(&self) -> &str {
let num_bytes = self.num_bytes();
// Since contents has been constructed by appending chars encoded as UTF-8 it will be valid UTF-8
unsafe { std::str::from_utf8_unchecked(&self.contents[..num_bytes]) }
}

/// Returns whether the cell contains any text data.
Expand Down
16 changes: 15 additions & 1 deletion crates/turborepo-vt100/src/grid.rs
Original file line number Diff line number Diff line change
Expand Up @@ -198,7 +198,21 @@ impl Grid {
}

pub fn visible_row(&self, row: u16) -> Option<&crate::row::Row> {
self.visible_rows().nth(usize::from(row))
// The number of rows that are in scrollback before the current screen
let scrollback_rows_before_screen =
self.scrollback.len().saturating_sub(self.scrollback_offset);
// The index for row if it is in the scrollback
let scrollback_idx = scrollback_rows_before_screen + usize::from(row);
// If this is a valid scrollback index, then return that row
if scrollback_idx < self.scrollback.len() {
self.scrollback.get(scrollback_idx)
} else {
// Need to subtract scrollback offset
// e.g. if we are showing 1 row of scrollback, and user requests 3rd visible row
// we should return the second row in self.rows
self.rows
.get(usize::from(row).saturating_sub(self.scrollback_offset))
}
}

pub fn drawing_row(&self, row: u16) -> Option<&crate::row::Row> {
Expand Down
6 changes: 3 additions & 3 deletions crates/turborepo-vt100/src/row.rs
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
use crate::term::BufWrite as _;

#[derive(Clone, Debug)]
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct Row {
cells: Vec<crate::Cell>,
wrapped: bool,
Expand Down Expand Up @@ -135,7 +135,7 @@ impl Row {
}
prev_col += col - prev_col;

contents.push_str(&cell.contents());
contents.push_str(cell.contents());
prev_col += if cell.is_wide() { 2 } else { 1 };
}
}
Expand Down Expand Up @@ -322,7 +322,7 @@ impl Row {
}
let mut cell_contents = prev_first_cell.contents();
let need_erase = if cell_contents.is_empty() {
cell_contents = " ".to_string();
cell_contents = " ";
true
} else {
false
Expand Down
29 changes: 29 additions & 0 deletions crates/turborepo-vt100/src/screen.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1621,3 +1621,32 @@ fn u16_to_u8(i: u16) -> Option<u8> {
Some(i.try_into().unwrap())
}
}

#[cfg(test)]
mod test {
use crate::screen;

use super::*;

#[test]
fn test_visible_row_matches_visible_rows() {
// do some fuzzing
let rows = 10;
let scrollback = 100;
let mut parser = crate::Parser::new(rows, 10, scrollback);
for i in 1..120 {
parser.process(format!("{i}\n\n").as_bytes());
}
for i in 0..scrollback {
let screen = parser.screen_mut();
screen.set_scrollback(i);
let grid = screen.grid();
for row_ix in 0..rows {
assert_eq!(
grid.visible_row(row_ix),
grid.visible_rows().nth(usize::from(row_ix))
);
}
}
}
}
2 changes: 1 addition & 1 deletion crates/turborepo-vt100/src/tui_term.rs
Original file line number Diff line number Diff line change
Expand Up @@ -49,7 +49,7 @@ fn fill_buf_cell(
let fg = screen_cell.fgcolor();
let bg = screen_cell.bgcolor();

buf_cell.set_symbol(&screen_cell.contents());
buf_cell.set_symbol(screen_cell.contents());
let fg: Color = fg.into();
let bg: Color = bg.into();
let mut style = Style::reset();
Expand Down
2 changes: 1 addition & 1 deletion crates/turborepo-vt100/tests/helpers/fixtures.rs
Original file line number Diff line number Diff line change
Expand Up @@ -38,7 +38,7 @@ impl FixtureCell {
#[allow(dead_code)]
pub fn from_cell(cell: &vt100::Cell) -> Self {
Self {
contents: cell.contents(),
contents: cell.contents().to_string(),
is_wide: cell.is_wide(),
is_wide_continuation: cell.is_wide_continuation(),
fgcolor: cell.fgcolor(),
Expand Down

0 comments on commit 92d0a5e

Please sign in to comment.