diff options
| author | Paul Buetow <paul@buetow.org> | 2026-03-05 23:18:34 +0200 |
|---|---|---|
| committer | Paul Buetow <paul@buetow.org> | 2026-03-05 23:18:34 +0200 |
| commit | 8db5b64dcfdd9c75e8b9b8dc42f9b263fa00b64b (patch) | |
| tree | d0ee37858de76eec0fdc1587764fa9355ed39f91 /internal/tui/flamegraph | |
| parent | 4953fd0200eef52f7e1547d5961a2e70e24e49d1 (diff) | |
Add flamegraph benchmark suite and reuse hot-path buffers
Diffstat (limited to 'internal/tui/flamegraph')
| -rw-r--r-- | internal/tui/flamegraph/animation.go | 24 | ||||
| -rw-r--r-- | internal/tui/flamegraph/bench_test.go | 397 | ||||
| -rw-r--r-- | internal/tui/flamegraph/model.go | 6 | ||||
| -rw-r--r-- | internal/tui/flamegraph/renderer.go | 13 | ||||
| -rw-r--r-- | internal/tui/flamegraph/search.go | 18 |
5 files changed, 442 insertions, 16 deletions
diff --git a/internal/tui/flamegraph/animation.go b/internal/tui/flamegraph/animation.go index de3635d..103d43b 100644 --- a/internal/tui/flamegraph/animation.go +++ b/internal/tui/flamegraph/animation.go @@ -26,6 +26,7 @@ type frameSpring struct { // AnimationState stores per-frame spring interpolation state. type AnimationState struct { springs []frameSpring + frames []tuiFrame settled bool fps int @@ -71,6 +72,11 @@ func (a *AnimationState) SetTargets(targets []tuiFrame) { next = append(next, spring) } a.springs = next + if cap(a.frames) < len(a.springs) { + a.frames = make([]tuiFrame, len(a.springs)) + } else { + a.frames = a.frames[:len(a.springs)] + } a.settled = len(a.springs) == 0 for _, spring := range a.springs { if !isSpringSettled(spring) { @@ -86,15 +92,18 @@ func (a *AnimationState) Tick(delta float64) bool { a.settled = true return false } + baseDelta := harmonica.FPS(a.fps) if delta <= 0 { - delta = harmonica.FPS(a.fps) + delta = baseDelta } active := false for idx := range a.springs { spring := &a.springs[idx] - spring.widthSpring = harmonica.NewSpring(delta, a.angularVelocity, a.damping) - spring.colSpring = harmonica.NewSpring(delta, a.angularVelocity, a.damping) + if delta != baseDelta { + spring.widthSpring = harmonica.NewSpring(delta, a.angularVelocity, a.damping) + spring.colSpring = harmonica.NewSpring(delta, a.angularVelocity, a.damping) + } spring.currentW, spring.velocityW = spring.widthSpring.Update(spring.currentW, spring.velocityW, spring.targetW) spring.currentCol, spring.velocityCol = spring.colSpring.Update(spring.currentCol, spring.velocityCol, spring.targetCol) if !isSpringSettled(*spring) { @@ -106,15 +115,14 @@ func (a *AnimationState) Tick(delta float64) bool { } // CurrentFrames returns interpolated frames for the current animation step. -func (a AnimationState) CurrentFrames() []tuiFrame { - frames := make([]tuiFrame, 0, len(a.springs)) - for _, spring := range a.springs { +func (a *AnimationState) CurrentFrames() []tuiFrame { + for idx, spring := range a.springs { frame := spring.base frame.Col = maxInt(0, int(math.Round(spring.currentCol))) frame.Width = maxInt(1, int(math.Round(spring.currentW))) - frames = append(frames, frame) + a.frames[idx] = frame } - return frames + return a.frames } // Settled reports whether all active springs are at rest. diff --git a/internal/tui/flamegraph/bench_test.go b/internal/tui/flamegraph/bench_test.go new file mode 100644 index 0000000..e908fed --- /dev/null +++ b/internal/tui/flamegraph/bench_test.go @@ -0,0 +1,397 @@ +package flamegraph + +import ( + "encoding/json" + "fmt" + coreflamegraph "ior/internal/flamegraph" + "ior/internal/types" + "testing" + + "github.com/charmbracelet/harmonica" +) + +var ( + benchFramesSink []tuiFrame + benchStringSink string + benchIntSink int + benchFloatSink float64 +) + +func BenchmarkBuildTerminalLayout(b *testing.B) { + // Performance target: medium_120col should remain below 500us/op. + fixtures := []struct { + label string + depth int + breadth int + }{ + {label: "small", depth: fixtureSmallDepth, breadth: fixtureSmallBreadth}, + {label: "medium", depth: fixtureMediumDepth, breadth: fixtureMediumBreadth}, + {label: "large", depth: fixtureLargeDepth, breadth: fixtureLargeBreadth}, + {label: "deep", depth: fixtureDeepDepth, breadth: fixtureDeepBreadth}, + {label: "wide", depth: fixtureWideDepth, breadth: fixtureWideBreadth}, + } + widths := []int{80, 120, 200, 300} + const height = 40 + + snapshots := make(map[string]*snapshotNode, len(fixtures)) + for _, fixture := range fixtures { + snapshots[fixture.label] = generateTestSnapshot(fixture.depth, fixture.breadth) + } + + for _, fixture := range fixtures { + snapshot := snapshots[fixture.label] + for _, width := range widths { + name := fmt.Sprintf("%s_%dcol", fixture.label, width) + b.Run(name, func(b *testing.B) { + b.ReportAllocs() + for i := 0; i < b.N; i++ { + benchFramesSink = BuildTerminalLayout(snapshot, width, height) + } + if len(benchFramesSink) == 0 { + b.Fatal("layout returned no frames") + } + }) + } + } +} + +func BenchmarkRenderFrame(b *testing.B) { + // Performance target: medium_120x40 should remain below 2ms/op. + // Allocation target: run with -benchmem and keep render path below 5 allocs/op. + fixtures := []struct { + label string + snapshot *snapshotNode + }{ + {label: "medium", snapshot: generateTestSnapshot(fixtureMediumDepth, fixtureMediumBreadth)}, + {label: "large", snapshot: generateTestSnapshot(fixtureLargeDepth, fixtureLargeBreadth)}, + } + viewports := []struct { + width int + height int + }{ + {width: 80, height: 24}, + {width: 120, height: 40}, + {width: 200, height: 60}, + } + + for _, fixture := range fixtures { + for _, viewport := range viewports { + name := fmt.Sprintf("%s_%dx%d", fixture.label, viewport.width, viewport.height) + b.Run(name, func(b *testing.B) { + model := NewModel(nil) + model.width = viewport.width + model.height = viewport.height + model.snapshot = fixture.snapshot + model.rebuildFrames(false) + if len(model.frames) == 0 { + b.Fatal("render benchmark requires non-empty frame layout") + } + + for idx := range model.frames { + switch idx % 12 { + case 0: + model.frames[idx].Name = "sys_enter_read" + case 1: + model.frames[idx].Name = "sys_enter_write" + } + } + model.selectedIdx = midDepthFrameIndex(model.frames) + model.subtreeSet = computeSubtreeSetInto(model.frames, model.selectedIdx, model.subtreeSet) + model.applySearchQuery("sys_") + + b.ReportAllocs() + for i := 0; i < b.N; i++ { + benchStringSink = model.View().Content + } + }) + } + } +} + +func BenchmarkComputeSubtreeSet(b *testing.B) { + // Performance target: 1000-frame subtree membership should remain below 100us/op. + // Allocation target: zero allocs/op by reusing map storage. + cases := []struct { + label string + frameCount int + }{ + {label: "100frames", frameCount: 100}, + {label: "1000frames", frameCount: 1000}, + {label: "5000frames", frameCount: 5000}, + } + + for _, tc := range cases { + frames := benchmarkFramesForCount(tc.frameCount) + if len(frames) == 0 { + b.Fatalf("%s produced no frames", tc.label) + } + selectedIdx := midDepthFrameIndex(frames) + reuse := make(map[int]bool, len(frames)) + + b.Run(tc.label, func(b *testing.B) { + b.ReportAllocs() + for i := 0; i < b.N; i++ { + subtree := computeSubtreeSetInto(frames, selectedIdx, reuse) + benchIntSink = len(subtree) + } + }) + } +} + +func BenchmarkSearchHighlight(b *testing.B) { + // Performance target: 1000-frame search should remain below 200us/op. + cases := []struct { + label string + frameCount int + }{ + {label: "100frames", frameCount: 100}, + {label: "1000frames", frameCount: 1000}, + {label: "5000frames", frameCount: 5000}, + } + queries := []string{"read", "sys_", "/srv/app"} + + for _, tc := range cases { + frames := benchmarkFramesForCount(tc.frameCount) + if len(frames) == 0 { + b.Fatalf("%s produced no frames", tc.label) + } + decorateFramesForSearch(frames) + + model := NewModel(nil) + model.frames = frames + model.selectedIdx = midDepthFrameIndex(frames) + model.subtreeSet = computeSubtreeSetInto(model.frames, model.selectedIdx, model.subtreeSet) + + b.Run(tc.label, func(b *testing.B) { + b.ReportAllocs() + for i := 0; i < b.N; i++ { + model.applySearchQuery(queries[i%len(queries)]) + benchIntSink = len(model.matchIndices) + } + }) + } +} + +func BenchmarkSpringUpdate(b *testing.B) { + // Performance target: 500 active springs should update in < 1ms per tick. + counts := []int{100, 500, 2000} + const ( + angularVelocity = 6.0 + damping = 1.0 + ) + + for _, count := range counts { + b.Run(fmt.Sprintf("%d_springs", count), func(b *testing.B) { + springs := make([]harmonica.Spring, count) + current := make([]float64, count) + velocity := make([]float64, count) + target := make([]float64, count) + + for idx := range springs { + springs[idx] = harmonica.NewSpring(harmonica.FPS(30), angularVelocity, damping) + current[idx] = float64(idx) + target[idx] = float64(idx + 8) + } + + b.ReportAllocs() + for i := 0; i < b.N; i++ { + for idx := range springs { + current[idx], velocity[idx] = springs[idx].Update(current[idx], velocity[idx], target[idx]) + } + benchFloatSink = current[count-1] + } + }) + } +} + +func BenchmarkAnimationTick(b *testing.B) { + // Performance target: 500 animated frames should complete in < 1ms per tick. + // Allocation target: zero allocs/op in the tick + CurrentFrames path. + counts := []int{100, 500, 2000} + + for _, count := range counts { + b.Run(fmt.Sprintf("%d_frames", count), func(b *testing.B) { + state := NewAnimationState(30, 6.0, 1.0) + base := linearFrames(count, 0, 10) + target := linearFrames(count, 5, 20) + state.SetTargets(base) + state.SetTargets(target) + + b.ReportAllocs() + for i := 0; i < b.N; i++ { + if !state.Tick(0) { + state.SetTargets(base) + state.SetTargets(target) + _ = state.Tick(0) + } + frames := state.CurrentFrames() + benchIntSink = frames[len(frames)-1].Width + } + }) + } +} + +func BenchmarkZoomTransition(b *testing.B) { + // Performance target: zoom-in transition should stay below 1ms/op. + snapshot := generateTestSnapshot(fixtureMediumDepth, fixtureMediumBreadth) + model := NewModel(nil) + model.width = 120 + model.height = 40 + model.snapshot = snapshot + model.rebuildFrames(false) + if len(model.frames) == 0 { + b.Fatal("zoom benchmark requires non-empty initial layout") + } + zoomPath := model.frames[midDepthFrameIndex(model.frames)].Path + + b.Run("zoom_in", func(b *testing.B) { + benchModel := model + b.ReportAllocs() + for i := 0; i < b.N; i++ { + benchModel.zoomReset() + benchModel.selectedIdx = frameIndexByPath(benchModel.frames, zoomPath) + benchModel.zoomIn() + benchIntSink = len(benchModel.targetFrames) + } + }) + + b.Run("undo_zoom", func(b *testing.B) { + benchModel := model + benchModel.selectedIdx = frameIndexByPath(benchModel.frames, zoomPath) + benchModel.zoomIn() + if len(benchModel.zoomStack) == 0 { + b.Fatal("undo benchmark requires an active zoom stack") + } + + b.ReportAllocs() + for i := 0; i < b.N; i++ { + benchModel.zoomUndo() + benchIntSink = len(benchModel.frames) + + benchModel.selectedIdx = frameIndexByPath(benchModel.frames, zoomPath) + benchModel.zoomIn() + } + }) +} + +func BenchmarkLiveTrieIngestAndSnapshot(b *testing.B) { + // Performance target: ingest+snapshot pipeline should remain below 200us/op for small/medium cycles. + counts := []int{100, 1000, 10000} + for _, count := range counts { + b.Run(fmt.Sprintf("%d_events", count), func(b *testing.B) { + b.ReportAllocs() + for i := 0; i < b.N; i++ { + liveTrie := coreflamegraph.NewLiveTrie([]string{"comm", "path", "tracepoint"}, "count") + for eventIdx := 0; eventIdx < count; eventIdx++ { + traceID := types.SYS_ENTER_READ + if eventIdx%2 == 0 { + traceID = types.SYS_ENTER_WRITE + } + pair := newBenchmarkPair( + fmt.Sprintf("worker-%d", eventIdx%4), + traceID, + uint32(1000+(eventIdx%64)), + uint32(200000+eventIdx), + buildBenchmarkPath(8, 6, eventIdx), + ) + liveTrie.Ingest(pair) + } + + payload, _ := liveTrie.SnapshotJSON() + var snapshot snapshotNode + if err := json.Unmarshal(payload, &snapshot); err != nil { + b.Fatalf("snapshot decode failed: %v", err) + } + benchFramesSink = BuildTerminalLayout(&snapshot, 120, 40) + } + }) + } +} + +func BenchmarkResizeRelayout(b *testing.B) { + // Performance target: resize relayout cost should match BuildTerminalLayout (< 500us medium@120col). + snapshot := generateTestSnapshot(fixtureMediumDepth, fixtureMediumBreadth) + b.ReportAllocs() + for i := 0; i < b.N; i++ { + frames120 := BuildTerminalLayout(snapshot, 120, 40) + frames80 := BuildTerminalLayout(snapshot, 80, 24) + benchFramesSink = BuildTerminalLayout(snapshot, 120, 40) + benchIntSink = len(frames120) + len(frames80) + len(benchFramesSink) + } +} + +func benchmarkFramesForCount(frameCount int) []tuiFrame { + var snapshot *snapshotNode + switch frameCount { + case 100: + snapshot = generateTestSnapshot(fixtureDeepDepth, fixtureDeepBreadth) + case 1000: + snapshot = generateTestSnapshot(20, 5) + case 5000: + snapshot = generateTestSnapshot(fixtureWideDepth, fixtureWideBreadth) + default: + snapshot = generateTestSnapshot(10, 5) + } + return BuildTerminalLayout(snapshot, 200, 80) +} + +func decorateFramesForSearch(frames []tuiFrame) { + for idx := range frames { + switch idx % 6 { + case 0: + frames[idx].Name = "sys_enter_read" + case 1: + frames[idx].Name = "sys_enter_write" + case 2: + frames[idx].Name = "read_cache_buffer" + case 3: + frames[idx].Name = "path:/srv/app/api" + case 4: + frames[idx].Name = "worker_loop" + default: + frames[idx].Name = "io_wait" + } + } +} + +func midDepthFrameIndex(frames []tuiFrame) int { + if len(frames) == 0 { + return 0 + } + maxDepth := 0 + for _, frame := range frames { + if frame.Depth > maxDepth { + maxDepth = frame.Depth + } + } + targetDepth := maxDepth / 2 + indices := framesAtDepth(frames, targetDepth) + if len(indices) == 0 { + return len(frames) / 2 + } + return indices[len(indices)/2] +} + +func frameIndexByPath(frames []tuiFrame, path string) int { + for idx, frame := range frames { + if frame.Path == path { + return idx + } + } + return 0 +} + +func linearFrames(count, colOffset, width int) []tuiFrame { + frames := make([]tuiFrame, count) + for idx := 0; idx < count; idx++ { + path := fmt.Sprintf("root%snode-%d", pathSeparator, idx) + frames[idx] = tuiFrame{ + Name: fmt.Sprintf("node-%d", idx), + Path: path, + Col: colOffset + idx, + Row: idx % 8, + Width: width, + } + } + return frames +} diff --git a/internal/tui/flamegraph/model.go b/internal/tui/flamegraph/model.go index ddf40fd..5f5a83c 100644 --- a/internal/tui/flamegraph/model.go +++ b/internal/tui/flamegraph/model.go @@ -139,7 +139,7 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { m.animating = m.animation.Tick(0) m.frames = m.animation.CurrentFrames() m.clampSelection() - m.subtreeSet = computeSubtreeSet(m.frames, m.selectedIdx) + m.subtreeSet = computeSubtreeSetInto(m.frames, m.selectedIdx, m.subtreeSet) if m.animating { return m, animTickCmd() } @@ -202,7 +202,7 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { m.moveSibling(1) } if m.selectedIdx != prev { - m.subtreeSet = computeSubtreeSet(m.frames, m.selectedIdx) + m.subtreeSet = computeSubtreeSetInto(m.frames, m.selectedIdx, m.subtreeSet) } } return m, nil @@ -319,7 +319,7 @@ func (m *Model) rebuildFrames(animate bool) { m.frames = append(m.frames[:0], m.targetFrames...) } m.clampSelection() - m.subtreeSet = computeSubtreeSet(m.frames, m.selectedIdx) + m.subtreeSet = computeSubtreeSetInto(m.frames, m.selectedIdx, m.subtreeSet) } func (m *Model) zoomIn() { diff --git a/internal/tui/flamegraph/renderer.go b/internal/tui/flamegraph/renderer.go index d68fc21..6fba0b6 100644 --- a/internal/tui/flamegraph/renderer.go +++ b/internal/tui/flamegraph/renderer.go @@ -236,10 +236,21 @@ func renderRow(frames []indexedFrame, width int, selectedPath string, subtreeSet } func computeSubtreeSet(frames []tuiFrame, selectedIdx int) map[int]bool { - subtree := make(map[int]bool) + return computeSubtreeSetInto(frames, selectedIdx, nil) +} + +func computeSubtreeSetInto(frames []tuiFrame, selectedIdx int, subtree map[int]bool) map[int]bool { + if subtree == nil { + subtree = make(map[int]bool) + } else { + for idx := range subtree { + delete(subtree, idx) + } + } if selectedIdx < 0 || selectedIdx >= len(frames) { return subtree } + selectedPath := frames[selectedIdx].Path for idx, frame := range frames { path := frame.Path diff --git a/internal/tui/flamegraph/search.go b/internal/tui/flamegraph/search.go index f2d6dc0..8e392d1 100644 --- a/internal/tui/flamegraph/search.go +++ b/internal/tui/flamegraph/search.go @@ -16,7 +16,7 @@ func (m *Model) openSearch() { func (m *Model) clearSearch() { m.searchActive = false m.searchQuery = "" - m.matchIndices = make(map[int]bool) + clearBoolMap(m.matchIndices) m.searchInput.SetValue("") m.searchInput.Blur() } @@ -24,7 +24,11 @@ func (m *Model) clearSearch() { func (m *Model) applySearchQuery(raw string) { query := strings.ToLower(strings.TrimSpace(raw)) m.searchQuery = query - m.matchIndices = make(map[int]bool) + if m.matchIndices == nil { + m.matchIndices = make(map[int]bool) + } else { + clearBoolMap(m.matchIndices) + } if query == "" { return } @@ -51,7 +55,7 @@ func (m *Model) jumpMatch(direction int) { } else { m.selectedIdx = matches[0] } - m.subtreeSet = computeSubtreeSet(m.frames, m.selectedIdx) + m.subtreeSet = computeSubtreeSetInto(m.frames, m.selectedIdx, m.subtreeSet) return } @@ -63,7 +67,7 @@ func (m *Model) jumpMatch(direction int) { next = 0 } m.selectedIdx = matches[next] - m.subtreeSet = computeSubtreeSet(m.frames, m.selectedIdx) + m.subtreeSet = computeSubtreeSetInto(m.frames, m.selectedIdx, m.subtreeSet) } func orderedMatchIndices(matchSet map[int]bool) []int { @@ -104,3 +108,9 @@ func replaceHeaderLine(content, header string) string { lines[0] = header return strings.Join(lines, "\n") } + +func clearBoolMap[K comparable](values map[K]bool) { + for key := range values { + delete(values, key) + } +} |
