Parallel Decomposition

Parallel Overhead

Splitting, scheduling, synchronizing and combining are work the sequential version never does. For small tasks that overhead exceeds the task, and the parallel version is measurably slower on more hardware — which is why every parallel decomposition needs a size below which it stops decomposing.

▶ Run the lab

The question this answers

The question

Why is my parallel version slower than the sequential one, on the same machine, with more cores working?

The work

Applying x * 1.05 to each element of an array, decomposed into one task per element — 50 million tasks, each performing one multiply.

What is shared

Nothing in the computation itself; each element is independent. The shared state is entirely in the *machinery*: the task queues, the scheduler's counters, the join's completion count, and the cache lines those live on.

The invariant — what must stay true under every interleaving

The parallel version must produce the same result as the sequential one AND finish sooner. The first half is usually verified; the second half is assumed, and it is the half that fails.

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 four costs the sequential version never pays

Parallelising adds four categories of work. Split: partitioning the input, allocating task objects, capturing arguments. Schedule: pushing to a queue, a worker taking it, possibly a steal, and the cache misses when a task lands on a core that has never seen its data. Synchronize: joins, barriers, atomic completion counters, and any locking inside the tasks. Combine: folding partial results, plus the memory traffic to gather them.

None of these scale down with task size. A task object costs roughly the same to allocate whether its body does one multiply or ten million, so the *ratio* of overhead to work is entirely determined by granularity. This is the whole mechanism behind "parallelising tiny work is slower", and it is worth stating as a ratio rather than a threshold, because the absolute numbers move with hardware, runtime and allocator while the ratio argument does not.

The matrix below is the diagnostic version: which cost dominates tells you what to change. A tiny-task program is dominated by split and schedule and needs a coarser cutoff. A program with a hot lock inside the tasks is dominated by synchronization and needs a smaller critical section, not bigger tasks. A program whose combine is expensive needs fewer chunks. These are different fixes and applying the wrong one is the usual outcome of "it did not get faster, add more workers".

CostWhat it isDominates whenSignalFix
SplitPartitioning, task allocation, argument captureTasks are tiny and numerousAllocation rate and GC pressure track task count, not data sizeRaise the sequential cutoff; chunk instead of per-element tasks
ScheduleQueue push/pop, steals, worker wakeups, cold caches on arrivalTask duration is near the scheduling costSteal attempts per completed task is high; profile shows runtime internalsCoarser tasks; fewer, larger chunks
SynchronizeJoins, barriers, atomic counters, locks inside tasksTasks share anything mutable, or the phase has many barriersLock wait time or atomic contention visible in the profileShrink critical sections, use private accumulators, remove barriers
CombineFolding partials, gathering results, extra memory passesChunk count is high or the combine is expensive per chunkCombine phase is a visible fraction of wall timeFewer chunks; tree-combine instead of a linear fold
Memory trafficExtra passes and lost locality from migrationPer-element work is trivial and the data is largeAchieved bandwidth near machine peak with cores idleNot a decomposition problem — reduce passes or vectorise instead
The four overhead categories, what makes each dominate, and the fix that actually applies.

The anatomy of one task that should not have existed

The timeline traces a single one-multiply task through the machinery. The multiply is one lane segment; everything around it is the tax. Reading it, the conclusion is not "scheduling is slow" — a task hand-off is genuinely cheap in absolute terms — but that "cheap" is relative to what the task does, and one multiply is cheaper than anything.

The comparison lane shows the same 50 million multiplies as 8 chunked tasks. The identical overhead is paid 8 times instead of 50 million times, and it disappears into the noise. Nothing about the algorithm changed; only the granularity did. This is why the practical form of a parallel loop is always "parallel over chunks, sequential within a chunk", and why library parallel-for implementations chunk automatically rather than trusting the caller.

The rule to internalise is a ratio, not a number: a task must do enough work to make its own scheduling cost irrelevant. Whether that means a few microseconds or a few hundred depends on the runtime, the allocator, whether the task was stolen and how cold its data was on arrival — which is exactly why this lesson refuses to name a universal threshold and tells you to sweep the cutoff instead.

