IntermediateSpark · 12 min · 14 challenges

Execution Model: Intermediate

It is 2 a.m. at Netflix, and the Spark job behind Monday's viewing-hours dashboard has run for 3 hours. The UI shows 1 stage stuck at 199 of 200 tasks, with 1 more stage queued behind it. Will the dashboard land by 6 a.m., or not until noon? Knowing how a job splits into stages and tasks answers that before anyone panics.

Job, Stage, Task

Daily Life
Interviews

3 nested units

Before any data moves, Spark cuts your program into 3 nested units. A job is the work 1 action asks for. A stage is a stretch of that job in which no row has to leave the task holding it. A task is 1 stage applied to 1 partition, and it is the only unit an executor ever runs.

UnitCreated byHow manyWhere the UI shows it
JobAn action: `collect()`, `count()`, a writeAt least 1 per actionJobs tab, 1 row each
StageA point where rows must regroup across partitionsBoundaries in the job, plus 1Stages tab, 1 row each
TaskA stage applied to 1 partitionThe stage's partition countThe task table inside a stage

The first row says "at least" because a few operations, orderBy among them, submit an extra job ahead of yours. To pick its value ranges, the sort samples the rows arriving at it, so that sampling job runs every stage upstream of the sort to produce those rows. Spark keeps the shuffle files that job writes, and your job then lists the stages already done as skipped instead of running them again. The stage counts in this section describe the plan of the job your action submits, and the Lazy Evaluation lessons trace the extra job in full.

Counting the stages in one program

Regrouping rows so that every key lands in 1 task is called a shuffle. The report below sums net revenue per region over 6 orders stored in 2 partitions, and each shuffle splits its output into 2 partitions. Before you press play, mark each call where you expect a stage to end.
(orders.filter(F.col("status") == "completed")
.withColumn("net", F.col("amount") - F.col("discount"))
.groupBy("region").agg(F.sum("net").alias("revenue"))
.orderBy(F.col("revenue").desc())
.collect())
orders6 rows
order_id
region
status
amount
discount
1
EU
completed
120
10
2
US
completed
80
0
3
US
cancelled
60
0
4
APAC
completed
90
15
5
EU
completed
40
5
6
US
completed
150
20
Preparing the animation
StageWhat each task doesTasksWhy the stage ends
0Reads its partition, drops rows that are not completed, computes `net`, keeps a running sum per region2, 1 per input partition`groupBy` needs all rows of a region in 1 task, and a region's rows can sit in any partition
1Adds up the partial sums it receives into final revenue per region2, 1 per shuffle partition`orderBy` needs rows split into revenue ranges, so each task can sort 1 range
2Sorts its range and sends its rows to the driver2, 1 per rangeThe job is done once `collect()` holds every row

That plan has 3 stages and 6 tasks for 1 action, and the driver ends up with 210 for US and 145 for EU, with APAC at 75. The Jobs tab shows 2 jobs, though. The sampling job runs stage 0, then 2 tasks that aggregate the regions and sample the result. The main job finds stage 0's shuffle files already written, lists that stage as skipped, and runs stages 1 and 2, so the per-region sums are computed twice and 8 tasks run in all. filter and withColumn never get a stage of their own. Each output row depends on 1 input row, so Spark runs both inside the task that reads the partition and builds the partial sums in the same pass over its rows. A stage ends only where a task would need rows another task holds.

Scale the input to 50GB and the shape stays the same while the numbers grow. Spark reads files in pieces of up to 128MB by default, so the read stage gets around 400 tasks, and it runs once, inside the sampling job. Every stage after a boundary, the sampling pass included, gets 200 tasks, the default value of spark.sql.shuffle.partitions.

1,000
tasks run across both jobs

These counts assume adaptive execution is off. Spark 3.2 and later turn it on by default and can merge small partitions mid-job, a behaviour with lessons of its own.

Multiple Choice

In the program below, which call ends the first stage, and which call submits the job?

