← All Posts

Apache Spark for Large-Scale Processing

Introduction

Apache Spark has become the de-facto engine for large-scale data processing and distributed machine learning. Originally developed at UC Berkeley's AMPLab in 2009 and open-sourced through the Apache Software Foundation, Spark replaced MapReduce as the primary big-data compute layer by offering in-memory processing that can be up to 100× faster than disk-based Hadoop jobs.

For ML engineers, Spark is indispensable. It powers feature engineering pipelines that process terabytes of raw data, runs distributed model training through MLlib, and integrates natively with data lakes, feature stores, and orchestration tools like Airflow. Understanding Spark internals — not just its API — is essential for building performant, cost-efficient ML systems.

This post covers Spark from the ground up: its cluster architecture, the evolution from RDDs to DataFrames and Datasets, Spark SQL for declarative analytics, MLlib for distributed training, feature engineering patterns at scale, Kubernetes-native deployment, and battle-tested performance tuning strategies.

Prerequisites: Familiarity with Python/PySpark, basic SQL, and a conceptual understanding of distributed systems (partitioning, shuffles, fault tolerance).

Spark Architecture

Spark follows a master–worker architecture. Every Spark application consists of a driver process and a set of executor processes coordinated by a cluster manager. Understanding these three components is crucial for debugging, tuning, and capacity planning.

Driver

The driver is the JVM process that runs your main() function. It is responsible for three things: (1) maintaining the SparkSession / SparkContext, (2) converting your high-level API calls into a physical execution plan (a DAG of stages and tasks), and (3) scheduling tasks on executors and collecting results. The driver holds all metadata — lineage graphs, broadcast variables, and accumulator values. If the driver OOMs, your entire application fails.

Executors

Executors are JVM processes launched on worker nodes. Each executor is allocated a fixed amount of memory and CPU cores. Executors run the actual computation (tasks), store intermediate data in memory or on disk, and report status back to the driver. A single executor can run multiple tasks concurrently — one per core slot.

Cluster Manager

The cluster manager allocates resources across applications. Spark supports four cluster managers: Standalone (built-in), YARN (Hadoop), Mesos (deprecated), and Kubernetes (now the recommended cloud-native option). The cluster manager is external to Spark itself — it only manages container/process lifecycle.

Execution Model: DAG → Stages → Tasks

When you call an action (e.g., .count(), .collect(), .write), Spark's Catalyst optimizer compiles the logical plan into an optimized physical plan expressed as a DAG (Directed Acyclic Graph). The DAG is split at shuffle boundaries into stages. Each stage is divided into tasks — one task per partition. Tasks are the smallest unit of work shipped to executors.

# Conceptual execution flow
User Code  →  Logical Plan  →  Catalyst Optimizer  →  Physical Plan
                                                          ↓
                                                   DAG of Stages
                                                          ↓
                                              Stage 1: [Task₁, Task₂, …, Taskₙ]
                                              Stage 2: [Task₁, Task₂, …, Taskₘ]
                                                          ↓
                                              Scheduled on Executors
Common pitfall: Calling .collect() on a large DataFrame pulls all data to the driver, causing OutOfMemory errors. Always prefer .take(n), .show(), or write results to storage.

RDDs, DataFrames & Datasets

Spark's API has evolved through three generations. Each layer adds more structure, enabling progressively better optimization while preserving fault tolerance through lineage-based recomputation.

RDDs (Resilient Distributed Datasets)

RDDs are Spark's original abstraction — an immutable, partitioned collection of objects distributed across the cluster. They support two types of operations: transformations (lazy, produce a new RDD) and actions (trigger execution, return a result). RDDs give you fine-grained control over partitioning and data placement, but they are opaque to Spark's optimizer — the engine cannot inspect or rearrange your lambda functions.

# RDD example — word count
from pyspark import SparkContext

sc = SparkContext("local[*]", "WordCount")
lines = sc.textFile("s3://data/books/*.txt")

counts = (
    lines.flatMap(lambda line: line.split(" "))
         .map(lambda word: (word.lower(), 1))
         .reduceByKey(lambda a, b: a + b)
)

counts.saveAsTextFile("s3://output/word-counts")

DataFrames

