diff options
| author | Paul Buetow <paul@buetow.org> | 2026-05-11 20:02:47 +0300 |
|---|---|---|
| committer | Paul Buetow <paul@buetow.org> | 2026-05-11 20:02:47 +0300 |
| commit | 933be1ba2dbb7f6397a4112969bc85a4eac9d155 (patch) | |
| tree | 1c9f66ee8321880f322b0ddf8030e64dc2af976b /internal | |
| parent | 662dcfd7ca96d0d4157f9d30b04518db5adfbe45 (diff) | |
speed up flame graph TUI under heavy event load
Move the per-tick snapshot refresh off the Bubble Tea update goroutine,
add a frame ancestry index so navigation and filter helpers run in
O(subtree) instead of O(frames), skip refresh and animation while the
user is actively pressing keys, and memoize View() output. Keystrokes
(pause, filter, navigate) now land within one frame even when the live
trie ingests thousands of events per tick.
- new SnapshotTree() on LiveTrie bypasses JSON marshal+unmarshal
- RefreshFromLiveTrieCmd runs SnapshotTree + layout + ancestry on a
background goroutine, coalesced via refreshInFlight, and returns a
flameSnapshotReadyMsg the Update loop applies cheaply
- driveWindow gate (250 ms after last key press) skips refresh dispatch
and snaps frames directly to target without animation while the user
is driving
- View() caches its rendered string keyed on the inputs that affect
output; cache is bypassed during animation
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Diffstat (limited to 'internal')
| -rw-r--r-- | internal/flamegraph/livetrie.go | 65 | ||||
| -rw-r--r-- | internal/flamegraph/livetrie_test.go | 12 | ||||
| -rw-r--r-- | internal/tui/dashboard/model.go | 17 | ||||
| -rw-r--r-- | internal/tui/flamegraph/ancestry.go | 127 | ||||
| -rw-r--r-- | internal/tui/flamegraph/async_refresh_test.go | 204 | ||||
| -rw-r--r-- | internal/tui/flamegraph/model.go | 307 | ||||
| -rw-r--r-- | internal/tui/flamegraph/search.go | 6 |
7 files changed, 684 insertions, 54 deletions
diff --git a/internal/flamegraph/livetrie.go b/internal/flamegraph/livetrie.go index 51f3697..5487b9f 100644 --- a/internal/flamegraph/livetrie.go +++ b/internal/flamegraph/livetrie.go @@ -19,11 +19,11 @@ const ( liveTrieVisibleChildrenFallbackMaxDepth = 1 ) -type trieSnapshot struct { +type SnapshotNode struct { Name string `json:"n"` Value uint64 `json:"v"` Total uint64 `json:"t"` - Children []*trieSnapshot `json:"c,omitempty"` + Children []*SnapshotNode `json:"c,omitempty"` } // LiveTrie is a thread-safe, append-only trie used for live flamegraph snapshots. @@ -39,6 +39,13 @@ type LiveTrie struct { cacheMu sync.Mutex cacheVersion uint64 cacheJSON []byte + + // Tree cache lets the TUI fetch the snapshot tree without a JSON + // marshal+unmarshal round-trip. Built lazily; invalidated on reset and + // on field/metric reconfiguration. + treeCacheMu sync.Mutex + treeVersion uint64 + treeCache *SnapshotNode } // NewLiveTrie constructs an empty live trie with the configured frame/count fields. @@ -75,6 +82,10 @@ func (lt *LiveTrie) invalidateCache() { lt.cacheVersion = 0 lt.cacheJSON = nil lt.cacheMu.Unlock() + lt.treeCacheMu.Lock() + lt.treeVersion = 0 + lt.treeCache = nil + lt.treeCacheMu.Unlock() } // Ingest adds one event pair into the live trie. @@ -161,6 +172,8 @@ func (lt *LiveTrie) Version() uint64 { } // SnapshotJSON returns a compact JSON snapshot for the current trie version. +// Layered on top of SnapshotTree so the tree-building work is shared with +// callers that want the typed form directly. func (lt *LiveTrie) SnapshotJSON() ([]byte, uint64) { version := lt.Version() lt.cacheMu.Lock() @@ -171,12 +184,7 @@ func (lt *LiveTrie) SnapshotJSON() ([]byte, uint64) { } lt.cacheMu.Unlock() - lt.mu.RLock() - version = lt.version.Load() - rootTotal := subtreeTotal(lt.root) - snapshot := buildSnapshot(lt.root, 0, liveTrieMinFraction, rootTotal) - lt.mu.RUnlock() - + snapshot, version := lt.SnapshotTree() payload, err := json.Marshal(snapshot) if err != nil { return []byte(`{}`), version @@ -190,6 +198,37 @@ func (lt *LiveTrie) SnapshotJSON() ([]byte, uint64) { return payload, version } +// SnapshotTree returns the live trie snapshot as a typed node tree, bypassing +// the JSON round-trip. The pointer is safe to retain — buildSnapshot allocates +// fresh nodes per snapshot, and the trie never mutates a previously returned +// tree. The TUI uses this on a background goroutine so per-tick refreshes don't +// block the Bubble Tea update loop. +func (lt *LiveTrie) SnapshotTree() (*SnapshotNode, uint64) { + version := lt.Version() + lt.treeCacheMu.Lock() + if lt.treeVersion == version && lt.treeCache != nil { + tree := lt.treeCache + lt.treeCacheMu.Unlock() + return tree, version + } + lt.treeCacheMu.Unlock() + + lt.mu.RLock() + version = lt.version.Load() + rootTotal := subtreeTotal(lt.root) + tree := buildSnapshot(lt.root, 0, liveTrieMinFraction, rootTotal) + lt.mu.RUnlock() + + lt.treeCacheMu.Lock() + // Only commit if no concurrent caller stored a newer version. + if version >= lt.treeVersion { + lt.treeVersion = version + lt.treeCache = tree + } + lt.treeCacheMu.Unlock() + return tree, version +} + func eventPairToRecord(ep *event.Pair) IterRecord { return IterRecord{ Path: ep.FileName(), @@ -263,18 +302,18 @@ func subtreeTotal(node *trieNode) uint64 { return total } -func buildSnapshot(node *trieNode, depth int, minFraction float64, rootTotal uint64) *trieSnapshot { +func buildSnapshot(node *trieNode, depth int, minFraction float64, rootTotal uint64) *SnapshotNode { snapshot, _ := buildSnapshotWithTotal(node, depth, minFraction, rootTotal, false) return snapshot } type childSnapshotState struct { node *trieNode - snapshot *trieSnapshot + snapshot *SnapshotNode total uint64 } -func buildSnapshotWithTotal(node *trieNode, depth int, minFraction float64, rootTotal uint64, forceKeep bool) (*trieSnapshot, uint64) { +func buildSnapshotWithTotal(node *trieNode, depth int, minFraction float64, rootTotal uint64, forceKeep bool) (*SnapshotNode, uint64) { total := node.value children := slices.Clone(node.children) slices.SortFunc(children, func(a, b *trieNode) int { @@ -297,14 +336,14 @@ func buildSnapshotWithTotal(node *trieNode, depth int, minFraction float64, root } ensureFallbackVisibleChildren(childStates, depth, minFraction, rootTotal) - childSnapshots := make([]*trieSnapshot, 0, len(childStates)) + childSnapshots := make([]*SnapshotNode, 0, len(childStates)) for _, child := range childStates { if child.snapshot != nil { childSnapshots = append(childSnapshots, child.snapshot) } } - snapshot := &trieSnapshot{ + snapshot := &SnapshotNode{ Name: node.name, Value: node.value, Total: total, diff --git a/internal/flamegraph/livetrie_test.go b/internal/flamegraph/livetrie_test.go index 5d4ce47..6a825c0 100644 --- a/internal/flamegraph/livetrie_test.go +++ b/internal/flamegraph/livetrie_test.go @@ -371,7 +371,7 @@ func TestLiveTrieConcurrentIngestAndSnapshot(t *testing.T) { defer wg.Done() for i := 0; i < 500; i++ { payload, _ := lt.SnapshotJSON() - var snap trieSnapshot + var snap SnapshotNode if err := json.Unmarshal(payload, &snap); err != nil { t.Errorf("unmarshal snapshot: %v", err) return @@ -416,7 +416,7 @@ func TestLiveTrieStressHighRateConcurrentSnapshot(t *testing.T) { return case <-ticker.C: payload, _ := lt.SnapshotJSON() - var snap trieSnapshot + var snap SnapshotNode if err := json.Unmarshal(payload, &snap); err != nil { errCh <- fmt.Errorf("snapshot json invalid: %w", err) return @@ -486,17 +486,17 @@ func newTestPair(comm string, pid uint32, tid uint32, path string, duration uint return pair } -func decodeLiveSnapshot(t *testing.T, lt *LiveTrie) trieSnapshot { +func decodeLiveSnapshot(t *testing.T, lt *LiveTrie) SnapshotNode { t.Helper() payload, _ := lt.SnapshotJSON() - var snap trieSnapshot + var snap SnapshotNode if err := json.Unmarshal(payload, &snap); err != nil { t.Fatalf("unmarshal snapshot: %v", err) } return snap } -func findSnapshotPath(t *testing.T, root *trieSnapshot, names ...string) *trieSnapshot { +func findSnapshotPath(t *testing.T, root *SnapshotNode, names ...string) *SnapshotNode { t.Helper() node := root for _, name := range names { @@ -508,7 +508,7 @@ func findSnapshotPath(t *testing.T, root *trieSnapshot, names ...string) *trieSn return node } -func findSnapshotChild(node *trieSnapshot, name string) *trieSnapshot { +func findSnapshotChild(node *SnapshotNode, name string) *SnapshotNode { for _, child := range node.Children { if child.Name == name { return child diff --git a/internal/tui/dashboard/model.go b/internal/tui/dashboard/model.go index 79e3b38..df8f9f1 100644 --- a/internal/tui/dashboard/model.go +++ b/internal/tui/dashboard/model.go @@ -232,14 +232,17 @@ func (m Model) handleFlameTick() (tea.Model, tea.Cmd) { if !m.focused || m.activeTab != TabFlame { return m, nil } - var animCmd tea.Cmd - if m.liveTrie != nil && m.flamegraphModel.RefreshFromLiveTrie() { - animCmd = m.flamegraphModel.AnimationCmd() - } - if animCmd == nil { - return m, flameTickCmd() + // Always re-arm the 200 ms tick. The snapshot refresh itself runs on a + // background goroutine via RefreshFromLiveTrieCmd, so even when a previous + // refresh is still in flight (the cmd returns nil and skips), the tick + // channel stays alive. + cmds := []tea.Cmd{flameTickCmd()} + if m.liveTrie != nil { + if refreshCmd := m.flamegraphModel.RefreshFromLiveTrieCmd(); refreshCmd != nil { + cmds = append(cmds, refreshCmd) + } } - return m, tea.Batch(flameTickCmd(), animCmd) + return m, tea.Batch(cmds...) } func (m Model) handleBubbleTick() (tea.Model, tea.Cmd) { diff --git a/internal/tui/flamegraph/ancestry.go b/internal/tui/flamegraph/ancestry.go new file mode 100644 index 0000000..4065909 --- /dev/null +++ b/internal/tui/flamegraph/ancestry.go @@ -0,0 +1,127 @@ +package flamegraph + +import "strings" + +// frameAncestry stores parent/child relationships between frames keyed by their +// slice index. It is rebuilt whenever the frames slice is rebuilt, and reused +// across keystrokes so subtree and filter-visibility lookups run in O(subtree) +// time instead of O(frames) string scans. +type frameAncestry struct { + parent []int + children [][]int +} + +// buildFrameAncestry derives parent/child links for the supplied frame slice +// using each frame's Path string. Robust to any ordering — the layout pass +// emits depth-first frames, but withZoomLineage appends ancestor frames at the +// tail, so contiguous-range tricks are not safe. +func buildFrameAncestry(frames []tuiFrame) frameAncestry { + n := len(frames) + ancestry := frameAncestry{ + parent: make([]int, n), + children: make([][]int, n), + } + if n == 0 { + return ancestry + } + + byPath := make(map[string]int, n) + for idx, frame := range frames { + byPath[frame.Path] = idx + ancestry.parent[idx] = -1 + } + for idx, frame := range frames { + sep := strings.LastIndexByte(frame.Path, pathSeparatorByte) + if sep < 0 { + continue + } + parentPath := frame.Path[:sep] + parentIdx, ok := byPath[parentPath] + if !ok { + continue + } + ancestry.parent[idx] = parentIdx + ancestry.children[parentIdx] = append(ancestry.children[parentIdx], idx) + } + return ancestry +} + +// subtreeSetUsingAncestry fills `set` with the selected frame, every descendant +// reachable through the child adjacency list, and every ancestor reachable +// through the parent chain. Matches the visibility contract of the legacy +// path-prefix walk in computeSubtreeSetInto. +// +// If `ancestry` is stale relative to `frames` (different length), it is rebuilt +// in place. The hot path through the model keeps ancestry warm via +// rebuildFrames, so the fallback only fires in tests that mutate frames +// directly. +func subtreeSetUsingAncestry(frames []tuiFrame, selectedIdx int, ancestry frameAncestry, set map[int]bool) map[int]bool { + if set == nil { + set = make(map[int]bool) + } else { + for idx := range set { + delete(set, idx) + } + } + if selectedIdx < 0 || selectedIdx >= len(frames) { + return set + } + if len(ancestry.parent) != len(frames) { + ancestry = buildFrameAncestry(frames) + } + markSubtree(ancestry, selectedIdx, set) + for cur := ancestry.parent[selectedIdx]; cur >= 0; cur = ancestry.parent[cur] { + set[cur] = true + } + return set +} + +// filterVisibleSetUsingAncestry fills `set` with every match plus its full +// descendant subtree and ancestor chain — same semantics as the legacy +// computeFilterVisibleSetInto walk, but in O(sum-of-subtree-sizes) instead of +// O(frames × matches). Falls back to building ancestry on the fly when the +// supplied index is stale (see subtreeSetUsingAncestry). +func filterVisibleSetUsingAncestry(frames []tuiFrame, matchSet map[int]bool, ancestry frameAncestry, set map[int]bool) map[int]bool { + if set == nil { + set = make(map[int]bool) + } else { + for idx := range set { + delete(set, idx) + } + } + if len(matchSet) == 0 { + return set + } + if len(ancestry.parent) != len(frames) { + ancestry = buildFrameAncestry(frames) + } + for matchIdx := range matchSet { + if matchIdx < 0 || matchIdx >= len(frames) { + continue + } + markSubtree(ancestry, matchIdx, set) + for cur := ancestry.parent[matchIdx]; cur >= 0; cur = ancestry.parent[cur] { + if set[cur] { + break + } + set[cur] = true + } + } + return set +} + +// markSubtree marks a frame and every descendant. Uses an iterative stack +// to keep allocations bounded for deep trees. +func markSubtree(ancestry frameAncestry, root int, set map[int]bool) { + stack := []int{root} + for len(stack) > 0 { + last := len(stack) - 1 + idx := stack[last] + stack = stack[:last] + if set[idx] { + continue + } + set[idx] = true + stack = append(stack, ancestry.children[idx]...) + } +} diff --git a/internal/tui/flamegraph/async_refresh_test.go b/internal/tui/flamegraph/async_refresh_test.go new file mode 100644 index 0000000..d6027de --- /dev/null +++ b/internal/tui/flamegraph/async_refresh_test.go @@ -0,0 +1,204 @@ +package flamegraph + +import ( + "testing" + "time" + + coreflamegraph "ior/internal/flamegraph" + "ior/internal/types" + + tea "charm.land/bubbletea/v2" +) + +func ingestTwoEventsForAsync(t *testing.T, trie *coreflamegraph.LiveTrie) { + t.Helper() + for i := 0; i < 2; i++ { + traceID := types.SYS_ENTER_READ + if i%2 == 0 { + traceID = types.SYS_ENTER_WRITE + } + pair := newBenchmarkPair("worker", traceID, uint32(1000+i), uint32(200000+i), "/srv/app") + trie.Ingest(pair) + pair.Recycle() + } +} + +func TestRefreshFromLiveTrieCmdNilWhenNoTrie(t *testing.T) { + m := NewModel(nil) + if cmd := m.RefreshFromLiveTrieCmd(); cmd != nil { + t.Fatalf("expected nil command when liveTrie is nil") + } +} + +func TestRefreshFromLiveTrieCmdProducesSnapshotReady(t *testing.T) { + trie := coreflamegraph.NewLiveTrie([]string{"comm", "path"}, "count") + ingestTwoEventsForAsync(t, trie) + m := NewModel(trie) + m.width = 120 + m.height = 30 + + cmd := m.RefreshFromLiveTrieCmd() + if cmd == nil { + t.Fatalf("expected a refresh command when trie has new events") + } + if !m.refreshInFlight { + t.Fatalf("expected refreshInFlight=true after dispatch") + } + + msg := cmd() + ready, ok := msg.(flameSnapshotReadyMsg) + if !ok { + t.Fatalf("expected flameSnapshotReadyMsg, got %T", msg) + } + if ready.snapshot == nil { + t.Fatalf("expected snapshot in ready message") + } + if ready.layoutWidth != 120 || ready.layoutHeight != 30 { + t.Fatalf("ready msg layout = %dx%d, want 120x30", ready.layoutWidth, ready.layoutHeight) + } + if ready.version == 0 { + t.Fatalf("expected non-zero version after ingestion") + } + if len(ready.ancestry.parent) != len(ready.targetFrames) { + t.Fatalf("ancestry length %d != frames length %d", len(ready.ancestry.parent), len(ready.targetFrames)) + } +} + +func TestRefreshFromLiveTrieCmdCoalescesInFlight(t *testing.T) { + trie := coreflamegraph.NewLiveTrie([]string{"comm", "path"}, "count") + ingestTwoEventsForAsync(t, trie) + m := NewModel(trie) + m.width = 80 + m.height = 24 + + if first := m.RefreshFromLiveTrieCmd(); first == nil { + t.Fatalf("expected first cmd to dispatch") + } + if second := m.RefreshFromLiveTrieCmd(); second != nil { + t.Fatalf("expected second cmd to coalesce (return nil) while a refresh is in flight") + } +} + +func TestRefreshFromLiveTrieCmdSkippedWhileUserDrives(t *testing.T) { + trie := coreflamegraph.NewLiveTrie([]string{"comm", "path"}, "count") + ingestTwoEventsForAsync(t, trie) + m := NewModel(trie) + m.width = 80 + m.height = 24 + + // Load an initial snapshot so the drive gate (which requires an existing + // snapshot to skip) takes effect. + if !m.RefreshFromLiveTrie() { + t.Fatalf("expected initial sync refresh to populate snapshot") + } + ingestTwoEventsForAsync(t, trie) + + m.lastKeyAt = time.Now() + if cmd := m.RefreshFromLiveTrieCmd(); cmd != nil { + t.Fatalf("expected refresh to be skipped while user is actively pressing keys") + } + + // Move the timestamp outside the drive window — should dispatch. + m.lastKeyAt = time.Now().Add(-2 * driveWindow) + if cmd := m.RefreshFromLiveTrieCmd(); cmd == nil { + t.Fatalf("expected refresh to dispatch once drive window expires") + } +} + +func TestSnapshotReadyHandlerSnapsToTargetWhileDriving(t *testing.T) { + trie := coreflamegraph.NewLiveTrie([]string{"comm", "path"}, "count") + ingestTwoEventsForAsync(t, trie) + m := NewModel(trie) + m.width = 120 + m.height = 30 + m.refreshInFlight = true + + cmd := func() tea.Cmd { + liveTrie := trie + return func() tea.Msg { + tree, ver := liveTrie.SnapshotTree() + targetFrames := buildTerminalLayoutWithPath(tree, m.width, m.height, "") + ancestry := buildFrameAncestry(targetFrames) + return flameSnapshotReadyMsg{ + version: ver, + layoutWidth: m.width, + layoutHeight: m.height, + snapshot: tree, + targetFrames: targetFrames, + ancestry: ancestry, + globalTotal: snapshotTotal(tree), + } + } + }() + msg := cmd().(flameSnapshotReadyMsg) + + m.lastKeyAt = time.Now() + next, _ := m.handleSnapshotReady(msg) + post := next.(Model) + if post.animating { + t.Fatalf("expected snapshot ready to skip animation while user is driving") + } + if len(post.frames) != len(msg.targetFrames) { + t.Fatalf("expected frames to snap directly to target (len %d != %d)", len(post.frames), len(msg.targetFrames)) + } +} + +func TestViewCacheReusesContentWhenStateUnchanged(t *testing.T) { + trie := coreflamegraph.NewLiveTrie([]string{"comm", "path"}, "count") + ingestTwoEventsForAsync(t, trie) + m := NewModel(trie) + m.width = 120 + m.height = 30 + if !m.RefreshFromLiveTrie() { + t.Fatalf("expected initial refresh to populate snapshot") + } + + // Drain any pending animation so the cache path is exercised. + for m.animating { + nextModel, _ := m.Update(animTickMsg{}) + m = nextModel.(Model) + } + + first := m.View().Content + if !m.viewCache.valid { + t.Fatalf("expected viewCache to be marked valid after first View() call") + } + cachedAddr := &m.viewCache.content + + second := m.View().Content + if first != second { + t.Fatalf("expected identical content from two consecutive View() calls when state unchanged") + } + if cachedAddr != &m.viewCache.content { + t.Fatalf("expected cache content pointer to remain stable on a hit") + } +} + +func BenchmarkRecomputeFilterState(b *testing.B) { + // Performance target: 5000-frame filter recompute should remain below 200us/op. + cases := []struct { + label string + frameCount int + }{ + {label: "1000frames", frameCount: 1000}, + {label: "5000frames", frameCount: 5000}, + } + queries := []string{"sys_", "read", "/srv"} + + for _, tc := range cases { + frames := benchmarkFramesForCount(tc.frameCount) + decorateFramesForSearch(frames) + b.Run(tc.label, func(b *testing.B) { + model := NewModel(nil) + model.frames = frames + model.ancestry = buildFrameAncestry(frames) + model.selectedIdx = midDepthFrameIndex(frames) + + b.ReportAllocs() + for i := 0; i < b.N; i++ { + model.applySearchQuery(queries[i%len(queries)]) + benchIntSink = len(model.matchIndices) + } + }) + } +} diff --git a/internal/tui/flamegraph/model.go b/internal/tui/flamegraph/model.go index 0552a4f..6930170 100644 --- a/internal/tui/flamegraph/model.go +++ b/internal/tui/flamegraph/model.go @@ -2,13 +2,13 @@ package flamegraph import ( "cmp" - "encoding/json" "fmt" "image/color" "slices" "strings" "time" + coreflamegraph "ior/internal/flamegraph" common "ior/internal/tui/common" "charm.land/bubbles/v2/key" @@ -16,19 +16,68 @@ import ( tea "charm.land/bubbletea/v2" ) -type snapshotNode struct { - Name string `json:"n"` - Value uint64 `json:"v"` - Total uint64 `json:"t"` - Children []*snapshotNode `json:"c,omitempty"` -} +// snapshotNode aliases the live trie's snapshot type so the TUI can consume +// trees directly via SnapshotTree() without paying for a JSON marshal+unmarshal +// round-trip. The JSON tags on SnapshotNode keep the legacy SnapshotJSON path +// working unchanged. +type snapshotNode = coreflamegraph.SnapshotNode type animTickMsg struct{} +// flameViewCacheKey captures the View() inputs that determine the rendered +// output. When two consecutive calls produce the same key, the cached content +// string is reused instead of re-running RenderTerminalView. +type flameViewCacheKey struct { + version uint64 + selectedIdx int + width int + height int + framesLen int + matchCount int + visibleCount int + searchQuery string + statusMessage string + zoomPath string + searchActive bool + showHelp bool + paused bool + isDark bool +} + +type flameViewCache struct { + key flameViewCacheKey + content string + valid bool +} + +// flameSnapshotReadyMsg carries the result of a background snapshot+layout +// job. It is emitted by RefreshFromLiveTrieCmd and consumed by Update so the +// Bubble Tea goroutine can swap in the new state without blocking on JSON or +// frame layout work. +type flameSnapshotReadyMsg struct { + version uint64 + layoutWidth int + layoutHeight int + zoomPath string + snapshot *snapshotNode + zoomRoot *snapshotNode + targetFrames []tuiFrame + ancestry frameAncestry + globalTotal uint64 +} + const animFrameDuration = 33 * time.Millisecond const flameKeyDebugEnabled = false +// driveWindow defines how recently a key must have been pressed to count as +// "user is actively driving". While inside this window, the flamegraph defers +// snapshot refresh and skips animation so keystrokes land without waiting on +// JSON+layout work or a 1-second animation chain. +const driveWindow = 250 * time.Millisecond + // LiveTrieSource is the minimal trie contract needed by the flamegraph TUI model. +// SnapshotJSON is retained for tests and external callers; SnapshotTree is the +// fast path used by the model's background refresh. type LiveTrieSource interface { Fields() []string CountField() string @@ -37,6 +86,7 @@ type LiveTrieSource interface { Reset() Version() uint64 SnapshotJSON() ([]byte, uint64) + SnapshotTree() (*snapshotNode, uint64) } type zoomState struct { @@ -78,8 +128,14 @@ type Model struct { snapshot *snapshotNode globalTotal uint64 + // refreshInFlight is true while a background snapshot+layout job is + // running. It coalesces flameTickMsg dispatches so we never queue more + // than one snapshot rebuild concurrently. + refreshInFlight bool + frames []tuiFrame targetFrames []tuiFrame + ancestry frameAncestry width int height int @@ -106,6 +162,19 @@ type Model struct { animation AnimationState animating bool paused bool + + // lastKeyAt records when the user most recently pressed a key. While the + // user is actively driving the view (lastKeyAt within driveWindow ago), + // the background snapshot refresh is suppressed and snapshot-ready + // messages snap directly to target frames without animating. This keeps + // keystrokes feeling instant under heavy event load. + lastKeyAt time.Time + + // viewCache memoizes the last rendered string keyed on the inputs that + // produce it. Bubble Tea may call View() multiple times per state change; + // caching avoids re-running RenderTerminalView when nothing visible has + // moved. Lives behind a pointer so the value-receiver View() can update it. + viewCache *flameViewCache // hasNavigableSnapshot flips once we have at least one selectable non-root frame. hasNavigableSnapshot bool isDark bool @@ -139,6 +208,7 @@ func NewModel(liveTrie LiveTrieSource) Model { filterVisible: make(map[int]bool), subtreeSet: make(map[int]bool), searchInput: searchInput, + viewCache: &flameViewCache{}, fieldPresets: [][]string{ {"comm", "tracepoint", "path"}, {"path", "tracepoint", "comm"}, @@ -171,8 +241,10 @@ 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 = computeSubtreeSetInto(m.frames, m.selectedIdx, m.subtreeSet) + m.subtreeSet = subtreeSetUsingAncestry(m.frames, m.selectedIdx, m.ancestry, m.subtreeSet) return m, m.animationTickCmd() + case flameSnapshotReadyMsg: + return m.handleSnapshotReady(msg) case tea.WindowSizeMsg: m.width = msg.Width m.height = msg.Height @@ -182,6 +254,10 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { _ = m.handleMouseClick(msg) return m, nil case tea.KeyPressMsg: + // Stamp every keypress so RefreshFromLiveTrieCmd and the + // snapshot-ready handler can detect that the user is actively driving + // the view and defer / unanimate accordingly. + m.lastKeyAt = time.Now() if m.searchActive { handled := false switch msg.String() { @@ -261,13 +337,61 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { m.jumpToRoot() } if m.selectedIdx != prev { - m.subtreeSet = computeSubtreeSetInto(m.frames, m.selectedIdx, m.subtreeSet) + m.subtreeSet = subtreeSetUsingAncestry(m.frames, m.selectedIdx, m.ancestry, m.subtreeSet) } m.recordKeyDebug(msg, handled, m.selectedIdx != prev) } return m, nil } +// handleSnapshotReady applies the result of a background snapshot+layout job. +// Discards the result if viewport or zoom changed while the job was in flight +// (the next tick will dispatch a fresh refresh), or if the user paused after a +// snapshot already exists. Always clears refreshInFlight so subsequent ticks +// can dispatch the next refresh. +func (m Model) handleSnapshotReady(msg flameSnapshotReadyMsg) (tea.Model, tea.Cmd) { + m.refreshInFlight = false + if msg.snapshot == nil { + return m, nil + } + if msg.layoutWidth != m.width || msg.layoutHeight != m.height || msg.zoomPath != m.zoomPath { + return m, nil + } + if m.paused && m.snapshot != nil { + return m, nil + } + + prevPath := "" + if len(m.frames) > 0 && m.selectedIdx >= 0 && m.selectedIdx < len(m.frames) { + prevPath = m.frames[m.selectedIdx].Path + } + + m.snapshot = msg.snapshot + m.globalTotal = msg.globalTotal + m.zoomRoot = msg.zoomRoot + m.lastVersion = msg.version + // Snap directly to target frames while the user is actively pressing keys + // — animation would just add latency on top of the work the user wants to + // see. Animation resumes on the next refresh after the drive window + // expires. + animate := !m.userDriving() + m.applyTargetFrames(msg.targetFrames, msg.ancestry, prevPath, animate) + if !m.animating { + return m, nil + } + return m, m.animationTickCmd() +} + +// userDriving reports whether the user has pressed a key within the recent +// drive window. Used to skip snapshot refresh and animation while keystrokes +// are arriving. +func (m Model) userDriving() bool { + if m.lastKeyAt.IsZero() { + return false + } + return time.Since(m.lastKeyAt) < driveWindow +} + // ConsumesKey reports whether the flamegraph should handle a key press before // dashboard- or app-level shortcuts. func (m Model) ConsumesKey(msg tea.KeyPressMsg) bool { @@ -299,8 +423,27 @@ func (m Model) ConsumesKey(msg tea.KeyPressMsg) bool { } } -// View renders the flamegraph viewport. +// View renders the flamegraph viewport. Caches the rendered string keyed on +// the inputs that affect output; skips the cache while animating (frames +// change every 33 ms anyway, so cache hits are impossible). func (m Model) View() tea.View { + if !m.animating && m.viewCache != nil { + key := m.currentViewCacheKey() + if m.viewCache.valid && m.viewCache.key == key { + return tea.NewView(m.viewCache.content) + } + content := m.renderViewContent() + m.viewCache.key = key + m.viewCache.content = content + m.viewCache.valid = true + return tea.NewView(content) + } + return tea.NewView(m.renderViewContent()) +} + +// renderViewContent assembles the flamegraph string. Pure function over Model +// state — pulled out so View() can decide whether to memoize the result. +func (m Model) renderViewContent() string { extraLines := 1 // selection status line if m.showHelp { extraLines++ @@ -322,7 +465,29 @@ func (m Model) View() tea.View { if m.showHelp { content += "\n" + m.helpOverlay() } - return tea.NewView(content) + return content +} + +// currentViewCacheKey snapshots every Model field that influences View() +// output. If any of these differ between successive View() invocations, the +// cache misses and the content is rebuilt. +func (m Model) currentViewCacheKey() flameViewCacheKey { + return flameViewCacheKey{ + version: m.lastVersion, + selectedIdx: m.selectedIdx, + width: m.width, + height: m.height, + framesLen: len(m.frames), + matchCount: len(m.matchIndices), + visibleCount: len(m.filterVisible), + searchQuery: m.searchQuery, + statusMessage: m.statusMessage, + zoomPath: m.zoomPath, + searchActive: m.searchActive, + showHelp: m.showHelp, + paused: m.paused, + isDark: m.isDark, + } } // SetLiveTrie updates the data source used by the flamegraph model. @@ -342,6 +507,7 @@ func (m *Model) SetLiveTrie(liveTrie LiveTrieSource) { m.zoomLineWidth = 0 m.subtreeSet = make(map[int]bool) m.filterVisible = make(map[int]bool) + m.ancestry = frameAncestry{} m.animation = NewAnimationState(30, 6.0, 1.0) m.animating = false m.hasNavigableSnapshot = false @@ -380,7 +546,10 @@ func (m *Model) syncCountFieldToTrie() { m.countField = field } -// RefreshFromLiveTrie loads a new snapshot when the source version changes. +// RefreshFromLiveTrie loads a new snapshot synchronously and returns true when +// a new snapshot was applied. Retained as a simple facade for tests; the +// production TUI now uses RefreshFromLiveTrieCmd to do the heavy lifting on a +// background goroutine. func (m *Model) RefreshFromLiveTrie() bool { if m.liveTrie == nil { return false @@ -395,12 +564,11 @@ func (m *Model) RefreshFromLiveTrie() bool { return false } - payload, version := m.liveTrie.SnapshotJSON() - var snapshot snapshotNode - if err := json.Unmarshal(payload, &snapshot); err != nil { + tree, version := m.liveTrie.SnapshotTree() + if tree == nil { return false } - m.snapshot = &snapshot + m.snapshot = tree m.globalTotal = snapshotTotal(m.snapshot) if m.zoomPath != "" { m.zoomRoot = findNodeByPath(m.snapshot, m.zoomPath) @@ -412,6 +580,77 @@ func (m *Model) RefreshFromLiveTrie() bool { return true } +// RefreshFromLiveTrieCmd returns a tea.Cmd that fetches a snapshot, lays out +// frames, and builds the ancestry index on a background goroutine, then +// dispatches a flameSnapshotReadyMsg back to the Bubble Tea Update loop. +// +// Returns nil if no refresh is needed: no live trie configured, paused with an +// existing snapshot, version unchanged, another refresh already in flight, or +// the user is actively pressing keys. Skipping while driving keeps keystrokes +// responsive — the next idle tick picks up the latest version. +// +// Coalescing via refreshInFlight ensures we never queue more than one +// background job at a time. Newer ticks just no-op until the in-flight result +// lands, and the version gate then catches the freshest state. +func (m *Model) RefreshFromLiveTrieCmd() tea.Cmd { + if m.liveTrie == nil { + return nil + } + if m.paused && m.snapshot != nil { + return nil + } + if m.refreshInFlight { + return nil + } + if m.userDriving() && m.snapshot != nil { + return nil + } + version := m.liveTrie.Version() + if version == m.lastVersion && m.snapshot != nil { + return nil + } + m.refreshInFlight = true + + // Capture fields the goroutine needs. Avoids reading Model fields + // concurrently with the Update goroutine. + liveTrie := m.liveTrie + width := m.width + height := m.height + zoomPath := m.zoomPath + return func() tea.Msg { + tree, ver := liveTrie.SnapshotTree() + if tree == nil { + return flameSnapshotReadyMsg{version: ver, layoutWidth: width, layoutHeight: height, zoomPath: zoomPath} + } + var zoomRoot *snapshotNode + layoutRoot := tree + rootPath := "" + if zoomPath != "" { + zoomRoot = findNodeByPath(tree, zoomPath) + if zoomRoot != nil { + layoutRoot = zoomRoot + rootPath = zoomPath + } + } + targetFrames := buildTerminalLayoutWithPath(layoutRoot, width, height, rootPath) + if zoomPath != "" { + targetFrames = applyZoomLineage(targetFrames, tree, zoomPath, width) + } + ancestry := buildFrameAncestry(targetFrames) + return flameSnapshotReadyMsg{ + version: ver, + layoutWidth: width, + layoutHeight: height, + zoomPath: zoomPath, + snapshot: tree, + zoomRoot: zoomRoot, + targetFrames: targetFrames, + ancestry: ancestry, + globalTotal: snapshotTotal(tree), + } + } +} + // LastVersion returns the latest snapshot version loaded into the model. func (m Model) LastVersion() uint64 { return m.lastVersion @@ -466,7 +705,18 @@ func (m *Model) rebuildFrames(animate bool) { if m.zoomPath != "" { targetFrames = m.withZoomLineage(targetFrames) } + ancestry := buildFrameAncestry(targetFrames) + m.applyTargetFrames(targetFrames, ancestry, prevPath, animate) +} + +// applyTargetFrames installs a prebuilt frame layout and ancestry index, +// optionally animating from the previous frames. Shared between the +// synchronous rebuildFrames path and the async snapshot-ready handler so +// post-swap state stays consistent (animation kickoff, selection restore, +// filter recompute, subtree highlight). +func (m *Model) applyTargetFrames(targetFrames []tuiFrame, ancestry frameAncestry, prevPath string, animate bool) { m.targetFrames = targetFrames + m.ancestry = ancestry m.animation.SetTargets(m.targetFrames) if animate && len(m.frames) > 0 && !m.animation.Settled() { m.animating = true @@ -483,7 +733,7 @@ func (m *Model) rebuildFrames(animate bool) { m.recomputeFilterState() m.ensureSelectionNavigable() m.ensureSelectionVisible() - m.subtreeSet = computeSubtreeSetInto(m.frames, m.selectedIdx, m.subtreeSet) + m.subtreeSet = subtreeSetUsingAncestry(m.frames, m.selectedIdx, m.ancestry, m.subtreeSet) } func (m *Model) restoreSelectionByPath(path string) { @@ -1044,7 +1294,7 @@ func (m *Model) handleMouseClick(msg tea.MouseClickMsg) bool { currentRoot := m.currentRootPath() if clickedPath == currentRoot { m.selectedIdx = idx - m.subtreeSet = computeSubtreeSetInto(m.frames, m.selectedIdx, m.subtreeSet) + m.subtreeSet = subtreeSetUsingAncestry(m.frames, m.selectedIdx, m.ancestry, m.subtreeSet) return true } if m.zoomPath != "" && hasPathBoundaryPrefix(currentRoot, clickedPath) { @@ -1062,7 +1312,7 @@ func (m *Model) handleMouseClick(msg tea.MouseClickMsg) bool { if sel := m.frameIndexByPath(clickedPath); sel >= 0 { m.selectedIdx = sel } - m.subtreeSet = computeSubtreeSetInto(m.frames, m.selectedIdx, m.subtreeSet) + m.subtreeSet = subtreeSetUsingAncestry(m.frames, m.selectedIdx, m.ancestry, m.subtreeSet) m.statusMessage = "Zoom: " + compactFramePath(clickedPath) return true } @@ -1180,10 +1430,17 @@ func (m Model) frameIndexAt(x, y int) int { } func (m Model) withZoomLineage(frames []tuiFrame) []tuiFrame { - if len(frames) == 0 || m.snapshot == nil { + return applyZoomLineage(frames, m.snapshot, m.zoomPath, m.width) +} + +// applyZoomLineage prepends the zoom path's ancestors to a zoomed frame +// layout. Extracted as a free function so the async snapshot refresh can +// reuse it on a background goroutine without referencing Model state directly. +func applyZoomLineage(frames []tuiFrame, snapshot *snapshotNode, zoomPath string, width int) []tuiFrame { + if len(frames) == 0 || snapshot == nil { return frames } - parts := strings.Split(m.zoomPath, pathSeparator) + parts := strings.Split(zoomPath, pathSeparator) if len(parts) <= 1 { return frames } @@ -1191,7 +1448,7 @@ func (m Model) withZoomLineage(frames []tuiFrame) []tuiFrame { rowShift := len(parts) - 1 out := make([]tuiFrame, 0, len(frames)+len(parts)) for _, frame := range frames { - if frame.Path == m.zoomPath { + if frame.Path == zoomPath { continue } frame.Row += rowShift @@ -1199,10 +1456,10 @@ func (m Model) withZoomLineage(frames []tuiFrame) []tuiFrame { out = append(out, frame) } - rootTotal := snapshotTotal(m.snapshot) + rootTotal := snapshotTotal(snapshot) for depth := range parts { path := strings.Join(parts[:depth+1], pathSeparator) - node := findNodeByPath(m.snapshot, path) + node := findNodeByPath(snapshot, path) total := uint64(0) if node != nil { total = snapshotTotal(node) @@ -1216,7 +1473,7 @@ func (m Model) withZoomLineage(frames []tuiFrame) []tuiFrame { Name: name, Col: 0, Row: depth, - Width: m.width, + Width: width, Total: total, Percent: percent, Fill: terminalFrameColor(name), diff --git a/internal/tui/flamegraph/search.go b/internal/tui/flamegraph/search.go index 6bedc3e..5cd66b1 100644 --- a/internal/tui/flamegraph/search.go +++ b/internal/tui/flamegraph/search.go @@ -53,7 +53,7 @@ func (m *Model) jumpMatch(direction int) { } else { m.selectedIdx = matches[0] } - m.subtreeSet = computeSubtreeSetInto(m.frames, m.selectedIdx, m.subtreeSet) + m.subtreeSet = subtreeSetUsingAncestry(m.frames, m.selectedIdx, m.ancestry, m.subtreeSet) return } @@ -65,7 +65,7 @@ func (m *Model) jumpMatch(direction int) { next = 0 } m.selectedIdx = matches[next] - m.subtreeSet = computeSubtreeSetInto(m.frames, m.selectedIdx, m.subtreeSet) + m.subtreeSet = subtreeSetUsingAncestry(m.frames, m.selectedIdx, m.ancestry, m.subtreeSet) } func (m *Model) recomputeFilterState() { @@ -88,7 +88,7 @@ func (m *Model) recomputeFilterState() { m.matchIndices[idx] = true } } - m.filterVisible = computeFilterVisibleSetInto(m.frames, m.matchIndices, m.filterVisible) + m.filterVisible = filterVisibleSetUsingAncestry(m.frames, m.matchIndices, m.ancestry, m.filterVisible) } func orderedMatchIndices(matchSet map[int]bool) []int { |
