4.8 Spark performance & tuning¶
Concept¶
Spark is fast because it works in parallel — but how it splits, shuffles, and joins data
decides whether a job takes 10 seconds or 10 minutes. This page covers the levers you'll actually
reach for: partitions, repartition vs coalesce, spark.conf.set, broadcast joins
(autoBroadcastJoinThreshold), caching, and the ideas behind them.
spark is pre-created (3.7); every snippet runs as-is in a notebook.
Partitions — the unit of parallelism¶
A Spark DataFrame is split into partitions — chunks processed in parallel, one per task, on the cluster's cores. Partitions are the reason Spark scales.
from pyspark.sql.functions import spark_partition_id
RAW = "s3a://demouser-lake/files/source/shopflow" # exported in 4.2 (your bucket)
df = spark.read.parquet(f"{RAW}/history/orders")
# how many chunks this DataFrame is split into
print(df.select(spark_partition_id()).distinct().count())
- Too few partitions → cores sit idle, no parallelism, big tasks.
- Too many tiny partitions → scheduling overhead dominates ("small files problem").
- Rule of thumb: aim for partitions in the ~100 MB range, and at least as many as you have cores.
Where partitions come from: the source (files/blocks), and shuffles (see below). Reading a folder of Parquet gives roughly one partition per file/block.
Narrow vs wide transformations (and the shuffle)¶
- Narrow (
select,filter,withColumn) — each output partition depends on one input partition. No data movement — cheap. - Wide (
groupBy,join,distinct,orderBy) — output partitions need data from many input partitions, so Spark shuffles: writes data across the network and re-partitions by key. Shuffles are the expensive part of most jobs.
The number of partitions after a shuffle is controlled by:
200 is fine for big clusters but wasteful for small/learning data (200 near-empty tasks). Lower it for this stack.repartition vs coalesce¶
Both change the partition count, but differently:
repartition(n) |
coalesce(n) |
|
|---|---|---|
| Direction | up or down | down only |
| Shuffle? | yes (full reshuffle) | no (merges adjacent partitions) |
| Even sizes? | yes, balanced | can be uneven |
| Cost | higher | cheap |
| Can partition by column | yes: repartition("country") |
no |
# Fewer output files when writing (cheap — no shuffle):
df.coalesce(1).write.mode("overwrite").parquet("s3a://demouser-lake/files/tmp/orders_single")
# Rebalance / partition by a key before a heavy op (costs a shuffle, but evens skew):
df.repartition(8, "customer_id")
Use coalesce to reduce files before a write (avoid the small-files problem).
Use repartition to increase parallelism or to co-locate rows by a key (balances skew).
Step by step, with every output: 4.11 Partitions:
coalesce&repartition— including why one file can be several partitions.
spark.conf.set — tuning knobs at runtime¶
spark.conf.set("key", value) changes a Spark SQL setting for your session — no restart. The ones
worth knowing:
spark.conf.set("spark.sql.shuffle.partitions", 8) # post-shuffle partitions
spark.conf.set("spark.sql.autoBroadcastJoinThreshold", "50MB") # broadcast-join cutoff (below)
spark.conf.set("spark.sql.adaptive.enabled", True) # AQE — usually on by default in Spark 3+/4
# read one back
print(spark.conf.get("spark.sql.shuffle.partitions"))
spark.conf.set = session/runtime settings. Cluster-level things — cores, memory — are set when
the cluster starts, e.g. in spark-defaults.conf, not here.)
Broadcast joins & autoBroadcastJoinThreshold¶
Joining a big table to a small one? A normal join shuffles both — slow. A broadcast join sends a copy of the small table to every node, so the big table never shuffles. Much faster.
Spark does this automatically when the small side is under
spark.sql.autoBroadcastJoinThreshold (default 10 MB):
spark.conf.set("spark.sql.autoBroadcastJoinThreshold", "50MB") # broadcast bigger dimensions
# -1 disables auto-broadcast entirely
Or force it explicitly:
from pyspark.sql import functions as F
big = spark.table("my.silver.orders")
small = spark.table("my.silver.customers")
joined = big.join(F.broadcast(small), "customer_id") # broadcast the small side
Rule: broadcast the small dimension, keep the big fact table put. It turns a two-sided shuffle into a one-sided copy.
Caching / persist¶
If you reuse a DataFrame across several actions, cache it so Spark doesn't recompute the whole plan each time:
df = spark.table("my.silver.orders").filter("status = 'delivered'")
df.cache() # keep in memory after first action
df.count() # first action → computes & caches
df.groupBy("channel").count().show() # reuses the cache
df.unpersist() # free it when done
Partition pruning & predicate pushdown (free speed)¶
Spark skips data it doesn't need — if you let it:
- Partition pruning — the history data is laid out by
dt=...folders.WHERE dt = '2023-08-05'reads only that folder, not the whole dataset. - Predicate pushdown —
filter/WHEREand columnselectare pushed into the Parquet/Iceberg reader, so less data leaves disk.
# reads just one day's files, only two columns:
spark.read.parquet(f"{RAW}/history/orders") \
.where("dt = '2023-08-05'").select("order_id", "status").show()
Adaptive Query Execution (AQE)¶
Spark 3+/4 re-optimises at runtime using real data sizes — coalescing shuffle partitions,
switching to broadcast joins, handling skew — when spark.sql.adaptive.enabled is on (default). It
means the shuffle.partitions number matters less than it used to, but setting a sane value still
helps on small clusters.
Go deeper
- 4.9 How Spark runs — driver vs executors, the query lifecycle, how executors read the source in parallel, and the route of a join (broadcast vs sort-merge).
- 4.10 Data skew & salting — why one hot key stalls the whole job, and how to fix it.
- 4.11 Partitions —
coalescevsrepartitionin depth, files ↔ partitions. - 4.7 Table maintenance — fix the small-files problem at the table level
(
OPTIMIZE, Z-order,VACUUM).
Quick checklist¶
- Set
spark.sql.shuffle.partitionssensibly (low for this stack). coalesceto cut output files;repartitionto add parallelism / balance skew.- Broadcast the small side of a big↔small join (auto via
autoBroadcastJoinThreshold, orF.broadcast). - Filter and select early — lean on partition pruning + pushdown.
cacheonly DataFrames you reuse;unpersistwhen done.- Prefer fewer wide transformations — shuffles are the cost.
🎯 This runs unchanged on Azure, Databricks, Snowflake & Fabric¶
These are core Apache Spark concepts — identical on Azure Databricks and Fabric Spark
(same spark.conf, repartition/coalesce, broadcast joins, AQE). Databricks adds Photon +
auto-optimize on top, but the tuning model is exactly this.
You can now…¶
- Explain partitions and narrow vs wide transformations (and why shuffles cost)
- Choose
repartitionvscoalescecorrectly - Tune a session with
spark.conf.set(shuffle.partitions,autoBroadcastJoinThreshold) - Speed up big↔small joins with broadcast, and reuse work with cache
- Lean on partition pruning / pushdown and know what AQE does for you