DataFrames (introduced in Spark 1.3) are distributed tables with named columns and known types. Because the schema is explicit, the Catalyst optimizer can apply predicate pushdown, column pruning, constant folding, and join reordering — optimizations impossible with raw RDDs. DataFrames are the primary API for PySpark users.

# DataFrame example — aggregation
from pyspark.sql import SparkSession
from pyspark.sql import functions as F

spark = SparkSession.builder \
    .appName("SalesAgg") \
    .getOrCreate()

df = spark.read.parquet("s3://lake/sales/")

summary = (
    df.filter(F.col("amount") > 0)
      .groupBy("region", "product_category")
      .agg(
          F.sum("amount").alias("total_sales"),
          F.count("order_id").alias("num_orders"),
          F.avg("amount").alias("avg_order_value")
      )
      .orderBy(F.desc("total_sales"))
)

summary.write.mode("overwrite").parquet("s3://lake/sales_summary/")

Datasets (Scala/Java only)

Datasets combine the strong typing of RDDs with the optimization benefits of DataFrames. They are available only in Scala and Java — PySpark exposes DataFrames exclusively. In Scala, DataFrame is simply an alias for Dataset[Row].

RDDs

  • Unstructured — opaque objects
  • No Catalyst optimization
  • Manual serialization (Java/Kryo)
  • Fine-grained partitioning control
  • Best for: custom partitioners, graph algorithms

DataFrames / Datasets

  • Structured — schema-aware columns
  • Full Catalyst + Tungsten optimization
  • Efficient binary encoding (off-heap)
  • Declarative API (SQL-like)
  • Best for: ETL, SQL analytics, ML pipelines
Rule of thumb: Use DataFrames for 95% of workloads. Drop to RDDs only when you need custom partitioning logic or are integrating with legacy libraries that require raw object manipulation.

Spark SQL

Spark SQL lets you run ANSI SQL queries directly against DataFrames, Hive tables, Parquet/ORC files, and JDBC sources. Under the hood, the same Catalyst optimizer compiles SQL text into the identical physical plan that the DataFrame API would produce. This means there is zero performance difference between writing SQL and writing DataFrame chains — choose whichever is more readable.

Registering Views and Querying

# Register a DataFrame as a temporary SQL view
df.createOrReplaceTempView("sales")

result = spark.sql("""
    SELECT region,
           product_category,
           SUM(amount)   AS total_sales,
           COUNT(*)      AS num_orders,
           AVG(amount)   AS avg_order_value
    FROM   sales
    WHERE  amount > 0
    GROUP  BY region, product_category
    ORDER  BY total_sales DESC
""")

result.show(20, truncate=False)

Catalog and Metastore Integration

Spark SQL integrates with the Hive Metastore, AWS Glue Data Catalog, and Unity Catalog (Databricks) to provide a persistent table namespace. This enables schema evolution, partition discovery, and cross-engine interoperability (Presto, Trino, Athena can read the same tables).

Adaptive Query Execution (AQE)

Spark 3.0+ introduced AQE, which re-optimizes the query plan at runtime based on actual shuffle statistics. AQE can dynamically coalesce small post-shuffle partitions, switch join strategies (sort-merge → broadcast) when one side is small, and optimize skewed joins by splitting hot partitions. Enable it with:

spark.conf.set("spark.sql.adaptive.enabled", "true")
spark.conf.set("spark.sql.adaptive.coalescePartitions.enabled", "true")
spark.conf.set("spark.sql.adaptive.skewJoin.enabled", "true")

MLlib for Distributed ML

MLlib is Spark's built-in machine learning library. It provides distributed implementations of common algorithms — classification, regression, clustering, collaborative filtering, and dimensionality reduction — all operating on DataFrames through a Pipeline API inspired by scikit-learn.

The Pipeline API

An MLlib pipeline chains together Transformers (e.g., StringIndexer, VectorAssembler) and Estimators (e.g., LogisticRegression, GBTClassifier) into a single reproducible workflow. Calling .fit() on the pipeline produces a PipelineModel that can be serialized and deployed.

# End-to-end MLlib classification pipeline
from pyspark.ml import Pipeline
from pyspark.ml.feature import StringIndexer, VectorAssembler, StandardScaler
from pyspark.ml.classification import GBTClassifier
from pyspark.ml.evaluation import BinaryClassificationEvaluator

