DE Track · Module 07

Performance Tuning & FinOps

Turning Spark knowledge into runtime and dollars - the module that maps directly to production wins.

The methodology: measure before you touch anything

Every credible tuning answer starts the same way: you measure first. Random knob-turning is the number-one anti-pattern interviewers probe for. Your working order:

  1. Measure. For notebooks and jobs, open the Spark UI: find the longest stage, then check shuffle read/write volumes, spill (memory and disk), and task-duration skew (compare median vs. max task time in the stage summary). For Databricks SQL, use the Query Profile - it shows time per operator, rows produced, files pruned vs. read, and whether the query hit a cache.
  2. Fix data layout. File sizes, clustering, pruning, statistics. Layout fixes help every downstream query, not just the one you're staring at.
  3. Fix code. Join strategy, eliminating UDFs, removing unnecessary wide transformations, pushing filters early.
  4. Fix compute last. Resizing a cluster to outrun a layout problem is renting your way out of an engineering problem - it works until the data doubles.

Memorize one diagnostic sentence per signal: spill means partitions don't fit in task memory; skew means a few tasks do most of the work; high shuffle volume means you're moving data you could have pruned or broadcast. If you can read those three off the Spark UI, you can structure any tuning conversation.

Compute: sizing, autoscaling, spot, Photon, serverless

Cluster sizing and instance families

Match the family to the bottleneck: memory-optimized for wide aggregations, large joins, and anything that spills; compute-optimized for CPU-bound transforms and many small tasks; storage-optimized (instances with local NVMe) when you lean on the disk cache for repeated reads; general-purpose when you don't yet know. Two sizing heuristics worth quoting: prefer fewer, larger nodes for shuffle-heavy work (less network traffic between executors), and size total cluster memory so your largest shuffle stage doesn't spill - spill to disk is often the single biggest silent slowdown.

Autoscaling - and when it hurts

Autoscaling fits ragged, interactive, or multi-job workloads where demand varies. It actively hurts when: (1) the workload is a steady batch job - you pay scale-up latency every run for no benefit, so a fixed right-sized cluster is cheaper and more predictable; (2) the job is shuffle-heavy - removing a node can lose shuffle files and trigger recomputation; (3) it's a Structured Streaming job, where classic autoscaling reacts poorly to micro-batch load. For SQL warehouses, autoscaling adds whole clusters for concurrency rather than resizing one - that one is usually safe to leave on.

Spot instances

Spot/preemptible workers can cut compute cost 60-90 percent. Use them for retry-tolerant batch work: keep the driver (and ideally a small core of workers) on-demand, put the rest on spot with fallback to on-demand. Avoid spot for tight-SLA jobs and long-running streaming, where eviction mid-shuffle costs more in recomputation than it saves.

Photon: what it accelerates and when it pays

Photon is the C++ vectorized engine that replaces the JVM execution path for SQL and DataFrame operations: scans, filters, joins, aggregations, Delta writes and MERGE. It does not accelerate RDD code, Python UDFs, or most Scala UDFs - those fall back to the JVM row-at-a-time path. Photon carries a higher DBU rate, so the test is arithmetic: if it cuts runtime by more than the DBU multiplier increases cost (it often does on scan/join/aggregate-heavy SQL pipelines - 2-3x speedups are common), it is cheaper per job, not just faster. UDF-heavy or RDD-heavy code sees little speedup and pays the full premium. Check the Spark UI or Query Profile for how much of the plan actually ran "photonized" before deciding.

Serverless compute trade-offs

Serverless compute for notebooks, jobs, and pipelines is GA and is the strategic default Databricks is steering toward (serverless jobs and pipelines now default to a "Performance optimized" mode in the UI, with a cheaper standard mode available). What you gain: near-instant startup, no idle clusters burning DBUs, no sizing decisions, Databricks-managed Photon and scaling. What you give up: node-level control (no instance-family choice, no spot strategy, limited Spark conf), and a different cost shape - you pay a premium per DBU in exchange for paying only for execution. Rule of thumb as of mid-2026: serverless wins for spiky, short, or interactive workloads and for teams without tuning bandwidth; a well-tuned classic cluster on spot can still win for long, steady, predictable batch. Run the comparison with your own system-table billing data rather than asserting either way.

At ADM you run the FinOps side of Finance R2R delivery on Azure Databricks: cluster right-sizing, autoscaling policies, and Delta layout work - partitioning, compaction, and Z-ORDER - on the medallion tables feeding P&L and Budget-vs-Actual dashboards for Executive Committee and CFO-level consumers. When you tell this story, structure it in the measure-first order this module teaches: start from observed utilization and job metrics, fix layout before compute, and encode the outcome as policies so improvements persist. The right-sizing, autoscaling, and Delta tuning are genuinely your production work - the sequencing is how you make that work legible to an interviewer.

Data layout: the small-file problem and file-size management

