◐ ai-teacher

🔥 Spark & Databricks · Chapter 9

Reading the Spark UI to find the bottleneck

Every slow Spark job has the reason printed in the UI. Most people look at the wrong tab.

Study this lesson free in the app →

The basics

The Spark UI is the web dashboard every Spark application serves (port 4040 on the driver, the 'Spark UI' link in Databricks, or the History Server after the job ends). It shows what actually ran: jobs, stages, tasks, the physical plan with real row counts, and executor health.

Why it matters: guessing is expensive. Doubling a cluster to fix skew wastes money; adding partitions to fix a driver-side bottleneck changes nothing. The UI tells you which kind of slow you have, and each kind has a different fix.

Mental model: a hospital chart. You don't start surgery because the patient 'feels slow' — you read temperature, blood pressure and bloods first. In Spark the vitals are: where the time went (which stage), how it was spread (task distribution), whether memory coped (spill, GC), and whether the plan was sensible (rows read vs needed).

Words you must know 5

Stage summary metrics
min / 25th / median / 75th / max for each task metric
Spill
data that overflowed execution memory to disk
GC time
time the JVM spent garbage collecting instead of working
Shuffle read / write
bytes moved through each shuffle
SQL / DataFrame tab
the plan drawn as a graph, with real metrics per operator

Explained

Meghalaya Cabs' payout job went from 25 minutes to 2 hours. Three engineers, three theories: 'the cluster is too small', 'Delta is slow', 'the data grew'.

The Stages tab took ten seconds to settle it: one stage, 400 tasks, median 9 seconds, max 96 minutes, 31GB spilled by that single task. A new corporate fleet account had joined — 22% of all rides under one driver_id.

None of the three theories would have fixed it.

The ward round

A junior doctor sees a patient who 'isn't well' and wants to order every scan. The senior doctor reads the chart first: temperature normal, blood pressure normal, oxygen 88%.

That one number points to the lungs, and the next ten minutes go there instead of everywhere.

The Spark UI is the chart. Duration tells you which organ, the task distribution tells you if it's one spot or everywhere, spill and GC tell you if memory is struggling, and the plan tells you whether it's doing far more work than it should.

The diagnostic path

Top-down: application → query → stage → task → executor. Stop as soon as one metric explains the time.

Four stage signatures

                 Duration p25/med/p75/max    Spill(disk)  Verdict
A  skew          10s / 12s / 15s / 96min     31GB (1 task) one hot key
B  too big       6m / 7m / 8m / 9m           1.4TB        partitions too large
C  too many      8ms / 11ms / 15ms / 40ms    0            50k tiny tasks, overhead
D  healthy       40s / 52s / 61s / 80s       0            just volume

Executors tab                      Verdict
GC Time 41% of Task Time (red)     memory pressure: fat executors,
                                   over-caching, big UDF objects
Tasks: 312 failed, 6 executors lost   OOM or spot reclaim; check logs

A needs salting/AQE/broadcast. B needs more shuffle partitions or memory per task. C needs fewer partitions or compacted files. D needs more slots — the only case where a bigger cluster is the answer.

Reading the SQL tab plan graph

Scan parquet silver.rides
  number of files read: 41,280        ← expected ~120 for one day
  size of files read: 2.1 TB
  number of output rows: 9,812,004,551
      │
Filter (pickup_ts >= 2026-11-04 ...)
  number of output rows: 13,440,118   ← 99.9% thrown away AFTER reading
      │
Exchange hashpartitioning(driver_id, 200)
  shuffle bytes written: 1.9 GB
      │
SortMergeJoin
  spill size: 0

Read it bottom-up from the scan. The red flag isn't the join — it's 41,280 files and 9.8 billion rows read to keep 13 million. The filter is on pickup_ts while the table is partitioned by ride_date, so no partition pruning happened.

# confirm before changing anything
spark.table('silver.rides').filter(F.col('pickup_ts') >= '2026-11-04').explain('formatted')
# PartitionFilters: []   ← empty: nothing pruned

Fix: add ride_date = '2026-11-04' to the filter. Expected: files read drops to ~120.

The metrics that matter

Stage summary (per task):

Event timeline — coloured bars per task. One long bar alone = straggler. Long green 'executor computing' everywhere = genuine work. Lots of orange/red slivers = overhead.

Jobs timeline — gaps where no job runs are driver time: listing millions of files, collect(), toPandas(), Python loops issuing one small job each.

