Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
51 changes: 39 additions & 12 deletions docs/design/benchmark-data-map.md
Original file line number Diff line number Diff line change
Expand Up @@ -30,11 +30,23 @@ Schema types and builder live in [`internal/datamap`](../../internal/datamap/).
## Recommended reading flow

1. **measurements** — confirm benchmark ran; headline ns/op and allocs
2. **hotspots** — find top symbols (`top_symbols` in map)
3. **call_trees** — understand call path (`hot_path_summary`)
4. **source_lines** — line-level detail for chosen symbols
2. **hotspots** — open linked `hotspots/*.txt` for flat/cum rankings (`profile_cost_columns` explains columns)
3. **call_trees** — open linked `call_trees/*.txt` for caller/callee context
4. **source_lines** — line-level detail for chosen symbols (path index only in map.json)
5. **profiles** — raw binary only if deeper pprof queries needed

## Mapping vs metrics

`map.json` is an **artifact index**, not a copy of profile sample data.

| Section | map.json holds | Metrics live in |
| --- | --- | --- |
| `hotspots` | Path to `-top` text + column glossary | `hotspots/<benchmark>/<profile>.txt` |
| `source_lines.functions` | Path, `full_symbol`, `status` | Prior hotspots read; line detail in linked `.txt` |
| `profiles` | Path + profile total (orientation) | Raw `.out` binary |

Do not expect `flat`/`cum` on `source_lines` entries — agents reach source_lines after choosing a symbol from hotspots.

## Example (truncated)

```json
Expand All @@ -59,14 +71,7 @@ Schema types and builder live in [`internal/datamap`](../../internal/datamap/).
"path": "hotspots/BenchmarkDataGeneration/cpu.txt",
"purpose": "flat_and_cumulative_ranking",
"producer": "go tool pprof -top",
"top_symbols": [
{
"rank": 1,
"symbol": "github.com/example/benchmarks/utils.(*DataGenerator).GenerateStrings",
"flat_pct": 1.28,
"cum_pct": 70.53
}
]
"hotspots_metrics_note": "flat/cum rankings and sample values are in the hotspots text at path..."
}
},
"call_trees": {
Expand Down Expand Up @@ -103,6 +108,28 @@ Schema types and builder live in [`internal/datamap`](../../internal/datamap/).
}
```

## Profile cost columns (flat / cum)

`go tool pprof -top` prints five metric columns. Read them in `hotspots/*.txt`; `profile_cost_columns` in map.json explains each column:

| Column | Meaning |
| --- | --- |
| `flat` | Cost in this function's own code only (excludes callees). CPU: time in the function body; memory: bytes allocated there. |
| `flat%` | `flat` as % of total profile samples. |
| `sum%` | Running sum of `flat%` reading the `-top` table top to bottom. |
| `cum` | Cost in this function plus all functions it called. CPU: seconds; memory: bytes including callees. |
| `cum%` | `cum` as % of total profile samples. |

Each emitted `map.json` includes `profile_cost_columns` and `profile_cost_triage` so agents can interpret the hotspots text file:

> High flat: optimize this function's body. High cum but low flat: work is mostly in callees — check call_trees or child symbols.

## Sample units and display fields

`flat` / `cum` / `total_samples` on the **profiles** section are raw profile totals in the pprof sample unit (nanoseconds for CPU, bytes for heap profiles). Per-function metrics are **not** duplicated in map.json — read `hotspots/*.txt` instead.

Display strings on **profiles** (`total_display`, `total_seconds`) match the `go tool pprof -top` header. Implementation: [`internal/pprofscale`](../../internal/pprofscale/).

## Invariants

- Same `FunctionListEntry` set as source_lines on disk (from `prof.json` filters).
Expand All @@ -114,7 +141,7 @@ Schema types and builder live in [`internal/datamap`](../../internal/datamap/).

**Positive:** Agents load one JSON file to triage before opening large text artifacts.

**Neutral:** map.json grows with function count; acceptable for v1.
**Neutral:** map.json size scales with function count (path index only); much smaller than duplicating per-function metrics.

## Out of scope (v2)

