Cache shaped lines so unchanged rows skip CoreText typesetting - #619
Cache shaped lines so unchanged rows skip CoreText typesetting#619Nico-Sanchez wants to merge 3 commits into
Conversation
Profiling a terminal under an active workload puts essentially all of the draw time inside CTLineCreateWithAttributedString: every draw rebuilds an attributed string per visible row and runs CoreText's typesetter and OpenType shaping engine over it from scratch. For a monospace grid that is almost entirely repeated work, and a terminal redraws unchanged content constantly - each partial-rect exposure (a single draw pass can call drawTerminalContents dozens of times), every cursor blink, every 60 Hz tick when a single row changed, and every full repaint of an alt-screen TUI that rewrote one line. Lines are now shaped once and cached. The key carries everything the result depends on: the row, the BufferLine's identity and its generation counter (so a mutated line reshapes exactly once), the column count, the row's selection range, and an epoch bumped for global style changes - the font (via resetCaches), the palette (colorsChanged) and the hovered-link highlight. The cache is capped and flushed wholesale past the cap, which costs one reshape of the visible rows. Measured on a 145x40 view streaming output: drawTerminalContents fell 91% and total process CPU about 20%; on a real workload where drawing is ~58% of the process's main-thread time the saving is proportionally larger. Builds for macOS and iOS.
The cache's benefit turned out to be workload-dependent - large when a full-screen TUI repaints rows in place, small on scrolling output - and guessing why is not measurement. SWIFTTERM_SHAPED_CACHE_STATS=1 reports hits, misses, flushes and live entries to stderr every 5 seconds; when unset the cost is one static bool test per drawn row.
generation counts writes, not changes. A full-screen TUI that repaints the same text every frame bumps it on every line, so a cache keyed on it misses almost everything it should hit: measured, a 10 Hz identical repaint gave a 2.5% hit rate while scrolling output gave 95%. BufferLine gains a lazily computed contentHash over the cells (rune, width, attribute) plus isWrapped, renderMode and image presence, invalidated by the same bump() that moves generation. Hashing one line costs a single pass over its cells - orders of magnitude less than re-shaping it through CoreText.
|
Oh that is a good idea - nice find. Let me see where the cache is, need to think about it. |
|
I am afraid that the idea of the cache is a good one, but like they say "There are 2 hard problems in computer science: cache invalidation, naming things, and off-by-1 errors". And this change did not survive :-) Few issues:
|
Profiling a terminal hosting an AI-agent TUI (Claude Code) put essentially all of the app's draw time inside
CTLineCreateWithAttributedString:drawTerminalContentsrebuilds an attributed string per visible row and runs CoreText's typesetter + OpenType shaping over it on every draw — and a terminal redraws unchanged content constantly (partial-rect exposures call it dozens of times per pass, cursor blinks, the 60 Hz tick after a single-row change, and every full repaint of an alt-screen TUI that rewrote one line).In a 20 s
sampleof a real workload, 774 of 778drawTerminalContentsstacks were insideCTLineCreateWithAttributedString.What this PR does
Shapes each line once and caches the result (
ViewLineInfo+CTLines + runs). The key carries everything the shaped output depends on:BufferLine's identity,isWrapped,renderMode, image count),resetCaches), palette changes (colorsChanged), and hovered-link highlight changes.Content-keying (3rd commit) matters more than it looks:
BufferLine.generationcounts writes, not changes, so a TUI that repaints identical text every frame invalidates everything — measured 2.5% hit rate on a 10 Hz identical repaint. Keyed on content it's 100%, while genuinely changing content (top) correctly misses and scrolling output sits at ~95% (hits on exposure/blink redraws of already-shaped rows). The digest is cached on the line and invalidated by the samebump()that advancesgeneration, so it costs one pass over the cells — orders of magnitude less than re-shaping.The cache is capped (2048 entries) and flushed wholesale past the cap; a flush costs one reshape of the visible rows.
Measured
drawTerminalContentsfell from 778 to 68 samples (−91%) in the same 20 s profileThe 2nd commit adds opt-in counters (
SWIFTTERM_SHAPED_CACHE_STATS=1→ hits/misses/flushes to stderr every 5 s; a single static bool test per row when unset) — that's how the numbers above were measured, and reviewers can check their own workloads with it.Notes
AppleTerminalView.swift.