Spark Core · Module 5 of 5
Finding and fixing slow Spark jobs
Interviewers rarely ask "what is skew?" on its own. They describe a slow or failing job and watch how you investigate. Learn the flow first, then the six scenarios it leads to.
The debug flow
Start in the Spark UI: Jobs → the slowest job → Stages → the slowest stage → its summary metrics (min / 25th / median / 75th / max per task). Most answers come from comparing median and max, and from the spill and shuffle columns. Click an outcome to open that scenario.
Where each signal lives in the Spark UI
| Signal | Where to look |
|---|---|
| Which job/stage is slow | Jobs and Stages tabs, sort by duration |
| Skew | Stage → Summary Metrics: Duration and Shuffle Read Size, max vs median; Event Timeline with one long bar |
| Spill | Stage → Spill (Memory) and Spill (Disk) columns |
| GC pressure | Stage task table → GC Time; Executors tab → GC time vs task time (over ~10% is a warning) |
| Failures | Stage → failed tasks → error column; Executors tab → dead executors and logs |
| Final plan, rows per operator | SQL / DataFrame tab: the plan after AQE, with row counts and sizes per node |
Six scenarios
Each one: what you see, why it happens, how to confirm it, how to fix it, and a short interview answer. Metrics are representative examples.
Reducing shuffle: the checklist
- Read less. Filter and select columns as early as possible; make sure filters reach the scan (PushedFilters / PartitionFilters).
- Broadcast small sides. Raise the threshold or add a hint for dimension tables up to a few hundred MB.
- Pre-aggregate before joining. Aggregate the fact to the grain you need, then join the smaller result.
- Remove accidental shuffles. Unneeded
distinct,orderBybefore a write,repartitionbefore a join that reshuffles anyway, window functions with a differentpartitionByeach time. - Shuffle once, reuse. Several joins or aggregations on the same key in a row: Spark reuses an existing partitioning on that key. Bucketing (or co-located clustering) avoids the shuffle for repeated joins on large tables.
- Right-size what's left. Let AQE coalesce, or set
spark.sql.shuffle.partitionsso each shuffle partition is roughly 100–200 MB.
repartition vs coalesce
| repartition(n) / repartition(col) | coalesce(n) | |
|---|---|---|
| Shuffle | Yes (wide) | No (narrow) |
| Direction | Increase or decrease | Decrease only |
| Result | Evenly sized; by column = same key in same partition | Uneven if inputs were uneven |
| Use for | Fixing skew/too few partitions, increasing parallelism, partitioning before a partitionBy write | Cutting the number of output files cheaply after a filter |
df.filter(...).groupBy(...).agg(...).coalesce(1).write can run the whole final stage in a single task. If upstream work is heavy, repartition(1) is faster: the heavy work stays parallel and only the final write is single-threaded.Partition sizing
Rule of thumb: aim for roughly 100–200 MB per partition in memory and at least 2–3 tasks per core.
Executor sizing
Classic YARN/Kubernetes approach: about 5 cores per executor (good I/O throughput without too many threads sharing a heap), one core and ~1 GB per node left for the OS and daemons, and ~10% of memory for overhead.
One executor slot is subtracted for the YARN application master in cluster mode. On Databricks there is one executor per worker, so you size by instance type and worker count; the same per-core memory reasoning applies.
How to choose, in an interview
- Too-fat executors (1 executor × 15 cores × 60 GB): long GC pauses, HDFS/S3 client contention.
- Too-thin executors (1 core each): no shared broadcast variables or cache, more JVM overhead, more executors to manage.
- Memory-hungry stages (big joins, wide rows): fewer cores per executor or memory-optimised instances, so each task gets more memory.
- CPU-bound stages (parsing, UDFs, compression): compute-optimised instances, more cores.
- Variable load: dynamic allocation or autoscaling with sensible min/max.
Cache vs persist
cache()ispersist()with the default level: memory-only for RDDs, memory-and-disk for DataFrames.persist(StorageLevel.X)lets you choose:MEMORY_ONLY,MEMORY_AND_DISK,DISK_ONLY, serialised variants, and_2replicated variants.- Caching is lazy: nothing is stored until an action runs. Check the Storage tab to see what's cached and what fraction fits.
- Cache when a DataFrame is reused by several actions and is expensive to recompute. Don't cache something used once or cheap to re-read from columnar files.
- Call
unpersist()when done; cached data competes with execution memory. - Alternatives:
checkpoint()or writing an intermediate table to truncate very long lineage in iterative jobs; on Databricks the disk cache speeds up repeated Parquet/Delta reads automatically.
Check yourself
"Our nightly job went from 1 hour to 4 hours. Nothing changed in the code." Walk me through it.
199 tasks finish in 30 s, one takes 45 min. Fix it in three ways.
When would you increase spark.sql.shuffle.partitions, and when decrease it?
Is spill always bad?
Your write produces 20,000 small files. Options?
partitionBy), enable optimised writes or auto-compaction on Delta, run OPTIMIZE, and avoid partitioning on high-cardinality columns.