Parallel Performance

Parallelism Can Destroy Locality

A single-threaded loop walks memory in order and every access is nearly free. Split it across eight workers that migrate between cores, interleave their indices and share a last-level cache, and the same total work can move more bytes and take longer.

▶ Run the lab

The question this answers

The question

Why did splitting this loop across eight workers move more memory and run slower than the single-threaded version?

The work

A pass over a 500 MB array of records, computing a score per record and writing it into an output array — first single-threaded, then partitioned across eight workers.

What is shared

Logically nothing: disjoint input slices, disjoint output slices, no locks. Physically a great deal — the shared last-level cache, the cache lines at chunk boundaries, and the per-core caches that a migrated task leaves behind.

The invariant — what must stay true under every interleaving

Every record is scored exactly once and written to its own output index, at every worker count and under every scheduling decision. Locality changes the cost; it never changes the answer.

Synchronization exists to preserve this sentence. If a schedule can make it false, the code is wrong no matter which primitive it uses.

WorkCan it overlap?Can it parallelise?What is shared?What ordering?What synchronization?Where is contention?What can deadlock?What can race?What is gained?What complexity?

The warm cache a migrating task leaves behind

A thread running on one core accumulates state in that core's private caches: the data it just touched, the instructions it is executing, the address translations it needs. That accumulated state is worth a lot — an access that hits it is roughly two orders of magnitude cheaper than one that goes to DRAM — and it is completely invisible in your program.

When the scheduler moves the thread to another core, none of it moves. The thread arrives on a cold core and re-populates everything from scratch, at full miss cost, while the state it built on the old core is evicted by whatever runs there next. The thread is running the entire time; nothing blocks, nothing is contended, and no metric in your application notices. It is simply slower for a while.

This is why the naive intuition "eight workers means eight times the cache" is backwards for many workloads. Eight workers on eight cores each get a private cache, yes — but they share the last-level cache, so each one's effective share of it *shrinks*, and each migration throws away work that was already paid for. Oversubscription makes it dramatically worse: with 24 runnable workers on 8 cores, every worker's private cache is refilled by the two others that ran in between it and its last quantum (Oversubscription, The Cost of a Context Switch).

The same worker, before and after a migration. Nothing blocks; it just gets slower.ILLUSTRATIVE
Worker A — stays on core 3
cold start: filling caches
warm: streaming through its slice
Worker B — migrated at t=4 and t=8
cold start on core 5
warm on core 5
refilling caches on core 1
warm on core 1
refilling caches on core 6
warm on core 6
Worker C — 24 workers on 8 cores
refill
warm
preempted (2 others run here)
refill
warm
preempted
↑ B migrates: pays cold-start cost again↑ A did ~10 warm units; B ~6; C ~2
runningreadywaitingblockedidle1 unit ~ one scheduler quantum

The partitioning decision is a locality decision

How you split the array decides how much of this you suffer, and the two obvious splits behave completely differently. Blocked: worker k gets the contiguous range [k*n/W, (k+1)*n/W). Each worker walks its own region sequentially, the hardware prefetcher recognizes the stride immediately, and the regions do not overlap in cache except at the boundaries. Interleaved: worker k takes every W-th element. Each worker's accesses are strided by W elements, every cache line is touched by every worker, and you pay the full line fetch for one useful element in each.

Interleaved partitioning looks appealing because it balances load perfectly when per-element cost varies. It is usually the wrong default anyway, because the locality penalty is large and constant while the load-balance benefit only matters if the cost actually varies. When it does vary, the right answer is blocked chunks that are *smaller than the whole slice* and assigned dynamically — many chunks, each contiguous, handed out as workers finish — which keeps the prefetcher happy and rebalances at the same time. That is what a work-stealing scheduler does (Work Stealing), and it is why the chunk-size parameter in every parallel-for API exists.

Chunk size is then a genuine trade-off with locality on one side and balance on the other: chunks too large and one straggler holds the barrier; chunks too small and you pay dispatch overhead per chunk plus a cold cache at each chunk boundary. There is no universal number — it depends on the per-element cost, the variance, and the working-set size relative to the private cache — which is a theme this domain keeps returning to.

  • Give each worker a contiguous region, then make the regions smaller if you need balance — do not interleave.
  • Chunk boundaries are where lines are shared; align chunk starts to cache-line boundaries and the sharing disappears.
  • A per-element cost that varies by 10x makes balance dominate; one that is uniform makes locality dominate. Know which you have.
