Interview Prep/Spark & Distributed Processing

Advanced12 min read

Spark Performance Tuning and Data Skew

The practical playbook for making a slow Spark job fast — spotting skew, choosing the right join strategy, fixing small files, and reading the Spark UI like an engineer, not a tourist.

This is where senior candidates are separated from mid-level ones. Anyone can say 'add more executors.' You should be able to say which metric you looked at, what it told you, and why your fix addressed the actual bottleneck.

Step 1 — Diagnose before you tune

Symptom in the Spark UILikely causeFix
One task runs 30× longer than the median in a stageData skew — one key holds most of the rowsSalting, AQE skew join, or broadcast the small side
High 'Shuffle Spill (Disk)'Partitions too big for executor memoryMore shuffle partitions, or more memory per executor
Thousands of tiny tasks, low CPU usageToo many partitions / small filescoalesce, compaction, or fewer shuffle partitions
Long GC time on executorsOver-caching or too-large partitionsUnpersist, reduce partition size, tune memory fraction
Stage reads far more data than expectedNo predicate/partition pushdownFilter on the partition column; avoid UDFs before the filter

Step 2 — Kill the skew

Skew is the number-one cause of 'my job hangs at 199/200 tasks.' In telecom data it is everywhere: one busy cell site, one default unknown device id, one null subscriber key holding millions of rows.

# 1. CONFIRM the skew — never guess
(df.groupBy("cell_site_id")
   .count()
   .orderBy(F.col("count").desc())
   .show(20))

# 2a. Easiest fix: let AQE handle it (Spark 3+)
spark.conf.set("spark.sql.adaptive.enabled", "true")
spark.conf.set("spark.sql.adaptive.skewJoin.enabled", "true")

# 2b. Broadcast the small side so there is no shuffle at all
result = big_df.join(F.broadcast(small_dim), "cell_site_id")

# 2c. Salting — when one key is genuinely enormous
SALT = 16
left = big_df.withColumn("salt", (F.rand() * SALT).cast("int"))
right = (small_df
    .withColumn("salt_arr", F.array([F.lit(i) for i in range(SALT)]))
    .withColumn("salt", F.explode("salt_arr"))
    .drop("salt_arr"))

joined = left.join(right, ["cell_site_id", "salt"]).drop("salt")

Tip — Explain salting in one breath

'I add a random number 0–15 to the hot key on the big side, and replicate the small side 16 times, one copy per salt value. Now that one hot key becomes 16 medium keys spread across 16 tasks instead of crushing one.'

Step 3 — Pick the right join strategy

StrategyHow it worksBest when
Broadcast hash joinSmall table is copied to every executor; no shuffle on the big sideOne side fits in memory (default threshold 10 MB; often raised to 100–500 MB)
Sort-merge joinBoth sides shuffled by key, sorted, then mergedTwo large tables — the default for big-to-big
Shuffle hash joinBoth shuffled; one side built into a hash mapMedium tables where sorting is wasteful
# Raise the broadcast threshold when your dimension is bigger than 10 MB
spark.conf.set("spark.sql.autoBroadcastJoinThreshold", 200 * 1024 * 1024)

# Verify what Spark actually chose — do this, do not assume
joined.explain(mode="formatted")   # look for BroadcastHashJoin vs SortMergeJoin

Step 4 — Fix the small-file problem

Streaming jobs and over-partitioned batch writes create millions of tiny files. Every file costs a listing call and a task, so reads get slower and slower over time.

  • Target file sizes of roughly 128 MB–1 GB
  • coalesce() before writing, or set spark.sql.adaptive.coalescePartitions.enabled
  • Run a compaction job that rewrites yesterday's small files into big ones
  • Do not over-partition on disk — partitioning by date/hour/site when a site has 10 rows an hour is the cause, not the cure

Step 5 — Memory and resource sizing

# A sane starting point for a large batch job
--executor-memory 16g          # heap per executor
--executor-cores 4             # 3-5 is the sweet spot for HDFS/GCS throughput
--num-executors 40
--conf spark.memory.fraction=0.6            # execution + storage share
--conf spark.sql.shuffle.partitions=800     # ~= 2-3x total cores
--conf spark.sql.adaptive.enabled=true

Watch out — Why not 1 executor with 64 cores?

More than about 5 cores per executor causes HDFS/GCS throughput to collapse from I/O contention, and one huge JVM means brutal garbage-collection pauses. Several medium executors beat one giant one — a classic follow-up question.

Practice questions

QYour daily Spark job suddenly takes 4 hours instead of 40 minutes. Nothing in the code changed. What do you check?show

Something in the data or the cluster changed. Compare a good run to a bad run in the Spark History Server rather than tweaking configs blindly.

  • Input volume — did the source double? Did a backfill land in the same partition?
  • Skew — check task duration distribution in the slow stage; a new hot key is the most common cause
  • Small files — did an upstream streaming job start emitting millions of tiny files?
  • Cluster contention — did another team's job take the queue/slots?
  • Data layout — did someone drop the partitioning, so you now scan the full history?

Likely follow-up: Which Spark UI page tells you it is skew and not just volume?

QWhat is Adaptive Query Execution and what does it actually change?show

AQE (Spark 3+) re-optimises the physical plan at runtime using real statistics from completed stages, instead of relying only on estimates made before the job started.

  • Coalesces shuffle partitions — fixes the 'default 200 partitions' problem automatically
  • Switches sort-merge join to broadcast when a side turns out smaller than estimated
  • Splits skewed partitions into smaller sub-partitions when skewJoin is enabled
  • Honest caveat: it helps a lot but does not fix badly modelled data or missing partition filters
QHow do you decide between Spark and BigQuery for a transformation?show

If the work is expressible in SQL over data already in BigQuery, do it in BigQuery — no cluster to manage and the optimiser is excellent. Reach for Spark when you need something SQL cannot do well.

  • BigQuery — SQL aggregations, joins, dbt models, anything already warehouse-resident
  • Spark — complex procedural logic, ML feature pipelines, custom file-format parsing, heavy non-SQL transforms
  • Cost angle — BigQuery charges per byte scanned (or per slot); Spark charges for cluster uptime whether busy or idle
  • In a migration, moving Hive/Spark SQL batch jobs to BigQuery-native SQL is usually the biggest win
QWhat causes an OutOfMemoryError on the driver versus on an executor?show

They have different causes and different fixes — knowing which is which is a strong signal.

  • Driver OOM — usually collect(), toPandas(), a huge broadcast, or too many tasks' metadata. Fix: do not pull data to the driver
  • Executor OOM — partitions too large, heavy skew, too much cached data, or an expensive UDF. Fix: more/smaller partitions, unpersist, fix skew