Directory-Based Coherence

MESI over a snooping bus is elegant because the bus does the hard part for free: every core overhears every transaction. But that is also its doom. A broadcast bus is a single shared wire, and once you have 16, 32, 64 cores all shouting invalidations on it, the bus saturates. Snooping asks every cache to look at every transaction — O(n) work per access on an n-core machine, over a medium whose bandwidth does not grow. It simply does not scale.

Big machines — the 64-core-and-up servers, the historical NUMA supercomputers — replace the shouting with directories. Instead of broadcasting "who has this block?" to everyone, a directory already knows exactly which caches hold each block, and sends coherence messages point-to-point only to those caches. No broadcast, no shared bus, no everyone-listens-to-everything. That is how coherence reaches hundreds of cores.

The directory: a phone book for cache blocks

For every block of memory, the directory keeps a small record: what state is it in, and which caches currently hold it. The classic encoding of "which caches" is a sharer bit-vector — one bit per core. If bit i is set, core i has a copy. A block shared by cores 0 and 2 on a 4-core machine has the vector 1010.

The directory lives with memory — either one central table or, on a NUMA machine, distributed so that each memory node owns the directory entries for its own addresses (the block's home node). When a core misses on a block, it sends its request to that block's home directory, which looks up the entry and does exactly the right thing to exactly the right caches.

Point-to-point, not broadcast

Here core 1 wants to write block B. It sends one request to B's home directory. The directory reads the sharer vector — 1010, so cores 0 and 2 hold copies — and fires an invalidate to just those two, then grants core 1 exclusive ownership. Core 3, which never had the block, is never bothered. A snooping bus would have blasted the invalidate at all four (and every other core in the machine); the directory touches only who matters.

This is the whole win. The number of messages scales with the number of actual sharers of a block — usually one or two — not with the total core count. Traffic that snooping made everyone's problem becomes a private conversation between the directory and the handful of caches that care.

Snooping vs directories, side by side

SnoopingDirectory
How others learn of a writebroadcast on a shared buspoint-to-point messages from the home directory
Who is contactedeveryone (all caches snoop)only the actual sharers
Extra storagenone (just the bus)the directory: ~1 bit per core per block
Scales toa handful of coreshundreds of cores
Latency for a simple misslow (one bus round-trip)higher (indirection through the directory)
Typical usedesktop / small multicorebig NUMA servers, many-core

Directories are not free — they add memory overhead and an extra network hop of latency (a miss may go core → directory → owner → back). The bet is that saving bus bandwidth is worth the indirection once the core count is high. For small chips, snooping still wins on simplicity and latency; the crossover is why real systems often use both — snooping within a socket, a directory between sockets.

Simulate a directory with a sharer bit-vector

Let's model a directory for one block on a 4-core machine and count the messages it sends, comparing them to the broadcast a snooping bus would have used. Watch invalidations go only to the set bits:

const CORES = 4; let sharers = 0b0000; // bit i set => core i caches the block let state = "Invalid"; // Invalid | Shared | Modified let broadcastCount = 0, directoryCount = 0; function bits(v: number): string { return v.toString(2).padStart(CORES, "0"); } function read(core: number): void { sharers |= (1 << core); state = "Shared"; console.log(`core ${core} READ -> state=${state}, sharers=${bits(sharers)}`); } function write(core: number): void { // Invalidate every OTHER core that currently holds the block. let sent = 0; for (let i = 0; i < CORES; i++) { if (i !== core && (sharers & (1 << i))) { sent++; } } directoryCount += sent; // directory: one message per real sharer broadcastCount += (CORES - 1); // snooping: one broadcast reaches all others sharers = (1 << core); // only the writer holds it now state = "Modified"; console.log(`core ${core} WRITE -> invalidated ${sent} sharer(s); state=${state}, sharers=${bits(sharers)}`); } read(0); read(2); // cores 0 and 2 share the block -> sharers = 1010 write(1); // directory invalidates ONLY cores 0 and 2 (2 messages) read(3); // core 1 (M) + core 3 now share write(0); console.log(`directory messages: ${directoryCount} vs broadcast messages: ${broadcastCount}`);

A sharer bit-vector needs one bit per core per block. At 4 cores that is trivial; at 1024 cores it is 1024 bits (128 bytes) of directory for every 64-byte block — the directory would be twice the size of memory it describes. Real large systems use limited-pointer schemes (store only the first few sharer IDs, since most blocks are shared by very few caches) or coarse-vector schemes (one bit per group of cores, invalidating a whole group at once) or hierarchical directories. All are attacks on the same enemy: the directory's storage growing with core count. It is a classic space-vs-precision trade — spend fewer bits, occasionally invalidate a few extra caches.

It is tempting to think directories are strictly better than snooping. They are not — they trade one cost for another. Snooping spends bus bandwidth; a directory spends storage (the bit-vectors) and latency (an extra hop through the home node, and sometimes a three-way core→directory→owner dance for a single miss). On a 4-core laptop a directory would be pure overhead — the bus is nowhere near saturated, so snooping's lower latency wins. Directories only pay off once broadcast traffic becomes the bottleneck. The right question is never "which is better?" but "how many cores?"