Mechanical Sympathy: CPU Caches, Memory, and Disks22 min read
Module F-2·22 min read
Cache lines, the memory hierarchy, RAM vs NVMe vs HDD, IOPS — and why a sequential disk write beats a random RAM access, the one fact that explains Kafka, LSM-trees, and UUID keys later.
Module F-2 — Mechanical Sympathy: CPU Caches, Memory, and Disks
What this module covers: Everything above this layer is built on how long the machine takes to do things, and most engineers carry numbers that are wrong by three orders of magnitude. This module fixes that. It covers the memory hierarchy with real latencies, cache lines and why pointer-chasing is slow, the difference between latency and throughput, IOPS and queue depth on NVMe vs SATA vs spinning disks, the page cache and what fsync actually costs — and one specific counterintuitive fact, stated precisely: measured in useful bytes per second, a sequential write to an SSD beats a random access pattern in RAM. That single fact explains Kafka's design, LSM-trees, and why a random UUID primary key quietly wrecks a large table.
Why an Architect Cares About Silicon
You can design systems for years without knowing what an L3 cache is. You cannot, however, do any of the following without it:
Estimate whether a proposed design is 10× or 1000× off before you build it (Module F-3).
Explain why Cassandra and RocksDB chose a completely different storage structure from Postgres (Module P-2).
Explain why switching a primary key from an auto-incrementing integer to a random UUID degraded insert throughput on a 400GB table (Module P-3).
Explain how Kafka sustains gigabytes per second while writing every message to disk (Module P-15).
All four have the same answer, and it lives at this layer. "Mechanical sympathy" is Martin Thompson's borrowed term for it — a driver who understands the engine gets more out of the car, without being a mechanic.
Analogy: the memory hierarchy is a kitchen. What's on the cutting board in front of you is a register. The knife block within arm's reach is L1. The fridge two steps away is RAM. The supermarket across town is disk. Ordering from another city is the network. Every layer down is dramatically slower and dramatically bigger, and a good cook's entire technique is mise en place — arranging things so the next ingredient you need is already on the board. That's what a cache line, a prefetcher, and a sequential scan all are: mise en place for data.
The Numbers, and the Same Numbers in Human Time
Approximate latencies on a modern server. These vary by hardware generation, so treat them as orders of magnitude — which is exactly how they should be used.
Operation
Latency
Scaled: if 1 ns = 1 second
L1 cache reference
~1 ns
1 second
L2 cache reference
~4 ns
4 seconds
L3 cache reference
~15 ns
15 seconds
Main memory (DRAM) reference
~100 ns
1.5 minutes
NVMe SSD random read (4 KB)
~50 µs
~14 hours
SATA SSD random read (4 KB)
~150 µs
~1.7 days
Round trip within a datacentre
~0.5 ms
~6 days
HDD seek
~8 ms
~3 months
Round trip Mumbai → Virginia
~180 ms
~6 years
The right-hand column is the one worth memorising, because it converts abstract nanoseconds into intuitions you already have. A cache hit is picking up the knife next to you. A main-memory read is walking to the fridge. An SSD read is a full working day. A cross-continent round trip is six years — which is why Module A-9 treats a multi-region design as a fundamentally different animal rather than "the same system, further apart."
Two things follow immediately:
Distance dominates everything. The gap between L1 and DRAM (100×) is smaller than the gap between DRAM and NVMe (500×), which is smaller than the gap between NVMe and a cross-continent round trip (3,600×). This is why the single most reliable performance intervention in any system is moving the data closer to the computation — and it's why "just add a cache" works so often it's almost boring.
Nothing in your code will fix a bad layer choice. An algorithm improvement that halves CPU work is worth nothing if the request makes four sequential cross-region calls. The layer you're operating at sets the ceiling; code quality decides where you land under it.
Cache Lines: Memory Doesn't Arrive One Byte at a Time
When the CPU reads a single byte from memory, it does not fetch one byte. It fetches an entire cache line — 64 bytes on essentially every x86-64 and ARM64 server you'll deploy on — and puts it in L1. The consequence is enormous: the next 63 bytes are now effectively free, and reading a byte 1 MB away costs the full DRAM round trip again.
On top of that, the hardware prefetcher watches your access pattern. If you're walking memory with a predictable stride, it starts fetching lines before you ask for them, hiding the latency entirely. If your pattern looks random, it can't help, and you eat ~100 ns per access.
That's the whole mechanism. Now the consequence, which shows up in JavaScript just as much as in C:
typescript
Both loops are O(n). They differ by a large constant factor — often 3–10× on this kind of scan — and Big-O deliberately hides exactly that constant. This is where "O(n) is O(n)" stops being the whole story: two linear scans can differ by an order of magnitude purely from memory layout, and no amount of complexity analysis will show it to you.
The same principle explains why a linked list is slower to traverse than an array with identical complexity — every next pointer is a potential cache miss, and the prefetcher can't guess where the next node lives. It also explains why columnar storage formats (Module P-22) are so much faster for analytics: if a query only needs amount, a columnar layout hands you cache lines containing nothing but amounts, while a row store drags along every other field in each row.
Why this matters in production: the version of this you'll actually hit is a database page, not a cache line — same idea, larger unit. Postgres reads and writes in 8 KB pages. Asking for one 200-byte row costs a full 8 KB page read, and the other rows on that page are now cheap. Whether the rows you want next are on that page is decided by your insert order and your index — which is precisely the mechanism Module P-3 uses to explain how random UUID keys destroy insert performance.
Latency vs Throughput, and Why Both Are Needed
These get used interchangeably and mean different things.
Latency is how long one operation takes. Throughput is how many operations complete per unit time. They're related but not reciprocal, because operations run in parallel.
A single NVMe random 4 KB read takes ~50 µs of latency. That does not mean the device does 20,000 reads/second. A modern NVMe drive has many independent queues and will happily service hundreds of requests concurrently, delivering 500,000+ IOPS while each individual read still takes ~50 µs. Latency stayed the same; throughput went up 25× through queue depth — how many requests are in flight at once.
This distinction is why:
A single-threaded process issuing one blocking read at a time gets a tiny fraction of a device's capability, no matter how fast the device is. Concurrency is what converts latency into throughput.
Benchmarks are meaningless without stating queue depth. "4K random read IOPS" at QD1 and QD32 differ by more than an order of magnitude on the same drive.
Adding load to a system near saturation increases latency without increasing throughput — the requests queue instead of completing. Module F-4 formalises this with Little's Law, and Module A-8 turns it into a load-shedding strategy.
Rules of thumb worth carrying:
Device
Sequential throughput
Random 4 KB IOPS
DDR4/DDR5 DRAM
20–50 GB/s
~10M random accesses/s (at ~100 ns each)
NVMe SSD (PCIe 4.0)
3–7 GB/s
500K–1M
SATA SSD
~550 MB/s
50K–90K
7200 RPM HDD
150–250 MB/s
~100–200
Look at the HDD row. Sequential throughput is within 3× of a SATA SSD; random IOPS is worse by a factor of ~500. A spinning disk isn't uniformly slow — it's catastrophically slow at random access specifically, because each seek moves a physical arm. That asymmetry is why the entire first generation of database design was organised around avoiding seeks, and why those designs still pay off on SSDs where the asymmetry is smaller but real.
The Fact That Explains Everything Later
Here is the claim, stated precisely, because the sloppy version of it is wrong:
Measured in useful bytes per second, a sequential write to an SSD beats a random-access pattern in DRAM.
Work through it:
Random access in DRAM: each access costs ~100 ns and, if you only use a few bytes of the cache line you pulled in, you get ~8 useful bytes per access. That's ~10M accesses/second × 8 bytes = ~80 MB/s of useful data.
Sequential write to NVMe: ~3,000 MB/s, and every byte is useful.
So the sequential SSD write delivers roughly 35× more useful throughput than the random memory access pattern. Not because the SSD is faster than RAM — it isn't, by any measure of a single operation — but because access pattern beats storage medium by a wider margin than people expect.
Two caveats to keep this honest, both of which matter in practice:
This is a throughput claim, not a latency claim. One random DRAM read (~100 ns) is ~500× faster than one NVMe write (~50 µs). If you need one small value right now, memory wins overwhelmingly. The sequential-wins result applies when you're moving bulk data.
Sequential access in DRAM is far faster than either — 20–50 GB/s, since the prefetcher and full cache-line utilisation both work for you. The comparison above is deliberately worst-case-memory against best-case-disk to make the size of the access-pattern effect visible.
With that stated properly, the payoff chain for the rest of the course:
Kafka (Module P-15) writes every message by appending to a file and lets consumers read sequentially. It is not fast despite using disk; it's fast because an append-only log is the access pattern disks are best at. Kafka's own design notes make exactly this argument.
LSM-trees (Module P-2) — the engine behind Cassandra, RocksDB, ScyllaDB, and LevelDB — buffer writes in memory and flush them as large sequential files, deliberately converting random writes into sequential ones. The cost, named honestly, is read amplification and compaction: you pay later, in background I/O.
B-trees (Modules F-13, P-2) — Postgres, MySQL/InnoDB — update pages in place, which means writes land wherever the key belongs. Sequential keys keep those writes clustered; random keys scatter them across the whole index.
Random UUID primary keys (Module P-3) are the failure mode of that last point. Each insert targets a random leaf page, so the working set of "pages currently being written" becomes the entire index rather than the last few pages. Page splits go up, cache hit ratio goes down, and WAL traffic multiplies. Same insert, same complexity class, a completely different physical access pattern.
FinOps (Module O-8) closes the loop: provisioned IOPS is a line item on a cloud bill. An access pattern that needs 10× the IOPS costs 10× more, forever.
The Page Cache and What fsync Really Costs
One more layer, because it's where "the write succeeded" gets ambiguous.
When your process writes to a file, the data normally lands in the OS page cache — RAM managed by the kernel — and the write call returns. The kernel flushes it to the device later. This is why writes often look impossibly fast: you measured a memory copy, not a disk write.
fsync() (or fdatasync()) forces the kernel to push that data to the device and wait for the device to confirm it's durable. That's the difference between "the OS has my data" and "my data survives a power cut," and it costs one device round trip — hundreds of microseconds on NVMe, and historically milliseconds on anything with a rotating platter.
This single call is why:
Databases batch commits. Postgres's commit_delay and group commit exist to amortise one fsync across many transactions. synchronous_commit = off makes commits dramatically faster by not waiting — trading a window of possible data loss for throughput, which is the trade-off stated plainly.
Durability is a spectrum, not a boolean. "Written" can mean in the application buffer, in the page cache, on the device, or on the devices of two replicas in different availability zones. Each step is slower and safer. Modules P-5 and O-7 depend on being precise about which one you meant.
Cloud block storage adds a network hop. An EBS volume is not a local disk; a durable write there is a network round trip inside the datacentre (~0.5 ms) plus replication on their side. Local NVMe instance storage is far faster and disappears when the instance stops — a genuine trade, not a free upgrade.
Where This Shows Up in Your Stack
PostgreSQL:shared_buffers is Postgres's own page cache, and the ratio in pg_statio_user_tables (heap blocks hit vs read) is a direct measure of whether your working set fits in memory. A query plan that switches from an index scan to a sequential scan isn't necessarily a regression — for a large fraction of a table, one sequential read at 3 GB/s genuinely beats a million random 8 KB page fetches, and the planner is doing this exact arithmetic (Module F-13).
Redis: the reason Redis is fast is that it eliminated the slowest layer entirely — data is in RAM, so every operation is a ~100 ns access rather than a ~50 µs one. That's the whole trick, and its cost is that your dataset must fit in memory and durability requires deliberate configuration (AOF appendfsync is exactly the fsync trade-off above).
Node.js:JSON.parse on a large payload allocates a graph of scattered heap objects; the parse cost you measure is substantially memory-layout cost, not parsing logic. Streaming (Readable) processes bounded chunks that stay resident in cache rather than materialising a huge object graph — that's a cache-friendliness win as much as a memory-ceiling win.
Kafka and object storage: both are throughput-optimised systems that assume sequential access. Using Kafka as a random-access key-value store, or S3 as a database with many small ranged reads, fights the medium and produces exactly the latency you'd predict from the table above.
Summary
Know the hierarchy in human time: L1 ~1 s, DRAM ~1.5 min, NVMe read ~14 hours, cross-continent round trip ~6 years (at 1 ns = 1 s). Distance dominates.
Memory arrives in 64-byte cache lines, and hardware prefetchers reward predictable strides. Two O(n) scans can differ by 10× purely from layout — the constant factor Big-O hides.
Latency and throughput are different axes. Queue depth and concurrency convert a fixed latency into much higher throughput; near saturation, added load raises latency without raising throughput.
Random access is the expensive thing, not disk. HDDs do ~100–200 random IOPS but 150–250 MB/s sequential; NVMe narrows the gap without closing it.
In useful bytes/second, a sequential SSD write (~3 GB/s) beats a random DRAM access pattern (~80 MB/s) — a throughput claim, not a latency one. Sequential DRAM still beats both.
That fact is the design origin of Kafka's log, LSM-trees, and the UUID-key problem, and it ends up on your cloud bill as provisioned IOPS.
fsync is the boundary between "the OS has it" and "it survived a power cut," costing one device round trip. Durability is a spectrum; say which point on it you mean.
The next module, Back-of-the-Envelope Estimation, puts these numbers to work. Now that you know what a disk seek and a memory access actually cost, you can size a system in five minutes and be confident you're within an order of magnitude — which is all any early design decision needs.
Knowledge Check
A developer replaces a loop over a 10-million-element array of { x, y } objects with a loop over a Float64Array of the same x values, and measures a 6× speedup. What explains this, given both loops are O(n)?
The module states that a sequential write to an SSD beats a random-access pattern in DRAM. Which reading of that claim is correct?
A service writes a record and returns 201 Created as soon as its write() call succeeds. A power failure takes the machine down, and on restart the last several seconds of records are gone despite the successful responses. What happened?
Test your knowledge with more question sets
Sign in to access a wider variety of questions and get notified when new practice sets are added to this module.
// Two ways to hold 10 million points.// (a) Array of objects — each object is a separate heap allocation.// Walking `points` chases a pointer per element; the x values you want are// scattered across memory alongside y, and other object header overhead.const points:{ x:number; y:number}[]=/* 10M objects */[];let sumA =0;for(const p of points) sumA += p.x;// (b) Parallel typed arrays — x values are contiguous, 8 bytes each.// One 64-byte cache line delivers 8 consecutive x values, and the// prefetcher sees a perfect stride and runs ahead of the loop.const xs =newFloat64Array(10_000_000);const ys =newFloat64Array(10_000_000);let sumB =0;for(let i =0; i < xs.length; i++) sumB += xs[i];