Foundation · Core

Computer Fundamentals

What the hardware is actually doing underneath your process — caches, translation, interrupts and the six orders of magnitude between L1 and a disk seek.

22 min read Level: core → advanced Foundation 01 / 10
The model

THE LATENCY PYRAMID

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.

SOFTWAREYour processLibraries / runtimeSystem callsKERNELSchedulerVirtual memoryBlock layerNet stackTRANSLATIONMMU + TLBPage tablesIOMMU / DMACACHEL1d / L1i ~1 nsL2 ~4 nsL3 shared ~20-40 nsMEMORYDRAM ~80-100 nsNUMA remote +50%STORAGENVMe ~20-100 µsSATA SSD ~150 µsHDD ~5-10 msNETWORKSame rack ~0.1 msSame region ~1 msCross-continent ~100 ms

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.

Diagrams

THREE VIEWS OF THE SAME SYSTEM

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.

Low levelWhat is inside one core, between the instruction and the data?
ONE CPU COREinstruction streamload / storeL3 miss → memorycoherency trafficFETCH AND DECODEL1 instruction cache~32 KB, ~4 cyclesbranch predictora miss costs ~15 cyclesdecoder → µopsADDRESS TRANSLATIONTLBvirtual → physicalpage walk on a miss~100+ cyclesMMUDATA PATHL1 data cache~32 KB, ~4 cyclesL2~1 MB, ~14 cyclesL3 shared~32 MB, ~40 cyclesprefetcherguesses the next line
Nothing here is optional. Every line of your code goes through all of it, and the only part you can influence from a high-level language is whether the data you touch next is already in the line you just pulled in.
ConnectionWhat does one memory read actually cost?
load instructionL1d~1 nsL2missL3missmemory controllermissDRAM row~80 nscache line filled64 bytes
Six orders of magnitude separate the first hop from the last. This is why an array beats a linked list with identical Big-O: the array's next element arrived in the same 64-byte line, and the list's is a fresh trip to DRAM.
Core

CORE CONCEPTS

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.

What actually stalls a core

  • Cache misses — the biggest one. A last-level miss costs ~200–300 cycles of doing nothing useful.
  • Branch mispredictions — the pipeline is flushed and refilled, ~15–20 cycles. Unpredictable branches in a hot loop are expensive.
  • Dependency chains — instruction N+1 needs N's result, so out-of-order execution has nothing else to run.
measuring it rather than guessing
$ perf stat -e cycles,instructions,cache-misses,branch-misses ./app
41,238,551,102 cycles
18,442,190,338 instructions # 0.45 insn per cycle
891,204,116 cache-misses
92,441,003 branch-misses
IPC 0.45 on a workload that should be compute-bound = memory-bound in disguise

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.

The two localities

  • Temporal — you touched it recently, so it's probably still cached. Loop counters, hot config, the top of a call stack.
  • Spatial — you touched the neighbour, so it's already in the line. Arrays, struct fields accessed together, contiguous file reads.
False sharing

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.

Huge pages

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.

checking translation pressure
$ perf stat -e dTLB-load-misses,dTLB-loads ./app
$ cat /sys/kernel/mm/transparent_hugepage/enabled
[always] madvise never
databases usually want: madvise (or never), never always
$ grep -i huge /proc/meminfo

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.

IOPS and latency are not the same problem

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.

LayerTypical latencyWhat it means in practice
L1 cache~1 nsEffectively free. 4 cycles.
L3 cache~20–40 nsShared across cores; contention shows here first.
DRAM (local)~80–100 ns~250 cycles of doing nothing.
DRAM (remote NUMA)~130–160 ns50%+ penalty for crossing a socket.
NVMe read~20–100 µs~1,000× DRAM. Queue depth decides throughput.
SATA SSD read~100–200 µsFine 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 msCheaper than a disk seek. Design accordingly.
Cross-region RTT~50–150 msPhysics. No amount of tuning fixes light speed.
Advanced

ADVANCED

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.

