Software Engineering WikiSE Wiki

Linux performance

Find whether CPU, memory, disk or network is the bottleneck on a Linux host with a first-minute checklist, the USE method and per-resource tools.

Reviewed MarkdownEdit

On this page

Cheatsheet#

QuestionCommand
Is any resource stalling taskscat /proc/pressure/{cpu,io,memory}
Load and run queueuptime, vmstat 1 5
Per-CPU utilisationmpstat -P ALL 1
Top processestop -o %CPU, top -o %MEM
Memory that is really availablefree -h (the available column)
Disk latency and queueiostat -xz 1
Per-process I/Opidstat -d 1, iotop -o
Network throughputsar -n DEV 1
Retransmits and dropsnstat -az | grep -iE 'retrans|drop', ss -ti
Who owns a portss -ltnp
Syscalls a process makesstrace -c -f -p PID
Where a process is blockedcat /proc/PID/wchan, sudo cat /proc/PID/stack
CPU profileperf top, perf record -F 99 -a -g -- sleep 30
Usage per systemd unitsystemd-cgtop

mpstat, pidstat, iostat and sar come from the sysstat package, which is often not installed by default. The bcc tools are named biolatency-bpfcc and so on on Debian and Ubuntu, and installed under /usr/share/bcc/tools/ on Fedora and RHEL.

The first minute#

Brendan Gregg’s 60-second checklist, run in order on a host you know nothing about. Each line either rules a resource out or points at the next section.

uptime                     # 1, 5, 15 minute load averages: rising or falling
dmesg -T | tail -20        # OOM kills, disk errors, link flaps, hung tasks (may need root)
vmstat 1 5                 # r (runnable), b (blocked), si/so (swapping), us/sy/id/wa/st
mpstat -P ALL 1 3          # one hot CPU hides in the average
pidstat 1 3                # per-process CPU over time, unlike top's snapshot
iostat -xz 1 3             # per device: r/s, w/s, await, aqu-sz, %util
free -h                    # read "available", not "free"
sar -n DEV 1 3             # per-interface rxkB/s and txkB/s against link speed
sar -n TCP,ETCP 1 3        # new connections per second, retransmits per second
top                        # cross-check the picture

Ignore the first line of vmstat and iostat output: it is the average since boot, not the current interval.

Linux load average counts tasks that are runnable plus tasks in uninterruptible sleep (state D, usually waiting on disk or NFS). A load of 30 on a host with idle CPUs therefore means blocked I/O, not CPU demand. Compare load against the CPU count (nproc) and look at b in vmstat.

cat /proc/pressure/cpu /proc/pressure/io /proc/pressure/memory
some avg10=12.40 avg60=8.10 avg300=3.02 total=160383532
full avg10=0.00 avg60=0.00 avg300=0.00 total=0

Pressure stall information (PSI, kernel 4.20+) measures lost time directly. some is the share of time at least one task was stalled on the resource; full is the share when all non-idle tasks were stalled at once, meaning no work progressed. avg10, avg60 and avg300 are percentages over 10, 60 and 300 seconds. System-wide cpu full is always zero. Non-zero memory full or io full is a strong sign of a bottleneck. Every cgroup v2 directory has the same cpu.pressure, memory.pressure and io.pressure files for per-service or per-container views. See the kernel PSI documentation.

If /proc/pressure does not exist, the kernel was built without CONFIG_PSI or with PSI disabled by default; the psi=1 kernel parameter enables it in the latter case.

USE, per resource#

The USE method checks every resource for Utilisation (how busy), Saturation (work queued because it is busy) and Errors. Check saturation even when utilisation looks acceptable: a resource averaging 60% busy can still be saturated in bursts shorter than the sampling interval. Source: Gregg’s USE Linux checklist.

ResourceUtilisationSaturationErrors
CPUmpstat -P ALL 1; vmstat us+sy+stvmstat r above CPU count; PSI cpu; cgroup nr_throttledMachine-check events in dmesg, ras-mc-ctl --errors
Memoryfree -h, vmstat freevmstat si/so; sar -B pgscank/pgscand; PSI memory; OOM kills in dmesgHardware errors (ECC/EDAC) in dmesg
Diskiostat -xz 1 %utilaqu-sz, rising r_await/w_await; PSI iodmesg I/O errors, smartctl -a /dev/sda
Networksar -n DEV 1 against link speed; ip -s linkDrops and overruns in ip -s link; retransmits in nstatip -s link errors; ethtool -S eth0

CPU#