(orders
.filter(F.col("profit") > 0)
.withColumn("profit_k", F.col("profit") / 1000)
.groupBy("status")
.agg(F.sum("profit_k").alias("profit_k"))
.collect())

Why a count() shows 2 stages

Run this on the practice data. It has no groupBy anywhere, only a filter and a new column before the count, yet the Stages tab lists 2 stages for it. Spark counts in 2 halves: each task counts the rows in its own partition, then those per-partition numbers move to a single partition, where 1 task adds them up. That move is a boundary like any other, so the second stage has exactly 1 task.

items = spark.table("order_items")
heavy = (items
    .filter(F.col("quantity") >= 2)
    .withColumn("line_total", F.col("quantity") * F.col("unit_price")))
heavy.count()

Predict before you edit: change the last line to heavy.groupBy("order_id").count().count() and run it. The job grows to 3 stages: the read, the per-order totals, and the single task that counts orders. The number returned changes from line items to orders holding at least 1 such line.

Multiple Choice

A job reads events as 120 partitions and runs the code below, with spark.sql.shuffle.partitions at 200 and adaptive execution off. How many stages and tasks does it run?

(events
.filter(F.col("country") == "BR")
.select("user_id", "event_type")
.groupBy("event_type")
.agg(F.count("*").alias("events"))
.collect())

Why Stages Exist

Daily Life
Interviews

A task sees only its own partition

Go back to stage 0 of the revenue report. Task 0 holds some orders and task 1 holds the rest, and either can hold US rows, so neither can produce the final US total alone. Spark settles this by ending the stage. Each task writes its partial sums to its executor's local disk, split into 1 piece per destination partition, and a fresh set of tasks fetches the pieces it owns from every executor. The Shuffles lessons take that write and fetch apart; this lesson needs only the timing it forces.

The barrier between stages

A stage 1 task that owns the US piece cannot know it has every US row until every stage 0 task has finished writing, since the last one to finish might hold US rows too. So Spark starts no task in a stage until all tasks of the stage it reads from have succeeded. A stage lasts as long as its slowest task, and everything downstream waits for it.
That is the 2 a.m. dashboard from the top of this lesson. With 199 of 200 tasks done, the next stage is waiting on 1 task and will wait 9 minutes as readily as 9 seconds. Adding executors at that point does not speed that task up, because 1 task runs on 1 core. A partition that received far more rows than its neighbours is the usual cause, and the Data Skew lessons deal with it.
✓Inside a stage
  • Each task carries its rows through every step back to back, in memory
  • Tasks never wait on each other; each finishes on its own schedule
  • A filter or a new column costs CPU time and nothing else
✗Across a boundary
  • Every task writes its output to local disk, split by destination partition
  • The next stage's tasks fetch their pieces from every executor over the network
  • The next stage launches only after the slowest task here has written its output
That disk and network work is why boundaries are worth counting. In the 50GB run, each of the 400 tasks in the first stage writes 1 output file holding 200 pieces, 1 for each task in the next stage, and every one of those 200 tasks fetches its piece from all 400 files.
80,000
shuffle pieces fetched at 1 boundary

What creates a boundary

Calls that need rows from other partitions
  • groupBy(...).agg(...): rows sharing a key must meet in 1 task.
  • distinct() and dropDuplicates(): 2 copies of a row can start in different partitions.
  • join of 2 large tables: matching keys must land in the same task. A join to a small table can avoid it, as the Joins lessons show.
  • orderBy: rows move into value ranges, so each task sorts 1 range and the ranges line up.
  • repartition(n) or repartition("col"): moving rows is the whole purpose of the call.
  • A window with partitionBy: each window's rows must sit in 1 task.

Everything else you have used so far, filter, select, withColumn, union, runs inside the stage already in progress.

TIP
Spark counts boundaries from the plan it runs. groupBy("region") right after repartition("region") adds no second boundary, since rows already sit grouped by region. And orderBy then limit(10), returned to the driver, runs as a top-N: each task keeps its own top 10, the driver merges them, and no range boundary appears.
Multiple Choice

