Skip to content

perf 4/5: renderer — 2.7× cheaper full-frame build, memoised row shapes - #64

Merged
EDToaster merged 12 commits into
perf/termfrom
perf/render
Oct 22, 2026
Merged

EDToaster merged 12 commits into
perf/termfrom
perf/render

Conversation

@EDToaster

Copy link
Copy Markdown
Owner

Stack 4/5. Based on #63. It makes each frame cheaper to build in toastty-render: shaping rows and building GPU instances. The rendered output is unchanged.

What changed

  • Dense char table for the shaping fast path (text/glyph_rasterizer.rs). Characters below U+3000 cost one table load. Before, each one took up to three SipHash set lookups. The sets stay the source of truth, and a test checks every table entry against the old logic.
  • Row shapes memoised by their exact text (shape_line_memo, new text::row_shaper::RowShaper).
    • There is one memo per mode-2027 setting, trimmed to about two screens of rows each frame.
    • It is cleared when an old shape could be stale: a char found to ligate or become uncacheable, a cache miss, an atlas eviction, or a font-size/DPI change.
    • A row with a glyph that got no atlas slot is not memoised, so it is retried, as before.
    • Repainting unchanged CJK text drops from 33 ms to 0.18 ms.
  • No background quad for default-bg cells on full-clear frames. The clear already painted that colour, so this roughly halves the instances on a text screen. It is gated by may_skip_default_bg, which is off whenever edge bleed is active. It also replaces the old drop-default-bg pass for images drawn below cell backgrounds.
  • sRGB → linear is a 256-entry table holding bit-identical values. Truecolor cells used to cost six powf each.
  • Faint (SGR 2) colour is remembered across a run, instead of costing nine powf per cell.
  • Blink detection is done in the row-text pass instead of a second scan.
  • Bench.
    • bench/paced.py plus the paced_text, paced_tui, paced_cjk and *_big scenarios draw one full-screen synchronized frame every 20 ms, so the renderer is actually timed under load.
    • render_term gains TOASTTY_BENCH_CPU_ONLY=1 / TOASTTY_BENCH_MIN=1, which report the min and median of 400 CPU-only frames and stay usable on a loaded machine.

Review

An independent reviewer read the branch and found three problems. All are fixed here and confirmed by a re-review.

  1. Dropped: cosmic-text's shape-run-cache feature. It was in an earlier version of this branch.
    • Its key leaves out text direction, so a word made only of neutral characters, shaped inside an RTL line, came back mirrored in a later LTR line (the cells rendered blank). The row memo then kept the bad shape.
    • Cargo feature unification also turned it on in toastty-markdown, where nothing trims it.
    • Regression test: rtl_neutral_word_does_not_leak_into_ltr_row. It fails with the feature forced back on.
    • Cost: new CJK text in every row is back to about the base cost (see the table).
  2. The default-bg skip changed pixels under edge bleed. This happened with extend_background_when = always, scrolled back by part of a row: the bleed quads overlap neighbouring rows. It is now gated off whenever bleed is active.
    • tests/skip_default_bg.rs covers padding, bleed, a partial top row and a bottom sliver, in both Rgba8Unorm and the app's Bgra8UnormSrgb.
    • The test also asserts that forcing the skip under bleed does change pixels.
  3. The row memo no longer freezes degraded shapes. These are atlas-slot failures and chars that later become cache misses.

Numbers

CPU per frame, shaping + instance building only (TOASTTY_BENCH_MIN=1, min of 400 frames):

scenario before after speedup
repaint 200×60 497 µs 181 µs ×2.7
repaint 300×120 1498 µs 549 µs ×2.7
new text every frame, 256-colour, 200×60 / 300×120 951 / 2852 µs 329 / 1000 µs ×2.9
new text, truecolor, 200×60 / 300×120 1001 / 2997 µs 313 / 948 µs ×3.2
new text with CJK/emoji, 200×60 / 300×120 10.3 / 32.2 ms 9.4 / 29.8 ms ×1.1
repaint of faint text, 200×60 1796 µs 234 µs ×7.7
repaint of CJK text, 200×60 33.3 ms 0.18 ms ×190
CJK scroll, one new line per frame, 200×60 32.9 ms 0.36 ms ×92

