Approach summary: reduce shuffle volume, use vectorized operations (Pandas UDFs / built-in), minimize serialization, tune cluster/GC and enable AQE + shuffle/serde optimizations. Target: cut I/O and shuffle, increase parallelism and memory efficiency.Code/config examples:1) Spark submit / defaults tuned--conf spark.sql.adaptive.enabled=true
--conf spark.sql.adaptive.coalescePartitions.enabled=true
--conf spark.sql.adaptive.skewJoin.enabled=true
--conf spark.sql.shuffle.partitions=2000
--conf spark.sql.files.maxPartitionBytes=134217728 # 128MB
--conf spark.sql.execution.arrow.pyspark.enabled=true
--conf spark.executor.memory=32g
--conf spark.executor.cores=4
--conf spark.executor.instances=50
--conf spark.memory.fraction=0.6
--conf spark.memory.storageFraction=0.2
--conf spark.serializer=org.apache.spark.serializer.KryoSerializer
--conf spark.kryoserializer.buffer.max=512m
2) Repartition & avoid wide shuffles- Use range repartitioning on join keys to make joins local and balanced:python
from pyspark.sql import functions as F
df = spark.read.parquet("s3://.../data")
df = df.repartitionByRange(2000, F.col("device_id"))
- Broadcast small reference tables:python
from pyspark.sql.functions import broadcast
large.join(broadcast(small), "key")
3) Replace Python UDFs with vectorized Pandas UDFs or built-inspython
from pyspark.sql.functions import pandas_udf, PandasUDFType
@pandas_udf("double", PandasUDFType.SCALAR)
def anomaly_score(vals: pd.Series) -> pd.Series:
# vectorized numpy/pandas operations
z = (vals - vals.mean()) / vals.std()
return np.abs(z)
df = df.withColumn("score", anomaly_score(F.col("metric")))
Or use SQL/built-ins whenever possible (avoid row-at-a-time Python UDF).4) Cache carefully and persist hot intermediate resultspython
df = heavy_transformations(df)
df.persist(StorageLevel.MEMORY_AND_DISK)
# later reuse df for joins/aggregations
5) Reduce shuffle data size- Project only required columns early- Filter (predicate pushdown) before joins/aggregations- Use map-side combines (aggregate before shuffle):python
from pyspark.sql import Window
# if computing per-key aggregates, use groupBy which does map-side combine
agg = df.groupBy("device_id").agg(F.count("*"), F.avg("metric"))
6) Skew handling- Detect skew via AQE skew detection or sample key counts; salt heavy keys:python
# simple salting pattern
salted = df.withColumn("salt", (F.rand()*10).cast("int"))
df_salted = salted.groupBy("key","salt").agg(F.sum("value"))
df_combined = df_salted.groupBy("key").agg(F.sum("sum(value)"))
7) IO format & compression- Use columnar formats with snappy/none for CPU balance:write as parquet partitioned by date/device and enable vectorized parquet readerspark.conf.set("spark.sql.parquet.filterPushdown", "true")
spark.conf.set("spark.sql.parquet.mergeSchema", "false")
Measurement & iterative steps:- Add metrics: spark event logs, shuffle read/write sizes, GC times.- Start with small change set (enable Arrow + pandas_udf + AQE) and benchmark; then tune partitions and executor sizing.Expected impact: vectorized UDFs + Arrow reduces Python overhead by 5-10x; reducing shuffle partitions and using broadcast/range partitioning can cut shuffle IO and network by 5-20x; together plus proper cluster sizing, runtime can move from 6h toward <1h depending on IO bounds. Edge cases: watch memory pressure (OOM), validate AQE behavior, ensure deterministic partitioning for downstream consumers.