PartitioningPrefetcherLine sharingLoad balanceVerdict
Blocked: contiguous slice per workerRecognizes the stride immediatelyOnly at chunk boundariesPoor if per-element cost variesBest default for uniform work
Interleaved: every W-th elementStrided; much weakerEvery line touched by every workerPerfect by constructionUsually a net loss; the locality cost is constant
Many small blocked chunks, dynamicGood within each chunkOnly at chunk boundariesGood — rebalances as workers finishBest when per-element cost varies
Work-stealing dequesGood; stolen chunks are contiguousBoundaries only, plus steal trafficVery good, self-tuningBest general answer; more machinery
Four ways to split a loop, judged on locality rather than on tidiness.

The accumulator that is not shared but behaves like it is

The other half of this lesson is what workers *write*. A results array indexed by worker id — counts[workerId] += 1 — is the most natural thing to write and one of the most expensive. Eight 8-byte counters occupy a single cache line, so eight cores writing "their own" counter are in fact fighting over one line, which bounces between their private caches on every increment. There is no lock, no atomic, no shared variable in the source, and throughput can drop by an order of magnitude. That is False Sharing: Different Variables, Same Cache Line, and it is the sharpest, most reproducible version of the locality problem.

The fix is to give each worker a private accumulator in its own stack frame and combine once at the end. This is better than padding — which also works — because it eliminates the traffic entirely rather than spreading it out, and because it composes: a worker-local accumulator has good locality *and* no coherence traffic *and* no dependence on a padding constant that a struct layout change might silently break.

The general principle underneath both halves of this lesson: give each worker its own contiguous region to read and its own private variable to write, and combine at the end. That single rule prevents false sharing, keeps the prefetcher effective, avoids coherence traffic, and removes the need for synchronization in the hot loop. Almost everything else in parallel performance tuning is a variation on it.

Per-worker slot in a shared array — no lock, and still serialized by the hardware
1std::vector<double> partial(W, 0.0); // 8 doubles = one cache line
2
3parallel_for(0, W, [&](int w) {
4 for (size_t i = start(w); i < end(w); ++i)
5 partial[w] += score(rec[i]); // every += bounces the line
6});
7
8double total = 0;
9for (double p : partial) total += p;
Worker-local accumulator, written back once
1std::vector<double> partial(W, 0.0);
2
3parallel_for(0, W, [&](int w) {
4 double local = 0.0; // lives in a register / this core's stack
5 for (size_t i = start(w); i < end(w); ++i)
6 local += score(rec[i]); // zero coherence traffic in the hot loop
7 partial[w] = local; // one write per worker, total
8});
9
10double total = 0;
11for (double p : partial) total += p;

Both versions are correct — no data race, no lock needed, identical results. The first writes to a shared cache line once per record and the line ping-pongs between eight cores; the second writes to it once per worker. Same algorithm, same synchronization (none), and a difference that can exceed 10x on a tight loop. Note that the combine step still adds the partials in worker order, so the floating-point total depends on W — see Reduction Ordering: The Sum Changed When the Worker Count Did.

Key points

  • A thread's warm cache is real, unowned state that does not follow it when the scheduler moves it to another core.
  • Parallelism shrinks each worker's share of the shared last-level cache while adding migrations and preemptions that discard warm state.
  • Blocked (contiguous) partitioning preserves locality; interleaved partitioning destroys it in exchange for a load balance you usually do not need.
  • When per-element cost varies, use many small contiguous chunks assigned dynamically rather than interleaving.
  • Give each worker a contiguous region to read and a private variable to write, then combine once — that one rule prevents most of the problems in this lesson.

The loop, answered

Every field is required, which is why no lesson here can recommend concurrency without naming the interleaving that breaks it, the complexity it adds, and the simpler thing to consider first.

How it works
  • Each core keeps private caches and address-translation state populated by whatever ran on it most recently.
  • A migration or a preemption leaves that state behind; the thread resumes on a cold core and refills at miss cost while still counting as "running".
  • Workers sharing the last-level cache evict each other's lines, so the effective cache per worker falls as worker count rises.
  • Interleaved index assignment makes every cache line useful to only one worker per fetch, multiplying bytes moved per useful element.
  • Writes by different workers to the same cache line force the line to move between cores on every write, whether or not the bytes overlap.
Interleavings that matter
  • The correctness-neutral one: any interleaving of eight workers over disjoint slices produces the same output. Nothing in the schedule can break the invariant — the schedule only changes the cost.
  • The expensive one: W0 writes partial[0]; W1 writes partial[1]; W0 writes partial[0] again — same 64-byte line, so it is invalidated on W1's core and fetched back for W0, repeatedly, at every increment.
  • The migration one: W3 runs on core 5 for 4ms building a warm working set; the scheduler moves it to core 1; it runs the same code 3x slower for the next millisecond while nothing is blocked and utilization stays at 100%.
  • The oversubscription one: 24 workers on 8 cores; each worker's footprint is evicted by the two workers that ran between its quanta, so no worker ever reaches its warm steady state.
  • The boundary one: W0's slice ends mid-line and W1's begins in the same line; the two workers share that one line for the whole run, which is harmless at one boundary and significant if you chunked into thousands of tiny pieces.