The 256-colour rows overstate the base cost a little, because the bench passes no palette table.

End to end (bench/run.py, Xvfb, 3 reps back to back, compared with #63):

scenario frame ms p50 frame ms p99 CPU s
paced_text (128×42) 0.90 → 0.74 1.28 → 1.08 0.57 → 0.53
paced_text_big (256×73) 2.10 → 1.65 2.71 → 2.21 1.53 → 1.41
paced_tui 0.84 → 0.67 1.13 → 0.91 0.54 → 0.48
paced_tui_big 1.74 → 1.30 2.49 → 1.67 1.37 → 1.19
paced_cjk 5.54 → 5.39 7.00 → 7.26 1.99 → 1.96
scroll_wheel_big 1.12 → 1.07 overlaps on re-runs 1.04 → 0.98
  • Unchanged: idle CPU, idle memory, startup and key → frame p50.
  • Key → frame p99: 0.1 ms higher in one set of runs (1.56 → 1.77 ms, 133 samples). An interleaved run against perf 5/5: startup on worker threads (first frame 82→49 ms), binary −1 MB #65 measured 1.70 vs 1.61 ms, so it is within noise. The reviewer timed the keystroke path in isolation: it costs a few µs at most.
  • About 0.7 ms of each Xvfb frame is software presentation, which the test setup forces, so on a real compositor the gains take up a larger share of each frame.

Visual evidence

Built from #63 and from this branch, run on Xvfb with identical scenes, and compared with magick compare -metric AE. All 14 pairs differ by 0 pixels: 7 scenes, each in the default config and in an 8 px padding + extend_background_when = always config. The scenes are colours/attributes, CJK/emoji/box drawing/combining marks, cursor over a glyph, selection, scrolled back by 1 and 2 notches, and back at the bottom.

Text scenes, before (#63):

before (#63): colours, attributes, 256-colour, truecolor, CJK, emoji, box drawing, combining marks

After (this branch), 0 differing pixels:

after: the same scenes, pixel-identical

Scrolled back with padding and edge bleed on, the case the review caught. Before (#63):

before (#63): scrolled back, padding + edge bleed, alternating full-width colour rows

After (this branch), 0 differing pixels:

after: the same scrolled scene, pixel-identical

For reference, the pre-review build (default-bg skip ungated) against #63 on that scene: the differing pixels are in red along the bottom partial row, 756 px in total. The shipped build differs nowhere.

pre-review ungated build vs #63: red marks the 756 differing pixels on the bottom partial row

Testing

bench/paced.py runs inside toastty and emits one full-screen
synchronized-output frame every 20 ms, so frames actually render
(unlike the dense_cells flood) and frame_us shows the per-frame render
cost. paced_text: new random coloured words every frame (no row text
repeats); paced_tui: an htop-like screen whose text mostly stays put
while colours and a few numbers change. Both at 1280x800 and
2560x1400 (_big).
The fast path did up to three SipHash lookups per char
(uncacheable_chars, char_cache_misses, char_cache) plus a width test,
and grew LineGlyphs::by_column one resize per glyph. A row of 200 ASCII
chars took ~24 us to "shape" that way.

Mirror the three sets into a dense FastChar table for chars below
U+3000 (Latin through box drawing, blocks and Braille): one indexed load
per char gives Bail / Skip / Cached(slot). The sets stay the source of
truth; every mutation goes through a helper that recomputes the char's
entry with the old precedence, and set_font_size rebuilds the table.
Chars above U+3000 keep the hashed lookups. by_column is sized once
from the row with trailing blanks trimmed, with the same final contents
as before.

A test checks the table against the old hashed fast path on mixed
input and for every char in the table.
Every full redraw (scroll, viewport change, a TUI repainting in new
colours) marks all rows dirty, and every dirty row was shaped again
even when its text was unchanged. For rows that need cosmic-text (CJK,
emoji, ligatures) that is ~0.5 ms a row: a full repaint of a
mixed-script 200x60 screen took ~33 ms of CPU.

GlyphRasterizer::shape_line_memo keys shaped rows by their exact text
(one map per mode-2027 state) and hands back a shared Arc<LineGlyphs>,
so the row-shape cache and the memo share one allocation. It is
cleared whenever an earlier shape could go stale: a char turning
uncacheable or becoming a recorded miss, an atlas shelf eviction, a
size change. A row where a glyph got no atlas slot (atlas full even
after evicting) is not memoised, so it is retried the next time it is
dirty, as before. trim_line_memo, called once per rendered frame,
keeps it to about a screenful of recently used rows beyond the
visible ones.

The per-row text building and dirty-row loop move from render_term
into text::row_shaper::RowShaper, which the render_term criterion bench
now drives directly instead of keeping its own copy. The bench gains
fullframe_unicode_200x60 and scroll_{ascii,unicode}_200x60, and
TOASTTY_BENCH_CPU_ONLY=1 to time shaping + instance building without
the GPU. The rich-block suppress list is only allocated when rich
blocks are on screen.

A regression test checks that a neutral-only word shaped at RTL level
(mirrored brackets) doesn't leak into an LTR row with the same word.
A full redraw starts with LoadOp::Clear(theme.bg), and the bg pass
writes each quad with a REPLACE blend, so a quad in exactly theme.bg
that no other bg quad overlaps rewrites the value the clear already put
there. On a typical text screen most cells are on the default bg:
dropping those quads roughly halves the instances built, uploaded and
drawn per full redraw.

The full builder gets CellRenderOpts::skip_default_bg. The renderer
turns it on through may_skip_default_bg: only when edge bleed is off,
because a grown edge quad reaches over the neighbouring (partial) rows
and a default-bg quad drawn after it is what paints over that bleed.
The SIZE-9 frames (a z < INT32_MIN/2 image on screen) keep dropping
default-bg quads regardless, as before, so the post-pass that did that
(drop_default_bg_quads) goes and its tests move to instance.rs. The
dirty builder's damage.all fallback forces the option off, since it may
run under LoadOp::Load.

tests/skip_default_bg.rs renders colour, reverse, decoration, wide and
emoji scenes, and a view scrolled back by part of a row with and
without padding + bleed, in Rgba8Unorm and the app's Bgra8UnormSrgb,
and checks that the gated option changes no pixel (and that forcing it
under bleed would). The snapshot harness now shapes viewport rows with
RowShaper and can inset the grid and bleed like the renderer.
The instance builders resolve truecolor cells (and, without an OSC 4
palette, 256-colour ones) through srgb_channel_to_linear for the fg and
bg of every cell: six powf calls per truecolor cell. Serve the 256
possible inputs from a lazily built table holding exactly the same
values (a test compares every entry bit for bit). Halves the CPU of a
full-screen repaint of new coloured text in the render_term bench
(newtext 200x60: 727 -> 350 us).
TOASTTY_BENCH_MIN=1 renders 400 CPU-only frames per scenario (repaint
of unchanged text, new 256-colour and truecolor text every frame at
200x60 and 300x120, unicode repaint and scroll) and prints the minimum
and median frame time. The minimum stays usable while other processes
load the machine, where criterion's means swing by 2x.
paced.py --mode cjk (run.py paced_cjk) mixes CJK words and emoji into
the paced text frames, and the render_term min-time mode gains
newtext_cjk at both sizes: every row needs cosmic-text, so these time
the shaper's slow path. Text mode output is unchanged.
RowShaper walked every dirty row's cells twice: once for row_has_blink,
once to build the shaper text. Fold the blink test into the text pass
(a debug assertion checks it against row_has_blink). ~5% off a
full-repaint frame in the render_term min-time bench.
dim_color is nine powf calls (sRGB round trip per channel) and both
instance builders ran it for every SGR 2 cell. Faint text comes in runs
of the same fg/bg, so keep a one-entry memo per build, keyed on the
exact f32 bits. A full screen of faint text, 200x60: 1.40 -> 0.23 ms per
repaint (render_term min-time repaint_dim).
Split bench_min_times into report / frame-timing / screen-generator
helpers (it had grown past clippy's line limit) and build the new-text
screens with write! instead of format!-and-push.
@EDToaster
EDToaster merged commit 04acc33 into main Oct 22, 2026
1 of 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