Thousands of small files mean slow listings, bloated transaction-log state, and tasks that spend more time on overhead than on data. Classic causes: streaming or frequent small batch appends, over-partitioning, and high-cardinality partition keys. Target file sizes in the hundreds of MB to ~1 GB. The toolkit:

OPTIMIZE finance.gold.gl_actuals;          -- compact small files

-- New tables (current guidance): automatic liquid clustering
CREATE TABLE finance.gold.gl_actuals (...)
CLUSTER BY AUTO;

-- Existing table: adopt liquid clustering (drops partition/Z-ORDER strategy)
ALTER TABLE finance.silver.journal_lines CLUSTER BY (company_code, fiscal_period);

Partitioning vs. liquid clustering: the decision framework

As of mid-2026 the official recommendation is liquid clustering for all new Delta tables, preferably automatic via CLUSTER BY AUTO with key selection powered by predictive optimization. Hive-style partitioning plus Z-ORDER is legacy guidance - and the two are mutually exclusive: a table is either partitioned/Z-ORDERed or liquid-clustered, never both.

DimensionPartitioning + Z-ORDER (legacy)Liquid clustering (current default)
Key cardinalityLow-cardinality partition keys only; high cardinality explodes directories and small filesHandles high-cardinality keys; no directory explosion
Changing access patternsRepartitioning = full table rewrite; Z-ORDER rewrites on every runALTER TABLE ... CLUSTER BY changes keys without rewriting existing data; incremental clustering
Skewed key distributionSkewed partitions stay skewedClustering balances file sizes regardless of key skew
Operational loadYou schedule and pay for Z-ORDER runsWith CLUSTER BY AUTO + predictive optimization, largely hands-off
When it still makes senseExisting stable tables already tuned this way; hard physical-isolation needs (e.g., partition-boundary deletes/retention)All new tables; migrate existing tables opportunistically when access patterns shift

Pitfall: over-partitioning is still the most common layout mistake in the wild. Partitioning by date and region and category on a mid-sized table creates thousands of tiny partitions, each full of tiny files - the small-file problem with a directory structure on top. Anything below roughly 1 TB rarely needed partitioning even under the old guidance. In an interview, knowing when not to partition signals more seniority than reciting partitioning syntax. And never describe partitioning + Z-ORDER as your recommendation for a new table - say liquid clustering, and mention your production Z-ORDER experience as exactly that: tuning existing tables.

Caching: three layers, three different things

LayerWhat it cachesScope and invalidationUse when
Spark cache (df.cache() / persist())Materialized DataFrame in executor memory/diskPer-application; manual - goes stale if the underlying table changes; evicted under memory pressureOne DataFrame reused several times within one job (e.g., iterative logic); always unpersist() after
Disk cache (a.k.a. Delta cache)Parquet data files on workers' local NVMePer-cluster; automatic and consistency-aware (detects file changes)Repeated reads of the same tables on the same cluster; enabled by default on storage-optimized instances
DBSQL result cacheFinal result sets of queries on SQL warehousesPer-warehouse (plus a remote layer on serverless); invalidated when underlying data changesDashboards where many users re-run identical queries - the BI workhorse

