Skip to content

perf 3/5: parser + grid hot path — 2–7× parse, lazy scrollback (idle RSS 101→60 MB) - #63

Merged
EDToaster merged 12 commits into
perf/iofrom
perf/term
Oct 22, 2026
Merged

EDToaster merged 12 commits into
perf/iofrom
perf/term

Conversation

@EDToaster

Copy link
Copy Markdown
Owner

Stack 3/5. Based on #62. It speeds up the parser and terminal state (toastty-term, toastty-parser) and makes scrollback memory lazy. The rendered output is unchanged.

Changes

  1. Row cells are a private Vec<Cell> with a used prefix (8050756).
    • Real rows always overflowed the SmallVec<[Cell;16]> inline buffer, so a row shrinks from about 350 B to 56 B.
    • Every cell past used is blank, so recycling a cold row on a linefeed blanks only what was written instead of 2.5 KB.
    • The cells are private (cells() / cells_mut()), and a debug assert checks the invariant across the whole test suite.
  2. Scrollback ring slots are allocated on demand (221ef63). That's about 26 MB less at startup, and ED 3 now frees history memory. Grid::slot no longer uses %.
  3. Damage marks are O(1) for left-to-right printing, and mark_all returns early when everything is already marked (ede4dcd).
  4. Plain text no longer goes through vte (f24ce69).
    • The scanner tracks when vte is provably back in ground state, then hands text to the handler itself.
    • Printable-ASCII runs arrive in one Perform::print_ascii call. The default is one print per byte, so other handlers are unaffected.
  5. Term::print_ascii writes a run one row segment at a time (ff25867): it looks the row up once and marks damage once per segment. Wrapping, insert mode (IRM) and autowrap-off at the edge still go through print_char.
  6. Common CSI sequences are parsed without vte (fc613e2). The result matches vte byte for byte, and anything unusual is handed to vte. toastty_parser::Params is now our own type with vte's API.
  7. Non-ASCII printing is cheaper (eabb8c5). The 297-entry placeholder-table scan runs only next to a placeholder, and the grapheme check no longer builds a String per character.
  8. Smaller parser speedups: a SWAR printable-run scan (fddc47b) and a tighter loop for CSI digits (996a458).
  9. Docs: a dated note in docs/decisions/scrollback.md (10f668f). Ring semantics, stable line ids, reflow and eviction are unchanged.

Results

Headless parse_bench (MB/s, best of 5, base and new runs interleaved):

workload before after
ASCII 43 163 (3.8×)
SGR 63 157 (2.5×)
Unicode 29 55 (1.9×)
dense cells 116 240 (2.1×)
seq with bare LF 20.7 27.5 (1.35×)
seq with CRLF (what a PTY delivers) 23 155 (6.8×)

