eBPF Course · Lesson 6

Measuring Latency & Timing

Learn the single most useful tracing pattern — time any operation and see its latency distribution.

🎯 Answer "where's the latency?" in seconds, no code change
The one idea

To 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.

01 The pattern in one picture

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.

time → t0 · entry probe store: @start[tid] = nsecs t1 · return probe read @start[tid], subtract the operation runs… delta = t1 − t0 (nanoseconds) hist() buckets fill up: [2µs, 4µs) [4µs, 8µs) ← most reads land here [8µs, 16µs) [1ms, 2ms) ← the slow tail
Stash a timestamp at entry, subtract at return, histogram the difference. The same four steps for any operation.
💡 Why a histogram, not an average An average hides the truth. "Reads take 30µs on average" can mean every read is 30µs, or that most are 5µs and a few are 50ms. The histogram shows the whole shape — and the slow tail is usually what's hurting you.

02 nsecs — reading a nanosecond clock

nsecs 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); }'
Linux-only — copy and try later on a Linux box (needs sudo).
💡 Nanoseconds, since boot 1 second = 1,000,000,000 ns. 1 millisecond = 1,000,000 ns. 1 microsecond = 1,000 ns. Because nsecs counts from boot (not from the epoch), only differences are meaningful — and a difference is exactly what latency is.

Source: bpftrace language reference — builtins.

03 Storing the start time per-thread: @start[tid] = nsecs

When 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."

Why key by 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.

3 threads, all mid-read at once → thread tid=1842 thread tid=1907 thread tid=2055 @start (one slot per tid) [1842]→ 50000100 ns [1907]→ 50000420 ns [2055]→ 50000900 ns
Keying by tid gives every concurrent operation its own slot, so timestamps never collide.

04 The matching RETURN probe — and computing the delta

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:

ProbeFires when…Role in the pattern
kprobe:vfs_readthe kernel enters vfs_readsave the start time
kretprobe:vfs_readthe kernel returns from vfs_readcompute & 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
🎯 Tracepoints work too For some operations you'll prefer a tracepoint entry→exit pair (e.g. 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).

05 Cleanup & guarding — don't trust a half-told story

Guard: only subtract if we actually saw the start

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)
}

Cleanup: delete the slot after you use it

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)
⚠ A syntax note that bites people Older bpftrace wrote 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().

06 Build the latency HISTOGRAM with 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
}
Linux-only — save as 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 |                                                    |
💡 What hist() saved you Those ~1,000 reads never crossed into user space individually. The kernel counted them into 11 buckets; only 11 little numbers came up. That is in-kernel aggregation (Lesson 5) doing the heavy lifting — the difference between "negligible overhead" and "drowning in events."

Source: bpftrace stdlib — hist() (log2 buckets).

07 Interpreting the histogram — read the shape

The whole point of a distribution is that its shape tells a story an average can't. Three things to look for:

The bulk (the mode)

Where's the tallest bar? In our run it's [2, 4) µs — the typical read is a few microseconds. That's your "normal."

The tail

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.

Bimodality

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.

🎯 This is the debugging win A bug report says "the app feels laggy sometimes." The average latency looks fine, so logs and dashboards shrug. The histogram instantly shows a fat tail at 1–2 ms — now you know some reads are pathological, roughly how often, and how bad. You went from "feels slow" to a measured, reproducible target, without touching the app.
⚠ Log2 buckets are coarse on purpose A bucket like [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.

08 Variants — same pattern, new questions

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:

Per-process latency (which program is slow?)

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);
}
Linux-only — try later with sudo bpftrace.

Count by bucket instead of a chart

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.

💡 The canonical real tool: 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.

09 Check yourself

Why do we key the start-time map by tid (thread id) rather than by process or a single global variable?
Which probe pair times a kernel function like vfs_read from entry to return?
What does hist(nsecs - @start[tid]) give you when you stop tracing?
Why guard the return probe with /@start[tid]/?

10 Flashcards

Q: What does the nsecs builtin return?
A: The current time in nanoseconds since boot — a steadily climbing integer. Only differences between two reads are meaningful, and a difference is exactly a duration/latency.
Q: What does @start[tid] = nsecs do, and why key by tid?
A: It stores the entry timestamp in the map @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.
Q: How do kprobe and kretprobe pair up?
A: They point at the same kernel function. 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.)
Q: How is the latency delta computed at the return probe?
A: nsecs - @start[tid] — current time minus the stored entry time = elapsed nanoseconds. Divide by 1000 for microseconds, by 1e6 for milliseconds.
Q: What does hist() of a latency value produce, and why is it cheap?
A: A power-of-2 (log2) histogram — each delta is counted into a bucket like [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.
Q: When reading a latency histogram, what does "the tail" mean and why care?
A: The tail is the far-right buckets with non-zero counts — the rare, much-slower operations. Averages hide it, but the tail is usually what users actually feel (the laggy 1-in-1000 request). A second separate hump (bimodal) means two code paths, e.g. cache hit vs. cache miss.
👩‍🏫 I'm your teacher — ask me anything

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?"