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  }