# Load data
df = spark.read.parquet("s3://lake/churn_features/")
train, test = df.randomSplit([0.8, 0.2], seed=42)

# Feature pipeline
indexer   = StringIndexer(inputCol="plan_type", outputCol="plan_idx")
assembler = VectorAssembler(
    inputCols=["plan_idx", "tenure_months", "monthly_charges",
               "total_charges", "support_tickets"],
    outputCol="raw_features"
)
scaler = StandardScaler(inputCol="raw_features", outputCol="features")
gbt    = GBTClassifier(labelCol="churned", featuresCol="features", maxIter=100)

pipeline = Pipeline(stages=[indexer, assembler, scaler, gbt])

# Train
model = pipeline.fit(train)

# Evaluate
predictions = model.transform(test)
evaluator   = BinaryClassificationEvaluator(
    labelCol="churned", metricName="areaUnderROC"
)
auc = evaluator.evaluate(predictions)
print(f"Test AUC-ROC: {auc:.4f}")

# Save model
model.write().overwrite().save("s3://models/churn_gbt_v1/")

Hyperparameter Tuning with CrossValidator

MLlib's CrossValidator and TrainValidationSplit perform distributed hyperparameter search. Each fold/split is trained as a separate Spark job, utilizing the full cluster for parallelism. Use ParamGridBuilder to define the search space.

from pyspark.ml.tuning import CrossValidator, ParamGridBuilder

param_grid = ParamGridBuilder() \
    .addGrid(gbt.maxDepth, [5, 8, 12]) \
    .addGrid(gbt.stepSize, [0.05, 0.1, 0.2]) \
    .addGrid(gbt.subsamplingRate, [0.7, 0.9]) \
    .build()

cv = CrossValidator(
    estimator=pipeline,
    estimatorParamMaps=param_grid,
    evaluator=evaluator,
    numFolds=3,
    parallelism=4   # train 4 models concurrently
)

cv_model = cv.fit(train)
best_auc = evaluator.evaluate(cv_model.transform(test))
print(f"Best CV AUC-ROC: {best_auc:.4f}")

Supported Algorithms

Classification & Regression

  • Logistic Regression (L1/L2/ElasticNet)
  • Gradient-Boosted Trees / Random Forest
  • Linear / Generalized Linear Regression
  • Naive Bayes, Linear SVM
  • Factorization Machines

Clustering & Recommenders

  • K-Means, Bisecting K-Means
  • Gaussian Mixture Model
  • Latent Dirichlet Allocation (LDA)
  • ALS (Alternating Least Squares) for collaborative filtering
  • Power Iteration Clustering
When MLlib isn't enough: MLlib does not support deep learning natively. For distributed DL training, use frameworks like Horovod, PyTorch Distributed, or Ray Train — and use Spark solely for the data preprocessing stage of your pipeline.

Feature Engineering at Scale

In ML systems, feature engineering often accounts for 60–80% of total development time. Spark excels here because it can process billions of rows with complex joins, window functions, and aggregations that would be impossible in-memory on a single machine.

Common Feature Patterns

# Window-based feature engineering
from pyspark.sql import Window
from pyspark.sql import functions as F

# Define a 30-day rolling window per user
w30 = Window.partitionBy("user_id") \
            .orderBy(F.col("event_ts").cast("long")) \
            .rangeBetween(-30 * 86400, 0)

features = (
    events
    .withColumn("purchases_30d",
        F.count(F.when(F.col("event_type") == "purchase", 1)).over(w30))
    .withColumn("spend_30d",
        F.sum(F.when(F.col("event_type") == "purchase", F.col("amount"))).over(w30))
    .withColumn("avg_session_dur",
        F.avg("session_duration").over(w30))
    .withColumn("days_since_last_purchase",
        F.datediff(F.col("event_ts"), F.max(
            F.when(F.col("event_type") == "purchase", F.col("event_ts"))
        ).over(w30)))
)

# Persist to feature store (Delta Lake)
features.write.format("delta") \
    .mode("overwrite") \
    .partitionBy("ds") \
    .save("s3://feature-store/user_engagement/")