Expand Down
4 changes: 2 additions & 2 deletions engine/collect/datamap_emit_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -77,7 +77,7 @@ func TestEmitBenchmarkMap_writesMapJSON(t *testing.T) {
if len(m.SourceLines["cpu"].Functions) == 0 {
t.Fatal("expected source_lines functions")
}
if len(m.Hotspots["cpu"].TopSymbols) == 0 {
t.Fatal("expected top_symbols")
if m.Hotspots["cpu"].HotspotsMetricsNote == "" {
t.Fatal("expected hotspots_metrics_note")
}
}
110 changes: 49 additions & 61 deletions internal/datamap/build.go
Original file line number Diff line number Diff line change
Expand Up @@ -14,8 +14,6 @@ import (
)

const (
topSymbolLimit = 10
hotPathLimit = 5
statusOK = "ok"
statusSkipped = "skipped"
collectionAuto = "auto"
Expand All @@ -26,11 +24,20 @@ var (
defaultRecommendedFlow = []string{"measurements", "hotspots", "call_trees", "source_lines", "profiles"}
defaultReadingGuide = map[string]string{
"measurements": "Go benchmark output (ns/op, B/op, allocs/op). Start here to confirm the run succeeded.",
"hotspots": "pprof -top: functions ranked by flat and cumulative cost.",
"hotspots": "pprof -top text at path; flat/cum metrics live there, not in map.json. See profile_cost_columns.",
"call_trees": "pprof -tree: caller/callee context for top nodes.",
"source_lines": "pprof -list extracts per function; use on symbols chosen from hotspots.",
"source_lines": "pprof -list extract paths per function; open the linked .txt for line-level detail.",
"profiles": "Raw .out binaries; re-query with go tool pprof when text is insufficient.",
}
defaultProfileCostColumns = map[string]string{
"flat": "Cost in this function's own code only (excludes callees). CPU: seconds in the function body; memory: bytes allocated there.",
"flat_pct": "flat as % of total profile samples. Same as flat% in hotspots/*.txt; map.json field flat_pct.",
"sum_pct": "Running sum of flat% reading the -top table top to bottom. Column in hotspots/*.txt only (not stored in map.json).",
"cum": "Cost in this function plus all functions it called (transitive). CPU: seconds; memory: bytes including callees.",
"cum_pct": "cum as % of total profile samples. Same as cum% in hotspots/*.txt; map.json field cum_pct.",
}
defaultProfileCostTriage = "High flat: optimize this function's body. High cum but low flat: work is mostly in callees — check call_trees or child symbols."
hotspotsMetricsNote = "flat/cum rankings and sample values are in the hotspots text at path (go tool pprof -top). map.json links artifacts only; use profile_cost_columns when reading that file."
)

// ProfileSnapshot captures in-memory state for one profile kind at map emit time.
Expand Down Expand Up @@ -67,17 +74,19 @@ func Build(in BuildInput) (BenchmarkMap, error) {
}

m := BenchmarkMap{
SchemaVersion: SchemaVersion,
Tag: in.Tag,
Benchmark: in.Benchmark,
Package: in.Package,
RecommendedFlow: append([]string(nil), defaultRecommendedFlow...),
ReadingGuide: copyReadingGuide(),
Profiles: make(map[string]ProfileRef, len(in.Profiles)),
Hotspots: make(map[string]HotspotSection, len(in.Profiles)),
CallTrees: make(map[string]CallTreeSection, len(in.Profiles)),
SourceLines: make(map[string]SourceLinesSection, len(in.Profiles)),
CallGraphs: make(map[string]CallGraphRef, len(in.Profiles)),
SchemaVersion: SchemaVersion,
Tag: in.Tag,
Benchmark: in.Benchmark,
Package: in.Package,
RecommendedFlow: append([]string(nil), defaultRecommendedFlow...),
ReadingGuide: copyReadingGuide(),
ProfileCostColumns: copyProfileCostColumns(),
ProfileCostTriage: defaultProfileCostTriage,
Profiles: make(map[string]ProfileRef, len(in.Profiles)),
Hotspots: make(map[string]HotspotSection, len(in.Profiles)),
CallTrees: make(map[string]CallTreeSection, len(in.Profiles)),
SourceLines: make(map[string]SourceLinesSection, len(in.Profiles)),
CallGraphs: make(map[string]CallGraphRef, len(in.Profiles)),
Status: Status{
Profiles: make(map[string]string, len(in.Profiles)),
Hotspots: make(map[string]string, len(in.Profiles)),
Expand Down Expand Up @@ -127,6 +136,14 @@ func copyReadingGuide() map[string]string {
return out
}

func copyProfileCostColumns() map[string]string {
out := make(map[string]string, len(defaultProfileCostColumns))
for k, v := range defaultProfileCostColumns {
out[k] = v
}
return out
}

func (m *BenchmarkMap) addMeasurements(in BuildInput) error {
abs := in.Layout.Measurement(in.Benchmark)
rel, err := in.Layout.RelFromLayout(abs)
Expand Down Expand Up @@ -155,6 +172,15 @@ func (m *BenchmarkMap) addProfileArtifacts(in BuildInput, profile string, snap P
Purpose: PurposeRawPprofBinary,
Description: "Raw pprof profile binary; source of truth for go tool pprof.",
TotalSamples: snapTotal(snap.ProfileData),
SampleUnit: sampleUnit(snap.ProfileData),
}
if snap.ProfileData != nil {
display, sec, outUnit := profileTotalDisplay(snap.ProfileData)
ref := m.Profiles[profile]
ref.TotalDisplay = display
ref.TotalSeconds = sec
ref.OutputUnit = outUnit
m.Profiles[profile] = ref
}
m.Status.Profiles[profile] = statusOK

Expand All @@ -163,11 +189,11 @@ func (m *BenchmarkMap) addProfileArtifacts(in BuildInput, profile string, snap P
return err
}
m.Hotspots[profile] = HotspotSection{
Path: hotRel,
Purpose: PurposeFlatCumulativeRanking,
Description: "go tool pprof -top output: flat time in function body, cum time including callees.",
Producer: "go tool pprof -top",
TopSymbols: topSymbols(snap.ProfileData, topSymbolLimit),
Path: hotRel,
Purpose: PurposeFlatCumulativeRanking,
Description: "go tool pprof -top output: flat time in function body, cum time including callees.",
Producer: "go tool pprof -top",
HotspotsMetricsNote: hotspotsMetricsNote,
}
m.Status.Hotspots[profile] = statusOK

Expand All @@ -180,7 +206,6 @@ func (m *BenchmarkMap) addProfileArtifacts(in BuildInput, profile string, snap P
Purpose: PurposeCallerCalleeContext,
Description: "go tool pprof -tree output: caller/callee context for ranked nodes.",
Producer: "go tool pprof -tree",
HotPath: hotPathSummary(snap.ProfileData, hotPathLimit),
}
m.Status.CallTrees[profile] = statusOK

Expand Down Expand Up @@ -233,42 +258,11 @@ func snapTotal(d *parser.ProfileData) int64 {
return d.Total
}

func topSymbols(d *parser.ProfileData, limit int) []TopSymbol {
if d == nil || len(d.SortedEntries) == 0 {
return nil
}
n := limit
if len(d.SortedEntries) < n {
n = len(d.SortedEntries)
}
out := make([]TopSymbol, 0, n)
for i := range n {
entry := d.SortedEntries[i]
out = append(out, TopSymbol{
Rank: i + 1,
Symbol: entry.Name,
Flat: entry.Flat,
Cum: d.Cum[entry.Name],
FlatPct: d.FlatPercentages[entry.Name],
CumPct: d.CumPercentages[entry.Name],
})
}
return out
}

func hotPathSummary(d *parser.ProfileData, limit int) []string {
func sampleUnit(d *parser.ProfileData) string {
if d == nil {
return nil
}
n := limit
if len(d.SortedEntries) < n {
n = len(d.SortedEntries)
return ""
}
out := make([]string, 0, n)
for i := range n {
out = append(out, d.SortedEntries[i].Name)
}
return out
return d.SampleUnit
}

func functionRefs(in BuildInput, profile string, snap ProfileSnapshot) map[string]FunctionRef {
Expand All @@ -290,12 +284,6 @@ func functionRefs(in BuildInput, profile string, snap ProfileSnapshot) map[strin
FullSymbol: e.FullSymbol,
Status: status,
}
if snap.ProfileData != nil {
ref.Flat = snap.ProfileData.Flat[e.FullSymbol]
ref.Cum = snap.ProfileData.Cum[e.FullSymbol]
ref.FlatPct = snap.ProfileData.FlatPercentages[e.FullSymbol]
ref.CumPct = snap.ProfileData.CumPercentages[e.FullSymbol]
}
functions[e.OutputStem] = ref
}
if len(functions) == 0 {
Expand Down
10 changes: 8 additions & 2 deletions internal/datamap/build_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -60,8 +60,14 @@ func TestBuild_minimalCPUProfile(t *testing.T) {
if fn.FullSymbol != "main.work" || fn.Status != statusOK {
t.Fatalf("function ref=%+v", fn)
}
if len(m.Hotspots["cpu"].TopSymbols) != 1 {
t.Fatalf("top symbols=%d", len(m.Hotspots["cpu"].TopSymbols))
if m.Hotspots["cpu"].HotspotsMetricsNote == "" {
t.Fatal("expected hotspots_metrics_note on hotspot section")
}
if m.ProfileCostColumns["flat"] == "" || m.ProfileCostColumns["cum_pct"] == "" {
t.Fatal("expected profile_cost_columns glossary")
}
if m.ProfileCostTriage == "" {
t.Fatal("expected profile_cost_triage")
}
}

Expand Down
25 changes: 25 additions & 0 deletions internal/datamap/display.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
package datamap

import (
"github.com/AlexsanderHamir/prof/internal/pprofscale"
"github.com/AlexsanderHamir/prof/parser"
)

func profileOutputUnit(d *parser.ProfileData) string {
if d == nil || d.SampleUnit == "" {
return "auto"
}
return pprofscale.SelectOutputUnit(d.SampleUnit, d.Total, d.Flat, d.Cum)
}

func profileTotalDisplay(d *parser.ProfileData) (display string, seconds float64, outputUnit string) {
if d == nil || d.SampleUnit == "" {
return "", 0, ""
}
outputUnit = profileOutputUnit(d)
display = pprofscale.ScaledLabel(d.Total, d.SampleUnit, outputUnit)
if s, ok := pprofscale.Seconds(d.Total, d.SampleUnit); ok {
seconds = pprofscale.RoundSeconds(s)
}
return display, seconds, outputUnit
}
58 changes: 58 additions & 0 deletions internal/datamap/display_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,58 @@
package datamap

import (
"os"
"path/filepath"
"testing"

"github.com/AlexsanderHamir/prof/internal/pprofscale"
"github.com/AlexsanderHamir/prof/parser"
)

func cpuFixturePath(t *testing.T) string {
t.Helper()
for _, rel := range []string{
"tests/assets/fixtures/BenchmarkStringProcessor_cpu.out",
filepath.Join("..", "..", "tests", "assets", "fixtures", "BenchmarkStringProcessor_cpu.out"),
} {
if _, err := os.Stat(rel); err == nil {
return rel
}
}
t.Skip("cpu profile fixture not present")
return ""
}

// Display helpers must match go tool pprof -top for profile totals (used on profiles section).
func TestPprofDisplay_matchesPprofTop(t *testing.T) {
t.Parallel()
path := cpuFixturePath(t)
d, err := parser.DefaultPipeline().RunFromPath(path)
if err != nil {
t.Fatal(err)
}
if d.SampleUnit != "nanoseconds" {
t.Fatalf("sample_unit=%q want nanoseconds", d.SampleUnit)
}

want := map[string]string{
"crypto/internal/fips140/sha256.blockSHANI": "0.19s",
"runtime.memmove": "0.16s",
}
for sym, label := range want {
flat, ok := d.Flat[sym]
if !ok {
t.Fatalf("symbol %q missing from profile", sym)
}
outUnit := pprofscale.SelectOutputUnit(d.SampleUnit, d.Total, d.Flat, d.Cum)
got := pprofscale.ScaledLabel(flat, d.SampleUnit, outUnit)
if got != label {
t.Fatalf("%s flat display=%q want %q (raw=%d outUnit=%q)", sym, got, label, flat, outUnit)
}
}

display, sec, outUnit := profileTotalDisplay(d)
if display != "3.15s" || sec != 3.15 || outUnit != "s" {
t.Fatalf("total display=%q seconds=%v outUnit=%q want 3.15s/s", display, sec, outUnit)
}
}
Loading
Loading