What the hardware is actually doing underneath your process — caches, translation, interrupts and the six orders of magnitude between L1 and a disk seek.
Every layer below is roughly an order of magnitude slower than the one above it. Most performance work is moving an access up this diagram.
The numbers are for a current x86-64 server. They move slowly — the ratios between layers have been stable for two decades, which is what makes them worth memorising.
The diagram above is the high level: what the pieces are. These two are the ones you want when something is wrong — what is inside one of those boxes, and the path a request really takes through them.
The four things that explain most of what you'll see on a production box.
A modern core does not execute your instructions one at a time in order. It runs a deep pipeline — typically 14–20 stages — with several instructions in flight at once, issues them out of order, executes speculatively past branches, and retires them back in program order so the result looks sequential.
That matters operationally because it decouples clock speed from work done. The number you actually care about is IPC — instructions per cycle. A core at 3 GHz with IPC 0.4 is doing less work than one at 2 GHz with IPC 1.8. When a service gets slower after a deploy and CPU utilisation looks identical, IPC is usually where the answer is: the code started missing cache, and the core is spending its cycles stalled rather than retiring work.
The CPU never reads one byte from RAM. It reads a cache line — 64 bytes on every x86-64 and most ARM64 parts — and everything in that line comes along for free. This single fact drives most of the performance difference between two implementations of the same algorithm.
Walking an array of structs sequentially is fast because each miss pulls in the next several elements. Chasing pointers through a linked list is slow because every hop is a fresh miss with nothing useful alongside it — the same O(n) traversal can differ by 10× in wall time.
Two threads on two cores writing to different variables that happen to sit in the same 64-byte line will serialise on the cache-coherence protocol as if they shared one variable. Throughput collapses and nothing in the code looks wrong. The fix is padding — align hot per-thread counters to their own line. This shows up constantly in metrics libraries and lock-free queues.
Your process sees a flat virtual address space. The MMU translates each virtual address to a physical one by walking page tables — four levels on x86-64, so an untranslated access could cost four extra memory reads. The TLB caches recent translations to avoid that walk; it holds only a few thousand entries.
With a 4 KiB page and ~1,500 TLB entries, a core can cover roughly 6 MB of memory before it starts missing the TLB on every access. A process with a 40 GB working set that jumps around randomly will spend a startling fraction of its time walking page tables.
A 2 MiB page covers 512× more memory per TLB entry. For databases, JVMs with large heaps, and anything with a big random-access working set, huge pages can be a double-digit-percent win. Transparent Huge Pages (THP) does it automatically — and is also a classic latency culprit, because the compaction it does to find contiguous memory stalls the process that triggered it. Most database vendors tell you to turn THP off and use explicit hugepages instead. They are right, for that workload.
Storage is where the latency pyramid gets steep. An NVMe read is roughly a thousand times slower than DRAM; a spinning disk seek is a hundred thousand times slower. Any design decision that turns a memory access into a disk access is worth a hundred micro-optimisations elsewhere.
An NVMe device advertising 800k IOPS achieves that at high queue depth — many requests in flight at once. A single-threaded process issuing one synchronous read at a time gets device latency, not device throughput: maybe 12k IOPS from the same hardware. If your benchmark says the disk is fine and your application says it isn't, queue depth is usually the gap.
| Layer | Typical latency | What it means in practice |
|---|---|---|
| L1 cache | ~1 ns | Effectively free. 4 cycles. |
| L3 cache | ~20–40 ns | Shared across cores; contention shows here first. |
| DRAM (local) | ~80–100 ns | ~250 cycles of doing nothing. |
| DRAM (remote NUMA) | ~130–160 ns | 50%+ penalty for crossing a socket. |
| NVMe read | ~20–100 µs | ~1,000× DRAM. Queue depth decides throughput. |
| SATA SSD read | ~100–200 µs | Fine for most things, not for a hot index. |
| HDD seek + read | ~5–10 ms | ~100,000× DRAM. Sequential only, or don't. |
| Same-rack RTT | ~0.1–0.2 ms | Cheaper than a disk seek. Design accordingly. |
| Cross-region RTT | ~50–150 ms | Physics. No amount of tuning fixes light speed. |
Where the simple model stops predicting what you measure.
On a multi-socket server, each CPU package has its own memory controller and its own directly attached DRAM. Accessing memory on the other socket goes across the interconnect and costs roughly 1.5× the latency and less bandwidth. The kernel tries to allocate memory on the node where the allocating thread runs — but if the scheduler later migrates that thread, every access becomes remote.
This is why large single-process databases are usually pinned. It's also why a container without CPU affinity can show 30% variance run to run on the same hardware for no visible reason.
A NIC receiving a packet does not interrupt the CPU for each byte. It DMAs the frame straight into a ring buffer in RAM, then raises one interrupt to say "there is work". The kernel's top half acknowledges it fast and defers the real processing to a softirq, which is where most of the network stack actually runs.
Under load the kernel switches to NAPI polling — interrupts off, poll the ring — because at a million packets per second, interrupt overhead alone would consume the machine.
By default all NIC interrupts may land on CPU 0. One core saturates handling softirqs while 47 others idle, and your throughput ceiling has nothing to do with your application. RSS (receive-side scaling) spreads flows across multiple queues, and IRQ affinity pins each queue to a core — ideally one on the same NUMA node as the NIC.
Saving registers and swapping page tables takes roughly 1–5 µs. That number is misleading, because the expensive part is what happens afterwards: the incoming process finds the L1 and L2 caches full of the outgoing process's data, and the TLB partly flushed. It runs slowly for tens of microseconds while it re-warms.
A machine doing 200k context switches per second is not spending 20% of its time in the switch code — it is spending far more than that running cold. This is the real argument for CPU pinning on latency-sensitive services, and the reason thread-per-request models fall over at high concurrency while event loops don't.
You will make a hundred design decisions before you ever profile anything. Rough magnitudes are what keep those decisions sane — and they are stable, because they are set by physics and by hardware generations, not by your code.
Three commands that tell you what kind of machine you are actually on, before you tune anything on it. Run them on any box you are about to make promises about.
Before optimising anything, establish which of the four resources you are out
of: CPU cycles, memory bandwidth, I/O, or network. perf stat answers the first two,
iostat -x 1 the third, sar -n DEV 1 the fourth. Guessing wrong costs a
week; measuring costs a minute.
| Command | What it tells you |
|---|---|
lscpu | Cores, sockets, NUMA nodes, cache sizes, flags |
lstopo --of txt | Full topology map — which core shares which cache |
numactl --hardware | NUMA nodes, memory per node, inter-node distances |
numastat -p PID | Local vs remote memory hits for one process |
perf stat -e cycles,instructions CMD | IPC — cycles actually spent retiring work |
perf stat -e cache-misses,LLC-load-misses CMD | Whether you are memory-bound |
perf top | Live symbol-level view of where cycles go |
vmstat 1 | Context switches, interrupts, run queue, swap activity |
pidstat -w -p PID 1 | Voluntary vs involuntary switches for one process |
mpstat -P ALL 1 | Per-CPU breakdown — finds the one saturated core |
cat /proc/interrupts | Which CPU is servicing which device |
iostat -x 1 | Per-device await, queue depth, utilisation |
lsblk -o NAME,ROTA,SCHED | Rotational or not, and the I/O scheduler in use |
dmidecode -t memory | DIMM population, speed, channel layout |
getconf LEVEL1_DCACHE_LINESIZE | Cache line size — 64 on anything you'll meet |