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.

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.

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.

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())
orders
order_idcustomer_idstatusamount
1c1completed40
2c2cancelled25
3c1completed60
4c3completed35
5c2completed50
6c1cancelled20

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

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.

About this lesson

This section is part of the Shuffles: Advanced lesson on DataDriven, the home of data engineering. Lessons cover SQL, Python, Spark, data modeling and pipeline architecture, and each section pairs its explanation with worked examples and practice you run as you go.