Machines and programs
False sharing and NUMA
Why two threads writing to different variables in the same cache line slow each other down, and why memory on the far socket of a big server costs more to read.
Caches stay consistent across cores at the level of cache lines. That is useful when cores really share data. It becomes a problem when two cores write different variables that happen to sit in the same line. Nothing is logically shared, yet the line bounces between cores on every write. Two more hardware effects follow the same pattern. Memory can sit on another CPU socket, and a thread can run far from the data it needs.
Concept
Before you start: cache coherence is the rule that all cores see one consistent value for each cache line. When a core writes a line, other cores' copies are invalidated. Their next read must fetch the line again.
The big idea: false sharing
When core A writes to a line, every other core's copy is invalidated. When core B next touches the line, it must move over from A's cache. That costs tens of nanoseconds. The line can ping-pong between cores on every write:
Walking through it: two counters, two layouts
Two threads each increment their own counter, 200 million times. In the packed layout, a and b share a cache line. In the padded layout, a 120-byte pad pushes b onto its own line:
struct { volatile long a; volatile long b; } packed; // a and b share a line
struct { volatile long a; char pad[120]; volatile long b; } padded; // a and b on separate linesOn an Apple M-series laptop (128-byte lines), the packed version took about 0.20 to 0.23 s and the padded version 0.06 to 0.09 s. That is 2 to 4× slower, purely from where two variables sat. The effect grows with core count, and it is worse across sockets.
The symptom to recognize: a multi-threaded workload gets slower as you add threads, and the profile shows no lock contention.
The fix: keep hot per-thread data on separate lines
Pad or align so that fields written by different threads never share a line:
- Java:
@jdk.internal.vm.annotation.Contended(used insideLongAdderandConcurrentHashMap), or manual padding as in the LMAX Disruptor. - Go:
CacheLinePadfromgolang.org/x/sys/cpu, placed between fields. - C, C++ and Rust:
alignas(64),#[repr(align(64))], orstd::hardware_destructive_interference_sizein C++17.
Many libraries already do this. LongAdder, sharded counters and per-CPU data in the Linux kernel all avoid the problem.
NUMA: memory has a distance
On a server with two or more CPU sockets, each socket has its own memory controllers and its own DRAM. A core can read the other socket's memory, but the request crosses an interconnect. That is typically about 1.5 to 2× the latency, with less bandwidth. This is NUMA, for non-uniform memory access.
Linux tries to allocate memory on the node where the thread runs. That works until one process uses most of the machine. Then a big database or JVM heap ends up mostly on one node, threads on the other node pay remote latency on every access, and one node can swap while the other has free memory.
The usual tools:
numactl --hardwareshows the topology.numactl --interleave=allspreads a database's memory evenly across nodes.- The JVM's
-XX:+UseNUMAmakes the heap NUMA-aware. - Pinning one process per socket keeps threads and their memory together.
On a single-socket cloud VM you can mostly ignore NUMA. On large bare-metal database hosts, you cannot.
Warning
Pinning and interleaving change behavior under load. Test with production-shaped traffic, not a single benchmark run.
Common mistakes
- Packing per-thread counters into one struct. This is the classic false-sharing bug.
- Ignoring socket layout on a big host. Remote memory adds latency to every miss.
- Padding everything. Padding costs memory. Pad only the fields that are written by different threads.
Check yourself
- Two threads each write their own counter, and the program is slower with two threads than with one. What is the likely cause? The counters share a cache line, so each write invalidates the other core's copy.
- Why might a large database slow down on a two-socket server even with spare memory? Its memory sits on one socket, and threads on the other socket pay remote latency on each access.
Key takeaways
- False sharing happens when different threads write different variables in one cache line. Each write forces the line to move between cores.
- Pad or align hot per-thread fields onto separate 64-byte (or 128-byte) lines.
- On multi-socket servers, remote NUMA memory costs about 1.5 to 2× more. Large databases and JVM heaps need NUMA-aware setup.
Further learning
For how a process gets its memory and threads, see processes and threads. For a bigger picture of where these costs land in production, see Kubernetes in production.
Related
The memory hierarchy
From registers to SSDs to the network, the latency numbers that dominate performance, and why the tier your data sits in matters more than the code you write.
Processes, threads and the kernel
What the OS gives every program, and how to see it on a live Linux box. Start here for the process model.
Kubernetes in production
Requests and limits, probes, autoscaling, disruption budgets, packaging and the mistakes that cause outages.