From bdb0a4c7c8b81f1f49f9265374aaf9d4ee4b0231 Mon Sep 17 00:00:00 2001 From: tazjin Date: Mon, 11 Aug 2025 16:46:11 +0300 Subject: [PATCH] perf: reduce GC pressure in rendering pipeline (#687) The `renderIterator` function previously caused an extremely large amount of small strings and other objects to be created and abandoned during rendering, which caused performance issues after some time as the GC had to occasionally collect all of these objects. This was exacerbated by using streaming in models, which leads to extremely frequent updates. This commit refactors renderIterator to avoid constructing temporary strings. Instead, the function now performs two passes: 1. A first pass in which the "fragments" to render are aggregated, but the `rendered` string is not yet copied and appended/prepended to. 2. A second pass, which uses a `strings.Builder` to efficiently construct the final output string. This has *significantly* improved crush's performance for me. Whereas before `perf` would show it spending up to 70% (!) of its time in GC-related Go runtime functions, it now spends a trivial amount there. pprof's heap profiling previously showed renderIterator as a massive hotspot, whereas it now doesn't even show up in alloc `top` anymore. The updated function is slightly harder to read. I did spend some time trying different options for making it more readable, and also asking various LLMs about it (using crush!), but ultimately didn't find anything better than the two-pass solution. --- internal/tui/exp/list/list.go | 55 +++++++++++++++++++++++++++++------ 1 file changed, 46 insertions(+), 9 deletions(-) diff --git a/internal/tui/exp/list/list.go b/internal/tui/exp/list/list.go index a9ece4d336cc29716fc4ad0868bb32a90b70af27..fd9cc7f071cca915d8e4c363b9d3f5630f0e18cf 100644 --- a/internal/tui/exp/list/list.go +++ b/internal/tui/exp/list/list.go @@ -790,15 +790,28 @@ func (l *list[T]) blurSelectedItem() tea.Cmd { return tea.Batch(cmds...) } -// render iterator renders items starting from the specific index and limits hight if limitHeight != -1 +// renderFragment holds updated rendered view fragments +type renderFragment struct { + view string + gap int +} + +// renderIterator renders items starting from the specific index and limits height if limitHeight != -1 // returns the last index and the rendered content so far // we pass the rendered content around and don't use l.rendered to prevent jumping of the content func (l *list[T]) renderIterator(startInx int, limitHeight bool, rendered string) (string, int) { + var fragments []renderFragment + currentContentHeight := lipgloss.Height(rendered) - 1 itemsLen := l.items.Len() + finalIndex := itemsLen + + // first pass: accumulate all fragments to render until the height limit is + // reached for i := startInx; i < itemsLen; i++ { - if currentContentHeight >= l.height && limitHeight { - return rendered, i + if limitHeight && currentContentHeight >= l.height { + finalIndex = i + break } // cool way to go through the list in both directions inx := i @@ -811,6 +824,7 @@ func (l *list[T]) renderIterator(startInx int, limitHeight bool, rendered string if !ok { continue } + var rItem renderedItem if cache, ok := l.renderedItems.Get(item.ID()); ok { rItem = cache @@ -820,19 +834,42 @@ func (l *list[T]) renderIterator(startInx int, limitHeight bool, rendered string rItem.end = currentContentHeight + rItem.height - 1 l.renderedItems.Set(item.ID(), rItem) } + gap := l.gap + 1 if inx == itemsLen-1 { gap = 0 } - if l.direction == DirectionForward { - rendered += rItem.view + strings.Repeat("\n", gap) - } else { - rendered = rItem.view + strings.Repeat("\n", gap) + rendered - } + fragments = append(fragments, renderFragment{view: rItem.view, gap: gap}) + currentContentHeight = rItem.end + 1 + l.gap } - return rendered, itemsLen + + // second pass: build rendered string efficiently + var b strings.Builder + if l.direction == DirectionForward { + b.WriteString(rendered) + for _, f := range fragments { + b.WriteString(f.view) + for range f.gap { + b.WriteByte('\n') + } + } + + return b.String(), finalIndex + } + + // iterate backwards as fragments are in reversed order + for i := len(fragments) - 1; i >= 0; i-- { + f := fragments[i] + b.WriteString(f.view) + for range f.gap { + b.WriteByte('\n') + } + } + b.WriteString(rendered) + + return b.String(), finalIndex } func (l *list[T]) renderItem(item Item) renderedItem {