mpstat -P ALL 1                  # %usr, %sys, %iowait, %steal, %irq, %soft per CPU
pidstat -u 1                     # per-process %usr and %system
pidstat -w 1                     # voluntary (cswch/s) and involuntary (nvcswch/s) context switches
perf top -F 99                   # live profile by function
perf record -F 99 -a -g -- sleep 30 && perf report --stdio | head -40   # 30 s system-wide with stacks
taskset -cp PID                  # CPU affinity of a process

perf needs root, or kernel.perf_event_paranoid lowered, to profile other users’ processes and the kernel. Stacks need frame pointers or debug info; without them perf report shows [unknown] frames.

SymptomMeaning
High %usrApplication work; profile it with perf
High %sysKernel work: syscall-heavy code, context switching, network stack, page faults
High %iowaitCPU idle while I/O is outstanding. A disk symptom, not CPU load, and it falls when other work keeps the CPU busy
High %stealThe hypervisor ran another guest on this vCPU
High %soft on one CPUNetwork receive processing pinned to one queue; check RSS and IRQ affinity
High involuntary context switchesMore runnable threads than CPUs, or CPU throttling
One CPU at 100%, rest idleSingle-threaded bottleneck, or interrupts on one CPU

A container or systemd CPU limit (cpu.max, CPUQuota=) throttles the cgroup for the rest of each period once it uses its quota. Throttling shows as latency while the host looks idle.

cat /sys/fs/cgroup/system.slice/my-app.service/cpu.stat   # nr_periods, nr_throttled, throttled_usec
kubectl exec my-pod -- cat /sys/fs/cgroup/cpu.max /sys/fs/cgroup/cpu.stat   # inside a pod

nr_throttled rising between two reads means the limit is too low for the burst pattern, even if average usage is under the limit.

Memory#

free -h
grep -E 'MemAvailable|Dirty|Writeback|Slab|SUnreclaim|Committed_AS' /proc/meminfo
ps -eo pid,comm,rss,vsz --sort=-rss | head       # resident set size in KiB
smem -rs uss | head                               # unique set size: memory freed if the process exits
slabtop -o | head                                 # kernel object caches (root)
vmstat 1 5                                        # si/so: pages swapped in/out per second
dmesg -T | grep -iE 'out of memory|killed process'

free counts page cache in buff/cache. available (MemAvailable) estimates how much can be allocated without swapping, including reclaimable cache; that is the number to watch. A full page cache is normal and makes repeat reads fast.

Swap in use is not a problem by itself. Ongoing swap activity (si/so non-zero every second) is.

cat /sys/fs/cgroup/system.slice/my-app.service/memory.current
cat /sys/fs/cgroup/system.slice/my-app.service/memory.max
cat /sys/fs/cgroup/system.slice/my-app.service/memory.events   # high, max, oom, oom_kill counters

An OOM kill inside a cgroup sends SIGKILL, so the process gets no chance to log. Orchestrators report exit code 137 (128 + 9). The oom_kill counter in memory.events and the dmesg line are the evidence. memory.events high rising means the cgroup is being throttled at memory.high / MemoryHigh=.

Disk#

iostat -xz 1                    # r_await/w_await: ms per I/O including queueing; aqu-sz: average queue length
pidstat -d 1                    # kB_rd/s, kB_wr/s, iodelay per process
iotop -oPa                      # accumulated I/O by process (root)
biolatency 10 1                 # block I/O latency histogram (bcc, root)
df -h; df -i                    # space and inodes
lsof +L1                        # deleted files still held open

%util is the share of time the device had at least one request in flight. For SSDs, NVMe and RAID, which serve many requests in parallel, 100% does not mean saturated; the iostat man page says so. Use await and aqu-sz instead. Older sysstat releases print avgqu-sz for aqu-sz, and a svctm column that should be ignored.

A filesystem that reports full while df -h shows free space has run out of inodes (df -i) or is df on a different mount. Space that du cannot account for is usually a deleted file still held open by a process; it is freed only when the process closes it or restarts.

Network#

sar -n DEV 1                     # rxkB/s, txkB/s, %ifutil per interface
sar -n TCP,ETCP 1                # active/s (outbound), passive/s (inbound), retrans/s
nstat -az | grep -E 'TcpRetransSegs|TcpExtListenDrops|TcpExtListenOverflows|TcpExtTCPSynRetrans'
ss -ti                           # per-socket rtt, cwnd, retrans, delivery rate
ss -ltn 'sport = :8080'          # Recv-Q on a listener = connections waiting for accept()
ip -s link show eth0             # RX/TX errors, dropped, overruns
ethtool -S eth0 | grep -iE 'drop|err|miss|fifo'   # driver-level counters
mtr -rwzbc 100 host.example.com  # per-hop loss and latency

nstat prints counter deltas since its last run (it keeps history per user); -a shows absolute values and -z includes zero counters.

SignalMeaning
Retransmits risingLoss in the path or a congested link; ss -ti shows which peers
ListenOverflows / ListenDrops risingAccept queue full: the application is not calling accept() fast enough, or the backlog is small
Large TIME_WAIT countNormal for a busy client; a problem only if ephemeral ports run out
RX dropped on the interfaceHost not draining ring buffers fast enough; check ethtool -g and softirq CPU
RX errorsPhysical layer: cable, optic, switch port, duplex
High or variable rtt in ss -tiQueueing in the path, or a distant peer
ethtool -g eth0                  # ring buffer sizes, current and maximum
sysctl net.core.somaxconn net.ipv4.tcp_max_syn_backlog

For links, routes and sockets see iproute2.

Tracing#

strace -c -f -p PID               # syscall counts and time; Ctrl-C to print the summary
strace -f -tt -e trace=openat,connect -p PID   # specific syscalls with timestamps
execsnoop                         # every exec on the host, with arguments (bcc)
opensnoop -p PID                  # files a process opens (bcc)
tcpconnect                        # outbound TCP connections as they happen (bcc)
biosnoop                          # every block I/O with process and latency (bcc)
funclatency vfs_read              # latency histogram for a kernel function (bcc)
profile -F 99 30                  # sampled stacks for 30 s (bcc)

strace uses ptrace, stopping the target on every syscall; syscall-heavy processes can slow by an order of magnitude or more. Use it on a replica or for a few seconds. The bcc and bpftrace tools run in the kernel via eBPF and summarise there, so overhead is usually low, although tracing very frequent events still costs CPU. All need root.

perf#

perf samples or counts hardware and software events. perf stat answers “how much” (cycles, instructions, faults, context switches) with almost no overhead; perf record answers “where” by sampling stacks at a fixed rate; perf trace is an strace replacement that does not stop the target.

perf list | head -40                                     # events this kernel and CPU expose
perf stat -- ./my-app --once                             # task-clock, context switches, migrations, page faults, cycles, IPC
perf stat -e task-clock,page-faults,context-switches -p "$PID" -- sleep 10   # counters for a running process
perf stat -a -e cycles,instructions,cache-misses -- sleep 5   # whole machine; IPC well under 1 means memory-bound
perf record -F 99 -g -p "$PID" -- sleep 30               # 99 Hz with stacks for one process
perf record -F 99 -a -g --call-graph dwarf -- sleep 10   # unwind without frame pointers; larger perf.data
perf report --stdio --no-children --percent-limit 1      # self time per symbol, top-down
perf report --sort comm,dso                              # which processes and libraries own the samples
perf annotate --stdio -s my_hot_function                 # instruction-level breakdown of one function
perf script | stackcollapse-perf.pl | flamegraph.pl > flame.svg   # FlameGraph scripts, github.com/brendangregg/FlameGraph
perf trace -p "$PID" -- sleep 5                          # syscalls with durations, via tracepoints rather than ptrace
perf trace -s -p "$PID" -- sleep 5                       # per-syscall summary: count, total, min, avg, max
perf record -e sched:sched_switch -a -- sleep 5 && perf sched latency --sort max   # scheduler run-queue delay per task
perf top -e cache-misses                                 # live view for one event

Sampling at 99 Hz rather than 100 avoids lockstep with timers. -g records the kernel stack and, if the binary has frame pointers, the user stack; Go, Rust and most distribution C libraries keep frame pointers, JIT runtimes and -fomit-frame-pointer builds do not, hence --call-graph dwarf. perf.data is written to the current directory and can be hundreds of megabytes; perf record -o puts it elsewhere. perf sched needs CONFIG_SCHEDSTATS and records every context switch, so keep the window short on busy hosts.

bpftrace#

bpftrace attaches short programs to kernel tracepoints, kprobes, user-space probes and timers, aggregates in the kernel and prints maps on exit. It replaces one-off C for the questions that perf and the bcc tools do not already answer.

bpftrace -l 'tracepoint:syscalls:sys_enter_*' | wc -l    # list probes; -lv shows a probe's arguments
bpftrace -lv tracepoint:block:block_rq_issue
bpftrace -e 'tracepoint:syscalls:sys_enter_openat { printf("%-16s %s\n", comm, str(args.filename)); }'
bpftrace -e 'tracepoint:syscalls:sys_enter_* { @[probe] = count(); } interval:s:5 { print(@); clear(@); }'   # syscall rate table every 5 s
bpftrace -e 'kprobe:vfs_read { @bytes[comm] = hist(arg2); }'                    # requested read sizes per command
bpftrace -e 'kretprobe:vfs_read /retval > 0/ { @returned = hist(retval); }'     # actual bytes returned
bpftrace -e 'tracepoint:syscalls:sys_enter_read { @s[tid] = nsecs; }
             tracepoint:syscalls:sys_exit_read /@s[tid]/ { @us[comm] = hist((nsecs - @s[tid]) / 1000); delete(@s[tid]); }'   # read() latency per command
bpftrace -e 'profile:hz:99 /pid == $1/ { @[ustack] = count(); }' "$PID"          # user stacks sampled at 99 Hz, $1 is the first argument
bpftrace -e 'tracepoint:sched:sched_process_exit { printf("%d %s exited\n", pid, comm); }'
bpftrace -e 'kprobe:tcp_retransmit_skb { @[kstack] = count(); }'                # who is retransmitting
bpftrace -e 'uprobe:/usr/lib64/libc.so.6:malloc /pid == $1/ { @sz = hist(arg0); }' "$PID"   # allocation sizes in one process
bpftrace -e 'tracepoint:oom:mark_victim { printf("OOM killed pid %d\n", args.pid); }'

Filters go in /.../ before the block. @name[key] = hist(x) builds a power-of-two histogram, count(), sum(), avg(), min(), max() and lhist() (linear) are the other aggregations; printf fires per event and floods on hot probes, so aggregate instead. Tracepoints are stable across kernel versions; kprobes attach to any kernel function but the function and its argument order can change, and the argument names from -lv need BTF (/sys/kernel/btf/vmlinux). args.field syntax is bpftrace 0.20+; older releases used args->field. The bpftrace package ships the classic bcc tools as .bt scripts under /usr/share/bpftrace/tools/.

cgroup accounting#

Every systemd unit, container and Kubernetes pod is a cgroup v2 directory under /sys/fs/cgroup, and the kernel accounts CPU, memory, I/O and PIDs per directory. When the host looks idle but one workload is slow, its cgroup files hold the evidence.

cat /sys/fs/cgroup/cgroup.controllers          # controllers enabled on this host: cpuset cpu io memory pids ...
cat /proc/"$PID"/cgroup                        # 0::/system.slice/my-app.service: the path under /sys/fs/cgroup
systemctl show -p ControlGroup my-app.service
systemd-cgls --no-pager system.slice/my-app.service   # processes in the unit's cgroup
systemd-cgtop -m -d 2 --depth 4                # live table sorted by memory, refreshed every 2 s

cd /sys/fs/cgroup/system.slice/my-app.service
cat cpu.stat          # usage_usec user_usec system_usec nr_periods nr_throttled throttled_usec nr_bursts burst_usec
cat cpu.max           # "max 100000" is unlimited; "50000 100000" is half a CPU per 100 ms period
cat cpu.weight        # 1..10000, default 100; relative share only when CPUs are contended
cat memory.current memory.max memory.high memory.peak     # memory.peak needs kernel 5.19+
grep -E '^(anon|file|kernel|slab|file_dirty|workingset_refault_file|pgscan|pgsteal) ' memory.stat
cat memory.events     # low high max oom oom_kill: counters, not gauges; diff two reads
cat io.stat           # per device: rbytes wbytes rios wios dbytes dios
cat io.pressure memory.pressure cpu.pressure   # PSI scoped to this cgroup
cat pids.current pids.max

Set limits with systemd rather than writing the files, so they survive a restart and appear in the unit’s properties. systemctl set-property writes a drop-in under /etc/systemd/system.control/ and applies immediately; --runtime keeps it until reboot.

sudo systemctl set-property my-app.service CPUQuota=200% MemoryHigh=1500M MemoryMax=2G IOWeight=50
sudo systemctl set-property --runtime my-app.service CPUWeight=500     # experiment first, persist later
systemd-run --scope -p MemoryMax=512M -p CPUQuota=50% -- ./batch-job    # one-off command inside a transient cgroup
systemctl show -p CPUQuotaPerSecUSec,MemoryMax,MemoryHigh my-app.service

On a Kubernetes node the pod’s cgroup is kubepods.slice/kubepods-<qos>.slice/kubepods-<qos>-pod<uid>.slice/ with one cri-containerd-<id>.scope (or crio-<id>.scope) per container; kubectl get pod my-pod -o jsonpath='{.metadata.uid}' gives the UID with dashes, the slice name replaces them with underscores. Inside a container, /sys/fs/cgroup is the container’s own cgroup, so cat /sys/fs/cgroup/cpu.stat works without knowing the path.

memory.current includes page cache the cgroup touched, so it can sit near memory.max indefinitely on a healthy I/O-heavy service. The anon line in memory.stat and workingset_refault_file are the better signals: growing anon is the application, growing refaults mean the cache is too small for the working set and reads are going back to disk.

Memory pressure#

Pressure develops in stages: reclaim of clean page cache (cheap), writeback of dirty pages and swap-out (costly, shows in sar -B and PSI), then OOM. PSI memory some above a few percent for a minute means tasks are already waiting on reclaim; full means the whole cgroup or host is stalled.

sar -B 1 5                       # pgscank/s and pgscand/s: kswapd and direct reclaim scanning; pgsteal/s: pages reclaimed; %vmeff
sar -r 1 3                       # kbmemfree, kbavail, %memused, kbdirty
sar -W 1 3                       # pswpin/s, pswpout/s
grep -E 'workingset_refault|pgmajfault|allocstall' /proc/vmstat   # refaults: cache evicted then read again; allocstall: direct reclaim
watch -n1 'cat /proc/pressure/memory'
swapon --show; zramctl          # swap devices; Fedora uses a zram device by default
sysctl vm.swappiness vm.dirty_ratio vm.dirty_background_ratio vm.min_free_kbytes
cat /sys/kernel/mm/transparent_hugepage/enabled   # [always] causes khugepaged CPU and latency spikes for some workloads; madvise is the safe setting
for p in /proc/[0-9]*; do printf '%s %s %s\n' "$(cat "$p/oom_score" 2>/dev/null)" "${p#/proc/}" "$(cat "$p/comm" 2>/dev/null)"; done | sort -rn | head   # most likely OOM victims

MemoryHigh= (memory.high) throttles a cgroup’s allocations by forcing reclaim before it reaches the limit, turning a would-be OOM kill into slowness; MemoryMax= (memory.max) is the hard line where the kernel kills. Set both: MemoryHigh somewhat below MemoryMax gives a warning window in memory.events high and PSI before anything dies. memory.oom.group=1 (OOMPolicy=kill in systemd) kills the whole cgroup instead of one process, which keeps a multi-process service consistent.

systemd-oomd watches PSI and swap per cgroup and kills the offending slice before the kernel OOM killer runs. oomctl shows what it monitors; ManagedOOMMemoryPressure=kill and ManagedOOMMemoryPressureLimit= on a slice enable it. A service that disappears with systemd-oomd killed in the journal was killed by user-space policy, not by the kernel, and dmesg will show nothing.

PSI files accept triggers: writing some 150000 1000000 to memory.pressure and polling the descriptor wakes the reader when tasks were stalled for 150 ms within any 1 s window. That is how oomd and container runtimes react within seconds rather than at the next scrape.

drop_caches

echo 3 > /proc/sys/vm/drop_caches discards clean page cache and slab objects. It is not a fix for anything: the kernel already frees cache on demand, and the next reads go to disk. Use it only to reset a benchmark.

I/O latency#

iostat gives averages per device and interval; a p99 of 200 ms hides inside an await of 5 ms when most requests are fast. Latency histograms and per-request traces show the distribution and who caused it.

iostat -xz 1                     # r_await w_await d_await f_await (discard, flush); aqu-sz; rareq-sz wareq-sz (average request size)
biolatency -D 10 1               # histogram per disk over 10 s (bcc); -Q includes time queued in the block layer
biosnoop                         # each I/O: time, comm, pid, disk, direction, sector, bytes, latency ms
biotop 5                         # top processes by block I/O
ext4slower 10                    # ext4 operations slower than 10 ms with file name; xfsslower, nfsslower, btrfsslower
fileslower 10                    # synchronous file reads and writes above 10 ms, any filesystem
filetop -C                       # busiest files by reads and writes
cat /proc/"$PID"/io              # rchar wchar read_bytes write_bytes: what this process has done since it started
awk '{print "run=" $1/1e6 "ms wait=" $2/1e6 "ms"}' /proc/"$PID"/schedstat   # run-queue wait time for a task
cat /sys/block/nvme0n1/queue/scheduler         # [none] for NVMe, mq-deadline or bfq for SATA and virtual disks
cat /sys/block/sda/queue/nr_requests /sys/block/sda/queue/rotational
ioping -c 10 /var/lib/postgresql               # request latency to a directory's filesystem, like ping
fio --name=randread --filename=/var/tmp/fio.test --size=1G --rw=randread --bs=4k --iodepth=1 --direct=1 --runtime=20 --time_based --group_reporting   # single-queue random read latency; creates a 1 GiB file
lsblk -o NAME,SIZE,TYPE,MOUNTPOINTS,ROTA,SCHED,RQ-SIZE

fio with --iodepth=1 --direct=1 measures the latency a database’s fsync-bound path sees; raise iodepth to measure throughput instead. Delete the test file afterwards. d_await and f_await (sysstat 12+) separate discards and flushes, which on some virtual disks and consumer SSDs dominate write latency; fstrim timers and fsync-heavy workloads show there. Steady aqu-sz above the device’s effective queue depth with rising await is saturation; await rising while aqu-sz stays low points at the device or the layer below it (the hypervisor, the SAN).

Where to look first#

SymptomStart with
Everything slow, load highvmstat 1: compare r with b, then us/sy/wa
One service slow, host looks fineIts cgroup PSI and cpu.stat throttling; ss -ti to its dependencies
Latency spikes at intervalsGC pauses, log rotation, cron or timers, snapshotting, CPU throttling
Slow after a deployCompare perf profiles before and after
Slow only under loadQueueing: accept backlog, connection pool size, thread count
Memory grows steadilyLeak or page cache; compare smem USS over time, not free
Disk full but files deletedlsof +L1
Process in state D for a long timecat /proc/PID/wchan, dmesg for hung_task warnings, NFS or storage errors

Oneliners#

# Top 10 processes by resident memory
ps -eo pid,comm,rss --sort=-rss | head -11

# Processes averaging over 1% CPU across 10 seconds
LC_ALL=C pidstat -u 1 10 | awk '/^Average/ && $8 > 1 {print $8, $NF}' | sort -rn | head

# Processes writing to disk now (kB_wr/s)
LC_ALL=C pidstat -d 1 3 | awk '$5 ~ /^[0-9.]+$/ && $5 > 0 {print $5, $NF}' | sort -rn | head

# Established connections by remote address
ss -tn state established | awk 'NR > 1 {sub(/:[^:]*$/, "", $4); print $4}' | sort | uniq -c | sort -rn | head

# Sockets in each TCP state
ss -tan | awk 'NR > 1 {print $1}' | sort | uniq -c | sort -rn

# Largest directories on one filesystem
du -x -d 1 /var 2>/dev/null | sort -h | tail

# Deleted files still consuming space: size, command, path
sudo lsof +L1 | awk 'NR > 1 {print $7, $1, $10}' | sort -rn | head

# Interrupts and context switches per second
vmstat 1 5 | awk 'NR > 2 {print "in=" $11, "cs=" $12}'

# Threads of a process by CPU
top -H -b -n 1 -p "$PID" | head -20

# Live CPU and memory by cgroup
systemd-cgtop -m --depth=3

# Record 20 s of stacks for a flame graph
sudo perf record -F 99 -a -g -- sleep 20 && sudo perf script > out.perf

# PSI for every resource on one line, refreshed each second
watch -n1 'for r in cpu io memory; do printf "%-7s" $r; head -1 /proc/pressure/$r; done'

# Services with the highest memory PSI over the last minute
for f in /sys/fs/cgroup/system.slice/*/memory.pressure; do awk -v u="${f%/memory.pressure}" '/^some/ {split($3,a,"="); print a[2], u}' "$f"; done | sort -rn | head

# Units currently being CPU-throttled (nr_throttled rising is the real test; this shows any throttling since start)
grep -H '^nr_throttled' /sys/fs/cgroup/system.slice/*/cpu.stat | awk -F'[:/ ]' '$NF > 0 {print $NF, $(NF-2)}' | sort -rn | head

