Optimizing Spark Jobs in Production

Define the run you are improving

A Spark application can finish its calculation faster while delivering its result later. A production run may wait for a cluster, read a snapshot, execute several jobs, write output, validate it, and publish it to consumers. Choose the start and finish events before comparing runs. Measure consumer-ready elapsed time alongside Spark execution time when the deadline concerns availability.

A baseline is the reference run and its measurement definition. Record the input snapshot, code revision, Spark and connector versions, configuration, cluster resources, cache state, concurrency, and output contract. Include representative large and skewed inputs, not only an easy sample. A filter that silently drops difficult records is not an optimization of the same workload.

State success in terms of a deadline, acceptable output, failure rate, and a cost boundary. Reading fewer bytes is a useful hypothesis for a scan-bound query, not a general explanation for most speedups. Fix an urgent resource shortage when necessary; there is no universal rule that every code change must precede resource work.

Keep evidence after the application ends

Begin with the SQL execution and its physical plan, then follow its jobs, stages, and task attempts. A job starts work for an action; a stage groups tasks that can run without crossing a shuffle boundary. A task attempt is one execution attempt for a partition. Retries and speculation can create several attempts for the same logical task. Operator identifiers and scheduler stage identifiers are not interchangeable.

The live Spark UI normally disappears with the application. A Spark event log records execution events so a History Server can reconstruct supported UI views afterward. Enable spark.eventLog.enabled before the application starts, select a durable spark.eventLog.dir writable by the driver, and configure the History Server to read it. A driver-local temporary directory will not serve a remote history service after the driver is removed. Verify the application and attempt can actually be opened.

Keep application IDs, final adaptive plans, relevant environment settings, and source/sink metrics with the experiment record. Event logs are different from application text logs and are not a copy of the input or a checkpoint. Retention or lossy event-log compaction can remove evidence. Restrict access and check redaction because plans and settings can contain sensitive values.

Follow the critical path, not a single ratio

Use the timeline to separate resource waiting, driver work, task execution, and output commit. Compare task-duration distributions and then records, input bytes, shuffle reads and writes, fetch wait, spill, CPU, GC, and failed attempts. These counters describe different scopes and units. Task durations overlap; their sum is not stage elapsed time. JVM CPU metrics do not necessarily include Python-worker or external-service work.

A long tail with unusually large shuffle reads suggests uneven work; a long tail without large reads also warrants checking host health, expensive expressions, retries, and external calls. Many short tasks with little data suggest scheduling or file overhead, but a file listing delay may happen before those tasks. High spill warrants inspecting per-task working sets, concurrent tasks, skew, and cache pressure rather than automatically doubling executor memory.

Make a specific prediction. For a scan bottleneck, select only required columns and eligible partitions, then inspect the scan plan and actual read metrics. For a join bottleneck, check key multiplicity, estimates, and the final join strategy. Data skew can require redistributing work, but ordinary hash repartitioning does not split one hot key. A plan marker supports an execution explanation; it is not a measured speedup.

Change the plan while keeping the answer

Projection and filtering can reduce work, but only when their placement preserves meaning. Moving a predicate between ON and WHERE in an outer join may remove unmatched rows. Removing DISTINCT or changing a grouping key may change multiplicity. Built-in expressions may give Spark more optimization opportunities than a Python UDF, but first compare nulls, types, rounding, and exception behavior.

Check table statistics and actual cardinalities before forcing a join. Broadcast avoids a particular redistribution of the streamed side, but costs driver/executor memory and transfers the build relation. It does not fix every upstream imbalance. Preserve the output schema, row grain, duplicate counts, unmatched rows, and domain totals; a matching row count or one grand total can hide different records.

Compare deterministic outputs at an appropriate scale. The small lab uses a multiset, which preserves duplicates; production comparison may use partitioned reconciliation, domain aggregates, and sampled detail checks with explicit limitations. Define tolerances for floating-point results before running the candidate. Do not demand incidental row ordering from an unordered query, and do not turn expected nondeterminism into an unexamined exception.

Treat cache and resources as part of the experiment

Spark persistence and lineage separate retained partitions from their recomputation recipe. persist() is lazy: a subsequent action computes the partitions it needs. Check the Storage tab and the consuming plan, such as InMemoryTableScan for a cached DataFrame. A partial action need not populate the entire cache. Time cache construction, reuse, eviction, and unpersist within the workload being optimized.

For a reused expensive result, caching can avoid repeated work. It also occupies memory or disk and can increase spill elsewhere. Compare cold and warm cases separately; repeatedly timing a warmed candidate against a cold baseline changes the question. Cached data is not a durable publication or a backup of a changing source.