With adaptive execution off, how many stages does this job run?

(orders
.repartition("status")
.groupBy("region")
.agg(F.sum("profit").alias("profit"))
.collect())

A job with 2 boundaries

This program on the practice data counts line items per order and sorts the orders by that count. Trace it before running: a groupBy boundary, then an orderBy boundary, so 3 stages. The rows come back in descending order across the whole result because each task in the last stage sorted 1 contiguous range of counts, and the driver lines the ranges up.

items = spark.table("order_items")
per_order = (items
    .filter(F.col("order_id").isNotNull())
    .groupBy("order_id")
    .agg(F.count("*").alias("lines"))
    .orderBy(F.col("lines").desc(), F.col("order_id")))
per_order.collect()

Now add .limit(5) to the end of the chain and run it again. The 5 rows match the top of the full result, yet that version runs 2 stages, for the top-N reason in the tip above.

Multiple Choice

Stage 3 of a job has 200 tasks. 199 of them finish within 2 minutes, and the last finishes at 11 minutes. Stage 4 reads stage 3's output. When does stage 4's first task start?

Reading Parallelism

Daily Life
Interviews

Where a stage's task count comes from

Every figure in the Tasks column of the Stages tab has a source you can name. A stage that reads files gets its partitions from the input: Spark packs file bytes into partitions no larger than spark.sql.files.maxPartitionBytes, which defaults to 128MB. Several small files can end up in 1 partition. A stage that starts after a boundary gets whatever count the boundary split the data into.

Stage begins withTask count comes fromDefault
Reading filesInput bytes divided into pieces of `spark.sql.files.maxPartitionBytes`128MB pieces
A `groupBy`, `distinct` or join boundary`spark.sql.shuffle.partitions`200
An `orderBy` boundary`spark.sql.shuffle.partitions`, as value ranges200
`repartition(n)`The `n` you passedNo default
The next animation reads 9 page hits as 3 partitions, and its first line sets the regrouped side to 4 partitions. Count the tasks in each stage, and watch what each task of the second stage receives.
spark.conf.set("spark.sql.shuffle.partitions", 4)
(page_hits.groupBy("device")
.agg(F.count("*").alias("hits"))
.collect())
page_hits9 rows
hit_id
device
1
mobile
2
desktop
3
mobile
4
tv
5
mobile
6
desktop
7
tv
8
mobile
9
desktop
Preparing the animation

The first stage runs 3 tasks because the input has 3 partitions. The second runs 4 because the setting says 4, even though only 3 devices exist, so at least 1 of its tasks receives no rows: it gets scheduled and finishes without finding anything. The empty task leaves the result alone. Mobile comes back with 4 hits and desktop with 3, and tv has the remaining 2. Spark launches a task for every partition whether it holds data, so a groupBy over 12 distinct keys at the default of 200 leaves at least 188 tasks with no rows to process.

Tasks against slots

A stage's task count says how much work is queued. Your slots, 1 per executor core, say how much runs at once. With 10 executors of 4 cores, 40 tasks run together and the rest wait for a free slot. Each batch is a wave, and when tasks take similar time, a stage lasts about its wave count times 1 task's duration.
StageTasksSlotsWavesBusy slots in the last wave
Read 50GB400401040 of 40
After a `groupBy`20040540 of 40
After `repartition(50)`5040210 of 40
After `repartition(8)`84018 of 40
The bottom 2 rows are the ones to worry about. At 50 tasks, the second wave runs 10 tasks while 30 slots idle, so the stage takes 2 task-durations for 1.25 waves of work. At 8 tasks, 32 of the 40 cores do nothing for the whole stage, and adding machines can't help until the task count rises.
Multiple Choice

A cluster has 8 executors with 5 cores each. A stage has 100 tasks that each take roughly 30 seconds. Roughly how long does the stage take?

Reading it off the Stages tab

