Catalyst & Tungsten¶
A teammate opens a PR: a query joining a week of SaaS events to a 40 MB customer dimension, filtered to one region and one day. In review you ask for explain("formatted"). It shows a FileScan of the full 8 TB week and a SortMergeJoin — not the 40 GB, broadcast-joined plan either of you expected.
A. The optimiser has a bug. B. The filter is on a computed column (to_date(timestamp)), not the partition column, so pruning never fires. C. The dimension is over the broadcast threshold. D. AQE is disabled in this environment.
Pick one before reading on — the answer changes what you fix. You write a DataFrame query over a week of SaaS events. Spark does not “run the API.” It rewrites the query into something that might scan 40 GB of three columns instead of 8 TB of JSON-shaped Parquet, might filter before it joins, and might generate JVM bytecode that never boxes a Row.
If you do not read explain(), you are hoping Catalyst agrees with you. Production is not a hope.
Start with the situation¶
events.join(customers, "customer_id") \
.filter(F.col("region") == "eu-west-1") \
.filter(F.col("date") == "2024-01-15") \
.groupBy("service") \
.agg(F.avg("latency_ms"), F.count("*"))
events is date-partitioned Parquet, 8 TB for the week, 1.1 TB for the 15th. customers is 40 MB. region is a data column, not a partition.
What you want Catalyst to consider:
- Push
date =into partition pruning (only one day’s files). - Push
region =into Parquet row-group filters if stats allow. - Read only
customer_id, service, latency_ms, region, date. - Broadcast
customersinstead of shuffling 1.1 TB — if its measured size is under the broadcast threshold. Broadcast is not guaranteed: it depends on table/column statistics being present and accurate, AQE being enabled, and the join being broadcast-compatible. A hint (.hint("broadcast", ...)) is a request, not an order — Spark's own docs are explicit that join hints are not guarantees. - Partial aggregate
servicebefore any remaining exchange — the exact rewrite Catalyst produces depends on query shape and join semantics; do not assume this chain in general.
None of this is a guaranteed physical plan — it is the plan you should check for. If any of it is missing in explain("formatted") for this specific query, the job is wrong even if it “works” on a sample.
Observability: pruning on hour= is the difference between a 2 TB trace scan and a 12 GB one. CDC: MERGE plans that explode into Cartesian nested-loop joins. IoT: skipping the value column until the last projection.
Why the obvious approach breaks at scale¶
The optimiser is a pattern matcher, not an oracle:
- It cannot push a filter through a Python UDF. The UDF is a black box.
- It cannot prune
timestampif you partitioned ondateand filtered ontimestamp(unless Iceberg hidden partitioning). - Statistics may be stale (Hive) or absent (a pile of Parquet). AQE exists because compile-time stats lie.
- Whole-stage codegen fails open to interpreted execution on some expressions; you will not notice except in CPU.
- AQE changes the plan mid-job. The plan you pasted in the PR may not be the plan that ran.
At 8 TB, a missed pushdown is an incident. At 80 MB, it is a lab.
Build the mental picture¶
Two layers:
- Catalyst — what operators, in which order, with which join algorithm.
- Tungsten — how a stage runs: compact rows, off-heap, generated loops.
SQL / DataFrame
→ Unresolved logical plan
→ Analysis (names, types)
→ Logical optimisation (rules)
→ Physical planning (join strategy, exchanges)
→ Codegen (Tungsten)
→ Tasks
Your job is to write queries the optimiser can see through: native functions, explicit schemas, partition columns in WHERE, small dimensions that stay small.
Under the hood¶
Catalyst pipeline¶
Predicate pushdown. Filter before join/scan.
# You write join then filter
events.join(customers, "customer_id").filter(events.region == "eu-west-1")
# Catalyst rewrites to filter events first (and maybe customers)
Column pruning. Parquet ReadSchema lists only needed fields. SELECT * then groupBy is how you disable this.
Partition pruning. PartitionFilters: [date=2024-01-15] in FileScan. If you do not see it, you are paying for the week.
Constant folding / simplify. where(true), dead casts.
Join selection (physical):
| Strategy | When | Shuffle |
|---|---|---|
| Broadcast hash join (BHJ) | One side < threshold (10 MB default) | Small side to all executors |
| Sort-merge join (SMJ) | Both large, equi-join | Both sides |
| Shuffle hash join | Sometimes, smaller build side | Both sides |
| Broadcast nested loop | Non-equi / no condition | Danger — Cartesian |
Partial vs final HashAggregate. count/sum combine map-side. countDistinct and collect_list do not shrink the same way.
Tungsten¶
- UnsafeRow: bytes, not JVM objects. Fewer allocations, worse to look at in a debugger.
- Whole-stage codegen: fuse a pipeline (
FileScan → Filter → Project → PartialAgg) into one loop. Stage boundary (Exchange) breaks fusion — another reason shuffles cost CPU beyond the NIC. - Off-heap / unified memory: execution vs storage (
spark.memory.fraction,storageFraction). Cache fights shuffle buffers.
If codegen bails (WholeStageCodegen not wrapping your operators), you are back to Volcano-style virtual calls. explain shows it.
Adaptive Query Execution (Spark 3.x)¶
Compile-time \(R=200\) is a guess. AQE reoptimises after each shuffle using map-output stats.
spark.conf.set("spark.sql.adaptive.enabled", "true")
spark.conf.set("spark.sql.adaptive.coalescePartitions.enabled", "true")
spark.conf.set("spark.sql.adaptive.advisoryPartitionSizeInBytes", str(128 * 1024 * 1024))
spark.conf.set("spark.sql.adaptive.skewJoin.enabled", "true")
spark.conf.set("spark.sql.adaptive.skewJoin.skewedPartitionFactor", "5")
spark.conf.set("spark.sql.adaptive.localShuffleReader.enabled", "true")
# optional: let AQE broadcast if the *measured* side is small
spark.conf.set("spark.sql.adaptive.autoBroadcastJoinThreshold", str(64 * 1024 * 1024))
What AQE actually does:
- Coalesce tiny shuffle partitions (fixes “200 tasks on 80 MB”).
- SMJ → BHJ if runtime size says so.
- Skew join split on fat join partitions.
- Local shuffle reader when reducers are on the same executor as the map output (saves NIC).
It will not: invent a partition column you forgot; push through UDFs; make SELECT * cheap; split a skewed aggregation key (that is salting — Shuffle).
Put it to work¶
Always explain expensive jobs¶
from pyspark.sql import functions as F
result = (
events
.filter((F.col("date") == "2024-01-15") & (F.col("status_code") >= 500))
.select("customer_id", "service", "latency_ms", "date", "status_code")
.groupBy("service")
.agg(F.count("*").alias("n"), F.avg("latency_ms").alias("avg_ms"))
)
result.explain(mode="formatted")
# mode="extended" for logical + physical
# mode="cost" when stats exist
Checklist in the formatted plan:
| You want to see | You do not want to see |
|---|---|
PushedFilters / PartitionFilters | FileScan of the entire prefix |
ReadSchema with 4 fields | Every column in the table |
BroadcastHashJoin for 40 MB dim | BroadcastNestedLoopJoin |
HashAggregate (partial) then Exchange then HashAggregate | Exchange before any filter |
WholeStageCodegen wrapping the scan pipeline | Python BatchEvalPython on the hot path |
Native expressions, not UDFs¶
# Catalyst can push / codegen
df.filter(F.col("endpoint").startswith("/api/internal"))
# Catalyst cannot see through this
@F.udf("boolean")
def is_internal(ep):
return ep.startswith("/api/internal") if ep else False
df.filter(is_internal(F.col("endpoint")))
The UDF version scans more, serialises to Python, and disables some fusion. See Gotchas.
Join hints when you know sizes¶
df.hint("broadcast", "customers").join(customers, "customer_id")
# or
events.join(customers.hint("broadcast"), "customer_id")
Hints are how you stop AQE oscillating in a tight SLA. They are also how you OOM if you are wrong. Prefer measuring.
Iceberg / Delta¶
Table formats give Catalyst manifest-level pruning (min/max, partition specs, sometimes clustering). A well-sorted Iceberg table can skip files for customer_id = even without identity partitioning — if you maintained the sort. That is optimiser input, not magic.
Production gotchas¶
where(col("date") == F.current_date()) in a file name sense
If date is a string partition 2024-01-15 and you compare to a DateType, analysis may cast the column and disable pruning. Cast the literal: F.lit("2024-01-15") matching stored type.
Dynamic partition pruning only on the probe side
Spark can skip fact partitions using the build side of a join (DPP). If you disable broadcast and stats are empty, you scan all dates of a 90-day fact for one customer’s country. Check the plan for dynamicpruningexpression.
AQE changed file counts overnight
Coalesce reduced \(R\) from 2000 to 24. Downstream assumed 2000 files. Or the opposite: a bigger day stopped coalescing. Pin advisoryPartitionSize and compact in the table format.
percentiles and countDistinct look like count
They do not partial-aggregate the same way. Shuffle bytes stay huge. HyperLogLog (approx_count_distinct) is the optimiser-friendly cousin.
How it fails¶
| Failure | Optimiser story |
|---|---|
| Cartesian explosion | Nested-loop join; missing equi-join key, or type mismatch (string vs bigint customer_id) so Spark cannot BHJ/SMJ as you thought |
| Full lake scan | Filter on non-partition column; or to_date(timestamp) on the column instead of date= |
| Executor OOM after AQE BHJ | Runtime broadcast of a side that was 8 MB yesterday, 900 MB today |
| Silent nulls | Analysis coerced types; JSON inference |
| Codegen huge method | Very wide rows; Spark falls back; CPU 5× |
Type mismatch joins are infamous: customer_id int vs string → cast → sometimes a BroadcastNestedLoopJoin. explain catches it in review; production catches it in the bill.
How to investigate¶
spark.conf.set("spark.sql.adaptive.enabled", "true")
# After the job, SQL tab shows the *final* AQE plan. explain() before run is compile-time.
| Place | What |
|---|---|
explain("formatted") | Intended physical plan |
| Spark UI SQL | Adaptive plan, time per node, scan bytes |
spark.sparkContext.setLogLevel("INFO") | Rule application (noisy) |
Metrics input size vs table size | Pushdown proof |
BatchEvalPython in plan | UDF tax |
Compare bytes scanned to bytes on disk for that date. If they match the whole table, pruning failed.
Scale¶
| Factor | Optimiser consequence |
|---|---|
| 10× rows, same schema | Same plan, 10× scan; pushdown still the biggest win |
| 100× files | Planning time and driver file listing dominate; Iceberg manifests matter more than a Catalyst rule |
| 1000× | You need incremental plans (scan one snapshot / one hour). A perfect full-scan plan is still a full scan |
Cost-based optimiser (CBO) helps when stats exist. On a raw lake they often do not — AQE is the CBO for people without stats.
Dynamic partition pruning (DPP)¶
A common SaaS pattern: join a tiny tenant table (WHERE customer_id IN (whales we care about)) to a date-partitioned fact.
whales = spark.read.parquet("s3://dims/watchlist/") # few hundred ids
fact = spark.read.parquet("s3://analytics/events/") # 90 days
fact.join(F.broadcast(whales), "customer_id") \
.groupBy("date", "customer_id").count()
If the fact is partitioned by date and you also filter dates from the dim, Spark can inject a dynamic pruning subquery so unused day directories are never listed. In explain, look for dynamicpruningexpression. If you SMJ two huge sides with no broadcast and no stats, DPP never fires and you scan 90 days to answer “yesterday for three tenants.”
Statistics you can actually collect¶
spark.sql("ANALYZE TABLE events COMPUTE STATISTICS FOR ALL COLUMNS")
# Iceberg: snapshot summaries + manifests already carry min/max per file
Hive-style ANALYZE goes stale the next ingest. Prefer file-level min/max (Parquet footers, Iceberg manifests) plus AQE. Do not build a religion around table-level row counts from last March.
Trade-offs¶
| Choice | Gain | Cost |
|---|---|---|
| AQE on (default-on in many 3.x distros) | Runtime join/partition fixes | Less predictable file counts and stage graphs |
| Broadcast | No fat shuffle | RAM × executors |
| Native functions | Pushdown + codegen | Less Python flexibility |
| Wide Parquet schema (400 cols) | Generic lake | Even with pruning, foot-guns of SELECT * |
| Hints | Stability | Stale hints after data grows |
Alternatives¶
- Trino / ClickHouse optimiser + storage layout may beat Spark for interactive SQL with the same Parquet. Spark wins at heavy transforms, UDF-ish ETL (still prefer native), and writes (Iceberg jobs).
- Materialised marts so Catalyst never sees the 8 TB join again.
- Flink for incremental plans; its optimiser is different (operator chaining, not Catalyst).
How to apply at work¶
PR template:
- Paste
explain("formatted")on production-sized partitions (or a sampled path that still has the partition spec). - Assert
PartitionFilters/PushedFilters. - Assert join type.
- List UDFs and why they are not native.
- AQE flags in the SparkConf of the submit, not in a notebook cell that never shipped.
If scan bytes in the last prod run were 8 TB for a “yesterday” job, Catalyst is not your problem — you never gave it a date filter.
Check your understanding¶
q = (
spark.read.parquet("s3://analytics/events/") # partitioned by date
.filter(F.to_date("timestamp") == F.lit("2024-01-15").cast("date"))
.join(customers, F.col("customer_id") == F.col("cust_id"))
.withColumn("bucket", udf_bucket("endpoint"))
.groupBy("bucket", "region")
.count()
)
customers is 25 MB with column cust_id integer; events customer_id is string. udf_bucket is a Python UDF. AQE on.
- Will partition pruning fire? Why?
- What join strategy do you fear, and why?
- Where does the UDF sit relative to pushdown?
- Rewrite the query so Catalyst can do the right thing. Name each change.
- After the rewrite, which AQE feature still matters at 100× files?
Worked answer
- Probably not. Filter is on
to_date(timestamp), not on partition columndate. Function on the column defeats identity partition pruning. Usefilter(F.col("date") == "2024-01-15")(matching type). - BroadcastNestedLoopJoin or SMJ with a cast because
string = intis not a clean equi-join on the same type. Could explode or shuffle-cast everything. Cast one side explicitly after making types equal; then BHJ of 25 MB. - UDF after the join in code, but it blocks predicate/column work on
endpointand forcesBatchEvalPython. Ifudf_bucketcould bewhen/regexp, do that before join to shrink rows, and keep it native so codegen holds. - Filter
date=;selectneeded cols;customers.withColumn("customer_id", F.col("cust_id").cast("string"))(or cast events if that is the source of truth);broadcast(customers); replace UDF with nativewhen;groupBy. - Coalesce + DPP / runtime BHJ. File listing at 100× needs a table format; AQE will still coalesce the agg shuffle and can DPP if the join can broadcast a set of dates/keys.