DE Learning

Spark Core · Module 1 · Deep dive

Inside the executors

How tasks fill the cluster, how executor memory is really divided, and the two features that change executors at runtime: dynamic allocation and speculative execution. Start with the overview if you need the basics.

Slots, waves and stage duration

Each executor core is a slot that runs one task at a time. With 10 executors × 4 cores you have 40 slots, so a stage of 1,000 tasks runs in about 1,000 ÷ 40 = 25 waves. Picture 40 checkout counters serving 1,000 customers.

40 slots running 1,000 tasks of 20 seconds in 25 waves over about 500 secondsslot 1slot 2slot 3slot 40⋮ slots 4–39 run the same pattern ⋮0s100s200s300s400s500swave 1: tasks 1–40wave 25: tasks 961–1,000each block = one 20 s task · 25 waves × 20 s ≈ 500 s

At about 20 s per task, 25 waves take roughly 500 s, or 8–9 minutes. In practice a slot picks up the next task as soon as it's free, so the waves blur together, but the total is the same.

What actually shortens a stage

  • More slots. 80 slots → about 13 waves → roughly half the time. This is the main lever.
  • No half-empty last wave. 1,010 tasks need 26 waves, and the last runs 10 tasks while 30 cores sit idle. Keep task counts near a multiple of total cores.
  • Merging only tiny tasks. Fewer, larger partitions don't speed up a stage: the work is the same, each task just runs longer. It helps only when tasks take around 100 ms and scheduling overhead dominates.
  • Fixing skew. A stage ends when its slowest task ends. One 15-minute task makes the stage take at least 15 minutes, however many slots you add.

Executor memory in detail

The same 16 GB, 4-core executor as on the overview, drawn as nested regions. Each box is a separate budget; running out of a different box gives a different error.

Executor container split into heap and overhead; heap split into reserved, user memory and a unified pool of storage and execution Executor container · 17.6 GB what the cluster manager allocates = heap + overhead JVM heap · spark.executor.memory = 16 GB Reserved 0.3 GB fixed User memory 6.3 GB 40% of the rest your objects, UDF data structures never spilled Unified pool · 9.4 GB · 60% of the rest Storage ~4.7 GB cache, persist, broadcast variables can be evicted Execution ~4.7 GB shuffle, join, sort, aggregate spills to disk Overhead 1.6 GB max(384 MB, 10%) JVM internals network buffers Python workers native libraries outside the heap

The dashed line between storage and execution is a soft boundary. Execution can push it left by evicting cached blocks, but only down to the protected storage half (spark.memory.storageFraction = 0.5). Cache can push it right only into execution memory that's free.

Try your own executor size

Defaults used: 300 MB reserved, spark.memory.fraction = 0.6, spark.memory.storageFraction = 0.5, overhead = max(384 MB, factor × heap).

Why cores matter as much as memory

The unified pool is shared by every task running at once. For the same 16 GB executor:

Cores per executorTasks at oncePer task, no cachePer task, cache at its half
224.7 GB2.4 GB
442.4 GB1.2 GB
881.2 GB0.6 GB

Halving cores doubles the memory each task gets, without buying any RAM. The trade-off is less parallelism per executor.

How the pool behaves under pressure

Memory that lives outside the heap

Container size = heap + overhead + PySpark memory (if set) + off-heap size (if enabled). The cluster manager kills the container when the process goes over that total.

Dynamic allocation

The problem it solves. With static allocation (--num-executors 50) a job holds all 50 executors for its whole life, even during a stage with 10 tasks or while the driver sits idle between jobs. On a shared YARN or Kubernetes cluster that blocks other teams and wastes money.

What it does. Spark grows and shrinks the number of executors based on how many tasks are waiting.

Executor count rising exponentially while tasks are backlogged, dropping to the minimum after 60 seconds idle, then rising again Stage A · 2,000 tasks queued driver-only work Stage B · 40 tasks max = 50 min = 2 50 25 0 executors time → tasks waiting > 1 s → request 1, 2, 4, 8 … more per round, up to the max idle 60 s → released backlog again

How it decides

DirectionTriggerDetail
Scale upTasks pending longer than schedulerBacklogTimeout (1 s)Requests double each round (1, 2, 4, 8 …) while the backlog lasts (sustainedSchedulerBacklogTimeout, 1 s), up to maxExecutors
Scale downAn executor ran no tasks for executorIdleTimeout (60 s)Never below minExecutors. Executors holding cached data use cachedExecutorIdleTimeout, which is infinite by default, so they're kept
spark.dynamicAllocation.enabled=true
spark.dynamicAllocation.minExecutors=2
spark.dynamicAllocation.initialExecutors=2
spark.dynamicAllocation.maxExecutors=50          # always set a cap on shared clusters
spark.dynamicAllocation.executorIdleTimeout=60s
spark.dynamicAllocation.shuffleTracking.enabled=true   # or spark.shuffle.service.enabled=true