3 readings to take on any slow stage
  • Total tasks under the slot count. The stage leaves cores idle by construction. It needs more partitions, and more machines would sit idle too.
  • Exactly 200 tasks after a boundary. Nobody set spark.sql.shuffle.partitions, and a count that suits 2GB of regrouped data fits 2TB badly.
  • Thousands of tasks lasting tens of milliseconds. Launching and tracking each task costs the driver time, and here that cost rivals the work itself.
Put the readings to work on a single row of the tab: a stage after a groupBy shows 200/200 tasks on a cluster with 40 slots, and its median task took 4 minutes. The count of exactly 200 says the setting was never touched. That is 5 waves of 4 minutes, which puts the stage near 20 minutes. Raising the count to 800 would cut each task to around 1 minute, but 800 tasks is 20 waves, still about 20 minutes, because the work was already spread over all 40 slots. With evenly split data, only more slots shorten this stage; more partitions change how much each task holds in memory.

If a stage after a boundary shows fewer than 200 tasks, the runtime merging noted earlier is the likely reason: in Spark 3.2 and later, small partitions are combined after Spark measures them.

Where the Driver Lives

Daily Life
Interviews

Client mode and cluster mode

The driver is the process that splits your job into stages, hands tasks to executors and gathers results, so where it runs decides what survives a disconnect and where results land. Spark offers 2 places for it, chosen by the deploy mode.

In client mode, the default, the driver runs inside the spark-submit process on the machine you launched from: your laptop, a shared gateway box, a notebook server. In cluster mode, spark-submit asks the cluster manager to start the driver on one of the cluster's own nodes, and the driver runs there from then on while spark-submit only reports on it.

Outside the cluster
Inside the cluster
spark-submit and driver
Launch machine
grants executors
Cluster manager
4 slots
Executor 1
4 slots
Executor 2

Client mode: the driver sits outside the cluster, so task launches and status updates cross the link between your machine and the executors, and so does every collect() result.

Outside the cluster
Inside the cluster
spark-submit only
Launch machine
starts the driver
Cluster manager
driver
Driver node
4 slots
Executor 1
4 slots
Executor 2

Cluster mode: spark-submit hands the application to the cluster manager, which starts the driver on a cluster node next to its executors.

QuestionClient modeCluster mode
Where the driver runsThe machine that ran spark-submitA node the cluster manager picks
Where `collect()` rows landYour machine's memoryThat node's memory
Where `print()` output showsYour terminal or notebookThe driver's log on that node
If the launch machine shuts downThe driver dies and the job is lostThe job keeps running
Typical useShells and notebooks, which need a driver where you typeScheduled and long-running production jobs
Multiple Choice

You run a 40-minute job from a notebook on your laptop, so the driver runs in client mode. At minute 25 the laptop's battery dies. What happens to the job?

The link the driver talks over

Client mode's cost is distance. The driver sends a message to launch every task and receives 1 back when it finishes, so a 400-task stage puts at least 800 messages on the link between your machine and the cluster. A collect() that returns 2GB crosses the same link: over a 50 megabit-per-second VPN that is more than 5 minutes of transfer, where a driver inside the cluster's network would take seconds.

A third arrangement runs on a single machine: --master local[4] puts the driver and 4 task threads in 1 process, so your slots are 4 threads, and shuffle pieces are written and read on the same disk with no network between them.

Checking from inside the program

A running application can tell you which arrangement it got. The property spark.submit.deployMode holds client or cluster, and spark.master holds the master URL.

conf = spark.sparkContext.getConf()
print(conf.get("spark.submit.deployMode"), conf.get("spark.master"))
✓Do
  • Run scheduled production jobs in cluster mode, so a launcher reboot can't end them
  • Use client mode for shells and notebooks, where results must reach your screen
  • Launch client-mode work from a machine on the cluster's network, close to the executors
  • Look for a cluster-mode job's output in the driver log on its node
✗Don't
  • Don't collect() a large result to a laptop driver; write it to storage instead
  • Don't run nightly jobs in client mode from a laptop
  • Don't expect a cluster-mode driver's print() to reach your terminal
