Parallel Decomposition

The Map/Reduce Pattern

Not a product and not a framework — a computational shape. Transform each input independently, group the intermediates by key, reduce each group. Once you can see the shape you find it everywhere: in a SQL GROUP BY, in a browser tab counting words, in a metrics pipeline, in eight lines of local code.

▶ Run the lab

The question this answers

The question

What is the actual computational pattern here, independent of any system that implements it?

The work

Counting term frequency across 200,000 documents: emit (term, 1) for every word in every document, group by term, sum each group.

What is shared

Nothing during the map phase — each input is transformed independently and emits into its own output buffer. The grouping structure is the only shared thing, and how you build it is the entire engineering problem.

The invariant — what must stay true under every interleaving

Every input record contributes to exactly one intermediate emission per emitted key, every intermediate is assigned to exactly one group, and every group is reduced exactly once — so the final count for a term equals the number of times it occurred across all documents, no more and no less.

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 phases, and why only one of them needs coordination

The pattern is: input → map → intermediate (key, value) pairs → group by key → reduce each group → output. The map phase is a pure per-record transformation, so it is embarrassingly parallel by construction: no record's map depends on any other, and no synchronization is needed. The reduce phase is a per-group Parallel Reduce, so it is parallel across groups and needs an associative combine within each. The grouping between them is the only phase with genuine coordination, and it is therefore where all the cost and all the difficulty sits.

That asymmetry is the point of learning the pattern rather than a tool. Whenever you find yourself with "transform each thing, then aggregate by some key", you already know: the transform parallelises for free, the aggregation is bounded by how you group, and the group step is the thing to optimise. A local implementation over 200,000 documents groups into a hash map; a distributed one moves data across a network partitioned by key hash; a SQL engine builds a hash aggregate or sorts. Same shape, three implementations, one place to look for the bottleneck.

The word "MapReduce" with a capital M refers to one specific system, and that association is actively unhelpful when learning the pattern. This lesson is about the shape, which predates the paper, appears in functional programming as map-then-fold, and shows up in local code far more often than in a cluster.

  • Map is parallel for free — a pure function of one record, no shared state, no ordering.
  • Group is the coordinated phase and therefore the bottleneck, whether it is a hash map, a sort or a network shuffle.
  • Reduce is parallel across groups; within a group the combine must be associative.
  • A combiner (local pre-aggregation before grouping) is the single highest-leverage optimisation, and it requires the same associativity.
Input → map (parallel) → group (coordination) → reduce (parallel) → output
far fewer pairsskewed: 8% of all pairs200k documentsmap: doc → (term, 1)*map: doc → (term, 1)*map: doc → (term, 1)*combiner: local pre-aggregate per mapperGROUP BY key — the only coordinated phasereduce: sum group "cat"reduce: sum group "dog"reduce: sum group "the" — the hot keyterm → count
ClientGateway / LBServiceWorkerDatabaseCacheQueue / LogObject storageCDNExternal system

Following one term through the pattern

The trace below follows the pattern concretely, because "map then reduce" stays abstract until you watch the pair counts. Two numbers in it carry the lesson. First, the intermediate volume: naive mapping emits one pair per word occurrence, which for 200,000 documents is tens of millions of pairs that must be materialised and grouped. A combiner that sums within each mapper before grouping collapses that to at most (distinct terms × mappers), often a hundredfold reduction, and costs nothing because the reduce operator was already associative.

Second, the skew. Term frequencies follow a heavy-tailed distribution, so the group for "the" is orders of magnitude larger than the group for "chromatic". Groups are the unit of reduce parallelism, so one enormous group is a straggler that no amount of extra workers fixes — the same straggler problem as Fork/Join, arriving through the key distribution instead of the range split. This is why hot keys are the characteristic failure of this pattern at every scale, from a hash map with one enormous bucket to a distributed reducer that runs for six hours after all the others finished.

The remedies for skew are the same everywhere: pre-aggregate with a combiner so the hot group shrinks before it is ever assembled; or salt the hot key into K sub-keys, reduce them independently, and reduce the K partials in a second pass — which works precisely because the operator is associative.

INPUT       200,000 documents, ~450 words each

MAP         doc -> [(term, 1), (term, 1), ...]      parallel, no coordination
            emitted pairs (naive)              ~90,000,000
            emitted pairs (with combiner)         ~2,400,000   <- 37x less to group
              combiner = per-mapper local sum; valid because + is associative

