diff --git a/doc/README.md b/doc/README.md index 57613fad9..de7c393e7 100644 --- a/doc/README.md +++ b/doc/README.md @@ -94,6 +94,8 @@ Some common pprof options are: *regex*. * **-ignore= _regex_:** Do not include samples that include a report entry matching *regex*. +* **-show\_from= _regex_:** Do not show entries above the first one that + matches *regex*. * **-show= _regex_:** Only show entries that match *regex*. * **-hide= _regex_:** Do not show entries that match *regex*. diff --git a/internal/driver/commands.go b/internal/driver/commands.go index b2c0a9cd3..91d32d1e7 100644 --- a/internal/driver/commands.go +++ b/internal/driver/commands.go @@ -186,6 +186,10 @@ var pprofVariables = variables{ "Only show nodes matching regexp", "If set, only show nodes that match this location.", "Matching includes the function name, filename or object name.")}, + "show_from": &variable{stringKind, "", "", helpText( + "Drops functions above the highest matched frame.", + "If set, all frames above the highest match are dropped from every sample.", + "Matching includes the function name, filename or object name.")}, "tagfocus": &variable{stringKind, "", "", helpText( "Restricts to samples with tags in range or matched by regexp", "Use name=value syntax to limit the matching to a specific tag.", diff --git a/internal/driver/driver.go b/internal/driver/driver.go index 986f9a4a6..acc0b4ad8 100644 --- a/internal/driver/driver.go +++ b/internal/driver/driver.go @@ -150,11 +150,11 @@ func generateReport(p *profile.Profile, cmd []string, vars variables, o *plugin. } func applyCommandOverrides(cmd []string, v variables) variables { - trim, focus, tagfocus, hide := v["trim"].boolValue(), true, true, true + trim, tagfilter, filter := v["trim"].boolValue(), true, true switch cmd[0] { case "proto", "raw": - trim, focus, tagfocus, hide = false, false, false, false + trim, tagfilter, filter = false, false, false v.set("addresses", "t") case "callgrind", "kcachegrind": trim = false @@ -163,7 +163,7 @@ func applyCommandOverrides(cmd []string, v variables) variables { trim = false v.set("addressnoinlines", "t") case "peek": - trim, focus, hide = false, false, false + trim, filter = false, false case "list": v.set("nodecount", "0") v.set("lines", "t") @@ -181,17 +181,16 @@ func applyCommandOverrides(cmd []string, v variables) variables { v.set("nodefraction", "0") v.set("edgefraction", "0") } - if !focus { - v.set("focus", "") - v.set("ignore", "") - } - if !tagfocus { + if !tagfilter { v.set("tagfocus", "") v.set("tagignore", "") } - if !hide { + if !filter { + v.set("focus", "") + v.set("ignore", "") v.set("hide", "") v.set("show", "") + v.set("show_from", "") } return v } @@ -242,7 +241,7 @@ func reportOptions(p *profile.Profile, numLabelUnits map[string]string, vars var } var filters []string - for _, k := range []string{"focus", "ignore", "hide", "show", "tagfocus", "tagignore", "tagshow", "taghide"} { + for _, k := range []string{"focus", "ignore", "hide", "show", "show_from", "tagfocus", "tagignore", "tagshow", "taghide"} { v := vars[k].value if v != "" { filters = append(filters, k+"="+v) diff --git a/internal/driver/driver_focus.go b/internal/driver/driver_focus.go index ba5b502ad..b23ee8105 100644 --- a/internal/driver/driver_focus.go +++ b/internal/driver/driver_focus.go @@ -33,6 +33,7 @@ func applyFocus(prof *profile.Profile, numLabelUnits map[string]string, v variab ignore, err := compileRegexOption("ignore", v["ignore"].value, err) hide, err := compileRegexOption("hide", v["hide"].value, err) show, err := compileRegexOption("show", v["show"].value, err) + showfrom, err := compileRegexOption("show_from", v["show_from"].value, err) tagfocus, err := compileTagFilter("tagfocus", v["tagfocus"].value, numLabelUnits, ui, err) tagignore, err := compileTagFilter("tagignore", v["tagignore"].value, numLabelUnits, ui, err) prunefrom, err := compileRegexOption("prune_from", v["prune_from"].value, err) @@ -46,6 +47,9 @@ func applyFocus(prof *profile.Profile, numLabelUnits map[string]string, v variab warnNoMatches(hide == nil || hm, "Hide", ui) warnNoMatches(show == nil || hnm, "Show", ui) + sfm := prof.ShowFrom(showfrom) + warnNoMatches(showfrom == nil || sfm, "ShowFrom", ui) + tfm, tim := prof.FilterSamplesByTag(tagfocus, tagignore) warnNoMatches(tagfocus == nil || tfm, "TagFocus", ui) warnNoMatches(tagignore == nil || tim, "TagIgnore", ui) diff --git a/internal/driver/driver_test.go b/internal/driver/driver_test.go index 06219eae6..309e9950b 100644 --- a/internal/driver/driver_test.go +++ b/internal/driver/driver_test.go @@ -65,6 +65,7 @@ func TestParse(t *testing.T) { {"topproto,lines,cum,hide=mangled[X3]0", "cpu"}, {"tree,lines,cum,focus=[24]00", "heap"}, {"tree,relative_percentages,cum,focus=[24]00", "heap"}, + {"tree,lines,cum,show_from=line2", "cpu"}, {"callgrind", "cpu"}, {"callgrind,call_tree", "cpu"}, {"callgrind", "heap"}, @@ -261,6 +262,9 @@ func solutionFilename(source string, f *testFlags) string { if f.strings["ignore"] != "" || f.strings["tagignore"] != "" { name = append(name, "ignore") } + if f.strings["show_from"] != "" { + name = append(name, "show_from") + } name = addString(name, f, []string{"hide", "show"}) if f.strings["unit"] != "minimum" { name = addString(name, f, []string{"unit"}) diff --git a/internal/driver/testdata/pprof.cpu.cum.lines.tree.show_from b/internal/driver/testdata/pprof.cpu.cum.lines.tree.show_from new file mode 100644 index 000000000..112b49b38 --- /dev/null +++ b/internal/driver/testdata/pprof.cpu.cum.lines.tree.show_from @@ -0,0 +1,16 @@ +Active filters: + show_from=line2 +Showing nodes accounting for 1.01s, 90.18% of 1.12s total +----------------------------------------------------------+------------- + flat flat% sum% cum cum% calls calls% + context +----------------------------------------------------------+------------- + 0 0% 0% 1.01s 90.18% | line2000 testdata/file2000.src:4 + 1.01s 100% | line2001 testdata/file2000.src:9 (inline) +----------------------------------------------------------+------------- + 1.01s 100% | line2000 testdata/file2000.src:4 (inline) + 0.01s 0.89% 0.89% 1.01s 90.18% | line2001 testdata/file2000.src:9 + 1s 99.01% | line1000 testdata/file1000.src:1 +----------------------------------------------------------+------------- + 1s 100% | line2001 testdata/file2000.src:9 + 1s 89.29% 90.18% 1s 89.29% | line1000 testdata/file1000.src:1 +----------------------------------------------------------+------------- diff --git a/profile/filter.go b/profile/filter.go index e68e93ae3..ea8e66c68 100644 --- a/profile/filter.go +++ b/profile/filter.go @@ -74,6 +74,71 @@ func (p *Profile) FilterSamplesByName(focus, ignore, hide, show *regexp.Regexp) return } +// ShowFrom drops all stack frames above the highest matching frame and returns +// whether a match was found. If showFrom is nil it returns false and does not +// modify the profile. +// +// Example: consider a sample with frames [A, B, C, B], where A is the root. +// ShowFrom(nil) returns false and has frames [A, B, C, B]. +// ShowFrom(A) returns true and has frames [A, B, C, B]. +// ShowFrom(B) returns true and has frames [B, C, B]. +// ShowFrom(C) returns true and has frames [C, B]. +// ShowFrom(D) returns false and drops the sample because no frames remain. +func (p *Profile) ShowFrom(showFrom *regexp.Regexp) (matched bool) { + if showFrom == nil { + return false + } + // showFromLocs stores location IDs that matched ShowFrom. + showFromLocs := make(map[uint64]bool) + // Apply to locations. + for _, loc := range p.Location { + if filterShowFromLocation(loc, showFrom) { + showFromLocs[loc.ID] = true + matched = true + } + } + // For all samples, strip locations after the highest matching one. + s := make([]*Sample, 0, len(p.Sample)) + for _, sample := range p.Sample { + for i := len(sample.Location) - 1; i >= 0; i-- { + if showFromLocs[sample.Location[i].ID] { + sample.Location = sample.Location[:i+1] + s = append(s, sample) + break + } + } + } + p.Sample = s + return matched +} + +// filterShowFromLocation tests a showFrom regex against a location, removes +// lines after the last match and returns whether a match was found. If the +// mapping is matched, then all lines are kept. +func filterShowFromLocation(loc *Location, showFrom *regexp.Regexp) bool { + if m := loc.Mapping; m != nil && showFrom.MatchString(m.File) { + return true + } + if i := loc.lastMatchedLineIndex(showFrom); i >= 0 { + loc.Line = loc.Line[:i+1] + return true + } + return false +} + +// lastMatchedLineIndex returns the index of the last line that matches a regex, +// or -1 if no match is found. +func (loc *Location) lastMatchedLineIndex(re *regexp.Regexp) int { + for i := len(loc.Line) - 1; i >= 0; i-- { + if fn := loc.Line[i].Function; fn != nil { + if re.MatchString(fn.Name) || re.MatchString(fn.Filename) { + return i + } + } + } + return -1 +} + // FilterTagsByName filters the tags in a profile and only keeps // tags that match show and not hide. func (p *Profile) FilterTagsByName(show, hide *regexp.Regexp) (sm, hm bool) { diff --git a/profile/filter_test.go b/profile/filter_test.go index c25887a4b..3fd1787e8 100644 --- a/profile/filter_test.go +++ b/profile/filter_test.go @@ -101,7 +101,29 @@ var inlinesProfile = &Profile{ }, } -func TestFilter(t *testing.T) { +var emptyLinesLocs = []*Location{ + {ID: 1, Mapping: mappings[0], Address: 0x1000, Line: []Line{{Function: functions[0], Line: 1}, {Function: functions[1], Line: 1}}}, + {ID: 2, Mapping: mappings[0], Address: 0x2000, Line: []Line{}}, + {ID: 3, Mapping: mappings[1], Address: 0x2000, Line: []Line{}}, +} + +var emptyLinesProfile = &Profile{ + TimeNanos: 10000, + PeriodType: &ValueType{Type: "cpu", Unit: "milliseconds"}, + Period: 1, + DurationNanos: 10e9, + SampleType: []*ValueType{{Type: "samples", Unit: "count"}}, + Mapping: mappings, + Function: functions, + Location: emptyLinesLocs, + Sample: []*Sample{ + {Value: []int64{1}, Location: []*Location{emptyLinesLocs[0], emptyLinesLocs[1]}}, + {Value: []int64{2}, Location: []*Location{emptyLinesLocs[2]}}, + {Value: []int64{3}, Location: []*Location{}}, + }, +} + +func TestFilterSamplesByName(t *testing.T) { for _, tc := range []struct { // name is the name of the test case. name string @@ -392,6 +414,130 @@ func TestFilter(t *testing.T) { } } +func TestShowFrom(t *testing.T) { + for _, tc := range []struct { + name string + profile *Profile + showFrom *regexp.Regexp + // wantMatch is the expected return value. + wantMatch bool + // wantSampleFuncs contains expected stack functions and sample value after + // filtering, in the same order as in the profile. The format is as + // returned by sampleFuncs function below, which is "callee caller: ". + wantSampleFuncs []string + }{ + { + name: "nil showFrom keeps all frames", + profile: noInlinesProfile, + wantMatch: false, + wantSampleFuncs: allNoInlinesSampleFuncs, + }, + { + name: "showFrom with no matches drops all samples", + profile: noInlinesProfile, + showFrom: regexp.MustCompile("unknown"), + wantMatch: false, + }, + { + name: "showFrom matches function names", + profile: noInlinesProfile, + showFrom: regexp.MustCompile("fun1"), + wantMatch: true, + wantSampleFuncs: []string{ + "fun0 fun1: 1", + "fun4 fun5 fun1: 2", + "fun9 fun4 fun10: 4", + }, + }, + { + name: "showFrom matches file names", + profile: noInlinesProfile, + showFrom: regexp.MustCompile("file1"), + wantMatch: true, + wantSampleFuncs: []string{ + "fun0 fun1: 1", + "fun4 fun5 fun1: 2", + "fun9 fun4 fun10: 4", + }, + }, + { + name: "showFrom matches mapping names", + profile: noInlinesProfile, + showFrom: regexp.MustCompile("map1"), + wantMatch: true, + wantSampleFuncs: []string{ + "fun9 fun4 fun10: 4", + }, + }, + { + name: "showFrom drops frames above highest of multiple matches", + profile: noInlinesProfile, + showFrom: regexp.MustCompile("fun[12]"), + wantMatch: true, + wantSampleFuncs: []string{ + "fun0 fun1 fun2: 1", + "fun4 fun5 fun1: 2", + "fun9 fun4 fun10: 4", + }, + }, + { + name: "showFrom matches inline functions", + profile: inlinesProfile, + showFrom: regexp.MustCompile("fun0|fun5"), + wantMatch: true, + wantSampleFuncs: []string{ + "fun0: 1", + "fun4 fun5: 2", + }, + }, + { + name: "showFrom drops frames above highest of multiple inline matches", + profile: inlinesProfile, + showFrom: regexp.MustCompile("fun[1245]"), + wantMatch: true, + wantSampleFuncs: []string{ + "fun0 fun1 fun2: 1", + "fun4 fun5: 2", + }, + }, + { + name: "showFrom keeps all lines when matching mapping and function", + profile: inlinesProfile, + showFrom: regexp.MustCompile("map0|fun5"), + wantMatch: true, + wantSampleFuncs: []string{ + "fun0 fun1 fun2 fun3: 1", + "fun4 fun5 fun6: 2", + }, + }, + { + name: "showFrom matches location with empty lines", + profile: emptyLinesProfile, + showFrom: regexp.MustCompile("map1"), + wantMatch: true, + wantSampleFuncs: []string{ + ": 2", + }, + }, + } { + t.Run(tc.name, func(t *testing.T) { + p := tc.profile.Copy() + + if gotMatch := p.ShowFrom(tc.showFrom); gotMatch != tc.wantMatch { + t.Errorf("match got %+v, want %+v", gotMatch, tc.wantMatch) + } + + if got, want := strings.Join(sampleFuncs(p), "\n")+"\n", strings.Join(tc.wantSampleFuncs, "\n")+"\n"; got != want { + diff, err := proftest.Diff([]byte(want), []byte(got)) + if err != nil { + t.Fatalf("failed to get diff: %v", err) + } + t.Errorf("profile samples got diff(want->got):\n%s", diff) + } + }) + } +} + // sampleFuncs returns a slice of strings where each string represents one // profile sample in the format " : ". This allows // the expected values for test cases to be specifed in human-readable strings.