What it guarantees — and does not
  • Cache coherence guarantees that a load returns the most recently written value, regardless of which core wrote it — correctness is never the issue here.
  • Disjoint slices guarantee no data race and no need for synchronization, at any worker count.
  • Nothing guarantees a thread stays on a core, that its cache survives a quantum, or that a parallel version moves fewer bytes than the sequential one.
  • Nothing guarantees that "no shared variables" means "no shared cache lines" — false sharing is invisible at the language level, and no compiler or type system flags it.
  • A parallel-for API guarantees the iterations run; it makes no promise at all about the locality of the assignment it chooses.
Where contention appears
  • Cache-line contention from adjacent per-worker writes: no lock, no blocking, and a serialization enforced by the coherence protocol.
  • Last-level cache capacity contention: workers evict each other, so per-worker miss rates rise as parallelism rises.
  • Run-queue contention when runnable workers exceed cores, producing the migrations and preemptions that discard warm state.
  • Prefetcher contention: several workers streaming different regions can exceed the number of streams the hardware tracks, and prefetching quietly stops helping any of them.
How it fails
  • False sharing on a per-worker counter array: an order-of-magnitude throughput loss with no lock and no shared variable in the source.
  • Parallel version slower than sequential because interleaved partitioning multiplied the bytes moved.
  • Throughput that degrades as the machine gets busier, because migrations increase — reproducible only under load.
  • Chunk size tuned on a machine with a large private cache and shipped to one with a small one, where the working set no longer fits.
  • Oversubscription from nested parallel libraries: no single component is wrong, and every worker runs cold.
  • A benchmark whose input fits entirely in cache, showing scaling that production never reproduces.
When it helps
  • Any parallel loop over a large array: choosing contiguous chunks over interleaved indices is free at authoring time and frequently worth several times the throughput.
  • Hot accumulation loops, where switching a shared-array slot to a local variable is a two-line change with a large, reliable effect.
  • Sizing pools to the real core budget instead of a larger number, which removes migrations you were paying for and getting nothing from.
When it hurts
  • When it becomes premature: for a loop that runs once over a small array, none of this is measurable and the tuning is noise.
  • When padding is applied everywhere defensively, inflating memory footprint and *causing* cache pressure to avoid a problem that was not there.
  • When chunk size is tuned to one machine and hard-coded, so it is wrong on every other machine and silently so.
  • When locality reasoning is used to justify pinning threads without measuring — see Thread Affinity: Pinning, and What It Costs You for what that costs.
How you would know
  • Bytes moved from DRAM for the parallel version against the sequential one. Same work, more bytes, means the partitioning is the problem.
  • Cache miss rate per worker at increasing worker counts; a rise means workers are evicting each other rather than helping each other.
  • Involuntary context switches and thread migrations per second — the direct measure of how much warm state is being discarded.
  • The false-sharing check: pad the per-worker slots to a full cache line and re-run. A large change is a diagnosis, and it costs ten minutes.
  • The chunk-size sweep: run the same job at several chunk sizes. The curve is usually flat in the middle with sharp edges, and that middle is where to live.
Complexity it introduces
  • Locality-aware partitioning adds a chunking parameter that must be chosen, documented and re-checked on new hardware.
  • Padding for false sharing bakes a cache-line constant into your data structures, which is machine-dependent and easily lost in a refactor.
  • Worker-local accumulation adds a combine step, which for floating-point results introduces a dependence of the answer on the worker count.
  • Reasoning about any of this requires hardware counters or careful A/B experiments; it is not visible in ordinary application profiling.
Simpler alternatives
  • Fewer workers. A four-worker run with warm caches routinely beats a sixteen-worker run with cold ones, and it is a one-line change.
  • Fuse the passes so the data is touched once while it is resident, rather than optimizing where several passes run — the Memory Bandwidth: More Cores, Same Bus fix, which also fixes this one.
  • Reduce the working set: a smaller record, a narrower projection or a compressed representation may make the whole thing fit and remove the question.
  • Use a work-stealing runtime and stop hand-partitioning, accepting its chunking heuristics in exchange for not owning the parameter.

Two counters, no lock, one cache line