INTERMEDIATE, grouped by term
            ("the",        [ 4,821,003 ])            <- 5.4% of all occurrences
            ("of",         [ 2,914,776 ])
            ("cat",        [    18,204 ])
            ("chromatic",  [        61 ])
            distinct terms                          ~310,000

REDUCE      one group per term, parallel across groups, sum within
            group "chromatic"      reduce time  ~microseconds
            group "the"            reduce time  dominated by input size
                                                 <- ONE group sets the phase time

SKEW        largest group / median group      ~79,000x
            remedy A: combiner (already applied above)
            remedy B: salt -> ("the#0".."the#15"), reduce 16 groups,
                      then reduce the 16 partials. Valid because + is associative.

THE SHAPE IS SCALE-FREE
            in-process    map over a slice     group = HashMap<String, i64>
            SQL           SELECT term, COUNT(*) ... GROUP BY term
            streaming     windowed aggregation keyed by term
            cluster       partition by hash(term) across reducers
Term-frequency counting, traced by pair volume. Constructed for teaching; volumes are shape, not measurement.

Where the pattern fits, and where people force it

The pattern fits when records are independent and the aggregation is keyed and associative. It fits badly, and is forced anyway, in three recognisable situations. When records are *not* independent — each one needs the result of the previous — there is no map phase, only a sequential scan wearing a map's clothes. When the reduce is not associative, groups cannot be combined incrementally and the whole parallel structure collapses to "collect everything, then fold" (Parallel Reduce). And when the data is small: a hash map over 200,000 records in one process is a few hundred milliseconds, and any pipeline, framework or shuffle you add to it is pure loss.

The genuinely useful skill is recognising the shape in code that does not announce it. A loop that builds a dictionary of counts is map/reduce with the group and reduce fused. A SQL GROUP BY is map/reduce with the engine choosing the grouping strategy. A metrics agent aggregating by label set is map/reduce with a time window as part of the key. Recognising it tells you immediately which phase to parallelise (map, always) and which one will hurt (group, always).

One more distinction worth holding: map/reduce is a *batch* shape. It assumes the full input is available and the groups are complete before reducing. Streaming variants recover most of it by grouping within bounded windows, but they trade completeness for latency, and the "have I seen all of this key yet?" question that batch answers trivially becomes the hardest problem in the streaming version.

SettingMap phaseGrouping mechanismDominant costCharacteristic failure
In-process, one machineParallel loop over a sliceConcurrent hash map, or per-worker maps merged at the endHash map contention, or the mergeOne shared map behind a lock, serialising the map phase
SQL aggregatePer-row projection during the scanHash aggregate or sort-based aggregate, chosen by the plannerMemory for the hash table; spilling if it does not fitSpill to disk on a high-cardinality key
StreamingPer-event transformKeyed state within a bounded windowState store size and watermark lagLate events arriving after the window closed
Cluster batchTask per input splitNetwork shuffle partitioned by hash(key)The shuffle — network and disk, not CPUOne hot key sending a reducer into a multi-hour straggler
The same shape at four scales — and what the grouping phase actually costs at each.

Key points

  • Map/reduce is a computational pattern — input → map → intermediates → group by key → reduce — not a product.
  • Map is parallel for free; reduce is parallel across groups; grouping is the only coordinated phase and therefore the bottleneck.
  • A combiner (pre-aggregating within each mapper) is the highest-leverage optimisation and is valid exactly because the reduce is associative.
  • Groups are the unit of reduce parallelism, so a hot key is a straggler that more workers cannot fix.
  • The shape is scale-free: a hash-map loop, a SQL GROUP BY, a windowed stream aggregation and a cluster shuffle are the same pattern with different grouping mechanisms.
  • It is a batch shape; streaming variants trade completeness for latency and inherit the "have I seen everything for this key?" problem.

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
  • Split the input into independent records or splits; assign them to workers.
  • Map: apply a pure function to each record, emitting zero or more (key, value) pairs. No coordination and no ordering requirement.
  • Optionally combine: apply the reduce operator locally within each mapper to collapse intermediates before they are grouped.
  • Group: collect all values for each key into one place — a hash map bucket, a sorted run, or a network partition by hash(key).
  • Reduce: fold each group with an associative operator, independently and in parallel across groups.
  • Emit one output per key; optionally feed it into another map/reduce stage, which is how multi-step pipelines are built.
