Learn the single most useful tracing pattern — time any operation and see its latency distribution.
🎯 Answer "where's the latency?" in seconds, no code changeTo measure how long something takes: timestamp at the entry probe, stash it in a map keyed by thread, subtract at the matching return probe, and drop the difference into a hist() — a latency distribution falls out for free.
Almost every "how slow is X?" question in the kernel is answered by the same four-step move. Learn it once and you can time syscalls, disk I/O, function calls, lock waits — anything that has a clear start and end.
The trick is that an eBPF program is just a tiny handler that fires when a probe is hit. You get two firings — one when the operation begins, one when it ends — and you need to carry a number from the first to the second. A map (the key/value store from Lesson 5) is the luggage that crosses that gap.
nsecs — reading a nanosecond clocknsecs is a bpftrace builtin: a variable that's always
available inside a probe. It returns the current time in nanoseconds since the machine
booted — a steadily climbing integer. You don't set it; you just read it whenever a
probe fires.
On its own it's not very interesting. Its power is relative: read it at two moments, and the difference is how much time passed between them, measured to the nanosecond.
# Print the kernel's nanosecond clock once per second sudo bpftrace -e 'interval:s:1 { printf("now = %llu ns since boot\n", nsecs); }'
sudo).nsecs counts from boot (not from the epoch), only
differences are meaningful — and a difference is exactly what latency is.@start[tid] = nsecsWhen the entry probe fires, we record "the operation started now" by writing the timestamp into a map. The line is exactly:
@start[tid] = nsecs;
Two things are happening. @start is a map (the @ prefix
means "global map"). The part in brackets, [tid], is the key.
tid is another builtin: the thread ID of whatever is running right now.
So we're saying: "for this thread, remember the start time."
tid and not, say, by process?Because many threads can be inside the same operation at the same time. Imagine
ten threads all calling read() at once. If you stored a single start time in a
plain variable, the tenth thread's timestamp would clobber the first nine, and every
subtraction would be garbage.
A thread, though, is strictly sequential: it enters the function, then returns from it,
then maybe enters again. It can't be in two places at once. So tid is the
perfect key — it gives each in-flight operation its own private slot, and the
return probe (running on that same thread) can look up exactly the right start time.
tid gives every concurrent operation its own slot, so timestamps never collide.Every kernel function you can hook on the way in with a kprobe, you can also hook on the way out with a kretprobe (the "ret" = return). They come as a pair, pointing at the same function:
| Probe | Fires when… | Role in the pattern |
|---|---|---|
kprobe:vfs_read | the kernel enters vfs_read | save the start time |
kretprobe:vfs_read | the kernel returns from vfs_read | compute & record latency |
At the return probe, the thread that's running is the same one that entered, so
tid still points at the right slot. The latency is just:
nsecs - @start[tid] # time-now minus time-at-entry = elapsed ns
tracepoint:syscalls:sys_enter_read →
sys_exit_read) instead of kprobe/kretprobe. Tracepoints are stable,
documented hook points and survive kernel upgrades; kprobes can attach to any
function but the names can shift between kernel versions. The timing pattern is
identical either way — entry stores, exit subtracts.Source: bpftrace one-liner tutorial — Lesson 7 (timing vfs_read).
bpftrace may attach your probes mid-operation. A thread could be sitting
inside vfs_read already when you start tracing — so the return fires,
but there's no matching @start[tid]. Reading a missing key gives
0, and nsecs - 0 would record a fake latency of "billions of
nanoseconds." We guard against that with a predicate — the /…/ filter
after the probe — so the action runs only when a start exists:
kretprobe:vfs_read /@start[tid]/ { # only runs when @start[tid] is non-zero (a real start was seen) }
Once you've computed the latency, that thread's start time is spent — leave it in the
map and it just leaks memory. Remove it with delete():
delete(@start, tid); # current syntax: delete(map, key)
delete(@start[tid]). Current bpftrace prefers the
two-argument form delete(@start, tid) and flags the old one as deprecated.
Both still work on 0.21-era builds; write the new form so your scripts don't warn on
fresh installs. The guard /@start[tid]/ + the delete together keep the
map small and the numbers honest.Source: bpftrace standard library — delete().
hist()Instead of printing one line per read (thousands per second — useless), we feed every
delta into hist(). It builds a power-of-2 (log2) histogram entirely
in the kernel: each value is dropped into a bucket like [4K, 8K), and only
the bucket counts are sent up to user space. That's why it's cheap enough to leave
running in production.
@ns = hist(nsecs - @start[tid]); # bucket the elapsed time
Putting all five steps together — the complete, copy-later script that times every
read() reaching the VFS layer:
#!/usr/bin/env bpftrace # read-latency.bt — distribution of vfs_read durations, in microseconds BEGIN { printf("Tracing vfs_read latency. Hit Ctrl-C to end.\n"); } kprobe:vfs_read { @start[tid] = nsecs; # ① entry: remember start, per thread } kretprobe:vfs_read /@start[tid]/ { # ② guard: only if we saw the start @us = hist((nsecs - @start[tid]) / 1000); # ③ delta → µs → histogram delete(@start, tid); # ④ cleanup this thread's slot } END { clear(@start); # tidy: drop any half-finished entries }
read-latency.bt and run sudo bpftrace read-latency.bt later on a Linux box.On Ctrl-C, bpftrace auto-prints every map. A realistic @us histogram looks
like this (left = latency bucket in µs, middle = count, right = ASCII bar):
@us:
[1] 24 |@@@ |
[2, 4) 418 |@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@|
[4, 8) 392 |@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@ |
[8, 16) 153 |@@@@@@@@@@@@@@@@@@ |
[16, 32) 41 |@@@@@ |
[32, 64) 12 |@ |
[64, 128) 3 | |
[128, 256) 0 | |
[256, 512) 0 | |
[512, 1K) 2 | |
[1K, 2K) 7 | |
The whole point of a distribution is that its shape tells a story an average can't. Three things to look for:
Where's the tallest bar? In our run it's [2, 4) µs — the typical read is a few microseconds. That's your "normal."
Anything far to the right with a non-zero count. Here, those [1K, 2K) µs reads (1–2 ms!) are hundreds of times slower than typical — that's where a user-felt hiccup hides.
Two separate humps = two different code paths. Classic for disk I/O: a fast hump (cache hit) and a slow hump (cache miss). One number could never reveal that.
[512, 1K) lumps everything from 512µs to 1023µs together —
great for spotting orders of magnitude, not for "was it 600 or 700µs." That
coarseness is exactly what makes it cheap. Need finer detail? lhist() (linear
histogram) lets you set explicit bucket ranges.Once the entry/return/subtract/histogram shape is in your head, you bend it to ask sharper questions just by changing the map key or the aggregator:
Add comm (the process name) as a histogram key, so you get one histogram
per program:
kretprobe:vfs_read /@start[tid]/ { @us[comm] = hist((nsecs - @start[tid]) / 1000); # split by process delete(@start, tid); }
sudo bpftrace.Don't need the visual? Just tally how many landed in each rough band with
count() keyed by a computed bucket — or keep hist() and read the count
column. Same data, leaner output.
biolatency
Brendan Gregg's biolatency times block-device I/O with this exact pattern,
but it can't key by tid — a disk request is issued by one thread and
completed later by an interrupt on a different thread. So it keys the start map by the
request's sector instead (@start[args.sector] = nsecs), then subtracts on the
completion tracepoint. The four steps are identical — only the key changes to whatever
uniquely identifies one in-flight operation.Source: bpftrace tools/biolatency.bt · Brendan Gregg — BPF Performance Tools.
tid (thread id) rather than by process or a single global variable?vfs_read from entry to return?hist(nsecs - @start[tid]) give you when you stop tracing?/@start[tid]/?nsecs builtin return?@start[tid] = nsecs do, and why key by tid?@start under the key tid (the current thread id). Keying by thread gives every concurrent in-flight operation its own private slot, so simultaneous operations never overwrite each other's start time.kprobe and kretprobe pair up?kprobe:fn fires on entry (store the start), kretprobe:fn fires on return (compute the latency). The return runs on the same thread, so tid still finds the right start slot. (A tracepoint enter/exit pair works the same way.)nsecs - @start[tid] — current time minus the stored entry time = elapsed nanoseconds. Divide by 1000 for microseconds, by 1e6 for milliseconds.hist() of a latency value produce, and why is it cheap?[4, 8). The bucketing happens in the kernel; only the small bucket counts are sent to user space, so it adds almost no overhead even at high event rates.Curious where this pattern breaks, or how to adapt it? Ask me things like "why can't biolatency key by tid like the read example does?" or "how would I time a userland function in my own app instead of a kernel one?" or "what's the difference between hist() and lhist(), and when would I want linear buckets?"