From 6dba99e879704677b8e8a92a1c43c638805b4df8 Mon Sep 17 00:00:00 2001 From: Dylan Reimerink Date: Thu, 16 Jul 2026 15:51:15 +0200 Subject: [PATCH] Add `web` command for interactive analysis of stack usage This commit adds a new web command which launches a web server that allows you to interactively analyze stack usage of your BPF collections. It combines the existing CLI commands into a web interface, which is more user-friendly than the CLI commands. Signed-off-by: Dylan Reimerink --- README.md | 17 + cmd/stackwhere/lifetimes.go | 194 ++++--- cmd/stackwhere/list.go | 469 +---------------- cmd/stackwhere/list_test.go | 200 +++----- cmd/stackwhere/main.go | 4 + cmd/stackwhere/web.go | 704 ++++++++++++++++++++++++++ cmd/stackwhere/web_test.go | 275 ++++++++++ cmd/stackwhere/webassets/app.js | 107 ++++ cmd/stackwhere/webassets/index.html | 46 ++ cmd/stackwhere/webassets/program.html | 85 ++++ cmd/stackwhere/webassets/source.html | 59 +++ cmd/stackwhere/webassets/style.css | 256 ++++++++++ internal/stackview/stackview.go | 504 ++++++++++++++++++ 13 files changed, 2241 insertions(+), 679 deletions(-) create mode 100644 cmd/stackwhere/web.go create mode 100644 cmd/stackwhere/web_test.go create mode 100644 cmd/stackwhere/webassets/app.js create mode 100644 cmd/stackwhere/webassets/index.html create mode 100644 cmd/stackwhere/webassets/program.html create mode 100644 cmd/stackwhere/webassets/source.html create mode 100644 cmd/stackwhere/webassets/style.css create mode 100644 internal/stackview/stackview.go diff --git a/README.md b/README.md index 19079b3..28ade15 100644 --- a/README.md +++ b/README.md @@ -78,6 +78,23 @@ Multiple variables can share the same offset when their lifetimes do not overlap Spilling of unamed values, such as intermediate values of an expression does not appear in this output (at this this). Such spilled values can share stack slots with named variables and may use gaps in offsets that are shown. +### Inspecting in a web UI + +The `web` (alias `w`) sub-command starts a local web server for exploring stack usage interactively. + +``` +$ stackwhere web {path to .o} +Serving stackwhere web UI on http://127.0.0.1:8080 +``` + +Use `--source-dir` to limit which directories can be read by the `/source` endpoint. The flag is repeatable. + +``` +$ stackwhere web {path to .o} --source-dir ./bpf --source-dir ./common +``` + +If no `--source-dir` is set, stackwhere defaults to the directory containing the collection file. + ## Tips for reducing stack usage The limiting factor for a BPF program is the **peak** memory usage, some ideas to reduce peak memory usage are: diff --git a/cmd/stackwhere/lifetimes.go b/cmd/stackwhere/lifetimes.go index 6831cef..6e85421 100644 --- a/cmd/stackwhere/lifetimes.go +++ b/cmd/stackwhere/lifetimes.go @@ -121,6 +121,11 @@ type StackLifetime struct { lifetime *Lifetime } +type bpfFn struct { + fn *btf.Func + insns asm.Instructions +} + type lifetimesCmd struct { // Dump visitor state, reachable writes, reachable reads, and discovered lifetimes debug *bool @@ -129,12 +134,10 @@ type lifetimesCmd struct { drawBBB *bool } -func (lc *lifetimesCmd) run(cmd *cobra.Command, args []string) error { - dbgWriter := cmd.OutOrStdout() - - spec, err := ebpf.LoadCollectionSpec(args[0]) +func loadCollectionFunctions(collectionPath string) (*ebpf.CollectionSpec, map[string]bpfFn, error) { + spec, err := ebpf.LoadCollectionSpec(collectionPath) if err != nil { - return fmt.Errorf("load ebpf collection: %w", err) + return nil, nil, fmt.Errorf("load ebpf collection: %w", err) } fns := make(map[string]bpfFn) @@ -159,65 +162,13 @@ func (lc *lifetimesCmd) run(cmd *cobra.Command, args []string) error { } } - fn := fns[args[1]] - if fn.fn == nil { - return fmt.Errorf("program %s not found in collection", args[1]) - } - - blocks, err := analyze.MakeBlocks(fn.insns) - if err != nil { - return fmt.Errorf("make blocks: %w", err) - } - - if *lc.dumpInstructions || *lc.debug { - _, _ = fmt.Fprintln(dbgWriter, "=== Program instructions ===") - _, _ = fmt.Fprint(dbgWriter, blocks.Dump(fn.insns)) - } - - visitor := &visitor{ - dbgWriter: io.Discard, - spec: spec, - insns: fn.insns, - reachableWrites: make(map[*analyze.Block][]rw), - inStates: make(map[*analyze.Block]state), - outStates: make(map[*analyze.Block]state), - seenReads: make(map[rw]struct{}), - seenWrites: make(map[rw]struct{}), - } - if *lc.debug { - _, _ = fmt.Fprintln(dbgWriter, "=== Visitor state ===") - visitor.dbgWriter = dbgWriter - } - visitor.visit(blocks[0], state{stack: make(map[int16]stackState)}) - - if visitor.inaccurate { - _, _ = fmt.Fprintln(dbgWriter, "WARN: analysis may be inaccurate due to unhandled instructions or unknown helper functions, see debug log for details") - } - - if *lc.debug { - _, _ = fmt.Fprintln(dbgWriter, "\n=== Reachable writes per block ===") - for _, block := range blocks { - _, _ = fmt.Fprintf(dbgWriter, "%d:\n", block.ID) - for _, write := range visitor.reachableWrites[block] { - _, _ = fmt.Fprintf(dbgWriter, "\tBlock: %d, Offset: %d, RawIns: %d\n", write.Block.ID, write.Offset, write.RawIns) - } - } - - _, _ = fmt.Fprintln(dbgWriter, "\n=== Reads in program ===") - for _, read := range visitor.reads { - _, _ = fmt.Fprintf(dbgWriter, "\tBlock: %d, Offset: %d, RawIns: %d\n", read.Block.ID, read.Offset, read.RawIns) - } - } + return spec, fns, nil +} +func computeStackLifetimes(blocks analyze.Blocks, insns asm.Instructions, reachableWrites map[*analyze.Block][]rw, reads []rw) []StackLifetime { var results []StackLifetime - if *lc.debug { - _, _ = fmt.Fprintln(dbgWriter, "\n=== Lifetime discovery ===") - } - for _, read := range visitor.reads { - if *lc.debug { - _, _ = fmt.Fprintf(dbgWriter, "Finding lifetimes for %d at %d\n", read.Offset, read.RawIns) - } + for _, read := range reads { lt := &Lifetime{} visited := make(map[*analyze.Block]bool) @@ -229,13 +180,12 @@ func (lc *lifetimesCmd) run(cmd *cobra.Command, args []string) error { visited[block] = true var reachableWritesForOffset []rw - for _, write := range visitor.reachableWrites[block] { + for _, write := range reachableWrites[block] { if write.Offset == read.Offset { reachableWritesForOffset = append(reachableWritesForOffset, write) } } - // No more reachable writes for this offset, no point in visiting further predecessors if len(reachableWritesForOffset) == 0 { return } @@ -250,30 +200,21 @@ func (lc *lifetimesCmd) run(cmd *cobra.Command, args []string) error { return cmp.Compare(a.RawIns, b.RawIns) }) - // If there is a write in the current block if len(writesInCur) > 0 { - // if the current block is the same block as the read, add a single interval from the write to the read if block == read.Block { for _, write := range slices.Backward(writesInCur) { if write.RawIns < read.RawIns { lt.Add(LTInterval(write.RawIns, read.RawIns, false)) - - // No need to explore further, we have found the last write before the read return } } } else { lastWrite := writesInCur[len(writesInCur)-1] - // From last write to end of block - lt.Add(LTInterval(lastWrite.RawIns, insRawOff(block, fn.insns, block.End), false)) - // From start of block to end of block for all blocks in the stack + lt.Add(LTInterval(lastWrite.RawIns, insRawOff(block, insns, block.End), false)) for _, b := range slices.Backward(stack) { - lt.Add(LTInterval(b.Raw, insRawOff(b, fn.insns, b.End), true)) + lt.Add(LTInterval(b.Raw, insRawOff(b, insns, b.End), true)) } - // From start of block to read in the read block lt.Add(LTInterval(read.Block.Raw, read.RawIns, true)) - - // No need to explore further, we have found the last write before the read return } } @@ -289,10 +230,6 @@ func (lc *lifetimesCmd) run(cmd *cobra.Command, args []string) error { offset: read.Offset, lifetime: lt, }) - - if *lc.debug { - _, _ = fmt.Fprintln(dbgWriter, lt) - } } } @@ -340,7 +277,102 @@ func (lc *lifetimesCmd) run(cmd *cobra.Command, args []string) error { slt.lifetime.Intervals = slices.Delete(slt.lifetime.Intervals, i, i+1) } } - if *lc.debug { + } + + return results +} + +func buildProgramLifetimeGraph(spec *ebpf.CollectionSpec, insns asm.Instructions, drawBBB bool) (string, error) { + if len(insns) == 0 { + return "", nil + } + + blocks, err := analyze.MakeBlocks(insns) + if err != nil { + return "", fmt.Errorf("make blocks: %w", err) + } + if len(blocks) == 0 { + return "", nil + } + + visitor := &visitor{ + dbgWriter: io.Discard, + spec: spec, + insns: insns, + reachableWrites: make(map[*analyze.Block][]rw), + inStates: make(map[*analyze.Block]state), + outStates: make(map[*analyze.Block]state), + seenReads: make(map[rw]struct{}), + seenWrites: make(map[rw]struct{}), + } + visitor.visit(blocks[0], state{stack: make(map[int16]stackState)}) + + results := computeStackLifetimes(blocks, insns, visitor.reachableWrites, visitor.reads) + return graphLifetimes(results, blocks, insns, visitor.writes, visitor.reads, drawBBB), nil +} + +func (lc *lifetimesCmd) run(cmd *cobra.Command, args []string) error { + dbgWriter := cmd.OutOrStdout() + + spec, fns, err := loadCollectionFunctions(args[0]) + if err != nil { + return err + } + + fn := fns[args[1]] + if fn.fn == nil { + return fmt.Errorf("program %s not found in collection", args[1]) + } + + blocks, err := analyze.MakeBlocks(fn.insns) + if err != nil { + return fmt.Errorf("make blocks: %w", err) + } + + if *lc.dumpInstructions || *lc.debug { + _, _ = fmt.Fprintln(dbgWriter, "=== Program instructions ===") + _, _ = fmt.Fprint(dbgWriter, blocks.Dump(fn.insns)) + } + + visitor := &visitor{ + dbgWriter: io.Discard, + spec: spec, + insns: fn.insns, + reachableWrites: make(map[*analyze.Block][]rw), + inStates: make(map[*analyze.Block]state), + outStates: make(map[*analyze.Block]state), + seenReads: make(map[rw]struct{}), + seenWrites: make(map[rw]struct{}), + } + if *lc.debug { + _, _ = fmt.Fprintln(dbgWriter, "=== Visitor state ===") + visitor.dbgWriter = dbgWriter + } + visitor.visit(blocks[0], state{stack: make(map[int16]stackState)}) + + if visitor.inaccurate { + _, _ = fmt.Fprintln(dbgWriter, "WARN: analysis may be inaccurate due to unhandled instructions or unknown helper functions, see debug log for details") + } + + if *lc.debug { + _, _ = fmt.Fprintln(dbgWriter, "\n=== Reachable writes per block ===") + for _, block := range blocks { + _, _ = fmt.Fprintf(dbgWriter, "%d:\n", block.ID) + for _, write := range visitor.reachableWrites[block] { + _, _ = fmt.Fprintf(dbgWriter, "\tBlock: %d, Offset: %d, RawIns: %d\n", write.Block.ID, write.Offset, write.RawIns) + } + } + + _, _ = fmt.Fprintln(dbgWriter, "\n=== Reads in program ===") + for _, read := range visitor.reads { + _, _ = fmt.Fprintf(dbgWriter, "\tBlock: %d, Offset: %d, RawIns: %d\n", read.Block.ID, read.Offset, read.RawIns) + } + } + + results := computeStackLifetimes(blocks, fn.insns, visitor.reachableWrites, visitor.reads) + if *lc.debug { + _, _ = fmt.Fprintln(dbgWriter, "\n=== Lifetime discovery ===") + for _, slt := range results { _, _ = fmt.Fprintf(dbgWriter, "%d : %v\n", slt.offset, slt.lifetime) } } @@ -1402,7 +1434,7 @@ func graphLifetimes(lifetimes []StackLifetime, blocks analyze.Blocks, insns asm. } cx := marginLeft + int(w.RawIns)*cellW + cellW/2 cy := marginTop + rowIdx*rowH + rowH/2 - fmt.Fprintf(&sb, ``, cx, cy) + fmt.Fprintf(&sb, ``, w.RawIns, cx, cy) } // Read markers: white circles with black stroke. @@ -1413,7 +1445,7 @@ func graphLifetimes(lifetimes []StackLifetime, blocks analyze.Blocks, insns asm. } cx := marginLeft + int(r.RawIns)*cellW + cellW/2 cy := marginTop + rowIdx*rowH + rowH/2 - fmt.Fprintf(&sb, ``, cx, cy) + fmt.Fprintf(&sb, ``, r.RawIns, cx, cy) } // Border around the chart area. diff --git a/cmd/stackwhere/list.go b/cmd/stackwhere/list.go index 84c0201..36866ee 100644 --- a/cmd/stackwhere/list.go +++ b/cmd/stackwhere/list.go @@ -3,15 +3,9 @@ package main import ( "encoding/json" "fmt" - "maps" "slices" - "strings" - "github.com/cilium/ebpf" - "github.com/cilium/ebpf/asm" - "github.com/cilium/ebpf/btf" - "github.com/cilium/stackwhere/internal/dwarf" - "github.com/cilium/stackwhere/internal/dwarf/op" + "github.com/cilium/stackwhere/internal/stackview" "github.com/spf13/cobra" ) @@ -46,110 +40,18 @@ func (psl *programStackList) runE(cmd *cobra.Command, args []string) error { return psl.runListProgram(cmd, args) } -type bpfFn struct { - fn *btf.Func - insns asm.Instructions -} - func (psl *programStackList) runListProgram(cmd *cobra.Command, args []string) error { collectionPath := args[0] functionName := args[1] - tree, err := dwarf.NewDWARFTree(collectionPath) + analyzer, err := stackview.NewAnalyzer(collectionPath) if err != nil { - return fmt.Errorf("failed to parse DWARF data: %w", err) + return err } - coll, err := ebpf.LoadCollectionSpec(collectionPath) + usage, err := analyzer.ProgramDetails(functionName) if err != nil { - return fmt.Errorf("failed to load eBPF collection: %w", err) - } - - fns := make(map[string]bpfFn) - for _, prog := range coll.Programs { - var cur bpfFn - iter := prog.Instructions.Iterate() - for iter.Next() { - if fn := btf.FuncMetadata(iter.Ins); fn != nil { - if cur.fn != nil { - fns[cur.fn.Name] = cur - } - - cur.fn = fn - cur.insns = asm.Instructions{} - } - - cur.insns = append(cur.insns, *iter.Ins) - } - - if cur.fn != nil { - fns[cur.fn.Name] = cur - } - } - - subProgsDwarf := tree.ByType(dwarf.TagSubprogram) - subProgDwarfIdx := slices.IndexFunc(subProgsDwarf, func(n *dwarf.Node) bool { - return n.Name() == functionName - }) - if subProgDwarfIdx == -1 { - return fmt.Errorf("function %q not found in DWARF data", functionName) - } - - subProgDwarf := subProgsDwarf[subProgDwarfIdx] - subProg := fns[functionName] - if subProg.fn == nil { - return fmt.Errorf("function %q not found in eBPF collection", functionName) - } - - usage := stackSlotsFromDWARFVars(subProgDwarf) - usage = append(usage, stackSlotsFromInsns(subProg, subProgDwarf)...) - - // Sort outer array - slices.SortFunc(usage, func(a, b []slotUsage) int { - return int(a[0].Offset - b[0].Offset) - }) - // Merge inner arrays with the same offset - for i := range slices.Backward(usage) { - if i == 0 { - break - } - - if usage[i][0].Offset == usage[i-1][0].Offset { - usage[i-1] = append(usage[i-1], usage[i]...) - usage = slices.Delete(usage, i, i+1) - } - } - // Sort inner arrays by size, largest first, and then by name. And deduplicate. - for i := range usage { - slices.SortFunc(usage[i], func(a, b slotUsage) int { - sz := int(b.ByteSize - a.ByteSize) - if sz != 0 { - return sz - } - - name := strings.Compare(a.Name, b.Name) - if name != 0 { - return name - } - - return strings.Compare(a.FileLineCol, b.FileLineCol) - }) - - // Remove duplicates that can occur, for example when a function is inlined multiple times and it ends up reusing the same stack space. - usage[i] = slices.CompactFunc(usage[i], func(a, b slotUsage) bool { - callstackEqual := true - if len(a.Callstack) != len(b.Callstack) { - callstackEqual = false - } else { - for j := range a.Callstack { - if a.Callstack[j] != b.Callstack[j] { - callstackEqual = false - break - } - } - } - return a.Name == b.Name && a.ByteSize == b.ByteSize && a.FileLineCol == b.FileLineCol && callstackEqual - }) + return err } if jsonOutput(cmd) { @@ -164,8 +66,8 @@ func (psl *programStackList) runListProgram(cmd *cobra.Command, args []string) e w := cmd.OutOrStdout() for _, slots := range usage { if !*psl.flagCallStack { - slots = slices.CompactFunc(slices.Clone(slots), func(a, b slotUsage) bool { - return a.displayEqual(b) + slots = slices.CompactFunc(slices.Clone(slots), func(a, b stackview.SlotUsage) bool { + return a.DisplayEqual(b) }) } @@ -199,176 +101,19 @@ func (psl *programStackList) runListProgram(cmd *cobra.Command, args []string) e return nil } -type slotList [][]slotUsage - -func (s slotList) Add(slot slotUsage) slotList { - i, found := slices.BinarySearchFunc(s, []slotUsage{slot}, func(a, b []slotUsage) int { - return int(a[0].Offset - b[0].Offset) - }) - if found { - s[i] = append(s[i], slot) - } else { - s = slices.Insert(s, i, []slotUsage{slot}) - } - return s -} - -type slotUsage struct { - Offset int64 `json:"offset"` - Name string `json:"name"` - ByteSize int64 `json:"byte_size"` - FileLineCol string `json:"file_line_col"` - Callstack []callStackEntry `json:"callstack,omitempty"` -} - -func (s slotUsage) displayEqual(other slotUsage) bool { - return s.Name == other.Name && s.ByteSize == other.ByteSize && s.FileLineCol == other.FileLineCol -} - -type callStackEntry struct { - Name string `json:"name"` - FileLineCol string `json:"file_line_col"` -} - -// stackSlotsFromDWARFVars returns a list of stack slots used by the given function, result is unsorted. -func stackSlotsFromDWARFVars(progDwarf *dwarf.Node) slotList { - result := slotList{} - - stackMap := map[int64][]*dwarf.Node{} - dwarf.VisitPrefixOrder(progDwarf, func(n *dwarf.Node) { - // We are interested in variables and function parameters since those are the things that can be stored on - // the stack. - if n.Entry().Tag != dwarf.TagVariable && n.Entry().Tag != dwarf.TagFormalParameter { - return - } - - // If the current variable lives on the stack, add it to the map of stack offsets to variables that live at that offset. - offsets := stackOffsets(n) - if len(offsets) > 0 { - for _, offset := range offsets { - if !slices.Contains(stackMap[offset], n) { - stackMap[offset] = append(stackMap[offset], n) - } - } - } - }) - - for offset, nodes := range stackMap { - slices.SortFunc(nodes, func(a, b *dwarf.Node) int { - sz := int(b.ByteSize()) - int(a.ByteSize()) - if sz != 0 { - return sz - } - - return strings.Compare(a.Name(), b.Name()) - }) - - for _, n := range nodes { - callstack := []callStackEntry{} - - parents := []*dwarf.Node{} - p := n - for p != nil { - p = p.Parent() - parents = append(parents, p) - if p == progDwarf { - break - } - } - for _, parent := range parents { - if parent.Name() == "" { - continue - } - fileLineCol := parent.CallFileLineCol() - if fileLineCol == "" { - fileLineCol = parent.FileLineCol() - } - - callstack = append(callstack, callStackEntry{ - Name: parent.Name(), - FileLineCol: fileLineCol, - }) - } - - fileLineCol := n.CallFileLineCol() - if fileLineCol == "" { - fileLineCol = n.FileLineCol() - } - - usage := slotUsage{ - Offset: offset, - Name: n.Name(), - ByteSize: n.ByteSize(), - FileLineCol: fileLineCol, - Callstack: callstack, - } - - result = result.Add(usage) - } - } - - return result -} - -func stackOffsets(n *dwarf.Node) []int64 { - offsets := []int64{} - - // DWARF can express variable locations in two ways: as a single location expression or - // as a list of location expressions that are valid for different ranges of instructions. - if location, err := n.Location(); err == nil && location != nil { - // Loop over all instructions, see if any of them reference the frame base register, and thus some - // offset into the stack. - for _, locOp := range location { - if locOp.Opcode == op.DW_OP_fbreg { - if !slices.Contains(offsets, locOp.Args[0].(int64)) { - offsets = append(offsets, locOp.Args[0].(int64)) - } - } - } - } else if locList, err := n.LocationList(); err == nil && locList != nil { - // Loop over all entries in the locations list, each entry is valid for a specific range of instructions - // so a single variable may live in different places (registers, stack, etc) at different points in - // the program. - for _, entry := range locList.Entries() { - // The base address + offset pair entries seem to be the only ones used in BPF object files. - switch e := entry.(type) { - case dwarf.LLEOffsetPair: - // Loop over all instructions, see if any of them reference the frame base register, and thus some - // offset into the stack. - for _, locOp := range e.Ops() { - if locOp.Opcode == op.DW_OP_fbreg || locOp.Opcode == op.DW_OP_breg10 { - if !slices.Contains(offsets, locOp.Args[0].(int64)) { - offsets = append(offsets, locOp.Args[0].(int64)) - } - } - } - } - } - } - - slices.Sort(offsets) - return slices.Compact(offsets) -} - func (psl *programStackList) runListCollection(cmd *cobra.Command, args []string) error { collectionPath := args[0] - tree, err := dwarf.NewDWARFTree(collectionPath) + analyzer, err := stackview.NewAnalyzer(collectionPath) if err != nil { - return fmt.Errorf("failed to parse DWARF data: %w", err) + return err } - stackUsagePerProgram := map[string]int64{} - for _, prog := range tree.ByType(dwarf.TagSubprogram) { - if !isBPFProgram(prog) { - continue - } - - stackUsagePerProgram[prog.Name()] = getProgramStackUsage(prog) + out, err := analyzer.CollectionSummaryInCollection() + if err != nil { + return err } - out := sortedProgramStackUsage(stackUsagePerProgram) - if jsonOutput(cmd) { e := json.NewEncoder(cmd.OutOrStdout()) e.SetIndent("", " ") @@ -387,193 +132,3 @@ func (psl *programStackList) runListCollection(cmd *cobra.Command, args []string return nil } - -type programStackUsage struct { - Name string `json:"name"` - StackUsage int64 `json:"stack_usage"` -} - -func sortedProgramStackUsage(stackUsagePerProgram map[string]int64) []programStackUsage { - out := make([]programStackUsage, 0, len(stackUsagePerProgram)) - for _, prog := range slices.Collect(maps.Keys(stackUsagePerProgram)) { - out = append(out, programStackUsage{ - Name: prog, - StackUsage: stackUsagePerProgram[prog], - }) - } - - // Sort by stack usage, largest first, and then by name. - slices.SortFunc(out, func(a, b programStackUsage) int { - sz := int(b.StackUsage - a.StackUsage) - if sz != 0 { - return sz - } - - return strings.Compare(a.Name, b.Name) - }) - - return out -} - -func getProgramStackUsage(prog *dwarf.Node) int64 { - largestOffset := int64(0) - lastSize := int64(0) - dwarf.VisitPrefixOrder(prog, func(n *dwarf.Node) { - // Only consider variables and function parameters since those are the things that can be stored on the stack. - if n.Entry().Tag != dwarf.TagVariable && n.Entry().Tag != dwarf.TagFormalParameter { - return - } - - // Find all stack offsets used by this variable. - offsets := stackOffsets(n) - if len(offsets) == 0 { - return - } - - // If this was the largest stack offset we've seen so far, then the total stack usage must be at least - // large enough to fit this variable. If this variable has the same largest offset as a previous variable, - // but is larger than that previous variable, then the total stack usage must be increased to fit this variable. - sz := n.ByteSize() - for _, offset := range offsets { - if offset > largestOffset { - largestOffset = offset - lastSize = sz - } - if offset == largestOffset && sz > lastSize { - lastSize = sz - } - } - }) - - // Stack usage is always a multiple of 8 bytes, so round up to the nearest multiple of 8. - stackUsage := largestOffset + lastSize - if stackUsage%8 != 0 { - stackUsage = ((stackUsage / 8) + 1) * 8 - } - - return stackUsage -} - -func isBPFProgram(n *dwarf.Node) bool { - if n.Entry().Tag != dwarf.TagSubprogram { - return false - } - - if n.Entry().Val(dwarf.AttrName) == nil { - return false - } - - if n.Entry().Val(dwarf.AttrInline) != nil { - return false - } - - if n.Entry().Val(dwarf.AttrType) == nil { - return false - } - - return true -} - -// Find stack slot usage by looking at the instructions and enhance with DWARF information. -// We are specifically looking for this pattern of instructions: -// -// Mov Rx, R10 -// Add Rx, -N -// -// Where Rx is any register and N is some constant. -func stackSlotsFromInsns(fn bpfFn, progDbg *dwarf.Node) slotList { - i2n := instructionToNodes(progDbg) - - var result slotList - - iter := fn.insns.Iterate() - for iter.Next() { - if iter.Ins.Src == asm.R10 && iter.Ins.OpCode.ALUOp() == asm.Mov { - // Validate the pattern: Mov Rx, R10 followed by Add Rx, -N on the same register. - nextIdx := iter.Index + 1 - if nextIdx >= len(fn.insns) { - continue - } - nextInsn := fn.insns[nextIdx] - if nextInsn.OpCode.ALUOp() != asm.Add || nextInsn.Dst != iter.Ins.Dst || nextInsn.Constant >= 0 { - continue - } - - byteOff := uint64(iter.Offset * asm.InstructionSize) - - var line *btf.Line - for j := iter.Index; j < len(fn.insns); j++ { - ins := fn.insns[j] - if lo, ok := ins.Source().(*btf.Line); ok && lo.LineNumber() != 0 { - line = lo - break - } - } - - fileCol := "" - if line != nil { - fileCol = line.FileName() + ":" + fmt.Sprint(line.LineNumber()) - } - - usage := slotUsage{ - Offset: -nextInsn.Constant, - Name: iter.Ins.Dst.String(), - ByteSize: -1, - FileLineCol: fileCol, - } - for _, n := range i2n[byteOff] { - // Some nodes like Lexical blocks do not have a name or file/line information. - // So not useful in the trace. - if n.Name() == "" && n.FileLineCol() == "" { - continue - } - - fileLineCol := n.CallFileLineCol() - if fileLineCol == "" { - fileLineCol = n.FileLineCol() - } - - usage.Callstack = append(usage.Callstack, callStackEntry{ - Name: n.Name(), - FileLineCol: fileLineCol, - }) - } - - result = result.Add(usage) - } - } - - return result -} - -// Create a mapping of instruction offsets to the DWARF nodes that are valid at that instruction. -func instructionToNodes(prog *dwarf.Node) map[uint64][]*dwarf.Node { - instRange := make(map[uint64][]*dwarf.Node) - - progInsOffset := prog.Entry().Val(dwarf.AttrLowpc).(uint64) - - dwarf.VisitPrefixOrder(prog, func(n *dwarf.Node) { - lowpc := n.Entry().Val(dwarf.AttrLowpc) - highpc := n.Entry().Val(dwarf.AttrHighpc) - if lowpc != nil && highpc != nil { - for i := lowpc.(uint64); i < lowpc.(uint64)+uint64(highpc.(int64)); i += asm.InstructionSize { - instRange[i-progInsOffset] = append(instRange[i-progInsOffset], n) - } - } - - if rng, err := n.Ranges(); err == nil { - for _, r := range rng.Ranges { - for i := r.Start; i < r.End; i += asm.InstructionSize { - instRange[i-progInsOffset] = append(instRange[i-progInsOffset], n) - } - } - } - }) - - // Reverse since we want to report the stack callstack from the innermost function to the outermost - for _, nodes := range instRange { - slices.Reverse(nodes) - } - - return instRange -} diff --git a/cmd/stackwhere/list_test.go b/cmd/stackwhere/list_test.go index 1180e23..d25b119 100644 --- a/cmd/stackwhere/list_test.go +++ b/cmd/stackwhere/list_test.go @@ -7,172 +7,90 @@ import ( "strings" "testing" - "github.com/cilium/ebpf" - "github.com/cilium/ebpf/btf" - "github.com/cilium/stackwhere/internal/dwarf" + "github.com/cilium/stackwhere/internal/stackview" ) -func TestGetStackSlotUsageFromDWARF(t *testing.T) { - tree, err := dwarf.NewDWARFTree("../../testdata/basic.o") - if err != nil { - t.Fatalf("failed to parse DWARF data: %v", err) - } - - subProgs := tree.ByType(dwarf.TagSubprogram) - idx := slices.IndexFunc(subProgs, func(n *dwarf.Node) bool { - return n.Name() == "cil_entry" - }) - if idx == -1 { - t.Fatalf("failed to find subprogram node for cil_entry") - } - - stackUsage := stackSlotsFromDWARFVars(subProgs[idx]) - if len(stackUsage) != 3 { - t.Fatalf("expected 3 stack slots, got %d", len(stackUsage)) - } - - // Verify each stack slot group contains expected slots - slotGroups := []struct { - index int - expected map[string]int64 - }{ - { - index: 0, - expected: map[string]int64{ - "a": 32, - "b": 32, - "c": 32, - }, - }, - { - index: 1, - expected: map[string]int64{ - "two_inlined_a": 16, - "two_inlined_b": 16, - "two_inlined_c": 16, - }, - }, - { - index: 2, - expected: map[string]int64{ - "one_inlined_d": 8, - }, - }, - } - - for _, group := range slotGroups { - found := make(map[string]int64) - for _, slot := range stackUsage[group.index] { - found[slot.Name] = slot.ByteSize - } - - // Check all expected slots are present with correct size - for name, expectedSize := range group.expected { - actualSize, ok := found[name] - if !ok { - t.Errorf("slot group %d: expected slot %q not found", group.index, name) - continue +func hasSlot(slots [][]stackview.SlotUsage, wantName string, wantSize int64) bool { + for _, group := range slots { + for _, slot := range group { + if slot.Name == wantName && slot.ByteSize == wantSize { + return true } - if actualSize != expectedSize { - t.Errorf("slot group %d: slot %q has size %d, expected %d", group.index, name, actualSize, expectedSize) - } - delete(found, name) - } - - // Check no unexpected slots are present - if len(found) > 0 { - t.Errorf("slot group %d: unexpected slots found: %v", group.index, found) } } + + return false } -func TestGetStackSlotUsageFromInsns(t *testing.T) { - tree, err := dwarf.NewDWARFTree("../../testdata/spill.o") +func TestProgramDetailsFromDWARF(t *testing.T) { + analyzer, err := stackview.NewAnalyzer("../../testdata/basic.o") if err != nil { - t.Fatalf("failed to parse DWARF data: %v", err) + t.Fatalf("failed to initialize analyzer: %v", err) } - spec, err := ebpf.LoadCollectionSpec("../../testdata/spill.o") + stackUsage, err := analyzer.ProgramDetails("cil_entry") if err != nil { - t.Fatalf("failed to load collection spec: %v", err) - } - - subProgs := tree.ByType(dwarf.TagSubprogram) - idx := slices.IndexFunc(subProgs, func(n *dwarf.Node) bool { - return n.Name() == "cil_entry" - }) - if idx == -1 { - t.Fatalf("failed to find subprogram node for cil_entry") + t.Fatalf("failed to get program details: %v", err) } - fn := bpfFn{ - fn: btf.FuncMetadata(&spec.Programs["cil_entry"].Instructions[0]), - insns: spec.Programs["cil_entry"].Instructions, + for _, want := range []struct { + name string + size int64 + }{ + {name: "a", size: 32}, + {name: "b", size: 32}, + {name: "c", size: 32}, + {name: "two_inlined_a", size: 16}, + {name: "two_inlined_b", size: 16}, + {name: "two_inlined_c", size: 16}, + {name: "one_inlined_d", size: 8}, + } { + if !hasSlot(stackUsage, want.name, want.size) { + t.Fatalf("expected to find slot %q (size %d) in program details", want.name, want.size) + } } +} - stackUsage := stackSlotsFromInsns(fn, subProgs[idx]) - if len(stackUsage) != 1 { - t.Fatalf("expected 1 stack slot group, got %d", len(stackUsage)) +func TestProgramDetailsFromInsns(t *testing.T) { + analyzer, err := stackview.NewAnalyzer("../../testdata/spill.o") + if err != nil { + t.Fatalf("failed to initialize analyzer: %v", err) } - // Verify each stack slot group contains expected slots - slotGroups := []struct { - index int - expected map[string]int64 - }{ - { - index: 0, - expected: map[string]int64{ - "r2": -1, - }, - }, + stackUsage, err := analyzer.ProgramDetails("cil_entry") + if err != nil { + t.Fatalf("failed to get program details: %v", err) } - for _, group := range slotGroups { - found := make(map[string]int64) - for _, slot := range stackUsage[group.index] { - found[slot.Name] = slot.ByteSize - } - - // Check all expected slots are present with correct size - for name, expectedSize := range group.expected { - actualSize, ok := found[name] - if !ok { - t.Errorf("slot group %d: expected slot %q not found", group.index, name) - continue - } - if actualSize != expectedSize { - t.Errorf("slot group %d: slot %q has size %d, expected %d", group.index, name, actualSize, expectedSize) - } - delete(found, name) - } - - // Check no unexpected slots are present - if len(found) > 0 { - t.Errorf("slot group %d: unexpected slots found: %v", group.index, found) - } + if !hasSlot(stackUsage, "r2", -1) { + t.Fatalf("expected to find inferred spill slot r2 with unknown byte size") } } -func TestGetProgramStackUsage(t *testing.T) { - tree, err := dwarf.NewDWARFTree("../../testdata/basic.o") +func TestCollectionSummaryIncludesProgramUsage(t *testing.T) { + analyzer, err := stackview.NewAnalyzer("../../testdata/basic.o") if err != nil { - t.Fatalf("failed to parse DWARF data: %v", err) + t.Fatalf("failed to initialize analyzer: %v", err) } - stackUsagePerProgram := map[string]int64{} - for _, prog := range tree.ByType(dwarf.TagSubprogram) { - if !isBPFProgram(prog) { + var got int64 + found := false + for _, prog := range analyzer.CollectionSummary() { + if prog.Name != "cil_entry" { continue } - stackUsagePerProgram[prog.Name()] = getProgramStackUsage(prog) + got = prog.StackUsage + found = true + break + } + if !found { + t.Fatalf("expected cil_entry in collection summary") } - stackUsage := stackUsagePerProgram["cil_entry"] expectedStackUsage := int64(56) - if stackUsage != expectedStackUsage { - t.Errorf("expected stack usage %d, got %d", expectedStackUsage, stackUsage) + if got != expectedStackUsage { + t.Errorf("expected stack usage %d, got %d", expectedStackUsage, got) } } @@ -240,13 +158,13 @@ func TestListProgramCallStackPreservesDistinctInlineCallSites(t *testing.T) { } func TestSortedProgramStackUsageOrdersEqualUsageByName(t *testing.T) { - out := sortedProgramStackUsage(map[string]int64{ + out := stackview.SortProgramStackUsage(map[string]int64{ "beta": 16, "alpha": 16, "gamma": 24, }) - want := []programStackUsage{ + want := []stackview.ProgramStackUsage{ {Name: "gamma", StackUsage: 24}, {Name: "alpha", StackUsage: 16}, {Name: "beta", StackUsage: 16}, @@ -268,12 +186,12 @@ func TestListCollectionJSONOrdersEqualUsageByName(t *testing.T) { t.Fatalf("list command failed: %v", err) } - var got []programStackUsage + var got []stackview.ProgramStackUsage if err := json.Unmarshal(stdout.Bytes(), &got); err != nil { t.Fatalf("failed to decode JSON output: %v", err) } - want := []programStackUsage{ + want := []stackview.ProgramStackUsage{ {Name: "alpha", StackUsage: 16}, {Name: "beta", StackUsage: 16}, } @@ -294,12 +212,12 @@ func TestListProgramSupportsStartXLengthRangeLists(t *testing.T) { t.Fatalf("list command failed: %v", err) } - var got slotList + var got [][]stackview.SlotUsage if err := json.Unmarshal(stdout.Bytes(), &got); err != nil { t.Fatalf("failed to decode JSON output: %v", err) } - want := slotList{ + want := [][]stackview.SlotUsage{ { {Offset: 4, Name: "r1", ByteSize: -1}, }, diff --git a/cmd/stackwhere/main.go b/cmd/stackwhere/main.go index e8e8153..1d3d212 100644 --- a/cmd/stackwhere/main.go +++ b/cmd/stackwhere/main.go @@ -44,9 +44,13 @@ func root() *cobra.Command { lifetimes := lifetimesCommand() lifetimes.GroupID = primaryGroupID + web := webCommand() + web.GroupID = primaryGroupID + c.AddCommand( listCmd, lifetimes, + web, versionCommand(), ) diff --git a/cmd/stackwhere/web.go b/cmd/stackwhere/web.go new file mode 100644 index 0000000..7e4ff90 --- /dev/null +++ b/cmd/stackwhere/web.go @@ -0,0 +1,704 @@ +package main + +import ( + "bufio" + "embed" + "errors" + "fmt" + "html/template" + "io" + "io/fs" + "net" + "net/http" + "net/url" + "os" + "path/filepath" + "slices" + "strconv" + "strings" + + "github.com/cilium/ebpf/asm" + "github.com/cilium/ebpf/btf" + "github.com/cilium/stackwhere/internal/stackview" + "github.com/spf13/cobra" +) + +//go:embed webassets/* +var webAssets embed.FS + +type webCmd struct { + flagAddr *string + flagSourceDirs *[]string +} + +func webCommand() *cobra.Command { + c := &cobra.Command{ + Use: "web {collection}", + Aliases: []string{"w"}, + Short: "Hosts an interactive local web UI for stack usage.", + Long: "Hosts an interactive local web UI for stack usage from the given collection.", + Example: "stackwhere web /path/to/collection.o --addr :8080", + Args: cobra.ExactArgs(1), + } + + flags := c.Flags() + wc := &webCmd{ + flagAddr: flags.String("addr", ":8080", "Address to bind the local web server to"), + flagSourceDirs: flags.StringSlice( + "source-dir", + nil, + "Directory allowed for source file lookups (repeatable)", + ), + } + + c.RunE = wc.runE + return c +} + +func (wc *webCmd) runE(cmd *cobra.Command, args []string) error { + collectionPath := args[0] + + app, err := newWebApp(collectionPath, *wc.flagSourceDirs) + if err != nil { + return err + } + + if _, err := fmt.Fprintf(cmd.OutOrStdout(), "Serving stackwhere web UI on %s\n", startupURL(*wc.flagAddr)); err != nil { + return err + } + + server := &http.Server{ + Addr: *wc.flagAddr, + Handler: app.handler(), + } + + if err := server.ListenAndServe(); err != nil && err != http.ErrServerClosed { + return fmt.Errorf("web server failed: %w", err) + } + return nil +} + +func startupURL(addr string) string { + host, port, err := net.SplitHostPort(addr) + if err != nil { + return "http://" + addr + } + + if host == "" { + host = "127.0.0.1" + } + + return "http://" + net.JoinHostPort(host, port) +} + +type webProgram struct { + Name string + URLName string + StackUsage int64 +} + +type webSlotEntry struct { + Size string + Name string + FileLineCol string + SourceURL string +} + +type webSlotGroup struct { + Offset int64 + Entries []webSlotEntry +} + +type webProgramPage struct { + Groups []webSlotGroup + InstructionLines []webInstructionLine + LifetimesSVG template.HTML +} + +type webInstructionLine struct { + Text string + RawOffset int + IsComment bool + SourceURL string + CommentIndent string + CommentText string +} + +type webApp struct { + programs []webProgram + programDetail map[string]webProgramPage + indexTmpl *template.Template + programTmpl *template.Template + sourceTmpl *template.Template + staticFS fs.FS + sourceDirs []string +} + +type sourceLine struct { + Number int + Content string + IsHighlight bool +} + +const ( + maxSourceLines = 20000 + maxSourceBytes = 2 << 20 +) + +var errSourceFileFound = errors.New("source file found") + +func newWebApp(collectionPath string, sourceDirs []string) (*webApp, error) { + analyzer, err := stackview.NewAnalyzer(collectionPath) + if err != nil { + return nil, err + } + + normalizedSourceDirs, err := normalizeSourceDirs(collectionPath, sourceDirs) + if err != nil { + return nil, err + } + + summary := analyzer.CollectionSummary() + spec, functions, err := loadCollectionFunctions(collectionPath) + if err != nil { + return nil, err + } + + programs := make([]webProgram, 0, len(summary)) + programDetail := make(map[string]webProgramPage, len(summary)) + + for _, prog := range summary { + slots, err := analyzer.ProgramDetails(prog.Name) + if err != nil { + if errors.Is(err, stackview.ErrFunctionNotFoundInCollection) { + continue + } + return nil, err + } + + fn, ok := functions[prog.Name] + if !ok || fn.fn == nil { + continue + } + + graph, err := buildProgramLifetimeGraph(spec, fn.insns, false) + if err != nil { + return nil, err + } + + programDetail[prog.Name] = webProgramPage{ + Groups: toWebSlotGroups(slots), + InstructionLines: toWebInstructionLines(fn.insns), + LifetimesSVG: template.HTML(graph), + } + + programs = append(programs, webProgram{ + Name: prog.Name, + URLName: url.PathEscape(prog.Name), + StackUsage: prog.StackUsage, + }) + } + + indexTmpl, err := template.ParseFS(webAssets, "webassets/index.html") + if err != nil { + return nil, fmt.Errorf("failed to parse index template: %w", err) + } + + programTmpl, err := template.ParseFS(webAssets, "webassets/program.html") + if err != nil { + return nil, fmt.Errorf("failed to parse program template: %w", err) + } + + sourceTmpl, err := template.ParseFS(webAssets, "webassets/source.html") + if err != nil { + return nil, fmt.Errorf("failed to parse source template: %w", err) + } + + staticFS, err := fs.Sub(webAssets, "webassets") + if err != nil { + return nil, fmt.Errorf("failed to initialize static assets: %w", err) + } + + return &webApp{ + programs: programs, + programDetail: programDetail, + indexTmpl: indexTmpl, + programTmpl: programTmpl, + sourceTmpl: sourceTmpl, + staticFS: staticFS, + sourceDirs: normalizedSourceDirs, + }, nil +} + +func normalizeSourceDirs(collectionPath string, sourceDirs []string) ([]string, error) { + if len(sourceDirs) == 0 { + sourceDirs = []string{filepath.Dir(collectionPath)} + } + + normalized := make([]string, 0, len(sourceDirs)) + seen := make(map[string]struct{}, len(sourceDirs)) + + for _, dir := range sourceDirs { + dir = strings.TrimSpace(dir) + if dir == "" { + continue + } + + absDir, err := filepath.Abs(dir) + if err != nil { + return nil, fmt.Errorf("resolve source dir %q: %w", dir, err) + } + + realDir, err := filepath.EvalSymlinks(absDir) + if err != nil { + return nil, fmt.Errorf("resolve source dir %q: %w", dir, err) + } + + info, err := os.Stat(realDir) + if err != nil { + return nil, fmt.Errorf("stat source dir %q: %w", dir, err) + } + if !info.IsDir() { + return nil, fmt.Errorf("source dir %q is not a directory", dir) + } + + if _, ok := seen[realDir]; ok { + continue + } + seen[realDir] = struct{}{} + normalized = append(normalized, realDir) + } + + if len(normalized) == 0 { + return nil, errors.New("at least one source directory must be configured") + } + + return normalized, nil +} + +func toWebSlotGroups(slots [][]stackview.SlotUsage) []webSlotGroup { + groups := make([]webSlotGroup, 0, len(slots)) + for _, group := range slots { + displaySlots := slices.CompactFunc(slices.Clone(group), func(a, b stackview.SlotUsage) bool { + return a.DisplayEqual(b) + }) + + entries := make([]webSlotEntry, 0, len(displaySlots)) + for _, slot := range displaySlots { + size := fmt.Sprintf("%d", slot.ByteSize) + if slot.ByteSize == -1 { + size = "?" + } + + name := slot.Name + if name == "" { + name = "(unknown)" + } + + entries = append(entries, webSlotEntry{ + Size: size, + Name: name, + FileLineCol: slot.FileLineCol, + SourceURL: sourceURLForLocation(slot.FileLineCol), + }) + } + + groups = append(groups, webSlotGroup{ + Offset: group[0].Offset, + Entries: entries, + }) + } + + slices.Reverse(groups) + + return groups +} + +func toWebInstructionLines(insns asm.Instructions) []webInstructionLine { + rawOffsetSourceURL := make(map[int]string, len(insns)) + rawOffset := 0 + for _, ins := range insns { + if src, ok := ins.Source().(*btf.Line); ok && src.LineNumber() != 0 { + loc := fmt.Sprintf("%s:%d", src.FileName(), src.LineNumber()) + if sourceURL := sourceURLForLocation(loc); sourceURL != "" { + rawOffsetSourceURL[rawOffset] = sourceURL + } + } + + rawOffset += int(ins.Size() / asm.InstructionSize) + } + + raw := fmt.Sprintf("%+v", insns) + lines := strings.Split(raw, "\n") + out := make([]webInstructionLine, 0, len(lines)) + pendingCommentIdx := make([]int, 0, 4) + + for _, line := range lines { + trimmed := strings.TrimSpace(line) + isComment := strings.HasPrefix(trimmed, ";") + rawOffset := -1 + if idx := strings.Index(line, ":"); idx > 0 { + candidate := strings.TrimSpace(line[:idx]) + if n, err := strconv.Atoi(candidate); err == nil { + rawOffset = n + } + } + + out = append(out, webInstructionLine{ + Text: line, + RawOffset: rawOffset, + IsComment: isComment, + }) + + lineIdx := len(out) - 1 + if isComment { + commentStart := strings.Index(line, ";") + if commentStart >= 0 { + out[lineIdx].CommentIndent = line[:commentStart] + out[lineIdx].CommentText = line[commentStart:] + } else { + out[lineIdx].CommentText = line + } + } + if isComment { + pendingCommentIdx = append(pendingCommentIdx, lineIdx) + continue + } + + if rawOffset >= 0 { + if sourceURL, found := rawOffsetSourceURL[rawOffset]; found { + for _, idx := range pendingCommentIdx { + out[idx].SourceURL = sourceURL + } + } + pendingCommentIdx = pendingCommentIdx[:0] + } + } + + return out +} + +func (wa *webApp) handler() http.Handler { + mux := http.NewServeMux() + mux.Handle("/static/", http.StripPrefix("/static/", http.FileServer(http.FS(wa.staticFS)))) + mux.HandleFunc("/source", wa.handleSource) + mux.HandleFunc("/", wa.handleIndex) + mux.HandleFunc("/program/", wa.handleProgram) + return mux +} + +func (wa *webApp) handleIndex(w http.ResponseWriter, r *http.Request) { + if r.URL.Path != "/" { + http.NotFound(w, r) + return + } + + data := struct { + Programs []webProgram + }{Programs: wa.programs} + + if err := wa.indexTmpl.Execute(w, data); err != nil { + http.Error(w, fmt.Sprintf("failed to render page: %v", err), http.StatusInternalServerError) + return + } +} + +func (wa *webApp) handleProgram(w http.ResponseWriter, r *http.Request) { + name := strings.TrimPrefix(r.URL.Path, "/program/") + if name == "" { + http.NotFound(w, r) + return + } + + decodedName, err := url.PathUnescape(name) + if err != nil { + http.Error(w, "invalid program name", http.StatusBadRequest) + return + } + + detail, ok := wa.programDetail[decodedName] + if !ok { + http.NotFound(w, r) + return + } + + data := struct { + ProgramName string + Groups []webSlotGroup + InstructionLines []webInstructionLine + LifetimesSVG template.HTML + }{ + ProgramName: decodedName, + Groups: detail.Groups, + InstructionLines: detail.InstructionLines, + LifetimesSVG: detail.LifetimesSVG, + } + + if err := wa.programTmpl.Execute(w, data); err != nil { + http.Error(w, fmt.Sprintf("failed to render page: %v", err), http.StatusInternalServerError) + return + } +} + +func sourceURLForLocation(fileLineCol string) string { + file, line, _ := parseLocation(fileLineCol) + if file == "" || !isSupportedSourceFile(file) { + return "" + } + + v := url.Values{} + v.Set("file", file) + if line > 0 { + v.Set("line", strconv.Itoa(line)) + } + + return "/source?" + v.Encode() +} + +func parseLocation(fileLineCol string) (string, int, int) { + parts := strings.Split(fileLineCol, ":") + if len(parts) == 0 { + return "", 0, 0 + } + + last, lastErr := strconv.Atoi(parts[len(parts)-1]) + if lastErr != nil { + return fileLineCol, 0, 0 + } + + if len(parts) == 1 { + return fileLineCol, 0, 0 + } + + secondLast, secondLastErr := strconv.Atoi(parts[len(parts)-2]) + if secondLastErr == nil { + return strings.Join(parts[:len(parts)-2], ":"), secondLast, last + } + + return strings.Join(parts[:len(parts)-1], ":"), last, 0 +} + +func isSupportedSourceFile(path string) bool { + ext := strings.ToLower(filepath.Ext(path)) + return ext == ".c" || ext == ".h" +} + +func (wa *webApp) handleSource(w http.ResponseWriter, r *http.Request) { + requestedFile := strings.TrimSpace(r.URL.Query().Get("file")) + if requestedFile == "" { + http.Error(w, "missing file query parameter", http.StatusBadRequest) + return + } + if !isSupportedSourceFile(requestedFile) { + http.Error(w, "unsupported source file type", http.StatusBadRequest) + return + } + + lineNumber := 0 + lineParam := strings.TrimSpace(r.URL.Query().Get("line")) + if lineParam != "" { + parsedLine, err := strconv.Atoi(lineParam) + if err != nil || parsedLine < 1 { + http.Error(w, "invalid line query parameter", http.StatusBadRequest) + return + } + lineNumber = parsedLine + } + + resolvedPath, ok := wa.resolveSourcePath(requestedFile) + if !ok { + w.WriteHeader(http.StatusNotFound) + _ = wa.sourceTmpl.Execute(w, struct { + Found bool + RequestPath string + SourceDirs []string + }{ + Found: false, + RequestPath: requestedFile, + SourceDirs: wa.sourceDirs, + }) + return + } + + lines, truncated, err := readSourceLines(resolvedPath, lineNumber) + if err != nil { + http.Error(w, fmt.Sprintf("failed to read source file: %v", err), http.StatusInternalServerError) + return + } + + if err := wa.sourceTmpl.Execute(w, struct { + Found bool + RequestPath string + ResolvedPath string + Lines []sourceLine + Truncated bool + SourceDirs []string + }{ + Found: true, + RequestPath: requestedFile, + ResolvedPath: resolvedPath, + Lines: lines, + Truncated: truncated, + SourceDirs: wa.sourceDirs, + }); err != nil { + http.Error(w, fmt.Sprintf("failed to render page: %v", err), http.StatusInternalServerError) + return + } +} + +func (wa *webApp) resolveSourcePath(requestedPath string) (string, bool) { + cleanPath := filepath.Clean(requestedPath) + if cleanPath == "." || cleanPath == "" { + return "", false + } + if !isSupportedSourceFile(cleanPath) { + return "", false + } + + candidates := make([]string, 0, len(wa.sourceDirs)) + if filepath.IsAbs(cleanPath) { + candidates = append(candidates, cleanPath) + } else { + for _, sourceDir := range wa.sourceDirs { + candidates = append(candidates, filepath.Join(sourceDir, cleanPath)) + } + } + + for _, candidate := range candidates { + resolvedPath, ok := wa.resolveAllowedSourcePath(candidate) + if ok { + return resolvedPath, true + } + } + + baseName := filepath.Base(cleanPath) + if baseName == "." || baseName == string(filepath.Separator) { + return "", false + } + + var foundPath string + for _, sourceDir := range wa.sourceDirs { + err := filepath.WalkDir(sourceDir, func(path string, d fs.DirEntry, err error) error { + if err != nil { + return nil + } + if d.IsDir() { + return nil + } + if d.Name() != baseName { + return nil + } + resolvedPath, ok := wa.resolveAllowedSourcePath(path) + if !ok { + return nil + } + foundPath = resolvedPath + return errSourceFileFound + }) + if foundPath != "" { + return foundPath, true + } + if err != nil && !errors.Is(err, errSourceFileFound) { + return "", false + } + } + + return "", false +} + +func (wa *webApp) resolveAllowedSourcePath(candidate string) (string, bool) { + if !isReadableFile(candidate) { + return "", false + } + + absPath, err := filepath.Abs(candidate) + if err != nil { + return "", false + } + + resolvedPath, err := filepath.EvalSymlinks(absPath) + if err != nil { + return "", false + } + + if !isSupportedSourceFile(resolvedPath) { + return "", false + } + + for _, sourceDir := range wa.sourceDirs { + if pathInDir(resolvedPath, sourceDir) { + return resolvedPath, true + } + } + + return "", false +} + +func pathInDir(path string, dir string) bool { + rel, err := filepath.Rel(dir, path) + if err != nil { + return false + } + if rel == "." { + return true + } + if rel == ".." { + return false + } + return !strings.HasPrefix(rel, ".."+string(filepath.Separator)) +} + +func isReadableFile(path string) bool { + info, err := os.Stat(path) + if err != nil { + return false + } + return info.Mode().IsRegular() +} + +func readSourceLines(path string, highlightLine int) (lines []sourceLine, truncated bool, err error) { + file, err := os.Open(path) + if err != nil { + return nil, false, err + } + defer func() { + if closeErr := file.Close(); closeErr != nil { + err = errors.Join(err, closeErr) + } + }() + + limitedReader := &io.LimitedReader{R: file, N: maxSourceBytes} + reader := bufio.NewScanner(limitedReader) + reader.Buffer(make([]byte, 0, 64*1024), 1024*1024) + + lines = make([]sourceLine, 0, 256) + for reader.Scan() { + if len(lines) >= maxSourceLines { + return lines, true, nil + } + + lineNumber := len(lines) + 1 + lines = append(lines, sourceLine{ + Number: lineNumber, + Content: reader.Text(), + IsHighlight: lineNumber == highlightLine, + }) + } + + if err := reader.Err(); err != nil { + return nil, false, err + } + + if limitedReader.N == 0 { + var nextByte [1]byte + n, readErr := file.Read(nextByte[:]) + if readErr != nil && !errors.Is(readErr, io.EOF) { + return nil, false, readErr + } + if n > 0 { + return lines, true, nil + } + } + + return lines, false, nil +} diff --git a/cmd/stackwhere/web_test.go b/cmd/stackwhere/web_test.go new file mode 100644 index 0000000..60aef36 --- /dev/null +++ b/cmd/stackwhere/web_test.go @@ -0,0 +1,275 @@ +package main + +import ( + "bytes" + "net/http/httptest" + "os" + "path/filepath" + "strings" + "testing" +) + +func TestStartupURL(t *testing.T) { + tests := []struct { + name string + addr string + want string + }{ + { + name: "host omitted", + addr: ":8080", + want: "http://127.0.0.1:8080", + }, + { + name: "explicit ipv4 host", + addr: "0.0.0.0:8080", + want: "http://0.0.0.0:8080", + }, + { + name: "explicit hostname", + addr: "localhost:9090", + want: "http://localhost:9090", + }, + { + name: "explicit ipv6 host", + addr: "[::1]:8080", + want: "http://[::1]:8080", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + if got := startupURL(tt.addr); got != tt.want { + t.Fatalf("startupURL(%q) = %q, want %q", tt.addr, got, tt.want) + } + }) + } +} + +func TestWebCommandRequiresCollectionArg(t *testing.T) { + cmd := root() + cmd.SetArgs([]string{"web"}) + + if err := cmd.Execute(); err == nil { + t.Fatalf("expected web command to fail when collection argument is missing") + } +} + +func TestWebHandlerServesLandingPage(t *testing.T) { + app, err := newWebApp("../../testdata/basic.o", nil) + if err != nil { + t.Fatalf("failed to initialize web app: %v", err) + } + + req := httptest.NewRequest("GET", "/", nil) + rr := httptest.NewRecorder() + app.handler().ServeHTTP(rr, req) + + if rr.Code != 200 { + t.Fatalf("unexpected status code: got %d want 200", rr.Code) + } + + body := rr.Body.String() + if !strings.Contains(body, "cil_entry") { + t.Fatalf("expected landing page to include program name, got %q", body) + } + if !strings.Contains(body, "56 bytes") { + t.Fatalf("expected landing page to include stack usage, got %q", body) + } +} + +func TestWebHandlerServesProgramPage(t *testing.T) { + app, err := newWebApp("../../testdata/basic.o", nil) + if err != nil { + t.Fatalf("failed to initialize web app: %v", err) + } + + req := httptest.NewRequest("GET", "/program/cil_entry", nil) + rr := httptest.NewRecorder() + app.handler().ServeHTTP(rr, req) + + if rr.Code != 200 { + t.Fatalf("unexpected status code: got %d want 200", rr.Code) + } + + body := rr.Body.String() + if !strings.Contains(body, "R10-0") { + t.Fatalf("expected program page to include stack offset group, got %q", body) + } + if !strings.Contains(body, "a") { + t.Fatalf("expected program page to include variable rows, got %q", body) + } + if !strings.Contains(body, "/source?file=") { + t.Fatalf("expected program page to include source location links, got %q", body) + } + if !strings.Contains(body, "Instructions") { + t.Fatalf("expected program page to include instructions pane, got %q", body) + } + if !strings.Contains(body, "Variable Lifetimes") { + t.Fatalf("expected program page to include lifetimes pane, got %q", body) + } + if !strings.Contains(body, "= lineCount { + t.Fatalf("expected fewer preview lines than full file, got %d lines out of %d", len(lines), lineCount) + } +} diff --git a/cmd/stackwhere/webassets/app.js b/cmd/stackwhere/webassets/app.js new file mode 100644 index 0000000..4d0f4c8 --- /dev/null +++ b/cmd/stackwhere/webassets/app.js @@ -0,0 +1,107 @@ +(function () { + var input = document.getElementById("search"); + var table = document.getElementById("program-table"); + if (!input || !table) { + return; + } + + var rows = Array.prototype.slice.call(table.querySelectorAll("tbody tr")); + input.addEventListener("input", function () { + var needle = input.value.trim().toLowerCase(); + rows.forEach(function (row) { + var text = row.textContent.toLowerCase(); + row.style.display = needle === "" || text.indexOf(needle) !== -1 ? "" : "none"; + }); + }); +})(); + +(function () { + var graphContainer = document.querySelector(".lifetimes-scroll"); + var instructionsPane = document.getElementById("instructions-pane-scroll"); + var instructionsDump = document.getElementById("instructions-dump"); + if (!graphContainer || !instructionsPane || !instructionsDump) { + return; + } + + var activeLine = null; + var dragging = false; + var suppressClick = false; + var startX = 0; + var startScrollLeft = 0; + + function onMouseMove(event) { + if (!dragging) { + return; + } + + var dx = event.clientX - startX; + if (Math.abs(dx) > 2) { + suppressClick = true; + } + + graphContainer.scrollLeft = startScrollLeft - dx; + event.preventDefault(); + } + + function stopDragging() { + if (!dragging) { + return; + } + + dragging = false; + graphContainer.classList.remove("lifetimes-scroll-dragging"); + window.removeEventListener("mousemove", onMouseMove); + window.removeEventListener("mouseup", stopDragging); + } + + graphContainer.addEventListener("mousedown", function (event) { + if (event.button !== 0) { + return; + } + + dragging = true; + suppressClick = false; + startX = event.clientX; + startScrollLeft = graphContainer.scrollLeft; + graphContainer.classList.add("lifetimes-scroll-dragging"); + + window.addEventListener("mousemove", onMouseMove); + window.addEventListener("mouseup", stopDragging); + event.preventDefault(); + }); + + graphContainer.addEventListener("click", function (event) { + if (suppressClick) { + suppressClick = false; + return; + } + + var target = event.target; + if (!(target instanceof Element)) { + return; + } + + var dot = target.closest(".lifetime-dot"); + if (!dot) { + return; + } + + var raw = dot.getAttribute("data-raw"); + if (!raw) { + return; + } + + var line = instructionsDump.querySelector('.instruction-line[data-raw="' + raw + '"]'); + if (!line) { + return; + } + + if (activeLine) { + activeLine.classList.remove("instruction-line-active"); + } + line.classList.add("instruction-line-active"); + activeLine = line; + + line.scrollIntoView({ behavior: "smooth", block: "center", inline: "nearest" }); + }); +})(); diff --git a/cmd/stackwhere/webassets/index.html b/cmd/stackwhere/webassets/index.html new file mode 100644 index 0000000..ec8f376 --- /dev/null +++ b/cmd/stackwhere/webassets/index.html @@ -0,0 +1,46 @@ + + + + + + + stackwhere web + + + + +
+
+

