When a customer says "the job used to take 20 minutes, now it takes 2 hours", the Spark UI almost always shows why. Here's where to look, in order.
1. Jobs and stages: where did the time go?
Open Jobs, sort by Duration, and click the slowest job, then its slowest stage. Usually one stage accounts for most of the runtime.
2. Stage summary metrics
On the stage page, look at Summary Metrics for Completed Tasks:
| Pattern | Meaning |
|---|---|
| Max duration ≫ median (10×+) | Skew: a few tasks process most of the data |
Spill (Memory) / Spill (Disk) above 0 | Partitions don't fit in memory. Use more partitions or bigger executors. |
High Shuffle Read Size everywhere | Wide shuffles. Check the join strategy. |
| Thousands of tiny tasks of a few ms | Too many partitions or small files |
Long Scheduler Delay / Task Deserialization | Driver overloaded, or a huge closure (a big broadcast variable) |
High GC Time (> 10% of task time) | Executor memory pressure |
3. SQL / DataFrame tab: the physical plan
Open the query, then Details. Things to check:
- Join type:
SortMergeJoinon a small table that should beBroadcastHashJoin. Small tables overspark.sql.autoBroadcastJoinThreshold(10 MB by default) won't broadcast. - Scan nodes:
number of files readandfiles pruned. If filters on partition columns don't prune, the filter is probably on a transformed column (to_date(ts)instead of the partition column). - AQE notes: with Adaptive Query Execution, look for
AQEShuffleReadand the "skew" markers that show AQE split skewed partitions.
4. Executors tab
- Dead executors with exit code 137 or 143 → killed for memory. Raise memory overhead, or reduce partition size.
- Uneven input per executor → skew, or data locality problems.
Fixes by pattern
# Skew: let AQE handle it, or salt the hot key
spark.conf.set("spark.sql.adaptive.enabled", "true")
spark.conf.set("spark.sql.adaptive.skewJoin.enabled", "true")
# Too many small partitions after a filter
df = df.coalesce(64)
# Spills: more, smaller shuffle partitions (or let AQE coalesce)
spark.conf.set("spark.sql.shuffle.partitions", "800")
# Force a broadcast of a known-small dimension
from pyspark.sql.functions import broadcast
fact.join(broadcast(dim), "customer_id")
What changed?
If the job got slower without any code change, compare with an older run:
- Input size: did the data grow, or did a new partition become huge (often a backlog or a reprocessing day)?
- File count: a new upstream writer producing thousands of small files.
- Cluster or runtime change: a smaller node type, Spot capacity loss, or a new runtime with a different default.
Tip
Save the Spark event logs (cluster log delivery). Without them, the Spark UI of a finished job on a terminated cluster is gone, and so is your evidence.