seeing and fixing NUMA placement
$ lscpu | grep -i numa
NUMA node(s): 2
NUMA node0 CPU(s): 0-23,48-71
NUMA node1 CPU(s): 24-47,72-95
$ numastat -p $(pgrep -f postgres | head -1)
high 'other_node' means the process is reaching across the interconnect
$ numactl --cpunodebind=0 --membind=0 ./latency-sensitive-thing
or interleave when the working set genuinely exceeds one node:
$ numactl --interleave=all ./big-heap-thing

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.

Where this bites in production

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.

diagnosing a single-core softirq bottleneck
$ mpstat -P ALL 1 | head -20
one CPU at 100% %soft while the rest idle = classic IRQ pinning problem
$ cat /proc/interrupts | grep -E 'eth0|ens'
$ cat /proc/softirqs | head -3
$ ethtool -l ens5 # how many RX queues does the NIC have?
$ ethtool -L ens5 combined 16
then let irqbalance spread them, or pin by hand:
$ echo 2 > /proc/irq/142/smp_affinity_list

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.

is switching the problem?
$ vmstat 1 5
procs -----------memory---------- ---system--- ------cpu-----
r b swpd free buff cache in cs us sy id wa st
8 0 0 2104832 189232 8814720 48219 241883 62 31 6 1 0
cs 241k/s with sy 31% — the kernel is busier than the application
$ pidstat -w -p $(pgrep -f myapp) 1
cswch/s = voluntary (waiting on I/O or a lock)
nvcswch/s = involuntary (preempted — too many runnable threads)

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.

  • A cache miss to DRAM is ~250 cycles. If a hot loop misses every iteration, you have a memory problem, not a CPU problem.
  • An NVMe read is ~1,000× a DRAM read. Caching a value that costs a disk read is worth doing even if the cache hit rate is only 50%.
  • A same-region network round trip is cheaper than an HDD seek. A remote cache can genuinely be faster than local spinning disk.
  • Cross-region is ~100 ms and unfixable. Any design with N sequential cross-region calls has an N × 100 ms floor. Batch them or move the compute.
  • 1 Gbps is 125 MB/s. Divide bits by eight before promising anyone a transfer window.
In practice

ON A REAL BOX

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.

machine inventory in ninety seconds
$ lscpu
Architecture: x86_64
CPU(s): 96 Thread(s) per core: 2 Core(s) per socket: 24
Socket(s): 2 NUMA node(s): 2
L1d cache: 32K L1i cache: 32K L2 cache: 1024K L3 cache: 36864K
96 'CPUs' is 48 physical cores. Capacity plan on cores, not threads.
$ lsblk -o NAME,ROTA,SIZE,MODEL,SCHED
NAME ROTA SIZE MODEL SCHED
nvme0n1 0 1.8T Samsung PM9A3 none
ROTA=0 is solid state. SCHED=none is correct for NVMe — the device
reorders better than the kernel can, and mq-deadline just adds latency.
$ ethtool ens5 | grep -E 'Speed|Duplex'
Speed: 25000Mb/s
25 Gbps = 3.1 GB/s. Now you know the ceiling before you design around it.
The one-minute rule

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.

Reference

CHEATSHEET

CommandWhat it tells you
lscpuCores, sockets, NUMA nodes, cache sizes, flags
lstopo --of txtFull topology map — which core shares which cache
numactl --hardwareNUMA nodes, memory per node, inter-node distances
numastat -p PIDLocal vs remote memory hits for one process
perf stat -e cycles,instructions CMDIPC — cycles actually spent retiring work
perf stat -e cache-misses,LLC-load-misses CMDWhether you are memory-bound
perf topLive symbol-level view of where cycles go
vmstat 1Context switches, interrupts, run queue, swap activity
pidstat -w -p PID 1Voluntary vs involuntary switches for one process
mpstat -P ALL 1Per-CPU breakdown — finds the one saturated core
cat /proc/interruptsWhich CPU is servicing which device
iostat -x 1Per-device await, queue depth, utilisation
lsblk -o NAME,ROTA,SCHEDRotational or not, and the I/O scheduler in use
dmidecode -t memoryDIMM population, speed, channel layout
getconf LEVEL1_DCACHE_LINESIZECache line size — 64 on anything you'll meet