Skip to content

perf: skip plain text when scanning for OSC, and measure the read path - #25

Merged
tkc merged 1 commit into
mainfrom
measure-read-throughput
Sep 10, 2026
Merged

perf: skip plain text when scanning for OSC, and measure the read path#25
tkc merged 1 commit into
mainfrom
measure-read-throughput

Conversation

@tkc

@tkc tkc commented Sep 10, 2026

Copy link
Copy Markdown
Owner

Follow-up to #24, from reading what other terminals do about performance.

New measurement: termit --throughput

Everything measured so far was the drawing half. This measures the other half — how fast bytes from the pty are consumed — through the real read path (OscScanner scan → vte → grid), without opening a window, timing the scan and the parse separately.

alacritty's vtebench has to run inside the terminal under test and this machine cannot drive termit's window, so its workloads are reproduced instead (light_cells, medium_cells, dense_cells, scrolling, scrolling_region, cursor_motion, unicode, sync_cells, plus an osc_heavy of our own). Results are not comparable with other terminals; they are comparable across commits.

163x47, 10,000 lines of scrollback, 16 MiB per workload:

workload total scan parse rate
light_cells 153ms 0.3ms 153ms 104 MiB/s
medium_cells 102ms 0.3ms 102ms 157 MiB/s
dense_cells 58ms 6.9ms 51ms 277 MiB/s
scrolling 179ms 0.3ms 179ms 89 MiB/s
scrolling_region 634ms 0.3ms 634ms 25 MiB/s
cursor_motion 66ms 11.3ms 54ms 244 MiB/s
unicode 70ms 0.4ms 69ms 230 MiB/s
sync_cells 94ms 2.4ms 91ms 171 MiB/s
osc_heavy 150ms 17.0ms 133ms 106 MiB/s

What that measures is how fast the parser and the grid can accept bytes: 100–270 MiB/s. How fast the pty actually delivers them was not measured, so this does not say where the slowness a user feels comes from. It does say there is no headroom left to win in the read path itself, apart from the scan below.

The one win it did find: memchr

OSC 7 / OSC 133 are picked up by scanning the same bytes the VT parser gets, one byte at a time. Almost all of it is plain text, so jump to the next ESC:

workload scan before scan after
light_cells 8.7ms 0.35ms (25x)
dense_cells 12.2ms 6.9ms
cursor_motion 14.1ms 11.4ms
osc_heavy 21.7ms 17.0ms

light_cells end to end: 193ms → 153–170ms per 16 MiB. Two tests added for the skip: offsets stay right after a long plain run, and an OSC directly after a non-OSC escape is still picked up.

Measured and rejected: one TextArea per row

ghostty's performance discussion optimises for ~64 distinct styles; termit hands glyphon one area per cell (7,661 full screen), so batching by row looked promising. --bench now estimates it:

approach areas shape + build prepare total
current (per cell) 7,661 0.02ms 0.55ms 0.57ms
per row 47 0.84ms 0.22ms 1.06ms

Fewer areas do cut prepare (~43ns of fixed cost per area), but a row's contents change every frame, so the row has to be reshaped every frame. Per-cell shaping happens once, ever. 1.9x worse — not adopted, and written down so it is not tried again.

Also recorded, not fixable here

Scrolling inside a DECSTBM region runs at 25 MiB/s against 89 MiB/s without a region (3.5x), because it becomes a line rotation instead of a push into history. tmux, vim and agent UIs all use regions. That code is inside alacritty_terminal.

Docs

  • docs/references/performance-techniques.md — new: every technique found in other terminals (alacritty damage tracking, foot's memmove scroll, ghostty's vsync-off default and renderer thread, kitty's repaint delay, wgpu frame latency), each marked adopted / rejected-with-measurement / still open.
  • docs/performance.md 7.12 (row batching), 7.13 (read path), and 2.3 for the new flag.
  • docs/BUILD.md--throughput.

Test

cargo test — 202 pass. cargo clippy --all-targets -- -D warnings and cargo fmt --check — clean.

🤖 Generated with Claude Code

https://claude.ai/code/session_01GvMacYD6T9EoWqc7C247Hr

There was no way to measure the other half of the terminal: how fast
bytes coming out of the pty are consumed. Added `--throughput`, which
feeds nine synthetic workloads modelled on alacritty's vtebench through
the real read path (OscScanner, then the VT parser, then the grid)
without opening a window, and times the scan and the parse separately.
vtebench itself has to run inside the terminal under test, which this
machine cannot drive, so the payloads are reproduced instead: results are
not comparable with other terminals, but they are comparable across
commits.

What it found:

- The read path is not a bottleneck: 100-270 MiB/s, two orders of
  magnitude above what a flooding build produces.
- OSC scanning walked every byte one at a time. Most output is plain
  text, so jump to the next ESC with memchr: 8.7ms -> 0.35ms per 16 MiB
  of plain text (25x), and light_cells overall 193ms -> 153-170ms.
- Scrolling inside a DECSTBM region runs at 25 MiB/s against 89 MiB/s
  without a region. That is inside alacritty_terminal, so it is recorded
  rather than fixed.

`--bench` also gained an estimate for the "one TextArea per row instead
of per cell" idea that ghostty's performance discussion suggests. It
loses: fewer areas cut prepare from 0.55ms to 0.22ms, but reshaping a
row every frame costs 0.84ms against 0.02ms for cached single glyphs.
Measured and rejected, written down so it is not tried again.

docs/references/performance-techniques.md is the new catalogue: every
technique found in other terminals with a verdict of adopted, rejected
with the measurement, or still open.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GvMacYD6T9EoWqc7C247Hr
@tkc
tkc force-pushed the measure-read-throughput branch from c0699be to abafd78 Compare September 10, 2026 01:45
@tkc
tkc merged commit 94d9ff1 into main Sep 10, 2026
1 check passed
@tkc
tkc deleted the measure-read-throughput branch September 10, 2026 01:48
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