Parallel Decomposition

Parallel Algorithms

Map, reduce, scan, sort and divide-and-conquer are the primitives almost every parallel program is built from. The useful skill is not implementing them — your library already did — it is recognising which shape a problem has, and knowing that scan is parallelisable at all.

▶ Run the lab

The question this answers

The question

Which computations have a parallel form at all, and how do I recognise the shape of the one in front of me?

The work

Four jobs over the same 50-million-element array: scale every element (map), find the sum (reduce), compute running totals (scan), and sort it — plus the recursive shape underneath the last one.

What is shared

The input array. Map and reduce touch disjoint regions and share nothing mutable. Scan and merge-based sort write into a separate output buffer, which is what keeps them free of in-place write conflicts — sorting in place in parallel is a materially harder problem.

The invariant — what must stay true under every interleaving

Each parallel algorithm produces exactly the result its sequential specification defines, for every schedule the runtime may choose: same elements, same values, and same order wherever the specification fixes an order.

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 five shapes, and the two numbers that decide whether parallelism helps

Almost everything parallel decomposes into five primitives. Map applies a function per element independently. Reduce folds all elements into one with an associative operator. Scan (prefix sum) produces, for each position, the reduction of everything up to it. Sort produces a permutation in order. Divide-and-conquer splits a problem into independent subproblems and combines their answers — and is really the general form the other four are instances of.

Two numbers decide whether a parallel form helps, and they are worth introducing here even though Work and Span develops them properly. Work is the total number of operations the parallel version performs — often more than the sequential algorithm does, which is the first honest thing to notice. Span is the longest chain of operations that must happen in sequence, and it is the floor no number of cores gets under. A map has span O(1) — every element is independent, so with enough workers it is one step. A reduce over a balanced tree has span O(log n). A naive left-to-right scan has span O(n), which is why the interesting fact about scan is that a different algorithm brings it to O(log n) at the cost of doing roughly twice the work.

That trade — more total work, shorter critical path — is the recurring shape of parallel algorithm design, and it is why "the parallel algorithm is less efficient" is often true and often irrelevant. A version doing 2n operations with span log n beats a version doing n operations with span n as soon as you have more than a handful of cores, and loses on one core. Parallel Overhead is the lesson on where that crossover lands.

ShapeWorkSpan (critical path)What must be trueParallelism helps when
MapO(n)O(1)Per-element function is independent and side-effect-freePer-element work is non-trivial; otherwise memory-bound
ReduceO(n)O(log n)Combine is associative; seed is a true identityPer-element work is non-trivial, or n is very large
Scan / prefix sumO(n) sequential, ~O(2n) parallelO(n) naive → O(log n) with a two-pass algorithmOperator is associative — the same requirement as reducen is large and cores are plentiful; the extra work is real
Merge sortO(n log n)O(log² n) with a parallel merge, O(n) with a sequential mergeComparison is a total order; output buffer separate from inputAbove a size threshold; the merge is the part that limits you
QuicksortO(n log n) expectedO(log² n) expected, degrades with bad pivotsPartition step parallelised or done sequentially per levelSizes are balanced; skew makes it a straggler problem
Divide-and-conquer (general)Problem-dependentDepth of the recursion × combine costSubproblems are independent; combine is cheap relative to themSubproblems are large enough to beat the fork cost
Sequential dependence (running state machine)O(n)O(n) — irreducibleNothing; each step needs the previous stateNever, within one instance. Parallelise across instances instead
The primitives, their span, and the honest answer on when parallelism pays.

Scan: the one that looks sequential and is not

A running total looks irreducibly sequential — output[i] needs output[i−1], so how could you compute the millionth without the 999,999th? The resolution is the single most useful idea in this lesson, because it generalises: you do not need the previous answer, you need the reduction of everything before you, and reductions parallelise.

The two-pass algorithm below is the practical form. Pass one: chunk the array and compute each chunk's total in parallel — an ordinary parallel reduce. Then compute an exclusive scan over the P chunk totals, which is sequential but P is tiny. Pass two: each chunk scans itself locally, offset by its chunk's prefix — parallel again. Two parallel passes over the data plus a trivial sequential middle, giving span O(log n) work-efficiently, at the cost of reading the array twice.