Pitfall: sprinkling .cache() over a pipeline as a performance reflex. Caching a DataFrame used once costs memory (often causing the very spill you're debugging) and adds a materialization step. The disk cache already covers repeated reads transparently - reach for .cache() only when you can name the specific re-use it serves.

Joins and skew: the recap

The deep treatment lives in Module 01 - Apache Spark Internals & Troubleshooting; for this module you need the decision summary. Broadcast the small side of a join to eliminate the shuffle entirely (check the threshold; hint explicitly when statistics mislead the planner). Let AQE do its job - it coalesces post-shuffle partitions, converts sort-merge joins to broadcast at runtime, and splits skewed partitions via skewed-join handling. When AQE isn't enough - one customer or one ledger account dominating a key - salt the hot key manually. Diagnose skew in the Spark UI as a handful of straggler tasks running far longer than the stage median.

Writing patterns that keep Photon and the optimizer in play

# Bad: opaque UDF - no Catalyst, no Photon
@udf("double")
def margin(rev, cost):
    return (rev - cost) / rev if rev else None

# Good: built-in expressions - optimizable, Photon-eligible
df = df.withColumn(
    "margin",
    F.when(F.col("rev") != 0, (F.col("rev") - F.col("cost")) / F.col("rev"))
)

Symptom → likely cause → fix

SymptomLikely causeFix
One stage dominated by long shuffle read/writeWide join/aggregation moving too much data; no pruning; small side not broadcastFilter and project before the shuffle; broadcast the small side; cluster/layout the table so file pruning works; verify AQE coalescing is on
Spill (memory and disk) in stage metricsShuffle partitions too large for task memory; under-provisioned memory per coreLet AQE size partitions; raise spark.sql.shuffle.partitions if static; move to memory-optimized instances; reduce data per task before resizing the cluster
Driver OOMcollect() / toPandas() on big data; huge task-result accumulation; thousands of tiny tasks overwhelming scheduling stateRemove driver-side collection; write to tables instead; compact small files to cut task counts; bigger driver only as last resort
Stragglers - a few tasks run 10-100x longer than the medianData skew on the join/aggregation key (one hot customer, account, or null bucket)Confirm via task-duration distribution; enable AQE skew handling; salt the hot key; handle null keys separately
Slow MERGEMERGE rewrites every file containing a potential match: no pruning on the target, small files, or an unclustered merge keyAdd target-side predicates (e.g., date range) to the ON clause; compact the target; liquid-cluster on the merge key; enable deletion vectors so updates/deletes avoid full file rewrites

"A nightly job that used to run in 40 minutes now takes 3 hours and costs 4x. Walk me through what you'd do."

Strong outline: (1) What changed? Compare Spark UI / job metrics against a good run - data volume growth, new code, cluster change, more concurrent workloads. (2) Localize: find the dominant stage; classify it as shuffle volume, spill, or skew. (3) Layout first: check small files and pruning - is OPTIMIZE/predictive optimization actually running? Did a key's cardinality drift? (4) Code: any new UDFs, a join that lost its broadcast because the small side grew past the threshold, a MERGE without target pruning. (5) Compute last: right-size or consider Photon/serverless with a cost-per-run comparison from system tables. Close with prevention: alert on runtime and cost regressions so it never gets to 3 hours unnoticed. The order is the answer.

Cost observability: you can't optimize what you can't attribute

On the Data Intelligence Platform, cost data is just more Delta tables - which means you can do FinOps with the same SQL skills you use for finance data:

-- Last 30 days of cost by team tag and SKU
SELECT u.custom_tags['team']                       AS team,
       u.sku_name,
       SUM(u.usage_quantity * p.pricing.default)   AS est_dollars
FROM system.billing.usage u
JOIN system.billing.list_prices p
  ON u.sku_name = p.sku_name
 AND u.usage_start_time >= p.price_start_time
 AND (p.price_end_time IS NULL OR u.usage_start_time < p.price_end_time)
WHERE u.usage_date >= current_date() - INTERVAL 30 DAYS
GROUP BY ALL
ORDER BY est_dollars DESC;

The FinOps operating model: visibility → accountability → optimization

Frame cost work as an operating loop, not a one-off cleanup. Visibility: system tables, enforced tagging, and an AI/BI dashboard showing spend by team, job, and SKU - cost becomes a metric people see weekly. Accountability: attribute every dollar to an owner via tags, set budgets per team, and let cluster policies make the efficient path the default path. Optimization: only now do the technical work - right-sizing, autoscaling and spot policy, Photon and serverless evaluations, layout and compaction - prioritized by the dashboard, and measured against it afterward so wins are provable. Then the loop repeats, because workloads drift. Skipping straight to optimization without visibility is how teams "save" money on clusters nobody was using anyway while the real burn continues untouched.

Your biggest cost number lives at Maersk: re-architecting Fact-Based Reporting's consumption layer - SSAS multidimensional cubes migrated to Azure Analysis Services Tabular, then a Dremio + Power BI semantic-layer lakehouse over Delta on ADLS Gen2, plus moving managed tables to external tables for governance and cost control - delivering a $10M annual infrastructure cost reduction. That figure comes from your resume: verify how it was calculated (and over what baseline) before quoting it externally - in the meantime, "eight figures annually" or "a multi-million-dollar annual reduction" is a safe phrasing. The architectural story needs no hedging: you removed an expensive serving tier by letting the semantic layer query the lakehouse directly - eliminating a copy of the data and the infrastructure that hosted it, which is FinOps at the architecture level rather than the cluster level.

"How would you stand up cost governance for Databricks across an organization?"

Strong outline: (1) Visibility - enable and query billing system tables, build a spend dashboard by workspace/team/job, dollarize with list prices. (2) Accountability - mandatory tag policy enforced through cluster policies, budgets with alerts per team, monthly cost review with owners in the room. (3) Optimization - a prioritized backlog from the dashboard: kill idle/oversized clusters, fix autoscaling and auto-termination defaults, spot for retry-tolerant batch, Photon/serverless where the math works, then data-layout work (compaction, liquid clustering, predictive optimization) for the biggest tables. Anchor it in experience: you ran this loop at ADM - right-sizing, autoscaling policies, and Delta tuning - and you've delivered architecture-level cost reduction at Maersk. Name the loop explicitly: visibility → accountability → optimization, repeated.

One-line summary to carry into interviews: "Measure with the Spark UI and Query Profile, fix layout before code, code before compute, and make cost a first-class metric with system tables, tags, and policies." Every question in this domain is some subset of that sentence.