Interleavings that matter
  • Three mappers process disjoint document sets concurrently and emit into private buffers — no interleaving matters, because there is no shared state to interleave on.
  • Mappers emit into one shared hash map without synchronization: two mappers read count["the"] = 400, both write 401, and one occurrence is lost. The map phase's freedom from coordination is destroyed the moment the grouping structure is shared (Shared Mutable State).
  • Mappers emit into one shared hash map behind a global lock: correct, and the map phase now serialises on that lock, so eight cores run at the speed of one plus lock overhead.
  • Per-worker maps merged at the end: correct, lock-free during the map phase, and the merge is itself an associative reduce over hash maps. This is the combiner, arrived at from the local direction.
  • Reduce workers start on their groups as they become complete; the "the" group completes last and its reducer runs alone for the remainder of the phase while every other worker is idle.
  • A record is retried after a worker failure and its pairs are emitted twice; because the reduce is a sum, the count for those terms is now wrong — at-least-once execution plus a non-idempotent aggregate (Worker Pools Beyond Threads).
What it guarantees — and does not
  • Guaranteed: map is order-independent, so any assignment of records to workers gives the same intermediates.
  • Guaranteed: with a correct grouping, every intermediate lands in exactly one group and the result is independent of worker count — provided the reduce is associative.
  • NOT guaranteed: that map output order is preserved anywhere. If the output must be ordered, the ordering is a separate sort, not something the pattern provides.
  • NOT guaranteed: balanced groups. Key distribution decides that, and real key distributions are heavy-tailed.
  • NOT guaranteed: that a combiner is safe. It is valid only for associative (and, if the grouping reorders, commutative) reduces — a combiner on a non-associative reduce silently changes results.
  • NOT guaranteed: exactly-once record processing in a distributed implementation. Retries duplicate map output, and a summing reduce is not idempotent under duplication.
  • NOT guaranteed: that the reduce sees a complete group in a streaming variant. That is what watermarks and window closing approximate.
Where contention appears
  • The grouping structure is the contended object at every scale: a shared hash map in-process, a hash table in memory for a SQL aggregate, the network for a shuffle.
  • A single shared map across mappers converts a coordination-free phase into the most contended one; per-worker maps plus a merge is nearly always better.
  • Hot keys concentrate contention on one bucket, one partition or one reducer — the pattern's signature imbalance.
  • The shuffle in a distributed implementation contends for network and disk, which is why it dominates cost there and why combiners matter so much.
How it fails
  • Lost updates from an unsynchronized shared grouping structure — silently low counts.
  • Serialisation from a single lock around the grouping structure — correct and slow.
  • Hot-key straggler: one group dominating the reduce phase, immune to added parallelism.
  • Memory exhaustion from materialising intermediates when no combiner is used and cardinality is high.
  • Duplicate emission under retry making a summing reduce over-count.
  • A combiner applied to a non-associative reduce, changing results in a way that only appears when the combiner is enabled.
  • Late data in streaming variants arriving after a window closed and being dropped or, worse, counted into the wrong window.
When it helps
  • Independent records with a keyed, associative aggregation — the pattern's home ground, from word counts to metric roll-ups to feature aggregation.
  • When the per-record transformation is expensive, because that is the phase that parallelises perfectly.
  • As an analysis lens: recognising the shape tells you where to parallelise and where the cost will land before you write anything.
  • Multi-stage pipelines, where each stage's output is the next stage's input and each stage independently follows the shape.
When it hurts
  • When records are not independent — each depending on the last is a sequential scan, and no map phase exists.
  • When the reduce is not associative, which removes combiners, incremental aggregation and reduce-side parallelism at once.
  • When the data is small enough for a single hash map, where any pipeline machinery costs more than the computation.
  • When key cardinality is extremely high, so grouping cost dominates and there is barely any reduction happening.
  • When the output must be globally ordered — the pattern gives grouped, not ordered, results.
How you would know
  • Intermediate pair count with and without a combiner — the ratio is directly how much grouping work you removed.
  • Group size distribution, especially max/median. A ratio in the thousands means skew is your problem, not parallelism.
  • Time split across the three phases. If grouping dominates, optimise emission volume; if map dominates, the pattern is working as intended.
  • Peak memory of the grouping structure against key cardinality — the spill and OOM predictor.
  • Reduce-phase idle time after all but the largest group finish, which quantifies the straggler.
  • Duplicate-emission rate if the implementation retries, since a summing reduce will silently absorb them.
