PySpark / Performance Fig_04  ·  Lesson 04 / 14
Fig_04 · Executor memory · v1.0 OSS Spark + Azure Databricks

Executor memory

The unified model · spill · OOM

There are many executors, and each one's heap is not one pool — Spark's UnifiedMemoryManager carves it into regions, and which region runs out first decides whether the same job finishes, quietly spills to disk, or dies with an OOM. This lesson is about reading that heap.

Spark 3.2+ / DBR LTS memory.fraction 0.6 storageFraction 0.5 reserved 300 MiB verified Jun 2026

01 · What it is

One heap, four regions, two consumers that fight

An executor is a JVM process on a worker node that runs your tasks and holds cached data. Its heap is carved into four regions, and the one that runs out first sets the fate of your stage. The deep dive below breaks each down once; for now just hold the shape:

analogy The executor heap is a shared commercial kitchen: a walled-off back office (reserved), your private pantry (user memory), and one big shared counter (region M). On that counter the active cooking workspace (execution) and the shelf of prepped ingredients (storage/cache) share the surface — the cook can shove ingredients aside, but the shelf can never push the cook off.

The one rule to remember Think of region M as one shared room. Execution is the memory a running task needs right now for a sort, join, aggregation, or shuffle. Storage is cached data Spark saved for later. When the room gets crowded, Spark protects the running task first: execution can push cached blocks out until storage reaches its protected floor R, but storage is never allowed to push execution out. In plain English: work happening now wins over cache saved for later.
What the heap holds

Reserved (300 MiB), user memory, and region M — the shared pool where execution and storage (cache) compete.

Fits in memory

Shuffle buffers and cache both stay under M — the stage finishes fast, no disk, no crash.

Spill → OOM

Execution outgrows M → spills to disk (slow); if even spill can't keep a partition inside the container → executor OOM.

02 · Why it matters

Spill and OOM are the two tickets you'll actually get

03 · How it works — deep dive

The regions, the asymmetry, and the two ways it fails

Everything an executor-memory problem comes down to is where the data sits while a task runs. So the useful way to understand it is: (1) what the regions are and how they're sized, (2) the borrow-and-evict rule that governs the shared pool, and (3) the two failure modes — spill, then OOM — plus the memory the heap diagram doesn't show. Each idea is explained here once; later sections show it, verify it, and help you decide, but don't re-explain it.

3.1 · The four regions, sized

How the heap is partitioned, in order, on a default spark.executor.memory = 1g executor (usable = heap − 300 MiB ≈ 724 MiB):

RegionWhat it holdsSize (1g default)Evict rule
ReservedSpark internals, carved off first300 MiB (fixed)Never touched
User memoryUser objects, UDF state, metadata, safety cushion~40% of usable ≈ 290 MiBNot managed by the unified manager
Unified MShared pool: execution + storage (cache)0.6 × usable ≈ 434 MiBExecution evicts storage down to R
Storage floor REviction-immune slice of cache inside M0.5 × M ≈ 217 MiBImmune — never evicted

use case That ~434 MiB of M is all that's shared between every shuffle buffer and every cached block on the executor — which is why a heavy shuffle and a big cache in the same stage fight over the same few hundred MiB, and someone spills.

3.2 · Two consumers, two lifecycles

analogy Execution is scratch paper you scribble on and toss mid-task; storage is a reference book you keep on the desk all session.

3.3 · The unified model — borrow & the evict asymmetry

M is unified, not a static 60/40 split. Three rules govern it:

Execution evicts Storage ≠ Storage evicts Execution A running task must make progress to finish the stage — evicting its buffers would fail or deadlock it. A cached block is only an optimization: if evicted, Spark recomputes it from lineage. That asymmetry is the one distinction interviewers test most — hold it firmly. (Contrast Lesson 03: an executor OOM is a partition too big to process here on a worker; a driver OOM is data brought back to the one driver heap. Different node, different log, different fix.)

use case You cache() a 400 MiB DataFrame then aggregate it; the aggregation evicts cache blocks above R, so the cache ends up smaller than expected and the next read partly recomputes — the asymmetry biting a real job.

3.4 · Spill — the warning sign before death

analogy Spill is overflow parking: the lot (execution memory) is full, so cars (data) park in the muddy field down the road (disk). Everyone still parks — it's just slower to walk back.

3.5 · Executor OOM — when spilling isn't enough

OOM (java.lang.OutOfMemoryError) happens when even spilling can't keep a partition within the container limit. The four causes, and the fix that actually works for each:

