eBPF Course · Lesson 5

Maps & In-Kernel Aggregation

The trick that makes eBPF cheap enough for production: summarize in the kernel, ship only the answer.

🎯 Trace millions of events/sec without drowning in output
The one idea

Instead of copying every event to user space, eBPF summarizes inside the kernel using maps and ships out only a tiny result — that's why tracing millions of events per second stays cheap.

01 The problem: you can't print them all

Picture asking the kernel a simple question: "how big is every disk read on this box?"

On a busy server that's easily a million read events per second. If your tracer's plan is "send each event to my program and let me tally them up," you've just signed up to move a million little messages out of the kernel, across the kernel↔user-space boundary, every second — forever. Each crossing costs a context switch, a copy, and CPU. Your "observability tool" becomes the heaviest thing on the machine, and you might still drop events because you can't keep up.

1,000,000+

events/sec on a busy box — far too many to print

~1 line

what you actually want back: a histogram or a count

0 changes

to the traced program — no recompile, no restart

The naive "stream everything out and count in my app" approach is exactly how a lot of older tracing worked, and it's why "just turn on tracing in prod" used to be a scary sentence. eBPF flips it: do the counting where the events already are — in the kernel — and only carry the finished summary across the boundary.

user space ↑ · kernel ↓ (events happen here) user space ↑ · kernel ↓ (events happen here) NAIVE — copy every event out 1,000,000 events/s (disk reads) 1,000,000 copies cross the boundary your tool counts them — drowning 🔥 eBPF — aggregate in the kernel 1,000,000 events/s (disk reads) @count kernel map 1 small summary crosses once your tool prints the histogram ✅
Same million events. Left: every event is copied to user space (expensive). Right: events fold into one in-kernel @ map; only the finished summary crosses the boundary (cheap).
🎯 Why this is the whole gameThis single move — summarize in the kernel, ship only the answer — is what lets you safely run a tracer against a production box handling millions of events/sec. Almost every useful bpftrace one-liner you'll write is really "fold a firehose of events into a small map."

02 Quick recap: what a map is

From the earlier lessons: a map is a key/value data structure that lives in the kernel and is the official way an eBPF program keeps state and hands data back to user space. The verifier knows about maps; the kernel manages their memory; both your little kernel-side program and your user-space tool can read them.

Think of a map as a shared dictionary that survives between events. Each time a read happens, your probe fires, runs for a few microseconds, bumps a number in the map, and exits. The map remembers. When you're done, the tool reaches into the same map and reads the totals out — one cheap crossing instead of a million.

💡 App-dev analogyIt's the difference between POST-ing every click to your analytics server (a request per event) versus keeping a counter in memory and sending {clicks: 1_000_000} once. Same answer, a millionth of the traffic.

Source: ebpf.io — What is eBPF? (maps)

03 bpftrace maps: the @ prefix

bpftrace gives you maps with almost no ceremony. Any variable name that starts with @ is a map. That single character is the whole signal — when you see @, think "this is a kernel map that bpftrace will summarize and print for me."

You writeIt means
@An anonymous (unnamed) map — fine when you only have one.
@bytesA named map called bytes. The name is just a label so output is readable.
@bytes[comm]A map keyed by comm (the process name) — an associative array: one slot per distinct key.
@start[tid]A map keyed by thread id — the classic "remember a timestamp per thread" pattern (next lesson).

Two things bpftrace does for free, which is why one-liners feel like magic:

💡 Read @ as "summarize this"Whenever you spot @ in a one-liner, the author is saying: "fold these events into a kernel map and let bpftrace summarize it." That's the difference between this and a printf that fires per-event.

Source: bpftrace — One-Liner Tutorial (maps), bpftrace Reference / Standard Library

04 The aggregation functions

