on main help sign in
perf(ui): build render-cache fingerprints without per-frame allocations

The sidebar and compact-header render caches recompute a fingerprint of all their inputs on every frame to decide whether the cached render can be reused. Those fingerprints were built with fmt.Fprintf/Sprintf, which boxes every argument onto the heap — pure per-frame GC churn on the key whose entire purpose is to be cheaper than the render it guards. Build both into a scratch buffer reused across frames using strconv appends and hash once. compactFingerprint's cache key changes from string to uint64, also dropping the per-frame result-string allocation and string comparison. Microbenchmark (header + 5 files subset): 11 allocs/6060ns -> 0 allocs/1059ns. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

commit94f4d40b85acb0f9cd356033a71edc3a5c1aaa9d authoriko <mail@iko.soy> dateThu 2 Jul 2026, 05:04 (3 months ago) parent7f2f09b
3 files changed+106−33
internal/ui/model/header.go+35−7 internal/ui/model/sidebar.go+68−26 internal/ui/model/ui.go+3
internal/ui/model/header.go+35−7
@@ -2,6 +2,8 @@ package model
22
33import (
44 "fmt"
5+ "hash/fnv"
6+ "strconv"
57 "strings"
68
79 "charm.land/lipgloss/v2"
@@ -36,9 +38,12 @@ type header struct {
3638 // lipgloss.Render calls, PrettyPath+DirTrim, ansi.Truncate) every
3739 // frame; memoize the rendered string keyed by a cheap fingerprint of
3840 // everything it depends on.
39− compactCacheKey string
41+ compactCacheKey uint64
4042 compactCacheView string
4143 hasCompactCache bool
44+ // compactFPBuf is a scratch buffer reused across frames to build the
45+ // compact-header fingerprint without allocating (see compactFingerprint).
46+ compactFPBuf []byte
4247}
4348
4449// newHeader creates a new header model.
@@ -119,7 +124,7 @@ func (h *header) compactFingerprint(
119124 width int,
120125 hyperCredits *int,
121126 lspErrorCount int,
122−) string {
127+) uint64 {
123128 var contextWindow int64
124129 agentCfg := h.com.Config().Agents[config.AgentCoder]
125130 if model := h.com.Config().GetModelByType(agentCfg.Model); model != nil {
@@ -130,11 +135,34 @@ func (h *header) compactFingerprint(
130135 if hasCredits {
131136 credits = *hyperCredits
132137 }
133− return fmt.Sprintf("w=%d;sid=%s;ct=%d;pt=%d;eu=%t;lsp=%d;det=%t;hyper=%t;hc=%t/%d;cw=%d;cwd=%s",
134− width, session.ID,
135− session.CompletionTokens, session.PromptTokens, session.EstimatedUsage,
136− lspErrorCount, detailsOpen, h.com.IsHyper(), hasCredits, credits,
137− contextWindow, h.com.Workspace.WorkingDir())
138+ // Build into a reused buffer with strconv appends and hash once, rather
139+ // than fmt.Sprintf: this runs every frame in compact mode, so the arg
140+ // boxing and result-string allocation Sprintf performed were pure churn.
141+ b := h.compactFPBuf[:0]
142+ putInt := func(k string, v int64) { b = append(b, k...); b = strconv.AppendInt(b, v, 10) }
143+ putInt("w=", int64(width))
144+ b = append(b, ";sid="...)
145+ b = append(b, session.ID...)
146+ putInt(";ct=", session.CompletionTokens)
147+ putInt(";pt=", session.PromptTokens)
148+ b = append(b, ";eu="...)
149+ b = strconv.AppendBool(b, session.EstimatedUsage)
150+ putInt(";lsp=", int64(lspErrorCount))
151+ b = append(b, ";det="...)
152+ b = strconv.AppendBool(b, detailsOpen)
153+ b = append(b, ";hyper="...)
154+ b = strconv.AppendBool(b, h.com.IsHyper())
155+ b = append(b, ";hc="...)
156+ b = strconv.AppendBool(b, hasCredits)
157+ putInt("/", int64(credits))
158+ putInt(";cw=", contextWindow)
159+ b = append(b, ";cwd="...)
160+ b = append(b, h.com.Workspace.WorkingDir()...)
161+ h.compactFPBuf = b // retain the grown buffer for reuse next frame
162+
163+ hash := fnv.New64a()
164+ hash.Write(b)
165+ return hash.Sum64()
138166}
139167
140168// buildCompactHeader renders the compact header string.
internal/ui/model/sidebar.go+68−26
@@ -6,6 +6,7 @@ import (
66 "hash/fnv"
77 "image"
88 "sort"
9+ "strconv"
910
1011 "charm.land/lipgloss/v2"
1112 "github.com/charmbracelet/crush/internal/config"
@@ -134,41 +135,67 @@ func getDynamicHeightLimits(availableHeight, fileCount, lspCount, mcpCount, skil
134135// changed. skillItems and mcpSorted are computed once per render by
135136// drawSidebar and reused by buildSidebar to avoid recomputing them.
136137func (m *UI) sidebarFingerprint(area uv.Rectangle, skillItems []skillStatusItem, mcpSorted []config.MCP) uint64 {
137− h := fnv.New64a()
138− fmt.Fprintf(h, "w=%d;h=%d;theme=%s;", area.Dx(), area.Dy(), m.themeKey)
138+ // Build the fingerprint into a reused scratch buffer using strconv
139+ // appends instead of fmt.Fprintf. This runs on every frame (before the
140+ // sidebar render cache is consulted), so the reflection-based arg boxing
141+ // fmt.Fprintf performs was pure per-frame allocation churn. Appending to
142+ // a retained buffer and hashing it once is allocation-free after warmup.
143+ b := m.sidebarFPBuf[:0]
144+ putInt := func(k string, v int64) { b = append(b, k...); b = strconv.AppendInt(b, v, 10) }
145+ putStr := func(k, v string) { b = append(b, k...); b = append(b, v...) }
146+ putBool := func(k string, v bool) { b = append(b, k...); b = strconv.AppendBool(b, v) }
147+
148+ putInt("w=", int64(area.Dx()))
149+ putInt(";h=", int64(area.Dy()))
150+ putStr(";theme=", m.themeKey)
151+ b = append(b, ';')
139152
140153 if m.session != nil {
141− fmt.Fprintf(h, "sid=%s;title=%s;ct=%d;pt=%d;cost=%f;eu=%t;",
142− m.session.ID, m.session.Title,
143− m.session.CompletionTokens, m.session.PromptTokens,
144− m.session.Cost, m.session.EstimatedUsage)
154+ putStr("sid=", m.session.ID)
155+ putStr(";title=", m.session.Title)
156+ putInt(";ct=", m.session.CompletionTokens)
157+ putInt(";pt=", m.session.PromptTokens)
158+ b = append(b, ";cost="...)
159+ b = strconv.AppendFloat(b, m.session.Cost, 'f', -1, 64)
160+ putBool(";eu=", m.session.EstimatedUsage)
161+ b = append(b, ';')
145162 }
146163
147− fmt.Fprintf(h, "cwd=%s;hyper=%t;", m.com.Workspace.WorkingDir(), m.com.IsHyper())
164+ putStr("cwd=", m.com.Workspace.WorkingDir())
165+ putBool(";hyper=", m.com.IsHyper())
166+ b = append(b, ';')
148167 if m.hyperCredits != nil {
149− fmt.Fprintf(h, "hc=%d;", *m.hyperCredits)
168+ putInt("hc=", int64(*m.hyperCredits))
169+ b = append(b, ';')
150170 }
151171 // The logo string is already cached; fold it in so any change to it (or
152172 // the small-render breakpoint) invalidates the sidebar.
153− h.Write([]byte("logo="))
154− h.Write([]byte(m.sidebarLogo))
155− h.Write([]byte{';'})
173+ putStr("logo=", m.sidebarLogo)
174+ b = append(b, ';')
156175
157176 if model := m.selectedLargeModel(); model != nil {
158− fmt.Fprintf(h, "model=%s|%s|reason=%t|lvls=%d|def=%s|think=%t|eff=%s|ctx=%d;",
159− model.ModelCfg.Provider, model.CatwalkCfg.Name,
160− model.CatwalkCfg.CanReason, len(model.CatwalkCfg.ReasoningLevels),
161− model.CatwalkCfg.DefaultReasoningEffort, model.ModelCfg.Think,
162− model.ModelCfg.ReasoningEffort, model.CatwalkCfg.ContextWindow)
177+ putStr("model=", model.ModelCfg.Provider)
178+ putStr("|", model.CatwalkCfg.Name)
179+ putBool("|reason=", model.CatwalkCfg.CanReason)
180+ putInt("|lvls=", int64(len(model.CatwalkCfg.ReasoningLevels)))
181+ putStr("|def=", model.CatwalkCfg.DefaultReasoningEffort)
182+ putBool("|think=", model.ModelCfg.Think)
183+ putStr("|eff=", model.ModelCfg.ReasoningEffort)
184+ putInt("|ctx=", int64(model.CatwalkCfg.ContextWindow))
185+ b = append(b, ';')
163186 } else {
164− h.Write([]byte("model=nil;"))
187+ b = append(b, "model=nil;"...)
165188 }
166189
167190 // Files.
168191 for i := range m.sessionFiles {
169192 f := &m.sessionFiles[i]
170− fmt.Fprintf(h, "f%d=%s|%d|%d|%d;", i,
171− f.LatestVersion.Path, f.Additions, f.Deletions, f.LatestVersion.UpdatedAt)
193+ putInt("f", int64(i))
194+ putStr("=", f.LatestVersion.Path)
195+ putInt("|", int64(f.Additions))
196+ putInt("|", int64(f.Deletions))
197+ putInt("|", f.LatestVersion.UpdatedAt)
198+ b = append(b, ';')
172199 }
173200
174201 // LSPs (sorted by name for a stable fingerprint), with diagnostic counts.
@@ -188,9 +215,14 @@ func (m *UI) sidebarFingerprint(area uv.Rectangle, skillItems []skillStatusItem,
188215 // Hash the error text, not just its presence: the render prints
189216 // the message, so a changed message within the same state must
190217 // invalidate the cache.
191− fmt.Fprintf(h, "l=%s|%v|err=%s|%d/%d/%d/%d;",
192− st.Name, st.State, errText(st.Error),
193− counts.Error, counts.Warning, counts.Hint, counts.Information)
218+ putStr("l=", st.Name)
219+ putInt("|", int64(st.State))
220+ putStr("|err=", errText(st.Error))
221+ putInt("|", int64(counts.Error))
222+ putInt("/", int64(counts.Warning))
223+ putInt("/", int64(counts.Hint))
224+ putInt("/", int64(counts.Information))
225+ b = append(b, ';')
194226 }
195227
196228 // MCPs (config order, matching the render).
@@ -199,16 +231,26 @@ func (m *UI) sidebarFingerprint(area uv.Rectangle, skillItems []skillStatusItem,
199231 if !ok {
200232 continue
201233 }
202− fmt.Fprintf(h, "m=%s|%v|err=%s|%d/%d/%d;",
203− state.Name, state.State, errText(state.Error),
204− state.Counts.Tools, state.Counts.Prompts, state.Counts.Resources)
234+ putStr("m=", state.Name)
235+ putInt("|", int64(state.State))
236+ putStr("|err=", errText(state.Error))
237+ putInt("|", int64(state.Counts.Tools))
238+ putInt("/", int64(state.Counts.Prompts))
239+ putInt("/", int64(state.Counts.Resources))
240+ b = append(b, ';')
205241 }
206242
207243 // Skills.
208244 for _, it := range skillItems {
209− fmt.Fprintf(h, "s=%s|%s|%s;", it.icon, it.name, it.title)
245+ putStr("s=", it.icon)
246+ putStr("|", it.name)
247+ putStr("|", it.title)
248+ b = append(b, ';')
210249 }
211250
251+ m.sidebarFPBuf = b // retain the grown buffer for reuse next frame
252+ h := fnv.New64a()
253+ h.Write(b)
212254 return h.Sum64()
213255}
214256
internal/ui/model/ui.go+3
@@ -301,6 +301,9 @@ type UI struct {
301301 sidebarView string
302302 sidebarCacheKey uint64
303303 hasSidebarCache bool
304+ // sidebarFPBuf is a scratch buffer reused across frames to build the
305+ // sidebar fingerprint without allocating (see sidebarFingerprint).
306+ sidebarFPBuf []byte
304307
305308 // selectedLargeModel cache: resolving the selected large model hits
306309 // the agent coordinator, which is an RPC (AgentIsReady + AgentModel)