One element-sized task versus one chunk-sized task. Modelled to show proportion — segment lengths are relative, not measured times.SIMULATED
Task per element · submitter
allocate task object
idle
Task per element · worker
wake / take
cache miss on element
publish + completion counter
— ratio —
overhead ≈ 17 ticks : useful work 1 tick
Task per chunk (n/8) · worker
take + cold miss
MULTIPLY × 6,250,000 — streaming, prefetched
↑ per-element task: useful work begins↑ per-element task: useful work ends
runningreadywaitingblockedidle1 tick ≈ the cost of one scalar multiply

The crossover, and why it is a shape rather than a number

Plot speedup against workers for a fixed decomposition and you get a curve with three regions. Below the crossover the parallel version is *slower than sequential* — speedup under 1.0 — because overhead exceeds the work, and adding workers makes it worse by adding steals and contention. Around the crossover the curve climbs but stays well under ideal. Well above it, the curve approaches ideal until some other ceiling (span, bandwidth, a serial fraction) takes over, which is Amdahl's Law territory.

The curve below is a fine-grained decomposition, and the important feature is that it starts and stays below 1.0 and *declines* as workers are added. That decline is the signature to recognise: a program that gets slower with more workers while the CPU graph rises is doing more coordination, not more work. Contrast More Threads Is Not More Speed, which is the same shape arriving from oversubscription rather than granularity.

Getting the crossover for your case is a sweep, not a lookup. Fix the workload, vary the chunk size (or cutoff constant) across a doubling ladder, and record wall time — the curve is flat-and-good over a broad middle and bad at both ends, so you are looking for the middle rather than an optimum. It moves with the machine, the runtime version, and whether the data is in cache, so pin it as a configurable constant with a comment rather than a magic number, and re-measure when any of those change.

  • Speedup below 1.0 means the parallel version is a pessimisation; more workers make it worse, not better.
  • Sweep the chunk size or cutoff on a doubling ladder — you are looking for the broad flat middle, not a precise optimum.
  • The crossover moves with hardware, runtime version and cache residency; treat it as configuration, not a constant of nature.
  • Always keep the sequential implementation: below the cutoff it *is* the implementation, not a fallback.
Speedup for a per-element decomposition of trivial work. Modelled from an overhead-to-work ratio, not measured.SIMULATED
1 workerdashed = linear speedup32 workers · max 32.0×
The curve departs from ideal immediately and then falls, because per-task overhead is fixed while the useful work per task is one multiply. Adding workers adds steal attempts, wakeups and cold-cache arrivals without adding work per task. Chunking the same computation into one task per worker moves this curve to roughly linear up to the memory-bandwidth ceiling — the algorithm was never the problem, the granularity was.

Key points

  • Split, schedule, synchronize and combine are work the sequential version never does, and none of them shrink with task size.
  • What matters is the ratio of overhead to useful work per task, which is why granularity — not core count — decides whether parallelism pays.
  • A speedup below 1.0 that declines with more workers is the signature of too-fine granularity.
  • The fix for tiny tasks is chunking: parallel over chunks, sequential within a chunk. Library parallel-for does this for you.
  • Which of the four costs dominates determines the fix; adding workers is not one of them.
  • The crossover is a shape you measure by sweeping the cutoff, not a threshold anyone can give you.
  • Below the cutoff, the sequential version is the correct implementation — keep it and call it.

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
  • A parallel decomposition allocates a task per piece, pushes it to a scheduler, and pays a hand-off and possibly a steal before the body runs.
  • The task body runs on a worker whose caches may know nothing about its data, adding cold misses proportional to the working set.
  • Completion is recorded through a shared counter or a join, which is an atomic operation on a contended cache line.
  • Partial results are combined, adding memory traffic and, for many chunks, a visible combine phase.
  • Total parallel time ≈ (work ÷ effective workers) + overhead × task count + combine — so shrinking tasks raises the middle term while lowering the first.
  • A sequential cutoff caps task count by stopping decomposition below a size, which is the only lever that reduces the middle term directly.
Interleavings that matter
  • One-multiply task: allocate, push, wake a worker, steal, cold miss, multiply once, increment the completion counter. The useful step is one of seven.
  • Fifty million such tasks: workers spend most of their time in queue operations and steals, and the profile is dominated by runtime internals rather than user code.
  • Chunked into eight tasks: the same seven-step machinery runs eight times and vanishes into the noise, while each body streams through six million elements with the prefetcher working.
  • Contention feedback loop: as tasks shrink, deques empty faster, steal attempts rise, the contended index gets hotter, and each steal costs more — so the overhead per task grows as granularity falls rather than staying constant.
  • A barrier per element instead of per chunk: every worker waits for the slowest at every step, so the phase costs (elements × slowest-worker time) instead of (elements ÷ workers) (Barriers).