Avoiding Skew in Feature Joins

When joining a large fact table against a dimension with high-cardinality keys, data skew can cause a single task to process disproportionately more data. Mitigation strategies include: salting (appending a random suffix to the skewed key), broadcast joins (if the small side fits in memory), and leveraging AQE's automatic skew handling.

# Salting technique to handle skewed joins
import random

SALT_BUCKETS = 10

# Salt the large (skewed) side
large_df = large_df.withColumn(
    "salt", (F.rand() * SALT_BUCKETS).cast("int")
).withColumn(
    "salted_key", F.concat(F.col("join_key"), F.lit("_"), F.col("salt"))
)

# Explode the small side to match all salt values
small_df = small_df.crossJoin(
    spark.range(SALT_BUCKETS).withColumnRenamed("id", "salt")
).withColumn(
    "salted_key", F.concat(F.col("join_key"), F.lit("_"), F.col("salt"))
)

joined = large_df.join(small_df, "salted_key", "inner").drop("salt", "salted_key")

Spark on Kubernetes

Since Spark 2.3 (GA in 3.1), Kubernetes has been a first-class cluster manager. Running Spark on K8s brings significant operational advantages: no dedicated Hadoop/YARN cluster, unified infrastructure with other microservices, autoscaling via the Horizontal Pod Autoscaler, GPU scheduling for DL workloads, and multi-tenant isolation through namespaces and resource quotas.

How It Works

When you spark-submit with --master k8s://, Spark creates a driver pod. The driver pod then requests executor pods from the Kubernetes API server. Each executor pod runs a single JVM. When the job finishes, executor pods are deleted automatically. The driver pod persists until you retrieve logs.

# spark-submit on Kubernetes
spark-submit \
  --master k8s://https://k8s-api.internal:6443 \
  --deploy-mode cluster \
  --name feature-pipeline \
  --conf spark.kubernetes.namespace=ml-pipelines \
  --conf spark.kubernetes.container.image=registry.io/spark:3.5.1 \
  --conf spark.kubernetes.authenticate.driver.serviceAccountName=spark-sa \
  --conf spark.executor.instances=20 \
  --conf spark.executor.memory=8g \
  --conf spark.executor.cores=4 \
  --conf spark.driver.memory=4g \
  --conf spark.kubernetes.executor.volumes.persistentVolumeClaim.spark-data.mount.path=/data \
  --conf spark.kubernetes.executor.volumes.persistentVolumeClaim.spark-data.options.claimName=spark-pvc \
  s3a://code/feature_pipeline.py

Dynamic Allocation on K8s

Spark supports dynamic allocation on Kubernetes, meaning executor pods are created and terminated based on workload demand. Idle executors are removed after a configurable timeout, saving cluster resources. This is essential for cost efficiency in cloud environments where you pay per vCPU-second.

# Enable dynamic allocation on K8s
spark.conf.set("spark.dynamicAllocation.enabled", "true")
spark.conf.set("spark.dynamicAllocation.shuffleTracking.enabled", "true")
spark.conf.set("spark.dynamicAllocation.minExecutors", "2")
spark.conf.set("spark.dynamicAllocation.maxExecutors", "50")
spark.conf.set("spark.dynamicAllocation.executorIdleTimeout", "60s")

Spark Operator for K8s

The spark-on-k8s-operator (GoogleCloudPlatform/spark-on-k8s-operator) is a Kubernetes operator that manages Spark applications as custom resources (SparkApplication CRDs). It handles driver/executor lifecycle, automatic retries, scheduled jobs (cron), and integrates with Prometheus for metrics. This is the recommended approach for production Spark-on-K8s deployments.

YARN (Traditional)

  • Requires dedicated Hadoop cluster
  • Static resource pools
  • Mature ecosystem (Hive, HBase)
  • Complex multi-tenant config
  • Best for: on-prem Hadoop shops

Kubernetes (Cloud-Native)

  • Shares infra with all services
  • Dynamic autoscaling (HPA + cluster autoscaler)
  • GPU scheduling for mixed ML workloads
  • Namespace-level isolation
  • Best for: cloud, hybrid, greenfield

Performance Tuning