Stackwhere

+

Programs and peak stack usage

+
+ +
+
+ + +
+ + + + + + + + + + {{range .Programs}} + + + + + {{end}} + +
ProgramPeak Stack
{{.Name}}{{.StackUsage}} bytes
+
+
+ + + + + diff --git a/cmd/stackwhere/webassets/program.html b/cmd/stackwhere/webassets/program.html new file mode 100644 index 0000000..9ac5b13 --- /dev/null +++ b/cmd/stackwhere/webassets/program.html @@ -0,0 +1,85 @@ + + + + + + + {{.ProgramName}} - stackwhere + + + + + +
+
+

{{.ProgramName}}

+

Per-program stack usage

+ Back to programs +
+ +
+
+
+

Variables At Stack Slots

+
+
+ {{range .Groups}} +
+

R10-{{.Offset}}

+ + + + + + + + + + {{range .Entries}} + + + + + + {{end}} + +
SizeNameLocation
{{.Size}}{{.Name}} + {{if .SourceURL}} + {{.FileLineCol}} + {{else}} + {{.FileLineCol}} + {{end}} +
+
+ {{end}} +
+
+
+ +
+

Instructions

+
+
{{- range .InstructionLines }} +
{{if and .IsComment + .SourceURL}}{{.CommentIndent}}{{.CommentText}}{{else}}{{.Text}}{{end}}
{{- end + }} +
+
+
+
+ +
+