What it guarantees — and does not
  • Guaranteed: the parallel version computes the same result, if the decomposition is correct. Overhead is a performance property, not a correctness one.
  • NOT guaranteed: that parallel is faster. At fine granularity it is reliably slower, on every machine, at every core count.
  • NOT guaranteed: that adding workers helps. Below the crossover it hurts, monotonically.
  • NOT guaranteed: that a cutoff tuned on one machine holds on another — it depends on cache sizes, allocator behaviour and runtime version.
  • NOT guaranteed: that a library parallel algorithm chunks well for your element cost. Most chunk by count, not by estimated work.
  • NOT guaranteed: that overhead is constant per task. It rises as tasks shrink, because steal rates and contention rise with task count.
Where contention appears
  • Task queues and completion counters are the contended objects, and their contention grows as tasks get smaller — the opposite of what you want.
  • Steal attempts rise sharply when deques drain quickly, so fine granularity converts a lock-free fast path into a contended slow path.
  • Barriers at fine granularity make every worker wait on the slowest at every step, converting imbalance into a multiplier.
  • Cold-cache arrival is contention for memory bandwidth rather than for a lock, and it is invisible in lock-wait metrics.
How it fails
  • Pessimisation: a correct parallel version that is measurably slower than sequential and consumes many times the CPU.
  • Negative scaling: throughput declining as workers are added, misdiagnosed as a machine or runtime problem.
  • Allocation pressure and GC churn driven by task-object count rather than data size.
  • Scheduler thrash: workers spending the majority of their time hunting for work (Work Stealing).
  • Cache-locality collapse from tasks migrating between cores, so the same computation issues far more memory traffic than the sequential version.
  • A cutoff tuned once, then silently wrong after a machine class change or a runtime upgrade.
When it helps
  • It never helps — this lesson describes a cost. What helps is *knowing* it exists, which converts "parallelism did not work here" into "the granularity was wrong".
  • The overhead is worth paying when per-task work is large relative to it, which is the ordinary case for chunked decompositions.
  • Accepting slightly more overhead is correct when it buys load balance: over-decomposing to 4× the worker count trades a little overhead for a lot of imbalance tolerance (Fork/Join).
When it hurts
  • Per-element tasks over cheap operations — the archetype, and reliably slower than a plain loop.
  • Deep recursive decomposition with no sequential cutoff, where the leaves are single elements.
  • Barriers or synchronization inside the inner loop rather than at phase boundaries.
  • Parallelising an operation already limited by memory bandwidth, where the extra passes make it worse and no core count helps.
  • Parallelising a fast operation on a small input to "future-proof" it — the overhead is paid every run, and the future input size may never arrive.
How you would know
  • Parallel wall time against sequential wall time on the same input. If this comparison is not run, nothing else matters — and it is skipped constantly.
  • Speedup versus workers: values below 1.0, or a declining curve, identify granularity as the problem immediately.
  • Task count and mean task duration. Microsecond means with millions of tasks is a diagnosis on its own.
  • Fraction of profile time in runtime internals (queue operations, steal loops, allocation) versus user code.
  • Steal attempts per completed task, which rises sharply as granularity falls.
  • Allocation rate correlated with task count rather than input size.
  • A cutoff sweep on a doubling ladder, reporting wall time — the flat middle is the answer.
Complexity it introduces
  • A cutoff constant enters the code, needs a rationale, and needs re-measurement when hardware or runtime changes.
  • Two code paths — chunked-parallel and sequential — must both be tested, and the boundary between them is a real edge case.
  • Performance now depends on a decomposition parameter, so a performance regression can come from a data-shape change with no code change at all.
  • Reasoning about performance requires distinguishing four overhead categories that all present as "it is slow".
