Quant developer HFT course · Module 5: The CPU
Cache coherency and MESI
The question
Eight threads each do an atomic increment on one shared counter. Throughput is lower than with one thread. Why?
The problem coherency solves
Each core has private caches. If two cores both hold a copy of a line and one writes it, the other copy is stale. Hardware cache coherency ensures every core sees a single, consistent value for each location: at any moment a line may be cached read-only by many cores, or writable by exactly one.
The protocol behind that on most processors is a variant of MESI, named after the four states a line can be in, in each core's cache.
| State | Meaning | This core may |
|---|---|---|
| Modified | only copy, changed since it was loaded | read and write freely |
| Exclusive | only copy, unchanged | read, and write without telling anyone (becomes Modified) |
| Shared | other cores may hold copies | read; must invalidate the others before writing |
| Invalid | not usable | must fetch it again |
Intel adds a Forward state and AMD an Owned state to reduce memory traffic, but MESI is enough for reasoning about code.
What a write to a shared line costs
When a core wants to write a line that it holds in Shared state, or does not hold at all, it must gain ownership: it sends a request that invalidates every other copy and waits for the acknowledgements. Only then can the write complete. The next time another core touches the line, it misses, fetches the line from the writer's cache, and the cycle continues.
A line written by several cores in turn therefore travels between them on every write. Each transfer costs roughly what an L3 hit or more costs, tens of nanoseconds on one socket and well over a hundred across sockets, and the transfers serialise: only one core can own the line at a time.
The shared counter
That answers the question. Each fetch_add needs the counter's line in Modified state on the incrementing core. With eight cores incrementing, the line moves continuously, every increment waits for a transfer, and they happen one at a time. One thread incrementing a line that stays in its own L1 is far faster than eight threads queueing for one line.
The fixes are the general ones for contended shared data:
- Per-thread counters, each on its own cache line, summed when read. Writes become local; only the occasional reader pulls lines across.
- Batching: accumulate locally and publish occasionally.
- Ownership: one thread owns the value and others send it messages.
Reading is cheap, writing is not
Many cores can hold a line in Shared state at once and read it from their own caches without any traffic. That is why read-mostly data, such as configuration or reference data, scales perfectly, and why a seqlock or a versioned snapshot works well: readers share the line; only the single writer forces invalidations, and only when something changes.
The asymmetry gives a rule for designing hot data: separate what is written from what is read, and separate what different threads write.
Spinning politely
A thread waiting for a flag set by another core should spin on a plain load, not on an atomic exchange or compare-and-swap. A load keeps the line in Shared state in the waiter's cache and costs nothing until the writer changes it. A read-modify-write in the loop requests ownership every iteration and drags the line away from the thread trying to set it. That is why the spinlock in Module 4 tests with a load before trying the exchange.
Across sockets
On a two-socket machine, coherence traffic between sockets goes over the interconnect, which is several times slower than within a socket. Two threads that share a hot line on different sockets pay that on every transfer. Pinning cooperating threads to cores on the same socket, ideally sharing the same L3, is part of the system setup covered in Module 6.
Check yourself
Question 1 of 3
Several cores hold a line in Shared state. One core writes to it. What must happen first?
Writes require exclusive ownership. Invalidating the other copies, and waiting for that to complete, is the cost of writing shared data.
Question 2 of 3
Why do eight threads incrementing one atomic counter achieve lower total throughput than one thread?
Coherence permits one owner at a time. Each transfer costs tens of nanoseconds or more, much longer than an increment in L1.
Question 3 of 3
Why should a spin-wait loop use a load rather than a compare-and-swap in each iteration?
Read-only spinning costs nothing until the line changes. Spinning with writes creates coherence traffic that slows down the very thread you are waiting for.
In the interview
Say your own answer out loud first, then open the one we would give.
What is cache coherency?
The hardware guarantee that all cores see one consistent value for each memory location despite private caches. Protocols like MESI allow many read-only copies of a line or a single writable copy; a write invalidates the other copies first.
Why is writing shared data expensive on a multicore machine?
The writing core must gain exclusive ownership of the line, invalidating every other copy and waiting for confirmation. If several cores write the same line, it moves between them on every write and the writes serialise. Reads of unchanging shared data, by contrast, are local and free.
How would you implement a counter incremented by many threads?
A counter per thread on its own cache line, summed when someone reads the total. Writes stay local and nothing is contended. If an exact live total is needed often, I would reconsider the design, since a single shared atomic is a serialisation point.