perf(tui): memoize rendering hot paths for long conversations#3404
Conversation
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.
docker-agent
left a comment
There was a problem hiding this comment.
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:
-
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.linesstill holds the old backing array, keeping it alive — so the old and new arrays can never have the same address simultaneously. This is safe. -
baseLinecaptured beforesyncScrollbarclamping:ViewWithRestyledLinescapturesm.scrollOffsetasbaseLine, thenviewWithLinescallssyncScrollbar(). In the messages.go call sequence,SetContentandSetScrollOffsetare called immediately beforeViewWithRestyledLines, already propagating the clamped offset to the scrollbar.syncScrollbartherefore reads back the same value and makes no change. ThebaseLineis always consistent with the visible-line slice.
No correctness issues were found in the changed code.
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.StringWidthresults per content line, keyed on the string pointer identity of each line, and invalidates the whole cache whenSetContentreplaces the slice. The content+scrollbar column join replaceslipgloss.JoinHorizontal(which re-splits and re-measures already-padded strings) with a single-pass string builder that zips the two columns directly. AViewWithRestyledLinesfast 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, sincerunewidthandansi.StringWidthcan 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.