Execution Model: Advanced
- 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
- 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 Ftotals = rides.groupBy("city").agg(F.sum("fare").alias("total_fare"))totals.collect()
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.
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 run | Answered by | What it uses |
|---|---|---|
| Which stages does this job have? | DAGScheduler | Stage boundaries in the job's plan |
| Can stage 2 start yet? | DAGScheduler | Whether every parent stage has finished |
| How many tasks does stage 2 get? | DAGScheduler | Stage 2's partitions not yet computed |
| Which executor runs task 7? | TaskScheduler | Free cores and where task 7's input lives |
| Task 7 threw; run it again? | TaskScheduler | Attempts task 7 has used so far |
| Task 7 cannot read the previous stage's output | DAGScheduler | Its record of where each stage left its output |
Stage 3 has 400 tasks. Task 211 throws an exception on its first attempt while the others keep running. What does Spark do next?
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.
| Attempt | In the error message | Executor | Result |
|---|---|---|---|
| 1st | 3.0 | exec 7 | Failed: malformed date in row 48,112 |
| 2nd | 3.1 | exec 7 | Failed: same error |
| 3rd | 3.2 | exec 12 | Failed: same error |
| 4th | 3.3 | exec 3 | Failed: 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 wrong | Handled by | Counts toward the 4? | What runs again |
|---|---|---|---|
| Your code throws on a row | TaskScheduler | Yes | That task, as a new attempt |
| Executor killed for exceeding its memory limit | TaskScheduler | Yes | Every task that was running on it |
| Executor removed for reasons outside the app, such as decommissioning | TaskScheduler | No | Every task that was running on it |
| A task cannot fetch output from the previous stage | DAGScheduler | No | The 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
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.
- 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
- 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
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?
| Setting | Spark 3.5 default | What it controls |
|---|---|---|
| spark.speculation | false | Whether any copies are launched |
| spark.speculation.interval | 100ms | How often the TaskScheduler checks for slow tasks |
| spark.speculation.quantile | 0.75 | Share of a stage's tasks that must finish before checking starts |
| spark.speculation.multiplier | 1.5 | How many times the median finished duration a task must exceed |
| spark.speculation.minTaskRuntime | 100ms | Minimum 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
| Task | Running for | Past 60 s? | Decision |
|---|---|---|---|
| 117 | 95 s | Yes | Copy queued for a core on another host |
| 44 | 61 s | Yes | Copy queued for a core on another host |
| 182 | 58 s | No | Checked again in 100ms |
| 9 | 41 s | No | Checked 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
- 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
- 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
another attempt succeeded. Many killed copies and no shorter stages means speculation is spending cores and saving nothing.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?
Tiny tasks and the driver's serial work
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.
from pyspark.sql import functions as Fus_events = events.filter(F.col("country") == "US")us_events.collect()
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 see | Where | What it points to |
|---|---|---|
| Executors idle while a stage still has pending tasks | Event Timeline on the stage page | Driver handing out tasks too slowly, or a locality wait |
| Thousands of tasks with a median duration under 200ms | Summary metrics on the stage page | Tasks too small for their scheduling cost |
| One driver core at 100% CPU | Host metrics for the driver machine | Scheduler thread saturated |
| `Dropping event from queue` warnings | Driver log | Events lost; UI numbers may be incomplete |
| maxResultSize error or driver out of memory at the action | Driver log | Too much data returned to the driver |
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?
| Level | Where the task's input is | Typical cause |
|---|---|---|
| PROCESS_LOCAL | In the memory of the executor running the task | Data that executor computed earlier and kept in memory |
| NODE_LOCAL | On the same machine, on local disk or in another executor | File blocks stored on the cluster's own disks |
| NO_PREF | Equally far from every executor | Rows from a source with no location, such as a remote database |
| RACK_LOCAL | On another machine in the same rack | File blocks stored on a neighbouring node |
| ANY | Anywhere else on the network | No better core appeared within the wait |
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.
| Time | Free core on | Best level allowed | Decision |
|---|---|---|---|
| 0 s | node K | NODE_LOCAL | Hold: no pending task has data on K |
| 3 s | node K | RACK_LOCAL | Hold: K's rack holds none of the data |
| 6 s | node K | ANY | Launch the next pending task on K |
Short stages that lose more to waiting than they save
- 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
- 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
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?
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?
> 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.
spark.excludeOnFailure.enabled keeps retries off it. If attempts fail identically everywhere, the input needs fixing before the 4th failure aborts the job.spark.locality.wait trades for network reads.spark.task.maxFailures attempts, 4 by default, counted per partition, and every retry restarts the partition from its first row.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
- 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
- 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
- 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
- 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
- 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