Multiple Choice

A nightly job ends with print(report.collect()) and is launched with --deploy-mode cluster from a scheduler machine. Where do the rows end up?

The spark-submit Config Surface

Daily Life
Interviews

Every flag is a property

Each named flag of spark-submit is a shortcut for a Spark property, one of the spark.* keys that the Environment tab of the Spark UI lists for a running application. A property with no shortcut goes through --conf key=value, which you can repeat.

FlagProperty it setsDefault
`--master``spark.master``local[*]`
`--deploy-mode``spark.submit.deployMode``client`
`--driver-memory``spark.driver.memory``1g`
`--executor-memory``spark.executor.memory``1g`
`--executor-cores``spark.executor.cores`Depends on the cluster manager
`--num-executors``spark.executor.instances`Depends on the cluster manager
`--conf k=v`Any property `k`Whatever `k` defaults to

Which value wins

One property can be set in 3 places, and Spark settles them in a fixed order. A value set in code on the session builder beats a spark-submit flag or --conf, and a flag beats the cluster's spark-defaults.conf file. Here is 1 job's settings traced through all 3.

Propertyspark-defaults.confspark-submitSession builderJob runs with
`spark.executor.memory`4g8gnot set8g
`spark.executor.cores`4not setnot set4
`spark.executor.instances`510not set10
`spark.sql.shuffle.partitions`200400800800
So the job gets 10 executors of 4 cores, which is 40 slots, and every stage after a boundary runs 800 tasks, which is 20 waves.

Read once at startup, or per query

Precedence decides which value Spark reads; the other question is when Spark reads it. Spark reads executor memory and cores, along with the executor count, when executors launch, and every executor in an application launches with the values the application started with. Most properties under spark.sql., including spark.sql.shuffle.partitions, are read each time a query is planned, so a new value applies from the next action on.

spark.conf.set("spark.sql.shuffle.partitions", 400)
daily = events.groupBy("event_date").count()
daily.collect()
spark.conf.set("spark.executor.memory", "8g")

The session builder fails more quietly. In a notebook where a session already exists, SparkSession.builder.config("spark.executor.memory", "8g").getOrCreate() hands back that existing session, and its executors keep the memory they started with.

Driver memory follows the same rule, with a twist from the previous section. In client mode the driver is the very process running your code, so Spark's documentation says not to set spark.driver.memory in code there: by then the driver's JVM has already started. Pass --driver-memory or put it in spark-defaults.conf. In client mode that heap also comes from the machine you launched on, so a laptop with 16GB of RAM can't host a 24g driver.

✓Read once, at startup
  • spark.executor.memory
  • spark.executor.cores
  • spark.executor.instances
  • spark.driver.memory
  • spark.submit.deployMode
✗Read per query
  • spark.sql.shuffle.partitions
  • spark.sql.files.maxPartitionBytes
  • Most other spark.sql. properties

Checking what applied

When a job behaves as if a setting never landed, open the Environment tab: it lists every property the application started with and the value that won. A value changed later with spark.conf.set lives in the session and doesn't appear there, so check that one through its effect, such as the task count of the first stage after the next boundary.

Multiple Choice

spark-defaults.conf sets spark.sql.shuffle.partitions to 200, spark-submit passes --conf spark.sql.shuffle.partitions=300, and the session builder sets 600. Right before the only action, the code runs spark.conf.set("spark.sql.shuffle.partitions", 100). With adaptive execution off, how many tasks run in the stage after the groupBy?

❯❯❯PUTTING IT ALL TOGETHER

> Back at Netflix, the viewing-hours job reads 50GB of play events, groups them by title and country, sorts by hours watched, and writes the result for the dashboard. A scheduler launches it with --deploy-mode cluster, --num-executors 10 and --executor-cores 4, adaptive execution is switched off in its configuration, and spark.sql.shuffle.partitions is left at 200. Tonight 1 stage sits at 199 of 200 tasks while the 6 a.m. deadline approaches.

