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 outputInstead 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.
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.
events/sec on a busy box — far too many to print
what you actually want back: a histogram or a count
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.
@ map; only the finished summary crosses the boundary (cheap).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.
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)
@ prefixbpftrace 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 write | It means |
|---|---|
@ | An anonymous (unnamed) map — fine when you only have one. |
@bytes | A 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:
@x and it exists.Ctrl-C (or the program calls exit()), bpftrace prints every map for you, nicely formatted. You usually don't write any print statement at all.@ 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
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.
| Function | What it keeps in the kernel | Typical 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.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 bucketshist() 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 |@@ |
sudo).How to read it, left to right:
[64, 128) uses standard math notation: 64 ≤ value < 128. Square bracket = inclusive, round paren = exclusive. (A single number like [1] is the lone value 1.)|@@@@| ASCII bar is just that count drawn to scale; the tallest bucket fills the bar. Your eye finds the shape and the outliers instantly.[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 bucketsWhen 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 |@@@@@@@@@@@@ |
sudo).hist(n) | lhist(n, min, max, step) | |
|---|---|---|
| Bucket widths | Power-of-2 (double each step) | Linear (all the same width) |
| Arguments | Just the value | Value + min + max + step |
| Best for | Wide range (latency, sizes) | Known, narrow range with even resolution |
| Risk | Coarse at the high end | Too many buckets if range is huge |
Source: bpftrace Standard Library — hist() / lhist(), Brendan Gregg — Linux eBPF Tracing
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);
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:
commProcess name — "which program?" The single most useful key.
pid / tidProcess / thread id — pin it to one specific instance.
probe / funcWhich probe or function fired — "which call path is hot?"
@[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.
@[comm] = count() is SELECT comm, COUNT(*) … GROUP BY comm. The map is the result table; the key is the GROUP BY column.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.
| Operation | What 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. |
delete() the key the moment you're done with it so the map doesn't grow without bound.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(); }'
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.
"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); }'
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.
@ prefix on a name mean?hist() produce?@ prefix denote in bpftrace?@x auto-allocates, and bpftrace auto-prints every map on exit / Ctrl-C — usually you write no print statement at all.count() do, and how do you count per key?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.hist() vs lhist() — what's the difference?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.[64, 128) 48 |@@@@|?[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.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.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?"