github.com/grafana/pyroscope@v1.18.0/pkg/frontend/dot/report/report.go (about) 1 // Copyright 2014 Google Inc. All Rights Reserved. 2 // 3 // Licensed under the Apache License, Version 2.0 (the "License"); 4 // you may not use this file except in compliance with the License. 5 // You may obtain a copy of the License at 6 // 7 // http://www.apache.org/licenses/LICENSE-2.0 8 // 9 // Unless required by applicable law or agreed to in writing, software 10 // distributed under the License is distributed on an "AS IS" BASIS, 11 // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 12 // See the License for the specific language governing permissions and 13 // limitations under the License. 14 15 // Package report summarizes a performance profile into a 16 // human-readable report. 17 package report 18 19 import ( 20 "fmt" 21 "path/filepath" 22 "regexp" 23 "strings" 24 "time" 25 26 "github.com/google/pprof/profile" 27 28 "github.com/grafana/pyroscope/pkg/frontend/dot/graph" 29 "github.com/grafana/pyroscope/pkg/frontend/dot/measurement" 30 ) 31 32 // Options are the formatting and filtering options used to generate a 33 // profile. 34 type Options struct { 35 OutputFormat int 36 37 CumSort bool 38 CallTree bool 39 DropNegative bool 40 CompactLabels bool 41 Ratio float64 42 Title string 43 ProfileLabels []string 44 ActiveFilters []string 45 NumLabelUnits map[string]string 46 47 NodeCount int 48 NodeFraction float64 49 EdgeFraction float64 50 51 SampleValue func(s []int64) int64 52 SampleMeanDivisor func(s []int64) int64 53 SampleType string 54 SampleUnit string // Unit for the sample data from the profile. 55 56 OutputUnit string // Units for data formatting in report. 57 58 Symbol *regexp.Regexp // Symbols to include on disassembly report. 59 SourcePath string // Search path for source files. 60 TrimPath string // Paths to trim from source file paths. 61 62 IntelSyntax bool // Whether to print assembly in Intel syntax. 63 } 64 65 // newTrimmedGraph creates a graph for this report, trimmed according 66 // to the report options. 67 func (rpt *Report) newTrimmedGraph() (g *graph.Graph, origCount, droppedNodes, droppedEdges int) { 68 o := rpt.options 69 70 // Build a graph and refine it. On each refinement step we must rebuild the graph from the samples, 71 // as the graph itself doesn't contain enough information to preserve full precision. 72 cumSort := o.CumSort 73 74 // First step: Build complete graph to identify low frequency nodes, based on their cum weight. 75 g = rpt.newGraph(nil) 76 totalValue, _ := g.Nodes.Sum() 77 nodeCutoff := abs64(int64(float64(totalValue) * o.NodeFraction)) 78 edgeCutoff := abs64(int64(float64(totalValue) * o.EdgeFraction)) 79 80 // Filter out nodes with cum value below nodeCutoff. 81 if nodeCutoff > 0 { 82 if nodesKept := g.DiscardLowFrequencyNodes(nodeCutoff); len(g.Nodes) != len(nodesKept) { 83 droppedNodes = len(g.Nodes) - len(nodesKept) 84 g = rpt.newGraph(nodesKept) 85 } 86 } 87 origCount = len(g.Nodes) 88 89 // Second step: Limit the total number of nodes. Apply specialized heuristics to improve 90 // visualization when generating dot output. 91 g.SortNodes(cumSort, true) 92 if nodeCount := o.NodeCount; nodeCount > 0 { 93 // Remove low frequency tags and edges as they affect selection. 94 g.TrimLowFrequencyTags(nodeCutoff) 95 g.TrimLowFrequencyEdges(edgeCutoff) 96 if nodesKept := g.SelectTopNodes(nodeCount, true); len(g.Nodes) != len(nodesKept) { 97 g = rpt.newGraph(nodesKept) 98 g.SortNodes(cumSort, true) 99 } 100 } 101 102 // Final step: Filter out low frequency tags and edges, and remove redundant edges that clutter 103 // the graph. 104 g.TrimLowFrequencyTags(nodeCutoff) 105 droppedEdges = g.TrimLowFrequencyEdges(edgeCutoff) 106 g.RemoveRedundantEdges() 107 return 108 } 109 110 func (rpt *Report) selectOutputUnit(g *graph.Graph) { 111 o := rpt.options 112 113 // Select best unit for profile output. 114 // Find the appropriate units for the smallest non-zero sample 115 if o.OutputUnit != "minimum" || len(g.Nodes) == 0 { 116 return 117 } 118 var minValue int64 119 120 for _, n := range g.Nodes { 121 nodeMin := abs64(n.FlatValue()) 122 if nodeMin == 0 { 123 nodeMin = abs64(n.CumValue()) 124 } 125 if nodeMin > 0 && (minValue == 0 || nodeMin < minValue) { 126 minValue = nodeMin 127 } 128 } 129 maxValue := rpt.total 130 if minValue == 0 { 131 minValue = maxValue 132 } 133 134 if r := o.Ratio; r > 0 && r != 1 { 135 minValue = int64(float64(minValue) * r) 136 maxValue = int64(float64(maxValue) * r) 137 } 138 139 _, minUnit := measurement.Scale(minValue, o.SampleUnit, "minimum") 140 _, maxUnit := measurement.Scale(maxValue, o.SampleUnit, "minimum") 141 142 unit := minUnit 143 if minUnit != maxUnit && minValue*100 < maxValue { 144 // Minimum and maximum values have different units. Scale 145 // minimum by 100 to use larger units, allowing minimum value to 146 // be scaled down to 0.01, except for callgrind reports since 147 // they can only represent integer values. 148 _, unit = measurement.Scale(100*minValue, o.SampleUnit, "minimum") 149 } 150 151 if unit != "" { 152 o.OutputUnit = unit 153 } else { 154 o.OutputUnit = o.SampleUnit 155 } 156 } 157 158 // newGraph creates a new graph for this report. If nodes is non-nil, 159 // only nodes whose info matches are included. Otherwise, all nodes 160 // are included, without trimming. 161 func (rpt *Report) newGraph(nodes graph.NodeSet) *graph.Graph { 162 o := rpt.options 163 164 // Clean up file paths using heuristics. 165 prof := rpt.prof 166 for _, f := range prof.Function { 167 f.Filename = trimPath(f.Filename, o.TrimPath, o.SourcePath) 168 } 169 // Removes all numeric tags except for the bytes tag prior 170 // to making graph. 171 // TODO: modify to select first numeric tag if no bytes tag 172 for _, s := range prof.Sample { 173 numLabels := make(map[string][]int64, len(s.NumLabel)) 174 numUnits := make(map[string][]string, len(s.NumLabel)) 175 for k, vs := range s.NumLabel { 176 if k == "bytes" { 177 unit := o.NumLabelUnits[k] 178 numValues := make([]int64, len(vs)) 179 numUnit := make([]string, len(vs)) 180 for i, v := range vs { 181 numValues[i] = v 182 numUnit[i] = unit 183 } 184 numLabels[k] = append(numLabels[k], numValues...) 185 numUnits[k] = append(numUnits[k], numUnit...) 186 } 187 } 188 s.NumLabel = numLabels 189 s.NumUnit = numUnits 190 } 191 192 // Remove label marking samples from the base profiles, so it does not appear 193 // as a nodelet in the graph view. 194 prof.RemoveLabel("pprof::base") 195 196 formatTag := func(v int64, key string) string { 197 return measurement.ScaledLabel(v, key, o.OutputUnit) 198 } 199 200 gopt := &graph.Options{ 201 SampleValue: o.SampleValue, 202 SampleMeanDivisor: o.SampleMeanDivisor, 203 FormatTag: formatTag, 204 CallTree: false, 205 DropNegative: o.DropNegative, 206 KeptNodes: nodes, 207 } 208 209 return graph.New(rpt.prof, gopt) 210 } 211 212 // TextItem holds a single text report entry. 213 type TextItem struct { 214 Name string 215 InlineLabel string // Not empty if inlined 216 Flat, Cum int64 // Raw values 217 FlatFormat, CumFormat string // Formatted values 218 } 219 220 // GetDOT returns a graph suitable for dot processing along with some 221 // configuration information. 222 func GetDOT(rpt *Report) (*graph.Graph, *graph.DotConfig) { 223 g, origCount, droppedNodes, droppedEdges := rpt.newTrimmedGraph() 224 rpt.selectOutputUnit(g) 225 labels := reportLabels(rpt, g, origCount, droppedNodes, droppedEdges, true) 226 227 c := &graph.DotConfig{ 228 Title: rpt.options.Title, 229 Labels: labels, 230 FormatValue: rpt.formatValue, 231 Total: rpt.total, 232 } 233 return g, c 234 } 235 236 // ProfileLabels returns printable labels for a profile. 237 func ProfileLabels(rpt *Report) []string { 238 var label []string 239 prof := rpt.prof 240 o := rpt.options 241 if len(prof.Mapping) > 0 { 242 if prof.Mapping[0].File != "" { 243 label = append(label, "File: "+filepath.Base(prof.Mapping[0].File)) 244 } 245 if prof.Mapping[0].BuildID != "" { 246 label = append(label, "Build ID: "+prof.Mapping[0].BuildID) 247 } 248 } 249 // Only include comments that do not start with '#'. 250 for _, c := range prof.Comments { 251 if !strings.HasPrefix(c, "#") { 252 label = append(label, c) 253 } 254 } 255 if o.SampleType != "" { 256 label = append(label, "Type: "+o.SampleType) 257 } 258 if prof.TimeNanos != 0 { 259 const layout = "Jan 2, 2006 at 3:04pm (MST)" 260 label = append(label, "Time: "+time.Unix(0, prof.TimeNanos).Format(layout)) 261 } 262 if prof.DurationNanos != 0 { 263 duration := measurement.Label(prof.DurationNanos, "nanoseconds") 264 totalNanos, totalUnit := measurement.Scale(rpt.total, o.SampleUnit, "nanoseconds") 265 var ratio string 266 if totalUnit == "ns" && totalNanos != 0 { 267 ratio = "(" + measurement.Percentage(int64(totalNanos), prof.DurationNanos) + ")" 268 } 269 label = append(label, fmt.Sprintf("Duration: %s, Total samples = %s %s", duration, rpt.formatValue(rpt.total), ratio)) 270 } 271 return label 272 } 273 274 // reportLabels returns printable labels for a report. Includes 275 // profileLabels. 276 func reportLabels(rpt *Report, g *graph.Graph, origCount, droppedNodes, droppedEdges int, fullHeaders bool) []string { 277 nodeFraction := rpt.options.NodeFraction 278 edgeFraction := rpt.options.EdgeFraction 279 nodeCount := len(g.Nodes) 280 281 var label []string 282 if len(rpt.options.ProfileLabels) > 0 { 283 label = append(label, rpt.options.ProfileLabels...) 284 } else if fullHeaders || !rpt.options.CompactLabels { 285 label = ProfileLabels(rpt) 286 } 287 288 var flatSum int64 289 for _, n := range g.Nodes { 290 flatSum = flatSum + n.FlatValue() 291 } 292 293 if len(rpt.options.ActiveFilters) > 0 { 294 activeFilters := legendActiveFilters(rpt.options.ActiveFilters) 295 label = append(label, activeFilters...) 296 } 297 298 label = append(label, fmt.Sprintf("Showing nodes accounting for %s, %s of %s total", rpt.formatValue(flatSum), strings.TrimSpace(measurement.Percentage(flatSum, rpt.total)), rpt.formatValue(rpt.total))) 299 300 if rpt.total != 0 { 301 if droppedNodes > 0 { 302 label = append(label, genLabel(droppedNodes, "node", "cum", 303 rpt.formatValue(abs64(int64(float64(rpt.total)*nodeFraction))))) 304 } 305 if droppedEdges > 0 { 306 label = append(label, genLabel(droppedEdges, "edge", "freq", 307 rpt.formatValue(abs64(int64(float64(rpt.total)*edgeFraction))))) 308 } 309 if nodeCount > 0 && nodeCount < origCount { 310 label = append(label, fmt.Sprintf("Showing top %d nodes out of %d", 311 nodeCount, origCount)) 312 } 313 } 314 315 // Help new users understand the graph. 316 // A new line is intentionally added here to better show this message. 317 if fullHeaders { 318 label = append(label, "\nSee https://git.io/JfYMW for how to read the graph") 319 } 320 321 return label 322 } 323 324 func legendActiveFilters(activeFilters []string) []string { 325 legendActiveFilters := make([]string, len(activeFilters)+1) 326 legendActiveFilters[0] = "Active filters:" 327 for i, s := range activeFilters { 328 if len(s) > 80 { 329 s = s[:80] + "…" 330 } 331 legendActiveFilters[i+1] = " " + s 332 } 333 return legendActiveFilters 334 } 335 336 func genLabel(d int, n, l, f string) string { 337 if d > 1 { 338 n = n + "s" 339 } 340 return fmt.Sprintf("Dropped %d %s (%s <= %s)", d, n, l, f) 341 } 342 343 // New builds a new report indexing the sample values interpreting the 344 // samples with the provided function. 345 func New(prof *profile.Profile, o *Options) *Report { 346 format := func(v int64) string { 347 if r := o.Ratio; r > 0 && r != 1 { 348 fv := float64(v) * r 349 v = int64(fv) 350 } 351 return measurement.ScaledLabel(v, o.SampleUnit, o.OutputUnit) 352 } 353 return &Report{prof, computeTotal(prof, o.SampleValue, o.SampleMeanDivisor), 354 o, format} 355 } 356 357 // NewDefault builds a new report indexing the last sample value 358 // available. 359 func NewDefault(prof *profile.Profile, options Options) *Report { 360 index := len(prof.SampleType) - 1 361 o := &options 362 if o.Title == "" && len(prof.Mapping) > 0 && prof.Mapping[0].File != "" { 363 o.Title = filepath.Base(prof.Mapping[0].File) 364 } 365 o.SampleType = prof.SampleType[index].Type 366 o.SampleUnit = strings.ToLower(prof.SampleType[index].Unit) 367 o.SampleValue = func(v []int64) int64 { 368 return v[index] 369 } 370 return New(prof, o) 371 } 372 373 // computeTotal computes the sum of the absolute value of all sample values. 374 // If any samples have label indicating they belong to the diff base, then the 375 // total will only include samples with that label. 376 func computeTotal(prof *profile.Profile, value, meanDiv func(v []int64) int64) int64 { 377 var div, total, diffDiv, diffTotal int64 378 for _, sample := range prof.Sample { 379 var d, v int64 380 v = value(sample.Value) 381 if meanDiv != nil { 382 d = meanDiv(sample.Value) 383 } 384 if v < 0 { 385 v = -v 386 } 387 total += v 388 div += d 389 if sample.DiffBaseSample() { 390 diffTotal += v 391 diffDiv += d 392 } 393 } 394 if diffTotal > 0 { 395 total = diffTotal 396 div = diffDiv 397 } 398 if div != 0 { 399 return total / div 400 } 401 return total 402 } 403 404 // Report contains the data and associated routines to extract a 405 // report from a profile. 406 type Report struct { 407 prof *profile.Profile 408 total int64 409 options *Options 410 formatValue func(int64) string 411 } 412 413 // Total returns the total number of samples in a report. 414 func (rpt *Report) Total() int64 { return rpt.total } 415 416 func abs64(i int64) int64 { 417 if i < 0 { 418 return -i 419 } 420 return i 421 } 422 423 func trimPath(path, trimPath, searchPath string) string { 424 // Keep path variable intact as it's used below to form the return value. 425 sPath, searchPath := filepath.ToSlash(path), filepath.ToSlash(searchPath) 426 if trimPath == "" { 427 // If the trim path is not configured, try to guess it heuristically: 428 // search for basename of each search path in the original path and, if 429 // found, strip everything up to and including the basename. So, for 430 // example, given original path "/some/remote/path/my-project/foo/bar.c" 431 // and search path "/my/local/path/my-project" the heuristic will return 432 // "/my/local/path/my-project/foo/bar.c". 433 for _, dir := range filepath.SplitList(searchPath) { 434 want := "/" + filepath.Base(dir) + "/" 435 if found := strings.Index(sPath, want); found != -1 { 436 return path[found+len(want):] 437 } 438 } 439 } 440 // Trim configured trim prefixes. 441 trimPaths := append(filepath.SplitList(filepath.ToSlash(trimPath)), "/proc/self/cwd/./", "/proc/self/cwd/") 442 for _, trimPath := range trimPaths { 443 if !strings.HasSuffix(trimPath, "/") { 444 trimPath += "/" 445 } 446 if strings.HasPrefix(sPath, trimPath) { 447 return path[len(trimPath):] 448 } 449 } 450 return path 451 }