MSAZ-321 · Lesson 10 of 12 · 21 min

Optimizing Spark and warehouse performance

▶ Video coming soon A video version of this lesson is on the way.

After lunch today, Friday, November 13, Nikolai and Ines took on the two performance questions left over from earlier sessions: why nb_clean_sales spent two and a half of its nine minutes starting up, and why Keiko's Sales Overview visual was slow on Fridays. The first was Spark; the second turned out to be the warehouse. Walt Hadley joined for the second half, since the warehouse's T-SQL was familiar ground for a database administrator.

Spark: start-up time

A Spark notebook cannot run until it has a session, and how long that takes depends on the pool and environment. Fabric's starter pools keep warm nodes ready, so a session on a starter pool with no custom libraries usually starts in seconds. A custom pool, or a starter pool with an environment that installs extra libraries, has to provision nodes or install packages first, which takes minutes. nb_clean_sales used an environment with two extra Python libraries, so every run paid about two and a half minutes before the first cell ran.

There were three fixes, and they combined. First, Ines checked whether the libraries were still needed: one, for fuzzy matching of product names, had been replaced by plain PySpark in MSAZ-320, so it came out. Second, high concurrency mode lets notebooks that share the same lakehouse and environment reuse one session; in a pipeline, turning it on for the workspace and giving the notebook activities the same session tag meant nb_setup_dates, nb_clean_sales and nb_clean_products shared one session instead of starting three. Third, the remaining library was small enough to install inline at the start of the notebook during development, while production kept it in the environment.

Reducing Spark start-up time in the nightly pipeline.
ChangeStart-up beforeStart-up after
Remove the unused library from the environmentAbout 150 sAbout 95 s
Share one session across the three notebooks (high concurrency, session tag)3 sessions a night1 session a night
Total session start-up per nightAbout 7 minutesAbout 1.5 minutes

Spark: the engine and its settings

Once a session is running, the biggest single setting in Fabric Spark is the native execution engine, which runs Spark SQL and DataFrame operations in a vectorized C++ engine instead of on the Java virtual machine. It is enabled in the environment or per session with spark.native.enabled, and for scan-, filter- and aggregation-heavy work it is often much faster. Not every operation is supported; when one falls back to ordinary Spark, the Spark advisor shows a message in the cell output, which is worth reading because a fallback in the middle of a query can lose most of the benefit.

Adaptive query execution, on by default, adjusts plans while they run: it merges small shuffle partitions after a shuffle, switches a join to a broadcast join when one side turns out to be small, and splits skewed partitions. The default number of shuffle partitions is 200, which suits large data; for a nightly load of a few hundred megabytes, 200 partitions means 200 tiny tasks. Adaptive execution coalesces many of them, but setting spark.sql.shuffle.partitions to a size that fits the data avoids the waste in the first place.

Python Example · Sizing shuffle partitions and checking which dimensions broadcast.
# How many shuffle partitions suit a job? Aim for roughly 128 MB per partition.
target_mb = 128
jobs = {"nightly clean (one day)": 350, "monthly slice backfill": 3_400, "full 3-year rebuild": 41_000}
for name, shuffle_mb in jobs.items():
    default_size = shuffle_mb / 200
    suggested = max(8, round(shuffle_mb / target_mb))
    print(f"{name:26s} shuffle {shuffle_mb:>6,} MB: 200 partitions of {default_size:6.1f} MB;"
          f" suggest about {suggested:>3} partitions")

broadcast_limit_mb = 10            # Spark's default automatic broadcast threshold
for table, mb in {"dim_store": 0.2, "dim_product": 38, "dim_date": 0.3}.items():
    print(f"{table:12s} {mb:>5} MB: {'broadcast automatically' if mb <= broadcast_limit_mb else 'too large for automatic broadcast'}")
Output
nightly clean (one day)    shuffle    350 MB: 200 partitions of    1.8 MB; suggest about   8 partitions
monthly slice backfill     shuffle  3,400 MB: 200 partitions of   17.0 MB; suggest about  27 partitions
full 3-year rebuild        shuffle 41,000 MB: 200 partitions of  205.0 MB; suggest about 320 partitions
dim_store      0.2 MB: broadcast automatically
dim_product     38 MB: too large for automatic broadcast
dim_date       0.3 MB: broadcast automatically

What this code does

The program takes three jobs and the amount of data each one shuffles, and compares Spark's default of 200 shuffle partitions with a count that gives about 128 megabytes per partition, never fewer than eight. It then checks three dimension tables against Spark's default ten-megabyte limit for broadcasting a table to every executor in a join. The output shows that the nightly job's default partitions are under two megabytes each, that the full rebuild needs more than 200, and that the product dimension is too large to broadcast automatically.

