Skip to content

Cache shaped lines so unchanged rows skip CoreText typesetting - #619

Open
Nico-Sanchez wants to merge 3 commits into
migueldeicaza:mainfrom
Nico-Sanchez:shaped-line-cache-upstream
Open

Cache shaped lines so unchanged rows skip CoreText typesetting#619
Nico-Sanchez wants to merge 3 commits into
migueldeicaza:mainfrom
Nico-Sanchez:shaped-line-cache-upstream

Conversation

@Nico-Sanchez

Copy link
Copy Markdown

Profiling a terminal hosting an AI-agent TUI (Claude Code) put essentially all of the app's draw time inside CTLineCreateWithAttributedString: drawTerminalContents rebuilds 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 sample of a real workload, 774 of 778 drawTerminalContents stacks were inside CTLineCreateWithAttributedString.

What this PR does

Shapes each line once and caches the result (ViewLineInfo + CTLines + runs). The key carries everything the shaped output depends on:

  • the row and the BufferLine's identity,
  • a content digest of the line (cells' rune/width/attribute + isWrapped, renderMode, image count),
  • the column count and the row's selection range,
  • an epoch bumped on font changes (resetCaches), palette changes (colorsChanged), and hovered-link highlight changes.

Content-keying (3rd commit) matters more than it looks: BufferLine.generation counts 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 same bump() that advances generation, 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

  • draw path: drawTerminalContents fell from 778 to 68 samples (−91%) in the same 20 s profile
  • whole process on a 9-pane agent workload: 247 → 88 ms/s
  • pure-scroll synthetic benchmark: ~7% (that workload was already shape-once-per-line; the win is in-place repaints)

The 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

  • macOS + iOS builds; the full test suite passes (73 tests).
  • The stored properties live on the platform view classes (extensions can't hold storage); the cache logic is shared in AppleTerminalView.swift.
  • Happy to split the stats commit out, rename things, or rework the eviction policy to LRU if you'd prefer — the wholesale flush was chosen for simplicity and hasn't been observable in practice.

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.
@migueldeicaza

Copy link
Copy Markdown
Owner

Oh that is a good idea - nice find. Let me see where the cache is, need to think about it.

@migueldeicaza

Copy link
Copy Markdown
Owner

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:

  • iOS link hover does not invalidate the cache.

  • The content hash omits the cell payload. Link rendering uses cell.hasPayload, but the digest only includes code, width, and attributes.

  • Runtime rendering options can reuse stale ViewLineInfo as the epoch bumping is not consistent.

  • Sadly, image count is not a sufficient image identity. ShapedLine retains the exact image array, but contentHash includes only images?.count. Replacing an image or placement with another image while the count stays constant reuses the old array.

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.

2 participants