CauseConfirm in the Spark UIThe right fix
Skewed partitionStages: one task's time / shuffle-read / spill ≫ median before it diesAQE skew join → salting (Lesson 08). Heap only delays it.
Cache pinned in RStorage tab: large cached DataFrames; OOM in a shuffle/agg stageCache less, disk/serialized level, unpersist() early (Lesson 06).
Oversized Python workersExecutors tab: JVM heap healthy but the container is killedRaise overhead / set pyspark.memory; not executor.memory.
Undersized heap / overheadNo skew, little cache, no Python — yet every task spills heavilyAdd partitions first; only then raise executor.memory / overhead.

use case A groupBy where 90% of rows share one value → one task builds a hash aggregation for 90% of the data → spills, spills, then exceeds the container and the executor dies. Fix the skew, don't just add RAM.

3.6 · The memory outside the bar — off-heap and Python workers

use case A Pandas UDF that materializes a big batch per group blows the container while the JVM heap looks fine. The cure is more overhead (or a smaller batch / native function), not a bigger spark.executor.memory.

Figure A · borrow, evict, spill, OOM

Watch the shared region fill

Drag execution demand and storage (cache) demand and watch execution borrow free space, then evict cache down to the R floor when it needs more — while cache can never push execution back. Push total demand past M and a hatched spill segment appears; push it well past the heap and the executor OOMs. This figure isolates one thing: the regions and their thresholds. (Where it runs across the cluster is Figure B.)

executor unified-memory simulator

A default spark.executor.memory = 1g executor: usable ≈ 724 MiB, M ≈ 434 MiB, R ≈ 217 MiB.

reserved
user
storage
execution
R floor (cache immune)
—
cache evicted by execution
—
spill to disk
—
executor state

Off-heap and Python-worker memory live outside this JVM bar. Toggle them to see which budget they spend — and why a Python-heavy OOM is an overhead bug, not a heap bug.

off-heap (Tungsten)Outside the heap, no GC. Disabled by default; sized with offHeap.size, and shrink the heap so RAM isn't double-counted.
python workersOutside the heap. Spend the container's overhead budget (min 384m), not region M. If they OOM, raise overhead.
reserved 300 MiB user ~40% storage (cache) execution spill (disk)

Simplified illustration — real accounting is per-task and continuous; the asymmetry (execution evicts storage down to R; storage never evicts execution) is exact.

04 · The code — and how to verify it

Four moves, each with its proof

Every technique below is paired with how to prove it worked — the plan node or the Spark-UI signal. Apply, then verify; never assume.

Simple reading rule For every config you see, ask one question first: "Is this sizing the JVM heap, the unified region M inside it, or the off-heap / PySpark bucket outside it?" executor.memory sizes the heap; memory.fraction / storageFraction carve up M within it; offHeap.*, memoryOverhead, and pyspark.memory live outside the heap entirely — so raising the heap does nothing for them.

Inspect the unified-memory configs

python# The four numbers that define region M. Read them before tuning anything.
print(spark.conf.get("spark.executor.memory"))          # default "1g" — the JVM heap
print(spark.conf.get("spark.memory.fraction"))          # default "0.6" — M = 0.6 * (heap - 300 MiB)
print(spark.conf.get("spark.memory.storageFraction"))   # default "0.5" — R = 0.5 * M (eviction-immune cache)

# VERIFY (do the arithmetic): on a 1g executor,
#   usable = 1024 - 300 = 724 MiB ; M = 0.6 * 724 ≈ 434 MiB ; R = 0.5 * 434 ≈ 217 MiB
# That ~434 MiB is shared by EVERY shuffle buffer AND every cached block on the executor.

See (and cure) a spill

pythonfrom pyspark.sql import functions as F

# CREATE the condition: a wide aggregation over many groups stresses EXECUTION memory.
big = spark.range(0, 200_000_000).withColumn("k", (F.rand() * 50_000_000).cast("long"))
agg = big.groupBy("k").count()

# VERIFY the spilling operator is in the plan:
agg.explain(mode="formatted")
#   look for: HashAggregate(keys=[k], ...)  +  Exchange hashpartitioning(k, 200)
#   HashAggregate is the execution-memory consumer that spills when M is exhausted.

# Run the job, then READ THE UI:
agg.write.format("noop").mode("overwrite").save()
# Spark UI → Stages → aggregation stage → columns "Spill (Memory)" / "Spill (Disk)".
# Non-zero spill = execution memory ran out. Cure: more, smaller partitions:
spark.conf.set("spark.sql.shuffle.partitions", 800)   # smaller slices → less per-task memory
# Re-run and confirm the spill columns drop toward zero. (Reset the conf afterwards.)

Watch execution evict storage (the asymmetry)

pythonfrom pyspark import StorageLevel

# Cache a DataFrame, then run heavy EXECUTION work on it in the same stage.
cached = big.persist(StorageLevel.MEMORY_AND_DISK)   # DataFrame default; spills cache to disk
cached.count()                                        # materialize (cache is lazy until an action)

heavy = cached.groupBy("k").count()                   # execution now competes with the cache
heavy.write.format("noop").mode("overwrite").save()