The output gave Ines two changes. The nightly notebook set shuffle partitions to 8, leaving adaptive execution to adjust further, and the rebuild notebook, which shuffled about 41 GB, used about 320. And dim_product, at 38 MB, would not broadcast on its own, so the join to it shuffled the whole day's sales; an explicit broadcast hint fixed that, because 38 MB fitted comfortably in each executor's memory. Nikolai warned against copying these numbers to other jobs: the right values come from the Spark UI's stage metrics, not from a rule of thumb.

Settings and a broadcast hint for the nightly notebook (PySpark; not runnable here).
spark.conf.set("spark.native.enabled", "true")
spark.conf.set("spark.sql.shuffle.partitions", "8")

from pyspark.sql.functions import broadcast
clean = sales_day.join(broadcast(dim_product), "ProductCode", "left")

Spark: skew and memory

Skew is when a few tasks have far more data than the rest, so the stage waits for them. In the Spark UI, a stage whose maximum task time is many times its median is skewed. In the hub, the full rebuild had one: the busiest store, number 4, had about six times as many sales lines as the quietest, and a step grouped by store. Adaptive execution's skew handling splits skewed partitions in joins; for aggregations, adding a second grouping key, such as the day, spreads the work. Memory errors are the other common failure, usually from collecting large results to the driver, caching more than is needed or very wide rows; the spark.task.cpus setting, which defaults to 1, can be raised so that memory-hungry tasks get more of each executor.

Reading Spark symptoms.
Symptom in the Spark UILikely causeRemedy
Many tasks of a few milliseconds eachToo many shuffle partitionsLower shuffle partitions; rely on adaptive execution
A few tasks take many times the medianSkewed keysSkew join handling; add a key to spread aggregation
Large shuffle before a join to a small tableNo broadcastBroadcast hint or a higher threshold
Executors lost with out-of-memory errorsCollecting or caching too much; memory-hungry tasksAvoid collect on large data; raise spark.task.cpus
Fallback messages in the advisorOperations the native engine cannot runRewrite the operation or accept the fallback
Two panels. Left, labeled shuffle join: a large fact_sales block and a 38 MB dim_product block are both split and exchanged across four executors, with many crossing arrows. Right, labeled broadcast join: dim_product is copied whole to each of four executors, and fact_sales partitions stay where they are, with no crossing arrows. A caption strip notes the default automatic broadcast threshold of 10 MB.
A shuffle join against a broadcast join for a small dimension.

The warehouse: Keiko's Friday problem

Keiko's slow visual showed each store's sales for the current week against the same week last year. It read a view, wh_sales.dbo.vw_store_week, which Walt had written quickly in MSAZ-320 to test the star schema. Sales Overview was a Direct Lake model, but Direct Lake cannot read a SQL view directly, so visuals on that view fell back to DirectQuery, sending T-SQL to the warehouse each time. On Fridays, when all 23 store managers looked at the week at once, those queries queued.

The warehouse's query insights confirmed it. Every Fabric warehouse has a queryinsights schema with views over its recent history: exec_requests_history lists completed queries with their duration, data scanned and the user; long_running_queries and frequently_run_queries summarize them. Walt filtered exec_requests_history to the last two Fridays: the view's query ran 231 times between 8 and 9 a.m. on November 6, with a median of 5.8 seconds, and each run scanned the whole of fact_sales.

Finding the busiest queries in a Friday hour (times in UTC; column names may differ by release).
SELECT TOP 10 command, COUNT(*) AS runs,
       AVG(total_elapsed_time_ms) / 1000.0 AS avg_seconds,
       AVG(data_scanned_remote_storage_mb) AS avg_mb_scanned
FROM queryinsights.exec_requests_history
WHERE start_time >= '2026-11-06T14:00' AND start_time < '2026-11-06T15:00'
GROUP BY command
ORDER BY runs DESC;

The view filtered the week with a function applied to the date column, WHERE DATEPART(week, SaleDate) = DATEPART(week, GETDATE()), and a predicate that wraps the column in a function cannot use the column's statistics or the file-level elimination underneath, so every query read every row. Walt rewrote the filter as a range on the integer DateKey, DateKey BETWEEN @week_start AND @week_end, computed once from dim_date, and the scan fell to the fortnight's rows. Then he went further: because the weekly comparison was the same for everyone, he had the nightly load_day procedure maintain a small gold table, gold_store_week, with one row per store per week, which Sales Overview could read in Direct Lake mode with no fallback at all.

Fixing the Friday visual.
StepMedian timeData scanned per query
Original view, function on SaleDate5.8 sAll of fact_sales
View rewritten with a DateKey range1.1 sAbout two weeks of rows
Gold table read in Direct LakeUnder 0.5 sOne row per store per week

Warehouse habits

The Fabric warehouse manages much of its own tuning: it maintains statistics automatically, distributes data itself and allocates resources without the resource classes of older SQL products. What remains for the engineer is the design. Statistics can be created or updated manually after a large load if queries run before automatic statistics catch up. Choosing the narrowest data types that fit, such as int keys and varchar of a sensible length rather than varchar(max), shrinks data and speeds joins. Selecting only needed columns matters because storage is columnar. And predicates should compare columns directly, as Walt's fix showed. The first run of a query after a period of quiet can also be slower than later runs, because data must be fetched from OneLake into the warehouse's cache, so performance tests should time several runs, not one.

Warehouse performance habits.
HabitWhy
Filter on columns, not on functions of columnsLets statistics and elimination work
Narrow data types; avoid varchar(max) for keysSmaller data, faster joins
Select only the columns neededColumnar storage reads only those columns
Pre-aggregate shared, repeated questions into gold tablesOne computation instead of hundreds
Check queryinsights after a complaintShows what ran, how often and how long
Time several runs when testingThe first run after a quiet period warms the cache

Pipelines

Pipelines have their own performance levers, and DP-700 lists optimizing a pipeline separately. Activities with no dependency between them run in parallel, so the shape of the pipeline matters: in pl_nightly_sales, the copy job for small tables already ran alongside copy_sales. ForEach loops run their iterations in parallel up to a batch count, sequentially if Sequential is chosen; the monthly backfill slices in MSAZ-320 used a batch count of 4 to keep capacity use moderate. Copy activities speed up with partitioned reads and parallel copies, as MSAZ-320 showed, and slow down when they wait on a busy source, as lesson 7 found. Small, frequent activities each carry a few seconds of overhead, so combining many tiny steps into one notebook or stored procedure can save more than tuning any of them.

Worked problem

What does sharing a Spark session save?

Three notebooks each started their own session, taking 150, 140 and 130 seconds to start. With high concurrency and a session tag, one session starts in 95 seconds and the other two attach in about 5 seconds each. How much start-up time is saved per night, and over a 30-day month?

Show solution
  1. Before: 150 + 140 + 130 = 420 seconds, 7 minutes.
  2. After: 95 + 5 + 5 = 105 seconds, 1.75 minutes.
  3. Saved per night: 420 − 105 = 315 seconds, 5.25 minutes.
  4. Per month: 315 × 30 = 9,450 seconds, about 2.6 hours.

Answer: About five and a quarter minutes a night and over two and a half hours a month, with the same results.

Common mistakes

  • MistakeAccept Spark's default of 200 shuffle partitions for every job.

    InsteadSize shuffle partitions to the data, and check the Spark UI.

  • MistakeJoin a large fact to a 38 MB dimension and wait for a shuffle.

    InsteadUse a broadcast hint when the dimension fits in executor memory.

  • MistakeFilter a warehouse view with a function on the date column.

    InsteadCompare the column directly, for example a range on DateKey.

  • MistakePoint a Direct Lake model at a SQL view and expect Direct Lake speed.

    InsteadViews cause DirectQuery fallback; materialize shared results as tables.

Summary and key terms

  • Spark start-up depends on the pool and environment; starter pools, fewer libraries and shared high-concurrency sessions shorten it.
  • The native execution engine and adaptive query execution speed up most Spark SQL work; the advisor reports fallbacks.
  • Size shuffle partitions to the data, broadcast small dimensions and handle skew.
  • queryinsights views show a warehouse's recent queries, durations and data scanned.
  • Warehouse speed comes from direct predicates, narrow types, column selection and pre-aggregated gold tables.
  • Pipelines go faster with parallel branches, sensible ForEach batch counts, partitioned copies and fewer tiny steps.
Native execution engine
A vectorized C++ engine that runs Spark operations faster than the JVM.
Adaptive query execution
Spark's adjustment of query plans while they run.
High concurrency mode
Sharing one Spark session among notebooks with the same lakehouse and environment.
Broadcast join
A join that sends a small table to every executor to avoid shuffling the large one.
Skew
Uneven data distribution that makes a few tasks far slower than the rest.
Query insights
Views in a Fabric warehouse that record recent queries and their performance.
DirectQuery fallback
A Direct Lake model sending queries to the SQL engine when it cannot read data directly.

Practice problems

Work each problem on paper first, then reveal the answer to check.

  1. What made nb_clean_sales slow to start?

    Show answer

    Its environment installed extra libraries, so the session took minutes to start.

  2. How do three notebooks share one session in a pipeline?

    Show answer

    Turn on high concurrency for the workspace and give the activities the same session tag.

  3. Why was dim_product not broadcast automatically?

    Show answer

    At 38 MB it was over Spark's default 10 MB threshold.

  4. What did query insights show about the Friday visual?

    Show answer

    Its query ran 231 times in an hour, took a median 5.8 seconds and scanned all of fact_sales.

  5. Why did the visual's queries go to the warehouse at all?

    Show answer

    The model read a SQL view, which Direct Lake cannot read, so it fell back to DirectQuery.

Lesson quiz · 5 questions

Score 60% or more to complete this lesson.

Take the quiz