Simpler alternatives
  • The sequential version. Below the crossover it is faster, simpler and already correct — this is the alternative, and it wins more often than parallel advocacy suggests.
  • Chunking with a library parallel-for that sizes chunks itself, which removes the most common way to get this wrong.
  • SIMD within one thread: for trivial per-element operations it delivers real speedup with zero scheduling cost (SIMD: One Instruction, Many Elements).
  • Reducing passes over memory instead of parallelising them, when the operation is bandwidth-bound — fusing two loops beats parallelising either.
  • Parallelising at a coarser level entirely: one task per file, per request or per document rather than per element, which usually makes the ratio problem disappear.

Why is 8 cores only 4.5×?

Why is 8 cores only 4.5×?
Amdahl is one term of four. Turn each effect off and watch which part of the curve straightens.
1 workerdashed = linear speedup16 workers · max 16.0×
workersidealAmdahl onlyrealisticlimited by
11.0×1.00×1.00×none
22.0×1.90×1.85×serial
44.0×3.48×3.19×serial
66.0×4.80×4.17×serial
88.0×5.93×4.17×bandwidth
1010.0×6.90×4.17×bandwidth
1212.0×7.74×4.17×bandwidth
1414.0×8.48×4.17×bandwidth
1616.0×9.14×4.17×bandwidth
speedup at 8 workers
4.17×
best point on the curve
4.17× @ 6
past best, adding workers
costs
distinct causes on curve
serial, bandwidth
At 8 workers this configuration reaches 4.17× and the dominant cause is "bandwidth". Past 6 workers the cores are fed by a memory system that is already saturated — they are stalled, not computing. More threads make the stall queue longer. The fix is fewer bytes per unit of work (better locality, smaller types), not more parallelism. The reason to name the cause is that each one has a different fix, and three of the four get worse if you respond by adding threads.
SIMULATEDcomposed from named effects, not fitted to a measurement

CPU parallelism simulator

Scaling 100 CPU tasks
100 independent tasks of 20 ms each. The tasks do not share anything — the job around them does.
SIMULATEDA composed model, not a benchmark.

Amdahl’s term, a synchronisation term, an oversubscription term and a bandwidth ceiling, each one a knob you can switch off. Real curves have more causes than four and are rarely this smooth. There is no ideal core count to read off this chart.

Cores
The serial part is the split and the merge, not the tasks. The sync term is what each worker pays to coordinate with the others. The ceiling is where the memory system stops feeding cores, whatever the core count says.
1 workerdashed = linear speedup16 workers · max 16.0×
ideal
4.0× · 500 ms
Amdahl only
3.48×
modelled
3.28× · 610 ms
efficiency
82%
Where the 4× went
delivered3.3×
lost to the serial part0.5×
lost to sync, switching and bandwidth0.2×
At 4 cores the model delivers 3.28× of a possible 4×, so 109 ms of the run is overhead rather than work. The serial part dominates. Splitting the input, merging the results and the one section that cannot overlap now cost more than the cores save — and no core count fixes that term.
One hundred tasks that share nothing still do not scale linearly, because the job that owns them is not the tasks. Read the gap between the dashed line and the curve as the price of coordination — and note it is charged even when every task is independent.
limited by: serialSIMULATED

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

What people believe, and what is true

Claim

Parallel code might not be faster, but it will not be slower.

Reality

At fine granularity it is reliably slower — often by more than 2× — while using every core. The overhead is real work the sequential version never does.

Claim

More, smaller tasks give the scheduler more to work with.

Reality

Past the crossover they give it more to *do*. Overhead per task is fixed and steal rates rise, so the coordination grows faster than the parallelism.

Claim

There is a standard minimum task size to aim for.

Reality

The ratio matters, not the absolute size, and the ratio depends on runtime, allocator, cache state and whether the task was stolen. Sweep it.

Claim

It was slower, so the machine or the library must be at fault.

Reality

Check task count and mean task duration first. Millions of microsecond tasks is a granularity finding, not a platform bug.

Go deeper

Overview

Splitting work up costs something. If each piece is tiny, you spend more on the splitting than on the work.

Practical

Chunk instead of per-element tasks, keep a sequential cutoff, and always compare against the plain loop before believing a parallel version.

Advanced

Diagnose which of split, schedule, synchronize or combine dominates — they have different fixes. Overhead per task rises as tasks shrink, so the problem compounds.

Internals

The dominant hidden cost at fine granularity is usually not queue operations but cold-cache arrival: a stolen task pulls its working set across the interconnect, which no lock-wait metric reports.

Apply it