The executor memory model (execution vs storage), off-heap memory, Tungsten code generation, and diagnosing OOM and spills.
⏱ 16 min readTopics chapter readerLevel · Performance
01 · Orientation
Start here
⏱ 5 min · Topic 1 of 12
Spark keeps data in memory while it works. When memory is too small, a job gets slow or it crashes. In this chapter you learn where an executor puts its memory.
You know the executor from "Spark Architecture: Driver, Executors & Cluster Managers". You know caching from "Caching, Persistence & Checkpointing". The chapter "Performance Tuning & Debugging with the Spark UI" covers the Spark UI in full.
You will learn five things.
How an executor splits its memory into parts, with real numbers.
How running work and cached data share one pool.
What memory a single task gets, and what spill is.
Why a job fails with out-of-memory, and which fix to try first.
What Tungsten is: binary rows and generated code.
We use "Spark 3" defaults. Check the settings on your own version.
02 · The parts
The executor memory model
⏱ 8 min · Topic 2 of 12
An executor is one Spark process on a worker machine. It runs on the JVM, the Java engine.
You set its heap size with spark.executor.memory. The heap is the memory area the JVM manages for objects.
Spark splits the heap into three parts. Reserved memory is 300 MB that Spark keeps for itself.
The unified pool is 60% of the rest. User memory is the remaining 40%.
The unified pool holds two kinds of data. Execution memory holds data while a shuffle, join, sort or aggregation runs. Storage memory holds cached data.
Take an executor with 8g. Look at the settings, then the numbers.
Memory parts of an 8g executor (Spark 3 defaults)worked example
The heap parts add up: 300 + 4735 + 3157 = 8192. The numbers are rounded, so a sum can be 1 MB off.
Spark reads the real heap size from the JVM, and it is a little smaller than 8192. So the numbers in your own Spark UI are close to these, not the same.
The pool is 4735 MB. The "protected" 2368 MB is the part of the pool that cached data can keep. The next section explains it.
User memory is for your own objects in JVM code, for example in Scala RDD functions. Spark does not manage it. If you fill it, the executor can run out of memory.
Overhead is the 819 MB outside the heap. The section after next explains it.
03 · Sharing
Execution and storage share
⏱ 7 min · Topic 3 of 12
The two kinds of data do not have fixed sizes. They share the 4735 MB pool and borrow free space from each other.
There are two rules. Execution can push cached blocks out of memory, but only down to the protected line. Storage can never push running work out.
Follow one executor over three moments. The pool is 4735 MB. The protected line is 2368 MB.
Moment
Execution MB
Storage MB
What happens
1. A join runs, nothing is cached
4000
0
Execution borrows the free storage space.
2. The join now uses 1000 MB. You cache 3000 MB of orders
1000
3000
Storage borrows free space. 735 MB stay free.
3. A sort asks for 3000 MB more
2367
2368
Spark drops 632 MB of cached blocks. Storage stops at the line.
At moment 2, storage uses 632 MB above the line. At moment 3, only that 632 MB can be taken.
The sort gets 1000 + 735 free + 632 = 2367 MB. The rest of the sort goes to disk.
A dropped block is not lost. With MEMORY_AND_DISK it goes to disk.
With MEMORY_ONLY Spark builds it again when needed. You saw both in the caching chapter.
Watch out
Caching a big table can starve your joins. The cached part below the line is safe. Running work cannot take that cached part, so a sort may spill while the pool holds a lot of cache.
04 · Outside the heap
Memory outside the heap
⏱ 7 min · Topic 4 of 12
An executor needs memory that is not in the JVM heap. Threads, network buffers and Python workers use it. Spark asks for it with spark.executor.memoryOverhead.
A container is the block of memory and CPU that YARN or Kubernetes gives one executor.
The default is the larger of 384 MB and 10% of spark.executor.memory. For the 8g executor that is 819 MB, so the container needs 8192 + 819 = 9011 MB. On Kubernetes, a PySpark job uses 40% by default, not 10%.
A Python worker is a separate Python process on the same machine. PySpark starts one to run your Python functions in a query. It lives outside the heap, so it uses overhead memory.
By default Spark does not limit it. You can set spark.executor.pyspark.memory to give Python its own limit.
You can also move some Spark data off the heap. Set spark.memory.offHeap.enabled to true and spark.memory.offHeap.size to a size, for example 2g. Both are off by default.
Spark then keeps execution data in that extra area. Cached data goes there only with the OFF_HEAP storage level.
The JVM does not clean up this area, so its clean-up pauses can be shorter. The container must still have room for it.
Watch out
A heap error says "Java heap space". A container kill says the container went over its memory limit. Often Python is the cause, so a bigger heap does not help.
05 · Per task
Memory for one task
⏱ 6 min · Topic 5 of 12
An executor runs many tasks at the same time, one for each core. All of them use the same execution memory.
Say N tasks are running now. Each task gets at least 1/(2N) and at most 1/N of the execution memory. N counts the tasks that run now, not the cores.
Take the 8g executor with the whole 4735 MB free for execution. Read the code, then the table.
Memory for each task as tasks are addedworked example
More tasks at the same time means less memory for each one. Cached data up to the protected line would shrink these numbers, because running work cannot take it.
This executor has 4 cores, so up to 4 tasks run at once. Each task then gets 592 to 1184 MB.
Now test partitions of different sizes on this executor. A partition is the piece of data one task reads. Use the 4-task row, 592 to 1184 MB.
Data one task must hold
Fits?
51 MB
Yes. It is far under 592 MB.
500 MB
Yes, even at the minimum.
900 MB
Maybe. It fits only if the task gets more than the minimum.
2000 MB
No. It is over 1184 MB, so the task writes the extra to disk.
These sizes are rough. A sort or a join needs different amounts, and data in memory is often bigger than the file on disk. But the idea holds: a big partition on a busy executor does not fit.
06 · When it does not fit
Spill and out-of-memory
⏱ 8 min · Topic 6 of 12
Spill means a task writes data to disk because it does not fit in memory. The job goes on, but slower.
Out-of-memory, or OOM, means a task needs memory that it cannot get. The task fails.
You see spill in the Spark UI. Open a stage and look at the task table. Two columns show it.
Spill (Memory): the size of the spilled data while it was in memory.
Spill (Disk): the size of the same data on disk. It is often smaller, because it is compressed.
Here is a made-up stage. Orders history is shuffled for a sort, and every task spills.
Task table of a stage (made-up numbers)worked example
Size of each shuffle partition (160 GB of orders history)
Partitions
Size of each (MB)
Against 592 to 1184 MB
200
819
Too big. In memory it grows past 1.8 GiB. Every task spills.
800
205
Fits.
160 GB is 163840 MB. Divide by 200 for 819 MB. Divide by 800 for 205 MB.
The tasks spilled about the same amount. So every partition is too big. Skew, where one hot key has far more rows than the others, shows spill in only a few tasks.
More partitions made each piece smaller, so each task fits in memory. You do not need more memory.
Adaptive Query Execution, or AQE, changes the plan while the query runs. With AQE you can start with many partitions, and Spark merges the small ones. See "Adaptive Query Execution & Cost-Based Optimization".
Spill and OOM have different causes. This table shows the common ones and the first fix.
What you see
What it means
First fix
Spill (Memory) and Spill (Disk) are above 0
A sort, join or aggregation did not fit and went to disk.
More partitions. Then check skew.
"Java heap space" in an executor
Something could not spill. One huge group is a common cause.
Find the big group or object. Make the pieces smaller.
Container killed for exceeding the memory limit
Heap plus outside-heap memory was too large. Often Python.
Do not collect big data. Write it out or use limit().
Watch out
Adding memory to every OOM is slow and costly. The real cause is often one big partition or one hot key. Try smaller partitions and the skew fixes first.
07 · The speed engine
Tungsten and binary rows
⏱ 8 min · Topic 7 of 12
Tungsten is a set of changes in the Spark engine. Its goal is to use memory and the CPU better.
The Databricks team started it for Spark. It works mainly for DataFrames and Spark SQL. RDD code gets little of it.
Tungsten has three ideas.
Binary rows: Spark stores a row as compact bytes, not as Java objects.
Fast sort: Spark sorts a short key stored next to each row pointer, so the CPU reads less memory.
Generated code: Spark writes one Java function for a group of steps. The next section shows it.
Start with binary rows. Java objects carry extra data for each object, and the JVM has to clean them up. This cleaning is garbage collection, or GC, and it pauses work.
Spark stores a row in a format called UnsafeRow. It is a block of bytes with a fixed slot for each column. Take order 101.
Order 101 as one binary row (simplified)worked example
The number after the star names one generated function. The *(1) steps run as one function, and *(2) does the final sum. This number is not the stage number in the Spark UI.
The Exchange line has no star. A shuffle cannot be fused, because it moves rows between machines. So a shuffle ends one block and starts the next.
To read the generated Java, call df.explain("codegen"). Do this only when you debug.
Some plans have no stars. A star is missing when a step cannot be generated. Spark also skips codegen for a very wide table, over 100 columns by default.
Since Spark 3.2, Adaptive Query Execution is on by default. If the query has a shuffle, explain() then shows no stars until the query has run.
RDD code has no stars. An RDD runs your functions on plain objects, Python objects in PySpark. Catalyst, the Spark query planner, does not plan it.
This is one reason DataFrames are faster. The next section shows a step that breaks the fused code.
Interview question
The question: "Why are DataFrames faster than RDDs?" A good answer: "Catalyst plans the query. Tungsten runs it with binary rows and generated code."
09 · A break in the chain
Python UDFs leave the JVM
⏱ 7 min · Topic 9 of 12
A UDF, or user-defined function, is a function you write and use in a query. A Python UDF runs in a Python worker, outside the JVM. So Spark must send every row there and get the answer back.
Spark has to turn the row into bytes, send it, run your function, and send the result back. This work is called serialization cost. It is paid for every row.
Take a label for each order: "big" if amount is 50 or more, else "small". Here is the same label as a Python UDF and as built-in functions.
The same label as a Python UDF and as a built-inworked example
Order 105 has a null amount. Both versions label it small.
The plan is simplified. BatchEvalPython has no star. The UDF cuts the fused steps in two blocks, *(1) and *(2).
The built-in version stays inside one generated function. The UDF version sends rows to Python. It also uses Python worker memory, which is outside the heap.
A pandas UDF is cheaper. It sends rows in batches that use the Arrow format.
The default batch is up to 10000 rows, set by spark.sql.execution.arrow.maxRecordsPerBatch. The plan then shows ArrowEvalPython.
The best choice is a built-in function when one exists. The chapter "UDFs & the Pandas API on Spark" covers UDFs in full.
Watch out
A UDF is not just a little slower. It breaks the fused code and sends every row out of the JVM. Try a built-in first.
10 · Decide
Which fix first
⏱ 4 min · Topic 10 of 12
Start from what you see. Try the fixes in the order of the table, from cheap to costly.
What you see
Try this first
Then
Spill in a few tasks only
Check skew. See "Data Skew & Salting".
Salt or split the hot key.
Spill in most tasks
More partitions, for example spark.sql.shuffle.partitions.
Fewer cores for each executor.
Spill after you cached a table
Check the storage tab. Unpersist what you do not need.
Cache less, or use a disk level.
Container killed, and you use Python
Raise spark.executor.memoryOverhead. Replace UDFs with built-ins.
Set spark.executor.pyspark.memory.
"Java heap space"
Find the huge group or object. Make pieces smaller.
Raise spark.executor.memory.
No stars in the plan after the query ran
Look for a UDF. Replace it with built-ins.
Check the number of columns.
Raise memory last. More memory costs money on every run, and it may not fix the cause.
11 · Recap
Check yourself
⏱ 4 min · Topic 11 of 12
Four points to keep.
The heap has reserved memory, the unified pool (60%) and user memory. Overhead sits outside the heap.
Execution can push cached blocks out, only down to the protected line. Storage cannot push out running work.
More tasks at once means less memory for each one. Smaller partitions fix most spill.
Tungsten stores rows as bytes and generates code for a stage. Python UDFs break that chain.
Answer these without looking back.
An executor has 4g, which is 4096 MB. How big is the unified pool? Hint: subtract 300 first.
Why can a sort spill when a lot of the pool holds cached data?
What is the difference between Spill (Memory) and Spill (Disk)?
What does the star in *(1) mean, and why has an Exchange no star?
12 · Next Chapter
Next Chapter
You can now say where executor memory goes, and why a task spills or fails.