Complexity it introduces
  • You now have three phases with different parallelism characteristics, and a performance question requires knowing which one you are in.
  • The reduce operator carries an associativity obligation, and enabling a combiner asserts it a second time.
  • Key design becomes a real decision: too coarse and you get skew, too fine and grouping dominates.
  • Distributed implementations add retry semantics, so the aggregate has to tolerate duplicated map output or the pipeline needs deduplication.
  • Streaming variants add windows, watermarks and late-data policy — considerably more machinery than the batch shape.
Simpler alternatives
  • A plain hash map in a single loop, when the data fits in one process — usually the right answer and always the baseline.
  • A SQL GROUP BY, when the data is already in a database: the engine parallelises, spills and optimises the grouping far better than application code.
  • A parallel reduce with no keys, when there is exactly one group — the grouping phase disappears entirely (Parallel Reduce).
  • Streaming aggregation with bounded windows, when results are needed continuously and completeness can be approximate.
  • A sketch (HyperLogLog, count-min), when an approximate aggregate is acceptable: it makes the reduce associative and tiny, turning a shuffle-bound job into a cheap one.

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

One request, N downstream calls

One request, N downstream calls
Fanning out turns N × latency into 1 × latency — and one request per second into N requests per second. The second number is the one that takes the downstream service down.
latency
unbounded
sequential would be
800 ms
peak downstream concurrency
200
downstream busy
over 100%
Latency by concurrency limit
1 at a time800.0 ms · 20 rounds · peak 10 downstream
2 at a time400.0 ms · 10 rounds · peak 20 downstream
4 at a time200.1 ms · 5 rounds · peak 40 downstream
8 at a time · peak 80 concurrent against 64 slots — no steady state
16 at a time · peak 160 concurrent against 64 slots — no steady state
20 at a time · peak 200 concurrent against 64 slots — no steady state
What the downstream sees
calls per parent request20 · each parent request multiplies into 20
concurrent calls at peak200 · 64 slots exist
queued at the downstream136 · these are connections, buffers and threads it did not budget for
Wait per call: unbounded
10 parent requests × 20 concurrent calls each = 200 simultaneous calls against 64 slots. The downstream has no steady state here: latency is not high, it is unbounded, and in a real system this appears as connection-pool exhaustion, timeouts and a service that was healthy until an unrelated caller shipped a loop. The best limit at this configuration is 4 at a time (200 ms) — and note that it is usually not 20. Raising the limit removes rounds, which is a linear win; it also raises peak downstream concurrency, which becomes a cliff the moment the peak crosses what the downstream can hold. A limit costs you a little latency in the good case and is the only thing standing between a routine traffic bump and a self-inflicted outage in the bad one. Bound it, and set the bound from the downstream capacity you were actually granted — not from the fan-out you happen to have today, which will be larger next quarter.
SIMULATEDA burst of 10 simultaneous parent requests against a downstream of 64 concurrent slots; waits from the engine's M/M/c approximation. Real fan-out also pays serialisation, connection setup and a tail latency that grows with N — the fastest of N calls does not set your latency, the slowest does.

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

What people believe, and what is true

Claim

Map/reduce means Hadoop or Spark.

Reality

It is a computational pattern that predates both, and most instances of it are a loop building a dictionary. The tools implement the shape; they do not define it.

Claim

The reduce phase is the expensive one, because that is where the aggregation happens.

Reality

Grouping is the expensive phase. Reduce is parallel across groups and usually cheap; the shuffle or hash table is what costs.

Claim

More reducers will fix the slow reduce phase.

Reality

Not if one key holds most of the data. Groups are indivisible units of reduce work; the fix is a combiner or key salting, not parallelism.

Claim

A combiner is just an optimisation, so it is always safe to add.

Reality

It applies the reduce operator an unspecified number of times before the real reduce. That is only equivalent for associative operators — for anything else it changes the answer.

Go deeper

Overview

Turn each input into labelled pieces, pile up the pieces with the same label, then summarise each pile. Piling is the slow part.

Practical

Parallelise the map, pre-aggregate before grouping, and look at your key distribution before blaming worker count for a slow reduce.

Advanced

Skew is the characteristic failure. Salt hot keys into sub-keys and reduce in two passes — valid because the operator is associative, which is the same property the combiner needed.

Internals

Grouping is either hashing or sorting. Hashing needs memory proportional to cardinality and spills when it runs out; sorting needs a pass over everything but has bounded memory. Query planners pick between exactly these.

Apply it