Performance Debugging for Slow Builds and Hanging Sessions: the USE Method, Tracing and Kernel Diagnostics
Last verified2026-10 Also see: Sandboxing primitives (cgroup throttling) · Sandbox isolation: gVisor vs Firecracker vs Kata (why runsc is slower) · tcpdump & eBPF deep dive · Build platform security · Linux hardening
Start here: the basics in plain words
Something is slow. A build that took four minutes now takes twelve, or an engineer says their session into a runner is "frozen". You have two jobs, in this order:
before you change anything. A slow program is either doing work (burning CPU) or waiting (for disk, network, a lock, or another process). Those have opposite fixes, and guessing wrong wastes an afternoon.
The wrong tool can turn a slow process into a stuck one. Attaching a tracer to a busy multithreaded program can multiply its runtime by a hundred, as you will see below.
Three ideas carry most of the work:
On-CPU = the program is running and slow; profile it. Off-CPU = it is blocked, waiting; find what it waits on. Totally different tools.
Utilization is how busy a resource is (80% CPU). Saturation is the queue of work waiting for it (load average, PSI). A disk at 100% busy with a growing queue is the problem; at 100% with no queue it is just working.
Never jump to a fix. "The build is slow" is a symptom. "I think it is disk-bound" is a hypothesis. iostat showing the disk saturated is the measurement that confirms or kills it.
"it's slow"
│
▼
is it running or waiting? ← the single most important question
┌────────────┴─────────────┐
ON-CPU OFF-CPU
(burning cycles) (blocked)
│ │
perf / flame graph /proc/<pid>/wchan + stack,
"what code?" off-CPU trace "what's it waiting on?"
│
┌────────────┼────────────┬───────────┐
disk I/O network a lock another process
iostat,PSI ss, strace futex parent/childMemory hook"running or waiting?" Ask it first, every time. The answer splits every performance problem into two halves and tells you which half of your toolbox to open. Everything else on this page is detail under that one question.
This is a security topic too, not only an operations one. Performance anomalies are a detection signal: the xz-utils backdoor (CVE-2024-3094) was caught because an engineer chased an SSH login that used suspiciously much CPU. And on a multi-tenant CI platform, "my build is slow" is often "another tenant is saturating a shared resource", which is a tenancy and fairness question as much as a speed one.
Why it matters
Interviewers use performance debugging to tell whether you treat the host as a black box or understand what the kernel is doing. "I'd add more CPU" is a black-box answer. "I'd check whether it's on-CPU or off-CPU, read wchan to see which kernel function it's blocked in, and confirm with PSI before touching anything" is a systems answer.
It also maps directly onto hardened infrastructure. In a world of gVisor sandboxes, cgroup-limited runners and egress proxies, the slow-down is frequently caused by the security layer: syscalls reflected through gVisor's user-space kernel, a cgroup CPU cap throttling the job, a TLS handshake hanging on a proxy. If you cannot tell "the sandbox is doing its job and it costs 2x" apart from "something is broken", you cannot run the platform.
The USE method: a checklist so you don't miss a resource
Brendan Gregg's USE method is a way to not forget anything. For every resource, check three things:
how much of the time it is busy.
how much extra work is queued because it is busy.
error counts, which are easy to skip and often the actual cause.
Walk the resources: CPU, memory, disk I/O, network. For each, you have a one-line command for U, S and E. The point of the checklist is coverage: people reliably find CPU and forget disk, or find utilization and forget the error counter that was the whole story.
- CPU
- sat: load average, PSI cpu
- Memory
- sat: PSI memory, swap in/out (si/so)
- Disk I/O
- sat: iostat aqu-sz, PSI io
- Network
- sat: retransmits, queue drops
RED vs USEYou will be asked the difference. USE is resource-centric, for one machine: is any resource exhausted? RED is service-centric, for a request-handling service: Rate (requests/sec), Errors (failed requests), Duration (latency). Use USE to debug a slow host; use RED to watch a service. A slow CI runner is a USE problem; a slow API behind it is a RED problem.
In practiceThe first-60-seconds reconnaissance on the lab VM. Each line answers one USE cell:
$ uptime
load average: 0.32, 0.29, 0.28 ← 4 CPUs, load < 1: not CPU-saturated
$ vmstat 1 3
r b swpd free buff cache si so bi bo in cs us sy id wa st
0 0 0 138460 98648 4847524 0 0 14 175 3476 12 1 3 96 0 0
1 0 0 138460 98648 4847612 0 0 0 12 3529 4725 0 1 98 0 0
# ^r=run queue ^si/so=swap (0 = not memory-saturated) ^wa=0 (not disk-blocked) ^id=98 idle
$ for f in cpu memory io; do echo "$f: $(cat /proc/pressure/$f | head -1)"; done
cpu: some avg10=1.53 avg60=1.81 avg300=2.09 ← ~2% of the time *something* waited for CPU
memory: some avg10=0.00 ← nothing ever waited for memory
io: some avg10=0.00 ← nothing waited for disk in the last 10sThis machine is healthy: idle CPU, no swap, no I/O wait, flat pressure. That negative result is worth as much as a positive one, it rules out three whole resources in four commands, so you can stop looking at the host and look at the application.
🎯On the job. Pressure Stall Information (PSI), the
/proc/pressure/*files, is the metric to graph per CI runner.some avg10rising oncpu,memoryoriotells you which resource is the bottleneck and how long work is stalled on it, in one number, without a sampling tool. A platform that alerts on PSI catches "runners are thrashing" before engineers file "CI is slow" tickets.
Off-CPU: finding what a frozen process is waiting on, without touching it
When a process is stuck, the most useful facts are already in /proc, free and non-invasive. Two files answer "what is it blocked on?":
/proc/<pid>/wchanthe name of the kernel function the process is currently sleeping in. One word, and often enough.
/proc/<pid>/stackthe full kernel-side call stack (needs root). It shows the exact path into that sleep.
In practiceA pipeline where one command waits for input that never comes, the shape of a hung build step waiting on a child:
$ (sleep 60 | cat) & # cat blocks reading a pipe the sleep hasn't written
$ grep State /proc/$(pgrep -n cat)/status
State: S (sleeping) ← interruptible sleep: waiting, not working
$ cat /proc/$(pgrep -n cat)/wchan
pipe_read ← one word: it is blocked reading a pipe
$ sudo cat /proc/$(pgrep -n cat)/stack
[<0>] pipe_read+0x40c/0x480 ← confirmed: parked inside the kernel's pipe_read
[<0>] vfs_read+0x318/0x348
[<0>] ksys_read+0x118/0x140
[<0>] __arm64_sys_read+0x28/0x50
$ ls -l /proc/$(pgrep -n cat)/fd
0 -> pipe:[3149574] ← stdin is a pipe; nobody is writing the other endIn four reads, and without stopping the process, you know it is not slow but waiting, waiting on a pipe, for a writer that is itself asleep in a timer. No profiler needed. The same reads diagnose a frozen SSH session: wchan of do_select or n_tty_read means it is waiting for terminal input or a poll, not deadlocked.
Reading the sleep stateThe State letter matters:
Normal waiting: a signal can wake it. A frozen-but-killable process. Usually waiting on I/O, a pipe, a socket or a lock.
Waiting on something the kernel won't let a signal interrupt, almost always disk or a storage/network filesystem. A pile of D-state processes = I/O is the bottleneck. Cannot be killed, even with -9.
On-CPU or waiting for a core. If it's slow and R, profile it; it's doing work.
Finished but its parent hasn't reaped it. Many zombies = a broken supervisor, not a performance bug.
Off-CPU over timewchan is a snapshot. To learn where a process spends its blocked time across a whole slow run, an eBPF off-CPU trace sums the time each thread spends switched out:
$ sudo bpftrace -e 'tracepoint:sched:sched_switch {...}' # off-CPU time per command, abridged
@offcpu_ms[dockerd]: 8905 ← dockerd spent ~8.9 s of the window blocked (mostly idle waiting for work)
@offcpu_ms[yes]: 0 ← the CPU hog never blocks: its problem, if any, is on-CPUA build step that shows large off-CPU time is waiting, not computing; one that shows near-zero off-CPU time while running slowly is CPU-bound and wants a profiler.
📰Real incident — the xz-utils backdoor, CVE-2024-3094 (2024). A maintainer hid a backdoor in the liblzma compression library that, when linked into OpenSSH's server through a distribution patch, let an attacker with the right key run commands on the host. It was caught not by a scanner but by performance debugging: an engineer noticed SSH logins were using far more CPU than they should and that a profiling run under Valgrind threw errors, pulled the thread, and found the backdoor before the affected versions reached stable releases. Lesson: a login that is suddenly slower, a process burning CPU it has no reason to, is a security signal, not just an annoyance. The same off-CPU/on-CPU instincts that debug a slow build are what caught a supply-chain implant.
On-CPU: profiling code that is genuinely busy
If the process is R and slow, it is executing too much code, and you want to know which code. perf samples the running stack many times a second and tells you where the time concentrated.
The frame-pointer problemA profiler reconstructs "which function called which" by walking stack frames. Compilers that omit frame pointers for speed (common for Go, and for C/Rust built -O2 without -fno-omit-frame-pointer) leave the walker guessing, and you get a flat list of raw addresses instead of a readable tree. The fix is to build the hot code with frame pointers, or use DWARF/last-branch-record unwinding. On the lab VM, perf without frame pointers shows exactly that degraded output:
$ sudo perf record -F 99 -g -- gzip -1 -c big.bin >/dev/null
$ sudo perf report --stdio --no-children --percent-limit 3
18.37% gzip gzip [.] 0x00000000000030e4 ← raw address, not a function name:
---0xc9e583aa30e4 no frame pointers / symbols in this build
15.31% gzip gzip [.] 0x000000000000cf90A flame graph turns those samples into a picture: the x-axis is the share of samples (so a wide box is where time went, width is what matters, left-to-right order does not), the y-axis is call depth. You read it by scanning for the widest boxes near the top: those are the functions actually on-CPU. It is the fastest way to answer "where is the build spending its compile time?" across thousands of short-lived compiler processes.
PermissionsProfiling reads hardware counters, which is itself gated:
$ cat /proc/sys/kernel/perf_event_paranoid
4 ← >=2 disallows kernel profiling; perf needs CAP_PERFMON or a lower valueThat gate is a security control: unrestricted performance counters are a side-channel risk, so hardened hosts raise perf_event_paranoid. On a locked-down runner you profile with elevated privilege in a controlled window, not by weakening the host.
🎯On the job. When a CI owner asks "why did compile time double?", a flame graph of one representative build usually shows it in one glance: a dependency that regressed into a hot loop, a debug build flag left on, a linker doing far more work than before. Capture it on one slow build rather than guessing from averages.
Dynamic tracing with strace: precise, and dangerous to the subject
strace prints every syscall a process makes, with arguments, return value and timing. It is unbeatable for "what file is it trying to open?" or "where does the network call stall?". It is also the tool most likely to turn a slow process into a stuck one, because it works by stopping the target on every single syscall and handing control to the tracer.
In practiceFirst, the good use, timing a network call to find the stall (-tt timestamps, -T per-call duration):
$ strace -f -tt -T -e trace=connect curl -s -o /dev/null https://example.com
21:32:35.435907 connect(3, {AF_UNIX, "/var/run/nscd/socket"}) = -1 ENOENT <0.000081> ← name cache miss, instant
21:32:35.444294 connect(7, {AF_INET, 53, 127.0.0.53}) = 0 <0.000066> ← DNS to local resolver
21:32:35.467847 connect(7, {AF_INET, 443, 104.20.23.154}) = 0 <0.000078> ← TLS target; gap before it = DNS timeThe timestamps localise the delay: time between the DNS connect and the port-443 connect is DNS resolution; a long <...> on the 443 connect itself is a network or firewall stall. This is how you tell "DNS is slow" from "the egress proxy is dropping us" without guessing.
Now the danger, measured on the lab VM, a tight loop of 20,000 syscalls run three ways:
plain: 0.12 s
under strace -f: 27.47 s ← ~230x slower: every syscall round-trips through the tracer
under strace -e trace=connect: 26.35 s ← filtering the *output* doesn't help; it still stops on every syscallThe filter reduces what is printed, not what is intercepted, so it barely helps. On a high-throughput multithreaded Go or TypeScript build, that slowdown can trip timeouts, change the timing enough to hide the bug (a heisenbug), or cause the very hang you are chasing.
Lower-overhead alternatives
(bpftrace, execsnoop, opensnoop, offcputime) attach in-kernel probes and aggregate in the kernel, so overhead is a few percent, not 200x. Prefer these on anything busy.
perf traceis a lighter syscall tracer than strace for sampling.
/proc reads(wchan, stack, fd) are free and are usually enough to localise a hang before you reach for any tracer.
📰Real incident — strace-induced deadlock pattern (ongoing). Attaching
strace -pto one thread of a running multithreaded daemon is a well-known way to deadlock it: strace stops the traced thread on a syscall while other threads keep running and block on a lock the stopped thread holds. There is no single CVE; it is a recurring outage cause. Lesson:strace -pon a production, multithreaded, lock-heavy process is a change to the system, not a passive observation. On anything you can't afford to perturb, read/procfirst and use eBPF for live tracing.
The sandbox tax: diagnosing gVisor and microVM overhead
On hardened runners the "slowness" is often the isolation layer doing its job, and the skill is telling expected cost apart from a real bottleneck.
gVisor (runsc)gVisor answers the workload's syscalls in a user-space kernel (the Sentry) and routes file operations through a host helper (the Gofer). Both add cost per operation, so CPU-bound code runs near native while small-file, syscall-heavy work (an npm ci, a BuildKit build compiling thousands of files) slows noticeably. The lab VM shows the shape, a 3,000-tiny-file workload under each runtime:
$ time docker run --runtime=runc alpine sh -c 'for i in $(seq 3000); do echo x >/tmp/f$i; done'
runc: 0.34 s
$ time docker run --runtime=runsc alpine sh -c '...same...'
runsc: 0.50 s ← ~1.5x here; on real builds with large files the gap widens toward 2xTo decide where the gVisor cost lands, syscall emulation (Sentry) or file I/O (Gofer), turn on its strace log and look at the per-call durations:
$ docker run --runtime=runsc-debug alpine sh -c 'cat /etc/hostname' # --strace enabled
cat E openat(AT_FDCWD, "/etc/hostname", O_RDONLY) ...
cat X openat(AT_FDCWD, "/etc/hostname", O_RDONLY) = 3 (278.879µs) ← one open took 279µs through the GoferIf opens and reads dominate (hundreds of µs each, many of them), the bottleneck is file-system reflection through the Gofer, and the fix is fewer files / a cache / a different storage mode, not more CPU. If compute dominates the flame graph and syscalls are cheap, gVisor is not your problem. The sandbox isolation deep dive covers the platform modes that change this.
microVMs and Apple Virtualization (vz)Inside a Firecracker/Kata microVM or an Apple vz guest on Apple Silicon, the guest has its own kernel, so wchan, PSI and iostat all work normally inside the guest, that is where you debug. The extra question is the virtual device: a hang on virtio-fs (file sharing into the guest) or a virtio-net stall looks like disk or network trouble inside the guest but is really the host-side device daemon. On vz you cannot perf the host kernel from inside the guest, so you profile in the guest and correlate with host-side CPU separately. The decision between the two sandbox families is in the sandbox isolation deep dive.
🎯On the job. When someone reports "builds on the sandboxed runners are 4x slower", the first move is not to remove the sandbox. It's to establish the shape: CPU-bound (flame graph) or syscall/IO-bound (strace-log durations, PSI io). gVisor's tax is on the second shape, and the answer is usually caching the dependency fetch, not abandoning isolation.
Network stalls: the slowness that looks like a hang
A session or download that "freezes" is often a network wait, and the socket state names the cause. ss -tanpi and /proc/net/tcp expose it.
$ ss -tanpi dst :443
State Recv-Q Send-Q Peer Process
CLOSE-WAIT 415 0 104.20.23.154:443 python3 pid=101449 ← app never close()d after peer hung up
cubic rtt:12.9/7.6 cwnd:10 ... ← healthy RTT; the stall is the app, not the netReading the states:
The peer closed; your side hasn't called close(). A pile of these = a file-descriptor leak in the app, not a network fault.
Normal: a closed connection waiting out its timeout. Thousands can exhaust ports but aren't a hang.
Your SYN got no reply: a firewall or egress proxy is dropping it silently. Classic CI symptom behind a default-deny egress.
Data arrived that the app isn't reading: the app is blocked (check its wchan), not the network.
You have data to send but the window is near zero: the receiver or a middlebox is the throttle.
TLS handshake hangs (common behind an egress proxy or an automatic-certificate service) show as a connected socket with no application data flowing, the TCP connect succeeded but the handshake stalled, which you see as a long gap after connect in an strace and a live-but-silent socket in ss. SSH keepalive failures show as a session that dies silently through a stateful firewall or NAT that dropped the idle flow; ClientAliveInterval on the server keeps the flow warm and surfaces a dead peer promptly instead of hanging. For the TCP header fields and congestion-control mechanics behind these, see the tcpdump & eBPF deep dive.
🎯On the job. "Downloads from the package registry hang intermittently on the runners" is, four times out of five, a
SYN-SENTstuck against the egress proxy or a dropped idle TLS flow, not a slow mirror.ss -tanpifrom inside the job during a hang tells you which, and whether the proxy allowlist is quietly dropping a host the build newly depends on.
CPU saturation vs cgroup throttling
A build can be CPU-bound two ways that look identical in top and need opposite fixes. True saturation is more runnable work than cores, visible as a run queue (r in vmstat) above the core count and a rising CPU PSI. cgroup throttling is the job hitting a cpu.max ceiling the platform set, visible only inside the cgroup, the CPU may be mostly idle at the host level while the job crawls.
In practiceA container capped at half a core, running two busy loops:
$ docker run --cpus=0.5 alpine sh -c 'yes>/dev/null & yes>/dev/null & sleep 3; cat /sys/fs/cgroup/cpu.stat'
nr_periods 31
nr_throttled 31 ← throttled in every scheduling period
throttled_usec 4552700 ← 4.5 s parked waiting for quota, while host CPUs sat ~idleThe host's vmstat would show idle CPU the whole time; only cpu.stat inside the cgroup reveals the job is capped. If you debug this from the host you conclude "CPU is fine" and never find it. The lesson for multi-tenant platforms: always read the job's own cgroup, not just the host.
📰Real incident — the Cloudflare regex outage (2 July 2019). A single firewall rule with a regular expression that backtracked catastrophically drove CPU to 100% on every machine handling traffic, taking the service down worldwide for about 27 minutes. It is the canonical "on-CPU saturation" story: not a leak, not I/O, just one piece of code burning every available cycle, and a CPU flame graph pointed straight at the regex engine. Lesson: runaway CPU is often one hot function, and a flame graph finds it in one capture; and a resource limit (a CPU budget, a regex step limit) turns "the fleet is down" into "one rule is slow".
A diagnostic order that doesn't waste time
Put it together as a sequence, cheapest and least invasive first:
-
1. Is it running or waiting?defendread /proc/
/status State and /proc/ /wchan — free, instant - R and slow → on-CPU path. S/D and slow → off-CPU path.
-
2. First 60 seconds on the hostdefenduptime, vmstat 1, PSI (/proc/pressure/*), iostat -xz 1, free
- Rules whole resources in or out before you touch the process
-
3. Read the job's own cgroupdefendcpu.stat nr_throttled, memory.events, *.pressure
- Catches throttling and OOM the host view hides
-
4. Localisedefendoff-CPU: ss/strace-the-connect for network, wchan for locks/IO. on-CPU: perf + flame graph
- Use eBPF, not strace -p, on anything busy or production
-
5. Account for the sandboxdefendif runsc, check Gofer open/read durations; if a VM, debug inside the guest + virtio devices
- Expected tax, not a bug, unless the shape says otherwise
TipSee it yourselfThe sandbox-isolation lab Level 6 ("where the time goes") runs the same workload under runc and runsc and has you read the per-syscall cost, the exact gVisor-tax measurement this page describes.
Interview Questions
Read /proc/<pid>/status and /proc/<pid>/wchan first, all free: the sleep state and the kernel function it's parked in already split the three. D state with an I/O wchan plus iostat showing the disk saturated means disk; a wchan of pipe_read or a futex with a sibling holding the lock means deadlock; a socket in SYN-SENT or a long gap after connect in an strace -tt -T -e connect means the proxy or DNS. I confirm with PSI (/proc/pressure/io vs cpu) and ss -tanpi, and I never attach strace -p to the busy process, because on a multithreaded build it can deadlock the thing I'm trying to observe.
USE is resource-centric for one host, Utilization, Saturation, Errors for every resource, and it's a coverage checklist so you don't forget disk or the error counter. RED is service-centric, Rate, Errors, Duration for a request-handling service. A slow runner is USE; a slow API is RED. Saturation specifically: CPU is the run queue in vmstat and /proc/pressure/cpu; memory is swap in/out (si/so) and /proc/pressure/memory; disk is iostat queue depth (aqu-sz) and /proc/pressure/io. Utilization tells you a resource is busy; saturation tells you work is queued behind it, which is the one that actually hurts.
/proc/<pid>/wchan, /proc/<pid>/stack and /proc/<pid>/net/tcp for a frozen SSH session?wchan gives the one kernel function it's sleeping in, so n_tty_read or do_select means it's just waiting for input, while a lock function means a real block. stack (as root) gives the full kernel call path into that sleep to confirm it. /proc/<pid>/net/tcp, or more readably ss -tanpi, shows the session's socket: a healthy RTT with a silent connection points at the application or a dropped keepalive, a stuck SYN-SENT points at a firewall. Together they tell me in seconds whether it's frozen (deadlocked) or merely idle, without perturbing it.
strace -p wreck a high-throughput multithreaded process, and what do you use instead?strace stops the target on every single syscall and round-trips through the tracer, so overhead is enormous, I measured a tight loop go from 0.12 s to 27 s, about 230x, and filtering with -e only cuts what's printed, not what's intercepted, so it barely helps. On a multithreaded daemon it can also deadlock: the stopped thread holds a lock the others block on. I use eBPF tools instead, bpftrace, execsnoop, offcputime, which aggregate in-kernel at a few percent overhead, or perf trace, and I read /proc for hangs before reaching for any tracer at all.
Check the state: R and slow means on-CPU, so I take a perf profile and a flame graph and read the widest boxes near the top as the hot functions. S or D and slow means off-CPU, so I capture an off-CPU trace (offcputime via eBPF) that sums time each thread spends blocked and attributes it to the stack it blocked on. An off-CPU flame graph is read the same way, width is time spent waiting, so a wide box is where the program was stuck, and the stack under it names what it waited on, a lock, a read, a socket. On-CPU answers "what code?", off-CPU answers "waiting on what?".
First establish the shape. If a flame graph shows compute dominating and syscalls are cheap, gVisor isn't the cause. If it's syscall- or I/O-heavy, I turn on runsc's strace debug log and read per-call durations: lots of openat/read taking hundreds of microseconds each means the Gofer, gVisor's file-system helper, is the bottleneck, and the fix is fewer files or a cache or a different storage mode, not more CPU. If the costly calls are compute-adjacent syscalls handled in the Sentry, that's the emulation tax. The 4x is almost always small-file I/O through the Gofer, the classic npm ci/BuildKit pattern.
Inside the guest the normal tools all work, wchan, PSI, iostat, so that's where I debug the workload. The differences are two: I can't profile the host kernel from inside the guest, so host-side CPU has to be correlated separately, and anything that looks like disk or network trouble might actually be the virtual device, virtio-fs for shared files or virtio-net, whose work happens in a host-side daemon. So a guest iostat showing I/O stalls on a virtio-fs mount sends me to the host's file-sharing daemon, not to the guest's disk. On bare metal there's no such split; perf sees everything and the device is real hardware.