AdvancedSpark · 14 min · 7 challenges

Execution Model: Advanced

At 3 a.m. at Meta, a feature-ranking job has sat at 99% for 40 minutes: 2 of its 30,000 tasks are still running, 1 executor keeps throwing the same error, and the driver's CPU is pinned. Adding executors, killing the job or changing a single setting are all on the table, and which one works depends on which part of Spark's scheduling is holding the job up.

DAGScheduler vs TaskScheduler

Daily Life
Interviews
An action hands its job to the driver, and 2 schedulers inside the driver divide the work between them. The DAGScheduler thinks in stages: it cuts the job at every stage boundary, works out which stages read from which, and releases a stage only when every stage it reads from has finished. The TaskScheduler thinks in tasks: it takes a released stage as a set of tasks, 1 per partition, and matches each task to a free core on an executor. Neither does the other's job, and most of this lesson follows from where that line falls.
✓DAGScheduler
  • Cuts the job into stages by walking back from the action's result to each stage boundary
  • Submits a stage once every parent stage has finished
  • Turns a stage into a task set: 1 task per partition still to compute
  • Records which executor holds each finished task's output
  • Reruns earlier-stage tasks whose output a later stage can no longer read
✗TaskScheduler
  • Receives task sets and knows nothing of the stages behind them
  • Matches each task to a free core, preferring executors near its data
  • Retries a failed task until it succeeds or runs out of attempts
  • Launches backup copies of slow tasks when speculation is on
  • Shares cores among task sets from concurrent jobs, first in first out by default

One job through both schedulers

The program below sums ride fares per city over a table of 9 rides read as 3 partitions. A groupBy needs every row for a city on the same task, so the DAGScheduler cuts the job in 2. Stage 0 runs 3 tasks that read the partitions and prepare their rows for regrouping by city; stage 1 runs 3 tasks that each gather their cities' rows and sum them. Watch the order: no stage 1 task starts until all 3 stage 0 tasks have reported success.

from pyspark.sql import functions as F
totals = rides.groupBy("city").agg(F.sum("fare").alias("total_fare"))
totals.collect()
rides9 rows
ride_id
city
fare
1
SF
18.5
2
NYC
22
3
LA
15
4
SF
9.5
5
LA
31
6
NYC
12.5
7
SF
27
8
NYC
8
9
LA
19.5
Preparing the animation

Count the work: 6 tasks in 2 stages, 1 job, with adaptive query execution off as in the animation. The DAGScheduler built both task sets and decided their order, and it never chose an executor. The TaskScheduler placed all 6 tasks and would have retried any that failed, and it never knew they belonged to a groupBy. When the last task of stage 1 succeeded, the DAGScheduler marked the job done and collect() returned the 3 city totals. Adaptive query execution is on in a default session since Spark 3.2, so the Jobs tab lists stage 0 as a job of its own, then as skipped in the job that returns the totals, and the 3 tiny reduce partitions may run as 1 task.

collect()
Action
stages and task sets
DAGScheduler
cores
TaskScheduler
retriescopies
free executor cores
Scheduler backend
run task attempts
Executors

A job flows left to right: stages, then task sets, then task attempts on cores. Status updates flow back along the same path, and the TaskScheduler handles each task's own failures before anything reaches the DAGScheduler.

Which scheduler answers which question

Question during a runAnswered byWhat it uses
Which stages does this job have?DAGSchedulerStage boundaries in the job's plan
Can stage 2 start yet?DAGSchedulerWhether every parent stage has finished
How many tasks does stage 2 get?DAGSchedulerStage 2's partitions not yet computed
Which executor runs task 7?TaskSchedulerFree cores and where task 7's input lives
Task 7 threw; run it again?TaskSchedulerAttempts task 7 has used so far
Task 7 cannot read the previous stage's outputDAGSchedulerIts record of where each stage left its output
Which scheduler handles a failure depends on how far the damage reaches. A problem that stays inside 1 task, such as an exception or a slow machine, belongs to the TaskScheduler: the DAGScheduler is told as each task ends but acts only when the task set finishes or gives up. A problem that destroys output a later stage needs goes to the DAGScheduler, because only it knows which earlier stage produced that output and can run those tasks again. The next 2 sections follow each path.
TIP
In the Spark UI, the Jobs tab and a job's DAG visualization show the DAGScheduler's view: stages and the order between them. A stage's detail page shows the TaskScheduler's view: a row per task attempt, with its executor, locality level and status.
Multiple Choice

