Shared Memory: UMA and NUMA

You have many cores now. How do they talk to each other? The dominant answer — the one behind almost every multicore laptop, phone and server — is shared memory: every core sees one big common address space. Core 0 writes to address 0x1000, and later core 7 reads address 0x1000 and gets what core 0 wrote. Communication is just ordinary loads and stores; there is no explicit "send a message" step.

That single shared address space is a wonderful programming abstraction — but the hardware underneath is not uniform. The crucial question of this lesson is: does every core reach every byte of memory in the same amount of time? If yes, you have UMA; if no, you have NUMA, and ignoring the difference can quietly halve your program's speed.

UMA: one memory, equidistant from all

In a Uniform Memory Access machine — historically called a symmetric multiprocessor (SMP) — all cores share a single pool of main memory through a common interconnect, and the latency to any address is the same from every core. It is symmetric: no core is privileged, no address is "closer". This is the mental model most programmers carry, and it is exactly right for a small chip.

The beauty of UMA is its simplicity: the operating system can place a thread on any core and any data anywhere, and performance does not care. The catch is that the single shared path to memory is a bottleneck. Add enough cores and they all contend for the same bus and the same memory controller; the interconnect saturates and everyone waits. UMA does not scale to dozens of sockets.

NUMA: memory has a neighbourhood

To scale past a handful of cores, big systems split memory into pieces and attach each piece directly to a group of cores — a socket or NUMA node. Now a core's local memory is fast, but reaching another node's memory means a hop across an inter-socket link (Intel's UPI, AMD's Infinity Fabric, the old QPI). That remote access is real, physical, and slower — typically 1.5\times to 2\times the local latency, sometimes worse.

The address space is still shared — a core can read any byte on any node with a plain load — but access is now Non-Uniform. Two identical loads can take wildly different times depending on where the data physically lives relative to the core running the code. That is the whole idea of NUMA, and it turns memory placement into a first-class performance concern.

Worked example: why placement matters

Suppose local memory takes 100\ \text{ns} and remote memory takes 180\ \text{ns}. If a thread does all its accesses locally, its average access time is 100\ \text{ns}. But if the operating system carelessly puts the thread on socket 0 while its data sits on socket 1, every access is remote — 180\ \text{ns}, an 80\% slowdown for the exact same code. Even a modest fraction of remote accesses hurts:

t_{\text{avg}} \;=\; (1-r)\,t_{\text{local}} \;+\; r\,t_{\text{remote}},

where r is the fraction of accesses that are remote. Run it and watch the average climb as data drifts to the far node:

// NUMA average access time as the fraction of remote accesses grows. const tLocal = 100; // ns, local memory const tRemote = 180; // ns, across the inter-socket link function avgAccess(remoteFraction: number): number { return (1 - remoteFraction) * tLocal + remoteFraction * tRemote; } console.log("remote%\tavg (ns)\tslowdown vs all-local"); for (const r of [0.0, 0.25, 0.5, 0.75, 1.0]) { const t = avgAccess(r); const slow = (t / tLocal).toFixed(2); console.log(`${(r * 100).toFixed(0)}%\t${t.toFixed(0)}\t\t${slow}x`); } console.log("Lesson: NUMA-aware placement keeps r near 0 and access near local speed.");

NUMA-aware placement: the first-touch rule

Operating systems fight remote access with two tricks. Affinity pins a thread to the cores of one node so it does not wander away from its data. And the first-touch policy allocates each memory page on the node of whichever core first writes to it — so if a thread initialises its own data, that data lands next door. The classic NUMA bug is to have one setup thread touch a giant array (parking it all on node 0) and then hand slices to worker threads scattered across every node — now most workers read remotely. The fix is to let each worker initialise its own slice.

UMA (SMP)NUMA
Memory latencysame from every corelocal fast, remote slower
Scales toa few cores / one socketmany cores / many sockets
Bottleneckthe one shared path to memorythe inter-socket links
Placement matters?noyes — affinity & first-touch
Typical usephones, laptops, small chipsmulti-socket servers, big EPYC/Xeon

You might think NUMA only appears when you bolt two physical CPU sockets together. Not any more. Modern AMD EPYC and Ryzen chips are built from several chiplets (CCDs) on one package, each with its own path to memory, stitched together by Infinity Fabric. Cross-chiplet accesses are measurably slower than same-chiplet ones, so a single processor can behave like a small NUMA machine — and the OS exposes it as multiple NUMA nodes. Apple's and Intel's big designs have similar internal distance effects. NUMA has quietly moved inside the chip.

A tempting mistake: "it's one address space, so every access costs the same." No. The shared address space is a logical abstraction — any core can name any byte. But the physical distance to that byte varies on a NUMA machine, and the hardware will not hide it from you. A load that hits local memory and a load that crosses a socket link both look like plain \texttt{mov} instructions in your code, yet one can take twice as long. "Shared" is about reachability; "uniform" is about latency — and NUMA has the first without the second.