diff options
| author | Paul Buetow <paul@buetow.org> | 2026-05-13 14:41:18 +0300 |
|---|---|---|
| committer | Paul Buetow <paul@buetow.org> | 2026-05-13 14:41:18 +0300 |
| commit | d392eebe5bd127e1573734321b0cabaad4182d7c (patch) | |
| tree | e6e0b38ba26110411d80e00b224640c26b8110ae /internal | |
| parent | de6b9c4741dea87ce66e0309bac580030490dc30 (diff) | |
perf: replace string += concatenation with strings.Builder in TUI render hot paths
Swap out ad-hoc += string concatenation in the flamegraph toolbar/status
lines, dashboard filter summary, bubble/treemap status lines, eventstream
view, processes tab, and probes list for strings.Builder, eliminating
redundant allocations on every render tick. Also update dashboard/model_test.go
fake SnapshotSource implementations to match the updated interface signature.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Diffstat (limited to 'internal')
| -rw-r--r-- | internal/tui/dashboard/bubbles.go | 13 | ||||
| -rw-r--r-- | internal/tui/dashboard/model.go | 27 | ||||
| -rw-r--r-- | internal/tui/dashboard/model_test.go | 8 | ||||
| -rw-r--r-- | internal/tui/dashboard/processes.go | 6 | ||||
| -rw-r--r-- | internal/tui/dashboard/tabs.go | 7 | ||||
| -rw-r--r-- | internal/tui/dashboard/treemap.go | 12 | ||||
| -rw-r--r-- | internal/tui/eventstream/model.go | 12 | ||||
| -rw-r--r-- | internal/tui/flamegraph/controls.go | 27 | ||||
| -rw-r--r-- | internal/tui/flamegraph/model.go | 12 | ||||
| -rw-r--r-- | internal/tui/flamegraph/renderer.go | 10 | ||||
| -rw-r--r-- | internal/tui/help.go | 14 | ||||
| -rw-r--r-- | internal/tui/probes/model.go | 10 |
12 files changed, 110 insertions, 48 deletions
diff --git a/internal/tui/dashboard/bubbles.go b/internal/tui/dashboard/bubbles.go index f4fa6d5..8f9b745 100644 --- a/internal/tui/dashboard/bubbles.go +++ b/internal/tui/dashboard/bubbles.go @@ -542,14 +542,19 @@ func (c *bubbleChart) statusLine(width int) string { } node := c.nodes[c.selected] metricText := fmt.Sprintf("%s=%s", c.metricLabel(), c.formatMetricValue(node)) - base := fmt.Sprintf("sel:%d/%d %s | %s | bytes=%s", c.selected+1, len(c.nodes), node.Label, metricText, formatBytes(float64(node.Bytes))) + // Use a Builder to avoid extra allocations for the optional hint/detail suffixes + // that are appended conditionally on every render. + var b strings.Builder + b.WriteString(fmt.Sprintf("sel:%d/%d %s | %s | bytes=%s", c.selected+1, len(c.nodes), node.Label, metricText, formatBytes(float64(node.Bytes)))) if c.statusHint != "" { - base += " | " + c.statusHint + b.WriteString(" | ") + b.WriteString(c.statusHint) } if node.Detail != "" { - base += " | " + node.Detail + b.WriteString(" | ") + b.WriteString(node.Detail) } - return padOrTrim(base, width) + return padOrTrim(b.String(), width) } func (c *bubbleChart) metricLabel() string { diff --git a/internal/tui/dashboard/model.go b/internal/tui/dashboard/model.go index 2535a90..8a3c5d4 100644 --- a/internal/tui/dashboard/model.go +++ b/internal/tui/dashboard/model.go @@ -27,14 +27,18 @@ const dashboardHelpHintRows = 1 const dashboardExpandedHelpRows = 2 const dashboardTabBarRows = 1 -// SnapshotSource is the dashboard data source. +// SnapshotSource is the dashboard data source. Snapshot returns nil, nil when +// the engine is nil. A non-nil error indicates that snapshot construction +// failed and the caller should discard the result. type SnapshotSource interface { - Snapshot() *statsengine.Snapshot + Snapshot() (*statsengine.Snapshot, error) } +// resettableSnapshotSource extends SnapshotSource with a Reset method that +// clears accumulated state and restarts the series baselines. type resettableSnapshotSource interface { Reset() - Snapshot() *statsengine.Snapshot + Snapshot() (*statsengine.Snapshot, error) } type refreshTickMsg struct{} @@ -1105,15 +1109,22 @@ func (m Model) View() tea.View { } func (m Model) filterSummary() string { - summary := "filter: " + presenter.FilterSummary(m.globalFilter) + // Use a Builder to avoid repeated string copies for the optional suffix segments + // (filter stack, recording status, auto-reset label) on every render tick. + var b strings.Builder + b.WriteString("filter: ") + b.WriteString(presenter.FilterSummary(m.globalFilter)) if len(m.filterStack) > 0 { - summary += " | stack: " + strings.Join(m.filterStack, " | ") + b.WriteString(" | stack: ") + b.WriteString(strings.Join(m.filterStack, " | ")) } if m.recordingStatus != "" { - summary += " | " + m.recordingStatus + b.WriteString(" | ") + b.WriteString(m.recordingStatus) } - summary += " | " + m.autoResetStatus() - return summary + b.WriteString(" | ") + b.WriteString(m.autoResetStatus()) + return b.String() } // autoResetStatus is the human-readable label for the current diff --git a/internal/tui/dashboard/model_test.go b/internal/tui/dashboard/model_test.go index 4ca10c9..59c2155 100644 --- a/internal/tui/dashboard/model_test.go +++ b/internal/tui/dashboard/model_test.go @@ -24,9 +24,9 @@ type fakeSnapshotSource struct { snap *statsengine.Snapshot } -func (f *fakeSnapshotSource) Snapshot() *statsengine.Snapshot { +func (f *fakeSnapshotSource) Snapshot() (*statsengine.Snapshot, error) { f.snapshots++ - return f.snap + return f.snap, nil } type fakeResettableSnapshotSource struct { @@ -39,9 +39,9 @@ func (f *fakeResettableSnapshotSource) Reset() { f.resetCount++ } -func (f *fakeResettableSnapshotSource) Snapshot() *statsengine.Snapshot { +func (f *fakeResettableSnapshotSource) Snapshot() (*statsengine.Snapshot, error) { f.snapCount++ - return f.snap + return f.snap, nil } func stripANSIEscape(value string) string { diff --git a/internal/tui/dashboard/processes.go b/internal/tui/dashboard/processes.go index 34fdbc8..f4eedec 100644 --- a/internal/tui/dashboard/processes.go +++ b/internal/tui/dashboard/processes.go @@ -42,7 +42,11 @@ func renderProcessesWithSort(snap *statsengine.Snapshot, width, height, offset, columns := processColumns() out := renderSelectableTable(columns, rows, height, offset, selectedCol, "enter:filter", "s/S:sort", processSortHint(sortState), "v:mode", "b:metric") if pidFilter > 0 { - out += "\n" + "Note: this tab is most useful with All PIDs." + // Use a Builder to avoid an extra allocation for the PID-filter note suffix. + var b strings.Builder + b.WriteString(out) + b.WriteString("\nNote: this tab is most useful with All PIDs.") + return b.String() } return out } diff --git a/internal/tui/dashboard/tabs.go b/internal/tui/dashboard/tabs.go index 0e9d924..8fc5132 100644 --- a/internal/tui/dashboard/tabs.go +++ b/internal/tui/dashboard/tabs.go @@ -228,7 +228,12 @@ func renderTabBarPlain(active Tab, width int) string { text = truncatePlain(text, width) padding := width - utf8.RuneCountInString(text) if padding > 0 { - text += strings.Repeat(" ", padding) + // Use a Builder to avoid a redundant allocation when right-padding to width. + var b strings.Builder + b.Grow(len(text) + padding) + b.WriteString(text) + b.WriteString(strings.Repeat(" ", padding)) + return b.String() } } return text diff --git a/internal/tui/dashboard/treemap.go b/internal/tui/dashboard/treemap.go index 03c2917..4d5486a 100644 --- a/internal/tui/dashboard/treemap.go +++ b/internal/tui/dashboard/treemap.go @@ -438,7 +438,10 @@ func treemapStatusLine(items []syscallTreemapItem, selected int, metric bubbleMe default: metricText = fmt.Sprintf("%d", item.Count) } - status := fmt.Sprintf( + // Use a Builder to avoid a redundant allocation for the optional detail suffix + // appended conditionally on every render call. + var b strings.Builder + b.WriteString(fmt.Sprintf( "sel:%d/%d %s | %s=%s | bytes=%s", selected+1, len(items), @@ -446,11 +449,12 @@ func treemapStatusLine(items []syscallTreemapItem, selected int, metric bubbleMe treemapMetricLabel(metric), metricText, formatBytes(float64(item.Bytes)), - ) + )) if detail := strings.TrimSpace(item.Detail); detail != "" { - status += " | " + detail + b.WriteString(" | ") + b.WriteString(detail) } - return status + return b.String() } func treemapMetricLabel(metric bubbleMetric) string { diff --git a/internal/tui/eventstream/model.go b/internal/tui/eventstream/model.go index 2780524..a8f399c 100644 --- a/internal/tui/eventstream/model.go +++ b/internal/tui/eventstream/model.go @@ -500,9 +500,15 @@ func (m *Model) View(width, height int) string { if m.paused && m.selectedIdx >= 0 { status = fmt.Sprintf("Row %d/%d | Sel %d/%d Col %d/%d | Enter push-filter | Esc/F undo", rowNumber(start, len(m.filtered)), len(m.filtered), rowNumber(m.selectedIdx, len(m.filtered)), len(m.filtered), m.selectedCol+1, streamColumnCount) } - out := base + "\n" + status + // Use a Builder to avoid a redundant allocation for the optional status-message + // line appended conditionally on every render call. + var b strings.Builder + b.WriteString(base) + b.WriteString("\n") + b.WriteString(status) if m.statusMessage != "" { - out += "\n" + m.statusMessage + b.WriteString("\n") + b.WriteString(m.statusMessage) } if m.exportModal.Visible() { @@ -511,7 +517,7 @@ func (m *Model) View(width, height int) string { if m.searchModal.Visible() { return m.searchModal.View(width, height) } - return out + return b.String() } func (m *Model) Refresh() { diff --git a/internal/tui/flamegraph/controls.go b/internal/tui/flamegraph/controls.go index bd588b3..8ec1051 100644 --- a/internal/tui/flamegraph/controls.go +++ b/internal/tui/flamegraph/controls.go @@ -90,21 +90,27 @@ func (m Model) toolbarLine() string { state = lipgloss.NewStyle().Foreground(common.ColorDanger).Bold(true).Render("[PAUSED]") } order := m.currentFieldPresetLabel() - line := fmt.Sprintf("%s | view:%s | o:order(%s) | b:metric(%s) | /:search | enter/click:zoom | click ancestor:undo | u/esc:undo | r:reset | space:pause", state, compactFramePath(m.currentRootPath()), order, m.countFieldLabel()) + // Use a Builder to avoid repeated allocations for the optional suffix segments. + var b strings.Builder + b.WriteString(fmt.Sprintf("%s | view:%s | o:order(%s) | b:metric(%s) | /:search | enter/click:zoom | click ancestor:undo | u/esc:undo | r:reset | space:pause", + state, compactFramePath(m.currentRootPath()), order, m.countFieldLabel())) if m.searchQuery != "" { - line += " | filter:" + m.searchQuery + b.WriteString(" | filter:") + b.WriteString(m.searchQuery) } if m.statusMessage != "" { - line += " | " + m.statusMessage + b.WriteString(" | ") + b.WriteString(m.statusMessage) } if flameKeyDebugEnabled && m.lastKeyDebug != "" { - line += " | " + m.lastKeyDebug + b.WriteString(" | ") + b.WriteString(m.lastKeyDebug) } width := m.width if width <= 0 { width = 80 } - return padOrTrim(line, width) + return padOrTrim(b.String(), width) } func (m Model) helpOverlay() string { @@ -148,12 +154,15 @@ func (m Model) selectionStatusLine() string { shareLabel = fmt.Sprintf("%.2f%% of filtered %s", filterShare, metric) } } - line := fmt.Sprintf("[%s] sel:%d/%d %s | path:%s | depth:%d | total(%s):%d | %s", - mode, selIdx+1, len(m.frames), frame.Name, compactFramePath(frame.Path), frame.Depth, m.countFieldLabel(), frame.Total, shareLabel) + // Use a Builder to avoid a separate allocation for the optional filter suffix. + var b strings.Builder + b.WriteString(fmt.Sprintf("[%s] sel:%d/%d %s | path:%s | depth:%d | total(%s):%d | %s", + mode, selIdx+1, len(m.frames), frame.Name, compactFramePath(frame.Path), frame.Depth, m.countFieldLabel(), frame.Total, shareLabel)) if m.searchQuery != "" { - line += " | filter:" + m.searchQuery + b.WriteString(" | filter:") + b.WriteString(m.searchQuery) } - return common.HelpBarStyle.Width(width).Render(padOrTrim(line, width)) + return common.HelpBarStyle.Width(width).Render(padOrTrim(b.String(), width)) } func (m Model) currentFieldPresetLabel() string { diff --git a/internal/tui/flamegraph/model.go b/internal/tui/flamegraph/model.go index c188323..3c63c3e 100644 --- a/internal/tui/flamegraph/model.go +++ b/internal/tui/flamegraph/model.go @@ -506,11 +506,17 @@ func (m Model) renderViewContent() string { if m.snapshot != nil && len(m.frames) == 0 { content = common.PanelStyle.Render(fmt.Sprintf("Flame: snapshot v%d has no visible frames", m.lastVersion)) } - content += "\n" + m.selectionStatusLine() + // Assemble the final output using a Builder to avoid repeated string copies + // for the optional help-overlay suffix. + var b strings.Builder + b.WriteString(content) + b.WriteString("\n") + b.WriteString(m.selectionStatusLine()) if m.showHelp { - content += "\n" + m.helpOverlay() + b.WriteString("\n") + b.WriteString(m.helpOverlay()) } - return content + return b.String() } // currentViewCacheKey snapshots every Model field that influences View() diff --git a/internal/tui/flamegraph/renderer.go b/internal/tui/flamegraph/renderer.go index 24b99ed..361feb2 100644 --- a/internal/tui/flamegraph/renderer.go +++ b/internal/tui/flamegraph/renderer.go @@ -246,14 +246,16 @@ func computeRenderParams(frames []tuiFrame, height int) renderViewParams { // buildToolbar assembles the top-of-view toolbar string and pads/trims it to // width. The toolbar is replaced by the caller via replaceHeaderLine. +// A Builder is used to avoid an extra allocation for the optional truncation suffix. func buildToolbar(frames []tuiFrame, width int, params renderViewParams) string { viewPath := compactFramePath(frames[0].Path) - toolbar := fmt.Sprintf("Flame | view:%s | frames:%d | rows:%d", - viewPath, params.visibleFrames, params.availableRows) + var b strings.Builder + b.WriteString(fmt.Sprintf("Flame | view:%s | frames:%d | rows:%d", + viewPath, params.visibleFrames, params.availableRows)) if params.truncated { - toolbar += " | showing deepest levels" + b.WriteString(" | showing deepest levels") } - return padOrTrim(toolbar, width) + return padOrTrim(b.String(), width) } // buildFilteredStatus builds the per-selection status line when a search filter diff --git a/internal/tui/help.go b/internal/tui/help.go index ba4ed02..5a343cb 100644 --- a/internal/tui/help.go +++ b/internal/tui/help.go @@ -47,12 +47,18 @@ type helpSection struct { } func (m Model) helpSections() []helpSection { + line1 := "f filter p pid picker t tid picker o probes R parquet rec" + if help := m.keys.Export.Help(); help.Key != "" || help.Desc != "" { + // Use a Builder to append the optional export hint without reallocating + // the base string on each render when help is visible. + var b strings.Builder + b.WriteString(line1) + b.WriteString(" e stream export") + line1 = b.String() + } globalLines := []string{ "H help esc/? close help q quit", - "f filter p pid picker t tid picker o probes R parquet rec", - } - if help := m.keys.Export.Help(); help.Key != "" || help.Desc != "" { - globalLines[1] += " e stream export" + line1, } return []helpSection{ diff --git a/internal/tui/probes/model.go b/internal/tui/probes/model.go index b7e694f..986b697 100644 --- a/internal/tui/probes/model.go +++ b/internal/tui/probes/model.go @@ -276,11 +276,15 @@ func (m Model) View(width, height int) string { if p.Active { check = "[x]" } - line := fmt.Sprintf("%s%s %-24s", prefix, check, p.Syscall) + // Use a Builder to avoid an extra allocation for the optional error suffix + // emitted per probe row on every render call. + var lb strings.Builder + lb.WriteString(fmt.Sprintf("%s%s %-24s", prefix, check, p.Syscall)) if p.Error != "" { - line += " ! " + truncateText(sanitizeOneLine(p.Error), 28) + lb.WriteString(" ! ") + lb.WriteString(truncateText(sanitizeOneLine(p.Error), 28)) } - lines = append(lines, line) + lines = append(lines, lb.String()) } if len(items) == 0 { lines = append(lines, " (no probes)") |