Stage 3 has 400 tasks. Task 211 throws an exception on its first attempt while the others keep running. What does Spark do next?

Task Failure and Retry

Daily Life
Interviews

When a task throws, its executor reports the failure and the exception to the driver. The TaskScheduler keeps a manager for each task set, and that manager records the failure against the partition and puts the task back among the pending ones, so the next free core runs it as a new attempt. Each partition gets spark.task.maxFailures attempts, 4 by default. Once a single partition fails that many times, the manager gives up, the DAGScheduler aborts the stage, and the job fails.

The limit counts per partition. If 300 different tasks each fail once, the job still succeeds, provided each later attempt succeeds. Local mode allows 1 attempt unless the master is written as local[4,3], where the 3 sets the attempt limit, so a laptop test fails on the first error a cluster would retry.

Here partition 3 of stage 2 hits a malformed date near the end of its input:
AttemptIn the error messageExecutorResult
1st3.0exec 7Failed: malformed date in row 48,112
2nd3.1exec 7Failed: same error
3rd3.2exec 12Failed: same error
4th3.3exec 3Failed: same error, job aborted

The driver then logs Task 3 in stage 2.0 failed 4 times, most recent failure: Lost task 3.3 in stage 2.0. In stage 2.0, the 0 is the stage's first attempt; in task 3.3, the second 3 is the attempt number, counted from 0. Identical errors on 3 executors point at the data or the code, and a 5th attempt would fail the same way.

4 failures, 4 different paths

What went wrongHandled byCounts toward the 4?What runs again
Your code throws on a rowTaskSchedulerYesThat task, as a new attempt
Executor killed for exceeding its memory limitTaskSchedulerYesEvery task that was running on it
Executor removed for reasons outside the app, such as decommissioningTaskSchedulerNoEvery task that was running on it
A task cannot fetch output from the previous stageDAGSchedulerNoThe lost earlier tasks, then the stage
//

A failure that crosses a stage boundary

Stage 2's tasks read the output that stage 1's tasks left on their executors. Suppose executor 4 ran 12 of stage 1's 100 tasks and dies while stage 2 is running. The first stage 2 task that asks executor 4 for its share gets a fetch failure. Another attempt of that task would ask for the same missing output, so the attempt is not counted and the failure goes up to the DAGScheduler. It marks the 12 outputs on executor 4 as lost, resubmits stage 1 with only those 12 tasks, and when they finish, resubmits stage 2 for its unfinished partitions. The Stages tab lists the new attempts as 1 (retry 1) and 2 (retry 1). A stage gets spark.stage.maxConsecutiveAttempts tries, 4 by default, before the job aborts.

What a retry costs

A retry starts its partition from the first row; nothing a failed attempt computed survives. Take a task that normally runs 10 minutes and dies at minute 9 on a bad record near the end of its input. Each attempt holds a core for 9 minutes, so the 4th failure lands around 36 minutes after the first attempt began, assuming every retry finds a free core at once. The rest of the stage may have finished long before, leaving the cluster mostly idle while it waits for an abort.

By default a retry may land on the executor where it failed, since the TaskScheduler still prefers the executor next to the data. If that machine has a bad disk, the retry fails again for a reason unrelated to your code. With spark.excludeOnFailure.enabled set to true (it is off by default), Spark stops retrying a task on an executor where it already failed, and excludes an executor or a node from the rest of the stage after repeated failures there.

✓Do
  • Read the error under the last attempt before touching any retry setting
  • Treat identical failures on several executors as a data or code bug
  • Enable excludeOnFailure when failures cluster on 1 machine
  • Test locally knowing each task gets 1 attempt there
✗Don't
  • Raise spark.task.maxFailures to push a deterministic error through
  • Blame your code for a stage retry before checking for a lost executor
  • Ignore repeated task retries in a job that eventually succeeds