Two counters, no lock, one cache line
Each thread increments its own counter. Nothing is shared in the source: different variables, no mutex, no atomics between them. The hardware shares things at a coarser granularity than your variable names do.
struct Counters {
    long a;   // byte 0..7
    long b;   // byte 8..15   <- same 64-byte line as a
};            // sizeof == 16
64-byte cache line
a
b
·
·
·
·
·
·
One line. Every write by either thread takes exclusive ownership of the whole line, so the other thread’s next write has to take it back.
1 workerdashed = linear speedup8 workers · max 8.0×
Speedup over one thread. The dashed line is what perfectly independent work would give — and this work is perfectly independent.
speedup at 8 threads
2.03×
ideal
counters per line
2
padded would give
7.78×
8 threads, 8 independent counters, zero locks — and 2.03× instead of 8×. The speedup does not arrive, and every tool you would reach for says the code is fine: no lock to profile, no contention counter to read, no shared variable to point at. The threads contend on a 64-byte cache line that happens to hold both counters, because sharing is decided by address, not by intent. Add the padding and the same code gives 7.78×. The lesson for this domain is a diagnostic one: when parallel code does not speed up and there is no lock in sight, ask what else the threads are sharing — a line, an allocator, a queue head, a metrics counter, the first slot of an array indexed by thread id. The fix is layout, not synchronization. And it is not free: padding costs memory and cache footprint, and per-thread accumulators cost a reduction step at the end, so pay it where a measurement told you to, not everywhere.
ILLUSTRATIVEThe curve is a composed model with false sharing entered as a per-worker synchronization cost, not a measurement. The size of the effect depends entirely on the processor, the cache-line size and how hot the loop is. Why sharing a line costs anything — the coherence protocol and its invalidation traffic — is Computer Architecture material and is deliberately not taught here.

Work stealing between deques

Work stealing — an idle worker is a bug, not a rest
Four deques start uneven: 11 / 6 / 2 / 1 tasks. Static partitioning finishes when the unluckiest worker finishes.
Worker 1
own work
stolen work
Worker 2
own work
Worker 3
own work
stolen work
Worker 4
own work
stolen work
runningreadywaitingblockedidleticks (1 task each)
makespan, no stealing
11 ticks
makespan, stealing
5 ticks
perfect balance
5.00 ticks
steal operations
4
Steal log
t=1 W4 idle → steals 5 from the tail of W1's deque
t=2 W3 idle → steals 2 from the tail of W1's deque
t=4 W1 idle → steals 1 from the tail of W2's deque
t=4 W3 idle → steals 1 from the tail of W4's deque
Own work  → popped from the HEAD of my deque (LIFO: hottest in cache)
Stolen    → taken from the TAIL of a victim's deque (oldest, biggest sub-task)
Two ends  → the owner and the thief rarely touch the same slot, so the
            common case is uncontended and needs no lock at all.
5 ticks instead of 11, from 4 steal operations. The total work never changed — 20 tasks either way. What changed is that the last 6 ticks are no longer three workers watching one worker finish. Stealing is not free: each steal is a synchronised hand-off, and the stolen sub-task arrives cold in the thief's cache. It pays when task durations are unpredictable, which is exactly when static partitioning fails.
1/5 · tick 1SIMULATED

More workers than cores

More workers than cores
Four cores, purely CPU-bound tasks, no I/O to hide behind. Add workers and watch what the extra ones buy.
4 cores · 0 ms I/O
1 workerdashed = linear speedup64 workers · max 64.0×
Throughput relative to one worker, 1 → 64 workers. The dashed line is what workers would buy if a worker were a core.
throughput800/s · peak is 800/s at 4 workers
context-switch overhead per task0 · 0.00 ms of every 5 ms task, and it grows with every worker past 4
runnable per core
1.0
CPU utilisation
100.0%
vs. peak
at peak
4 workers on 4 cores: each one has a core to itself, so throughput rises roughly linearly. This is the only region where "add a thread" and "add capacity" mean the same thing. The honest form of the rule: for genuinely CPU-bound work with no waiting, more workers than cores adds overhead, latency variance and memory, and adds no throughput. That is *not* a formula for pool size — this workload has no I/O, no lock and no memory-bandwidth ceiling. Add any of those and the useful worker count moves, sometimes far above the core count. Size a pool from measurement of the real workload, not from a rule of thumb.
SIMULATEDContext switching modelled as a flat cost per switch. Real cost depends on cache and TLB footprint and is usually worse — and never better — than this.

What people believe, and what is true

Claim

Eight workers means eight times the cache.

Reality

Eight private caches, yes — but a shared last-level cache split eight ways, plus migrations that discard warm state. Effective cache per worker usually falls.

Claim

No shared variables means no sharing.

Reality

Sharing is per cache line, not per variable. Eight adjacent per-worker counters are one line and are contended by the hardware without any lock in sight.

Claim

Interleaving indices is the fair way to split a loop.

Reality

It is the fair way and usually the slow way: every cache line is fetched by every worker for one useful element. Use small contiguous chunks instead.

Claim

The parallel version cannot be slower — it is the same work.

Reality

It is the same computation and can move several times the memory. Bytes moved, not operations performed, is what a memory-bound loop is billed for.

Apply it