AdvancedSpark · 15 min · 13 challenges

Shuffles: Advanced

A reduce stage can crawl while the cluster sits half idle, because each reducer is pulling 10,000 blocks of about 10KB apiece from disks spread across the cluster. LinkedIn's engineers hit this problem in their nightly jobs at scale and built Magnet, the push-based shuffle that shipped in Spark 3.2. This lesson shows how to price that shuffle, then remove or tune it.

Sort-Based Shuffle Internals

Daily Life
Interviews
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

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.

ReducerReads from byteReads to byteBlock size
0018,40018,400
118,40018,4000 (no rows)
218,40051,20032,800
351,20060,0008,800
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 as 2 equal offsets. The driver also knows each block's size from the map task's status report, so reducer 1 never requests its empty block.

The old design: a file for every block

Spark's first shuffle wrote a separate file for every pair of map task and reducer. Sort-based shuffle became the default in Spark 1.2, and Spark 2.0 removed the old design.
LayoutFiles after 1,000 map tasks feed 1,000 reducersLogical blocks
One file per map task and reducer1,000,0001,000,000
Sort-based: data file plus index2,0001,000,000
The block count stays at 1 million either way, because each block became a byte range inside a data file instead of a file of its own. The sort layout removes the million file creations, the inode pressure and the per-node limit on open file handles. In exchange, every map task must order its output by reducer before writing it.

Inside one map task

The sorting writers, step by step
  • 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.
The sort is by reducer id. Inside a segment, rows carry no key order unless the operation asked for one. A task that never fills its buffer makes a single pass. A task that spills writes every spilled row twice, once to a spill file and once into the merged data file, and reads it back in between. Spill (Disk) on the stage page measures that extra traffic.

3 writers produce 1 layout

Spark picks 1 of 3 writers for each shuffle when it registers it. All 3 produce the same data file and index, so reducers never know which one ran.
WriterSpark picks it whenHow it builds the data file
Bypassthe writer does no combining and R is at most `spark.shuffle.sort.bypassMergeThreshold`, 200 by defaultopens R files at once, 1 per reducer, then concatenates them in order
Serializedthe writer does no combining, the serializer can move records as raw bytes, and R is at most 16,777,216sorts 8-byte pointers that pack the reducer id with the record's address, so rows stay as bytes
Base sortthe writer itself must combine values per keysorts 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.

Multiple Choice

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 External Shuffle Service

Daily Life
Interviews

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.

executor 7
Map task
node A
map output locations
Driver
node A
Local disk
spark.local.dir
node A
Shuffle service
port 7337
executor 22
Reduce task
node B

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.

spark.shuffle.service.enabled: true
spark.shuffle.service.port: 7337
✓Executor serves its files
  • 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
✗Shuffle service serves them
  • 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

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 service turns an executor loss into a non-event, while a node loss still costs a partial rerun of the map stage.

Push-based shuffle

The service can also fix the block count that the sort layout leaves behind. A shuffle from 10,000 map tasks to 10,000 reducers stores 100 million blocks, so a 1TB shuffle averages 10KB per block. Each reducer issues 10,000 small reads scattered across the cluster's disks, and on spinning disks the seeks cost more than the bytes. LinkedIn's engineers described this in their Magnet paper and contributed push-based shuffle to Spark 3.2.

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.

Multiple Choice

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?

Pricing a Shuffle

Daily Life
Interviews
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.
LineWorkPaid byScales with
Writeserialize, compress, write; reread and rewrite spillsmap tasksShuffle Write Size, Spill (Disk)
Serveread byte ranges back off diskthe node holding the filesShuffle Write Size and block count
Networkmove blocks between nodesthe cluster networkremote share of Shuffle Read Size
Readdecompress, deserialize, aggregate or sortreduce tasksShuffle 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

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 its blocks on its own executor.
1.44M
blocks in the shuffle
FigureArithmetic
Disk traffic90GB written + 90GB read back = 180GB, before any spill
Network90GB × 59/60 = 88.5GB
Blocks2,400 map tasks × 600 reducers = 1,440,000
Average block90GB ÷ 1,440,000 = 62.5KB
Per reducer2,400 fetches totalling 150MB
The bytes look large, but the block size is the line that warns you. At 62.5KB per block, each reducer makes 2,400 small reads to collect 150MB. Fast local SSDs absorb that, while spinning disks spend more time seeking than transferring. Doubling the reducers to 1,200 halves the average block to about 31KB and doubles the total fetches to 2.88 million. Each reducer still makes 2,400 reads, now for 75MB. Shuffle Write Size stays at 90GB.
Multiple Choice

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

Every line of the bill multiplies rows crossing the boundary by bytes per row. A filter and a narrow select before the wide operation cut both, and the partial aggregate cuts rows to 1 per key per map task. The board plays a filter and a sum over 6 orders read as 2 partitions and shuffled to 2 reducers.
(orders
.filter(F.col("status") == "completed")
.groupBy("customer_id")
.agg(F.sum("amount").alias("spend"))
.collect())
orders6 rows
order_id
customer_id
status
amount
1
c1
completed
40
2
c2
cancelled
25
3
c1
completed
60
4
c3
completed
35
5
c2
completed
50
6
c1
cancelled
20
Preparing the animation
Of the 6 orders, 4 survive the filter, and each map task sends 1 partial sum for every customer it holds, so the cancelled orders never reach the shuffle. The driver collects c1 at 100, c2 at 50 and c3 at 35.

Counting what a shuffle would carry