Multiple Choice

The Stages tab shows stage 5 twice, the second time as 5 (retry 1). Stage 4 also has a retry, with 9 tasks, although every task of its first attempt succeeded. What happened?

Speculative Execution

Daily Life
Interviews
A stage ends when its last task ends. If 199 of 200 tasks take 40 seconds and 1 lands on a machine with a failing disk, the stage lasts as long as that task, and every stage after it starts late. Speculative execution lets the TaskScheduler hedge: when a running task falls far behind its finished siblings, it launches a second attempt of the same partition on a different host, keeps whichever finishes first, and kills the other.
SettingSpark 3.5 defaultWhat it controls
spark.speculationfalseWhether any copies are launched
spark.speculation.interval100msHow often the TaskScheduler checks for slow tasks
spark.speculation.quantile0.75Share of a stage's tasks that must finish before checking starts
spark.speculation.multiplier1.5How many times the median finished duration a task must exceed
spark.speculation.minTaskRuntime100msMinimum runtime before a task can be copied

Defaults move between releases, so check the configuration page for the version you run before relying on these numbers.

The rule applied to one stage

Stage 7 has 200 tasks, with speculation on and the defaults above. Nothing is checked until 150 tasks, 75% of 200, have finished. At that point the median duration of the finished tasks is 40 seconds, so the bar is 1.5 x 40 = 60 seconds, and every 100ms the TaskScheduler compares each running task's elapsed time with it.
TaskRunning forPast 60 s?Decision
11795 sYesCopy queued for a core on another host
4461 sYesCopy queued for a core on another host
18258 sNoChecked again in 100ms
941 sNoChecked again in 100ms

Task 117's original had read half its partition after 95 seconds, because its host's disk was serving reads slowly. Its copy, on a healthy executor, runs for 41 seconds and finishes at 136 seconds. The TaskScheduler marks partition 117 done and kills the original with the reason another attempt succeeded. Left alone, the original would have needed 190 seconds, so the copy saved 54.

Slow tasks a copy cannot fix

✓A copy finishes sooner when
  • The original's host has a failing disk or a saturated network link
  • Another process on that node is starving the executor of CPU
  • The slow task read about as much input as its siblings
✗A copy wastes a core when
  • The slow task's partition holds far more rows, so the copy repeats the same long read
  • The cluster has no idle cores, so copies queue behind pending tasks
  • The task writes to an outside system, which then receives every write twice
Check partition size first. A task 6 times slower than the median because its partition holds 6 times the rows gets a copy that reads those same rows, finishes no sooner, and holds a second core the whole time. On the stage page, compare the slow task's input size with the median input: a match makes the machine the suspect, and far more input means speculation cannot fix it.
Copies also run the same code twice at once. Spark's built-in file writers let only 1 attempt of each task commit its files, so tables Spark writes stay correct. Code inside a task that sends rows to an outside service, such as an HTTP call per row, runs in both attempts and sends everything twice.
TIP
In the task table, a copy appears as a second attempt of the same task index, flagged as speculative. The losing attempt ends as KILLED with the reason another attempt succeeded. Many killed copies and no shorter stages means speculation is spending cores and saving nothing.
Multiple Choice

Speculation is on with multiplier 1.5 and quantile 0.75. A stage has 80 tasks, 60 have finished, and their median duration is 20 s. Task 33 has run for 27 s and task 51 for 34 s. Which get a copy now?

The Driver as Bottleneck

Daily Life
Interviews
Everything in the last 3 sections runs in 1 process. The DAGScheduler handles its events one at a time on a single thread, and the TaskScheduler shares the driver's JVM, tracking every executor and every task attempt. Executors scale out and the driver does not, so a job can add 500 cores and still be capped by how fast a single machine schedules, tracks and receives the work those cores do.
Builds and serializes every task before sending it to an executor
Builds and serializes every task before sending it to an executor
Takes a status update each time a task attempt starts, succeeds, fails or is killed
Takes a status update each time a task attempt starts, succeeds, fails or is killed
Tracks where each earlier-stage task left its output, with a size per next-stage partition
Tracks where each earlier-stage task left its output, with a size per next-stage partition
Feeds every scheduler event to the UI through a queue holding 10,000 events by default, which drops the overflow
Feeds every scheduler event to the UI through a queue holding 10,000 events by default, which drops the overflow
Receives each task's result for actions such as collect() and count()
Receives each task's result for actions such as collect() and count()