End to end (bench/run.py, Xvfb, median of 3, compared with #62):

scenario before after
cat ASCII MB/s / CPU s 39.7 / 2.25 97.5 / 1.13
cat SGR MB/s / CPU s 30.9 / 1.23 131.9 / 0.46
cat Unicode MB/s / CPU s 12.3 / 1.56 38.3 / 0.61
seq MB/s / CPU s 20.6 / 2.02 25.7 / 1.59
dense_cells 128×42 / 256×73 MB/s 105 / 98 226 / 200
idle RSS 101.5 MB 60.4 MB
flood peak RSS 103–128 MB 62–91 MB
launch→first frame (3 interleaved runs of 5) 100 / 105 / 101 ms 83 / 76 / 73 ms
key→frame p50 0.61 ms 0.61 ms

seq is now limited by the PTY and reader, not parsing: headless parsing of the CRLF form is 155 MB/s.

Visual evidence

The same scene rendered by #62 and by this branch, as 1280×800 window captures. It has scrollback, SGR attributes, truecolor and 256-colour, CJK, emoji with ZWJ and skin tone, combining marks, tabs, absolute positioning, and a wrapped line. magick compare -metric AE reports 0 differing pixels.

Before (#62):
before: reference scene

After (this PR):
after: same scene, pixel-identical

Review

An independent reviewer ran a differential fuzz harness: about 6.6M random escape-heavy byte streams fed into the base and new Term, split into reads of 1–12 bytes, with random resizes. After every chunk it compared a full state dump: every cell in the primary screen, alt screen, scrollback and viewport, plus cursor, modes, title, tab stops, damage and PTY replies.

  • Regressions: none.
  • One intentional behaviour change. Base mishandled a DEL inside an open escape sequence or CSI when it was the last byte of an all-ASCII read and the next read started with a byte ≥ 0x80. vte then stayed in the sequence, the WIRE-17 abort was skipped, and the result depended on where the read was split. This branch behaves the same however the input is split. Regression tests are in 7e06add.
  • The reviewer also confirmed: the vte-idle tracking at every transition against the vte 0.15 source, fast_csi against vte's CSI states, parity of the Params API, the used-prefix invariant on every write path, and that empty ring slots never become visible.

Risks

  • The vte bypass depends on knowing when vte is idle, which was checked against vte 0.15's state machine. Bumping vte (it's pinned =0.15.0) means re-checking those transitions and fast_csi.
  • Row::cells is now private. Downstream code uses cells() / cells_mut().

Tests

  • Differential property tests for the parser (bypass vs vte path) and for Term (fast path vs per-character printing: cells, wrap flags, scrollback, cursor, damage, pending wrap; covering wide chars, margins, IRM, DECAWM off and the alt screen).
  • Unit tests for Row::used, on-demand ring slots and the run scan, plus the DEL split-read regression tests.
  • cargo test --workspace passes on the rebased branch, and clippy adds no warnings beyond the base.

Row kept its cells in a SmallVec<[Cell; 16]>: ~330 bytes of inline
storage per ring slot that real rows (one cell per column) always
spill out of. A plain Vec is 24 bytes.

The cells are now private (Row::cells / cells_mut) so the row can
track `used`, an upper bound on the written prefix: every cell past
it is Cell::BLANK. Row::clear, which runs on every bottom-of-screen
linefeed on a row recycled from the far end of the scrollback ring
(cold in cache), then blanks only that prefix instead of all cols
cells. put / erase / fill track it exactly; cells_mut conservatively
marks the whole row. A debug assertion in clear checks the invariant
across the whole test suite.

Call sites outside the crate change mechanically (.cells -> .cells()).
Grid::new blanked every ring slot up front: 10 042 rows x 128 cols x
20-byte cells ~= 26 MB of RSS at startup before any output. Off-screen
slots now start empty and get their cells when output first scrolls
into them; ED 3 hands history memory back. Every path that brings a
slot on screen (scroll_up, resize, reflow) sizes it, so visible rows
still always hold cols cells.

Grid::slot did (head + idx) % cap, a 64-bit divide on every row lookup
(several per printed character); a conditional subtract covers the
in-range indices, with % kept for far out-of-range ones.
…rk_all

RowDamage::mark binary-searched and inserted for every printed column;
printing goes left to right, so append when the column is past the last
one (mark_range extends in one go). Damage::mark_all re-walked all rows
on every full-screen scroll even when `all` was already set; `all`
implies every row is marked, so return early (resize still marks the
rows it adds).
vte decodes ground text and calls print() once per character. The
scanner now tracks when vte is known to be in its ground state (after a
CSI / escape sequence's final byte, an OSC's BEL, a DCS's ST) and then
dispatches clean ground text itself, exactly as vte's ground_dispatch
would, except that each run of printable ASCII arrives in one new
Perform::print_ascii call (default: one print per byte). Anything else
handed to vte clears the flag, so uncertain cases keep going through vte.

The all-ASCII shortcut that left CSI parsing to vte inside 'ground' text
(open_seq) is gone: every CSI / escape sequence now goes through
step_seq, which is what tells the scanner where vte returns to ground.

Tests: a differential proptest runs random escape-heavy input through
the parser with and without the bypass (test-only no_bypass hook) and
requires identical event streams, including chunk splits.
Term::print_ascii takes the parser's runs. Within a segment that fits
before the right edge every byte behaves identically under print_char
(width 1, no placeholder or grapheme handling, no pending wrap after
the first, wide-cluster orphans only possible at the two ends), so the
row is looked up once, the cells written in one loop (Row::write_ascii)
and the damage marked as one span (RowDamage::mark_cols, the same
representation as per-column marks). Wrap, IRM and DECAWM-off at the
edge still go through print_char a byte at a time.

A proptest compares the fast path against one print per character
(cells, wrap flags, scrollback, cursor, damage, and the pending-wrap
state via a probe print) over random text, wide chars, margins, IRM,
DECAWM and alt-screen input.
vte's per-byte CSI state machine was most of the time on SGR-heavy
output (42 % of the dense workload). With vte in ground, the scanner
now parses a CSI of the usual shape itself (private marker, params with
; and :, up to two intermediates, final byte) and dispatches it,
matching vte 0.15 byte for byte, including the empty trailing parameter
it always pushes and the csi_param_digits report. Anything vte treats
specially (C0 / DEL / CAN / SUB / ESC / non-ASCII inside, misplaced
private markers or digits, more than 32 params or 2 intermediates, or
the input ending mid-sequence) dispatches nothing and goes to vte as
before.

vte::Params can only be built by vte, so toastty_parser::Params is now
our own type with the same API; parameters vte parses (DCS hooks, CSIs
left to it) are copied into it.

Tests: differential proptests (fast path vs vte, via the no_bypass
hook) over CSI bodies built from every byte class vte distinguishes,
plus the parameter-cap and saturation edges.
Every non-ASCII character printed went through a linear scan of kitty's
297-entry placeholder diacritic table before checking whether the cell
to its left is a placeholder at all (34 % of the unicode workload), and
try_extend_grapheme built the cluster string before deciding whether the
character joins it (malloc + free for nearly every CJK / Greek char).
Check the cheap conditions first; behaviour is unchanged.
The ground dispatcher's run scan checked one byte per iteration; a SWAR
test (high bit, +1 overflow for DEL, -0x20 borrow for C0) does a word at
a time. The lowest flagged byte is exact since carries and borrows only
reach bytes above it; a unit test covers every byte value at every
offset.
fast_csi matched every digit through the full byte-class match; the
digits of one number now go through a tight inner loop (the parameter
count can only change at ; : or the final byte, so one capacity check
covers the number).
Dated note in docs/decisions/scrollback.md: Vec cells instead of the
inline SmallVec, on-demand ring slots, written-prefix tracking. A test
pins the Row slot size.
…e one read

Locks in a fix that came with the ground-text bypass: base's all-ASCII
shortcut stopped tracking an open escape / CSI at a DEL (seq_end stops
at 0x7F), so a read starting with a byte >= 0x80 didn't abort it
(WIRE-17) and vte swallowed the text; the result depended on where the
read split. step_seq keeps the sequence open across the DEL. Parser and
term tests check split vs one-read agreement for ESC [ 1 DEL | é X,
ESC ( DEL | 0x9C a and ESC - DEL | 0x8F 0xBD, and the 3x10 repro
(prints U+FFFD X, cursor at column 2).
# Conflicts:
#	crates/toastty-term/src/link.rs
#	crates/toastty-term/src/reflow.rs
#	crates/toastty-term/src/term.rs
@EDToaster
EDToaster merged commit 04acc33 into main Oct 22, 2026
2 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant