on main help sign in
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.

commitbdb0a4c7c8b81f1f49f9265374aaf9d4ee4b0231 authortazjin <tazjin@tvl.su> dateMon 11 Aug 2025, 16:46 (1 year ago) parent95c4d55
1 file changed+46−9
internal/tui/exp/list/list.go+46−9
internal/tui/exp/list/list.go+46−9
@@ -790,15 +790,28 @@ func (l *list[T]) blurSelectedItem() tea.Cmd {
790790 return tea.Batch(cmds...)
791791}
792792
793−// render iterator renders items starting from the specific index and limits hight if limitHeight != -1
793+// renderFragment holds updated rendered view fragments
794+type renderFragment struct {
795+ view string
796+ gap int
797+}
798+
799+// renderIterator renders items starting from the specific index and limits height if limitHeight != -1
794800// returns the last index and the rendered content so far
795801// we pass the rendered content around and don't use l.rendered to prevent jumping of the content
796802func (l *list[T]) renderIterator(startInx int, limitHeight bool, rendered string) (string, int) {
803+ var fragments []renderFragment
804+
797805 currentContentHeight := lipgloss.Height(rendered) - 1
798806 itemsLen := l.items.Len()
807+ finalIndex := itemsLen
808+
809+ // first pass: accumulate all fragments to render until the height limit is
810+ // reached
799811 for i := startInx; i < itemsLen; i++ {
800− if currentContentHeight >= l.height && limitHeight {
801− return rendered, i
812+ if limitHeight && currentContentHeight >= l.height {
813+ finalIndex = i
814+ break
802815 }
803816 // cool way to go through the list in both directions
804817 inx := i
@@ -811,6 +824,7 @@ func (l *list[T]) renderIterator(startInx int, limitHeight bool, rendered string
811824 if !ok {
812825 continue
813826 }
827+
814828 var rItem renderedItem
815829 if cache, ok := l.renderedItems.Get(item.ID()); ok {
816830 rItem = cache
@@ -820,19 +834,42 @@ func (l *list[T]) renderIterator(startInx int, limitHeight bool, rendered string
820834 rItem.end = currentContentHeight + rItem.height - 1
821835 l.renderedItems.Set(item.ID(), rItem)
822836 }
837+
823838 gap := l.gap + 1
824839 if inx == itemsLen-1 {
825840 gap = 0
826841 }
827842
828− if l.direction == DirectionForward {
829− rendered += rItem.view + strings.Repeat("\n", gap)
830− } else {
831− rendered = rItem.view + strings.Repeat("\n", gap) + rendered
832− }
843+ fragments = append(fragments, renderFragment{view: rItem.view, gap: gap})
844+
833845 currentContentHeight = rItem.end + 1 + l.gap
834846 }
835− return rendered, itemsLen
847+
848+ // second pass: build rendered string efficiently
849+ var b strings.Builder
850+ if l.direction == DirectionForward {
851+ b.WriteString(rendered)
852+ for _, f := range fragments {
853+ b.WriteString(f.view)
854+ for range f.gap {
855+ b.WriteByte('\n')
856+ }
857+ }
858+
859+ return b.String(), finalIndex
860+ }
861+
862+ // iterate backwards as fragments are in reversed order
863+ for i := len(fragments) - 1; i >= 0; i-- {
864+ f := fragments[i]
865+ b.WriteString(f.view)
866+ for range f.gap {
867+ b.WriteByte('\n')
868+ }
869+ }
870+ b.WriteString(rendered)
871+
872+ return b.String(), finalIndex
836873}
837874
838875func (l *list[T]) renderItem(item Item) renderedItem {