Performance Optimization
Spark tutorial · PySpark.in
Catalyst Optimizer and Tungsten Execution Engine
Spark SQL relies on the Catalyst optimizer to plan queries and the Tungsten engine to execute them efficiently. The Catalyst optimizer is a rule-based and cost-based query optimizer: it parses a DataFrame query into a logical plan, applies analysis and optimization rules (predicate pushdown, constant folding, column pruning, etc.), then produces a physical plan. Finally, Spark generates optimized JVM bytecode for the plan. In essence, Catalyst “knows” how to transform your DataFrame operations into an efficient execution plan.
Tungsten complements Catalyst by focusing on CPU and memory efficiency under the hood. It introduces manual memory management (off-heap storage), binary processing of data, and whole-stage code generation. For example, Tungsten places intermediate data into CPU registers, unrolls loops, and avoids virtual function calls. The net effect is that Spark workloads become much faster: Tungsten drives Spark to approach the limits of modern hardware. Developers benefit because these optimizations (efficient binary formats, cache-aware computation, SIMD, etc.) happen automatically, so most DataFrame code runs faster without manual intervention.
Using Explain Plans
To diagnose and understand performance, Spark provides df.explain(). Calling df.explain() prints Spark’s logical and physical plans for a DataFrame, which shows how Catalyst will execute the query. For example:
```
df = spark.createDataFrame([(1, "Alice"), (2, "Bob"), (3, "Carol")], ["id","name"])
filtered = df.filter("id > 1")
filtered.explain()
```
This prints a Physical Plan (by default) with operators like Scan, Filter, etc. You can use df.explain(True) or df.explain(mode="extended") to see Parsed, Analyzed and Optimized Logical Plans as well as the final physical plan. The logical plan shows what Spark intends to do (after rule-based optimizations), and the physical plan shows exactly how it will do it (which join types, shuffles, etc.). For instance, a join might appear as a BroadcastHashJoin or SortMergeJoin depending on data sizes. Understanding the explain output helps identify bottlenecks: look for Exchange or large Shuffle operators which indicate expensive data movements.
How to read an explain plan: Spark plans are bottom-up – the bottom of the plan is the data source, and each indentation shows a transformation. An Exchange operator indicates a shuffle. For example, the plan
*(5) HashAggregate
+- Exchange hashpartitioning(column, 200)
+- *(3) Filter ...
means Spark will shuffle data (Exchange) into 200 buckets to perform the aggregation. Using explain() in development is a key debugging step to ensure Spark will use efficient strategies (see DZone tutorial for detailed examples).
Broadcast Join Optimization
A broadcast join avoids shuffling the large side of a join by sending a small table to every executor. When you join a very large DataFrame with a much smaller one, you can call Spark’s broadcast() function or use a broadcast hint to force a broadcast hash join. For example:
```
hide
from pyspark.sql.functions import broadcast
large_df = spark.read.parquet("large_data.parquet")
small_df = spark.read.parquet("small_lookup.parquet")
# Use broadcast join: small_df is sent to all executors
result = large_df.join(broadcast(small_df), on="id", how="inner")
```
This causes Spark to copy small_df once to each worker, then perform the join locally – no shuffle of the large data is needed. The effect is a huge speedup when one side is very small (e.g. few MB). As a rule of thumb, broadcast only small tables: Spark’s default spark.sql.autoBroadcastJoinThreshold is 10 MB. You can increase it if your cluster has extra memory. Spark’s Adaptive Query Execution can also automatically convert a join to a broadcast join at runtime when it sees one side is small.
Performance benefit: By broadcasting the small side, each join task sees all its matching keys locally, so there is no shuffle phase for the large side. This avoids expensive network I/O and sorting in a shuffle, often making the join an order-of-magnitude faster. (Without broadcast, a large join would require a two-stage shuffle, which is much slower.) Note that excessive broadcasting of large tables will OOM; always ensure the broadcasted table fits in executor memory.
Partitioning Strategies
Spark’s performance critically depends on how data is partitioned. Proper data partitioning ensures work is evenly distributed across the cluster and minimizes unnecessary shuffles. For better parallelism, you can manually repartition a DataFrame so that data is spread across more partitions (up to your cluster’s core count). For example:
```
hide
# Increase partitions and distribute by "user_id"
df = df.repartition(8, "user_id")
```
This creates 8 partitions, hashing on user_id, which can help if you will later join or group by that column. SparkByExamples and CloudThat recommend using repartition() to boost parallelism. Conversely, if you have too many tiny partitions (too many small files or tasks), you can coalesce partitions (next topic) or adjust spark.sql.shuffle.partitions.
When writing out files, partitionBy can also improve performance. For example:
```
hide
df.write.partitionBy("country", "year").parquet("hdfs://.../output")
```
This lays out the output in directories by country and year. Partitioning on common filter keys (like date or region) means that later reads can skip irrelevant partitions – Spark will prune partitions in queries to only read needed files (improving I/O). Spark’s DataFrameWriter docs explain that .partitionBy() physically partitions output on disk.
In summary, to optimize partitioning: ensure an appropriate number of partitions, repartition on join/group keys if needed, and use partitionBy for write paths so data is organized by common predicates.
Repartition vs Coalesce
Both repartition() and coalesce() change the number of partitions, but with different costs.
- repartition(n) always shuffles data across the cluster to create exactly n partitions (and can both increase or decrease the count). This results in a full data shuffle and uniform distribution. Use repartition() when you need to rebalance uneven partitions or increase parallelism. For example, after a heavy filter you might call df = df.repartition(50) to ensure enough partitions for downstream work.
- coalesce(n) reduces the number of partitions without a full shuffle. It simply collapses existing partitions (a narrow operation). For example, df.coalesce(10) from 100 partitions will merge them into 10 without redistributing data. This is faster (no full shuffle) but can lead to data skew if partitions are imbalanced. Coalesce cannot increase partitions, and if you do a drastic coalesce (to 1, say), it may under-utilize your cluster. In that case you should use repartition() to force a shuffle so work is spread.
Use cases: If you have too many small partitions (e.g. after filtering or map-only ops), use coalesce() to reduce them quickly before writing. If you need to evenly spread data or increase partitions (e.g. before a heavy reduce), use repartition(). In summary, coalesce() is cheap for merging partitions, while repartition() is expensive (full shuffle) but more flexible. Always match your partition strategy to your data size and cluster.
Cache and Persist
Spark can cache (persist) intermediate DataFrames in memory (or on disk) to avoid recomputing them. Use .cache() or .persist() on a DataFrame when you will reuse it multiple times. For example:
```
hide
# Cache a filtered DataFrame before multiple actions
filtered = df.filter(df.score > 0.8).cache()
print(filtered.count())
print(filtered.agg({"score": "avg"}).collect())
# When done, unpersist to free memory
filtered.unpersist()
```
By caching, Spark stores the DataFrame’s partitions so repeated actions do not re-run all prior transformations. This is especially useful in iterative algorithms or repeated queries on the same data.
Technically, .cache() is shorthand for .persist() with the default storage level. In Spark 3.x the default for DataFrames is MEMORY_AND_DISK (stores as much in memory as possible, spills to disk if needed). You can use .persist(StorageLevel.X) to control this (e.g. MEMORY_ONLY, MEMORY_AND_DISK, serialized options, etc.). For large datasets that won’t fit in memory, consider MEMORY_AND_DISK so Spark writes some partitions to disk rather than OOM.
When to use: Cache only if a DataFrame will be reused (multiple actions or joins). Caching a one-off intermediate is wasteful. After caching, watch the Spark UI – use too much cache and the job may run slower due to GC. Finally, always release caches with df.unpersist() or in Spark 3.x it may auto-unpersist when the DataFrame is out of scope.
Avoiding Shuffle Operations
Shuffles (data exchanges across the network) are very expensive – they involve disk I/O, network transfer, and sorting. Wide transformations such as joins, aggregations (groupBy/reduce), distinct, and repartitioning trigger shuffles. In contrast, narrow transformations like map, filter, select, etc. operate within each partition and avoid shuffles. Keeping transformations narrow “where possible” leads to much faster jobs.
Strategies to reduce shuffles:
- Broadcast small tables: As above, broadcasting avoids shuffle for joins when one side is small.
- Prefilter early: Apply .filter() or .where() as early as possible to cut data volume before any shuffle. Spark’s Catalyst optimizer already pushes down filters, but designing your job to filter first helps.
- Partition wisely: Sometimes pre-repartition()ing on a join key can reduce later shuffles. For example, if you know you’ll join df1 and df2 on "key", you could do df1 = df1.repartition("key") once so that Spark already colocates rows by key. (Use judiciously, since repartition itself is a shuffle.)
- Use map-side aggregations: In RDD-based code, prefer reduceByKey or aggregateByKey (which combine mapper outputs locally) instead of groupByKey, to reduce data before shuffle. In DataFrames, using built-in aggregations is usually fine, but be aware that any grouping will shuffle data by key.
Spark’s explain plan and UI can help you detect shuffles (look for “Exchange” steps and large shuffle read/write sizes). If a shuffle is unavoidable, ensure enough memory and partitions are allocated so that data does not spill too much to disk (e.g. increase spark.sql.shuffle.partitions or enable AQE). Finally, caching intermediate results can also avoid repeated shuffles if the same data is joined multiple times.
Parquet File Size and Layout Optimization
When writing Parquet files, layout and compression greatly affect performance. Partitioning by columns (as discussed) allows pruning and faster reads. Additionally, aim for moderately large file sizes (hundreds of MB) rather than many tiny files.
- Block (row-group) size: Parquet divides files into row groups (blocks). The default in Spark is usually 128 MB. You can increase this via spark.sql.parquet.block.size if you want larger row groups (e.g. 256 MB). Larger blocks improve compression (less overhead) but require more memory when reading. As one rule of thumb, match the HDFS block size if using HDFS – for cloud storage like S3, it’s still often better to use multiple moderate-sized objects. In fact, writing one gigantic file (tens of GB) is not advisable: Spark will have to split it into many parallel read tasks anyway, and a single file can become a bottleneck. As one answer notes, “you’ll get better performance if you break it down into several smaller (preferably partitioned) files, since they can be written in parallel… and have better reading performance”.
- Compression: Use a fast, columnar-friendly codec. Spark’s default is Snappy (spark.sql.parquet.compression.codec=snappy). Snappy offers a good balance of speed and compression. You can also use more aggressive codecs (e.g. gzip or zstd) if reducing file size is critical and you have CPU to spare. Set compression with df.write.option("compression", "snappy") or via Spark SQL config.
- Column pruning and filter pushdown: Parquet is columnar, so Spark will only read the needed columns and push filters into Parquet scans by default. Ensure spark.sql.parquet.filterPushdown=true (default) so that Spark skips irrelevant row groups. This means if you write partitioned Parquet (or even bucketed), queries with WHERE on those columns will only load matching files, greatly speeding reads.
- File count vs size: After writing, check the number and size of output files. Too many small files (<<128 MB) can degrade performance. You can coalesce or repartition before writing, or use .write.partitions to control file count. Conversely, avoid single huge files. A balanced approach is to have each file around 128–512 MB.
- Parquet formats: Use the modern Parquet writer in Spark (the default) which benefits from Tungsten (vectorized writes). Spark 2.0+ uses the efficient VectorizedParquetWriter.
More Spark tutorials
- about pyspark
- text diagram
- Apache Spark Runtime Architecture
- Introduction to RDD
- Actions vs Transformations
- Lazy Evaluation in PySpark
All tutorials · Try the free PySpark compiler · Practice challenges