Shuffles: Intermediate
The Shuffle Write
| Shuffle write step | What the executor does | The cost |
|---|---|---|
| Partition by key | Sort local rows into per-destination buckets | CPU and memory for the sort |
| Write to local disk | Persist the buckets as shuffle files | A full disk write of the data |
| Register the files | Tell the driver where the buckets live | Metadata the reduce side will read |
The Shuffle Read
The two halves around one wide op
(orders
.filter(F.col("status") == "Completed")
.groupBy("region")
.agg(F.sum("profit").alias("total_profit"))
.orderBy(F.col("total_profit").desc()))Spilling to Disk
- Partition fits in execution memory
- Sort and combine happen in memory
- Disk is touched only for the staged buckets
- The shuffle runs at expected speed
- Partition is too big for memory
- Data is written out and read back mid-task
- Extra disk I/O on top of the shuffle
- The shuffle runs several times slower
The 200-Partition Default
Where the knob bites
> From order_items, compute total quantity sold per product_id, highest first. The grouping triggers a shuffle whose output is split into the configured partition count, which you would size to the data.
(order_items .("product_id") .agg(F.("quantity").alias("units")) .orderBy(F.col("units").desc(), F.col("product_id").asc()))
Why Shuffles Dominate Runtime
| Cost in a shuffle | Why it is slow | When it gets worse |
|---|---|---|
| Disk write (map side) | Disk is far slower than memory | Large shuffle volume |
| Network transfer | Bandwidth is shared and limited | M x R fan-out on a big cluster |
| Serialisation | Encoding and decoding every row | Many small rows, fat schemas |
| Spill | Re-writing and re-reading mid-task | Partitions too big for memory |
| Barrier wait | Everyone waits for the slowest task | Skew, one giant partition |
- Read shuffle write and shuffle read in the UI to see how much data a shuffle moves.
- Treat any spill as a sizing problem: raise the partition count or the memory.
- Size spark.sql.shuffle.partitions to roughly data over 128MB, not the 200 default.
- Filter and pre-aggregate before a wide operation so the shuffle moves less.
- Don't accept the 200 default on large or tiny data; it is a guess that fits a narrow band.
- Don't ignore spill; it multiplies a shuffle's cost with avoidable disk I/O.
- Don't forget the shuffle is a barrier; one giant partition stalls the whole stage.
- Don't optimise narrow work to save a job that is dominated by a shuffle.
> A nightly aggregation that shuffles a large fact table has started spilling to disk and missing its window. The Spark UI shows one stage with high shuffle read and nonzero disk spill, running on the default 200 shuffle partitions.
Two halves, a write and a read, with disk and the network in between.
- Category
- Spark
- Difficulty
- intermediate
- Duration
- 14 minutes
- Challenges
- 2 hands-on challenges
Topics covered: The Shuffle Write, The Shuffle Read, Spilling to Disk, The 200-Partition Default, Why Shuffles Dominate Runtime
Lesson Sections
- The Shuffle Write (concepts: paShuffleOptimization)
A shuffle has two halves, and the first is the write, which happens on the map side, the executors that hold the input data. When a wide operation runs, each of these executors takes its local partition and sorts the rows into buckets, one bucket for each destination partition, based on the key you are grouping or joining by. A row for region EU goes in the EU bucket; a row for APAC goes in the APAC bucket. That bucketing by key is the write. The executor does not send these buckets immediately.
- The Shuffle Read (concepts: paShuffleOptimization)
The second half of a shuffle is the read, which happens on the reduce side, the executors that will run the stage after the shuffle. Each reduce task is responsible for one output partition, say all the rows for region EU, and to assemble it, that task has to fetch its bucket from every map executor that wrote one. So a single reduce task reaches out across the network to many machines, pulls down each one's EU bucket, and combines them into the complete EU partition. This is where the all-to-al
- Spilling to Disk (concepts: paShuffleOptimization)
Both halves of a shuffle want to work in memory, but memory is finite, and when a task needs more than it has, it spills to disk. Spill is a word to recognise in shuffle tuning, because any spill signals a task fighting for memory, and disk is far slower than the memory it wanted. A job that spills is doing extra disk work it would not have to do if its partitions were sized to fit. Spill happens most visibly during the sort on the write side and the combine on the read side. If a reduce task fe
- The 200-Partition Default (concepts: paShuffleOptimization)
Every shuffle produces a number of output partitions, and that number is controlled by a single configuration value: spark.sql.shuffle.partitions. Its default is 200, which means that unless you change it, every shuffle in your job divides its output into exactly 200 partitions, regardless of how much data you have. This one default is behind a surprising share of Spark performance problems, in both directions. Think about what 200 means at different scales. If you are shuffling a few hundred me
- Why Shuffles Dominate Runtime (concepts: paShuffleOptimization)
Pulling the mechanics together explains the central fact of Spark performance: the shuffle is almost always where the time goes. It is not one cost but a stack of them, each individually expensive, all paid at once for potentially the entire dataset. Narrow work, by comparison, is CPU on data already in memory. The two are not in the same league, and a job's runtime is usually dominated by its shuffles even when the narrow work looks like the bulk of the code. Notice that several of those costs