Tungsten, codegen and columnar execution

PySpark · Expert · 15 min

These engine features explain why built-in expressions beat UDFs by large factors and how to read low-level plan details.

Once Catalyst has a physical plan, Tungsten, Spark's execution layer, makes it fast.

UnsafeRow binary format: rows are stored as compact byte arrays managed by Spark, not as JVM objects. That reduces garbage collection pressure and memory use, and lets sorts and hash aggregations work directly on bytes. Memory can be on-heap or off-heap (spark.memory.offHeap.enabled).

Whole-stage code generation: instead of calling each operator through virtual functions row by row (the Volcano model), Spark fuses a chain of operators, such as scan, filter, project and partial aggregate, into one generated Java function with tight loops, compiled at runtime by Janino. In explain() output, operators prefixed with *(n) belong to codegen stage n. df.explain("codegen") prints the generated code. Python UDFs, some complex expressions and very wide plans break codegen into pieces.

Vectorized columnar reading: Parquet and ORC are decoded in batches into column vectors rather than row by row (spark.sql.parquet.enableVectorizedReader).

Other runtime techniques: dynamic partition pruning uses join results to skip partitions of the other table, and runtime bloom filters reduce shuffle data.

Vendor engines such as Databricks Photon, and open-source native accelerators like Apache Gluten and DataFusion Comet, replace this JVM execution with native vectorized code while keeping the same Spark plans.

Example

from pyspark.sql import SparkSession, functions as F
spark = SparkSession.builder.master("local[2]").getOrCreate()

spark.conf.set("spark.sql.adaptive.enabled", "false")   # show the static plan
q = (spark.range(10_000_000)
     .filter(F.col("id") % 3 == 0)
     .select((F.col("id") * 2).alias("x"))
     .agg(F.sum("x")))
print(q.first()[0])                     # 33333336666666

plan = q._jdf.queryExecution().executedPlan().toString()
print("*(1) Filter" in plan)            # True: operators fused into codegen stage 1

q.explain("codegen")
# Found 2 WholeStageCodegen subtrees.
# == Subtree 1 / 2 (maxMethodCodeSize:...) ==
# *(1) HashAggregate(keys=[], functions=[partial_sum(x#4L)], ...)
# +- *(1) Project [(id#0L * 2) AS x#4L]
#    +- *(1) Filter ((id#0L % 3) = 0)
#       +- *(1) Range (0, 10000000, step=1, splits=2)
# Generated code:
# /* 001 */ public Object generate(Object[] references) { ...

Remember

  • Tungsten stores rows as compact binary UnsafeRows to cut GC
  • Whole-stage codegen fuses operators into one generated function (*(n) in plans)
  • Parquet/ORC are read in vectorized column batches

Watch out for

  • Inserting Python UDFs into hot paths, which breaks code generation and adds serialization.

Check yourself

  1. What does a '*(2)' prefix on operators in a physical plan mean?

Sign in to answer, see the explanations, and get this lesson back as a quick review later.

Official docs: spark.apache.org/docs/latest/sql-performance-tuning.html