Tuning Spark is part science, part art. The key is understanding the relationship between partitions, memory, serialization, and shuffle. Below are the most impactful levers.

1. Partition Count

The number of partitions determines parallelism. Too few partitions underutilize the cluster; too many create scheduling overhead and small-file problems. A good heuristic: 2–4 partitions per executor core, with each partition holding 128–256 MB of data. Use .repartition(n) (full shuffle) or .coalesce(n) (narrow dependency, reduce-only) to adjust.

2. Memory Configuration

Executor memory is divided into: execution memory (shuffles, sorts, joins), storage memory (cached DataFrames), and overhead (off-heap, PySpark interop). The unified memory manager (default since Spark 1.6) dynamically balances execution and storage. Key settings:

# Memory tuning checklist
spark.executor.memory          = 8g      # JVM heap
spark.executor.memoryOverhead  = 2g      # off-heap (PySpark needs more)
spark.memory.fraction          = 0.6     # fraction of heap for execution+storage
spark.memory.storageFraction   = 0.5     # initial split (dynamic by default)
spark.sql.shuffle.partitions   = 200     # default shuffle partitions (tune!)

3. Serialization

Spark uses Java serialization by default, which is slow and verbose. Switching to Kryo serialization can reduce shuffle sizes by 2–5× and improve task throughput. Register your custom classes for best results.

spark.conf.set("spark.serializer", "org.apache.spark.serializer.KryoSerializer")
spark.conf.set("spark.kryo.registrationRequired", "false")

4. Broadcast Joins

When one side of a join is small enough to fit in executor memory (typically < 200 MB), Spark can broadcast it to all executors, eliminating the shuffle entirely. This is one of the most effective optimizations.

# Force a broadcast join
from pyspark.sql.functions import broadcast

result = large_df.join(
    broadcast(small_lookup_df),
    "key_column",
    "left"
)

# Or set the auto-broadcast threshold
spark.conf.set("spark.sql.autoBroadcastJoinThreshold", "200m")

5. Caching Strategy

Use .cache() (alias for .persist(MEMORY_AND_DISK)) when a DataFrame is reused multiple times in the same job. But cache judiciously — cached data competes with execution memory. Always .unpersist() when done.

6. Avoiding Shuffles

Shuffles are the most expensive operation in Spark. They serialize data, write it to local disk, transfer it over the network, and deserialize it on the receiving end. Minimize shuffles by: co-partitioning data before joins, using broadcast joins, pre-aggregating before a groupBy, and avoiding .repartition() unless necessary.

Monitoring tip: Always check the Spark UI's Stages tab for shuffle read/write sizes, task duration distributions, and GC time. Skewed tasks (one task taking 10× longer than others) indicate partition skew — apply salting or AQE skew handling.

Tuning Quick Reference

High-Impact Settings

  • spark.sql.shuffle.partitions — tune to cluster size
  • spark.sql.adaptive.enabled — always true in Spark 3+
  • spark.serializer — use Kryo
  • spark.sql.autoBroadcastJoinThreshold — raise for cloud

Common Anti-Patterns

  • .collect() on large DataFrames
  • Python UDFs (use Pandas UDFs instead)
  • Too many small files (< 1 MB each)
  • Caching everything without unpersisting

Summary

Apache Spark remains the backbone of large-scale data processing for ML. Its architecture — a driver coordinating executors via a cluster manager — enables linear horizontal scaling. The evolution from RDDs to DataFrames unlocked the Catalyst optimizer and Tungsten execution engine, delivering order-of-magnitude performance gains with simpler code.

MLlib provides production-ready distributed ML algorithms and a pipeline API that mirrors scikit-learn's design. For feature engineering, Spark's window functions, join optimizations, and integration with Delta Lake / feature stores make it indispensable. Running on Kubernetes brings cloud-native autoscaling, GPU support, and operational simplicity. And performance tuning — partition sizing, memory config, broadcast joins, AQE — is the difference between a job that takes 3 hours and one that takes 15 minutes.

Key takeaway: Master Spark internals, not just the API. Understanding how shuffles work, why partition count matters, and when to use broadcast joins will save your team thousands of dollars in compute and hours of debugging time.

Continue with worked examples

Continue with these focused notes: