15. Case Studies and Performance
The preceding fourteen chapters examined operating system mechanisms one at a time; this one looks at complete systems that combine them, and at the discipline of measuring whether the combination actually performs. The case studies show that there is no single correct set of trade-offs — Linux, Windows, and the BSDs each resolved the same design questions differently, and each resolution is coherent on its own terms. The performance half then supplies the methodology that turns "the system is slow" into a specific, measured, fixable resource, which is the skill that outlasts any particular kernel.
<a id="151-system-case-studies"></a>
15.1 System Case Studies
Reading a real kernel's architecture is the fastest way to see why the abstractions in earlier chapters look the way they do. This section examines the two kernels that run most of the world's computers, the design tradition both descend from, and the research systems whose ideas keep arriving in production a decade after they were published.
Linux Kernel Architecture
Theory
Linux is the clearest working example of a monolithic but modular kernel, the design introduced in Chapter 1. Everything — scheduler, memory manager, filesystems, network stack, drivers — runs in a single address space at the highest privilege level, so a subsystem calls another with an ordinary function call rather than a message. That is what makes it fast, and it is also why a bug in any driver can corrupt any kernel structure.
"Modular" softens this considerably. Loadable kernel modules (.ko files) can be inserted and removed at runtime, so a distribution ships one kernel that loads only the drivers a machine actually has. Modules are not isolated — once loaded, a module is as privileged as the core — but they give the deployment flexibility of a microkernel without its IPC cost.
The kernel is organised into subsystems that map onto the earlier chapters almost one for one:
| Subsystem | Responsibility | Chapter |
|---|---|---|
| Process management | task_struct, fork/exec, signals, namespaces | 2, 13 |
| Scheduler | EEVDF (since 6.6, replacing CFS), RT and deadline classes | 4 |
| Memory management | Page tables, page cache, reclaim, mmap, swap | 7, 8 |
| VFS | One interface over every filesystem | 10 |
| Block layer | I/O schedulers, request merging, multi-queue | 9, 11 |
| Network stack | Sockets, TCP/IP, netfilter, qdiscs | — |
| Device drivers | Roughly 60-70% of the source tree | 11 |
| Security | LSM hooks, SELinux/AppArmor, capabilities, seccomp | 12 |
Two design decisions distinguish Linux more than any technical detail.
Threads are processes. Linux does not have separate process and thread structures. There is one task_struct, and clone() chooses how much of it is shared: share the address space and you have made a thread; share nothing and you have made a process; share a namespace subset and you have made a container. fork(), pthread_create(), and container creation are the same call with different flags — an unusually economical unification, and the reason Chapter 3's "threads vs processes" distinction is blurrier on Linux than on Windows.
The userspace ABI is sacred; the internal API is not. Linux guarantees that a binary compiled twenty years ago still runs — "we do not break userspace" is enforced socially and absolutely. Internally, the opposite holds: function signatures, structure layouts, and whole subsystems change between releases without notice. This is deliberate. It is why out-of-tree drivers are painful to maintain and why the project pressures vendors to upstream code, and it is why Linux can refactor its internals in ways a stable-internal-ABI kernel like Windows cannot.
The development model is itself an architectural force: a merge window of roughly two weeks, then seven or eight release candidates, a release every 9-10 weeks, and selected releases marked LTS for multi-year maintenance. Thousands of developers from competing companies merge tens of thousands of commits per release under the GPLv2, which requires derivative kernels to be published — the licensing choice that shaped the ecosystem more than any code.
The most significant recent addition is eBPF: a verified, JIT-compiled virtual machine inside the kernel. Programs are checked before loading to prove they terminate and cannot access memory out of bounds, then run at hook points across the kernel. It lets you extend kernel behaviour — tracing, networking, security policy — without writing a module or rebooting, and it is the closest thing Linux has to the extensibility an exokernel promised.
Example
LINUX: MONOLITHIC, BUT MODULAR
USER SPACE
+------------------------------------------------------------+
| applications | glibc / musl | systemd | containers |
+------------------------------------------------------------+
| system call interface (~350 stable syscalls)
===========v==================================================
KERNEL SPACE -- ONE address space, ONE privilege level
+------------------------------------------------------------+
| PROCESS | MEMORY | VFS | NETWORK | eBPF VM |
| task_struct page +--------+ sockets verified, |
| scheduler tables | ext4 | TCP/IP JIT'd, |
| (EEVDF) reclaim | xfs | netfilter sandboxed |
| signals page | btrfs | qdiscs programs |
| namespaces cache | overlay| |
+------------+-----------+----+----+-----------+--------------+
| BLOCK LAYER (multi-queue, I/O sched) |
+-------------------------------------------------------------+
| DEVICE DRIVERS (~65% of the source tree) [loadable .ko] |
+-------------------------------------------------------------+
|
HARDWARE
Calls between boxes are FUNCTION CALLS, not messages.
Fast (ns, not us) -- and a fault in any box can corrupt any other.
ONE STRUCTURE, THREE ABSTRACTIONS: clone() FLAGS
fork() clone(SIGCHLD)
-> shares NOTHING: a new process
pthread_create() clone(CLONE_VM | CLONE_FS | CLONE_FILES |
CLONE_SIGHAND | CLONE_THREAD)
-> shares address space, fds, signals: a THREAD
container start clone(CLONE_NEWPID | CLONE_NEWNS | CLONE_NEWNET
| CLONE_NEWUSER | ...)
-> shares the kernel, isolates the VIEW
Same syscall. Same task_struct. The "kind" of thing you created
is just which pointers it shares with its parent.
parent task_struct child task_struct
+----------------+ +----------------+
| mm -------|---+ +---|------ mm |
| files -------|--+| |+--|------ files |
| fs | || || | ... |
+----------------+ || || +----------------+
vv vv
CLONE_VM: both point at the SAME mm_struct
no CLONE_VM: child gets a COPY (copy-on-write)
THE TWO ABI PROMISES
USERSPACE ABI INTERNAL KERNEL API
------------- -------------------
NEVER broken. Broken constantly, on purpose.
A 2004 binary runs Function signatures, struct layouts,
on a 2026 kernel. whole subsystems change per release.
consequence: consequence:
distros can upgrade out-of-tree drivers rot fast
the kernel freely -> strong pressure to UPSTREAM
-> the kernel can refactor internals
that Windows, with a stable driver
ABI, must keep supporting forever
/* The unification, in code: one syscall, three meanings. */
#define _GNU_SOURCE
#include <sched.h>
#include <sys/wait.h>
#include <unistd.h>
#include <stdio.h>
static char stack[1024 * 1024];
static int shared_counter = 0;
static int child(void *arg)
{
shared_counter++; /* visible to the parent ONLY if
* CLONE_VM was set */
printf(" child: pid=%d counter=%d\n", getpid(), shared_counter);
return 0;
}
int main(void)
{
/* 1. A PROCESS: no sharing. The child gets a copy-on-write copy
* of the address space, so its increment is invisible here. */
pid_t p = clone(child, stack + sizeof stack, SIGCHLD, NULL);
waitpid(p, NULL, 0);
printf("after process clone: counter=%d\n", shared_counter); /* 0 */
/* 2. A THREAD: CLONE_VM means one shared mm_struct, so the
* increment IS visible. This is what pthread_create() does. */
p = clone(child, stack + sizeof stack,
CLONE_VM | CLONE_FS | CLONE_FILES | CLONE_SIGHAND
| CLONE_THREAD | SIGCHLD, NULL);
/* ... */
printf("after thread clone: counter=%d\n", shared_counter); /* 1 */
/* 3. A CONTAINER: same call again, isolating the VIEW instead
* of the memory (Chapter 13). */
p = clone(child, stack + sizeof stack,
CLONE_NEWPID | CLONE_NEWNS | CLONE_NEWNET | SIGCHLD, NULL);
return 0;
}
/* A minimal loadable module: privileged code, inserted at runtime.
* Note there is no isolation -- once loaded this IS the kernel. */
#include <linux/module.h>
#include <linux/kernel.h>
#include <linux/init.h>
static int __init hello_init(void)
{
pr_info("module: loaded, running in kernel mode\n");
return 0; /* non-zero aborts the insertion */
}
static void __exit hello_exit(void)
{
pr_info("module: unloaded\n"); /* must undo EVERYTHING: a leaked
* allocation here leaks until reboot */
}
module_init(hello_init);
module_exit(hello_exit);
MODULE_LICENSE("GPL"); /* non-GPL modules are denied access
* to many symbols ("tainting") */
# ---- Read the architecture off a running system ----
uname -r # 6.12.1-arch1-1
ls /usr/src/linux/ # arch/ drivers/ fs/ kernel/ mm/
# net/ include/ security/
du -sh /usr/src/linux/drivers # ~65% of the tree is drivers
# Loaded modules = the modular half of "monolithic but modular":
lsmod | head
# Module Size Used by
# nvme 49152 3
# ext4 987136 1
modinfo ext4 | head -5
sudo modprobe -r some_module # unload at runtime
# The stable interface, enumerated:
ausyscall --dump | wc -l # ~350 syscalls, ~never removed
man 2 syscalls
# Threads really are tasks:
ps -eLf | head # -L shows LWPs (threads)
ls /proc/$$/task/ # one directory per thread
cat /proc/$$/status | grep -E 'Threads|Pid|TracerPid'
# Scheduler class in use (Chapter 4):
cat /sys/kernel/debug/sched/features 2>/dev/null | head -3
chrt -p 1 # pid 1 scheduling policy
# eBPF: extend the kernel without a module or a reboot
sudo bpftool prog list | head
sudo bpftrace -e 'tracepoint:syscalls:sys_enter_openat {
@[comm] = count(); }' # count opens by process name
Key takeaways
- Linux is monolithic — all subsystems share one address space and call each other directly — but modular, since drivers load and unload at runtime without the isolation a microkernel would provide.
- Loadable modules give deployment flexibility without IPC cost; a loaded module is as privileged as the core kernel, so it is not a security boundary.
- Linux has a single
task_structfor processes and threads, andclone()flags decide what is shared, unifyingfork, thread creation, and container creation into one mechanism. - The userspace ABI is never broken, which lets distributions upgrade kernels freely and keeps decades-old binaries running.
- The internal kernel API is broken deliberately and often, which forces drivers upstream and lets the kernel refactor internals that a stable-internal-ABI system could never change.
- Drivers are roughly two thirds of the source tree, which is the practical reason the monolithic design persists: in-tree drivers get refactored along with the interfaces they use.
- Development runs on a fixed cadence — a two-week merge window, release candidates, a release every 9-10 weeks — with selected LTS releases maintained for years.
- GPLv2 requires derivative kernels to be published, which shaped the ecosystem more than any individual technical decision.
- eBPF adds verified, JIT-compiled, sandboxed programs at kernel hook points, delivering safe runtime extensibility for tracing, networking, and security policy.
🧪 Practice
- Run
ls /proc/$$/task/in a shell, then start a multi-threaded program and repeat it. Explain what each directory corresponds to and whyps -eLfshows more rows thanps -ef.- Compare the flags Linux uses for
fork(),pthread_create(), and a container start. For each, state which resource is shared and which is copied.- Use
lsmodandmodinfoto identify three loaded modules and what hardware or filesystem each supports. Explain why unloading one is possible while unloading the scheduler is not.- Explain why an out-of-tree driver must be rebuilt for each kernel release while a userspace binary need not be, and what this policy is designed to encourage.
- Interview-style: "Linux is called monolithic, which sounds like a criticism. Is it?" Hint: name what the design buys and what it costs, then ask which of the two a driver author and a server operator each care about.
Windows Kernel Architecture
Theory
Windows NT was designed from a blank sheet in 1989 by a team led by Dave Cutler, who had built VMS at DEC — and it shows. Where Linux grew organically from a UNIX-like kernel, NT was specified up front with goals that Linux never had: run multiple OS personalities simultaneously, be portable across processor architectures, and meet a formal security specification. Every architectural oddity follows from those goals.
The result is a hybrid kernel (Chapter 1). A layered, modular structure resembling a microkernel, but with the components that would be separate servers pulled into kernel mode for speed.
From the bottom:
- The HAL (Hardware Abstraction Layer) isolates processor and platform specifics — interrupt controllers, timers, DMA, SMP details — so the kernel above it is largely platform-independent. This is what made NT portable to MIPS, Alpha, PowerPC, Itanium, x86-64, and ARM64.
- The kernel proper (
ntoskrnl's lowest layer) handles thread scheduling, interrupt and exception dispatch, and multiprocessor synchronisation. It is deliberately small and never paged out. - The Executive contains the real subsystems: Object Manager, Process/Thread Manager, Virtual Memory Manager, I/O Manager, Cache Manager, Security Reference Monitor, Configuration Manager (the registry), and more. These call one another directly, like Linux subsystems.
- Environment subsystems run in user mode and implement OS personalities. Win32 is the survivor; POSIX and OS/2 subsystems existed and were removed. WSL1 revived the idea by implementing Linux syscalls as a subsystem; WSL2 abandoned it and runs a real Linux kernel in a lightweight VM — an admission that emulating a syscall surface faithfully is harder than virtualising hardware.
Three features are genuinely distinctive.
Everything is an object. Where UNIX says everything is a file, NT says everything is an object managed by the Object Manager: processes, threads, files, events, mutexes, semaphores, registry keys, devices, and desktops are all objects in a single hierarchical namespace with uniform naming, security descriptors, and reference counting. A handle is a per-process index into a handle table pointing at an object, with an access mask recorded at open time. The consequence is uniformity: one security model, one lifetime model, and one set of wait primitives covering every resource type — WaitForMultipleObjects can wait on a thread, a file operation, a mutex, and a timer in a single call, which UNIX historically could not do (hence select/poll covering only descriptors).
I/O is asynchronous and packet-based. The I/O Manager builds an IRP (I/O Request Packet) and passes it down a stack of layered drivers — a filter driver, a filesystem driver, a volume manager, a disk driver — each of which may handle, modify, or forward it. Asynchronous completion (overlapped I/O, with I/O Completion Ports for scalable event handling) was designed in from the start, whereas UNIX bolted async I/O on repeatedly (aio, epoll, and finally io_uring).
The scheduler is strictly priority-based. Thirty-two priority levels: 0-15 dynamic (normal applications, with priority boosts on I/O completion and for the foreground window) and 16-31 real-time (no boosting, fixed). The dispatcher always runs the highest-priority ready thread. Compare Linux's CFS/EEVDF, which is fair-share by design — a philosophical difference, not just an algorithmic one.
| Dimension | Linux | Windows NT |
|---|---|---|
| Structure | Monolithic + modules | Hybrid: HAL, kernel, Executive, user-mode subsystems |
| Core abstraction | Everything is a file (fd) | Everything is an object (handle) |
| Process/thread | One task_struct, clone() flags | Distinct process and thread objects; CreateProcess is heavy |
| Process creation | fork() + exec(), cheap, CoW | CreateProcess() — no fork equivalent |
| Scheduling | Fair-share (EEVDF) + RT classes | 32 strict priority levels, with boosts |
| I/O model | Synchronous by default; epoll, io_uring | Asynchronous by design; IRPs, completion ports |
| Driver ABI | Unstable internally | Stable — decades-old drivers still load |
| Config | Text files in /etc | Registry (Configuration Manager) |
| Source | Open, GPLv2 | Proprietary (partial source programs) |
Example
WINDOWS NT ARCHITECTURE
USER MODE
+-------------------------------------------------------------+
| applications |
+-------------------------------------------------------------+
| subsystem DLLs: kernel32 / user32 / gdi32 / advapi32 |
| ENVIRONMENT SUBSYSTEMS: Win32 (csrss) [POSIX, OS/2: gone] |
| WSL1: Linux syscalls as a subsystem WSL2: a real VM |
+-------------------------------------------------------------+
| ntdll.dll -- the real syscall stubs (Nt*/Zw*) |
+------------------------|------------------------------------+
========================= v ===================================
KERNEL MODE
+-------------------------------------------------------------+
| E X E C U T I V E |
| Object Mgr | Process/Thread | Virtual Memory | I/O Manager |
| Security Reference Monitor | Cache Mgr | Config Mgr (registry)|
| Plug&Play | Power | ALPC |
+-------------------------------------------------------------+
| KERNEL: scheduling, interrupts, DPC/APC, SMP sync |
+-------------------------------------------------------------+
| HAL: interrupt controller, timers, DMA, bus, SMP specifics |
+-------------------------------------------------------------+
| DRIVERS (layered stacks, IRP-based) | Win32k (GUI) |
+-------------------------------------------------------------+
|
HARDWARE
"Hybrid": the STRUCTURE of a microkernel, with the servers
pulled into kernel mode so calls stay cheap.
HANDLES AND THE OBJECT MANAGER
process A handle table Object Manager namespace
+----+-------------------+ \Device\HarddiskVolume1
| 4 | -> File object | \BaseNamedObjects\MyMutex
| 8 | -> Mutex object | \Processes\...
| 12 | -> Process object | \Registry\Machine\...
+----+-------------------+
| | |
v v v
+--------------------------------+
| OBJECT (one per resource) |
| header: name, type, | ONE security model,
| security descriptor, | ONE lifetime model,
| HANDLE COUNT, | ONE wait model --
| POINTER COUNT | for files, threads,
| body: type-specific data | mutexes, registry keys.
+--------------------------------+
freed when both counts hit 0
Because everything is a waitable object:
WaitForMultipleObjects(4, {thread, file_event, mutex, timer}, ...)
One call, four DIFFERENT resource types. UNIX's select()/poll()
only ever covered file descriptors.
AN IRP TRAVELLING DOWN A DRIVER STACK
ReadFile() -> I/O Manager builds an IRP
|
v
+-----------------------+
| filter (antivirus, | may inspect, block, or pass on
| encryption) |
+-----------+-----------+
v
| filesystem (NTFS) | maps file offset -> volume LBA
+-----------+-----------+
v
| volume manager (RAID) | maps volume LBA -> disk LBA
+-----------+-----------+
v
| disk / storport | issues the hardware command
+-----------+-----------+
v
hardware
|
completion travels back UP the stack, each layer
getting a chance to post-process (an "IoCompletion routine")
This layering is why a filesystem filter can be a separate,
independently shipped driver -- and why a bad one hangs every
read on the machine.
SCHEDULING PHILOSOPHIES, SIDE BY SIDE
WINDOWS LINUX (CFS/EEVDF)
31 +-- real-time (no boost) --+ strict priority is the EXCEPTION
...| | (SCHED_FIFO/RR/DEADLINE)
16 +--------------------------+
15 | | the NORM is fair share:
...| dynamic: boosted on I/O | every runnable task gets a
8 | completion and for the | proportional slice, nice values
...| foreground window | adjust the proportion
0 +--------------------------+
"Highest priority ready thread runs" vs "everyone advances"
Neither is wrong; they optimise different things.
/* The Win32 view: handles, objects, and unified waiting. */
#include <windows.h>
#include <stdio.h>
int main(void)
{
/* A file is an object; CreateFile returns a HANDLE, not an int fd.
* FILE_FLAG_OVERLAPPED opts into asynchronous I/O -- the native
* model, not an add-on. */
HANDLE hFile = CreateFile(L"data.bin", GENERIC_READ, FILE_SHARE_READ,
NULL, OPEN_EXISTING,
FILE_FLAG_OVERLAPPED, NULL);
/* A mutex is an object too, and it can be NAMED in the global
* namespace so unrelated processes can find it by name. */
HANDLE hMutex = CreateMutex(NULL, FALSE, L"Global\\MyAppMutex");
/* So is a timer, and so is a thread. */
HANDLE hTimer = CreateWaitableTimer(NULL, TRUE, NULL);
HANDLE hThread = CreateThread(NULL, 0, worker, NULL, 0, NULL);
/* THE distinctive primitive: wait on FOUR DIFFERENT KINDS of
* object in one call. There is no UNIX equivalent -- select()
* and epoll() handle descriptors only, never threads or mutexes. */
HANDLE objs[] = { hThread, hMutex, hTimer, hFile };
DWORD r = WaitForMultipleObjects(4, objs, FALSE /* any */, INFINITE);
printf("object %lu signalled\n", r - WAIT_OBJECT_0);
/* Overlapped read: returns IMMEDIATELY, completion arrives later. */
OVERLAPPED ov = { .Offset = 0 };
ov.hEvent = CreateEvent(NULL, TRUE, FALSE, NULL);
char buf[4096];
ReadFile(hFile, buf, sizeof buf, NULL, &ov); /* async */
/* ... do other work while the IRP travels the driver stack ... */
DWORD got;
GetOverlappedResult(hFile, &ov, &got, TRUE); /* now collect it */
/* CloseHandle decrements the object's handle count; the object
* itself is freed only when handle AND pointer counts reach zero. */
CloseHandle(hFile); CloseHandle(hMutex);
CloseHandle(hTimer); CloseHandle(hThread);
return 0;
}
/* Process creation: the deepest API difference from UNIX.
* Windows has no fork(). CreateProcess does load+start in one call,
* and customisation happens through a parameter block rather than
* through code running between fork and exec (Chapter 2). */
STARTUPINFO si = { .cb = sizeof si };
PROCESS_INFORMATION pi;
si.dwFlags = STARTF_USESTDHANDLES;
si.hStdOutput = hPipeWrite; /* redirection is set up HERE,
* not by dup2() in a child */
BOOL ok = CreateProcess(
L"C:\\Windows\\System32\\cmd.exe", /* image */
L"cmd.exe /c dir", /* command line */
NULL, NULL, /* security descriptors */
TRUE, /* inherit handles */
CREATE_NEW_CONSOLE, /* creation flags */
NULL, NULL, &si, &pi);
WaitForSingleObject(pi.hProcess, INFINITE); /* a process IS an object */
DWORD code; GetExitCodeProcess(pi.hProcess, &code);
CloseHandle(pi.hProcess); CloseHandle(pi.hThread);
/* UNIX equivalent needs fork() + dup2() + execve() -- three calls and
* a window of arbitrary child code. Windows needs one call and a
* struct. The UNIX version is more flexible; the Windows version
* avoids ever duplicating an address space it is about to discard. */
# Inspect the architecture on a live Windows system.
Get-Process | Sort-Object -Property Handles -Descending |
Select-Object -First 5 Name, Handles, Threads
# Name Handles Threads <- handle count IS the object count
# Scheduling priority classes (the 32-level scheme, named):
Get-Process notepad | Select-Object Name, PriorityClass
(Get-Process notepad).PriorityClass = "AboveNormal"
# Idle / BelowNormal / Normal / AboveNormal / High / RealTime
# Loaded kernel drivers -- the layered stacks:
Get-WmiObject Win32_SystemDriver | Where-Object State -eq "Running" |
Select-Object -First 5 Name, PathName
# The Object Manager namespace is browsable with Sysinternals WinObj;
# from the CLI, the registry (Configuration Manager) is a namespace too:
Get-ChildItem HKLM:\SYSTEM\CurrentControlSet\Services | Select -First 5
# WSL: subsystem (WSL1) vs real kernel in a VM (WSL2)
wsl --list --verbose
# NAME STATE VERSION
# Ubuntu Running 2 <- VERSION 2 = a genuine Linux kernel
wsl --system -d Ubuntu uname -r # shows a Microsoft-built Linux kernel
Key takeaways
- Windows NT was specified up front for portability, multiple OS personalities, and a formal security model, and its architecture follows from those goals rather than from UNIX tradition.
- It is a hybrid kernel: microkernel-like layering with the Executive's subsystems placed in kernel mode so inter-component calls stay cheap.
- The HAL isolates platform specifics and is why NT ported across MIPS, Alpha, PowerPC, Itanium, x86-64, and ARM64.
- Everything is an object managed by the Object Manager, giving one naming, security, lifetime, and wait model across files, threads, mutexes, registry keys, and devices.
- A handle is a per-process table index with an access mask fixed at open time, and objects are freed only when both handle and pointer counts reach zero.
WaitForMultipleObjectscan wait on heterogeneous object types in a single call, which UNIX's descriptor-onlyselect/pollcould not do.- I/O is asynchronous and packet-based: the I/O Manager sends IRPs down layered driver stacks, with completion travelling back up.
- Scheduling is strict priority across 32 levels with boosts for I/O completion and the foreground window, contrasting with Linux's fair-share default.
- Windows guarantees a stable driver ABI, which keeps old drivers working but constrains internal evolution — the opposite of Linux's trade.
- Windows has no
fork();CreateProcessloads and starts in one call with customisation supplied through a parameter block rather than by code between fork and exec.
🧪 Practice
- List three resource types that are objects in Windows but not files in UNIX, and explain what uniform behaviour the object model gives each of them.
- Write the UNIX sequence equivalent to the
CreateProcessexample above, then state one thing the UNIX version can do that Windows cannot, and one advantage of the Windows approach.- Draw an IRP's path for a read on an encrypted volume with antivirus installed, naming each driver layer and what it contributes.
- Compare Windows' stable driver ABI with Linux's unstable internal API. For each, name one consequence for hardware vendors and one for kernel maintainers.
- Interview-style: "Why did Microsoft replace WSL1's syscall-translation approach with a real Linux kernel in a VM for WSL2?" Hint: consider how many syscalls Linux has, how precisely their edge cases are specified, and where filesystem semantics differ.
UNIX and BSD Design
Theory
Nearly everything in this book descends from a system built in 1969 by a small group at Bell Labs on a machine nobody else wanted. Understanding UNIX's design principles explains why modern systems look the way they do — and understanding the BSD branch explains why "UNIX-like" is not one thing.
The UNIX philosophy is a set of design commitments, not a feature list:
- Everything is a file. Devices, pipes, sockets (eventually), and in Linux kernel state itself (
/proc,/sys) are all reached throughopen/read/write/close. One interface, one set of tools, one permission model. - Small programs that do one thing well, composed through pipes. The pipe made composition the primary mode of use, which is why the shell became a programming environment rather than a launcher.
- Text as the universal interface. If every program's output is lines of text, every program can be the input to every other, without agreeing on anything.
- Mechanism, not policy. The kernel supplies primitives; userspace decides how to use them. This is why the same kernel runs a phone and a supercomputer.
- Portability through C. Rewriting UNIX in C around 1973 was the decisive move — it made the OS a portable artifact, which nothing else was.
The genealogy matters because the splits still shape the landscape. AT&T's research UNIX forked into System V (commercial) and BSD (the Berkeley Software Distribution, from university work). BSD contributed an astonishing share of what people think of as UNIX: sockets and the TCP/IP stack, the Fast File System (cylinder groups, long filenames), virtual memory with mmap, job control, vi, and the C shell. The two lineages diverged enough that POSIX was created to standardise a common core, which is the reason portable code is possible at all.
Legal history then produced the outcome everyone lives with. The USL v. BSDi lawsuit (1992-94) left BSD's future uncertain at precisely the moment Linux appeared with an unambiguous licence, and by the time BSD was clear, Linux had the developers. A timing accident, not a technical verdict.
The licensing difference persists and is genuinely consequential:
| GPL (Linux) | BSD/MIT licence (BSDs) | |
|---|---|---|
| Derivative works | Must publish source | May stay proprietary |
| Commercial embedding | Requires releasing changes | No obligation |
| Practical result | Vendors upstream their work | Code appears in closed products |
| Visible outcome | One dominant shared kernel | BSD code inside macOS, PlayStation, Junos, Netflix's CDN, Windows' TCP utilities |
The other structural difference is whole-system versus kernel-only development. Linux is a kernel; a distribution assembles it with GNU userland, an init system, and packaging chosen by different groups. A BSD is a complete operating system developed in one repository — kernel, libc, userland, documentation, and build system evolve together. This yields notable coherence (the man pages are genuinely authoritative, make buildworld rebuilds everything from source) at the cost of a smaller ecosystem.
The three main BSDs each optimise something different, and each has produced technology the rest of the world adopted: FreeBSD (performance and server use; ZFS integration, jails, DTrace, kqueue, the network stack Netflix uses to serve a large fraction of internet video from single machines), OpenBSD (correctness and security; OpenSSH, pf, LibreSSL, and a long series of exploit mitigations later adopted elsewhere), and NetBSD (portability; it runs on an almost absurd range of architectures). And macOS is a certified UNIX whose userland and much of whose kernel personality are BSD, sitting on the XNU hybrid kernel from Chapter 14.
Example
THE UNIX FAMILY TREE (simplified, but the branch points matter)
1969 UNIX (Bell Labs, PDP-7)
|
1973 rewritten in C <-- the decisive move: a PORTABLE OS
|
+---------------------------+
| |
AT&T research 1977 BSD (Berkeley)
| |
1983 System V +-- sockets, TCP/IP, FFS, mmap,
| (commercial UNIX) | job control, vi, csh
| |
| 1991-94 USL v. BSDi lawsuit
| | (BSD's future in doubt for
| | exactly the wrong two years)
| |
| 386BSD
| +---------+----------+
| | | |
| FreeBSD NetBSD OpenBSD
| | (portable) (security)
| |
| Darwin/macOS, PlayStation, Junos,
| Netflix CDN, WhatsApp servers
|
POSIX (1988) <-- standardises a common core across BOTH branches
|
1991 Linux (kernel only; GNU supplies the userland)
+ unambiguous licence at the moment BSD's was unclear
-> the developers went there
TWO DEVELOPMENT MODELS
LINUX BSD
----- ---
kernel.org ships a KERNEL one repo ships a WHOLE OS
+ kernel + libc + userland +
GNU coreutils (separate) docs + build system, versioned
glibc (separate) together
systemd (separate)
packaging (per distro) make buildworld -> rebuilds
= a DISTRIBUTION everything from source
consequence: consequence:
enormous ecosystem, coherent, documented, smaller;
inconsistency between man pages are authoritative
distros, "which Linux?" because one group owns it all
"EVERYTHING IS A FILE", MADE LITERAL
$ ls -l /dev/sda /dev/null /dev/tty
brw-rw---- ... /dev/sda <- b: BLOCK device (the disk itself)
crw-rw-rw- ... /dev/null <- c: CHARACTER device
crw--w---- ... /dev/tty <- c: your terminal
The SAME read()/write() reach a disk, a bit bucket, a terminal,
a pipe, a socket, and (on Linux) kernel state:
$ cat /proc/self/status kernel data structure, as text
$ echo 1 > /sys/.../enable kernel knob, as a file write
$ cat < /dev/urandom a driver, as a file read
One interface => one toolset. grep works on all of them.
COMPOSITION: THE PIPE AS THE REAL INNOVATION
$ ps aux | awk '{print $11}' | sort | uniq -c | sort -rn | head
ps ---> awk ---> sort ---> uniq ---> sort ---> head
| | | | |
+-------+--------+---------+---------+
five programs, none of which knows the others exist,
each written years apart by different people
No program here was designed for this task. That is the point:
the composition is created by the USER, at the shell, at will.
# The philosophy, demonstrated rather than described.
# 1. One interface for everything -- the same syscalls throughout.
exec 3< /dev/urandom # a driver as a file descriptor
exec 4< /proc/cpuinfo # kernel state as a file descriptor
head -c 16 <&3 | xxd # read() from a device
grep -m1 'model name' <&4 # read() from a kernel structure
# 2. Mechanism, not policy: the kernel supplies primitives, the shell
# composes them. Nothing here was designed as a "log analyser".
grep -h 'ERROR' /var/log/*.log \
| awk '{print $5}' \
| sort | uniq -c | sort -rn | head -20
# 3. Text as the universal interface: every stage speaks lines.
# Compare to an API where each tool needs a binding for every other.
# ---- BSD vs Linux: the same task, different toolchains ----
# Linux FreeBSD
# init systemd rc.d scripts
# fw nftables/iptables pf
# fs ext4/xfs/btrfs UFS2/ZFS (ZFS integrated)
# pkg apt/dnf/pacman pkg + ports
# trace eBPF/bpftrace DTrace
# jail namespaces+cgroups jails (one primitive, 2000)
# async epoll / io_uring kqueue (1999, one API for
# fds, signals, timers, vnodes)
# Rebuild an ENTIRE BSD operating system from source -- kernel and
# userland together. There is no Linux equivalent of this command,
# because on Linux those pieces belong to different projects.
cd /usr/src && make -j8 buildworld && make -j8 buildkernel
# See POSIX conformance in action -- the same C compiles on all of them:
getconf _POSIX_VERSION # 200809
man 7 standards # what your system claims to conform to
/* Portability through POSIX: this compiles unmodified on Linux, the
* BSDs, macOS, and AIX, because POSIX standardised the common core
* that survived the System V / BSD split. */
#define _POSIX_C_SOURCE 200809L
#include <fcntl.h>
#include <unistd.h>
#include <stdio.h>
int main(void)
{
/* open/read/write/close: the universal interface. The path can be
* a regular file, a device, a pipe, or a kernel pseudo-file, and
* this code does not change. */
int fd = open("/etc/hostname", O_RDONLY);
if (fd < 0) { perror("open"); return 1; }
char buf[256];
ssize_t n = read(fd, buf, sizeof buf - 1);
if (n > 0) { buf[n] = '\0'; printf("host: %s", buf); }
close(fd);
return 0;
}
/* The non-portable parts are exactly where the branches diverged:
* Linux: epoll_create1/epoll_wait, inotify, io_uring
* BSD: kqueue/kevent (one API for fds, signals, timers, and
* filesystem events -- a cleaner design that arrived first)
* Portable code either restricts itself to poll(), or abstracts both
* behind a library (libevent, libuv) -- which is what everyone does. */
Key takeaways
- The UNIX philosophy is a set of commitments — everything is a file, small composable programs, text as the universal interface, mechanism over policy, portability through C — and each is visible in systems built fifty years later.
- Rewriting UNIX in C made an operating system a portable artifact for the first time, which mattered more than any single feature.
- The pipe made composition the primary mode of use, letting users create workflows from programs whose authors never coordinated.
- BSD contributed sockets and TCP/IP, the Fast File System,
mmap-based virtual memory, and job control, so much of what is called "UNIX" is specifically Berkeley's work. - POSIX exists to standardise a common core across the System V and BSD lineages, and is the reason portable C is possible.
- The USL v. BSDi lawsuit left BSD's legal status uncertain exactly when Linux appeared with a clear licence, which decided the outcome on timing rather than technical merit.
- GPL forces derivative kernels to be published while BSD licences do not, which is why Linux consolidated contributions and why BSD code appears inside macOS, PlayStation, Junos, and Netflix's CDN.
- Linux is a kernel assembled into distributions by others; a BSD is a complete OS whose kernel, libc, userland, and docs are versioned together and rebuilt with one command.
- FreeBSD optimises server performance, OpenBSD correctness and security, and NetBSD portability, and each has exported technology — jails,
pf, OpenSSH,kqueue, DTrace integration — that the wider world adopted.
🧪 Practice
- Write a single pipeline that reports the ten most common commands in your shell history with counts. Identify which UNIX principles it relies on and name a program in it that was not designed for this purpose.
- Demonstrate "everything is a file" by reading from three fundamentally different sources — a regular file, a device, and a kernel pseudo-file — using the same command.
- Compare
kqueueandepollfor a server that must watch sockets, timers, and signals. State one design advantage of each.- Explain, in licensing terms, why BSD-derived TCP/IP code ended up inside proprietary products while Linux's kernel changes generally return upstream.
- Interview-style: "If BSD was technically mature first, why did Linux win?" Hint: look at the calendar between 1992 and 1994, and ask what a developer choosing a project in that window could and could not be sure of.
Research Operating Systems
Theory
Very few research operating systems are deployed, and that is the wrong measure of their worth. Their ideas migrate: virtual memory, the microkernel, capability security, and per-process namespaces were all research results long before they were ordinary. Reading them is how you tell which of today's constraints are fundamental and which are merely habitual.
Mach (CMU, 1985) tried to reduce the kernel to tasks, threads, virtual memory, and message-passing ports, with everything else in user-mode servers. It was influential enough that its DNA is in macOS and iOS to this day (Chapter 14), but pure Mach's performance disappointed — IPC cost dominated — which produced the hybrid compromise instead of the pure design. Its real legacy is the lesson: the microkernel's problem was never the concept, it was the cost of crossing the boundary.
L4 and seL4 answered that directly. Jochen Liedtke's L4 (1993) made IPC 10-20x faster than Mach by ruthless minimality and careful attention to cache behaviour, proving the cost was an implementation property rather than an inherent one. seL4 (2009) went further and produced a formally verified kernel: a machine-checked mathematical proof that the C implementation matches its specification, with no buffer overflows, no null dereferences, and correct enforcement of its security properties. It is around 10,000 lines of C — small enough to prove things about, which is the entire argument — and it ships today in defence, automotive, and aviation systems where the proof justifies the constraints.
Exokernel (MIT, 1995) attacked a different assumption: that the kernel should provide abstractions at all. It argued the kernel should only securely multiplex hardware and let applications implement their own abstractions in library operating systems, so a database could implement paging policy suited to a database rather than fighting the kernel's. The idea reappears constantly — in io_uring's bypass of traditional syscall paths, in DPDK and SPDK doing userspace networking and storage, in eBPF letting applications inject policy into the kernel, and in unikernels (Chapter 13).
Plan 9 (Bell Labs, late 1980s) is what happens when UNIX's own principles are applied without compromise. If everything is a file, then everything is a file: the network stack, the window system, process state, and remote machines' resources all appear in the filesystem. Each process gets its own namespace it can modify, and the 9P protocol makes any resource accessible over the network identically to a local one. Plan 9 never took over — but per-process namespaces are now the foundation of Linux containers, /proc grew from it, and UTF-8 was invented for it and is now universal.
Two more worth knowing. Singularity (Microsoft Research, 2004) built an OS where isolation came from type safety rather than hardware: processes are sealed, written in a managed language, verified before running, and communicate over statically checked channels — so isolation costs no page-table switch and everything can share one address space. Barrelfish (ETH/Microsoft, 2009) treated a many-core machine as a distributed system, running a separate kernel per core communicating by message passing, on the argument that cache-coherent shared memory does not scale indefinitely and the OS should stop pretending it does.
Modern efforts continue: Fuchsia (Google) with its Zircon microkernel and capability-based security, and Redox, a microkernel OS in Rust. Whether either ships broadly matters less than what they are testing.
| System | Central question | Idea that escaped into production |
|---|---|---|
| Mach | Can services live outside the kernel? | XNU's hybrid core; the microkernel vocabulary |
| L4 / seL4 | Can IPC be cheap? Can a kernel be proven? | Fast IPC design; formal verification in safety-critical use |
| Exokernel | Should the kernel abstract at all? | io_uring, DPDK/SPDK, eBPF, unikernels |
| Plan 9 | What if everything really were a file? | Per-process namespaces (containers), /proc, UTF-8 |
| Singularity | Can isolation come from types, not hardware? | Language-based sandboxing; WebAssembly's model |
| Barrelfish | Is shared memory the right model for many cores? | NUMA-aware and message-passing kernel designs |
Example
THE PIPELINE FROM RESEARCH TO YOUR LAPTOP
Mach (1985) --> XNU hybrid kernel --> every Mac and iPhone
L4 IPC (1993) --> fast-IPC designs --> seL4 in avionics/auto
Exokernel (1995) --> "let apps bypass the kernel"
--> DPDK, SPDK, io_uring, eBPF, unikernels
Plan 9 namespaces --> per-process namespaces --> DOCKER
Plan 9's UTF-8 --> literally every text file you own
Singularity (2004) --> language-based isolation --> WebAssembly
Barrelfish (2009) --> per-core kernels --> NUMA-aware scheduling
Typical lag: 10-25 years. The research was not "unsuccessful";
it was early.
THE MICROKERNEL STORY, IN THREE ACTS
ACT 1 -- Mach: the idea
+---------+ +--------+ +-------+
| FS srv | | net srv| | pager | user mode
+----+----+ +---+----+ +---+---+
| IPC | IPC | IPC <- and here is the problem:
+----v----------v-----------v-------+ every service call is
| microkernel: tasks, threads, VM | now TWO context switches
+-----------------------------------+ plus message copying
Verdict: beautiful, and too slow as built.
ACT 2 -- L4: the measurement
Liedtke: Mach's IPC is slow because of HOW IT WAS WRITTEN,
not because IPC is inherently slow.
Mach IPC: ~100 us L4 IPC: ~5 us
Minimal kernel, registers instead of memory for small messages,
cache-conscious layout. 10-20x, same hardware.
ACT 3 -- seL4: the proof
~10,000 lines of C, and a machine-checked proof that:
- the C code refines the formal specification
- no buffer overflow, no null deref, no arithmetic overflow
- the security properties (confidentiality, integrity,
availability of the isolation mechanism) actually hold
Cost: years of proof effort, ~20 person-years, and every
change must re-verify.
Payoff: "this kernel has no bugs" is a THEOREM, not a claim.
Linux: ~30,000,000 lines. Proving it is not a funding problem;
it is a category problem. That size difference IS the argument.
EXOKERNEL: REMOVE THE ABSTRACTION, NOT JUST THE POLICY
CONVENTIONAL EXOKERNEL
+---------------------+ +---------------------+
| app | | app + libOS | app picks
+---------------------+ | (its own paging, | its OWN
| OS abstractions: | | its own fs layout, | abstractions
| files, processes, | | its own scheduling)|
| sockets, VM policy | +---------------------+
+---------------------+ | exokernel: securely |
| hardware | | MULTIPLEX hardware | nothing else
+---------------------+ +---------------------+
| hardware |
one policy for everyone +---------------------+
(a database fights the
page cache constantly) each app gets the policy it wants
Where this actually shipped:
io_uring -- bypass per-syscall overhead, app drives the queue
DPDK/SPDK -- userspace owns the NIC/SSD queues entirely
eBPF -- app-supplied policy, verified, run IN the kernel
unikernels -- the libOS idea, taken literally (Chapter 13)
PLAN 9: "EVERYTHING IS A FILE" WITHOUT EXCEPTIONS
UNIX stopped halfway. Plan 9 did not.
/dev/sda a disk /net/tcp/0/ctl open a connection
/proc/1/status a process /net/tcp/0/data read/write it
BUT: /dev/draw/... the WINDOW SYSTEM
sockets need socket() /mnt/wsys/... the GUI, as files
GUI needs X protocol /proc/1/mem another process
no per-process view per-process NAMESPACE, mutable
Networking with no networking API:
% cat /net/tcp/clone -> allocates a connection, says "3"
% echo 'connect 10.0.0.1!80' > /net/tcp/3/ctl
% echo 'GET / HTTP/1.0' > /net/tcp/3/data
% cat /net/tcp/3/data
No sockets. No new syscalls. open/read/write, all the way down.
And because 9P makes remote resources mount identically:
mount a REMOTE machine's /net and use ITS network stack
as if it were local. Distribution for free.
Every container on earth uses the namespace idea. Almost nobody
remembers where it came from.
/* seL4: capabilities instead of ambient authority. A thread can do
* exactly what its capability space contains -- there is no "root",
* and no way to name an object you were not given. */
/* Send a message on an endpoint. The capability IS the permission:
* possessing ep_cap is the only way to send here. */
seL4_MessageInfo_t info = seL4_MessageInfo_new(
0, /* label: application-defined operation code */
0, /* capsUnwrapped */
0, /* extraCaps: capabilities to transfer with the message */
1); /* length: one machine word of payload */
seL4_SetMR(0, 42); /* message register 0 = payload.
* Small messages travel in CPU
* REGISTERS -- this is the trick
* that made L4-family IPC fast. */
seL4_Call(ep_cap, info); /* send + block for the reply */
/* Grant a subset of authority to a child: derive a WEAKER capability.
* Authority can only ever be narrowed, never widened, so there is no
* privilege-escalation path of the setuid kind (Chapter 12). */
seL4_CNode_Mint(
dest_root, dest_index, dest_depth,
src_root, src_index, src_depth,
seL4_CanWrite, /* strictly fewer rights than ours */
badge); /* the receiver sees this badge, so
* it can tell callers apart */
# Research ideas you can run today.
# --- Exokernel descendants: the app drives the hardware queues ---
# io_uring: submit and complete without a syscall per operation
# (the exokernel argument, applied to the syscall boundary)
grep io_uring_setup /proc/kallsyms | head -1
sudo fio --ioengine=io_uring --name=t --rw=randread --bs=4k \
--filename=/dev/nvme0n1 --runtime=10 --numjobs=4
# compare --ioengine=psync: same hardware, different kernel involvement
# --- eBPF: app-supplied, VERIFIED policy running inside the kernel ---
sudo bpftrace -e 'kprobe:vfs_read { @[comm] = count(); }'
# The verifier proves termination and memory safety BEFORE loading --
# the same "prove it, then trust it" idea as seL4, at a smaller scale.
# --- Plan 9 namespaces, which you already use as containers ---
sudo unshare --mount --pid --fork --mount-proc bash
# You now have your own namespace -- Plan 9's central idea, arriving
# in Linux roughly 20 years later (Chapter 13).
# --- Plan 9 itself, and 9P, are still runnable ---
# 9P is a supported Linux filesystem: it is how many VMs share
# directories with the host.
grep 9p /proc/filesystems
# nodev 9p <- Plan 9's protocol, in your kernel, today
# --- seL4 and modern microkernels ---
# seL4: https://sel4.systems -- builds and runs under QEMU
# Fuchsia (Zircon): capability-based, ships on Nest Hub devices
# Redox: a Rust microkernel OS, boots under QEMU
Key takeaways
- Research operating systems are judged by which ideas migrate, not by deployment share, and the typical lag from paper to production is 10-25 years.
- Mach showed that services can live outside the kernel but that IPC cost dominates, which produced hybrid kernels like XNU rather than pure microkernels.
- L4 demonstrated that microkernel IPC was slow by implementation rather than by nature, achieving a 10-20x improvement through minimality and cache-conscious design.
- seL4 is formally verified: a machine-checked proof that its 10,000 lines of C match a specification and enforce its isolation properties, which is feasible only because it is small.
- Exokernel argued the kernel should securely multiplex hardware and let applications supply their own abstractions, an idea now visible in io_uring, DPDK/SPDK, eBPF, and unikernels.
- Plan 9 applied "everything is a file" without exception, giving each process a mutable namespace and exposing the network and window system as files.
- Per-process namespaces,
/proc, 9P, and UTF-8 all came from Plan 9, and namespaces are the foundation of every Linux container. - Singularity explored isolation through type safety and verified managed code rather than hardware protection, an approach WebAssembly now echoes.
- Barrelfish modelled a many-core machine as a distributed system with one kernel per core, anticipating that cache coherence would not scale indefinitely.
- Reading research systems is how you distinguish constraints that are fundamental from ones that are merely conventional.
🧪 Practice
- For each of Mach, Exokernel, and Plan 9, name one mechanism in a system you use daily that descends from it, and explain the connection in a sentence.
- Explain why seL4 can be formally verified while Linux cannot, in terms of a specific quantitative property rather than effort or funding.
- Use
unshareto create a private mount and PID namespace, then describe what Plan 9 idea you have just reproduced and what Plan 9 additionally offered that Linux does not.- Benchmark the same random-read workload with
fiousing thepsyncandio_uringengines. Explain the difference in exokernel terms.- Interview-style: "Microkernels were supposed to win and did not. Was the idea wrong?" Hint: separate the concept from a single 1980s implementation, and ask where microkernels are in fact deployed today and why those domains chose them.
<a id="152-performance-analysis"></a>
15.2 Performance Analysis
Knowing how an operating system works is only half of the skill; the other half is determining, from a running system, which of its mechanisms is currently the limit. This section covers the metrics worth trusting, the tracing tools that answer questions profiling cannot, a repeatable method for finding bottlenecks, the arithmetic of planning capacity before the system saturates, and the tunable parameters worth touching once you have evidence.
System Metrics and Profiling
Theory
"The system is slow" is not a measurement. Turning it into one requires knowing which numbers mean something, and — more importantly — which popular numbers do not.
Start with the most misleading metric of all. CPU utilisation does not measure work done; it measures time not idle. A CPU stalled waiting for DRAM is counted 100% busy while retiring almost no instructions. On a memory-bound workload, "100% CPU" frequently means "100% waiting for memory", and adding CPUs will not help. The metric worth using alongside it is IPC (instructions per cycle): below roughly 1.0 suggests a stall-bound workload, above roughly 2.0 suggests genuine compute.
Load average is misunderstood almost as often. On Linux it counts processes that are running or in uninterruptible sleep (disk wait), averaged over 1, 5, and 15 minutes — so heavy disk I/O inflates it with no CPU shortage at all. A load of 8 on an 8-core machine may be perfect saturation or may be one runnable process and seven blocked on a failing disk.
Two frameworks organise the rest and are worth memorising.
The USE method (Brendan Gregg), for resources: for every resource, check Utilisation, Saturation, and Errors. Utilisation is the fraction of time busy; saturation is the degree of queued extra work; errors are failed operations. Saturation is the one people skip and the one that matters — a disk at 100% utilisation with no queue is fine, while a disk at 60% utilisation with a deep queue is not.
The RED method, for services: Rate (requests per second), Errors (failed requests), Duration (the latency distribution). USE tells you which resource hurts; RED tells you whether users notice.
For latency, averages are nearly useless. Latency distributions are right-skewed and often multi-modal (cache hit versus cache miss), so the mean describes nobody's experience. Use percentiles — p50, p95, p99, p99.9 — and remember that tail latency compounds: a page making 20 backend calls sees its p99 with probability roughly 1 − 0.99²⁰ ≈ 18% of requests. The tail is the user experience at scale.
One measurement bug deserves naming: coordinated omission. A load generator that waits for each response before sending the next stops sending requests exactly when the system stalls, so the stall is never measured. The results look excellent precisely because the worst behaviour was omitted. Fix it by sending at a fixed rate and measuring latency from intended send time (wrk2, and Gil Tene's work on this, exist for exactly this reason).
Finally, profiling. Sampling profilers (perf record) interrupt periodically and record the stack, giving statistical coverage at low overhead — the default choice. Instrumenting profilers insert counters at every function, giving exact counts but distorting timing and often changing the behaviour they measure. Flame graphs are the standard visualisation: stacks are collapsed, sorted alphabetically, and drawn as nested rectangles whose width is time spent — a wide plateau is where the CPU actually is. And a crucial complement: a normal profiler shows only on-CPU time, so a process blocked on a lock or disk is invisible. Off-CPU analysis samples what threads block on and is how you find the other half of the latency.
Example
THE FOUR METRICS THAT MISLEAD
1. CPU UTILISATION: "busy" != "working"
+--------------------------------------------+
| 100% CPU, IPC = 0.3 -> stalled on MEMORY |
| 100% CPU, IPC = 2.4 -> actually computing |
+--------------------------------------------+
Same top(1) output. Completely different fix:
the first needs better data layout, NOT more cores.
2. LOAD AVERAGE (Linux): running + UNINTERRUPTIBLE SLEEP
load 8.0 on 8 cores could be:
- 8 CPU-bound threads -> perfect saturation
- 1 thread + 7 stuck on a dying disk -> a storage incident
The number cannot distinguish them. Look at run queue vs iowait.
3. AVERAGE LATENCY: describes nobody
requests: 90 x 10ms, 9 x 100ms, 1 x 2000ms
mean = 30 ms <- no request took 30 ms
p50 = 10 ms p99 = 2000 ms
The mean hid a 2-second outlier completely.
4. "AVAILABILITY 99.9%": over what window, measured by whom?
99.9% monthly = 43 minutes down. In one outage or 43 one-minute
blips? Users experience those very differently.
USE METHOD: A CHECKLIST, NOT AN INTUITION
for EVERY resource: Utilisation | Saturation | Errors
------------------------------------------------------------
CPU %busy, IPC | runqueue len, PSI | throttles, MCE
Memory %used | swap/compress rate,| OOM kills
| PSI, page scanning |
Disk %util | avgqu-sz, await | I/O errors, SMART
Network bytes/s vs | dropped, retrans, | errors, CRC
link speed | backlog
------------------------------------------------------------
SATURATION is the column people skip and the one that predicts pain:
disk 100% util, queue depth 1 -> fine, just always busy
disk 60% util, queue depth 40 -> everyone is waiting
RED METHOD (per service): Rate | Errors | Duration(p50/p99/p999)
USE finds the sick RESOURCE; RED says whether USERS feel it.
FLAME GRAPH: WIDTH IS TIME, NOT CALL COUNT
[all]
+----------------------+--------------------------+
| http_handler (62%) | gc (21%)| ...|
+--+-----------+-----------+ +--------+
| parse_json(41%) | db_query |
+-------+--------+ +-----------+
| utf8 | alloc |
+-------+--------+
^^^^^^^^
Read it BOTTOM-UP for ancestry, and look for the widest
PLATEAU: parse_json at 41% of all CPU time is the answer.
x-axis is alphabetical, NOT time -- do not read it left to right.
COORDINATED OMISSION: THE BUG IN YOUR BENCHMARK
BAD (closed loop): send -> wait for reply -> send -> ...
t=0 send req1
t=0.01 reply. send req2
t=0.02 SERVER STALLS FOR 5 SECONDS
t=5.02 reply to req2 (5 s). send req3...
Recorded: 1 slow request.
Reality: during those 5 s a real user base would have sent
500 requests, ALL of which would have been slow.
The generator stopped sending exactly when it mattered.
GOOD (open loop, fixed rate): send at 100/s REGARDLESS of replies,
and measure latency from the INTENDED send time.
Recorded: 500 slow requests. Which is the truth.
ON-CPU vs OFF-CPU: TWO HALVES OF THE SAME LATENCY
request latency = 200 ms
on-CPU |###| 30 ms <- what perf shows
off-CPU |####################| 170 ms <- INVISIBLE to perf
waiting on: lock 120 ms, disk 50 ms
Profiling alone would send you optimising the 30 ms.
# ---- The 60-second first pass (Netflix's checklist) ----
uptime # load averages: trend over 1/5/15 min
dmesg -T | tail -20 # OOM kills, disk errors, TCP drops
vmstat 1 5 # r (runqueue), si/so (swap), us/sy/id/wa
mpstat -P ALL 1 3 # per-CPU: is ONE core pinned? (single-threaded)
pidstat 1 3 # which processes, on/off CPU
iostat -xz 1 3 # per-disk: %util, avgqu-sz, await
free -m # memory and, critically, buff/cache
sar -n DEV 1 3 # network throughput vs link capacity
sar -n TCP,ETCP 1 3 # retransmits, connection failures
top # the overview, read LAST not first
# ---- Utilisation is not work: check IPC ----
perf stat -a sleep 10
# 12,345,678,901 cycles
# 3,456,789,012 instructions # 0.28 insn per cycle <-- STALLED
# ^ IPC 0.28 means ~3.5 cycles per instruction: memory-bound.
# More cores will not help; better locality will.
# ---- Saturation, the column that predicts pain ----
cat /proc/pressure/cpu /proc/pressure/io /proc/pressure/memory
# some avg10=42.11 avg60=38.05 ... <- PSI: % of time tasks STALLED
# full avg10=12.00 ... <- ALL tasks stalled
# PSI is a far better saturation signal than utilisation (Chapter 13).
# ---- Sampling profile + flame graph ----
sudo perf record -F 99 -a -g -- sleep 30 # 99 Hz, all CPUs, callgraphs
sudo perf script | stackcollapse-perf.pl | flamegraph.pl > cpu.svg
# -F 99 (not 100) avoids sampling in lockstep with periodic timers
# ---- The other half: OFF-CPU time ----
sudo offcputime-bpfcc -df 30 | flamegraph.pl --color=io > offcpu.svg
# Shows WHERE threads blocked and for how long: locks, disk, network.
sudo wakeuptime-bpfcc 10 # who WOKE the blocked thread (the culprit)
# Why percentiles, and why the tail dominates at scale.
import random, statistics
def simulate(n=100_000):
out = []
for _ in range(n):
r = random.random()
if r < 0.90: out.append(random.gauss(10, 2)) # cache hit
elif r < 0.99: out.append(random.gauss(100, 20)) # cache miss
else: out.append(random.gauss(1500, 300))# lock + retry
return sorted(x for x in out if x > 0)
lat = simulate()
pct = lambda q: lat[int(len(lat) * q)]
print(f"mean {statistics.mean(lat):7.1f} ms <- describes NOBODY")
print(f"p50 {pct(0.50):7.1f} p95 {pct(0.95):7.1f}")
print(f"p99 {pct(0.99):7.1f} p999 {pct(0.999):7.1f}")
# mean 35.0 ms <- between the modes: a value no request ever had
# p50 10.0 p95 103.2
# p99 1436.8 p999 2107.4
# TAIL AMPLIFICATION: one page = many backend calls.
for calls in (1, 10, 20, 100):
p = 1 - 0.99 ** calls # chance at least one call is p99-slow
print(f"{calls:3d} backend calls -> {p:5.1%} of PAGES hit the p99 tail")
# 1 -> 1.0% 10 -> 9.6% 20 -> 18.2% 100 -> 63.4%
#
# At 100 fan-out, nearly two thirds of page loads contain a p99 call.
# This is why the p99 of a backend IS the p50 experience of a product,
# and why "only 1% are slow" is not a reassuring sentence.
# COORDINATED OMISSION, demonstrated:
def closed_loop(stall_s=5.0, rate=100):
# Waits for each reply, so during the stall it sends NOTHING.
return [stall_s * 1000] # 1 slow sample
def open_loop(stall_s=5.0, rate=100):
# Keeps sending at a fixed rate; every request queued behind the
# stall is also slow, measured from its INTENDED send time.
return [(stall_s - i / rate) * 1000 for i in range(int(stall_s * rate))]
print(len(closed_loop()), "vs", len(open_loop()), "slow samples recorded")
# 1 vs 500 -- the same outage, two benchmarks, opposite conclusions.
Key takeaways
- CPU utilisation measures time not idle, not work performed; pair it with IPC, where below roughly 1.0 indicates a stall-bound workload that more cores will not fix.
- Linux load average counts uninterruptible sleep as well as runnable tasks, so disk problems inflate it without any CPU shortage.
- The USE method checks utilisation, saturation, and errors for every resource, and saturation is both the most skipped and the most predictive column.
- The RED method checks rate, errors, and duration per service; USE identifies the sick resource while RED shows whether users feel it.
- Latency distributions are skewed and often multi-modal, so averages describe no real request and percentiles are required.
- Tail latency amplifies with fan-out: at 20 backend calls roughly 18% of requests encounter a p99-slow call, so a backend's p99 becomes the product's typical experience.
- Coordinated omission makes closed-loop benchmarks stop sending exactly during a stall, hiding the worst behaviour; fixed-rate load with latency measured from intended send time fixes it.
- Sampling profilers are the low-overhead default; instrumenting profilers give exact counts but distort the timing they measure.
- Flame graph width is time spent and the x-axis is alphabetical, so the widest plateau — not the leftmost stack — is the target.
- On-CPU profiling misses time spent blocked, so off-CPU analysis is required to find latency caused by locks, disk, and network waits.
🧪 Practice
- Run
perf stat -a sleep 10on an idle machine and again under a CPU-heavy workload. Report the IPC in each case and say whether the workload is stall-bound or compute-bound.- Generate a bimodal latency distribution in a script, then compute the mean, p50, p99, and p99.9. Explain in one sentence why reporting the mean to a stakeholder would be misleading.
- Record a flame graph of any CPU-heavy process on your machine, identify the widest plateau, and explain why the x-axis ordering carries no meaning.
- A page makes 30 backend calls, each with a 99.5th-percentile latency of 400 ms. Compute the fraction of page loads containing at least one such call.
- Interview-style: "Our dashboard shows CPU at 100%, so we are scaling out. Reasonable?" Hint: ask what the IPC and run-queue length are, and what would happen to both if you doubled the core count.
Tracing and Instrumentation
Theory
Profiling answers "where is time being spent?" statistically. Tracing answers the questions profiling cannot: which file was slow, which process issued that syscall, why did this one request take 4 seconds when the average is 8 ms. A profiler samples; a tracer records events with their arguments and timestamps.
The distinction that organises everything is static versus dynamic instrumentation.
Static tracepoints are put in the source by its authors at points deemed interesting — Linux has thousands (sched:sched_switch, block:block_rq_issue, syscalls:sys_enter_*), and userspace can add USDT probes. They are stable across versions, documented, and nearly free when disabled, but you only get the points someone chose to expose.
Dynamic instrumentation (kprobes for the kernel, uprobes for userspace) can attach to almost any instruction address at runtime by patching in a breakpoint. Total freedom, at the cost of stability: you are attaching to internal functions whose names and arguments change between releases.
The historical arc is worth knowing because it explains today's tooling. DTrace (Sun, 2005) was the breakthrough — safe, production-grade, dynamic, whole-system tracing with a small language — and it was the first to make "just instrument production" a reasonable sentence. Linux's answer arrived piecemeal (ftrace, perf, SystemTap) until eBPF unified it: a verified, JIT-compiled VM in the kernel that attaches programs to tracepoints, kprobes, uprobes, and more, and aggregates in the kernel rather than shipping every event to userspace. That last property is the important one — computing a histogram in kernel space costs a fraction of emitting a million events for userspace to parse.
Overhead is the governing constraint, and the difference between tools is enormous:
| Tool | Mechanism | Typical overhead | Production safe |
|---|---|---|---|
strace | ptrace, two stops per syscall | 100-500x on syscall-heavy code | No |
ltrace | Library call interception | Similarly severe | No |
perf record | Sampling interrupts | 1-5% at 99 Hz | Yes |
ftrace | In-kernel ring buffer | 1-5% | Yes |
bpftrace/BCC | eBPF, in-kernel aggregation | Under 1% for most probes | Yes |
strace deserves the warning explicitly: it stops the traced process twice per syscall, and on a syscall-heavy program it can slow execution by two orders of magnitude — enough to change the behaviour you were investigating, and enough to cause an outage if applied to a production database. Use it on a test instance or use eBPF instead.
The other major use is distributed tracing, which extends the idea across machines. A request is given a trace ID at entry, each service records spans (start, end, parent, attributes), and a collector reassembles the causal tree. This is the only practical way to answer "which of these 40 services made that request slow", and it exists because a single machine's profile cannot see a call it made to another machine. OpenTelemetry is the current standard for the instrumentation API and wire format.
Example
PROFILING vs TRACING: DIFFERENT QUESTIONS
PROFILER (sampling) TRACER (events)
"44% of CPU is in parse_json" "THIS request called parse_json
statistical, cheap, lossy 3,412 times, 2 of which took
answers WHERE 800 ms, on file X, from pid Y"
exact, per-event, answers WHICH
and WHY
Use a profiler for: steady-state CPU cost.
Use a tracer for: rare events, outliers, causality, arguments.
The 4-second request in a system with an 8 ms average is a
TRACING problem -- it will never show up in a profile.
THE INSTRUMENTATION POINTS AVAILABLE TO YOU
USER SPACE
application --- USDT probes (author-placed, stable)
--- uprobes (any function, any address; unstable)
libraries --- uprobes on libc: malloc, read, ...
------------------------------------------------ syscall boundary
KERNEL
syscalls --- tracepoints: syscalls:sys_enter_openat (STABLE)
scheduler --- tracepoints: sched:sched_switch, sched_wakeup
block layer --- tracepoints: block:block_rq_issue/complete
network --- tracepoints + kprobes: tcp_retransmit_skb
ANY function --- kprobes (attach to ~any address; UNSTABLE)
------------------------------------------------
HARDWARE
PMU counters --- cycles, instructions, cache-misses, LLC-load-miss
STATIC (tracepoints/USDT) DYNAMIC (kprobes/uprobes)
stable across releases may break on any kernel update
documented arguments you must read the source for args
only what authors exposed anything you can name
~zero cost when off ~zero cost when off
WHY eBPF AGGREGATES IN THE KERNEL
OLD WAY (strace / naive tracing)
kernel event -> copy to userspace -> parse -> count
1,000,000 events/s x (context switch + copy + parse)
= the tool costs more than the workload
eBPF WAY
kernel event -> eBPF program runs IN KERNEL -> increments a
map entry (a histogram bucket)
userspace reads the finished HISTOGRAM once per second
= 1,000,000 events cost ~1,000,000 map increments, and
userspace sees ~50 numbers
This is the entire reason eBPF tracing is production-safe
and strace is not.
DISTRIBUTED TRACING: THE ONLY VIEW THAT CROSSES MACHINES
trace_id = 7f3a... (generated at the edge, propagated in headers)
|------------------------ gateway 840 ms -----------------------|
|--- auth 12 ms ---|
|------------------ cart 810 ms -----------------------|
|-- db query 30 ms --|
|------------- inventory 760 ms ------------|
|-- redis 2 ms --|
|---------- db: LOCK WAIT 740 ms ----------|
^^^^^^^^^^^^^^^^^^^^^^^
the answer, four services deep
No single machine's profile could have found this: from the
gateway's view it is "cart was slow", and cart's own CPU profile
shows it doing nothing at all -- it was BLOCKED (off-CPU).
# ---- bpftrace: a tracing language in one line ----
# 1. Which processes are opening which files?
sudo bpftrace -e 'tracepoint:syscalls:sys_enter_openat {
printf("%-16s %s\n", comm, str(args->filename)); }'
# 2. Syscall latency as an in-kernel HISTOGRAM (log2 buckets).
# Note: no per-event data ever crosses to userspace.
sudo bpftrace -e '
tracepoint:syscalls:sys_enter_read { @start[tid] = nsecs; }
tracepoint:syscalls:sys_exit_read /@start[tid]/ {
@us = hist((nsecs - @start[tid]) / 1000); // aggregate in kernel
delete(@start[tid]);
}'
# @us:
# [1] 4213 |@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@|
# [2, 4) 1102 |@@@@@@@@@@ |
# [1K, 2K) 18 | | <- the tail
# [8K, 16K) 3 | | <- outliers
# The shape is the answer: bimodal means two different code paths.
# 3. Who is causing disk I/O, with size and latency?
sudo biolatency-bpfcc 10 1 # block I/O latency histogram
sudo biosnoop-bpfcc # per-I/O: pid, disk, sector, ms
sudo ext4slower-bpfcc 10 # only ext4 ops slower than 10 ms
# ^ the "show me only outliers" idiom
# 4. Off-CPU: why is a thread not running?
sudo offcputime-bpfcc -p $(pgrep -n myapp) 30
# 5. Dynamic instrumentation of an arbitrary kernel function:
sudo bpftrace -e 'kprobe:vfs_read { @[kstack] = count(); }'
sudo bpftrace -l 'tracepoint:*' | wc -l # thousands of stable points
sudo bpftrace -l 'kprobe:tcp_*' | head # everything attachable
# 6. Userspace dynamic tracing (uprobes) -- no recompilation needed:
sudo bpftrace -e 'uprobe:/usr/lib/libssl.so:SSL_write {
printf("%s wrote %d bytes\n", comm, arg2); }'
# ---- ftrace: built in, no extra tooling ----
cd /sys/kernel/tracing
# Function graph: see the call tree WITH durations.
echo function_graph > current_tracer
echo vfs_read > set_graph_function
echo 1 > tracing_on; sleep 1; echo 0 > tracing_on
head -30 trace
# CPU DURATION FUNCTION CALLS
# 0) | vfs_read() {
# 0) 0.213 us | rw_verify_area();
# 0) + 18.442 us | ext4_file_read_iter(); <- '+' = over 10 us
# 0) ! 142.881 us | } <- '!' = over 100 us
echo nop > current_tracer # ALWAYS turn it off afterwards
# ---- strace: correct, and dangerous ----
strace -c -p 1234 # SUMMARY mode: counts and time per syscall
# % time seconds usecs/call calls errors syscall
# 62.11 0.451200 112 4028 12 futex <- lock contention
# 21.34 0.155000 9 17222 read
strace -T -e trace=openat,read ./app 2>&1 | head
# -T shows time spent IN each call
# WARNING: ptrace stops the process TWICE per syscall. On a busy
# server this is a 100-500x slowdown and can cause an outage.
# The eBPF equivalent, at <1% overhead:
sudo syscount-bpfcc -p 1234 # same summary, production-safe
# Distributed tracing: propagate context, record spans.
from opentelemetry import trace
from opentelemetry.propagate import inject, extract
import requests
tracer = trace.get_tracer(__name__)
def handle_checkout(request):
# Continue the trace the caller started (context arrives in headers),
# rather than starting a new, disconnected one.
ctx = extract(request.headers)
with tracer.start_as_current_span("checkout", context=ctx) as span:
# Attributes make spans searchable: "show me slow spans where
# cart_size > 50" is the query that finds real bugs.
span.set_attribute("user.tier", request.user.tier)
span.set_attribute("cart.size", len(request.cart))
with tracer.start_as_current_span("inventory.check"):
headers = {}
inject(headers) # propagate trace_id + span_id
r = requests.get("http://inventory/check",
headers=headers, timeout=2.0)
with tracer.start_as_current_span("payment.charge") as pay:
try:
charge(request.cart)
except PaymentError as e:
pay.record_exception(e)
pay.set_status(trace.StatusCode.ERROR) # errors are
raise # searchable too
# Sampling matters: tracing every request at scale is unaffordable.
# Head sampling (decide at entry, e.g. 1%) is cheap but may miss the
# rare slow request entirely. TAIL sampling buffers spans and keeps
# traces that were slow or errored -- more expensive, far more useful,
# because the interesting traces are by definition the rare ones.
Key takeaways
- Profiling answers where time goes statistically; tracing records individual events with arguments and timestamps, which is the only way to explain a rare outlier.
- Static tracepoints and USDT probes are author-placed, stable, and documented; kprobes and uprobes attach dynamically to almost any address but break across releases.
- DTrace established safe production-wide dynamic tracing, and eBPF is the Linux realisation of the same idea with a verifier and a JIT.
- eBPF aggregates inside the kernel, so a million events become a histogram rather than a million userspace copies, which is precisely why it is production-safe.
stracestops the process twice per syscall and can slow it by 100-500x, changing the behaviour under investigation and risking an outage.perf record,ftrace, and eBPF tools run at a few percent or less and are appropriate on production systems.- Histogram shape carries information: a bimodal latency distribution usually means two distinct code paths, such as cache hit versus miss.
- Filtering tools that emit only outliers (
ext4slower,biosnoop) are the practical way to catch rare slow events without recording everything. - Distributed tracing propagates a trace ID and records parent-linked spans, which is the only way to attribute latency across service boundaries.
- Tail-based sampling retains slow and errored traces rather than a uniform fraction, which matters because the interesting traces are by definition rare.
🧪 Practice
- Use
bpftraceto print the process name and filename for everyopenaton your system for ten seconds. Then modify it to count opens per process instead, and explain why the second version scales better.- Run
strace -con a syscall-heavy program and time it with and without tracing attached. Report the slowdown factor and state why this rules out production use.- Build a syscall-latency histogram with
bpftraceand interpret its shape. If it is bimodal, propose what two code paths could explain it.- Explain why a single-machine profile cannot diagnose a slow request that spends its time in a downstream service, and what mechanism supplies the missing information.
- Interview-style: "A request that normally takes 8 ms occasionally takes 4 seconds, maybe once every ten thousand requests. How do you find out why?" Hint: ask whether a sampling profiler would ever capture that request at all, and what a tool that fires only above a latency threshold would give you instead.
Bottleneck Identification
Theory
A system has exactly one bottleneck at a time — the resource that, if improved, improves the whole. Everything else is slack. The discipline is finding it rather than guessing, because intuition is reliably wrong: the code you suspect is rarely the code that is slow, and the resource showing the most alarming number is often a symptom of a different one.
The most common error is optimising the wrong thing, which Amdahl's law quantifies. If a fraction p of the runtime is improved by factor s, overall speedup is 1 / ((1 − p) + p/s). Make a component that is 5% of runtime infinitely fast and you gain 5.3%. Halve a component that is 80% and you gain 67%. The corollary: measure the proportion before you optimise, because the ceiling is set by the proportion, not by how clever the optimisation is.
The second error is confusing symptoms with causes. High CPU can be caused by a memory problem (cache misses stalling execution), by a disk problem (a busy-wait retry loop), by a lock problem (spinning), or by genuine compute. High iowait can be caused by a memory problem (reclaim pressure forcing page-outs). The USE method resists this by making you check every resource rather than following the first alarming number.
A practical taxonomy, with the signature of each:
| Bottleneck | Signature | Confirm with | Typical fix |
|---|---|---|---|
| CPU-bound (compute) | High %usr, IPC > 1.5, run queue > cores | perf stat, flame graph | Better algorithm; parallelise |
| CPU-bound (stalled) | High %usr, IPC < 1.0, high cache-miss | perf stat -e cache-misses | Data layout, locality, prefetch |
| Memory-capacity | Swap/compression activity, high PSI memory, page scanning | vmstat, /proc/pressure/memory | Add RAM; reduce footprint |
| Disk-bound | High await, deep avgqu-sz, high %iowait | iostat -xz, biolatency | Caching, batching, faster device |
| Network-bound | Throughput at link ceiling, retransmits | sar -n DEV,ETCP, ss -ti | Compression, batching, locality |
| Lock-bound | Low CPU, low disk, high latency, futex time | offcputime, strace -c | Shrink critical sections; shard |
| Single-threaded | One core at 100%, others idle | mpstat -P ALL | Parallelise; shard the work |
The last two are the ones people miss, because they look like nothing is wrong: the machine is mostly idle and everything is slow. That combination — low utilisation everywhere plus high latency — almost always means waiting, and off-CPU analysis is the tool.
Two laws structure the reasoning. Little's law: L = λW (items in system = arrival rate × time in system). It is remarkably useful for sanity-checking — if a service handles 1,000 req/s with 50 ms average latency, there are on average 50 requests in flight, so a connection pool of 20 is itself the bottleneck. And the queueing response-time curve: as utilisation ρ approaches 1, waiting time grows as roughly 1/(1 − ρ). At 50% utilisation the queue adds 1x the service time; at 90% it adds 9x; at 99%, 99x. This is why systems appear to fall off a cliff — they do not; the curve was always a hyperbola, and load merely walked up it.
The method itself, in order: characterise the workload (what is it actually doing?), apply USE across all resources, form a hypothesis, test it by changing one thing, and measure again. Then repeat — because fixing a bottleneck does not eliminate bottlenecks, it moves them.
Example
AMDAHL'S LAW: THE CEILING IS SET BY THE PROPORTION
speedup = 1 / ((1 - p) + p/s)
optimise a part that is... make it 2x make it INFINITE
---------------------------------------------------------------
5% of runtime 1.03x 1.05x <- pointless
20% of runtime 1.11x 1.25x
50% of runtime 1.33x 2.00x
80% of runtime 1.67x 5.00x <- the target
95% of runtime 1.90x 20.00x
The lesson is not "optimise more", it is "MEASURE THE PROPORTION
FIRST". A brilliant 100x optimisation of 5% of runtime is worth
less than a lazy 2x of 80%.
THE RESPONSE-TIME CURVE: WHY SYSTEMS "SUDDENLY" COLLAPSE
response
time
| *
| *
| *
| *
| *
| *
| *
| * * *
+----------------------------------------------> utilisation
0% 50% 70% 80% 90% 95% 99%
W = S / (1 - rho) (M/M/1, service time S)
rho=0.5 -> 2x S rho=0.9 -> 10x S
rho=0.7 -> 3.3x S rho=0.95-> 20x S
rho=0.8 -> 5x S rho=0.99-> 100x S
Going 90% -> 99% utilisation is "10% more work" and 10x the
latency. Nothing broke. The curve was always this shape.
This is WHY the operational rule is to stay near 70%.
SYMPTOM vs CAUSE: THE SAME SIGNAL, FOUR DISEASES
"CPU is at 100%"
|
+-- IPC 2.4, high %usr -> genuinely computing -> algorithm
+-- IPC 0.3, cache-misses -> STALLED ON MEMORY -> layout
+-- high %sys, futex calls -> SPINNING ON A LOCK -> shard it
+-- high %sys, many reads -> syscall overhead -> batch it
Same top(1). Four different fixes. Three of them do not
involve making the code "faster" at all.
THE BOTTLENECK NOBODY SEES: EVERYTHING IDLE, EVERYTHING SLOW
CPU ~8% busy disk ~2% util net ~1%
latency p99 = 3.2 s
No resource is saturated, so USE finds nothing. The work is
not RUNNING -- it is WAITING:
sudo offcputime-bpfcc -df 30 | flamegraph.pl
futex_wait ############################ 2.9 s <- a lock
io_schedule ### 0.2 s
Low utilisation + high latency == waiting. Always go off-CPU.
LITTLE'S LAW AS A SANITY CHECK: L = lambda * W
service does 1,000 req/s, average latency 50 ms
L = 1000 * 0.050 = 50 requests IN FLIGHT on average
connection pool size = 20 -> the POOL is the bottleneck;
requests queue for a connection
before the server is even involved
thread pool = 200 -> fine
db connections = 10 -> the database link is the limit
One multiplication finds capacity bugs that load tests take
hours to reproduce.
# ---- A worked bottleneck hunt, in the order you should do it ----
# STEP 1: characterise. What is the system actually doing?
uptime; vmstat 1 5; mpstat -P ALL 1 3
# r b swpd free buff cache si so bi bo in cs us sy id wa
# 1 9 0 2.1G 80M 9.8G 0 0 142 980 4.2k 18k 9 4 12 75
# ^ ^^^^^
# 9 blocked 75% iowait
# Hypothesis: disk-bound, not CPU-bound. us+sy = 13%.
# STEP 2: USE the suspected resource.
iostat -xz 1 3
# Device r/s w/s rkB/s %util aqu-sz r_await w_await
# nvme0n1 180 2400 720 99.2 38.4 2.1 41.7
# ^^^^ ^^^^ ^^^^
# utilisation 99%, QUEUE DEPTH 38, writes waiting 42 ms
# Saturation confirmed: it is not merely busy, work is QUEUED.
# STEP 3: attribute it. WHO is issuing the I/O?
sudo biosnoop-bpfcc | head -20
# TIME COMM PID DISK T SECTOR BYTES LAT(ms)
# 0.000 postgres 8812 nvme0n1 W 41288 8192 38.42
# 0.004 postgres 8812 nvme0n1 W 41304 8192 41.10
sudo biolatency-bpfcc 10 1 # is the device slow, or overloaded?
# [16K, 32K) us #### -> the DEVICE is fine; the QUEUE is the delay
# STEP 4: is it really the disk, or memory pushing on the disk?
cat /proc/pressure/io /proc/pressure/memory
free -m; vmstat 1 3 | awk '{print $7, $8}' # si/so: swap activity
# If si/so are non-zero, the "disk problem" is a MEMORY problem
# wearing a disk costume.
# STEP 5: the low-utilisation case -- everything idle, still slow.
sudo offcputime-bpfcc -df 30 > off.stacks
flamegraph.pl --color=io --title="Off-CPU" off.stacks > off.svg
sudo wakeuptime-bpfcc 10 # who WOKE the blocked thread = culprit
# STEP 6: change ONE thing, measure again, repeat.
# Fixing a bottleneck does not remove bottlenecks -- it MOVES them.
# Expect the next run to be limited by something else. That is
# success, not failure.
# The two laws, as tools you can actually run.
def amdahl(fraction_improved, speedup):
"""Ceiling on overall gain. Run this BEFORE starting the work."""
p, s = fraction_improved, speedup
return 1 / ((1 - p) + p / s)
print(f"{amdahl(0.05, 1e9):.2f}x") # 1.05x -- 5% of runtime, infinite
print(f"{amdahl(0.80, 2):.2f}x") # 1.67x -- 80% of runtime, merely 2x
# The lazy optimisation of the big thing beats the heroic one
# of the small thing. Every time.
def queue_wait(service_time_ms, utilisation):
"""M/M/1 response time. Shows the cliff that is not a cliff."""
if utilisation >= 1: return float("inf")
return service_time_ms / (1 - utilisation)
for rho in (0.5, 0.7, 0.8, 0.9, 0.95, 0.99):
print(f"rho={rho:4.2f} -> {queue_wait(10, rho):7.1f} ms")
# rho=0.50 -> 20.0 ms
# rho=0.90 -> 100.0 ms <- 10x the service time
# rho=0.99 -> 1000.0 ms <- 100x. "10% more load" = 10x latency.
def littles_law(rate_per_s, latency_s):
"""L = lambda * W: how many requests are in flight right now?"""
return rate_per_s * latency_s
in_flight = littles_law(1000, 0.050) # 50 concurrent requests
print(f"in flight: {in_flight:.0f}")
for pool in (10, 20, 50, 100):
verdict = "BOTTLENECK" if pool < in_flight else "adequate"
print(f" pool={pool:3d}: {verdict}")
# pool=10 BOTTLENECK <- requests queue before reaching the server
# pool=20 BOTTLENECK
# pool=50 adequate <- exactly at the limit: no headroom for spikes
# pool=100 adequate
Key takeaways
- A system has one bottleneck at a time, and fixing it moves the limit elsewhere rather than removing it — expect the next measurement to show a different constraint.
- Amdahl's law sets a ceiling from the proportion of runtime affected, so measuring the proportion must precede optimising; an infinite speedup of 5% of runtime yields 5%.
- High CPU can be genuine compute, memory stalls, lock spinning, or syscall overhead, and IPC plus cache-miss counters distinguish them.
- The signature of a lock or dependency bottleneck is low utilisation everywhere combined with high latency, which USE alone will not reveal.
- Off-CPU analysis is the tool for that case, and wakeup tracing identifies which thread caused the blocking.
- Little's law (
L = λW) converts throughput and latency into concurrency, immediately exposing undersized connection and thread pools. - The queueing response-time curve grows as
1/(1 − ρ), so 90% to 99% utilisation multiplies waiting time by ten for 10% more work. - Systems do not suddenly collapse; the hyperbola was always there, and load merely moved along it, which is why 70% is the usual operational target.
- High iowait can be a memory problem, since reclaim pressure generates disk traffic — always check swap and memory pressure before blaming storage.
- The method is: characterise the workload, apply USE to every resource, hypothesise, change one thing, measure, repeat.
🧪 Practice
- A profile shows 60% of runtime in JSON parsing and 15% in compression. Compute the overall speedup from making each 4x faster, and state which you would do first.
- A service handles 2,000 requests per second with a 30 ms mean latency. Use Little's law to compute the average concurrency, then decide whether a 40-connection pool is adequate.
- Use
vmstat,iostat -xz, andmpstat -P ALLon a machine under load and classify the bottleneck using the table above. Justify your classification with two specific numbers.- Construct a program that is lock-bound, verify that CPU and disk both look idle, and locate the contention with
offcputime.- Interview-style: "Traffic grew 10% and latency tripled. What happened?" Hint: sketch the response-time curve and mark where the system sat before and after — no component needs to have broken.
Capacity Planning
Theory
Capacity planning is the discipline of knowing, before it happens, when a system will run out of room — and deciding in advance what to do about it. The alternative is discovering it during an incident, which is both more expensive and much less pleasant.
The foundation is the queueing arithmetic from the previous topic, applied forward in time. Because response time rises as 1/(1 − ρ), the usable capacity of a system is not 100% of its theoretical capacity. A resource at 95% utilisation is technically working and practically unusable, since latency is 20x service time and any burst tips it over. The standard operating target is 70-80%, which leaves the headroom that absorbs traffic spikes, tolerates the loss of an instance, and leaves time to react.
Planning proceeds in four steps.
1. Characterise the workload. What is a "request" here, and what does one cost? Requests are rarely uniform: a search query and an image upload are both requests and differ by orders of magnitude in cost. Useful characterisation records the mix, the rate, the variability (peak-to-mean ratio is often 3-10x for consumer traffic, with weekly and seasonal cycles), and the growth trend.
2. Measure the cost per unit of work. From production or a load test: CPU-seconds per request, bytes of memory per session, IOPS per transaction, bytes per response. This converts a business forecast ("we expect 3x users") into a resource forecast ("we will need 2.4x the IOPS").
3. Model the limit. Find which resource saturates first at the projected rate. The answer is frequently not the obvious one — memory per connection, file descriptors, database connections, a single-threaded component, or a network link often bind before CPU does. Little's law is the fastest way to check concurrency limits.
4. Decide the scaling strategy. Vertical scaling (a bigger machine) is simple, requires no code changes, and has a hard ceiling with poor failure characteristics. Horizontal scaling (more machines) has no ceiling but demands statelessness or partitioning, and introduces the coordination problems of Chapter 14. The Universal Scalability Law refines Amdahl by adding a coherency term: throughput rises, flattens, and then falls as more nodes are added, because cross-node coordination grows quadratically. That the curve turns downward is the important part — beyond some point, adding capacity actively reduces throughput.
Then there is the part that is not arithmetic. Failure domains: if you run three instances at 70% and lose one, the survivors need 105% — so N+1 planning means sizing for the post-failure load, not the steady state. Load tests must be open-loop (fixed arrival rate) to avoid coordinated omission, and must use realistic data, because a benchmark against an empty database measures nothing. And the result of all this should be written down as explicit thresholds — the utilisation at which you order hardware, the latency at which you page someone — because a plan nobody wrote down is a plan that will be improvised at 3 a.m.
Example
USABLE CAPACITY IS NOT TOTAL CAPACITY
utilisation latency vs service time usable?
------------------------------------------------------
50% 2.0x lots of headroom
70% 3.3x <-- TARGET
80% 5.0x acceptable, watch it
90% 10.0x no burst tolerance
95% 20.0x effectively saturated
99% 100.0x an outage with a heartbeat
A machine at 95% is "working" the way a motorway at 95% density
is "moving". Both are one small perturbation from a jam.
N+1: SIZE FOR THE FAILURE, NOT THE STEADY STATE
3 instances, each at 70% -> total load = 210% of one instance
one dies:
210% / 2 survivors = 105% each -> CASCADE
(each survivor saturates, latency explodes, health checks
fail, they get killed, the remaining one gets 210%...)
Correct sizing for N+1 at a 70% post-failure target:
need: total_load / 0.70 + 1 instance
210% / 0.70 = 3 instances of capacity, + 1 spare = 4
steady state: each at 52.5%, after a failure: 70%. Survivable.
The cascade is why "we had redundancy" and "we had an outage"
are compatible statements.
UNIVERSAL SCALABILITY LAW: MORE NODES CAN MEAN LESS THROUGHPUT
C(N) = N / (1 + a(N-1) + bN(N-1))
^ ^
contention COHERENCY (crosstalk): grows as N^2
throughput
| ....*....
| ..* *.. <- the peak
| .* *..
| .* *.. <- adding nodes now
| .* * REDUCES throughput
|.*
+------------------------------------> nodes
1 4 8 16 32 64 128
Amdahl's curve flattens. The USL curve TURNS DOWN, because every
new node must coordinate with every existing one.
Practical reading: find your peak empirically, then stay left of it.
FROM BUSINESS FORECAST TO HARDWARE ORDER
measured today:
4,000 req/s peak, 12 cores at 70%, 48 GB at 60%, 9k IOPS
cost per request: 0.0021 CPU-s, 12 MB session, 2.25 IOPS
forecast: 3x traffic in 12 months -> 12,000 req/s peak
CPU: 12000 * 0.0021 = 25.2 cores of work
/ 0.70 target = 36 cores needed (have 12)
memory: 12000 * 0.012 GB concurrent sessions...
-> compute via Little's law, not per-request
IOPS: 12000 * 2.25 = 27,000 IOPS
/ 0.70 = 38,571 provisioned (have 9k)
^^^^^^ STORAGE binds first, not CPU
The point of the exercise: the first thing to saturate was not
the thing anyone was watching.
# Capacity planning as arithmetic you can re-run when the forecast changes.
import math
def usable_capacity(total, target_util=0.70):
"""Never plan against 100%: the response-time curve forbids it."""
return total * target_util
def response_multiplier(rho):
"""How many service times a request takes at utilisation rho."""
return float("inf") if rho >= 1 else 1 / (1 - rho)
def instances_needed(load_units, target_util=0.70, tolerate_failures=1):
"""Size for the POST-FAILURE state, which is the state that matters."""
active = math.ceil(load_units / target_util)
return active + tolerate_failures # N+1 (or N+2)
def usl(n, contention=0.03, coherency=0.0005):
"""Universal Scalability Law: returns SPEEDUP at n nodes.
Note that it eventually DECREASES -- Amdahl's never does."""
return n / (1 + contention * (n - 1) + coherency * n * (n - 1))
# --- Where does adding nodes stop helping? ---
best = max(range(1, 257), key=usl)
print(f"peak throughput at {best} nodes -> {usl(best):.1f}x")
print(f"at {best*2} nodes -> {usl(best*2):.1f}x (WORSE, and costs 2x)")
# peak throughput at 41 nodes -> 13.5x
# at 82 nodes -> 12.7x (WORSE, and costs 2x)
# --- A full forecast ---
current = {"rps": 4000, "cpu_s_per_req": 0.0021,
"iops_per_req": 2.25, "mb_per_session": 12}
growth = 3.0
future_rps = current["rps"] * growth
cpu_work = future_rps * current["cpu_s_per_req"] # cores of work
cpu_needed = math.ceil(cpu_work / 0.70)
iops_need = math.ceil(future_rps * current["iops_per_req"] / 0.70)
print(f"at {future_rps:.0f} rps: {cpu_needed} cores, {iops_need:,} IOPS")
# at 12000 rps: 36 cores, 38,572 IOPS
# --- N+1 sizing, and the cascade it prevents ---
load = 2.1 # 3 instances at 70%
print(f"instances required: {instances_needed(load)}") # 4
survivors = 3 - 1
print(f"naive 3-instance design after 1 failure: "
f"{load / survivors:.0%} each -> "
f"{response_multiplier(min(load/survivors, 0.999)):.0f}x latency")
# naive 3-instance design after 1 failure: 105% each -> inf x latency
# ---- Load testing WITHOUT coordinated omission ----
# wrk2: fixed ARRIVAL RATE (-R), latency measured from intended send
# time. This is the property that makes the numbers honest.
wrk2 -t8 -c200 -d5m -R2000 --latency http://svc/api
# Latency Distribution (HdrHistogram - Recorded Latency)
# 50.000% 12.31ms
# 90.000% 28.94ms
# 99.000% 145.22ms
# 99.990% 892.11ms <- the number that sizes your headroom
#
# Compare with a closed-loop tool (ab, plain wrk) on the SAME service:
# it will report a far better tail, because it stopped sending during
# every stall. Same system, flattering numbers.
# Find the knee: step the rate up and watch where latency departs
# from flat. That inflection is your real capacity, not the rate at
# which errors start.
for rate in 500 1000 1500 2000 2500 3000 4000; do
echo "=== ${rate} rps ==="
wrk2 -t8 -c200 -d60s -R$rate --latency http://svc/api \
| grep -E '99.000%|Requests/sec'
done
# 1000 rps -> p99 18 ms flat
# 2000 rps -> p99 31 ms flat-ish
# 2500 rps -> p99 96 ms <- the KNEE: plan to run below this
# 3000 rps -> p99 890 ms past the knee, on the hyperbola
# 4000 rps -> errors
# ---- Trend the resources you will run out of ----
sar -u -f /var/log/sa/sa$(date +%d) | tail -20 # CPU history
sar -r -f /var/log/sa/sa$(date +%d) | tail -20 # memory history
sar -d -f /var/log/sa/sa$(date +%d) | tail # disk history
# Fit a line to weekly peaks, not to averages: you run out of
# capacity at the PEAK, and averages hide it entirely.
df -h; df -i # both blocks AND inodes (Chapter 10)
ss -s # socket counts vs limits
cat /proc/sys/fs/file-nr # open fds: allocated / free / max
Key takeaways
- Usable capacity is well below total capacity because response time grows as
1/(1 − ρ); 70-80% utilisation is the standard target and 95% is effectively saturated. - Capacity planning proceeds by characterising the workload, measuring cost per unit of work, modelling which resource saturates first, and choosing a scaling strategy.
- Requests are not uniform, and peak-to-mean ratios of 3-10x with weekly and seasonal cycles mean planning must target peaks, not averages.
- The first resource to saturate is often not CPU — memory per session, IOPS, file descriptors, database connections, or a single-threaded component frequently bind first.
- Vertical scaling is simple with a hard ceiling; horizontal scaling has no ceiling but requires statelessness or partitioning and introduces distributed coordination.
- The Universal Scalability Law adds a coherency term to Amdahl, so throughput peaks and then declines as coordination costs grow quadratically with node count.
- N+1 sizing must target the post-failure utilisation, or losing one instance pushes survivors past saturation and triggers a cascade.
- Load tests must be open-loop with a fixed arrival rate, or coordinated omission will hide exactly the stalls that determine required headroom.
- The knee of the latency curve, found by stepping the offered rate, is the real capacity limit — not the rate at which errors begin.
- Capacity thresholds should be written down in advance, because the alternative is improvising the decision during an incident.
🧪 Practice
- A service uses 0.004 CPU-seconds per request and currently peaks at 1,500 requests per second. Compute the cores required to serve 4x that traffic at 70% utilisation.
- Four instances each run at 65%. Compute the per-instance utilisation after one fails, and determine the minimum instance count that keeps survivors at or below 75%.
- Use
wrk2(or any fixed-rate tool) to step the offered load against a local service and identify the knee of the latency curve. Explain why a closed-loop tool would report a different answer.- Plot the Universal Scalability Law for contention 0.05 and coherency 0.001, find the node count with peak throughput, and explain what is happening past that peak.
- Interview-style: "We run at 45% CPU and want to save money, so we are halving the fleet. Any concerns?" Hint: compute the post-change utilisation, then the post-change utilisation after one instance fails — and locate both on the response-time curve.
Kernel Tuning Parameters
Theory
Every kernel ships with defaults chosen to be reasonable across an enormous range of machines and workloads. That generality is exactly why tuning sometimes helps: your machine is not the average of all machines. But it is also why most tuning advice found online is harmful — a value that helped someone's write-heavy database in 2014 is not a value that helps your latency-sensitive web server on hardware that did not then exist.
So the rule comes before the knobs: measure, change one parameter, measure again, and keep the record. A tuning change with no before-and-after measurement is not tuning; it is superstition with root privileges. Most real performance problems are in the application, and the kernel's defaults are usually fine.
The parameters worth knowing, grouped by the subsystem they belong to:
Memory (Chapter 8). vm.swappiness (0-200, default 60) trades anonymous-page swapping against page-cache eviction — lower it for databases that manage their own caching, but 0 does not disable swap and can make OOM kills more likely. vm.dirty_ratio and vm.dirty_background_ratio bound how much modified page cache accumulates before writeback; large values on a big-memory machine create multi-gigabyte write storms that stall everything. vm.overcommit_memory decides whether the kernel permits allocations exceeding RAM plus swap. Transparent huge pages reduce TLB misses but their compaction can cause latency spikes, which is why most database vendors recommend madvise rather than always.
Storage (Chapters 9 and 11). The I/O scheduler choice matters far more than any single parameter: none (or mq-deadline) for NVMe, where the device reorders better than the kernel can, and bfq for desktop interactivity. nr_requests sets queue depth, and read_ahead_kb helps sequential scans while wasting bandwidth on random access.
Network. net.core.somaxconn caps the accept backlog and is a classic cause of dropped connections under bursty load — the default has been raised repeatedly precisely because so many people hit it. tcp_rmem/tcp_wmem size the socket buffers that determine achievable throughput on high bandwidth-delay-product links; tcp_max_syn_backlog and tcp_syncookies govern behaviour under connection floods; and the congestion control algorithm (bbr versus cubic) can matter significantly on lossy or long-haul paths.
Scheduler and limits (Chapters 3 and 4). sched_min_granularity_ns and sched_latency_ns trade context-switch overhead against responsiveness. fs.file-max and per-process RLIMIT_NOFILE bound open descriptors — "too many open files" is one of the most common production failures and is almost always a limit, not a resource shortage. kernel.pid_max matters on container hosts running thousands of processes.
Three cautions. First, many limits are per-cgroup now, so a container's effective limits come from its cgroup rather than from the host sysctl (Chapter 13) — tuning the host and wondering why nothing changed is a common waste of an afternoon. Second, NUMA placement usually beats parameter tuning on multi-socket machines: remote memory access can cost 1.5-2x, which dwarfs most knob adjustments. Third, make changes persistent and documented: a sysctl applied by hand disappears at reboot, and an undocumented one becomes a mystery the next person cannot safely remove.
Example
THE TUNING METHOD (the part that is not a parameter list)
1. MEASURE the problem (USE/RED, a number you can compare)
2. FORM a hypothesis ("writeback stalls cause the p99")
3. CHANGE ONE PARAMETER (one, so causality survives)
4. MEASURE again (same workload, same duration)
5. KEEP or REVERT (and WRITE DOWN which, and why)
6. Make it persistent (sysctl.d, with a comment)
Skipping step 1 produces "we set 40 sysctls from a blog post and
things feel better" -- unreproducible, unremovable, and untestable.
THE KNOBS THAT MATTER, BY SUBSYSTEM
MEMORY default consider ch.
vm.swappiness 60 1-10 for databases 8
vm.dirty_ratio 20 5-10 on big-RAM boxes 8
vm.dirty_background_ratio 10 3-5 8
vm.overcommit_memory 0 2 for strict accounting 7
THP /sys/.../enabled always madvise (databases) 7
STORAGE
.../queue/scheduler mq-deadline none for NVMe 9
.../queue/nr_requests 256 higher for deep queues 9
.../queue/read_ahead_kb 128 higher: seq; lower: rnd 9
NETWORK
net.core.somaxconn 4096 raise for bursty accept -
net.ipv4.tcp_rmem/wmem auto raise for high BDP -
net.ipv4.tcp_congestion_control cubic bbr on lossy/long paths -
net.ipv4.tcp_syncookies 1 keep at 1 12
SCHED / LIMITS
kernel.sched_latency_ns ~24ms lower = responsive 4
fs.file-max / RLIMIT_NOFILE varies raise; the #1 prod bug 10
kernel.pid_max 32768 raise on container hosts 2
WHY vm.dirty_ratio MATTERS ON A LARGE MACHINE
256 GB RAM, dirty_ratio = 20 (the default)
-> up to 51 GB of dirty pages may accumulate before a writer
is forced to block
-> then the kernel flushes 51 GB to a device doing 500 MB/s
-> 100 SECONDS of writeback, during which unrelated processes
stall on the same device
dirty forced writeback
pages | /| everything stalls
| /---- |
| /------ |
| /------ v
+-------------------------------------- time
^ dirty_ratio reached
Lowering it to 5% (12.8 GB) makes writeback smaller and more
frequent: worse peak throughput, FAR better worst-case latency.
That is the same trade as every other real-time decision (Ch. 14).
THE TRAP: TUNING THE HOST, RUNNING IN A CONTAINER
host: sysctl fs.file-max = 2000000 you set this
container: cgroup pids.max = 4096 this is what applies
RLIMIT_NOFILE = 1024 and this
result: "too many open files" at 1024, while the host says 2M
Since Chapter 13, the effective limit is the MOST RESTRICTIVE of:
host sysctl -> cgroup limit -> rlimit -> application config
Check all four before concluding the kernel is at fault.
# ---- Inspect before you change anything ----
sysctl -a | wc -l # ~1000 parameters exist
sysctl vm.swappiness vm.dirty_ratio net.core.somaxconn
sysctl -a --pattern 'net.ipv4.tcp_(r|w)mem'
# ---- Change ONE thing, temporarily, and measure ----
sysctl -w vm.swappiness=10 # runtime only; gone on reboot
# ... run the SAME workload, for the SAME duration, and compare ...
sysctl -w vm.swappiness=60 # revert if it did not help
# ---- Make it persistent, WITH A REASON ----
cat > /etc/sysctl.d/99-db-tuning.conf <<'EOF'
# 2026-09-11: postgres manages its own cache; swapping shared_buffers
# caused p99 spikes (see incident 4417). Measured: p99 820ms -> 95ms.
vm.swappiness = 10
# 256 GB RAM: default dirty_ratio=20 allowed ~51 GB of dirty pages and
# 100 s writeback storms. 5% = ~12.8 GB. Throughput -3%, p99 -85%.
vm.dirty_ratio = 5
vm.dirty_background_ratio = 2
EOF
sysctl --system # apply all sysctl.d files
# ---- Storage: the scheduler choice outweighs the knobs ----
cat /sys/block/nvme0n1/queue/scheduler
# [none] mq-deadline kyber bfq <- NVMe: 'none' is usually right
echo none > /sys/block/nvme0n1/queue/scheduler
cat /sys/block/sda/queue/rotational # 1 = spinning: scheduling helps
# ---- The single most common production failure ----
ulimit -n # per-shell soft limit
cat /proc/sys/fs/file-nr # allocated / unused / max
cat /proc/$(pgrep -n myapp)/limits | grep 'open files'
# Max open files 1024 4096 files <- soft / hard
# systemd services ignore /etc/security/limits.conf; set it here:
# [Service]
# LimitNOFILE=65535
# ---- In a container, check the cgroup, not the host ----
cat /sys/fs/cgroup/pids.max
cat /sys/fs/cgroup/memory.max
cat /sys/fs/cgroup/cpu.max
cat /sys/fs/cgroup/memory.pressure # PSI per cgroup (Chapter 13)
# ---- NUMA placement often beats every sysctl on this list ----
numactl --hardware
# node distances: node 0 1
# 0: 10 21 <- remote memory ~2x the latency
lscpu | grep -i numa
numactl --cpunodebind=0 --membind=0 ./workload # pin CPU and memory
numastat -p $(pgrep -n myapp) # numa_miss > 0 means remote access
# Tuning without measurement is superstition. This is the minimum
# honest harness: same workload, same duration, one variable changed.
import subprocess, statistics, json, time
def get(param):
return subprocess.check_output(["sysctl", "-n", param]).decode().strip()
def set_(param, value):
subprocess.run(["sysctl", "-w", f"{param}={value}"], check=True)
def benchmark(runs=5):
"""Return p99 latency in ms. Must be the REAL workload -- a
synthetic loop measures the synthetic loop."""
samples = []
for _ in range(runs):
out = subprocess.check_output([
"wrk2", "-t4", "-c100", "-d60s", "-R1000",
"--latency", "http://localhost:8080/api"])
samples.append(parse_p99(out))
return statistics.median(samples) # median of runs: resists
# one noisy outlier
def try_parameter(param, new_value):
old = get(param)
before = benchmark()
set_(param, new_value)
time.sleep(30) # let the system settle:
# caches and writeback lag
after = benchmark()
delta = (after - before) / before * 100
verdict = "KEEP" if after < before * 0.95 else "REVERT"
if verdict == "REVERT":
set_(param, old) # leave no undocumented state
return {"param": param, "old": old, "new": new_value,
"p99_before_ms": before, "p99_after_ms": after,
"change_pct": round(delta, 1), "verdict": verdict}
results = [try_parameter("vm.swappiness", 10),
try_parameter("vm.dirty_ratio", 5),
try_parameter("net.core.somaxconn", 8192)]
print(json.dumps(results, indent=2))
# Keep this output. It is the justification the next engineer needs
# in order to remove the setting safely when the workload changes.
Key takeaways
- Kernel defaults are chosen to be reasonable across all machines, which is both why tuning can help a specific workload and why copied advice usually does not.
- The method is to measure, change one parameter, measure again with the same workload, and record the result; an unmeasured change is superstition with root access.
- Most performance problems are in the application, so kernel tuning should follow application profiling, not precede it.
vm.swappinesstrades anonymous swapping against page-cache eviction and is commonly lowered for databases that manage their own caching, but 0 does not disable swap.vm.dirty_ratiobounds accumulated dirty pages, and the default on a large-memory machine can permit tens of gigabytes and multi-second writeback stalls.- Transparent huge pages reduce TLB misses but their compaction causes latency spikes, which is why
madviseis the common database recommendation. - Choosing the I/O scheduler —
nonefor NVMe,bfqfor desktop interactivity — matters more than any individual queue parameter. net.core.somaxconnand socket buffer sizes are frequent causes of dropped connections and throughput ceilings on bursty or high bandwidth-delay-product links.- "Too many open files" is among the most common production failures and is a limit problem, resolved at the systemd unit or rlimit level rather than by adding resources.
- In containers the effective limit is the most restrictive of host sysctl, cgroup limit, rlimit, and application config, so tuning the host alone often changes nothing.
- NUMA placement frequently outweighs every sysctl on a multi-socket machine, since remote memory access can cost 1.5-2x.
- Persist changes in
sysctl.dwith a comment explaining the measurement that justified them, so a future engineer can safely remove them.
🧪 Practice
- Record
vm.swappiness,vm.dirty_ratio, andnet.core.somaxconnon your machine and explain in one sentence what each controls and which chapter's mechanism it affects.- Compute the maximum dirty page bytes permitted on a 128 GB machine at the default
vm.dirty_ratio, then estimate the writeback duration on a device sustaining 600 MB/s.- Find the effective open-file limit for a running process via
/proc/<pid>/limits, then determine where that limit is configured and how you would raise it for a systemd service.- Inside a container, compare
sysctl fs.file-maxwith the cgroup'spids.maxand the process rlimit, and state which one would actually stop the application first.- Interview-style: "A blog post recommends 20 sysctl settings for high-performance servers. Should we apply them?" Hint: ask what each was measured against, and what you would do to find out whether any of them help this workload — then consider what happens when one needs removing in two years.