Once you see it, the pattern shows up everywhere: computing output offsets before a parallel compaction or partition, converting counts to positions in a radix sort, allocating ranges from variable-length outputs, line-start offsets when parsing a buffer in parallel. Any time you were about to write "I need a running counter, so this loop has to be sequential", the answer is usually a scan. And any time the operator is genuinely non-associative — a state machine whose transition depends on the accumulated state in a way that cannot be composed — the loop really is sequential, and the parallelism has to come from processing many independent inputs rather than one input faster.

  • The sequential middle pass is over P chunk totals, so it is negligible — the serial fraction was shrunk, not eliminated (Amdahl's Law).
  • Two passes over memory instead of one: work-efficient in operation count, not in bandwidth.
  • Requires associativity, exactly like reduce. Non-associative operators have no parallel scan.
  • Writes are disjoint by construction, so there are no locks anywhere in the algorithm.
1// Inclusive scan of a[0..n) into out[0..n) with an associative op.
2// Span is O(log n)-ish; total work is about 2n reads instead of n.
3void parallel_scan(const std::vector<int64_t>& a,
4 std::vector<int64_t>& out,
5 int P) // P = chunk count, P << n
6{
7 const size_t n = a.size();
8 const size_t chunk = (n + P - 1) / P;
9 std::vector<int64_t> chunk_total(P, 0);
10
11 // -- 1: PARALLEL. Each chunk reduces itself. No sharing: chunk c writes
12 // only chunk_total[c], and reads only its own slice.
13 parallel_for(0, P, [&](int c) {
14 int64_t acc = 0;
15 for (size_t i = c * chunk; i < std::min((c + 1) * chunk, n); ++i)
16 acc += a[i];
17 chunk_total[c] = acc;
18 });
19
20 // -- 2: SEQUENTIAL, but over P elements, not n. This is the whole trick:
21 // the irreducibly serial part was made tiny, not removed.
22 std::vector<int64_t> offset(P, 0);
23 for (int c = 1; c < P; ++c)
24 offset[c] = offset[c - 1] + chunk_total[c - 1]; // exclusive scan
25
26 // -- 3: PARALLEL. Each chunk scans locally, seeded with its offset.
27 parallel_for(0, P, [&](int c) {
28 int64_t acc = offset[c];
29 for (size_t i = c * chunk; i < std::min((c + 1) * chunk, n); ++i) {
30 acc += a[i];
31 out[i] = acc; // disjoint writes: no locks
32 }
33 });
34 // Note: for floating point, out[] will differ in the last bits from a
35 // sequential scan, for exactly the reason in [[parallel-reduce]].
36}
Two-pass parallel scan. The middle pass is sequential and that is fine — it is over P items, not n.

Sorting, and what the recursion tree actually tells you

Parallel merge sort is the canonical divide-and-conquer example, and its recursion tree makes the limiting factor visible. Splitting is free and the leaves are perfectly parallel, but merging is not: the final merge combines two n/2 runs into one n run, and a *sequential* merge at the root has span O(n) — which means that no matter how many cores you throw at the leaves, the last merge alone takes linear time and caps your speedup at roughly log n. The fix is a parallel merge (binary-search each run's median into the other and merge the pieces independently), which brings the span down to O(log² n) and is why library parallel sorts are more complicated than "recurse in parallel".

The tree also shows the other structural fact: parallelism is maximal at the leaves and minimal at the root. Early on there is one task; near the leaves there are n/cutoff. So the top of the recursion under-uses the machine and the bottom over-decomposes it, which is exactly why every parallel divide-and-conquer has a sequential cutoff — below some size, stop forking and sort the block with an ordinary sequential algorithm, usually insertion sort for very small blocks.

Quicksort inverts the structure: the partition is the expensive part and the combine is free, so it parallelises well when pivots are balanced and turns into a straggler problem when they are not — the same skew story as Fork/Join, arriving through pivot choice. It is worth noticing that the two algorithms fail in opposite places, which is a good reason to know both shapes rather than one sorting function.

Parallel merge sort: wide at the leaves, narrow at the root
forkforksorted blocksthe critical path ends heresort a[0..n) — 1 task, the whole arraysort a[0..n/2)sort a[n/2..n)sort n/4sort n/4sort n/4sort n/4CUTOFF: below ~10k elements, sort sequentially — stop forkingmerge n/4 + n/4 → n/2 (2 merges, parallel)merge n/2 + n/2 → n (1 merge — span O(n) if sequential)sorted a[0..n)
ClientGateway / LBServiceWorkerDatabaseCacheQueue / LogObject storageCDNExternal system

Key points

  • Map, reduce, scan, sort and divide-and-conquer cover most parallel programs; recognising the shape is the transferable skill.
  • Work is total operations; span is the longest sequential chain and is the floor no core count beats.
  • Parallel algorithms often do *more* total work to shorten the span, and that trade is usually correct above a few cores.
  • A running total is not sequential: you need the reduction of everything before you, and reductions parallelise. Scan is the answer to "I need a running counter".
  • Scan needs associativity, exactly as reduce does; genuinely non-associative accumulation is irreducibly sequential within one input.
  • In parallel merge sort the final merge is the critical path, which is why library implementations parallelise the merge itself.
  • Every recursive decomposition needs a sequential cutoff — the top of the tree under-uses the machine and the bottom over-decomposes 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
  • Identify the shape: is each element independent (map), is everything folded to one value (reduce), does each output depend on all prior inputs (scan), is it a permutation (sort), or is it a recursive split (divide-and-conquer)?
  • Check the algebraic requirement: independence for map, associativity for reduce and scan, a total order for sort, subproblem independence for divide-and-conquer.
  • Estimate span: constant for map, logarithmic for tree reduce and two-pass scan, and the recursion depth times combine cost for divide-and-conquer.
  • Choose a decomposition with more pieces than workers so a stealing scheduler can balance, and a sequential cutoff so pieces stay large enough to be worth scheduling.
  • Write results into disjoint locations so no synchronization is required inside the parallel phases.
  • Measure against the sequential version at several sizes to find the crossover, and keep the sequential version as the implementation below it.
Interleavings that matter
  • Parallel map: workers process disjoint index ranges; every interleaving produces identical output because no worker reads what another writes.
  • Parallel map with a shared output index (out[next++] = f(x)): two workers read next = 500, both write slot 500, one result is lost and slot 501 is never written — the classic reason parallel *filter* needs a scan to compute offsets first.
  • Two-pass scan: all chunk reductions complete, then the P-element sequential pass runs, then all local scans run. A local scan that starts before the offsets are computed reads a zero offset and produces silently wrong running totals for every element in its chunk.
  • Parallel merge sort: sibling subtrees sort concurrently and touch disjoint ranges; merges at each level are independent; only the parent-child dependency is ordered, and it is enforced by the join.
  • Parallel quicksort with a pathological pivot: one partition holds 99% of the elements, its subtree becomes the critical path, and every other worker idles — correct output, no speedup.
  • In-place parallel partition without care: two workers swapping elements across the same boundary corrupt the permutation — which is why the simple parallel sorts use a separate output buffer.
What it guarantees — and does not
  • Guaranteed: parallel map, reduce (associative), scan (associative) and merge sort produce exactly the sequential result for integer and comparison operations.
  • Guaranteed: with disjoint output ranges, no synchronization is needed within a parallel phase — the decomposition is the synchronization.
  • NOT guaranteed: bitwise identity for floating-point reduce or scan. Grouping changes rounding (Reduction Ordering: The Sum Changed When the Worker Count Did).
  • NOT guaranteed: stability of a parallel sort. Some parallel sorts are stable, many are not, and the docs are the only authority.
  • NOT guaranteed: that a parallel algorithm does the same amount of work as the sequential one. Scan does about twice as many operations; that is the price of the shorter span.
  • NOT guaranteed: speedup proportional to cores. Span, memory bandwidth and the sequential cutoff all cap it well before that.
  • NOT guaranteed: that a "parallel" library call actually runs in parallel. Many fall back to sequential below a size threshold, or when no thread pool is available.
Where contention appears
  • Well-formed parallel map, reduce and scan contend on nothing but memory bandwidth — the decomposition removes logical sharing entirely.
  • The merge phase of a parallel sort concentrates work at the root, so the machine is under-used exactly when the remaining work is largest.
  • Any shared output cursor (a next++ index) is a serialisation point and a lost-update hazard; compute offsets with a scan instead.
  • Deep recursion produces many small tasks whose scheduling and stealing overhead contends with the work itself (Work Stealing).
  • Adjacent per-chunk result slots are a False Sharing: Different Variables, Same Cache Line site when written repeatedly, though the algorithms here write each slot once.
How it fails
  • Lost update on a shared output cursor in a parallel filter or compaction — missing and uninitialised output slots.
  • Reading a scan offset before the sequential middle pass completed, producing wrong running totals for a whole chunk.
  • Straggler subtrees in quicksort from unbalanced pivots — correct output, no speedup, and the profile blames the wrong thing.
  • Sequential merge at the root capping merge-sort speedup at roughly log n regardless of core count.
  • Over-decomposition below the cutoff, where task overhead exceeds the work and the parallel version is slower than sequential.
  • Assuming stability from a parallel sort that does not provide it, which shows up as reordered equal keys much later.
  • Applying a parallel scan or reduce with a non-associative operator, producing run-dependent results.
When it helps
  • Large inputs with substantial per-element work, where map and reduce give near-linear speedup for very little code.
  • When a scan removes an apparent sequential dependency — parallel filter, compaction, partition, offset computation and parallel parsing all rest on it.
  • Sorting above a size threshold, where library parallel sorts deliver a solid multiple of sequential with no correctness work from you.
  • Recursive problems whose subproblems are naturally independent and comparable in size.
When it hurts
  • Small inputs, where the sequential version wins and the cutoff exists precisely to fall back to it.
  • Cheap per-element operations over big arrays, where memory bandwidth caps the result far below core count (Memory Bandwidth: More Cores, Same Bus).
  • Genuinely sequential dependence — a state machine, a running compression dictionary, an iterative solver whose step depends on the last — where the parallelism must come from independent inputs instead.
  • Unbalanced recursion, where one subtree dominates and the speedup collapses to that subtree.
  • When the sequential algorithm has a better constant factor: an O(n log n) parallel algorithm can lose to an O(n log n) sequential one with cache-friendly access patterns.
How you would know
  • Speedup versus core count at several input sizes — the family of curves shows both the crossover and the span ceiling.
  • Wall time against sum-of-task-times ÷ cores, which separates imbalance from overhead.
  • Achieved memory bandwidth versus machine peak, to tell "not enough cores" from "no bandwidth left".
  • Task count and mean task duration, to check the cutoff: microsecond tasks mean it is too low.
  • Per-subtree time in a recursive algorithm, which localises a straggler to a specific pivot or split.
  • Equality against the sequential result on the same input — exact for integers, within tolerance for floats.
Complexity it introduces
  • You now maintain a sequential implementation and a parallel one, plus the cutoff that chooses between them, and both need testing.
  • Cutoff and chunk-count constants are hardware-dependent tuning parameters with the same staleness problem as pool sizes.
  • The algebraic preconditions (associativity, independence, total order) are obligations the compiler will not check.
  • Parallel variants often need extra memory — an output buffer for scan and merge — which changes the memory profile of the whole program.
  • Debugging shifts from stepping through a loop to reasoning about a decomposition, and the failure is usually a wrong number rather than a crash.
Simpler alternatives
  • The sequential algorithm, always the baseline — for most real input sizes it is the right answer and it is already correct.
  • A library parallel algorithm (parallel STL, rayon, Java streams) instead of a hand-rolled one: same shape, far fewer opportunities to be wrong.
  • SIMD within one thread, when the operation is uniform over adjacent data — often several times faster with no coordination at all (SIMD: One Instruction, Many Elements).
  • A better sequential algorithm: an O(n) approach beats a parallel O(n log n) one at every core count, and algorithmic improvement composes with parallelism rather than competing with it.
  • Parallelism across independent inputs rather than within one, when the computation is genuinely sequential — the answer for state machines and iterative solvers.

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

Parallel reduce and the combine tree

Parallel reduce — the combine tree is part of the answer
Split into chunks, reduce each chunk, then combine pairwise. Associativity is what makes that legal — and floating-point addition is not associative.
input  [ 3, 6, 9, 12, 15, 18, 21, 24 ]
Per-worker chunks (each summed left to right, no sharing, no lock)
W1: [3, 6] = 9W2: [9, 12] = 21W3: [15, 18] = 33W4: [21, 24] = 45
Combine tree
partials9213345
level 13078
level 2108
sequential sum
108
tree sum, 4 workers
108
combine steps
2
distinct answers across 1–8 workers
1
Integer addition is associative, so all worker counts agree — 108 however you cut it. That is the precondition the whole pattern rests on: a reduce may be parallelised only when the operator is associative, and the tree needs an identity element for the empty chunk. Each worker accumulates privately and the 2 combine steps touch 4 values — contrast that with 8 threads incrementing one shared accumulator. Now switch on floating-point data and watch the same code stop being deterministic.
SIMULATED

Fork/join and the split threshold

Fork/join — splitting is not free
4,096 elements, 0.002 ms of work each, 8 workers. Each split costs 0.05 ms to create and join.
L0
1 × 4,096
L1
2 × 2,048
L2
4 × 1,024
L3
8 × 512
L4
16 × 256
sub-tasks created
30
useful work
8.2 ms
split + join overhead
1.5 ms
speedup on 8 workers
6.76×
fork(lo, hi):
  if (hi - lo <= 64)  return sequential(lo, hi)      // the base case is the tuning knob
  mid = (lo + hi) / 2
  left = spawn fork(lo, mid)                                // +0.05 ms
  right =       fork(mid, hi)                               // run one half on THIS thread
  return left.join() + right                                // join is where the parallelism ends

levels requested 4 → 4 actually taken
leaves 16 × 256 elements   span 0.91 ms   total 1.21 ms
16 leaves of 256 elements → 6.76× on 8 workers. Overhead is 1.5 ms against 8.2 ms of work, which is the range where splitting pays. Note the shape of the ceiling: you need at least 8 leaves to keep 8 workers busy, and past roughly 32 leaves you are buying load balance, not parallelism. Drag the threshold down to 1 and watch the overhead column overtake the work column.
1/9 · fork · level 0SIMULATED

Work, span and the speedup ceiling

Work and span — the ceiling nobody can buy
Work T₁ is every millisecond of compute. Span T∞ is the longest chain of dependencies. Speedup can never exceed T₁ / T∞, whatever the machine.
A · 20 msB · 30 ms ← AC · 25 ms ← AD · 40 ms ← AE · 15 ms ← B,CF · 20 ms ← DG · 10 ms ← E,F
Add a dependency and watch the ceiling drop
1 workerdashed = linear speedup8 workers · max 8.0×
work T₁
160 ms
span T∞ (critical path)
90 ms
speedup ceiling T₁/T∞
1.78×
useful workers
2
critical path  A → D → F → G  = 90 ms
work           20 + 30 + 25 + 40 + 15 + 20 + 10 = 160 ms
ceiling        T₁ / T∞ = 160 / 90 = 1.78×
no extra edges — toggle one above
T₁ = 160 ms of work, T∞ = 90 ms of unavoidable sequence, so nothing beats 1.78×. The critical path is A → D → F → G, highlighted above. Worker 9 has nothing to do that worker 2 was not already doing — and this is a statement about the problem, not about the runtime, the language or the hardware. Before tuning a parallel program, compute this ratio; if it is 2, you are arguing about the second decimal place of a 2× win.
SIMULATEDdurations in ms; greedy schedule

What people believe, and what is true

Claim

A running total cannot be parallelised because each value needs the previous one.

Reality

It needs the *reduction* of everything before it, and reductions parallelise. A two-pass scan does it with a logarithmic span at roughly twice the work.

Claim

The parallel algorithm is worse because it does more operations.

Reality

Parallel algorithms routinely trade extra work for a shorter critical path. That is a losing trade on one core and a winning one on many.

Claim

Parallel merge sort splits in parallel, so it scales with cores.

Reality

The final merge is the critical path. With a sequential merge the span is linear and speedup caps near log n no matter how many cores you have.

Claim

If the library call is named "parallel", the work ran in parallel.

Reality

Most implementations fall back to sequential below a size threshold or without an available thread pool, and none of them are obliged to tell you.

Go deeper

Overview

Most parallel work is one of five shapes: do the same thing to everything, squash everything into one value, running totals, sorting, or split-and-recurse.

Practical

Use library implementations, keep a sequential fallback below a cutoff, and reach for a scan whenever you think a loop needs a running counter.

Advanced

Span is the ceiling. Parallelise the merge in merge sort, watch pivot balance in quicksort, and never let a shared output cursor into a parallel loop — compute offsets with a scan.

Internals

Work-efficient scan is up-sweep then down-sweep over a balanced tree; the two-pass chunked version is the practical CPU form because it trades a tiny serial section for excellent cache behaviour.

Apply it