Tiny tasks and the driver's serial work

Suppose a stage launches 20,000 tasks, each doing 30ms of real work, on a cluster with 200 cores. Assume each task also costs the driver about 2ms of serial work: launching it, handling its status updates and recording its output. Price both sides:
3 s
executor work per core
40 s
driver work, done serially
The cores could clear their share quickly, but the driver cannot hand out and account for tasks that fast, so they spend most of the stage waiting for their next task. The 2ms is an assumption for the arithmetic, and the shape holds at any value: once a task's own work approaches what the driver spends on it, more cores change nothing. Spark's tuning guide says tasks as short as 200ms run efficiently. Well below that, merge the work into fewer, larger tasks.
The record of stage output grows with task counts on both sides of a boundary. In a stage of 50,000 tasks feeding a stage of 10,000 partitions, each task reports a status covering all 10,000 partitions. Above 2,000 partitions, each status keeps an average size and exact sizes for only the unusually large blocks, instead of every size. The driver still holds 50,000 such records and serves them to every executor that runs the next stage.

Results too large for the driver

An action's results end on the driver. For count() each task sends back 1 number; for collect() each task serializes its rows and the driver holds all of them in memory at once. The setting spark.driver.maxResultSize, 1g by default, guards this: when an action's serialized results pass it, the job fails with Total size of serialized results of 412 tasks (1025.3 MiB) is bigger than spark.driver.maxResultSize (1024.0 MiB). Raising the limit moves the failure into the driver's own memory.

Below, a filter with no stage boundary runs on 4 partitions and its rows are collected. Watch the last step, where every task's matching rows travel to the driver.
from pyspark.sql import functions as F
us_events = events.filter(F.col("country") == "US")
us_events.collect()
events12 rows
event_id
user_id
country
event_type
1
101
US
click
2
102
DE
view
3
103
US
view
4
104
BR
click
5
105
US
purchase
6
106
US
view
7
107
DE
click
8
108
US
click
9
109
BR
view
10
110
US
view
11
111
DE
purchase
12
112
US
click
Preparing the animation

Here 7 rows arrive. Over a year of events, the same program pushes every matching row through 1 network link into 1 heap while the executors that filtered them sit idle. Aggregating first keeps the work on the executors: a groupBy before the action adds a stage boundary, and the driver receives a row per group instead of a row per event.

Run the cell. It counts orders and sums profit per region on the executors, so the driver receives a few small rows. Then delete the .groupBy line and the .agg(...) block and run it again to see how many rows the driver receives instead.

from pyspark.sql import functions as F

orders = spark.table("orders")

summary = (
    orders
    .filter(F.col("region").isNotNull())
    .filter(F.col("profit").isNotNull())
    .groupBy("region")
    .agg(
        F.count("*").alias("order_count"),
        F.round(F.sum("profit"), 2).alias("total_profit"),
    )
)

summary.collect()

Telling a driver bottleneck apart

What you seeWhereWhat it points to
Executors idle while a stage still has pending tasksEvent Timeline on the stage pageDriver handing out tasks too slowly, or a locality wait
Thousands of tasks with a median duration under 200msSummary metrics on the stage pageTasks too small for their scheduling cost
One driver core at 100% CPUHost metrics for the driver machineScheduler thread saturated
`Dropping event from queue` warningsDriver logEvents lost; UI numbers may be incomplete
maxResultSize error or driver out of memory at the actionDriver logToo much data returned to the driver
Multiple Choice

A job's main stage runs 60,000 tasks with a median of 40ms each. Doubling the executors from 50 to 100 left its runtime unchanged. What is most likely to speed it up?

Locality and Scheduling Delay

