You split a counting job across four threads, each with its own counter, no locks, no shared state.
It runs slower than the single-threaded version.
There is no bug in the code. The problem is that “its own counter” was not as separate as it looked.
Caches work in lines, not variables
A CPU never fetches one byte. It fetches a cache line — 64 bytes on every common architecture — and that line is the unit of everything: fetching, invalidating, and coherence between cores.
const counters = new Int32Array(4); // 16 bytes: all four in one lineFour separate counters, one cache line.
64-byte cache lines · core A's counter · core B's counter
Two independent counters, one cache line. Every increment by A invalidates B's copy of the whole line and vice versa, so the two cores ping the line back and forth — often 5–10× slower than the single-threaded version, with no shared variable and no bug.
What the hardware does about it
Cores must agree on memory, so they run a coherence protocol. Simplified:
- Core 0 writes
counters[0]. To do so it must own the line exclusively. - That invalidates core 1’s copy of the line.
- Core 1 writes
counters[1]. It must fetch the line back and take exclusive ownership. - Which invalidates core 0’s copy.
- Repeat, millions of times per second.
The line ping-pongs between cores’ caches. Each bounce costs tens to hundreds of cycles — comparable to a main-memory access — for an operation that should have been one cycle in L1.
The fix is padding
Give each thread’s data its own line.
// 16 int32s per counter = 64 bytes = one full cache line.const STRIDE = 16;const counters = new Int32Array(4 * STRIDE);
function bump(thread: number) { counters[thread * STRIDE] += 1;}Wasteful, and the waste is 240 bytes. The speedup is routinely 5–10×.
Most languages have a way to say this properly:
@Contended long counter; // Java, with -XX:-RestrictContended#[repr(align(64))] struct Padded(AtomicU64); // Rustalignas(std::hardware_destructive_interference_size) std::atomic<long> counter;That C++ constant is the standard library admitting the line size is a portable concern.
Recognising it
The signature is distinctive: scaling gets worse as you add threads, and profilers show no lock contention because there is none.
perf c2c on Linux detects it directly, reporting which cache lines are
bouncing and which source lines touch them. perf stat -e cache-misses and Intel
VTune’s memory analysis also show it.
Without a profiler, the cheap test is to pad and re-measure. If padding four variables makes the program five times faster, that was false sharing.