You rarely store raw values in a map. Instead you assign the result of an aggregation function, and bpftrace keeps a running summary in the kernel. These are special: they can only be assigned to a map (that's the point — they accumulate into it). Here's the whole core set.

FunctionWhat it keeps in the kernelTypical use
count()A running tally — "how many times did this fire?"How often is a syscall/function called?
sum(n)Running total of all the n values.Total bytes read, total time.
avg(n)Running average (tracks count + total internally).Average request size or latency.
min(n) / max(n)Smallest / largest value seen so far.Best / worst case latency.
stats(n)count + average + total in one shot.A quick all-in-one summary per key.
hist(n)A power-of-2 distribution of n (see §5).Latency / size spread, find outliers.
lhist(n, min, max, step)A linear (even-width) distribution (see §5).Distribution where buckets should be uniform.

All of these are cheap to update: a probe fires, does one tiny arithmetic update to the map, and returns. Under the hood bpftrace uses per-CPU storage so concurrent updates from many CPUs don't fight each other — the (slightly more expensive) merge only happens once, at print time.

💡 @ = count() vs @++You could write @++, but count() is the idiomatic, lock-free, per-CPU way to tally — it scales to many CPUs without contention. Same idea for sum(n) vs @ += n.

Source: bpftrace Standard Library — map functions

05 Distributions: hist() and lhist()

An average lies. "Average latency 5 ms" hides the 2% of requests taking 500 ms that are actually ruining someone's day. The fix is a distribution — and bpftrace builds it in the kernel so even at a million events/sec you only carry out a couple dozen bucket counts.

hist(n) — power-of-2 buckets

hist() sorts each value into a bucket whose boundaries double each step: [1, 2), [2, 4), [4, 8), [8, 16), … This log2 spacing is perfect for things that span many orders of magnitude — latencies and sizes go from nanoseconds to seconds, from bytes to megabytes — and it keeps the bucket count tiny.

When bpftrace prints a histogram it looks like this (a read-size histogram, bytes per read):

# @bytes = hist(args->count)  →  on Ctrl-C, bpftrace prints:
@bytes:
[1]                    3 |@@@                                                 |
[2, 4)                 0 |                                                    |
[4, 8)                 9 |@@@@@@@@@                                           |
[8, 16)               12 |@@@@@@@@@@@@                                        |
[16, 32)               4 |@@@@                                                |
[32, 64)              19 |@@@@@@@@@@@@@@@@@@@                                  |
[64, 128)             48 |@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@|
[128, 256)            15 |@@@@@@@@@@@@@@@@                                     |
[256, 512)             2 |@@                                                  |
Linux-only — copy and try later on a Linux box (needs sudo).

How to read it, left to right:

💡 The shape is the answerThe bulge at [64, 128) tells you "most reads are ~64–127 bytes." A second bump far to the right would be your latent tail — the thing an average would have hidden.

lhist(n, min, max, step) — linear buckets

When you want even-width buckets instead of doubling ones, use lhist(). You pass a min, a max, and a step, and it makes (max − min) / step equal buckets (values below/above the range get their own catch-all buckets). It's the right tool when the range is known and you want uniform resolution — e.g. bucketing a value 0–100 in steps of 10:

# e.g. @ = lhist(retval, 0, 100, 10)  →  even 10-wide buckets:
[0, 10)              306 |@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@           |
[10, 20)             184 |@@@@@@@@@@@@@@@@@@@@@@@@@                            |
[20, 30)             318 |@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@         |
[30, 40)              92 |@@@@@@@@@@@@                                        |
Linux-only — copy and try later on a Linux box (needs sudo).
hist(n)lhist(n, min, max, step)
Bucket widthsPower-of-2 (double each step)Linear (all the same width)
ArgumentsJust the valueValue + min + max + step
Best forWide range (latency, sizes)Known, narrow range with even resolution
RiskCoarse at the high endToo many buckets if range is huge

Source: bpftrace Standard Library — hist() / lhist(), Brendan Gregg — Linux eBPF Tracing

06 Keys: counting by dimension

A single number ("48 reads") is useful, but the real power shows up when you key the map. Put something in the brackets and the map becomes an associative array — one running summary per distinct key, all maintained in the kernel at once.

# total bytes read, broken down BY process name:
@bytes[comm] = sum(args->count);
Linux-only — copy and try later on a Linux box (needs sudo).

Now bpftrace keeps a separate running total for chrome, for postgres, for node, and so on — and prints them all at the end. The key is just "the thing you want to group by." Common keys:

comm

Process name — "which program?" The single most useful key.

pid / tid

Process / thread id — pin it to one specific instance.

probe / func

Which probe or function fired — "which call path is hot?"

multiple keys

@[comm, pid] — group by more than one dimension at once.

You can also key a distribution: @ms[comm] = hist(latency) prints one little histogram per process. That's a remarkable amount of insight for a one-line command — and all the heavy lifting stayed in the kernel.

💡 Keys = your GROUP BYIf you think in SQL: @[comm] = count() is SELECT comm, COUNT(*) … GROUP BY comm. The map is the result table; the key is the GROUP BY column.

Source: bpftrace — One-Liner Tutorial (associative arrays)

07 Printing, clearing, and not blowing up memory

Most of the time you let bpftrace auto-print on Ctrl-C and you're done. But three operations matter once you go beyond one-liners — and one of them is a real-world footgun.

OperationWhat it does
print(@bytes)Print a map on demand (e.g. from an interval probe, once a second). print(@bytes, 10) prints only the top 10 keys.
clear(@bytes)Empty the whole map — all keys and values. Great for "reset every second and re-measure."
delete(@start[tid])Remove one key from a map. Essential for the per-thread timestamp pattern: store on entry, read on return, then delete so the slot is freed.
⚠ Watch your cardinality (unbounded maps)Every distinct key takes a slot, and maps have a fixed maximum number of entries. If you key by something with near-infinite variety — a raw timestamp, a full file path, a random id — the map fills up, new keys get silently dropped, and your numbers go wrong. Key by low-cardinality things (process name, syscall, a bucketed value). If you must track per-thread state, delete() the key the moment you're done with it so the map doesn't grow without bound.

Source: bpftrace Standard Library — print / clear / delete

08 Worked examples (copy & try later)

① Count syscalls by process

The "hello world" of in-kernel aggregation. One probe, one keyed counter, no printf — the firehose of every syscall on the box collapses into a tidy table.

sudo bpftrace -e 'tracepoint:raw_syscalls:sys_enter { @[comm] = count(); }'
Linux-only — copy and try later on a Linux box (needs sudo). Press Ctrl-C to stop and print.

On Ctrl-C, bpftrace prints the map sorted by count (lowest to highest):

@[systemd-journal]:      774
@[sshd]:                 911
@[node]:               12043
@[chrome]:             58921

Every one of potentially millions of syscalls was counted in the kernel; only this four-line table crossed to user space.

② A read-size histogram

"How big are the reads on this machine?" — answered as a distribution, not a misleading average.

sudo bpftrace -e 'tracepoint:syscalls:sys_enter_read { @bytes = hist(args->count); }'
Linux-only — copy and try later on a Linux box (needs sudo). Press Ctrl-C to stop and print.
@bytes:
[1]                   18 |@@@@@@@@@@                                          |
[2, 4)                 6 |@@@                                                 |
[4, 8)                 5 |@@                                                  |
[8, 16)               92 |@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@|
[16, 32)              47 |@@@@@@@@@@@@@@@@@@@@@@@@@@                            |
[32, 64)              12 |@@@@@@                                              |
[64, 128)              3 |@                                                   |
[128, 256)             0 |                                                    |
[256, 512)             4 |@@                                                  |

Reading the shape: most reads cluster around 8–31 bytes, with a small tail of larger reads — the kind of detail an "average read size" would have flattened away.

🎯 Notice what you did not doNo code change to chrome, node, or postgres. No restart. No per-event stream to user space. You asked the running kernel a precise question and got a shaped answer in seconds — exactly the production-safe superpower this course is about.

09 Check yourself

Why is in-kernel aggregation cheap enough for production?
In bpftrace, what does the @ prefix on a name mean?
What does hist() produce?
You want a separate count for each process name. Which is right?

10 Flashcards

Q: What is a map in eBPF / bpftrace?
A: A key/value data structure that lives in the kernel. It lets an eBPF program keep state between events and hand results back to user space. In bpftrace it's the thing you summarize events into, and it's read out once instead of streaming every event.
Q: What does the @ prefix denote in bpftrace?
A: It marks the variable as a map (an associative array). @x auto-allocates, and bpftrace auto-prints every map on exit / Ctrl-C — usually you write no print statement at all.
Q: What does count() do, and how do you count per key?
A: count() keeps a running tally in a map (lock-free, per-CPU). To count per dimension, key the map: @[comm] = count() gives one tally per process name — like SQL's GROUP BY comm.
Q: hist() vs lhist() — what's the difference?
A: hist(n) makes power-of-2 (log2) buckets — great for wide ranges like latency or sizes. lhist(n, min, max, step) makes linear, equal-width buckets over a known range. Both print as [low, high) intervals with a count and an ASCII bar.
Q: How do you read a bpftrace histogram line like [64, 128) 48 |@@@@|?
A: [64, 128) is the bucket: 64 ≤ value < 128 (square = inclusive, paren = exclusive). 48 is how many events fell in it. The |@@@@| bar draws that count to scale so you see the shape and outliers at a glance.
Q: How do you clear a map, and why worry about "unbounded" maps?
A: clear(@map) empties the whole map; delete(@map[key]) removes one key. Maps have a fixed max number of entries, so keying by something high-cardinality (raw timestamps, random ids, full paths) can fill the map and silently drop new keys. Key by low-cardinality values, and delete() per-thread state when done.
👩‍🏫 I'm your teacher — ask me anything

Curious how the per-CPU counting avoids locks, or what happens when a map fills up? Ask me. Try: "Walk me through what crosses the kernel boundary for @[comm] = count() vs a per-event printf" or "When would I pick lhist() over hist() for latency?"