Daily Life
Interviews
When the DAGScheduler builds a task set it also records, for each task, where its input already lives: for files on the cluster's own disks, the nodes holding that file's blocks. The TaskScheduler uses those preferred locations when it hands out cores. A task placed next to its data skips a network read, and the price of insisting on it is a free core elsewhere left waiting.
LevelWhere the task's input isTypical cause
PROCESS_LOCALIn the memory of the executor running the taskData that executor computed earlier and kept in memory
NODE_LOCALOn the same machine, on local disk or in another executorFile blocks stored on the cluster's own disks
NO_PREFEqually far from every executorRows from a source with no location, such as a remote database
RACK_LOCALOn another machine in the same rackFile blocks stored on a neighbouring node
ANYAnywhere else on the networkNo better core appeared within the wait
Data in a remote object store has no copy on any executor's machine, so its tasks run at NO_PREF or ANY and locality hardly matters.

Delay scheduling

When a core frees up on a machine holding none of a pending task's data, the TaskScheduler can run a task there now at a worse level, or hold the task briefly in case a core frees up next to its data. Spark holds it. The wait per level is spark.locality.wait, 3s by default, with overrides in spark.locality.wait.process, spark.locality.wait.node and spark.locality.wait.rack. The timer belongs to the task set: after 3s with no launch at its current level, the set relaxes one level, and it keeps relaxing until ANY.

Here a core on node K frees up. K sits in a rack holding none of the stage's input, and no core frees up next to the data meanwhile:
TimeFree core onBest level allowedDecision
0 snode KNODE_LOCALHold: no pending task has data on K
3 snode KRACK_LOCALHold: K's rack holds none of the data
6 snode KANYLaunch the next pending task on K

Short stages that lose more to waiting than they save

A job runs 40 short stages, each reading files whose blocks sit on 10 of the cluster's 40 nodes, with tasks of about 1.5 seconds. At the start of every stage, the 10 nodes holding data fill with local tasks, while offers from the other 30 are turned down for up to 3 seconds at NODE_LOCAL and up to 3 more at RACK_LOCAL. That is up to 6 seconds of idle cores on three quarters of the cluster, 40 times over, spent to save network reads of about a second each. Setting the wait to 0s or 1s lets those executors start at once.
✓Lower spark.locality.wait when
  • Tasks run a few seconds or less, so 3 s of waiting rivals the task itself
  • Input sits in an object store or remote service, so no executor is local anyway
  • The Event Timeline shows idle executors at each stage's start while tasks are pending
✗Keep the default when
  • Tasks read hundreds of megabytes each from disks inside the cluster
  • The network between nodes is slower than local disk, or already saturated
  • The Locality Level Summary shows mostly NODE_LOCAL and executors stay busy
Multiple Choice

Two queries run 1-second tasks. Query A reads from a remote object store; query B reads files stored on 8 of the cluster's 40 nodes. Only B's stage starts with executors idle while tasks are pending. Why?

TIP
The locality wait happens before a task launches, so no task's metrics include it, Scheduler Delay included. Find it on the stage page: the Event Timeline shows executors with gaps while tasks are pending, and the Locality Level Summary counts the tasks that ran at each level.
from pyspark.sql import SparkSession
spark = (
SparkSession.builder
.appName("short-stage-report")
.config("spark.locality.wait", "1s")
.getOrCreate()
)
Multiple Choice

A stage of 600 tasks, each running about 2 seconds, reads files stored on the cluster's own disks. Half the executors sit idle for its first 6 seconds while tasks are pending, and the Locality Level Summary shows 300 NODE_LOCAL and 300 ANY. Why the idle start?

❯❯❯PUTTING IT ALL TOGETHER

> Back at Meta at 3 a.m.: the feature-ranking job sits at 99% with 2 of its 30,000 tasks still running, 1 executor repeating the same error, and the driver's CPU pinned. Each symptom belongs to a different part of the scheduler, and reading them that way turns 4 guesses into 4 checks.