Executor cores affect concurrent task count, while memory must cover simultaneous working sets. Account for JVM heap, overhead, off-heap or native allocations, and Python processes under the deployment’s container accounting. More cores can increase contention; more memory can reduce spill. Inspect the actual allocation and losses before choosing a change.

Dynamic allocation requests or removes executors as demand changes; it does not guarantee immediate capacity or cheaper billing. Use a supported shuffle-preservation mechanism, such as configured external shuffle service or shuffle tracking, with the requirements of the chosen deployment. Check minimum/initial/maximum executors, backlog and idle behavior, cached executors, startup delay, and shared-cluster quotas. Releasing an executor does not necessarily terminate a billed worker node. Speculation duplicates suspected slow attempts; it adds resource use and does not split an intrinsically large partition.

Compare repeated runs and promote a bounded change

Repeat comparable baseline and candidate runs, alternate or randomize their order where practical, and retain every outcome. Separate warm-up from measured runs using a rule chosen beforehand. Report the distribution, failures, retries, input changes, and shared load, not only the fastest run. A handful of trials cannot establish a stable tail percentile or production reliability.

For a fixed-price allocation, resource time times rate gives an illustrative compute charge. A 30% shorter run using twice as many equally priced workers consumes 1.4 times the worker-hours. With dynamic resources, integrate the allocated resources over time. Provider billing may also include idle nodes, minimum charges, driver time, managed-service fees, requests, storage, and network costs. Keep cost per successful published unit and deadline compliance visible.

After correctness and representative load tests, introduce the change to a limited workload and compare observed behavior with the prediction. Retain the previous code/configuration and valid output version, decide who can roll back and under what deadline or error trigger, and ensure a rollback does not duplicate or partially replace published data. Record the final decision, the evidence, and conditions that would require another test.

Lab: interpret a proposed optimization

These independent Python 3 examples use invented records, not Spark measurements. The first rejects changed rows even when count and total match. The second compares five illustrative elapsed times under fixed four-worker and eight-worker allocations. They do not prove a performance effect. The last separates aggregate task duration from the span covered by tasks; the application may also wait before and after that span.

from collections import Counter
reference = [("A", 10), ("A", 10), ("B", 20)]
candidate = [("B", 20), ("A", 10), ("A", 10)]
wrong = [("A", 10), ("B", 10), ("B", 20)]
assert len(reference) == len(wrong)
assert sum(v for _, v in reference) == sum(v for _, v in wrong)
assert Counter(reference) == Counter(candidate)
assert Counter(reference) != Counter(wrong)
print("same count and total can hide wrong rows:", True)
print("reordered correct result accepted:", Counter(reference) == Counter(candidate))
# same count and total can hide wrong rows: True
# reordered correct result accepted: True
from statistics import median
from decimal import Decimal
baseline = [100, 104, 96, 102, 98]
candidate = [70, 72, 68, 71, 69]
base_s, new_s = median(baseline), median(candidate)
ratio = Decimal(new_s) / Decimal(base_s)
worker_hours_ratio = ratio * Decimal(8) / Decimal(4)
assert ratio == Decimal("0.7") and worker_hours_ratio == Decimal("1.4")
print("illustrative median seconds:", base_s, new_s)
print("illustrative runtime reduction percent:", (1-ratio)*100)
print("illustrative worker-hour ratio:", worker_hours_ratio)
# illustrative median seconds: 100 70
# illustrative runtime reduction percent: 30.0
# illustrative worker-hour ratio: 1.4
attempts = [("task-0", 0, 10), ("task-1", 0, 10), ("task-2", 10, 20)]
aggregate_seconds = sum(end-start for _, start, end in attempts)
active_span = max(end for _, _, end in attempts) - min(start for _, start, _ in attempts)
allocation = [(0, 10, 4), (10, 20, 2)]
core_seconds = sum((end-start)*cores for start, end, cores in allocation)
assert aggregate_seconds == 30 and active_span == 20 and core_seconds == 60
print("sum of task seconds versus active span:", aggregate_seconds, active_span)
print("illustrative allocated core-seconds:", core_seconds)
# sum of task seconds versus active span: 30 20
# illustrative allocated core-seconds: 60

References: Spark monitoring, Spark job scheduling, Spark tuning, SQL performance tuning.


Discover more from Insightful Data Lab

Subscribe to get the latest posts sent to your email.

Similar Posts

Questions, corrections, or additional insights?

This site uses Akismet to reduce spam. Learn how your comment data is processed.