The Architecture Recital
What you will be able to do
One question, 5 phrasings
| How it is phrased | What it probes | The figure that answers it |
|---|---|---|
| "What happens, step by step, when this job runs?" | Whether you can find the shuffle boundaries in the code | Stages in the plan and tasks per stage |
| "What is the difference between a transformation and an action?" | Whether you know nothing runs until the action, and what the action sets off | Shuffles in the plan the action triggers |
| "This job got slower when the input doubled. Where would you look?" | Whether you know which stage scales with data volume | Shuffle Write per stage |
| "Explain the driver and the executors." | Whether you know what lives where and what crosses the network | Bytes collected to the driver, bytes moved between executors |
| "Why does this join take 40 minutes?" | Whether you check the join strategy before tuning anything else | The smaller side's size against the 10MB threshold |
What the probe listens for
- The driver turns the code into a plan when an action runs
- The plan splits into stages at each shuffle
- Each stage runs a task per partition
- Executors run the tasks and keep shuffle files on local disk
- Which line of code causes each shuffle, and which stage moves the most bytes
- How many tasks run, from file size and the 200 shuffle default
- Whether the join broadcasts or shuffles both inputs, and the size that decided it
- Which Spark UI metric would confirm or refute each guess
- ▸Where in this code does data cross the network?
- ▸How many partitions does each stage run with?
- ▸Which number would I quote, and where would I read it in the UI?
A worked opener
Count the data movement before naming anything. At about 6MB, cities sits under the 10MB spark.sql.autoBroadcastJoinThreshold, so Spark broadcasts it: the driver collects the table and a copy reaches every executor, while trips never moves for the join. The groupBy adds a hash shuffle on city_name. The orderBy adds a range shuffle on revenue. Two shuffles split the write into 3 stages.
| Stage | What runs in it | Tasks |
|---|---|---|
| 1 | Scan trips, filter, broadcast hash join, partial sum per city | About 4000, a task per 128MB split |
| 2 | Final sum per city, then the write for the range shuffle | 200 planned by the shuffle default. Adaptive execution, on by default since Spark 3.2, coalesces these tiny partitions, so the UI shows far fewer tasks |
| 3 | Sort by revenue, write the report | Up to 200 planned, a task per range, coalesced the same way |
The 6MB figure is Spark's size estimate from file statistics. A filter or an explode on the small side can make the real size differ from the estimate, which is another reason the plan check comes before any tuning.
Component order against stage order
- The driver creates a SparkSession and requests resources
- The cluster manager launches executors
- The DAG scheduler splits the work into stages
- Executors run tasks and return results
- Plan: the write triggers a physical plan with a broadcast join and 2 shuffles
- Stage 1: about 4000 scan tasks filter, join and pre-sum, then write shuffle files
- Stage 2: 200 reducers fetch those files, finish the sum, write range output
- Stage 3: sorted ranges are written out as the report
The March job laid out by stage. Each storage box is a shuffle boundary: tasks on its left write grouped files to local disk, and tasks on its right fetch them over the network.
Where the boundaries fall
| Operation | Plan operators | New stage? |
|---|---|---|
| filter, select, withColumn | Filter, Project | No |
| groupBy then agg | HashAggregate around Exchange hashpartitioning | Yes, a hash shuffle on the keys |
| join, small side under 10MB | BroadcastExchange, BroadcastHashJoin | No, the large side stays in place |
| join, both sides over 10MB | Exchange under each side, SortMergeJoin | Yes, both inputs shuffle |
| orderBy | Exchange rangepartitioning, Sort | Yes, a range shuffle |
| groupBy after repartition on the same key | HashAggregate reusing the partitioning | No second shuffle |
In the SQL / DataFrame tab, pipelined operators sit inside boxes labelled WholeStageCodegen with a number, and the Exchange nodes sit between those boxes, so the stage boundaries are visible in the graph.
Watch a boundary form
city_totals = (trips.filter("fare > 5").groupBy("city").sum("fare"))city_totals.collect()
The spoken shape
- Planwhat the action triggersThe write or collect makes the driver build the physical plan. Name its shuffle exchanges and the join strategy it chose.
- Stagesa stage per stretch between exchangesCount them out loud as shuffles plus 1. Say which stage reads the most bytes and which writes the most.
- Taskspartitions decide parallelismInput splits of 128MB set the scan's task count. The 200 shuffle partitions are the planned count after each exchange, which adaptive execution may merge when they turn out small. Divide by cores to get waves.
- Shuffle and resultwhat crosses the networkSay roughly how many bytes cross each boundary, and whether the result returns to the driver or lands in storage.
The 3 defaults that size a job
| Setting | Default | What it sizes | Cost when it is wrong |
|---|---|---|---|
| spark.sql.files.maxPartitionBytes | 128MB | Tasks in each file scan | Too small: thousands of short tasks and scheduling overhead. Too large: fewer, longer tasks and more memory per task |
| spark.sql.shuffle.partitions | 200 | Tasks in every stage after a shuffle | Too few: gigabytes per task and spill. Too many: millions of tiny shuffle blocks |
| spark.sql.autoBroadcastJoinThreshold | 10MB | Whether a join shuffles at all | Below it: the small side is copied to every executor. Above it: both sides shuffle and sort |
One job, worked end to end
| Quantity | Arithmetic | Result |
|---|---|---|
| Scan tasks | 500GB / 128MB | 4000 |
| Task slots | 50 executors x 4 cores | 200 |
| Scan waves | 4000 / 200 | 20 |
| Data per reducer at 200 | 400GB / 200 | 2GB |
| Data per reducer at 2000 | 400GB / 2000 | about 200MB |
| Shuffle blocks at 2000 | 4000 x 2000 | 8,000,000 |
| Average block at 2000 | 400GB / 8,000,000 | about 52KB |
Read the table as 2 problems trading against each other. At the default, each reducer sorts 2GB for the window, which will likely spill to disk unless every task has well over 2GB of execution memory. Raising spark.sql.shuffle.partitions to 2000 brings each reducer near 200MB, a size that usually sorts in memory, but multiplies the shuffle into 8 million blocks averaging 52KB, and many small remote reads move fewer bytes per second than a few large ones. A common target is 100MB to 200MB per shuffle partition, and the interviewer wants to hear that both ends of the dial cost something.
- ▸500GB at 128MB a split is about 4000 tasks; on 200 cores that is 20 waves.
- ▸400GB over 200 reducers is 2GB a task, which will spill during the sort.
- ▸2000 partitions gets that near 200MB, at the price of 8 million shuffle blocks.
- ▸Cities is about 6MB, under the 10MB threshold, so the join broadcasts.
The join decision outweighs the rest
The answer that stalls
Three gaps, three fixes
| Gap | What the recital says | What it should say |
|---|---|---|
| Location | "splits it into stages" | "the join broadcasts at 6MB; the groupBy and the orderBy each add a shuffle, so 3 stages" |
| Size | "sends tasks to the executors" | "about 4000 scan tasks, 200 planned for each later stage, 20 waves on 200 cores" |
| Check | nothing | "I'd confirm BroadcastHashJoin and the 2 exchanges in the SQL / DataFrame tab" |
The repaired answer
Repair habits
- Open by counting the exchanges in the code on the screen
- Give every stage a task count, even a rough one
- Name the join strategy and the size that decided it
- Close with the Spark UI metric you would check first
- List components in startup order with no tie to the code
- Spend a sentence each on the DAG and task schedulers unprompted
- Say "it depends" without naming what it depends on
- Quote a number you could not derive if asked
Try it
One version: "The window partitions by rider_id, so every scanned row crosses a hash shuffle, about 400GB from 4000 map tasks. At the 200 default that is 2GB per reducer, so I'd raise shuffle partitions toward 2000 and check Spill (Disk) on the window stage."
Follow a byte through the shuffle
| Step | What it costs | Where the Spark UI shows it |
|---|---|---|
| Map-side write | Each row is serialized, grouped by destination partition, compressed with LZ4 and written to local disk with an index file | Shuffle Write Size / Records on the map stage |
| Barrier | The reduce stage starts only after every map task has finished, so the slowest map task sets the start time | The stage page's Event Timeline, a long last task |
| Fetch | Each reducer pulls its block from every map output over the network, up to map tasks times reducers in total | Shuffle Read Blocked Time, Shuffle Remote Reads |
| Merge | Blocks are decompressed, deserialized, then sorted or hashed; whatever does not fit in memory spills | Spill (Memory), Spill (Disk) |
| Skew | A hot key sends its whole volume to a single reducer | Max far above Median in Summary Metrics |
| Lost output | Shuffle files live on the machine that wrote them; losing them reruns the map tasks that produced them | FetchFailed in a task error, the map stage listed again as a retry |
Put figures on it
The 30-second answer
See both inputs move
spark.conf.set("spark.sql.autoBroadcastJoinThreshold", -1)joined = trips.join(cities, "city_id")joined.collect()
Delete the spark.conf.set line and the plan changes shape: cities, a few hundred bytes against a 10MB threshold, becomes a BroadcastExchange, and trips is joined inside its own scan tasks without crossing the network. That contrast is the strongest answer to "how would you cut shuffle cost?": the cheapest shuffle is the one the plan never contains.
When they push further
> A rides analytics team runs a nightly job that numbers every rider's March trips with a window on rider_id, joins the result to a 6MB cities dimension and aggregates revenue per city. Trip volume has grown: the scan now reads 500GB, Shuffle Write on the window stage reads about 400GB, and the job has gone from 25 minutes to 90. The on-call engineer has to explain the slowdown to the team before proposing a fix.
Turn the Spark architecture walk-through into an answer that pairs every stage with what it costs and a number.
- Category
- Spark
- Difficulty
- intermediate
- Duration
- 15 minutes
- Challenges
- 4 hands-on challenges
Topics covered: A Depth Probe, Answer in Stages, Mechanics to Cost, With a Number, Giving the Junior Answer, They Ask Why Shuffles Are Costly
Lesson Sections
- A Depth Probe (concepts: Spark Execution Model)
The walk-through question shows up in Spark interviews under many phrasings, and by now the interviewer assumes you can name the driver, the executors and the cluster manager. The question underneath is about weight: which steps move data, how many bytes, and what that movement does to the wall clock. A list of components answers none of that. The answers that pass this probe attach a cost to each step before the interviewer has to ask for it. One question, 5 phrasings The way to recognize the p
- Answer in Stages (concepts: Shuffle Optimization)
An answer ordered by component (driver, then cluster manager, then executors) describes the org chart of a Spark application. An answer ordered by stage describes the job's timeline, and the timeline is where the cost lives, because every stage boundary is a point where data is written, moved and read again. Reordering the same facts around stages is the change the rest of this lesson builds on. Component order against stage order The stage-ordered version still mentions the driver and the execu
- Mechanics to Cost, With a Number (concepts: Shuffle Optimization)
An interviewer asking for a number wants an estimate built from defaults you can defend, with the arithmetic visible so they can correct an input instead of rejecting the answer. Three settings supply most of those numbers, and all 3 are defaults you can quote without a cluster in front of you. The 3 defaults that size a job Each default converts a mechanism into a count. File size over 128MB gives the scan's tasks. The shuffle default is the number of partitions Spark plans after every shuffle,
- Giving the Junior Answer (concepts: Shuffle Optimization)
A common way to fail this probe is with an answer that is complete and correct. It names every component, puts them in startup order and contains no mistake to point at. What it lacks is any statement that could turn out wrong: no location, no count, no choice. With nothing falsifiable on the table, the interviewer pushes for numbers, and you end up producing them under questioning instead of leading with them. The answer that stalls Every sentence there is accurate, and it fits any Spark job ev
- They Ask Why Shuffles Are Costly (concepts: Shuffle Optimization)
Once the walk-through has placed the shuffles, the next question is close to predictable: "why are shuffles so expensive?" The thin answer is "because data goes over the network", which names 1 cost out of several and gives no way to tell which dominates. The answer the interviewer wants follows a shuffled byte from the task that produces it to the task that consumes it, names the cost at each step, and says where in the Spark UI each cost shows up. Follow a byte through the shuffle The costs fa