Data Engineering Module 9 – Batch processing with Apache Spark
Spark performance: AQE, skew and spills
Tell skew from spills in the row counts of a slow Spark job, see what adaptive execution fixes by itself, and decide when caching a DataFrame pays.
What you will learn
- Diagnose skew, spills and too many tiny tasks from per-partition row counts
- Explain what adaptive skew-join handling splits, and when salting is needed instead
- Decide whether a DataFrame is worth caching, and release it with unpersist
- Size a job's shuffle partitions, cores and executor memory from its data size
Before you start
What you’ll learn · Before you start
What you will learn
- Diagnose skew, spills and too many tiny tasks from per-partition row counts
- Explain what adaptive skew-join handling splits, and when salting is needed instead
- Decide whether a DataFrame is worth caching, and release it with unpersist
- Size a job's shuffle partitions, cores and executor memory from its data size
Before you start
On this page
A Spark job slows down, and its slowest stage shows 7 of its 8 tasks finished while one is still running. That one task holds 820,000 of the stage’s 1,000,000 rows, and more cores would not take a single row off it. So when a job is slow, look first at the tasks of its slowest stage and count the rows each one holds: one task far above the rest is skew, every task too large for its memory spills, and thousands of near-empty tasks mean a stage split too finely.
The row and task counts below were printed by PySpark 4.2.0 in local mode on fixed generated data: counts you can reproduce, never durations. Some of what they show, adaptive execution repairs by itself.
Three shapes of a slow stage, and the count behind each
A stage runs one task per partition and ends with its last task; where partitions come from is covered in Spark joins, shuffles and partitions. Given the partitions, three spreads of rows explain most slow stages:
- Skew. One key value holds far more rows than the rest, so one partition does too, and its task is the straggler: A task that runs far longer than the other tasks of its stage, so the whole stage, and every stage after it, waits for that one task to finish. the stage waits for.
- Spills. Every task holds more than its memory takes, so its sort or hash table goes to local disk, a In Spark, a task writing part of its sort buffer or hash table to local disk because it could not get more execution memory, then reading that part back to finish its work., and is read back.
- Too many tiny tasks. Thousands of partitions hold a few rows each, and starting a task costs more than its work.
data skew: An uneven spread of rows over the partitions of a stage, usually because one key value is far more common than the others, so the task that holds that key does most of the stage's work. is the shape more machines never fix, because one task runs on one core. Spills are quieter: the Spark UI’s stage page reports them per task as shuffle spill (memory) and shuffle spill (disk). A fourth cost shows in no task count: a DataFrame that two actions read is computed twice, unless caching keeps it.
adaptive query execution: A Spark feature, on by default, that re-plans parts of a query at runtime from the actual rows and bytes it has seen, under an AdaptiveSparkPlan root. (AQE) re-plans a query between stages from the sizes finished stages report, and AQE is on by default in Spark 4.2. Its skew handling is documented for the two joins that shuffle both sides, sort-merge and shuffled hash. The 4.2 UI shows a query’s initial and final adaptive plans side by side, where a split shows. Read a slow stage in this order:
The drawing shows the whole scene; its 6 steps are listed below.
Every step as text
- Start from the slowest stage. A slow job is nearly always one slow stage. Its tasks are listed with the rows each one read, and those counts, not the durations, say which of three shapes the stage has.
- Compare the largest task. Put the largest task's rows next to the mean. In this lesson's run one partition of 8 held 820,000 of 1,000,000 rows, 6.56 times the mean of 125,000, while 7 of the 8 held less than the mean.
- Why cores cannot help. One task runs on one core, so the stage ends when the 820,000-row task ends. The other cores finish early and wait: that task is a straggler, and only spreading its key over more tasks shortens the stage.
- Every task too big. If the tasks are even but each one is large, a task's sort or hash table can outgrow its memory and spill to local disk, to be read back later. The stage page reports it as shuffle spill (memory) and shuffle spill (disk).
- Thousands of tiny tasks. The opposite shape is a stage of thousands of tasks holding a few rows each. Each task still has to be scheduled, started and finished, so the overhead outweighs the work it does.
- Then the plan. A stage whose tasks are even, fit their memory and are not tiny has the right partitions. What is left is the query itself: a shuffle it could avoid, or a step it repeats.
Only the last check needs the query itself.
Checkpoint
Check: Three shapes of a slow stage
Skew as a number: the largest partition against the mean
A ratio says more than a list of tasks. The run below builds 1,000,000 orders over 41 cities, sends four orders in five to city 0, and spreads them over 8 partitions twice, by a hash of the order id and of the city id, counting each partition’s rows with spark_partition_id().
spt_skew_session.py
"""Session driver: run skew_counts.py on PySpark 4.2.0 in local mode, inside a private lab folder.
Spark needs a Java runtime and the pyspark package. The driver points at the pinned ones (Java 21 and PySpark
4.2.0), gives Spark a home and a temporary folder inside the lab, binds it to 127.0.0.1 and prints only what the
program prints: configuration values and row counts, never Spark's own log lines or paths.
"""
import os
import pwd
import subprocess
import sys
import tempfile
from pathlib import Path
JAVA_HOME = os.path.join(pwd.getpwuid(os.getuid()).pw_dir, ".local", "share", "mise", "installs", "java",
"temurin-21.0.12+101.0.LTS")
HERE = Path(__file__).resolve().parent
PROGRAM = "skew_counts.py"
def pinned_pyspark():
"""The folder that holds the pinned pyspark 4.2.0 package, or None to use the one installed for this Python."""
for folder in [Path.cwd(), *Path.cwd().parents, *HERE.parents]:
for found in folder.glob("*/toolchains/pyspark-4.2.0/python"):
return str(found)
return None
with tempfile.TemporaryDirectory(prefix="spt-skew-") as lab:
for name in ("home", "tmp", "out"):
os.makedirs(os.path.join(lab, name))
java_home = JAVA_HOME if os.path.isdir(JAVA_HOME) else os.environ.get("JAVA_HOME", "")
env = {
"PATH": os.pathsep.join([os.path.join(java_home, "bin"), "/usr/bin", "/bin"]),
"JAVA_HOME": java_home,
"HOME": os.path.join(lab, "home"),
"TMPDIR": os.path.join(lab, "tmp"),
"WORK": os.path.join(lab, "out"),
"SPARK_LOCAL_IP": "127.0.0.1",
"PYSPARK_PYTHON": sys.executable,
"TZ": "UTC",
"LANG": "en_US.UTF-8",
}
package = pinned_pyspark()
if package:
env["PYTHONPATH"] = package
done = subprocess.run([sys.executable, str(HERE / PROGRAM)], cwd=lab, env=env,
capture_output=True, text=True, check=False)
print(done.stdout.replace(lab, "<lab>").rstrip())
if done.returncode != 0:
last = [line for line in done.stderr.splitlines() if line.strip()][-1:] or ["no message"]
print(PROGRAM, "stopped with exit status", done.returncode, "-", last[0].replace(lab, "<lab>"))
sys.exit(done.returncode) Output
PySpark 4.2.0, local mode on 2 cores spark.sql.adaptive.enabled = true (not set by this program) spark.sql.adaptive.skewJoin.enabled = true (not set by this program) orders = 1000000, cities = 41 city 0 holds 800000 orders (80.0 % of all); each other city holds 5000 hashed on order_id into 8 partitions (a key with 1,000,000 values) rows per partition: [124954, 124686, 124925, 125210, 125164, 125249, 125087, 124725] largest = 125249, mean = 125000.0, largest / mean = 1.00 the largest partition holds 12.5 % of the rows; 4 of 8 partitions hold less than the mean hashed on city_id into 8 partitions (a key with 41 values, one of them hot) rows per partition: [25000, 30000, 15000, 40000, 10000, 30000, 30000, 820000] largest = 820000, mean = 125000.0, largest / mean = 6.56 the largest partition holds 82.0 % of the rows; 7 of 8 partitions hold less than the mean
Recorded with Python 3.14.8 on macOS 26 arm64. To run it yourself: mise exec python@3.14.8 -- python3 spt_skew_session.py
skew_counts.py
"""PySpark 4.2.0, local mode: measure skew as a number, from the rows each partition holds.
One million generated orders over 41 cities, one of which takes 80 % of them, are spread over 8 partitions twice:
once by a hash of the order id, once by a hash of the city id. For each spread the program prints the rows of every
partition, the largest against the mean, and the share of all rows the largest partition holds. Every number is a
row count of a fixed dataset, so two runs print the same lines.
"""
from pyspark.sql import SparkSession
from pyspark.sql.functions import spark_partition_id
spark = (SparkSession.builder
# learn-lint-allow inclusive: PySpark 4.2.0 names the builder method that sets the cluster URL master()
.master("local[2]")
.appName("skew-as-a-number")
.config("spark.ui.enabled", "false")
.config("spark.driver.bindAddress", "127.0.0.1")
.config("spark.driver.host", "127.0.0.1")
.config("spark.driver.memory", "1g")
.config("spark.sql.shuffle.partitions", "8")
.getOrCreate())
spark.sparkContext.setLogLevel("ERROR")
print(f"PySpark {spark.version}, local mode on {spark.sparkContext.defaultParallelism} cores")
for key in ("spark.sql.adaptive.enabled", "spark.sql.adaptive.skewJoin.enabled"):
print(f"{key} = {spark.conf.get(key)} (not set by this program)")
# Every fifth order goes to one of cities 1 to 40 in turn; the other four in five go to city 0.
orders = (spark.range(0, 1000000)
.selectExpr("id as order_id",
"case when id % 5 = 0 then cast(1 + (id div 5) % 40 as int) else 0 end as city_id",
"cast(100 + pmod(hash(id), 900) as int) as amount"))
per_city = {row["city_id"]: row["count"] for row in orders.groupBy("city_id").count().collect()}
others = sorted({count for city, count in per_city.items() if city != 0})
print(f"\norders = {sum(per_city.values())}, cities = {len(per_city)}")
print(f"city 0 holds {per_city[0]} orders ({100 * per_city[0] / sum(per_city.values()):.1f} % of all); "
f"each other city holds {', '.join(str(c) for c in others)}")
def rows_per_partition(df):
"""The row count of every partition of df, in partition order, with 0 for a partition that holds no rows."""
held = {row["part"]: row["count"]
for row in df.withColumn("part", spark_partition_id()).groupBy("part").count().collect()}
return [held.get(part, 0) for part in range(df.rdd.getNumPartitions())]
def report(label, df):
"""Print one spread: the rows of each partition, the largest against the mean, and its share of all rows."""
rows = rows_per_partition(df)
mean = sum(rows) / len(rows)
print(f"\n{label}")
print(f" rows per partition: {rows}")
print(f" largest = {max(rows)}, mean = {mean:.1f}, largest / mean = {max(rows) / mean:.2f}")
print(f" the largest partition holds {100 * max(rows) / sum(rows):.1f} % of the rows; "
f"{sum(1 for r in rows if r < mean)} of {len(rows)} partitions hold less than the mean")
report("hashed on order_id into 8 partitions (a key with 1,000,000 values)", orders.repartition(8, "order_id"))
report("hashed on city_id into 8 partitions (a key with 41 values, one of them hot)", orders.repartition(8, "city_id"))
spark.stop() Both spreads put 1,000,000 rows into 8 partitions, a mean of 125,000 each, so the largest partition of each spread says how even it is:
| Spread by | Largest partition | Largest / mean |
|---|---|---|
order_id |
125,249 | 1.00 |
city_id |
820,000 | 6.56 |
Hashed on the order id, the largest partition is 249 rows above the mean. Hashed on the city id, one holds 82.0 % of the rows, city 0’s 800,000 plus four small cities, so its task does the work of 6.56 average ones on any cluster. The run also printed spark.sql.adaptive.enabled and spark.sql.adaptive.skewJoin.enabled as true, though the program sets neither. What each shape needs next:
- For a skewed sort-merge or shuffled hash join, AQE’s skew-join handling: Adaptive query execution splitting an oversized partition of a sort-merge or shuffled hash join into several tasks at runtime, switched by spark.sql.adaptive.skewJoin.enabled, which is on by default. splits the hot partition into several tasks by itself.
- Where it cannot, salting: Adding a computed suffix to a hot key so that its rows spread over several partitions, then combining the parts in a second step that removes the suffix. adds a computed suffix to the hot key so its rows spread out, and a second aggregation removes it.
- For spills, more shuffle partitions or narrower rows shrink each task’s share before anyone adds memory.
- caching: Keeping a DataFrame's computed rows in executor memory or on local disk, so that later actions that read the DataFrame use those rows instead of computing it again. keeps a DataFrame that several actions reuse, and
unpersist()releases it afterwards. - Sizing works the partition count out from the data size, then the cores and the executor memory: The heap memory of each Spark executor process, set by spark.executor.memory and shared by the tasks that run in that executor..
Checkpoint
Check: Skew as the largest partition against the mean
Practise
Six questions on the three shapes and the skew ratio, each choice explained once you answer.
Practice that adapts to you
Practise this lesson
6 questions about 7 ideas of this lesson. Each answer is explained at once, and what you get right is kept in this browser.
Skew: what adaptive execution splits, and the salting that follows when it cannot Pro: opens with a Pro, Premium or Ultimate pass
Adaptive skew handling split the hot city’s join evenly in one run and left it whole in another, on the same rows. The cases below show what decides that, and what a salt changes.
In this part:
- What the split did to one hot partition, case by case
- When the split steps aside, and what forcing it costs
- Salting the hot key, and the aggregation that never needed it
1 runnable example (skew_join.py, spt_join_session.py, spt_plan.py) · about 7 minutes
Opening this part with your pass…
This part opens with any MySmartCoPilot pass.
- Pro pass · 1 week₹79$2.99
- 30 days₹179$5.99
- 1 year₹1,699$45
One payment; a pass never renews. The price shown is the total you pay: no fees or charges are added at checkout.
Passes can’t be bought right now, so this part can’t be opened at the moment. The free part above stands on its own. If you have a pass, enter your licence key.
It also opens every other in-depth part in Learn and every Pro tool. Sales are final, except where the law gives you a right to withdraw, for example in the EU and the UK (refund policy).
Spills and caching, decided from counts Pro: opens with a Pro, Premium or Ultimate pass
A spill and a needless cache both spend memory a task needed, and both show in counts. One run reproduces a spill and removes it two ways; the other counts what a cache saves, and when it answers with stale rows.
In this part:
- A spill reproduced, and the two changes that removed it
- What a cache saved, counted in rows
- When a cache serves stale rows, and the other cases against one
2 runnable examples (cache_reuse.py, spill_partitions.py, spt_cache_session.py, spt_plan.py, spt_spill_session.py) · about 7 minutes
Opening this part with your pass…
This part opens with any MySmartCoPilot pass.
- Pro pass · 1 week₹79$2.99
- 30 days₹179$5.99
- 1 year₹1,699$45
One payment; a pass never renews. The price shown is the total you pay: no fees or charges are added at checkout.
Passes can’t be bought right now, so this part can’t be opened at the moment. The free part above stands on its own. If you have a pass, enter your licence key.
It also opens every other in-depth part in Learn and every Pro tool. Sales are final, except where the law gives you a right to withdraw, for example in the EU and the UK (refund policy).
Sizing partitions, cores and memory, worked from the data size Pro: opens with a Pro, Premium or Ultimate pass
Partitions, cores and executor memory follow from one number, the data a stage reads, and a few documented defaults; a model then shows why cores added to a skewed stage change almost nothing.
In this part:
- Partitions and cores worked out from the data size
- Memory per running task, from the documented heap layout
- Why more cores cannot rescue a skewed stage, as a model
- The order of changes, and why memory comes last
- Name a slow stage's shape and the first change to make
- Results of the sample tests
- Key takeaways from these parts
1 diagram · 1 runnable example (Python) · 1 exercise with sample tests · about 9 minutes
Opening this part with your pass…
This part opens with any MySmartCoPilot pass.
- Pro pass · 1 week₹79$2.99
- 30 days₹179$5.99
- 1 year₹1,699$45
One payment; a pass never renews. The price shown is the total you pay: no fees or charges are added at checkout.
Passes can’t be bought right now, so this part can’t be opened at the moment. The free part above stands on its own. If you have a pass, enter your licence key.
It also opens every other in-depth part in Learn and every Pro tool. Sales are final, except where the law gives you a right to withdraw, for example in the EU and the UK (refund policy).
Interview questions
Warm-up: what is data skew in Spark, and how do you see it before you change anything?
Data skew is an uneven spread of rows over the partitions of a stage, nearly always because one key value is far more common than the rest. Spark sends every row of a key to the partition its hash picks, so a hot key fills one partition and its task becomes the straggler the stage waits for. You see it by counting rows per partition with spark_partition_id() and comparing the largest with the mean, or on the stage page as the one task with a far larger shuffle read. A ratio near 1 is an even stage; the hot city here gave 6.56.
Three questions carry the same diagnosis into cases with numbers:
-
Mid to senior
Every night, 300 million payments are joined to a merchants table too large to broadcast. Adaptive execution and its skew-join handling are on, yet one task of the join stage still runs for most of the job, holding a single merchant's rows. How do you work out why, and what do you change?
-
Mid to senior
A team caches every intermediate DataFrame of a nightly job to make it faster. Since then its widest stage spills in every task, and one report showed rows from the night before. What is going on, and what do you change?
-
Senior to staff
A new nightly job will read about 2.4 billion rows of roughly 150 bytes each once decoded, shuffle them once and write the result. Size its shuffle partitions, cores and executors with the arithmetic shown, and say what you would check after its first run.
Flashcards · 6 cards
Recall it, then check
Say the answer to yourself before you look: recalling it is what makes it stick.
-
A stage has 7 of 8 tasks finished and one still running. What do you look at first?
The rows each task holds. In the lesson that one task held 820,000 of the stage's 1,000,000 rows, and adding cores would not take a single row off it.
-
Which three shapes explain most slow Spark stages?
Skew, with one task far above the rest; spills, with every task too large for its memory; and thousands of near-empty tasks whose start costs more than their work.
-
How do you put a number on skew?
Count each partition's rows with spark_partition_id() and divide the largest by the mean. Hashed on the city id, one of 8 partitions held 82.0 % of 1,000,000 rows, 6.56 times the mean.
-
What does adaptive query execution do, and is it on in Spark 4.2?
It re-plans a query between stages from the sizes that finished stages report. AQE and its skew-join handling are both on by default; the skew handling covers the two joins that shuffle both sides.
-
Where does the Spark UI report a spill?
On the stage page, for each task, as shuffle spill (memory) and shuffle spill (disk).
-
Which Spark cost shows up in no task count?
A DataFrame that two actions read is computed twice unless it is cached; unpersist() frees the cache once the actions are done.
Key takeaways
- A slow stage usually has one of three shapes: one task far above the rest, every task too large for its memory, or thousands of near-empty tasks.
- Counting rows per partition turns skew into a number: the hot city put 820,000 of 1,000,000 rows into one of 8 partitions, 6.56 times the mean.
- Adaptive execution (AQE) and its skew-join handling are on by default in PySpark 4.2.0, and the skew handling is documented for sort-merge and shuffled hash joins.
Terms in this lesson
- caching
- Keeping a DataFrame's computed rows in executor memory or on local disk, so that later actions that read the DataFrame use those rows instead of computing it again.
- data skew
- An uneven spread of rows over the partitions of a stage, usually because one key value is far more common than the others, so the task that holds that key does most of the stage's work.
- executor memory
- The heap memory of each Spark executor process, set by spark.executor.memory and shared by the tasks that run in that executor.
- salting
- Adding a computed suffix to a hot key so that its rows spread over several partitions, then combining the parts in a second step that removes the suffix.
- skew-join handling
- Adaptive query execution splitting an oversized partition of a sort-merge or shuffled hash join into several tasks at runtime, switched by spark.sql.adaptive.skewJoin.enabled, which is on by default.
- storage level
- The way Spark keeps a cached or persisted dataset: in memory, on disk or both, as objects or as serialized bytes, and on how many nodes.
- straggler
- A task that runs far longer than the other tasks of its stage, so the whole stage, and every stage after it, waits for that one task to finish.
References
- Apache Spark 4.2.0: SQL performance tuning (The Apache Software Foundation)
- Apache Spark 4.2.0: tuning Spark (The Apache Software Foundation)
- Apache Spark 4.2.0: monitoring and the web UI (The Apache Software Foundation)
- Apache Spark 4.2.0: configuration (The Apache Software Foundation)
- Apache Spark 4.2.0: RDD programming guide (RDD persistence) (The Apache Software Foundation)
- Apache Spark 4.2.0: hardware provisioning (The Apache Software Foundation)
- Spark 4.2.0 release notes (The Apache Software Foundation)
- PySpark 4.2.0 API: DataFrame.cache (The Apache Software Foundation)
- PySpark 4.2.0 API: DataFrame.persist (The Apache Software Foundation)
- PySpark 4.2.0 API: DataFrame.unpersist (The Apache Software Foundation)
- PySpark 4.2.0 API: StorageLevel (The Apache Software Foundation)
- PySpark 4.2.0 API: Catalog.refreshByPath (The Apache Software Foundation)
- PySpark 4.2.0 API: SparkSession.newSession (The Apache Software Foundation)
- PySpark 4.2.0 API: spark_partition_id (The Apache Software Foundation)
Report a problem with this lesson
Kept only in this browser. Your Learn progress