# VERIFY — Spark UI → Storage tab: the cached DataFrame may show < 100% "Fraction Cached".
# Execution EVICTED cache blocks above R to make room. Storage could not evict execution.
cached.unpersist()                                    # always release cache when done (frees R)

Budget the memory outside the heap (off-heap & Python)

python# Off-heap lives OUTSIDE the JVM heap → no GC. Size it explicitly and shrink the heap
# (per the tuning docs) so you don't double-count RAM. Set at CLUSTER creation in prod.
spark.conf.set("spark.memory.offHeap.enabled", "true")
spark.conf.set("spark.memory.offHeap.size", str(2 * 1024 * 1024 * 1024))  # 2 GiB in bytes (>0)

# Python UDFs run OUTSIDE the heap, against the container's OVERHEAD (not runtime-settable):
print(spark.conf.get("spark.executor.pyspark.memory", "<not set>"))   # → "<not set>" by default
#   spark.executor.memoryOverhead = "2g"   # a floor for Python-heavy work (cluster config)

# VERIFY — Spark UI → Executors tab: "Off Heap Memory Used" becomes non-zero and GC Time drops;
# container OOM kills stop after raising OVERHEAD — bumping spark.executor.memory would NOT help.

Figure B · the task, filling one executor

See it run across the cluster

This figure isolates a different thing from Figure A: not the thresholds, but the order of events over time. Watch one aggregation on Executor A (two more shown for context): execution grows in M, evicts cached blocks down to the R floor, spills to local disk when it exceeds M, then a skewed partition outruns spill and the executor OOMs. Use Prev / Play / Next.

cluster execution lab — aggregation, spill & OOM

Defaults: reserved 300 MiB · memory.fraction 0.6 → M ≈ 434 · storageFraction 0.5 → R ≈ 217 (eviction-immune). Focus on Executor A.

Driver schedules

Executor A M≈434 · R≈217

Executor B M≈434 · R≈217

Executor C M≈434 · R≈217

PySpark — an aggregation over cache
# cache a reference table in storage (region M)ref = big.persist(StorageLevel.MEMORY_AND_DISK)ref.count() # materialize cache# wide aggregation builds per-key hash tablesagg = ref.groupBy("k").count()agg.write.format("noop").save()
Executor A — region M (≈434 MiB)
cache (storage) ...... 0 MiB
execution (hash) ..... 0 MiB
R floor (immune) ..... 217 MiB
spill to disk ........ 0 MiB
state ................ idle
step 1 / 6

Simplified illustration — per-task accounting is continuous, but the order (grow → evict storage to R → spill → OOM on skew) is exact.

05 · Comparison at a glance

Execution vs storage — the two consumers, side by side

Read this by the consumer you're worried about — it answers "how does this region behave under pressure?" at a glance. (The why is §3; this is the contrast.)

DimensionExecution memoryStorage memory
HoldsShuffle / sort / agg / join buffersCached/persisted blocks + broadcast
LifecycleTransient (per-task, freed on completion)Long-lived (until evicted / unpersist())
Lives inRegion M (shared)Region M; guaranteed R = 0.5 × M
Can evict the other?Yes — evicts Storage down to RNo — never evicts Execution
When short →Spill to disk (slow, but finishes)New block spills / isn't cached
Failure modeOOM if even spill can't keep upPressures execution → spill/OOM elsewhere
Tune withshuffle partitions, fix skew, off-heapcache less, serialized/disk levels, unpersist()

06 · Edge cases & limitations

Where it gets subtle

New nuance an interviewer probes — not a re-run of §3:

Hard limits worth stating plainly:

07 · Common mistakes

Gotchas — the mistake and its one-line fix

MistakeWhy it hurtsBetter move
"Add more executor.memory" as the reflexIf a Python worker / off-heap blew the container, or it's skew spill, more heap doesn't help.Raise overhead for container OOMs; fix skew / add partitions for spill.
Thinking the split is a static 60/40It's unified — either consumer can use all of M.Remember the only fixed guarantee is R.
Believing storage can evict executionIt can't, ever — cache always yields to active work.Trust the asymmetry: execution wins, storage recomputes.
Ignoring non-zero spill because "it finished"Spill is the early warning; a bigger input tomorrow turns it into an OOM.Investigate any non-zero Spill (Memory/Disk) in the UI.
Caching everythingCached blocks pin R and pressure GC.Cache only reused DataFrames; unpersist() when done.
Forgetting Python lives outside the heapThe unified bar excludes Python workers — the #1 hidden PySpark OOM.Budget them against overhead (min 384m), not region M.
Changing spark.memory.fraction at runtimeIt's a cluster-launch setting; a running executor won't resize.Set it in the cluster config, not mid-job.

08 · References

Sources