# Memory usage per unit, largest first
for d in /sys/fs/cgroup/system.slice/*/; do [ -f "$d/memory.current" ] && printf '%s %s\n' "$(numfmt --to=iec < "$d/memory.current")" "$(basename "$d")"; done | sort -h | tail

# Who was OOM-killed, from the journal, with cgroup path
journalctl -k -g 'oom-kill\|Killed process' --no-pager -n 20

# Kills by systemd-oomd (user-space OOM) in the last day
journalctl -u systemd-oomd --since -1d --no-pager

# Processes in uninterruptible sleep and what they are waiting on
ps -eo pid,stat,wchan:32,comm | awk '$2 ~ /D/'

# Major page faults per process (reads from disk to satisfy a fault)
ps -eo pid,maj_flt,comm --sort=-maj_flt | head

# Per-CPU softirq distribution: one column much higher than the rest means IRQ imbalance
awk '/NET_RX/ {for (i = 2; i <= NF; i++) printf "%s ", $i; print ""}' /proc/softirqs

# IRQs and the CPUs handling them
grep -E 'eth0|nvme' /proc/interrupts | awk '{s = 0; for (i = 2; i < NF - 2; i++) s += $i; print $1, s, $NF}'

# Count syscalls by name for 10 s, no ptrace
sudo bpftrace -e 'tracepoint:raw_syscalls:sys_enter { @[comm] = count(); } interval:s:10 { exit(); }'

# Files being opened right now, by process
sudo bpftrace -e 'tracepoint:syscalls:sys_enter_openat { printf("%-6d %-16s %s\n", pid, comm, str(args.filename)); }'

# Off-CPU time by user stack for one process, 10 s (why it is waiting, not what it is running)
sudo offcputime -p "$PID" -u 10 | head -60

# Block I/O latency histogram per disk for 30 s
sudo biolatency -D 30 1

# fsync latency of a process (database checkpoints, log flushes)
sudo bpftrace -e 'tracepoint:syscalls:sys_enter_fsync /pid == $1/ { @s[tid] = nsecs; } tracepoint:syscalls:sys_exit_fsync /@s[tid]/ { @ms = hist((nsecs - @s[tid]) / 1000000); delete(@s[tid]); }' "$PID"

# Cycles, instructions and IPC for one process over 10 s
sudo perf stat -e cycles,instructions,task-clock -p "$PID" -- sleep 10

# Scheduler run-queue latency: time from wakeup to running, as a histogram
sudo runqlat 10 1

# Hottest kernel and user functions, whole host, 10 s, text output
sudo perf record -F 99 -a -g -- sleep 10 >/dev/null && sudo perf report --stdio --no-children --percent-limit 2 | grep -v '^#' | head -40

# CPU frequency per core (throttled or power-saving cores show low values)
grep MHz /proc/cpuinfo | sort | uniq -c

# Container's own CPU throttling from inside the container
kubectl exec my-pod -- sh -c 'cat /sys/fs/cgroup/cpu.stat | grep -E "nr_throttled|throttled_usec"'

Scripts#

Snapshot of the health of every systemd service’s cgroup: memory against its limit, CPU throttling and 60-second PSI, sorted by memory pressure. Read-only; run as root for units that restrict access.

#!/usr/bin/env bash
# usage: cgroup-report.sh [slice]   default system.slice
set -euo pipefail

slice=${1:-system.slice}
base=/sys/fs/cgroup/$slice
[[ -d $base ]] || { echo "no cgroup at $base" >&2; exit 2; }

psi60() { awk '/^some/ {split($3, a, "="); print a[2]}' "$1" 2>/dev/null || echo 0; }
val() { [[ -r $1 ]] && cat "$1" || echo -; }

printf '%-40s %10s %10s %8s %8s %8s\n' UNIT MEM MAX THROTTLED MEMPSI IOPSI
for d in "$base"/*/; do
  unit=$(basename "$d")
  [[ -f $d/memory.current ]] || continue
  mem=$(numfmt --to=iec < "$d/memory.current")
  max=$(val "$d/memory.max"); [[ $max == max ]] || max=$(numfmt --to=iec <<< "$max")
  thr=$(awk '/^nr_throttled/ {print $2}' "$d/cpu.stat" 2>/dev/null || echo -)
  printf '%-40s %10s %10s %8s %8s %8s\n' "$unit" "$mem" "$max" "$thr" "$(psi60 "$d/memory.pressure")" "$(psi60 "$d/io.pressure")"
done | sort -k5 -rn

Alert when PSI on any resource stays above a threshold for a sustained window, logging a snapshot of the top consumers when it fires. Suitable as a systemd service for hosts without a metrics pipeline.

#!/usr/bin/env bash
# usage: psi-watch.sh [threshold-percent] [interval-seconds]
set -euo pipefail

threshold=${1:-10} interval=${2:-10}
[[ -d /proc/pressure ]] || { echo 'PSI not available (boot with psi=1)' >&2; exit 2; }

snapshot() {
  {
    printf '=== %s: %s avg60=%s%% above %s%%\n' "$(date -Is)" "$1" "$2" "$threshold"
    uptime
    head -1 /proc/pressure/{cpu,io,memory}
    ps -eo pid,pcpu,rss,stat,comm --sort=-pcpu | head -6
    ps -eo pid,pcpu,rss,stat,comm --sort=-rss | head -6
    systemd-cgtop -b -n 1 --depth 2 -m | head -12
  } | logger -t psi-watch
}

while :; do
  for r in cpu io memory; do
    avg60=$(awk '/^some/ {split($3, a, "="); print a[2]}' /proc/pressure/"$r")
    if (( $(printf '%.0f' "$avg60") >= threshold )); then snapshot "$r" "$avg60"; fi
  done
  sleep "$interval"