The catch: shuffle files

Executors write shuffle output to their own local disk, and the next stage reads it from there. If Spark removed an idle executor, that data would be lost and the parent stage would have to rerun. So dynamic allocation needs one of these:

When to use it

Good fitPoor fit
Shared clusters; notebooks and ad-hoc work; jobs whose stages vary a lot in size; cost control Latency-sensitive jobs (new executors take seconds to minutes to start, longer if new nodes are needed); Structured Streaming, which wants steady capacity; jobs that cache heavily (cached executors never scale down)

On Databricks you set cluster autoscaling (min/max workers) instead. It adds and removes worker nodes, the platform-level version of the same idea.

Common trap
Dynamic allocation with no maxExecutors on a shared cluster. One job with a large backlog can request every free container and starve everyone else. Always set a cap.
Say it in an interview
"Dynamic allocation requests executors when tasks are backlogged for more than a second, doubling each round up to a max, and releases them after 60 seconds idle. It needs an external shuffle service or shuffle tracking so removing an executor doesn't lose shuffle files. I use it on shared or bursty workloads with a sensible max, and avoid it for latency-critical or streaming jobs."

Speculative execution

The problem it solves. Sometimes one task is slow for reasons unrelated to its data: a failing disk, an overloaded node, a noisy neighbour, a slow network link. The stage can't finish until that task does.

What it does. When enabled (spark.speculation=true, off by default), Spark watches running tasks and launches a duplicate copy of a straggler on a different executor. Whichever copy finishes first wins; the other is killed.

Task 97 runs slowly on a bad node; after 75 percent of tasks finish and it passes 1.5 times the median, a copy launches elsewhere and finishes first 75% of tasks done (25 s) 1.5 × median 20 s = 30 s → launch copy task 1 task 2 task 3 ⋮ tasks 4–96 done by ~25 s task 97 (bad node) would have run to 80 s task 97 copy copy wins at 50 s; original killed 0 s 20 s 40 s 60 s 80 s

When a task counts as a straggler

Spark checks every spark.speculation.interval (100 ms). A running task gets a copy when both are true:

  1. At least spark.speculation.quantile (75%) of the stage's tasks have finished, and
  2. the task has been running longer than spark.speculation.multiplier (1.5) × the median duration of finished tasks.
spark.speculation=true
spark.speculation.multiplier=1.5       # raise to 2–3 to be less aggressive
spark.speculation.quantile=0.75
spark.speculation.minTaskRuntime=100ms # don't speculate on very short tasks
Classic trick question: does speculation fix skew?
No. A skewed task is slow because its partition holds far more data. The copy gets the same partition, so it's just as slow, and you've wasted a core. Speculation helps only when the machine is the problem, not the data. Tell them apart in the Spark UI: a skewed task has a much larger Shuffle Read Size than its peers; a bad-node task has normal input but a long duration, often with others on the same host.

Risks

Say it in an interview
"Speculation re-launches a task that's running much slower than its peers once most of the stage is done: by default 1.5 times the median after 75% of tasks finish. Whichever copy finishes first wins. It helps with bad nodes, not skew, because the copy gets the same data. I'd avoid it when tasks write to non-idempotent external systems."

Client vs cluster deploy mode

Client modeCluster mode
Where the driver runsOn the machine that ran spark-submit (or the notebook host)Inside the cluster, on a node the cluster manager picks
Good forInteractive work, notebooks, debuggingScheduled production jobs
RiskIf the laptop or edge node disconnects, the job dies; network latency to executorsLogs live in the cluster, so it's harder to watch interactively

On Databricks you don't choose this: each cluster has a driver node and worker nodes, with one executor per worker. You tune by choosing instance types and worker counts.

Check yourself

Your 64 GB executors have 16 cores each and spill constantly. What would you change first, and why?
Fewer cores per executor (for example 4–5), with more executors. Each task currently gets roughly 64 × 0.6 ÷ 16 ≈ 2.3 GB of pool; at 4 cores it's about 9 GB. Very large heaps also mean longer GC pauses.
Dynamic allocation is on, but executors never scale down. Why?
Most likely they hold cached data (cachedExecutorIdleTimeout is infinite by default), or shuffle tracking is keeping them because a later stage still needs their shuffle files. Unpersist cached data you no longer need, or set a finite cached idle timeout.
Why does dynamic allocation need an external shuffle service or shuffle tracking?
Shuffle output lives on executors' local disks. Removing an executor without either feature would lose shuffle files that the next stage needs, forcing recomputation of the parent stage.
One task in a stage takes 10× the median. Speculation or salting?
Check its Shuffle Read Size first. If it's 10× the median too, it's skew: salting, AQE skew join or broadcast. If its input is normal, it's the node: speculation helps, and you should look at that host.