Skip to content

Commit 987f691

Browse files
ksym: Introduce disk-based symbolizer (#1587)
Context ====== There are several use-cases, such as symbolization, that conceptually boil down to a list of tuples, each formed of an identifier and a string, where we want to efficiently find the string for a particular identifier. A possible approach to solve this problem is to create a large list in memory, sorted by identifier, so we can binary-search over the ids and find the entry for which entry_i.Id <= Id < entry_i+1.Id. While this works well and it's easy to understand and maintain, when the list grows too large, this can be a large source of retained memory. This issue is particularly bad when memory offloading (swap/zswap) is not enabled, as the "cold" anonymous memory won't have a way to be moved to secondary storage. This implementation produces a simple binary format that's easy to write and read, but most importantly, it should be efficient to query. ``` ┌─────────┬────────────────────────────┬────────────────────────────────────────────┐ │ │ │ │ │ Header │ Strings with nul endings │ Sorted ids + meta information on strings │ │ │ │ │ └─────────┴────────────────────────────┴────────────────────────────────────────────┘ ``` The strings aren't deduplicated or optimized in any way, to reduce the memory usage during the write phase. The file is read with `mmap(2)`, to avoid performing any read system calls while binary searching over the identifiers, and leveraging the caching layer of the filesystem. As we now have a backing file, rather than being anonymous memory, the OS can remove cached pages if there's a need for more memory. Test Plan ======= - ksym tests pass; - ran the agent for 1h without issues, spot-checked several kernel symbols and they looked good; In terms of efficiency it would be best to check on Demo / prod, but this is the early data I've gotten after running the Agent for 10mins ### Memory **before** ``` [javierhonduco@fedora parca-agent]$ cat /proc/`pidof parca-agent-debug`/status | grep -i rss VmRSS: 102212 kB RssAnon: 62148 kB RssFile: 40064 kB RssShmem: 0 kB ``` **after** ``` [javierhonduco@fedora parca-agent]$ cat /proc/`pidof parca-agent-debug`/status | grep -i rss VmRSS: 83996 kB RssAnon: 39324 kB RssFile: 40192 kB RssShmem: 4480 kB ``` ### CPU <details> **before** ![image](https://user-images.githubusercontent.com/959128/234549938-dc0088c0-c0cf-4303-8800-ba6b31f3d9d0.png) **after** ![image](https://user-images.githubusercontent.com/959128/234549967-bee7c458-5e2c-4198-9b65-786e6acb6b4d.png) No significant difference in CPU usage </details> This is roughly a 20% decrease in RSS, which is expected for the number of symbols that my box has. The more symbols the bigger the savings! :)
2 parents 3ff97d8 + bc20b25 commit 987f691

File tree

5 files changed

+502
-80
lines changed

5 files changed

+502
-80
lines changed

cmd/parca-agent/main.go

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -490,7 +490,7 @@ func run(logger log.Logger, reg *prometheus.Registry, flags flags) error {
490490
symbol.NewSymbolizer(
491491
log.With(logger, "component", "symbolizer"),
492492
perf.NewCache(logger),
493-
ksym.NewKsymCache(logger, reg),
493+
ksym.NewKsym(logger, reg, flags.Debuginfo.TempDir),
494494
vdsoCache,
495495
flags.Symbolizer.JITDisable,
496496
),

pkg/ksym/ksym.go

Lines changed: 60 additions & 70 deletions
Original file line numberDiff line numberDiff line change
@@ -15,71 +15,52 @@ package ksym
1515

1616
import (
1717
"bufio"
18+
"fmt"
1819
"io/fs"
1920
"os"
20-
"sort"
21+
"path"
2122
"strconv"
2223
"sync"
2324
"time"
2425
"unsafe"
2526

2627
"github.com/go-kit/log"
2728
"github.com/go-kit/log/level"
28-
burrow "github.com/goburrow/cache"
2929
"github.com/prometheus/client_golang/prometheus"
3030

31-
"github.com/parca-dev/parca-agent/pkg/cache"
3231
"github.com/parca-dev/parca-agent/pkg/hash"
3332
)
3433

35-
const KsymCacheSize = 10_000 // Arbitrary cache size.
36-
3734
type ksym struct {
38-
address uint64
39-
name string
40-
}
41-
42-
type ksymCache struct {
4335
logger log.Logger
36+
tempDir string
4437
fs fs.FS
45-
kernelSymbols []ksym
4638
lastHash uint64
4739
lastCacheInvalidation time.Time
4840
updateDuration time.Duration
49-
cache burrow.Cache
5041
mtx *sync.RWMutex
42+
optimizedReader *fileReader
5143
}
5244

5345
type realfs struct{}
5446

5547
func (f *realfs) Open(name string) (fs.File, error) { return os.Open(name) }
5648

57-
func NewKsymCache(logger log.Logger, reg prometheus.Registerer, f ...fs.FS) *ksymCache {
49+
func NewKsym(logger log.Logger, reg prometheus.Registerer, tempDir string, f ...fs.FS) *ksym {
5850
var fs fs.FS = &realfs{}
5951
if len(f) > 0 {
6052
fs = f[0]
6153
}
62-
return &ksymCache{
63-
logger: logger,
64-
fs: fs,
65-
// My machine has ~74k loaded symbols. Reserving 50k entries, as there might be
66-
// boxes with fewer symbols loaded, and if we need to reallocate, we would have
67-
// to do it once or twice, so this value seems like a reasonable middle ground.
68-
//
69-
// For 75000 ksyms, the memory used would be roughly:
70-
// 75000 [number of ksyms] * (24B [size of the address and string metadata] +
71-
// 38 characters [P90 length symbols in my box] * 8B/character) = ~ 24600000B = ~24.6MB
72-
kernelSymbols: make([]ksym, 0, 50000),
73-
cache: burrow.New(
74-
burrow.WithMaximumSize(KsymCacheSize),
75-
burrow.WithStatsCounter(cache.NewBurrowStatsCounter(logger, reg, "ksym")),
76-
),
54+
return &ksym{
55+
logger: logger,
56+
tempDir: tempDir,
57+
fs: fs,
7758
updateDuration: time.Minute * 5,
7859
mtx: &sync.RWMutex{},
7960
}
8061
}
8162

82-
func (c *ksymCache) Resolve(addrs map[uint64]struct{}) (map[uint64]string, error) {
63+
func (c *ksym) Resolve(addrs map[uint64]struct{}) (map[uint64]string, error) {
8364
c.mtx.RLock()
8465
lastCacheInvalidation := c.lastCacheInvalidation
8566
lastHash := c.lastHash
@@ -102,54 +83,33 @@ func (c *ksymCache) Resolve(addrs map[uint64]struct{}) (map[uint64]string, error
10283
c.mtx.Lock()
10384
c.lastCacheInvalidation = time.Now()
10485
c.lastHash = h
105-
c.cache.InvalidateAll()
106-
err := c.loadKsyms()
86+
err = c.reload()
10787
if err != nil {
108-
level.Debug(c.logger).Log("msg", "loadKsyms failed", "err", err)
88+
level.Error(c.logger).Log("msg", "reloading optimized kernel symbolizer failed", "err", err)
10989
}
11090
c.mtx.Unlock()
11191
}
11292
}
11393

11494
res := make(map[uint64]string, len(addrs))
115-
notCached := []uint64{}
95+
toResolve := []uint64{}
11696

117-
// Fast path for when we've seen this symbol before.
118-
c.mtx.RLock()
11997
for addr := range addrs {
120-
sym, ok := c.cache.GetIfPresent(addr)
121-
if !ok {
122-
notCached = append(notCached, addr)
123-
continue
124-
}
125-
res[addr], ok = sym.(string)
126-
if !ok {
127-
level.Error(c.logger).Log("msg", "failed to convert type from cache value to string")
128-
}
98+
toResolve = append(toResolve, addr)
12999
}
130-
c.mtx.RUnlock()
131100

132-
if len(notCached) == 0 {
101+
if len(toResolve) == 0 {
133102
return res, nil
134103
}
135104

136-
sort.Slice(notCached, func(i, j int) bool { return notCached[i] < notCached[j] })
137-
syms := c.resolveKsyms(notCached)
105+
syms := c.resolveKsyms(toResolve)
138106

139-
for i := range notCached {
107+
for i := range toResolve {
140108
if syms[i] != "" {
141-
res[notCached[i]] = syms[i]
109+
res[toResolve[i]] = syms[i]
142110
}
143111
}
144112

145-
c.mtx.Lock()
146-
defer c.mtx.Unlock()
147-
148-
for i := range notCached {
149-
if syms[i] != "" {
150-
c.cache.Put(notCached[i], syms[i])
151-
}
152-
}
153113
return res, nil
154114
}
155115

@@ -160,9 +120,41 @@ func unsafeString(b []byte) string {
160120
return *((*string)(unsafe.Pointer(&b)))
161121
}
162122

163-
// loadKsyms reads /proc/kallsyms and stores the start address for every function
164-
// names, sorted by the start address.
165-
func (c *ksymCache) loadKsyms() error {
123+
func (c *ksym) reload() error {
124+
path := path.Join(c.tempDir, "parca-agent-kernel-symbols")
125+
126+
// Generate optimized file.
127+
writer, err := NewWriter(path, 100)
128+
if err != nil {
129+
return fmt.Errorf("newWriter: %w", err)
130+
}
131+
132+
err = c.loadKsyms(
133+
func(addr uint64, symbol string) {
134+
_ = writer.addSymbol(symbol, addr)
135+
},
136+
)
137+
if err != nil {
138+
return fmt.Errorf("loadKsyms: %w", err)
139+
}
140+
141+
err = writer.Write()
142+
if err != nil {
143+
return fmt.Errorf("writer.Write: %w", err)
144+
}
145+
146+
// Set up reader.
147+
reader, err := NewReader(path)
148+
if err != nil {
149+
return fmt.Errorf("newReader: %w", err)
150+
}
151+
c.optimizedReader = reader
152+
return nil
153+
}
154+
155+
// loadKsyms reads /proc/kallsyms and passed the address and symbol name
156+
// to the given callback.
157+
func (c *ksym) loadKsyms(callback func(uint64, string)) error {
166158
fd, err := c.fs.Open("/proc/kallsyms")
167159
if err != nil {
168160
return err
@@ -207,33 +199,31 @@ func (c *ksymCache) loadKsyms() error {
207199
}
208200

209201
symbol := string(line[19:endIndex])
210-
c.kernelSymbols = append(c.kernelSymbols, ksym{address: address, name: symbol})
202+
callback(address, symbol)
211203
}
212204
if err := s.Err(); err != nil {
213205
return s.Err()
214206
}
215207

216-
// Sort the kernel symbols, as we will binary search over them.
217-
sort.Slice(c.kernelSymbols, func(i, j int) bool { return c.kernelSymbols[i].address < c.kernelSymbols[j].address })
218208
return nil
219209
}
220210

221211
// resolveKsyms returns the function names for the requested addresses.
222-
func (c *ksymCache) resolveKsyms(addrs []uint64) []string {
212+
func (c *ksym) resolveKsyms(addrs []uint64) []string {
223213
result := make([]string, 0, len(addrs))
224214

225215
for _, addr := range addrs {
226-
idx := sort.Search(len(c.kernelSymbols), func(i int) bool { return addr < c.kernelSymbols[i].address })
227-
if idx < len(c.kernelSymbols) && idx > 0 {
228-
result = append(result, c.kernelSymbols[idx-1].name)
229-
} else {
216+
symbol, err := c.optimizedReader.symbolize(addr)
217+
if err != nil {
230218
result = append(result, "")
219+
} else {
220+
result = append(result, symbol)
231221
}
232222
}
233223

234224
return result
235225
}
236226

237-
func (c *ksymCache) kallsymsHash() (uint64, error) {
227+
func (c *ksym) kallsymsHash() (uint64, error) {
238228
return hash.File(c.fs, "/proc/kallsyms")
239229
}

pkg/ksym/ksym_test.go

Lines changed: 10 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -17,7 +17,6 @@ import (
1717
"bytes"
1818
"errors"
1919
"testing"
20-
"unsafe"
2120

2221
"github.com/go-kit/log"
2322
"github.com/prometheus/client_golang/prometheus"
@@ -113,14 +112,11 @@ func BenchmarkUnsafeString(b *testing.B) {
113112
}
114113
}
115114

116-
func TestEnsureKsymSizeDoesNotGrow(t *testing.T) {
117-
require.Equal(t, int(unsafe.Sizeof(ksym{})), 24)
118-
}
119-
120115
func TestKsym(t *testing.T) {
121-
c := NewKsymCache(
116+
c := NewKsym(
122117
log.NewNopLogger(),
123118
prometheus.NewRegistry(),
119+
t.TempDir(),
124120
testutil.NewFakeFS(
125121
map[string][]byte{
126122
"/proc/kallsyms": []byte(`
@@ -248,13 +244,17 @@ var errLoadKsyms error
248244
func BenchmarkLoadKernelSymbols(b *testing.B) {
249245
b.ReportAllocs()
250246

251-
c := NewKsymCache(
247+
c := NewKsym(
252248
log.NewNopLogger(),
253249
prometheus.NewRegistry(),
250+
b.TempDir(),
254251
)
255252

256253
for n := 0; n < b.N; n++ {
257-
errLoadKsyms = c.loadKsyms()
254+
errLoadKsyms = c.loadKsyms(
255+
func(addr uint64, symbol string) {
256+
},
257+
)
258258
}
259259
}
260260

@@ -263,9 +263,10 @@ var kallsymsResult uint64
263263
func BenchmarkHashProcKallSyms(b *testing.B) {
264264
b.ReportAllocs()
265265

266-
c := NewKsymCache(
266+
c := NewKsym(
267267
log.NewNopLogger(),
268268
prometheus.NewRegistry(),
269+
b.TempDir(),
269270
)
270271

271272
for n := 0; n < b.N; n++ {

0 commit comments

Comments
 (0)