The 2 running tasks: compare their input with the median on the stage page. Similar sizes point at slow machines, where a speculative copy on another host ends the wait; far larger inputs mean a copy only doubles the work.
The repeating error: if a task fails on that executor and succeeds elsewhere, spark.excludeOnFailure.enabled keeps retries off it. If attempts fail identically everywhere, the input needs fixing before the 4th failure aborts the job.
The pinned driver: 30,000 tasks each cost it serial work. A median task time far under 200ms means fewer, larger tasks will help where more executors cannot.
Idle executors at the start of each stage would point at the locality wait holding tasks for data-local cores, which a shorter spark.locality.wait trades for network reads.
Through all of it the DAGScheduler stays quiet unless an executor dies with earlier-stage output on it; then it reruns those tasks and the stuck stage gets a new attempt.
KEY TAKEAWAYS
The DAGScheduler decides which stages exist and when each may start; the TaskScheduler decides where and when each task runs.
A task gets spark.task.maxFailures attempts, 4 by default, counted per partition, and every retry restarts the partition from its first row.
A fetch failure means earlier output is gone: the DAGScheduler reruns only the lost tasks, then gives the waiting stage a new attempt.
With Spark 3.5's defaults, speculation copies a task running past 1.5 times the median once 75% of the stage is done, and pays off only when the machine is the cause.
The driver does serial work for every task and receives every result, so tiny tasks and large collects cap a job whatever its executor count.
Delay scheduling holds tasks up to 3s per locality level; short tasks and remote data are better served by a shorter wait.

How the driver plans, places, retries and copies every task, and where that machinery slows a job down.

Category
Spark
Difficulty
advanced
Duration
14 minutes
Challenges
7 hands-on challenges

Topics covered: DAGScheduler vs TaskScheduler, Task Failure and Retry, Speculative Execution, The Driver as Bottleneck, Locality and Scheduling Delay

Lesson Sections

  1. DAGScheduler vs TaskScheduler (concepts: Spark Execution Model)

    An action hands its job to the driver, and 2 schedulers inside the driver divide the work between them. The DAGScheduler thinks in stages: it cuts the job at every stage boundary, works out which stages read from which, and releases a stage only when every stage it reads from has finished. The TaskScheduler thinks in tasks: it takes a released stage as a set of tasks, 1 per partition, and matches each task to a free core on an executor. Neither does the other's job, and most of this lesson follo

  2. Task Failure and Retry (concepts: Spark Execution Model)

    Here partition 3 of stage 2 hits a malformed date near the end of its input: 4 failures, 4 different paths A failure that crosses a stage boundary What a retry costs A retry starts its partition from the first row; nothing a failed attempt computed survives. Take a task that normally runs 10 minutes and dies at minute 9 on a bad record near the end of its input. Each attempt holds a core for 9 minutes, so the 4th failure lands around 36 minutes after the first attempt began, assuming every retry

  3. Speculative Execution (concepts: Data Skew)

    A stage ends when its last task ends. If 199 of 200 tasks take 40 seconds and 1 lands on a machine with a failing disk, the stage lasts as long as that task, and every stage after it starts late. Speculative execution lets the TaskScheduler hedge: when a running task falls far behind its finished siblings, it launches a second attempt of the same partition on a different host, keeps whichever finishes first, and kills the other. Defaults move between releases, so check the configuration page for

  4. The Driver as Bottleneck (concepts: Spark Execution Model)

    Everything in the last 3 sections runs in 1 process. The DAGScheduler handles its events one at a time on a single thread, and the TaskScheduler shares the driver's JVM, tracking every executor and every task attempt. Executors scale out and the driver does not, so a job can add 500 cores and still be capped by how fast a single machine schedules, tracks and receives the work those cores do. Tiny tasks and the driver's serial work Suppose a stage launches 20,000 tasks, each doing 30ms of real wo

  5. Locality and Scheduling Delay (concepts: Spark Execution Model)

    When the DAGScheduler builds a task set it also records, for each task, where its input already lives: for files on the cluster's own disks, the nodes holding that file's blocks. The TaskScheduler uses those preferred locations when it hands out cores. A task placed next to its data skips a network read, and the price of insisting on it is a free core elsewhere left waiting. Data in a remote object store has no copy on any executor's machine, so its tasks run at NO_PREF or ANY and locality hardl