Variable Lifetimes

+
+ {{.LifetimesSVG}} +
+
+
+
+ + + diff --git a/cmd/stackwhere/webassets/source.html b/cmd/stackwhere/webassets/source.html new file mode 100644 index 0000000..9629ad2 --- /dev/null +++ b/cmd/stackwhere/webassets/source.html @@ -0,0 +1,59 @@ + + + + + + + {{.RequestPath}} - source - stackwhere + + + + +
+
+

{{.RequestPath}}

+

Source view

+ Back to programs +
+ + {{if .Found}} +
+

Resolved path: {{.ResolvedPath}}

+ {{if .Truncated}} +

Showing a truncated preview of this file.

+ {{end}} +
{{- range .Lines -}}{{.Number}}{{.Content}}{{- end -}}
+
+ {{else}} +
+

Source file not found

+

Could not locate the requested source path in configured source directories.

+

Requested path: {{.RequestPath}}

+

Add one or more --source-dir flags when starting stackwhere web to allow access + to additional source locations.

+

Configured source directories:

+
    + {{range .SourceDirs}} +
  • {{.}}
  • + {{else}} +
  • (none)
  • + {{end}} +
+
+ {{end}} +
+ + + + + diff --git a/cmd/stackwhere/webassets/style.css b/cmd/stackwhere/webassets/style.css new file mode 100644 index 0000000..50fd103 --- /dev/null +++ b/cmd/stackwhere/webassets/style.css @@ -0,0 +1,256 @@ +:root { + --bg: #f3f4ef; + --panel: #ffffff; + --ink: #1a1d1a; + --accent: #0d7c66; + --grid: #d8ddd4; + --muted: #617067; +} + +* { + box-sizing: border-box; +} + +body { + margin: 0; + color: var(--ink); + background: radial-gradient(circle at top right, #dcefe7 0%, var(--bg) 45%); + font-family: "Iosevka Aile", "Source Sans 3", "DejaVu Sans", sans-serif; +} + +.container { + width: min(1000px, 92vw); + margin: 2rem auto 3rem; +} + +.container-wide { + width: min(1800px, 98vw); +} + +.hero { + margin-bottom: 1.25rem; +} + +.hero h1 { + margin: 0; + font-size: clamp(1.8rem, 2.8vw, 2.6rem); + letter-spacing: 0.01em; +} + +.hero p { + margin: 0.35rem 0 0; + color: var(--muted); +} + +.back-link { + display: inline-block; + margin-top: 0.9rem; + color: var(--accent); + text-decoration: none; +} + +.panel, +.slot-group { + background: var(--panel); + border: 1px solid var(--grid); + border-radius: 12px; + padding: 1rem; + box-shadow: 0 8px 20px rgba(26, 29, 26, 0.05); +} + +.toolbar { + display: flex; + align-items: center; + gap: 0.75rem; + margin-bottom: 1rem; +} + +.toolbar input { + width: min(360px, 100%); + border: 1px solid var(--grid); + border-radius: 8px; + padding: 0.5rem 0.7rem; +} + +table { + width: 100%; + border-collapse: collapse; +} + +th, +td { + text-align: left; + padding: 0.55rem; + border-bottom: 1px solid var(--grid); +} + +a { + color: var(--accent); +} + +.slots { + display: grid; + gap: 0.85rem; +} + +.slot-group h2 { + margin: 0 0 0.65rem; + font-size: 1.1rem; +} + +.slot-group h3 { + margin: 0 0 0.65rem; + font-size: 1rem; +} + +.program-layout { + display: grid; + gap: 1rem; +} + +.program-top-row { + display: grid; + gap: 1rem; + grid-template-columns: minmax(0, 1fr) minmax(0, 1fr); +} + +.program-pane { + display: grid; + gap: 0.75rem; + min-width: 0; +} + +.program-pane h2, +.lifetimes-panel h2 { + margin: 0; + font-size: 1.1rem; +} + +.pane-scroll { + max-height: 58vh; + overflow: auto; + min-height: 10rem; +} + +.instructions-dump { + margin: 0; + padding: 0.75rem; + border: 1px solid var(--grid); + border-radius: 10px; + background: #f8faf8; + font-family: "Iosevka", "Iosevka Aile", "Source Code Pro", monospace; + font-size: 0.84rem; + line-height: 1.35; + white-space: normal; + min-width: max-content; +} + +.instruction-line { + white-space: pre; + border-radius: 4px; +} + +.instruction-comment-link { + color: var(--accent); + text-decoration: underline; +} + +.instruction-line-active { + background: #d9f3e9; +} + +.lifetime-dot { + cursor: pointer; +} + +.lifetime-dot:hover { + stroke: #0d7c66; + stroke-width: 2; +} + +.lifetimes-panel { + display: grid; + gap: 0.75rem; +} + +.lifetimes-scroll { + overflow-x: auto; + overflow-y: hidden; + border: 1px solid var(--grid); + border-radius: 10px; + background: #fff; + padding: 0.6rem; + cursor: grab; +} + +.lifetimes-scroll-dragging { + cursor: grabbing; + user-select: none; +} + +.lifetimes-scroll svg { + display: block; +} + +.source-panel { + overflow: hidden; +} + +.source-meta { + margin: 0 0 0.75rem; + color: var(--muted); + word-break: break-all; +} + +.source-warning { + margin: 0 0 0.9rem; + color: #7b5f00; +} + +.source-code { + margin: 0; + border: 1px solid var(--grid); + border-radius: 10px; + background: #f8faf8; + overflow-x: auto; + padding: 0.65rem 0; +} + +.source-line { + display: grid; + grid-template-columns: 4rem 1fr; + gap: 0.65rem; + padding: 0 0.85rem; + white-space: pre; + line-height: 1.25; +} + +.source-line-number { + color: var(--muted); + text-align: right; + user-select: none; +} + +.source-line-highlight { + background: #d9f3e9; +} + +@media (max-width: 700px) { + th, + td { + font-size: 0.92rem; + } + + .toolbar { + align-items: stretch; + flex-direction: column; + } + + .program-top-row { + grid-template-columns: 1fr; + } + + .pane-scroll { + max-height: 45vh; + } +} diff --git a/internal/stackview/stackview.go b/internal/stackview/stackview.go new file mode 100644 index 0000000..1481089 --- /dev/null +++ b/internal/stackview/stackview.go @@ -0,0 +1,504 @@ +// SPDX-License-Identifier: Apache-2.0 +// Copyright Authors of Cilium + +package stackview + +import ( + "cmp" + "errors" + "fmt" + "maps" + "slices" + "strings" + + "github.com/cilium/ebpf" + "github.com/cilium/ebpf/asm" + "github.com/cilium/ebpf/btf" + dbgdwarf "github.com/cilium/stackwhere/internal/dwarf" + "github.com/cilium/stackwhere/internal/dwarf/op" +) + +// ErrFunctionNotFoundInCollection indicates a function was present in DWARF but not in the eBPF collection spec. +var ErrFunctionNotFoundInCollection = errors.New("function not found in eBPF collection") + +// Analyzer provides stack usage information for one collection file. +type Analyzer struct { + collectionPath string + tree *dbgdwarf.Tree + functions map[string]bpfFn +} + +type bpfFn struct { + fn *btf.Func + insns asm.Instructions +} + +// ProgramStackUsage holds peak stack usage for one program. +type ProgramStackUsage struct { + Name string `json:"name"` + StackUsage int64 `json:"stack_usage"` +} + +// CallStackEntry describes one call frame for a stack slot. +type CallStackEntry struct { + Name string `json:"name"` + FileLineCol string `json:"file_line_col"` +} + +// SlotUsage describes one variable/register use mapped to a stack offset. +type SlotUsage struct { + Offset int64 `json:"offset"` + Name string `json:"name"` + ByteSize int64 `json:"byte_size"` + FileLineCol string `json:"file_line_col"` + Callstack []CallStackEntry `json:"callstack,omitempty"` +} + +func (s SlotUsage) displayEqual(other SlotUsage) bool { + return s.Name == other.Name && s.ByteSize == other.ByteSize && s.FileLineCol == other.FileLineCol +} + +// DisplayEqual reports whether two entries render as the same line in non-callstack views. +func (s SlotUsage) DisplayEqual(other SlotUsage) bool { + return s.displayEqual(other) +} + +type slotList [][]SlotUsage + +func (s slotList) Add(slot SlotUsage) slotList { + i, found := slices.BinarySearchFunc(s, []SlotUsage{slot}, func(a, b []SlotUsage) int { + return cmp.Compare(a[0].Offset, b[0].Offset) + }) + if found { + s[i] = append(s[i], slot) + } else { + s = slices.Insert(s, i, []SlotUsage{slot}) + } + return s +} + +// NewAnalyzer parses DWARF data from a collection path. +func NewAnalyzer(collectionPath string) (*Analyzer, error) { + tree, err := dbgdwarf.NewDWARFTree(collectionPath) + if err != nil { + return nil, fmt.Errorf("failed to parse DWARF data: %w", err) + } + + return &Analyzer{ + collectionPath: collectionPath, + tree: tree, + }, nil +} + +// CollectionSummary returns peak stack usage for all BPF programs in the collection. +func (a *Analyzer) CollectionSummary() []ProgramStackUsage { + stackUsagePerProgram := map[string]int64{} + for _, prog := range a.tree.ByType(dbgdwarf.TagSubprogram) { + if !isBPFProgram(prog) { + continue + } + + stackUsagePerProgram[prog.Name()] = getProgramStackUsage(prog) + } + + return SortProgramStackUsage(stackUsagePerProgram) +} + +// CollectionSummaryInCollection returns peak stack usage for BPF programs that +// are also present in the loaded eBPF collection. +func (a *Analyzer) CollectionSummaryInCollection() ([]ProgramStackUsage, error) { + if err := a.loadFunctions(); err != nil { + return nil, err + } + + summary := a.CollectionSummary() + filtered := make([]ProgramStackUsage, 0, len(summary)) + for _, prog := range summary { + if fn, ok := a.functions[prog.Name]; ok && fn.fn != nil { + filtered = append(filtered, prog) + } + } + + return filtered, nil +} + +// ProgramDetails returns grouped stack slot usage for a single program. +func (a *Analyzer) ProgramDetails(functionName string) ([][]SlotUsage, error) { + if err := a.loadFunctions(); err != nil { + return nil, err + } + + subProgsDwarf := a.tree.ByType(dbgdwarf.TagSubprogram) + subProgDwarfIdx := slices.IndexFunc(subProgsDwarf, func(n *dbgdwarf.Node) bool { + return n.Name() == functionName + }) + if subProgDwarfIdx == -1 { + return nil, fmt.Errorf("function %q not found in DWARF data", functionName) + } + + subProgDwarf := subProgsDwarf[subProgDwarfIdx] + subProg := a.functions[functionName] + if subProg.fn == nil { + return nil, fmt.Errorf("%w: %q", ErrFunctionNotFoundInCollection, functionName) + } + + usage := stackSlotsFromDWARFVars(subProgDwarf) + usage = append(usage, stackSlotsFromInsns(subProg, subProgDwarf)...) + usage = normalizeSlotUsage(usage) + + return usage, nil +} + +func (a *Analyzer) loadFunctions() error { + if a.functions != nil { + return nil + } + + coll, err := ebpf.LoadCollectionSpec(a.collectionPath) + if err != nil { + return fmt.Errorf("failed to load eBPF collection: %w", err) + } + + fns := make(map[string]bpfFn) + for _, prog := range coll.Programs { + var cur bpfFn + iter := prog.Instructions.Iterate() + for iter.Next() { + if fn := btf.FuncMetadata(iter.Ins); fn != nil { + if cur.fn != nil { + fns[cur.fn.Name] = cur + } + + cur.fn = fn + cur.insns = asm.Instructions{} + } + + cur.insns = append(cur.insns, *iter.Ins) + } + + if cur.fn != nil { + fns[cur.fn.Name] = cur + } + } + + a.functions = fns + return nil +} + +func normalizeSlotUsage(usage slotList) slotList { + // Sort outer array by offset. + slices.SortFunc(usage, func(a, b []SlotUsage) int { + return cmp.Compare(a[0].Offset, b[0].Offset) + }) + + // Merge inner arrays with the same offset. + for i := range slices.Backward(usage) { + if i == 0 { + break + } + + if usage[i][0].Offset == usage[i-1][0].Offset { + usage[i-1] = append(usage[i-1], usage[i]...) + usage = slices.Delete(usage, i, i+1) + } + } + + // Sort inner arrays by size (largest first), name, then source location; and dedupe full entries. + for i := range usage { + slices.SortFunc(usage[i], func(a, b SlotUsage) int { + sz := cmp.Compare(b.ByteSize, a.ByteSize) + if sz != 0 { + return sz + } + + name := strings.Compare(a.Name, b.Name) + if name != 0 { + return name + } + + return strings.Compare(a.FileLineCol, b.FileLineCol) + }) + + usage[i] = slices.CompactFunc(usage[i], slotUsageEqual) + } + + return usage +} + +func slotUsageEqual(a, b SlotUsage) bool { + return a.Name == b.Name && a.ByteSize == b.ByteSize && a.FileLineCol == b.FileLineCol && slices.Equal(a.Callstack, b.Callstack) +} + +// SortProgramStackUsage returns usage sorted by bytes descending and name ascending. +func SortProgramStackUsage(stackUsagePerProgram map[string]int64) []ProgramStackUsage { + out := make([]ProgramStackUsage, 0, len(stackUsagePerProgram)) + for _, prog := range slices.Collect(maps.Keys(stackUsagePerProgram)) { + out = append(out, ProgramStackUsage{ + Name: prog, + StackUsage: stackUsagePerProgram[prog], + }) + } + + slices.SortFunc(out, func(a, b ProgramStackUsage) int { + sz := cmp.Compare(b.StackUsage, a.StackUsage) + if sz != 0 { + return sz + } + + return strings.Compare(a.Name, b.Name) + }) + + return out +} + +func getProgramStackUsage(prog *dbgdwarf.Node) int64 { + largestOffset := int64(0) + lastSize := int64(0) + dbgdwarf.VisitPrefixOrder(prog, func(n *dbgdwarf.Node) { + if n.Entry().Tag != dbgdwarf.TagVariable && n.Entry().Tag != dbgdwarf.TagFormalParameter { + return + } + + offsets := stackOffsets(n) + if len(offsets) == 0 { + return + } + + sz := n.ByteSize() + for _, offset := range offsets { + if offset > largestOffset { + largestOffset = offset + lastSize = sz + } + if offset == largestOffset && sz > lastSize { + lastSize = sz + } + } + }) + + stackUsage := largestOffset + lastSize + if stackUsage%8 != 0 { + stackUsage = ((stackUsage / 8) + 1) * 8 + } + + return stackUsage +} + +func isBPFProgram(n *dbgdwarf.Node) bool { + if n.Entry().Tag != dbgdwarf.TagSubprogram { + return false + } + + if n.Entry().Val(dbgdwarf.AttrName) == nil { + return false + } + + if n.Entry().Val(dbgdwarf.AttrInline) != nil { + return false + } + + if n.Entry().Val(dbgdwarf.AttrType) == nil { + return false + } + + return true +} + +func stackSlotsFromDWARFVars(progDwarf *dbgdwarf.Node) slotList { + result := slotList{} + + stackMap := map[int64][]*dbgdwarf.Node{} + dbgdwarf.VisitPrefixOrder(progDwarf, func(n *dbgdwarf.Node) { + if n.Entry().Tag != dbgdwarf.TagVariable && n.Entry().Tag != dbgdwarf.TagFormalParameter { + return + } + + offsets := stackOffsets(n) + if len(offsets) > 0 { + for _, offset := range offsets { + if !slices.Contains(stackMap[offset], n) { + stackMap[offset] = append(stackMap[offset], n) + } + } + } + }) + + for offset, nodes := range stackMap { + slices.SortFunc(nodes, func(a, b *dbgdwarf.Node) int { + sz := cmp.Compare(b.ByteSize(), a.ByteSize()) + if sz != 0 { + return sz + } + + return strings.Compare(a.Name(), b.Name()) + }) + + for _, n := range nodes { + callstack := []CallStackEntry{} + + parents := []*dbgdwarf.Node{} + p := n + for p != nil { + p = p.Parent() + parents = append(parents, p) + if p == progDwarf { + break + } + } + for _, parent := range parents { + if parent.Name() == "" { + continue + } + fileLineCol := parent.CallFileLineCol() + if fileLineCol == "" { + fileLineCol = parent.FileLineCol() + } + + callstack = append(callstack, CallStackEntry{ + Name: parent.Name(), + FileLineCol: fileLineCol, + }) + } + + fileLineCol := n.CallFileLineCol() + if fileLineCol == "" { + fileLineCol = n.FileLineCol() + } + + usage := SlotUsage{ + Offset: offset, + Name: n.Name(), + ByteSize: n.ByteSize(), + FileLineCol: fileLineCol, + Callstack: callstack, + } + + result = result.Add(usage) + } + } + + return result +} + +func stackOffsets(n *dbgdwarf.Node) []int64 { + offsets := []int64{} + + if location, err := n.Location(); err == nil && location != nil { + for _, locOp := range location { + if locOp.Opcode == op.DW_OP_fbreg { + if !slices.Contains(offsets, locOp.Args[0].(int64)) { + offsets = append(offsets, locOp.Args[0].(int64)) + } + } + } + } else if locList, err := n.LocationList(); err == nil && locList != nil { + for _, entry := range locList.Entries() { + switch e := entry.(type) { + case dbgdwarf.LLEOffsetPair: + for _, locOp := range e.Ops() { + if locOp.Opcode == op.DW_OP_fbreg || locOp.Opcode == op.DW_OP_breg10 { + if !slices.Contains(offsets, locOp.Args[0].(int64)) { + offsets = append(offsets, locOp.Args[0].(int64)) + } + } + } + } + } + } + + slices.Sort(offsets) + return slices.Compact(offsets) +} + +// Find stack slot usage by looking at the instructions and enhance with DWARF information. +func stackSlotsFromInsns(fn bpfFn, progDbg *dbgdwarf.Node) slotList { + i2n := instructionToNodes(progDbg) + + var result slotList + + iter := fn.insns.Iterate() + for iter.Next() { + if iter.Ins.Src == asm.R10 && iter.Ins.OpCode.ALUOp() == asm.Mov { + nextIdx := iter.Index + 1 + if nextIdx >= len(fn.insns) { + continue + } + nextInsn := fn.insns[nextIdx] + if nextInsn.OpCode.ALUOp() != asm.Add || nextInsn.Dst != iter.Ins.Dst || nextInsn.Constant >= 0 { + continue + } + + byteOff := uint64(iter.Offset * asm.InstructionSize) + + var line *btf.Line + for j := iter.Index; j < len(fn.insns); j++ { + ins := fn.insns[j] + if lo, ok := ins.Source().(*btf.Line); ok && lo.LineNumber() != 0 { + line = lo + break + } + } + + fileCol := "" + if line != nil { + fileCol = line.FileName() + ":" + fmt.Sprint(line.LineNumber()) + } + + usage := SlotUsage{ + Offset: -nextInsn.Constant, + Name: iter.Ins.Dst.String(), + ByteSize: -1, + FileLineCol: fileCol, + } + for _, n := range i2n[byteOff] { + if n.Name() == "" && n.FileLineCol() == "" { + continue + } + + fileLineCol := n.CallFileLineCol() + if fileLineCol == "" { + fileLineCol = n.FileLineCol() + } + + usage.Callstack = append(usage.Callstack, CallStackEntry{ + Name: n.Name(), + FileLineCol: fileLineCol, + }) + } + + result = result.Add(usage) + } + } + + return result +} + +func instructionToNodes(prog *dbgdwarf.Node) map[uint64][]*dbgdwarf.Node { + instRange := make(map[uint64][]*dbgdwarf.Node) + + progInsOffset := prog.Entry().Val(dbgdwarf.AttrLowpc).(uint64) + + dbgdwarf.VisitPrefixOrder(prog, func(n *dbgdwarf.Node) { + lowpc := n.Entry().Val(dbgdwarf.AttrLowpc) + highpc := n.Entry().Val(dbgdwarf.AttrHighpc) + if lowpc != nil && highpc != nil { + for i := lowpc.(uint64); i < lowpc.(uint64)+uint64(highpc.(int64)); i += asm.InstructionSize { + instRange[i-progInsOffset] = append(instRange[i-progInsOffset], n) + } + } + + if rng, err := n.Ranges(); err == nil { + for _, r := range rng.Ranges { + for i := r.Start; i < r.End; i += asm.InstructionSize { + instRange[i-progInsOffset] = append(instRange[i-progInsOffset], n) + } + } + } + }) + + for _, nodes := range instRange { + slices.Reverse(nodes) + } + + return instRange +}