CPU Cache-Line False Sharing
CPU cache-line false sharing occurs when independent variables used by different CPU cores occupy the same cache line. Each core is changing its own variable, but the hardware tracks ownership for the whole line, so the writes repeatedly invalidate and transfer one another’s cached data.
The result can be a dramatic slowdown in code that appears to have little or no sharing.
Why CPUs use cache lines
A CPU cache is a small, fast copy of recently used memory. It exists because main memory is much slower than the processor, and because programs often reuse nearby data. CPUs do not normally fetch one byte or one integer at a time. They fetch a cache line, a fixed-size block of adjacent bytes; 64 bytes is common, but the size depends on the architecture.
Caching creates a consistency problem when several cores access the same memory. Each core might have a cached copy, so the processor needs a cache-coherence protocol: hardware rules that keep cores from indefinitely using stale values. A common family of protocols is called MESI, whose states describe whether a line is modified, exclusive, shared, or invalid.
The important detail is that coherence operates on cache lines, not on individual variables.
What sharing looks like
Suppose two worker threads update separate counters:
struct Counters {
std::atomic<std::uint64_t> completed;
std::atomic<std::uint64_t> failed;
};
The counters are logically independent. A thread updating completed does not need to change failed, and another thread can update failed without reading completed.
However, the two atomic objects may be adjacent in memory. If both fit within one 64-byte cache line, both cores initially load that line into their caches. The line might then look like this:
one cache line
[ completed ][ failed ][ unused bytes ... ]
This is false sharing. The sharing is “false” because the application is not sharing the same variable; only the hardware’s cache-line granularity makes the variables appear shared.
How independent writes cause traffic
Assume core 1 updates completed and core 2 updates failed in a loop.
- Core 1 writes the cache line containing both counters. It must obtain permission to modify that line.
- The coherence protocol invalidates, or otherwise revokes, core 2’s usable copy.
- Core 2 writes its counter. It must obtain the line for modification, which makes core 1’s copy unusable.
- Core 1 writes again and the process repeats.
The line effectively ping-pongs between the cores. A write may require communication over the processor’s interconnect and may wait for the other core’s copy to be invalidated or for newer data to be supplied. The cost is not that the two counters conflict in the program’s logic; the cost is the repeated ownership movement of their shared cache line.
Atomic operations make each individual update safe, but they do not prevent false sharing. In fact, a tight loop of atomic increments makes the pattern easy to observe because every update is a real memory operation. A lock can hide the issue behind serialization, but it usually does not make the underlying layout better.
This differs from true sharing, where two cores really do need to read or modify the same variable. True sharing requires coordination by definition. False sharing is accidental and can often be removed without changing the algorithm.
Padding and layout changes
The usual fix is to place independently updated values on different cache lines. Padding means inserting unused space so that the values cannot occupy the same line:
struct alignas(64) Counter {
std::atomic<std::uint64_t> value;
};
struct Counters {
Counter completed;
Counter failed;
};
Here, alignas(64) requests 64-byte alignment for each Counter. Because each object’s alignment and size force the next object to a suitable boundary, the counters are separated on typical systems. The number must match the target platform’s destructive-sharing considerations; 64 bytes is common, not universal. Some C++ implementations also provide a standard hardware interference-size constant for this purpose, but its availability and usefulness should be checked for the project’s toolchain and targets.
Padding does not have to be explicit unused bytes. Often a better fix is changing the data layout:
- Give each worker a private counter, stored in a per-worker structure padded to a cache-line boundary.
- Store frequently written fields separately from fields that other cores frequently write.
- Use a structure-of-arrays layout when different workers update different columns or fields.
- Combine local results after the parallel phase instead of having every worker update one shared total.
For example, per-thread counters let workers update local memory without competing for one line. A final reduction adds those counters together once the work is complete. This replaces thousands of coherence transfers with local writes and a small amount of aggregation.
Finding the real issue
False sharing often appears in profiles as unexpectedly expensive atomic operations, poor scaling as more cores are added, or high cache-to-cache and coherence traffic in hardware-performance counters. It is especially common in arrays of small structs, adjacent per-thread state, ring-buffer metadata, and statistics counters allocated by a general-purpose allocator.
Do not add padding automatically. It increases memory use and can hurt locality if applied everywhere. First confirm the access pattern with a profiler or benchmark that compares one worker against several. Then inspect object offsets and cache-line boundaries, and test a layout change under the same workload.
The central rule is simple: different variables are not necessarily independent at the cache level. If separate cores write different words in the same cache line, coherence still treats that line as one unit. Separating those writes lets each core keep ownership of its own data, removing the ping-pong traffic and restoring parallel scaling.