Shuffles: Advanced
Each map task writes a data file and an index
Since Spark 2.0, the sort-based shuffle manager is the only one Spark ships. A map task with output for R reducers writes 2 files. The data file holds all of its output rows as R segments laid end to end in reducer order. The index file holds R + 1 byte offsets: where each segment starts, plus the end of the file. Reducer 3 reads from offset 3 to offset 4, and those bytes are its block from this map task.
| Reducer | Reads from byte | Reads to byte | Block size |
|---|---|---|---|
| 0 | 0 | 18,400 | 18,400 |
| 1 | 18,400 | 18,400 | 0 (no rows) |
| 2 | 18,400 | 51,200 | 32,800 |
| 3 | 51,200 | 60,000 | 8,800 |
The old design: a file for every block
| Layout | Files after 1,000 map tasks feed 1,000 reducers | Logical blocks |
|---|---|---|
| One file per map task and reducer | 1,000,000 | 1,000,000 |
| Sort-based: data file plus index | 2,000 | 1,000,000 |
Inside one map task
- Assigna reducer id per rowThe partitioner turns each row's key into a reducer id from 0 to R minus 1: a hash for groupBy, a sampled range for orderBy.
- Bufferin memoryRows collect in a memory buffer, each tagged with its reducer id.
- Sort and spillwhen memory runs outWhen the buffer reaches the memory the task was granted, the task sorts it by reducer id and writes it to a spill file.
- Mergeonce, at the endThe task merges its spill files and what remains in memory into the data file, segment by segment, then writes the index.
3 writers produce 1 layout
| Writer | Spark picks it when | How it builds the data file |
|---|---|---|
| Bypass | the writer does no combining and R is at most `spark.shuffle.sort.bypassMergeThreshold`, 200 by default | opens R files at once, 1 per reducer, then concatenates them in order |
| Serialized | the writer does no combining, the serializer can move records as raw bytes, and R is at most 16,777,216 | sorts 8-byte pointers that pack the reducer id with the record's address, so rows stay as bytes |
| Base sort | the writer itself must combine values per key | sorts row objects and merges values that share a key as it goes |
DataFrame aggregations do their combining in the partial aggregate before the shuffle, so their shuffle writer never combines. That leaves R as the deciding number. With spark.sql.shuffle.partitions at its default of 200, R equals the threshold, and a default DataFrame shuffle takes the bypass path: each map task holds 200 files open while it writes. Set 201 or more and the same job switches to the serialized writer, with no change to its output.
The reduce side
Before fetching, a reduce task asks the driver where every map task's output lives and how big each block is. It then pulls its block from each map task: from local disk when the map ran on its own executor, over the network otherwise. spark.reducer.maxSizeInFlight, 48MB by default, limits how many bytes it has requested and not yet received. If the operation needs key order, the reducer sorts what arrives and spills when it runs short of memory.
A DataFrame groupBy runs with 400 map tasks and spark.sql.shuffle.partitions set to 300. When the map stage finishes, how many shuffle files sit on local disks, and which writer wrote them?
The data and index files from the map stage land under spark.local.dir on the node where each map task ran. Unless you configure otherwise, the executor that wrote them also serves them: its block manager answers every reducer's request for a byte range. The files can be read only while that executor is alive.
Losing the executor that wrote the files
Executors leave mid-job for ordinary reasons: killed for exceeding their memory limit, preempted by the cluster manager, or released when idle. Suppose executor 7 ran 40 map tasks and then dies. Reducers asking it for blocks get a fetch failure, which Spark handles differently from the task failures you have met. Retrying the reduce task cannot help, because the bytes are gone. The DAGScheduler marks executor 7's map outputs as lost, resubmits the map stage to rerun those 40 tasks, and then reruns the reduce tasks that hadn't finished. In the Stages tab, the rerun appears with (retry 1) beside the stage id.
A server that outlives executors
The external shuffle service is a long-running process on every worker node, started by the cluster manager outside any application and shared by all of them. When an executor starts, it registers its local directories with the service on its node. Reducers then fetch blocks from the service, on port 7337 by default, and the service reads the bytes off disk itself. An executor can exit at any point after its map tasks finish, and its files stay readable until the application ends.
The map task writes its data and index files to node A's disk. The reduce task learns block locations from the driver, then fetches its byte ranges from node A's shuffle service, which keeps serving after executor 7 exits.
- Files readable only while the writing executor runs
- A lost executor reruns its map tasks
- Serving shares the executor's threads with its running tasks
- Nothing extra to install on the nodes
- Files readable while the node stays up
- A lost executor costs no rerun, and fetches continue
- Serving runs in its own process on the node
- The service must run, and stay compatible, on every node
A lost node still costs a rerun
Push-based shuffle
With spark.shuffle.push.enabled set to true, a map task writes its files as usual, then pushes its blocks to shuffle services Spark assigned as mergers, each responsible for a set of reducers. A merger appends every block for a reducer into 1 merged file. The reducer reads a few large merged chunks instead of 10,000 small blocks, and falls back to the original files for any block that missed the merge. It requires the external shuffle service, and not every cluster manager supports it.
An application runs with the external shuffle service enabled. Halfway through a reduce stage, executor 12 is killed for exceeding its memory limit, and its node stays up. What happens to the shuffle files executor 12 wrote?
| Line | Work | Paid by | Scales with |
|---|---|---|---|
| Write | serialize, compress, write; reread and rewrite spills | map tasks | Shuffle Write Size, Spill (Disk) |
| Serve | read byte ranges back off disk | the node holding the files | Shuffle Write Size and block count |
| Network | move blocks between nodes | the cluster network | remote share of Shuffle Read Size |
| Read | decompress, deserialize, aggregate or sort | reduce tasks | Shuffle Read Size / Records |
Shuffle output is compressed by default: spark.shuffle.compress is true and the codec is lz4. Shuffle Write Size therefore reports compressed bytes, the same bytes that later cross the disk and the network.
A worked price
| Figure | Arithmetic |
|---|---|
| Disk traffic | 90GB written + 90GB read back = 180GB, before any spill |
| Network | 90GB × 59/60 = 88.5GB |
| Blocks | 2,400 map tasks × 600 reducers = 1,440,000 |
| Average block | 90GB ÷ 1,440,000 = 62.5KB |
| Per reducer | 2,400 fetches totalling 150MB |
A map stage reports a Shuffle Write Size of 40GB from 1,000 tasks. The reduce stage runs 500 tasks across 50 executors. Which estimate fits?
Shrink the rows before you pay
(orders.filter(F.col("status") == "completed").groupBy("customer_id").agg(F.sum("amount").alias("spend")).collect())
Counting what a shuffle would carry
The cell below reads 3 counts that bound a shuffle of the seed's orders: rows read, rows left after keeping completed orders, and the distinct regions among them. For a groupBy("region") on completed orders, that last count caps the partial sums each map task can send: at most 1 per distinct region it holds, plus 1 for rows whose region is null, since countDistinct skips nulls. Run it, then change "Completed" to another status and see which counts move.
orders = spark.table("orders")
completed = F.col("status") == "Completed"
bounds = orders.agg(
F.count("*").alias("rows_read"),
F.sum(F.when(completed, 1).otherwise(0)).alias("rows_after_filter"),
F.countDistinct(F.when(completed, F.col("region"))).alias("regions_after_filter"),
)
bounds.show()Delete the shuffle nobody asked for
| Pattern | Shuffles as written | After the fix | Why it was waste |
|---|---|---|---|
| `repartition(400)` right before `groupBy("user_id")` | 2 | 1 | the round-robin layout is discarded by the hash shuffle the groupBy adds |
| `orderBy("ts")` before a write no reader consumes in order | 1 | 0 | the range shuffle and its extra stage buy an order nobody uses |
| `select("order_id").distinct()` on a column you know is unique | 1 | 0 | Spark cannot know the column is unique, so it shuffles to prove it |
How many shuffles does this program run, and which one can you delete without changing the rows it returns?
Hand over input that is already arranged
Spark records how each DataFrame is partitioned. After repartition("customer_id"), every row for a customer sits in 1 partition, so a groupBy("customer_id") that follows finds its requirement met and adds no shuffle of its own. The board plays exactly that: 2 wide operations in the code, rows exchanged once.
(orders.repartition("customer_id").groupBy("customer_id").agg(F.sum("amount").alias("spend"),F.count("order_id").alias("order_count")).collect())
One shuffle for 2 aggregations
A hash layout on user_id also satisfies a grouping on user_id plus other columns, since rows that share a (user_id, event_date) pair already share a user_id. The job below counts each user's events per day, then keeps each user's busiest day.
| Plan | Shuffles | Rows that cross |
|---|---|---|
| As written | 2 | partial counts per (user, day) per map task, then partial maxima per user |
| Repartitioned | 1 | every raw event |
The drill hands you a program that already repartitions the seed's order_items by order_id. You choose the grouping columns and aggregates for both steps so the result is each order's largest per-product quantity, with both aggregations reusing that single shuffle.
| Knob | Default | What it changes |
|---|---|---|
| `spark.sql.shuffle.partitions` | 200 | reducer count R, so block count and bytes per reducer |
| `spark.shuffle.sort.bypassMergeThreshold` | 200 | largest R that uses the bypass writer |
| `spark.shuffle.file.buffer` | 32KB | memory buffer in front of each file a map task writes |
| `spark.shuffle.compress` | true | compression of map output |
| `spark.reducer.maxSizeInFlight` | 48MB | bytes a reduce task keeps requested at once |
| `spark.reducer.maxBlocksInFlightPerAddress` | no limit | blocks one reducer requests from 1 server at once |
| `spark.shuffle.io.maxRetries` | 3 | fetch retries before a fetch failure |
| `spark.shuffle.io.retryWait` | 5s | wait between those retries |
| `spark.shuffle.service.enabled` | false | serve files from the node's shuffle service |
| `spark.shuffle.push.enabled` | false | push and merge blocks at remote services |
| `spark.local.dir` | /tmp | directories for shuffle and spill files |
Map side: larger, fewer disk writes
The bypass writer keeps 1 file buffer per reducer, so at 200 reducers a map task holds around 6.4MB of buffers. Raising spark.shuffle.file.buffer to 1MB makes about 32 times fewer write calls but holds 200MB per task. Raise it when Shuffle Write Time is a large share of map task time and executors have memory to spare. Leave spark.shuffle.sort.bypassMergeThreshold alone unless you have measured both writers on your job, since raising it means more files open at once in every map task.
Reduce side: how hard reducers pull
When Shuffle Read Blocked Time dominates the reduce stage and the serving nodes are lightly loaded, raising spark.reducer.maxSizeInFlight to 96MB keeps more requests outstanding, so transfers overlap with compute. spark.reducer.maxBlocksInFlightPerAddress works the other way: lowering it caps how many blocks 1 reducer asks of 1 server at a time, which protects shuffle services that drop connections under a flood of small requests.
Fetch retries: how long before a rerun
A failed fetch waits spark.shuffle.io.retryWait and tries again, up to spark.shuffle.io.maxRetries times, and how long that takes depends on how the server fails. A server that refuses connections fails each attempt at once, so the defaults of 3 retries 5 seconds apart turn it into a fetch failure and a rerun of map tasks after about 15 seconds. At 10 retries 10 seconds apart, that stretches to about 100 seconds. A server that hangs makes each attempt first wait out spark.shuffle.io.connectionTimeout, which falls back to spark.network.timeout, 120 seconds by default. With the defaults that is 4 attempts of up to 120 seconds plus 15 seconds of waits, about 8 minutes, and at 10 retries 10 seconds apart about 24 minutes. More patience rides out shuffle servers that pause under heavy load, and every second of it is also how long a dead node takes to be declared lost.
Local directories for shuffle files
spark.local.dir names the directories that receive shuffle and spill files, and cluster managers often override it with their own setting. List 1 directory per local disk, separated by commas, and Spark spreads files across them. Shuffle files on a network-attached volume pay the network twice: once to write and once to serve.
Most of these settings are read when executors start, so they belong in the submit-time configuration. spark.sql.shuffle.partitions is the exception: spark.conf.set changes it between queries.
- Delete unneeded shuffles before tuning the ones that remain
- Change 1 knob per run and compare the same stage's metrics
- Check Shuffle Read Blocked Time before raising maxSizeInFlight
- Put spark.local.dir on fast local disks, 1 directory per disk
- Raise spark.shuffle.file.buffer without counting buffers per task
- Raise fetch retries to hide a node that keeps failing
- Enable the shuffle service before it runs on every node
- Judge a change by total job time alone
Reduce tasks spend most of their time in Shuffle Read Blocked Time. The executors have memory to spare, and the shuffle services report light load. Which change targets that symptom first?
> Back at LinkedIn's crawling nightly job, the stage page shows 10,000 map tasks writing 1TB for 10,000 reducers, Shuffle Read Blocked Time dominating the reduce stage, and a stage retry after an executor was killed for memory. The fix runs in order: delete a leftover repartition(400) that added a shuffle, price whether 2 aggregations on member id should share 1 repartitioned shuffle, enable the external shuffle service so a lost executor reruns nothing, then turn on push-based shuffle so reducers read merged chunks instead of 100 million 10KB blocks.
What a shuffle writes to disk, who serves it, what it costs, and how to delete or tune it.
- Category
- Spark
- Difficulty
- advanced
- Duration
- 15 minutes
- Challenges
- 13 hands-on challenges
Topics covered: Sort-Based Shuffle Internals, The External Shuffle Service, Pricing a Shuffle, Eliminating a Shuffle, The Shuffle Tuning Knobs
Lesson Sections
- Sort-Based Shuffle Internals
A shuffle has 2 halves joined by files on local disk. Map tasks, the last tasks of the stage before the boundary, write those files. Reduce tasks, the first tasks of the stage after it, read them. The layout inside those files decides how many disk operations, open file handles and network requests the shuffle costs. Each map task writes a data file and an index This map task's index file holds 5 numbers for 4 reducers: 0, 18,400, 18,400, 51,200 and 60,000. Reducer 1 has an empty block, recorded
- The External Shuffle Service
Losing the executor that wrote the files A server that outlives executors A lost node still costs a rerun The service reads files from a local disk and copies them nowhere. If the node goes down or its disk fails, every map output on that node is gone. If only the service process dies, the files still sit on disk but nothing can serve them: on the first fetch failure Spark unregisters that host's map outputs, and the DAGScheduler reruns those map tasks as it would without the service. The servic
- Pricing a Shuffle
Every shuffle sends a bill with 4 lines, and each line scales with a number on the stage page. Pricing a shuffle means reading those numbers and turning them into disk traffic, network traffic and request counts before deciding what to change. A worked price A daily job reads 600GB of clickstream, keeps 4 columns, and groups by user. The map stage reports a Shuffle Write Size of 90GB across 2,400 tasks. The reduce stage runs 600 tasks spread over 60 executors, so a reducer finds about 1 in 60 of
- Eliminating a Shuffle (concepts: Broadcast Join)
The cheapest shuffle is the one the plan never contains. A wide operation needs every row sharing a key inside 1 task, and Spark adds a shuffle only when its input isn't already laid out that way. That leaves 2 ways to remove one: drop a wide operation the result doesn't need, or hand the next wide operation input that already meets its requirement. Delete the shuffle nobody asked for Each of these survives code review because it looks harmless: a repartition left over from debugging, a sort add
- The Shuffle Tuning Knobs
With only the needed shuffles left in the plan, configuration sets how each remaining one runs. The knobs sort by the part of the shuffle they touch, and each one answers a symptom you can see on the stage page. Map side: larger, fewer disk writes Reduce side: how hard reducers pull Fetch retries: how long before a rerun Local directories for shuffle files