Shuffle Write Size / Records on the map stage: the compressed bytes and the rows the map tasks wrote.
Shuffle Write Size / Records on the map stage: the compressed bytes and the rows the map tasks wrote.
Shuffle Read Size / Records on the reduce stage, with Shuffle Remote Reads giving the network line.
Shuffle Read Size / Records on the reduce stage, with Shuffle Remote Reads giving the network line.
Shuffle Read Blocked Time: how long reducers sat waiting for fetches instead of computing.
Shuffle Read Blocked Time: how long reducers sat waiting for fetches instead of computing.
Spill (Memory) and Spill (Disk): rows a writer or reader sorted in more than 1 pass.
Spill (Memory) and Spill (Disk): rows a writer or reader sorted in more than 1 pass.

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()

Eliminating a Shuffle

Daily Life
Interviews
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

PatternShuffles as writtenAfter the fixWhy it was waste
`repartition(400)` right before `groupBy("user_id")`21the round-robin layout is discarded by the hash shuffle the groupBy adds
`orderBy("ts")` before a write no reader consumes in order10the range shuffle and its extra stage buy an order nobody uses
`select("order_id").distinct()` on a column you know is unique10Spark cannot know the column is unique, so it shuffles to prove it
Each of these survives code review because it looks harmless: a repartition left over from debugging, a sort added so a sample looked tidy, a distinct added out of caution. Search the code for wide operations whose output the next step reshuffles or ignores.
Multiple Choice

How many shuffles does this program run, and which one can you delete without changing the rows it returns?

result = (orders
.repartition(50)
.filter(F.col("status") == "Completed")
.groupBy("status")
.agg(F.sum("profit").alias("profit"))
.orderBy(F.col("profit").desc()))
result.collect()

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())
orders6 rows
order_id
customer_id
amount
1
c1
40
2
c2
25
3
c1
60
4
c3
35
5
c2
50
6
c1
20
Preparing the animation
The driver collects c1 at 120 over 3 orders, c2 at 75 over 2, and c3 at 35 over 1. Look at what crossed the shuffle: all 6 raw orders, because a repartition moves rows before any aggregation runs. A plain groupBy would have shuffled partial sums. Repartitioning by a key first pays off only when 2 or more wide operations then share it.
//

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.

per_day = (events
.groupBy("user_id", "event_date")
.agg(F.count("*").alias("n")))
busiest = (per_day
.groupBy("user_id")
.agg(F.max("n").alias("busiest_day_events")))
per_day = (events
.repartition("user_id")
.groupBy("user_id", "event_date")
.agg(F.count("*").alias("n")))
busiest = (per_day
.groupBy("user_id")
.agg(F.max("n").alias("busiest_day_events")))
PlanShufflesRows that cross
As written2partial counts per (user, day) per map task, then partial maxima per user
Repartitioned1every raw event
Suppose a user-day averages 40 events and a user's events for a day tend to land in the same map task. The first plan's first shuffle then carries as little as a fortieth of the rows in the second plan's only shuffle. Fewer shuffles is the goal only when the bytes agree, so price both plans with the method from the previous section before choosing.

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.

The Shuffle Tuning Knobs

Daily Life
Interviews
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.
KnobDefaultWhat it changes
`spark.sql.shuffle.partitions`200reducer count R, so block count and bytes per reducer
`spark.shuffle.sort.bypassMergeThreshold`200largest R that uses the bypass writer
`spark.shuffle.file.buffer`32KBmemory buffer in front of each file a map task writes
`spark.shuffle.compress`truecompression of map output
`spark.reducer.maxSizeInFlight`48MBbytes a reduce task keeps requested at once
`spark.reducer.maxBlocksInFlightPerAddress`no limitblocks one reducer requests from 1 server at once
`spark.shuffle.io.maxRetries`3fetch retries before a fetch failure
`spark.shuffle.io.retryWait`5swait between those retries
`spark.shuffle.service.enabled`falseserve files from the node's shuffle service
`spark.shuffle.push.enabled`falsepush and merge blocks at remote services
`spark.local.dir`/tmpdirectories 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.

spark = (SparkSession.builder
.appName("daily_clicks")
.config("spark.shuffle.file.buffer", "1m")
.config("spark.reducer.maxSizeInFlight", "96m")
.config("spark.shuffle.io.maxRetries", "10")
.config("spark.shuffle.io.retryWait", "10s")
.config("spark.shuffle.service.enabled", "true")
.getOrCreate())
✓Do
  • 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
✗Don't
  • 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
Multiple Choice

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?

PUTTING IT ALL TOGETHER

> 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.

The 10KB blocks come from the sort layout: 2 files per map task, but still M times R logical blocks inside them.
The pricing arithmetic turns 1TB over 100 million blocks into the 10KB average that explains the seeks.
Deleting and reusing shuffles comes before any knob, because a shuffle that is gone needs no tuning.
The external shuffle service is the precondition both for surviving executor loss and for push-based merging.
The reduce-side and retry knobs handle what remains: fetch waits and short server pauses.
KEY TAKEAWAYS
A sort-based shuffle leaves 2 files per map task, a data file in reducer order and an index of R + 1 offsets, while the block count stays M times R.
A default DataFrame shuffle at 200 reducers takes the bypass writer; at 201 or more it switches to the serialized writer.
The external shuffle service keeps map output readable after its executor exits, but it copies nothing, so a lost node still reruns map tasks.
Price a shuffle from the stage page: disk traffic twice, network for the remote share, and block size from M times R.
Delete shuffles nobody needs, and line up keys so later wide operations reuse an earlier layout once both plans are priced.
Tune 1 knob at a time against its symptom: write time, read blocked time, or fetch failures.

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

  1. 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

  2. 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

  3. 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

  4. 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

  5. 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