Repository navigation
perf 4/5: renderer — 2.7× cheaper full-frame build, memoised row shapes - #64
Merged
Merged
Conversation
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.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
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
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.shape_line_memo, newtext::row_shaper::RowShaper).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.powfeach.powfper cell.bench/paced.pyplus thepaced_text,paced_tui,paced_cjkand*_bigscenarios draw one full-screen synchronized frame every 20 ms, so the renderer is actually timed under load.render_termgainsTOASTTY_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.
shape-run-cachefeature. It was in an earlier version of this branch.toastty-markdown, where nothing trims it.rtl_neutral_word_does_not_leak_into_ltr_row. It fails with the feature forced back on.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.rscovers padding, bleed, a partial top row and a bottom sliver, in bothRgba8Unormand the app'sBgra8UnormSrgb.Numbers
CPU per frame, shaping + instance building only (
TOASTTY_BENCH_MIN=1, min of 400 frames):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):paced_text(128×42)paced_text_big(256×73)paced_tuipaced_tui_bigpaced_cjkscroll_wheel_bigVisual 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 = alwaysconfig. 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):
After (this branch), 0 differing pixels:
Scrolled back with padding and edge bleed on, the case the review caught. Before (#63):
After (this branch), 0 differing pixels:
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.
Testing
cargo test --workspace --no-fail-fast: 2426 passed, 0 failed.cargo clippy --workspace --all-targets: no new warnings compared with perf 3/5: parser + grid hot path — 2–7× parse, lazy scrollback (idle RSS 101→60 MB) #63.cargo fmt --checkis clean fortoastty-render.