Pyroscope Link to heading
After diving into security with Falco and networking with XDP, I wanted to explore a profiling application that leverages eBPF.
Grafana Pyroscope is an open source continuous profiling platform. It supports various backends, including Go’s pprof
,
python via py-spy
and many other languages. But the reason why I am writing this post is because it supports also an eBPF
backend.
In order to profile, we need to understand what our program is doing Link to heading
I don’t know much about py-spy
, but I know how Go’s pprof works and it’s very simple: it takes a snapshot of the stack
of every goroutine of the program over time. By aggregating such information we can understand where our program is
consuming most of the CPU: if the same stack is captured over time, it means it belongs to a time consuming path.
A common way to aggregate such information is via a flamegraph:
- The flamegraph does not represent the functions called over time, but all the functions captured in the sampling time
- Each level of the graph represents a level in the stack, with functions being called
- The width of each box represents how often that function appears in the dump (i.e. how long our program is busy in that function)
This for example is a flamegraph generated out of a MetalLB’s speaker execution via the embedded Go pprof endpoint:
With eBPF Link to heading
Pyroscope uses both native profiling (for interpreted languages such as python, for example) but also eBPF based one, which is the focus of this post.
eBPF provides a nice (and generic!) way to inspect the user or the kernel stack of a given application, via the bpf_get_stackid
eBPF helper.
From the bpf-helper man page:
Walk a user or a kernel stack and return its id. To achieve this, the helper needs ctx, which is a pointer to the context on which the tracing program is executed, and a pointer to a map of type BPF_MAP_TYPE_STACK_TRACE.
So, the helper does two things:
- It walks the kernel (or user) stack and fills an element of the provided map of type
BPF_MAP_TYPE_STACK_TRACE
- It generates and returns the key corresponding to that stack to access the map
This is just what we need to build our flamegraph: the eBPF program is notified when the sampling interval occurs and is able to both count the occurrences of a given stack and to collect the stack.
The pyroscope
eBPF side is pretty simple and self contained:
SEC("perf_event")
int do_perf_event(struct bpf_perf_event_data *ctx)
{
u64 id = bpf_get_current_pid_tgid();
u32 tgid = id >> 32;
u32 pid = id;
struct sample_key key = { .pid = tgid};
key.kern_stack = -1;
key.user_stack = -1;
u32 *val, one = 1, zero = 0;
struct bss_arg *arg = bpf_map_lookup_elem(&args, &zero); // 1
if (!arg) {
return 0;
}
if (pid == 0) {
return 0;
}
if (arg->tgid_filter != 0 && tgid != arg->tgid_filter) {
return 0;
}
bpf_get_current_comm(&key.comm, sizeof(key.comm)); // 2
if (arg->collect_kernel) {
key.kern_stack = bpf_get_stackid(ctx, &stacks, KERN_STACKID_FLAGS); // 3
}
if (arg->collect_user) {
key.user_stack = bpf_get_stackid(ctx, &stacks, USER_STACKID_FLAGS); // 3
}
val = bpf_map_lookup_elem(&counts, &key); // 4
if (val)
(*val)++;
else
bpf_map_update_elem(&counts, &key, &one, BPF_NOEXIST); // 4
return 0;
}
What the program does is:
- it fetches the configuration (i.e. if filtering on the thread id must be performed) (1)
- it fetches the current command (2)
- depending on the configuration, fetches either the kernel stack or the user stack (3)
- it builds a key based on the command, the stack id and the thread id, and uses it to count how many time a given stack is occurring (4)
The stacks
map holds each different stack.
On the userspace side Link to heading
The userspace part is probably the most complex and interesting one. After registering the program, it translates the array of instruction pointers to the real stack.
Attaching the program Link to heading
For each CPU it creates the perf event:
func newPerfEvent(cpu int, sampleRate int) (*perfEvent, error) {
var (
fd int
err error
)
attr := unix.PerfEventAttr{
Type: unix.PERF_TYPE_SOFTWARE,
Config: unix.PERF_COUNT_SW_CPU_CLOCK,
Bits: unix.PerfBitFreq,
Sample: uint64(sampleRate),
}
fd, err = unix.PerfEventOpen(&attr, -1, cpu, -1, unix.PERF_FLAG_FD_CLOEXEC)
if err != nil {
return nil, fmt.Errorf("open perf event: %w", err)
}
return &perfEvent{fd: fd}, nil
}
and then attaches the eBPF program to it:
err = pe.attachPerfEvent(s.bpf.profilePrograms.DoPerfEvent)
Building the flame graph Link to heading
In order to build the flame graph it collects all the keys of all the stacks, reading the counter map
keys, values, batch, err := s.getCountsMapValues()
if err != nil {
return fmt.Errorf("get counts map: %w", err)
}
For each key it accesses the stacks map and builds a record with the pid, the stack, the counter:
for i := range keys {
/*...*/
if s.options.CollectUser {
uStack = s.getStack(ck.UserStack)
}
if s.options.CollectKernel {
kStack = s.getStack(ck.KernStack)
}
sfs = append(sfs, sf{
pid: ck.Pid,
uStack: uStack,
kStack: kStack,
count: value,
comm: getComm(ck),
labels: labels,
})
}
Those getStack
calls return the stack under the form of instruction pointers (as integers).
For each element, the code then walks the stack of the corresponding pid, translates it to a readable form with the name of the symbols and returns it via a callback:
for _, it := range sfs {
stats := stackResolveStats{}
sb.rest()
sb.append(it.comm)
if s.options.CollectUser {
s.walkStack(&sb, it.uStack, it.pid, &stats)
}
if s.options.CollectKernel {
s.walkStack(&sb, it.kStack, 0, &stats)
}
if len(sb.stack) == 1 {
continue // only comm
}
lo.Reverse(sb.stack)
cb(it.labels, sb.stack, uint64(it.count), it.pid)
s.debugDump(it, stats, sb)
}
walkStack
is where the magic happens:
func (s *session) walkStack(sb *stackBuilder, stack []byte, pid uint32, stats *stackResolveStats) {
if len(stack) == 0 {
return
}
var stackFrames []string
for i := 0; i < 127; i++ {
instructionPointerBytes := stack[i*8 : i*8+8]
instructionPointer := binary.LittleEndian.Uint64(instructionPointerBytes)
if instructionPointer == 0 {
break
}
sym := s.symCache.Resolve(pid, instructionPointer)
var name string
if sym.Name != "" {
name = sym.Name
stats.known++
} else {
if sym.Module != "" {
//name = fmt.Sprintf("%s+%x", sym.Module, sym.Start) // todo expose an option to enable this
name = sym.Module
stats.unknownSymbols++
} else {
name = "[unknown]"
stats.unknownModules++
}
}
stackFrames = append(stackFrames, name)
}
In order to be able to perform the translation, pyroscope
fills this symCache
object.
It reads the content of /proc/$pid/maps
which gives the details of each
address region of the process’ address space to a data structure under the form:
ProcMap{
StartAddr: saddr,
EndAddr: eaddr,
Perms: perms,
Offset: offset,
Dev: device,
Inode: inode,
Pathname: pathname,
}
Given the file corresponding to a given region, it uses elf.NewFile()
to parse it.
The logic is then quite complex, so I will just provide the references on how pyroscope
fills the symCache
:
- looking all the ranges to find where our instruction pointer belongs to here
- building an elf table using the file that corresponds to the given range here
- getting the elf file’s
buildID
here - trying to find the corresponding debug file to use it for the symbols here
- fetching the symbols from the elf table here
What we get at the end, is a list of stacks with their occurrences that can be stored somewhere and then presented to the user as a nice flamegraph.
Poor’s man version Link to heading
As always, I tried to write a simple version using Go for the userspace and C for the eBPF side, stealing pieces from the project.
The simplified version still interacts with perf_event
s, but it doesn’t do the super-hard work
of translating the set of pointers to a human readable stack with names. It can be found in the usual
github repo.
Let’s have a look at the eBPF side:
SEC("perf_event")
int do_perf_event(struct bpf_perf_event_data *ctx)
{
u64 id = bpf_get_current_pid_tgid();
u32 tgid = id >> 32;
u32 pid = id;
struct arguments *args = 0;
__u32 argsKey = 0;
args = (struct arguments *)bpf_map_lookup_elem(¶ms_array, &argsKey); // 1
if (!args)
{
bpf_printk("no args");
return -1;
}
if (pid != args->pid) {
return 0; // 2
}
bpf_printk("got event for pid %d", pid);
struct stack_key key;
key.pid = pid;
bpf_get_current_comm(&key.comm, sizeof(key.comm));
key.stack_id = bpf_get_stackid(ctx, &stacks, USER_STACKID_FLAGS); // 3
u32* val = bpf_map_lookup_elem(&counts, &key); // 4
if (val)
(*val)++;
else {
u32 one = 1;
bpf_map_update_elem(&counts, &key, &one, BPF_NOEXIST);
}
return 0;
}
Here we:
- get the pid we are asked to observe (1)
- return if the pid we got the event for doesn’t match (2)
- get the stack id (and contextually, fill the
stacks
map) (3) - count and store how many times that stack appear (4)
On the userspace side we iterate over the CPUs, and set a perf event for each one of them (heavily inspired by the pyroscope code):
for _, cpu := range cpus {
pe, err := newPerfEvent(int(cpu), 1)
if err != nil {
log.Fatalf("new perf event: %v", err)
}
opts := link.RawLinkOptions{
Target: pe.fd,
Program: objs.DoPerfEvent,
Attach: ebpf.AttachPerfEvent,
}
pe.link, err = link.AttachRawLink(opts)
if err != nil {
log.Fatalf("attach raw link: %v", err)
}
}
Then we collect the data from the map containing the number of occurrences for each different stack (still, of the same process we want to focus on):
for i := 0; i < 10; i++ {
time.Sleep(time.Second)
var mapKey perfStackKey
var count uint32
iter := objs.Counts.Iterate() // 1
for iter.Next(&mapKey, &count) {
if count > maxCount {
maxCount = count
toDump = mapKey (2)
}
}
}
Here, for each round of polling we
- iterate over the
counts
map which contains the number of occurrences of any given stack (1) - check if the count is the highest, and if so, we save the key (2)
The map key is something like:
struct stack_key {
__u32 pid;
__s64 stack_id;
char comm[16];
};
So we can then fetch the actual stack using the stack_id part of the key, to find which is the stack that appeared the most during our sampling (hence, were we consume the majority of our CPU):
stack, err := objs.Stacks.LookupBytes(uint32(toDump.StackId)) // 1
if err != nil {
log.Fatalf("stacks lookup: %v", err)
}
for i := 0; i < 127; i++ { // 2
instructionPointerBytes := stack[i*8 : i*8+8]
instructionPointer := binary.LittleEndian.Uint64(instructionPointerBytes) // 3
if instructionPointer == 0 {
break
}
fmt.Println("Instruction pointer", instructionPointer) // 4
}
We:
- fetch the stack information as an array of bytes (1)
- iterate over the maximum depth (2)
- take a subslice containing the corresponding element of the stack (3)
- print the stack element out (4)
Wrapping Up Link to heading
This was by far the most complex project to dig into, not because of the complexity of the eBPF program, but because of the super hard job it makes to convert those instruction pointers to something meaninful to the end user.
After digging into security with Falco and networking with Katran, here we explored another side of eBPF: observability.
As always, this is the result of my personal observations reading the code. Feel free to comment if something is not accurate and might be presented in a better fashion.