Skip to content

perf(tui): memoize rendering hot paths for long conversations#3404

Merged
dgageot merged 5 commits into
docker:mainfrom
dgageot:perf/tui-long-conversation-rendering
Jul 2, 2026
Merged

perf(tui): memoize rendering hot paths for long conversations#3404
dgageot merged 5 commits into
docker:mainfrom
dgageot:perf/tui-long-conversation-rendering

Conversation

@dgageot

@dgageot dgageot commented Jul 2, 2026

Copy link
Copy Markdown
Member

TUI rendering of long conversations was spending significant time re-computing things that rarely change: scrollbar geometry, visible-line widths, and the per-frame join of the content column with the scrollbar column. On a 500-message history at 120×40, a single render frame cost ~185µs and 353 allocations.

The fix memoizes three independent hot paths. The scrollbar now caches its track and thumb cells keyed on thumb geometry and styled cell content, so drag state and theme changes still invalidate naturally. The scroll view caches ansi.StringWidth results per content line, keyed on the string pointer identity of each line, and invalidates the whole cache when SetContent replaces the slice. The content+scrollbar column join replaces lipgloss.JoinHorizontal (which re-splits and re-measures already-padded strings) with a single-pass string builder that zips the two columns directly. A ViewWithRestyledLines fast path was also added so the messages list can benefit from cached widths even after selection or URL-hover restyling; restyled lines fall back to a fresh width measurement rather than trusting the cache, since runewidth and ansi.StringWidth can disagree on complex grapheme clusters.

After these changes the same benchmark runs at 6.1µs/op with 32 allocations — roughly a 30× throughput improvement. New unit tests assert byte-identical output versus the old JoinHorizontal-based composition across all scrollbar and reserved-space modes, and cover width-cache invalidation and the restyled-line re-measure fallback.

dgageot added 5 commits July 2, 2026 00:35
View() re-rendered one lipgloss style per track line on every frame while
scrolling. Render the track and thumb cells once per call and memoize the
joined result keyed by thumb geometry and the styled cells, so drag state
and theme switches still invalidate naturally.

BenchmarkMessagesView_LargeHistory: 185us -> 122us, 353 -> 47 allocs/op.
compose() re-measured ansi.StringWidth for every visible line on every
frame, dominating scroll cost for large viewports. Cache widths per content
line, invalidated when SetContent receives a different slice, and add
ViewWithRestyledLines for the messages list whose selection/URL-hover
restyling is width-preserving.

BenchmarkMessagesView_RenderWhileScrolling: 130us -> 100us/op.
…zontal

compose() already pads every line to exactly contentWidth, so
JoinHorizontal's per-line splitting and width re-measuring was pure
overhead — it dominated the remaining per-frame cost of scrolling a long
conversation. Zip content, gap, and scrollbar cells with a single builder,
and expose the scrollbar's cached per-line cells via ViewLines().

A differential test asserts the output is byte-identical to the previous
JoinHorizontal-based composition in all scrollbar/reserved-space modes.

BenchmarkMessagesView_LargeHistory: 110us -> 6.8us/op, 45KB -> 25KB.
Selection/URL-hover restyling measures with runewidth while the scrollview
caches ansi.StringWidth; the two can disagree on complex grapheme clusters
(ZWJ emoji, flag sequences), so a restyled line could be padded with a
stale width. Only reuse the memoized width when the visible line is
byte-identical to the content line (O(1) for unchanged lines via string
pointer identity); anything else is re-measured as before.
…story

Existing benchmarks only covered the clean scroll path. Streaming sets
renderDirty on every chunk, forcing ensureAllItemsRendered to rebuild the
full line buffer; this benchmark captures that cost so future work on the
rebuild path is measurable.
@dgageot dgageot requested a review from a team as a code owner July 2, 2026 08:48

@docker-agent docker-agent left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Assessment: 🟢 APPROVE

The memoization work is well-structured and the performance wins are real. The three independent hot-path caches (scrollbar geometry, per-line width, and the zip builder) each have clear invalidation paths and are exercised by the new unit tests.

On the two hypotheses raised during review:

  1. GC-based address reuse in SetContent (&lines[0] != &m.lines[0] guard): Go's GC is non-moving, so an object's address is stable for its lifetime. At the point the comparison executes, m.lines still holds the old backing array, keeping it alive — so the old and new arrays can never have the same address simultaneously. This is safe.

  2. baseLine captured before syncScrollbar clamping: ViewWithRestyledLines captures m.scrollOffset as baseLine, then viewWithLines calls syncScrollbar(). In the messages.go call sequence, SetContent and SetScrollOffset are called immediately before ViewWithRestyledLines, already propagating the clamped offset to the scrollbar. syncScrollbar therefore reads back the same value and makes no change. The baseLine is always consistent with the visible-line slice.

No correctness issues were found in the changed code.

@aheritier aheritier added the area/tui For features/issues/fixes related to the TUI label Jul 2, 2026
@dgageot dgageot merged commit f03bf88 into docker:main Jul 2, 2026
9 of 10 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

area/tui For features/issues/fixes related to the TUI

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants