Skip to content

Commit c286ca2

Browse files
authored
feat: repair Go stack traces (#3014)
1 parent aa403a7 commit c286ca2

15 files changed

+415
-79
lines changed

pkg/pprof/fix_go_heap_truncated.go

Lines changed: 266 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,266 @@
1+
package pprof
2+
3+
import (
4+
"bytes"
5+
"reflect"
6+
"sort"
7+
"unsafe"
8+
9+
"golang.org/x/exp/slices"
10+
11+
profilev1 "github.com/grafana/pyroscope/api/gen/proto/go/google/v1"
12+
)
13+
14+
const (
15+
minGroupSize = 2
16+
17+
tokens = 8
18+
tokenLen = 16
19+
suffixLen = tokens + tokenLen
20+
21+
tokenBytesLen = tokenLen * 8
22+
suffixBytesLen = suffixLen * 8
23+
)
24+
25+
// MayHaveGoHeapTruncatedStacktraces reports whether there are
26+
// any chances that the profile may have truncated stack traces.
27+
func MayHaveGoHeapTruncatedStacktraces(p *profilev1.Profile) bool {
28+
if !hasGoHeapSampleTypes(p) {
29+
return false
30+
}
31+
// Some truncated stacks have depth less than the depth limit (32).
32+
const minDepth = 28
33+
for _, s := range p.Sample {
34+
if len(s.LocationId) >= minDepth {
35+
return true
36+
}
37+
}
38+
return false
39+
}
40+
41+
func hasGoHeapSampleTypes(p *profilev1.Profile) bool {
42+
for _, st := range p.SampleType {
43+
switch p.StringTable[st.Type] {
44+
case
45+
"alloc_objects",
46+
"alloc_space",
47+
"inuse_objects",
48+
"inuse_space":
49+
return true
50+
}
51+
}
52+
return false
53+
}
54+
55+
// RepairGoHeapTruncatedStacktraces repairs truncated stack traces
56+
// in Go heap profiles.
57+
//
58+
// Go heap profile has a depth limit of 32 frames, which often
59+
// renders profiles unreadable, and also increases cardinality
60+
// of stack traces.
61+
//
62+
// The function guesses truncated frames based on neighbors and
63+
// repairs stack traces if there are high chances that this
64+
// part is present in the profile. The heuristic is as follows:
65+
//
66+
// For each stack trace S taller than 24 frames: if there is another
67+
// stack trace R taller than 24 frames that overlaps with the given
68+
// one by at least 16 frames in a row from the top, and has frames
69+
// above its root, stack S considered truncated, and the missing part
70+
// is copied from R.
71+
func RepairGoHeapTruncatedStacktraces(p *profilev1.Profile) {
72+
// Group stack traces by bottom (closest to the root) locations.
73+
// Typically, there are very few groups (a hundred or two).
74+
samples, groups := split(p)
75+
// Each group's suffix is then tokenized: each part is shifted by one
76+
// location from the previous one (like n-grams).
77+
// Tokens are written into the token=>group map, Where the value is the
78+
// index of the group with the token found at the furthest position from
79+
// the root (across all groups).
80+
m := make(map[string]group, len(groups)/2)
81+
for i := 0; i < len(groups); i++ {
82+
g := groups[i]
83+
n := len(groups)
84+
if i+1 < len(groups) {
85+
n = groups[i+1]
86+
}
87+
if s := n - g; s < minGroupSize {
88+
continue
89+
}
90+
// We take suffix of the first sample in the group.
91+
s := suffix(samples[g].LocationId)
92+
// Tokenize the suffix: token position is relative to the stack
93+
// trace root: 0 means that the token is the closest to the root.
94+
// TODO: unroll?
95+
// 0 : 64 : 192 // No need.
96+
// 1 : 56 : 184
97+
// 2 : 48 : 176
98+
// 3 : 40 : 168
99+
// 4 : 32 : 160
100+
// 5 : 24 : 152
101+
// 6 : 16 : 144
102+
// 7 : 8 : 136
103+
// 8 : 0 : 128
104+
//
105+
// We skip the top/right-most token, as it is not needed,
106+
// because there can be no more complete stack trace.
107+
for j := uint32(1); j <= tokens; j++ {
108+
hi := suffixBytesLen - j*tokens
109+
lo := hi - tokenBytesLen
110+
// By taking a string representation of the slice,
111+
// we eliminate the need to hash the token explicitly:
112+
// Go map will do it this way or another.
113+
k := unsafeString(s[lo:hi])
114+
// Current candidate: the group where the token is
115+
// located at the furthest position from the root.
116+
c, ok := m[k]
117+
if !ok || j > c.off {
118+
// This group has more complete stack traces:
119+
m[k] = group{
120+
gid: uint32(i),
121+
off: j,
122+
}
123+
}
124+
}
125+
}
126+
127+
// Now we handle chaining. Consider the following stacks:
128+
// 1 2 3 4
129+
// a b [d] (f)
130+
// b c [e] (g)
131+
// c [d] (f) h
132+
// d [e] (g) i
133+
//
134+
// We can't associate 3-rd stack with the 1-st one because their tokens
135+
// do not overlap (given the token size is 2). However, we can associate
136+
// it transitively through the 2nd stack.
137+
//
138+
// Dependencies:
139+
// - group i depends on d[i].
140+
// - d[i] depends on d[d[i].gid].
141+
d := make([]group, len(groups))
142+
for i := 0; i < len(groups); i++ {
143+
g := groups[i]
144+
t := topToken(samples[g].LocationId)
145+
k := unsafeString(t)
146+
c, ok := m[k]
147+
if !ok || c.off == 0 || groups[c.gid] == g {
148+
// The current group has the most complete stack trace.
149+
continue
150+
}
151+
d[i] = c
152+
}
153+
154+
// Then, for each group, we test, if there is another group with a more
155+
// complete suffix, overlapping with the given one by at least one token.
156+
// If such stack trace exists, all stack traces of the group are appended
157+
// with the missing part.
158+
for i := 0; i < len(groups); i++ {
159+
g := groups[i]
160+
c := d[i]
161+
var off uint32
162+
for c.off > 0 {
163+
off += c.off
164+
n := d[c.gid]
165+
if n.off == 0 {
166+
// Stop early to preserve c.
167+
break
168+
}
169+
c = n
170+
}
171+
if off == 0 {
172+
// The current group has the most complete stack trace.
173+
continue
174+
}
175+
// The reference stack trace.
176+
appx := samples[groups[c.gid]].LocationId
177+
// It's possible that the reference stack trace does not
178+
// include the part we're looking for. In this case, we
179+
// simply ignore the group. Although it's possible to infer
180+
// this piece from other stacks, this is left for further
181+
// improvements.
182+
if int(off) >= len(appx) {
183+
continue
184+
}
185+
appx = appx[uint32(len(appx))-off:]
186+
// Now we append the missing part to all stack traces of the group.
187+
n := len(groups)
188+
if i+1 < len(groups) {
189+
n = groups[i+1]
190+
}
191+
for j := g; j < n; j++ {
192+
// Locations typically already have some extra capacity,
193+
// therefore no major allocations are expected here.
194+
samples[j].LocationId = append(samples[j].LocationId, appx...)
195+
}
196+
}
197+
}
198+
199+
type group struct {
200+
gid uint32
201+
off uint32
202+
}
203+
204+
// suffix returns the last suffixLen locations
205+
// of the given stack trace represented as bytes.
206+
// The return slice is always suffixBytesLen long.
207+
// function panics if s is shorter than suffixLen.
208+
func suffix(s []uint64) []byte {
209+
return locBytes(s[len(s)-suffixLen:])
210+
}
211+
212+
// topToken returns the last tokenLen locations
213+
// of the given stack trace represented as bytes.
214+
// The return slice is always tokenBytesLen long.
215+
// function panics if s is shorter than tokenLen.
216+
func topToken(s []uint64) []byte {
217+
return locBytes(s[len(s)-tokenLen:])
218+
}
219+
220+
func locBytes(s []uint64) []byte {
221+
size := len(s) * 8
222+
h := (*reflect.SliceHeader)(unsafe.Pointer(&s))
223+
h.Len = size
224+
h.Cap = size
225+
return *(*[]byte)(unsafe.Pointer(h))
226+
}
227+
228+
func unsafeString(b []byte) string {
229+
return *(*string)(unsafe.Pointer(&b))
230+
}
231+
232+
// split into groups of samples by stack trace suffixes.
233+
// Return slice contains indices of the first sample
234+
// of each group in the collection of selected samples.
235+
func split(p *profilev1.Profile) ([]*profilev1.Sample, []int) {
236+
slices.SortFunc(p.Sample, func(a, b *profilev1.Sample) int {
237+
if len(a.LocationId) < suffixLen {
238+
return -1
239+
}
240+
if len(b.LocationId) < suffixLen {
241+
return 1
242+
}
243+
return bytes.Compare(
244+
suffix(a.LocationId),
245+
suffix(b.LocationId),
246+
)
247+
})
248+
o := sort.Search(len(p.Sample), func(i int) bool {
249+
return len(p.Sample[i].LocationId) >= suffixLen
250+
})
251+
if o == len(p.Sample) {
252+
return nil, nil
253+
}
254+
samples := p.Sample[o:]
255+
const avgGroupSize = 16 // Estimate.
256+
groups := make([]int, 0, len(samples)/avgGroupSize)
257+
var prev []byte
258+
for i := 0; i < len(samples); i++ {
259+
cur := suffix(samples[i].LocationId)
260+
if !bytes.Equal(cur, prev) {
261+
groups = append(groups, i)
262+
prev = cur
263+
}
264+
}
265+
return samples, groups
266+
}
Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,43 @@
1+
package pprof
2+
3+
import (
4+
"os"
5+
"testing"
6+
7+
"github.com/stretchr/testify/require"
8+
)
9+
10+
func Benchmark_RepairGoTruncatedStacktraces(b *testing.B) {
11+
p, err := OpenFile("testdata/goheapfix/heap_go_truncated_3.pb.gz")
12+
require.NoError(b, err)
13+
b.ResetTimer()
14+
b.ReportAllocs()
15+
for i := 0; i < b.N; i++ {
16+
RepairGoHeapTruncatedStacktraces(FixGoProfile(p.CloneVT()))
17+
}
18+
}
19+
20+
func Test_UpdateFixtures_RepairGoTruncatedStacktraces(t *testing.T) {
21+
t.Skip()
22+
t.Helper()
23+
paths := []string{
24+
"testdata/goheapfix/heap_go_truncated_1.pb.gz", // Cortex.
25+
"testdata/goheapfix/heap_go_truncated_2.pb.gz", // Cortex.
26+
"testdata/goheapfix/heap_go_truncated_3.pb.gz", // Loki. Pathological.
27+
"testdata/goheapfix/heap_go_truncated_4.pb.gz", // Pyroscope.
28+
}
29+
for _, path := range paths {
30+
func() {
31+
p, err := OpenFile(path)
32+
require.NoError(t, err, path)
33+
defer p.Close()
34+
f, err := os.Create(path + ".fixed")
35+
require.NoError(t, err, path)
36+
defer f.Close()
37+
p.Profile = FixGoProfile(p.Profile)
38+
RepairGoHeapTruncatedStacktraces(p.Profile)
39+
_, err = p.WriteTo(f)
40+
require.NoError(t, err, path)
41+
}()
42+
}
43+
}

pkg/pprof/fix_go_profile.go

Lines changed: 52 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -1,25 +1,47 @@
11
package pprof
22

33
import (
4+
"regexp"
5+
"strings"
6+
47
profilev1 "github.com/grafana/pyroscope/api/gen/proto/go/google/v1"
58
)
69

7-
// FixGoProfile removes type parameters from Go function names.
8-
//
9-
// In go 1.21 and above, function names include type parameters,
10-
// however, due to the bug, a function name can include any
11-
// of the type instances regardless of the call site. Thus, e.g.,
12-
// x[T1].foo and x[T2].foo can't be distinguished in a profile.
13-
// This leads to incorrect profiles and incorrect flame graphs,
14-
// and hugely increases cardinality of stack traces.
10+
// FixGoProfile fixes known issues with profiles collected with
11+
// the standard Go profiler.
1512
//
16-
// FixGoProfile will change x[T1].foo and x[T2].foo to x[...].foo
17-
// and normalize the profile, if type parameters are present in
18-
// the profile. Otherwise, the profile returned unchanged.
13+
// Note that the function presumes that p is a Go profile and does
14+
// not verify this. It is expected that the function is called
15+
// very early in the profile processing chain and normalized after,
16+
// regardless of the function outcome.
1917
func FixGoProfile(p *profilev1.Profile) *profilev1.Profile {
18+
p = DropGoTypeParameters(p)
19+
// Now that the profile is normalized, we can try to repair
20+
// truncated stack traces, if any. Note that repaired stacks
21+
// are not deduplicated, so the caller need to normalize the
22+
if MayHaveGoHeapTruncatedStacktraces(p) {
23+
RepairGoHeapTruncatedStacktraces(p)
24+
}
25+
return p
26+
}
27+
28+
// DropGoTypeParameters removes of type parameters from Go function names.
29+
//
30+
// In go 1.21 and above, function names include type parameters, however,
31+
// due to a bug, a function name could include any of the type instances
32+
// regardless of the call site. Thus, e.g., x[T1].foo and x[T2].foo can't
33+
// be distinguished in a profile. This leads to incorrect profiles and
34+
// incorrect flame graphs, and hugely increases cardinality of stack traces.
35+
//
36+
// The function renames x[T1].foo and x[T2].foo to x[...].foo and normalizes
37+
// the profile, if type parameters are present in the profile. Otherwise, the
38+
// profile returns unchanged.
39+
//
40+
// See https://github.com/golang/go/issues/64528.
41+
func DropGoTypeParameters(p *profilev1.Profile) *profilev1.Profile {
2042
var n int
2143
for i, s := range p.StringTable {
22-
c := DropGoTypeParameters(s)
44+
c := dropGoTypeParameters(s)
2345
if c != s {
2446
p.StringTable[i] = c
2547
n++
@@ -37,3 +59,21 @@ func FixGoProfile(p *profilev1.Profile) *profilev1.Profile {
3759
_ = m.MergeNoClone(p)
3860
return m.Profile()
3961
}
62+
63+
var goStructTypeParameterRegex = regexp.MustCompile(`\[go\.shape\..*\]`)
64+
65+
func dropGoTypeParameters(input string) string {
66+
matchesIndices := goStructTypeParameterRegex.FindAllStringIndex(input, -1)
67+
if len(matchesIndices) == 0 {
68+
return input
69+
}
70+
var modified strings.Builder
71+
prevEnd := 0
72+
for _, indices := range matchesIndices {
73+
start, end := indices[0], indices[1]
74+
modified.WriteString(input[prevEnd:start] + "[...]")
75+
prevEnd = end
76+
}
77+
modified.WriteString(input[prevEnd:])
78+
return modified.String()
79+
}

0 commit comments

Comments
 (0)