AQE note — with AQE on, the SQL tab shows the final plan (isFinalPlan=true) with AQEShuffleRead nodes noting coalesced or skew-split partitions.

At 100×

Jobs with hundreds of stages — don't read them all. In the Stages tab, sort completed stages by duration; the top three usually own 80% of the time.

Long-running and finished apps — the live UI disappears with the driver. Enable event logs (spark.eventLog.enabled) and use the History Server; Databricks keeps them per cluster for a while.

Turn findings into alerts. Once you know the signatures, track them automatically: task-duration max/p75 ratio, spill bytes, GC fraction, files read per query. A pipeline whose max/p75 ratio crept from 2 to 20 over a month is telling you a hot key is growing before it fails.

Compare runs, not single runs. A 2-hour run means little alone; next to last week's 25-minute run with the same stage IDs, the stage that grew is the answer.

Gotchas

Words to know

Summary metricsPer-stage min, 25th, median, 75th and max of each task metric; the shape reveals skew versus volume.
Spill (Disk)Bytes written to disk because a task's shuffle/sort/aggregation data exceeded available execution memory.
GC timeJVM garbage-collection time inside tasks; a large fraction signals memory pressure.
Event timelinePer-task bars across executors showing when each task ran and how its time divided.
Scheduler delayTime a task waited between being scheduled and starting; dominant when tasks are tiny and numerous.
Shuffle Read Blocked TimeTime tasks spent waiting for remote shuffle blocks to arrive.
History ServerService that rebuilds the Spark UI from event logs after an application has finished.

In two minutes

Test yourself

1. In stage summary metrics, which pattern signals skew?

  1. All percentiles roughly equal and high
  2. Max far greater than the 75th percentile
  3. Min equal to zero
  4. Median greater than the mean
Show the answer

Max far greater than the 75th percentile — Skew means a few tasks carry most of the data. Uniformly high percentiles indicate volume or oversized partitions instead.

2. Which tab shows the physical plan with real row counts and files read per operator?

  1. Environment
  2. Storage
  3. SQL / DataFrame
  4. Executors
Show the answer

SQL / DataFrame — The SQL tab draws the executed plan with per-operator metrics. Environment lists configs, Storage shows cached data, Executors shows per-executor resources.

3. What does a large Spill (Disk) on every task of a stage tell you?

  1. Each task's partition is too large for its execution memory
  2. The disk is slow
  3. Data is skewed
  4. The driver is overloaded
Show the answer

Each task's partition is too large for its execution memory — Spill happens when shuffle, sort or aggregation data exceeds execution memory. On every task, it means partitions are too big (or memory per task too small) — raise shuffle partitions or memory, or reduce cores per executor.

4. The jobs timeline shows 3 minutes of activity, then 18 minutes with no jobs, then 2 more minutes of jobs. What's the bottleneck?

  1. Skew
  2. Too few executors
  3. Shuffle spill
  4. Work on the driver between jobs — listing files, collect(), Python loops or planning
Show the answer

Work on the driver between jobs — listing files, collect(), Python loops or planning — When no job is running the executors are idle; the driver is doing something single-threaded. More executors cannot help.

5. A stage has 60,000 tasks with a median duration of 12ms and scheduler delay larger than compute time. Fix?

  1. Increase spark.sql.shuffle.partitions
  2. Reduce the number of partitions — compact small files or coalesce — so each task does meaningful work
  3. Add executors
  4. Turn on caching
Show the answer

Reduce the number of partitions — compact small files or coalesce — so each task does meaningful work — Per-task overhead (scheduling, deserialisation) dwarfs the work. Fewer, larger tasks (100MB–200MB) are faster. More partitions makes it worse.

6. Executors tab shows GC Time at 40% of Task Time on all executors, each configured with 16 cores. Most likely remedy?

  1. Fewer cores per executor (more executors) and less caching, so each task's heap share is larger and GC pauses shorter
  2. Increase spark.sql.shuffle.partitions only
  3. Raise autoBroadcastJoinThreshold
  4. Use coalesce(1)
Show the answer

Fewer cores per executor (more executors) and less caching, so each task's heap share is larger and GC pauses shorter — 16 concurrent tasks share one heap; memory churn triggers long GC. Thinner executors, unpersisting unused caches, or avoiding heavy UDF objects reduce pressure.

There are 8 questions, 3 puzzles and 5 recall cards for this lesson in the app — the recall cards come back on later days, just before you would forget.

Learn it so it stays. Read it, test yourself, then recall it on two different days. The app schedules that for you, free.

Start this lesson →