done

Collect a 30-second performance bundle before restarting a misbehaving service, so the evidence survives the restart. Writes into a timestamped directory under /var/tmp and needs root for perf and the cgroup files.

#!/usr/bin/env bash
# usage: perf-bundle.sh my-app.service [seconds]
set -euo pipefail

unit=${1:?unit required} secs=${2:-30}
out=/var/tmp/perf-bundle-${unit%.service}-$(date +%Y%m%dT%H%M%S)
mkdir -p "$out"
cg=$(systemctl show -p ControlGroup --value "$unit")
[[ -n $cg ]] || { echo "$unit has no cgroup (not running?)" >&2; exit 1; }
mainpid=$(systemctl show -p MainPID --value "$unit")

{ uptime; free -h; cat /proc/pressure/{cpu,io,memory}; } > "$out/host.txt"
for f in cpu.stat memory.current memory.max memory.stat memory.events io.stat cpu.pressure memory.pressure io.pressure; do
  cp "/sys/fs/cgroup$cg/$f" "$out/cgroup-$f" 2>/dev/null || true
done
systemctl status --no-pager -l "$unit" > "$out/status.txt" || true
journalctl -u "$unit" -n 500 --no-pager > "$out/journal.txt" || true
ss -tanpi > "$out/sockets.txt"
vmstat 1 "$secs" > "$out/vmstat.txt" &
iostat -xz 1 "$secs" > "$out/iostat.txt" &
pidstat -u -r -d -p "$mainpid" 1 "$secs" > "$out/pidstat.txt" &
perf record -F 99 -g -p "$mainpid" -o "$out/perf.data" -- sleep "$secs" 2> "$out/perf-record.log" || true
wait
perf report -i "$out/perf.data" --stdio --no-children --percent-limit 1 > "$out/perf-report.txt" 2>/dev/null || true
tar -C "$(dirname "$out")" -czf "$out.tar.gz" "$(basename "$out")" && rm -rf "$out"
printf 'bundle: %s.tar.gz\n' "$out"

Troubleshooting the tools#

SymptomCauseFix
mpstat: command not foundsysstat not installedInstall sysstat
sar reports “Cannot open /var/log/sa/…”History collection not enabledUse the interval form (sar -n DEV 1), or enable sysstat collection
perf shows [unknown] framesNo frame pointers or symbolsInstall debug info, or perf record --call-graph dwarf
perf: Permission deniedkernel.perf_event_paranoid too highRun as root
bcc tool fails to compileMissing kernel headers or BTFInstall headers for the running kernel, or use bpftrace/libbpf-tools builds
/proc/pressure missingPSI disabledBoot with psi=1
/proc/PID/stack permission deniedNeeds rootsudo
Container shows host-wide numbers/proc is not namespaced for most countersRead the cgroup files under /sys/fs/cgroup
bpftrace: ERROR: ... args.filename or no BTF foundKernel lacks BTF, so tracepoint argument names are unknownInstall kernel-debuginfo or use args->filename on old bpftrace; check ls /sys/kernel/btf/vmlinux
kprobe attaches but never firesFunction was inlined or renamed in this kernelbpftrace -l 'kprobe:*name*' to find the real symbol; prefer a tracepoint
perf record writes gigabytesToo high a frequency or too many events on a busy host-F 99, limit with -p PID or -C cpu, shorten the sleep
perf report shows only [kernel.kallsyms] and hexkptr_restrict or missing user symbolssysctl kernel.kptr_restrict=0 as root; install debuginfo for the binary
cpu.stat has no nr_throttled lineUnit has no CPUQuota=, so the cpu controller is not enforcing a bandwidth limitExpected; look at the parent slice or set a quota
memory.peak: No such fileKernel older than 5.19Sample memory.current over time instead
systemd-cgtop shows - for CPU or I/OAccounting for that controller is off for the slicesystemctl set-property SLICE CPUAccounting=yes IOAccounting=yes (default on cgroup v2 for most distributions)
ethtool -S empty or Operation not supportedVirtual NIC without driver statisticsUse ip -s link and the guest driver’s counters (virtio, ena)
fio numbers far better than the application seesTest hit the page cache or a different device--direct=1, --fsync=1 for durability paths, check df of the test path
systemd-oomd killed a service that looked fineSlice-level ManagedOOMMemoryPressureLimit exceeded by a sibling’s swap or PSIoomctl, journalctl -u systemd-oomd; raise the limit or move the service to its own slice

Tool map and background: Brendan Gregg’s Linux performance page. Field definitions: man 5 proc.

Further reading#