The write is the action that submits the job. The groupBy and the sort make 2 boundaries, so the main job's plan has 3 stages. The sort also adds a sampling job ahead of it, and that job is where the read stage runs, leaving it skipped in the main job.
The read stage runs about 400 tasks over 50GB at 128MB each, and every stage after a boundary runs 200 tasks, in 5 waves on 40 slots, because adaptive execution is off and can't merge partitions.
The stuck stage is the one after the groupBy, and the sort stage can't launch a task until that last task writes its output.
Cluster mode means the scheduler machine can reboot without ending the job, and the driver log on the cluster node holds whatever the job prints.
To change the post-boundary task count tomorrow, set spark.sql.shuffle.partitions with --conf or spark.conf.set. Adding slots means changing --num-executors or --executor-cores, and those take effect only at the next launch.
KEY TAKEAWAYS
An action submits a job, boundaries where rows regroup by key split it into stages, and each stage runs 1 task per partition.
Narrow steps run inside the task already holding the rows and never add a stage; filter is one, and so are select and withColumn.
No task of a stage starts until every task of the stage it reads from has written its output, so a stage lasts as long as its slowest task.
A file-reading stage gets about 1 task per 128MB of input, and a stage after a boundary gets spark.sql.shuffle.partitions tasks, 200 by default.
Tasks divided by slots gives waves, and a task count below the slot count leaves cores idle however many machines you add.
Client mode puts the driver on the launch machine and cluster mode puts it on a cluster node, which decides where collect() lands and what survives a shutdown.
Code beats spark-submit flags, which beat spark-defaults.conf; executor resources are fixed at startup, while spark.sql. settings apply per query.

Read a Spark job the way the scheduler does: jobs from actions, stages from boundaries, tasks from partitions.

Category
Spark
Difficulty
intermediate
Duration
12 minutes
Challenges
14 hands-on challenges

Topics covered: Job, Stage, Task, Why Stages Exist, Reading Parallelism, Where the Driver Lives, The spark-submit Config Surface

Lesson Sections

  1. Job, Stage, Task (concepts: Spark Execution Model)

    3 nested units Counting the stages in one program Regrouping rows so that every key lands in 1 task is called a shuffle. The report below sums net revenue per region over 6 orders stored in 2 partitions, and each shuffle splits its output into 2 partitions. Before you press play, mark each call where you expect a stage to end. These counts assume adaptive execution is off. Spark 3.2 and later turn it on by default and can merge small partitions mid-job, a behaviour with lessons of its own. Why a

  2. Why Stages Exist (concepts: Spark Execution Model)

    A task sees only its own partition Go back to stage 0 of the revenue report. Task 0 holds some orders and task 1 holds the rest, and either can hold US rows, so neither can produce the final US total alone. Spark settles this by ending the stage. Each task writes its partial sums to its executor's local disk, split into 1 piece per destination partition, and a fresh set of tasks fetches the pieces it owns from every executor. The Shuffles lessons take that write and fetch apart; this lesson need

  3. Reading Parallelism

    Where a stage's task count comes from The next animation reads 9 page hits as 3 partitions, and its first line sets the regrouped side to 4 partitions. Count the tasks in each stage, and watch what each task of the second stage receives. Tasks against slots A stage's task count says how much work is queued. Your slots, 1 per executor core, say how much runs at once. With 10 executors of 4 cores, 40 tasks run together and the rest wait for a free slot. Each batch is a wave, and when tasks take si

  4. Where the Driver Lives

    Client mode and cluster mode The driver is the process that splits your job into stages, hands tasks to executors and gathers results, so where it runs decides what survives a disconnect and where results land. Spark offers 2 places for it, chosen by the deploy mode. The link the driver talks over Checking from inside the program

  5. The spark-submit Config Surface (concepts: Configuration Tuning)

    Every flag is a property Which value wins So the job gets 10 executors of 4 cores, which is 40 slots, and every stage after a boundary runs 800 tasks, which is 20 waves. Read once at startup, or per query Checking what applied