Back to Blog All Walkthroughs
01

The PTSS Cliff: A Kernel That Won't Build

Why a large-K FMA matmul on a non-DPAS Intel iGPU fails before it ever runs

Where Bytes Can Live

Every GPU optimization argument is, at heart, an argument about where a value sits when you need it. Get the location wrong and either the memory bus or the register file bottlenecks you. Three locations matter for this story.

G

GRF — Register File

128 registers × 32 bytes = 4 KB per thread, 1-cycle access. The entire universe of things a thread can name at once. Overflow it and the compiler spills.

S

SLM — Shared Local Memory

A software-managed scratchpad shared across a workgroup. 64 KB on Xe-LPG iGPUs, one or two cycles to access. CUDA calls it "shared memory."

P

PTSS — Per-Thread Scratch

A DRAM region (backed by L3) where spilled registers land. Capped at 256 KB per thread. Spill past that and the kernel does not even build.

DRAM / system memory ~50 GB/s · GBs of capacity · ~hundreds of cycles · PTSS lives here L3 cache (shared across the GPU) few MB · ~tens of cycles SLM — Shared Local Memory 64 KB per workgroup · ~few cycles · software-managed GRF — General Register File 4 KB per thread · 1 cycle
Smaller, faster, more private as you go up. The pass moves bytes between these levels to dodge a specific disaster.
📍
The hierarchy in one line

Smaller, faster, more private as you go up: DRAM/PTSS → L3 → SLM → GRF. This pass moves bytes between these levels to dodge a specific disaster.

Two Paths From a tt.dot

When you write tl.dot(a, b, acc) in a Triton kernel, the compiler picks one of two lowering paths depending on the hardware.

D

DPAS Path

On hardware with a systolic matrix unit (PVC, BMG, ARL-H Xe2), the dot lowers to a few dpas instructions per K step. Register pressure stays bounded. This pass is a no-op here.

F

FMA Path

On non-DPAS iGPUs (ARL-S, MTL, A770), there is no matrix unit. The dot falls back to a hand-coded FMA loop nest, fully unrolled along K. This is the path that breaks.

The Arithmetic of "Fully Unrolled K"

Take a concrete shape: a 64×128×128 f16 GEMM where one thread covers an 8×8 micro-tile of the output. For its 8×8 chunk of C, the thread needs:

PER-THREAD WORK
# One thread owns an 8x8 block of C
C[i][j] = sum(A[i][k] * B[k][j]
          for k in range(128))

# 64 output cells x 128 K-steps
# = 8192 FMAs per thread, unrolled flat
WHAT IT MEANS

Each thread is responsible for 64 output cells.

Each cell needs a 128-step dot product along K.

The Triton lowering plus IGC's backend fully unroll that K loop — all 8192 FMAs live flat, with no loop structure left to bound register pressure.

There is no TTGIR-level K-tile knob in upstream's pipeliner, and IGC's unroll heuristic does not fire at this pressure regime.

A: 64×128 B: 128×128 = C: 64×128 For my 8×8 chunk of C, I need C[i][j] = Σ A[i][k] × B[k][j] for k = 0..127 That's 64 output cells × 128 K-steps = 8192 FMAs per thread, all unrolled flat
One thread is responsible for 64 output cells × 128 multiply-add steps each = 8192 FMAs. Highlighted strips are the operand slices it actually consumes.

The Cliff: 345 KB Spill, 256 KB Cap

A SIMD16 thread on Xe-LPG has a 4 KB GRF in the default small register mode. The fully-unrolled schedule wants far more than that, so IGC spills the surplus into PTSS. But ARL-S, A770, and MTL iGPUs cap PTSS at 256 KB per thread, enforced by Intel's compute-runtime (NEO) driver.

1
Spill estimate: 345 KB

On the 64×128×128 f16 GEMM, the spilled schedule wants 345 KB per thread — an 89 KB overshoot past the ceiling.

2
IGC reports: out of scratch space

The backend cannot fit the schedule into the 256 KB scratch budget.

3
Driver returns: ZE_RESULT_ERROR_INVALID_NATIVE_BINARY

The kernel is rejected at zeKernelCreate time. No SPIR-V is emitted.

256 KB ceiling “in-budget” zone overshoot 89 KB FMA-unrolled spill estimate per thread: 345 KB ↳ IGC reports: out of scratch space ↳ Driver returns: ZE_RESULT_ERROR_INVALID_NATIVE_BINARY No SPIR-V is emitted. The kernel never gets to run.
Full-K unroll asks for 345 KB; the driver caps you at 256 KB. There is no compiler flag to flip.
⚠️
This is not a soft regression

The kernel does not run slowly — it does not run at all. And there is no compiler flag to flip: the -ftarget-register-alloc-mode large-GRF switch is restricted to PVC, so it is not a portable escape hatch.

Check Your Understanding

Which memory region's 256 KB cap causes the build to fail outright?

Which lowering path hits the PTSS cliff?

02

The Idea: Stage in SLM, Tile K

Trade a register-bound flat schedule for a small, bounded loop fed from the scratchpad

Two Moves, One Goal

The fix is a new TTGIR pass — tritonintelgpu-stage-large-fma-dots-via-slm — that rewrites a doomed tt.dot into a shape IGC can actually schedule. It makes exactly two moves.

📥

Move 1 — Stage operands in SLM

Instead of keeping the full A and B tiles live in registers, write them once to SLM. The scratchpad becomes the home for the operands; the GRF only holds the slice in flight.

✂️

Move 2 — Tile the K dimension

Split the 128-deep dot into chunks of K_TILE. Loop over the chunks, accumulating into C. Only K_TILE columns of each operand are live in registers at any instant.

💡
The core trade

We swap a flat, fully-unrolled schedule that spills 345 KB to PTSS for a small, looped schedule whose live set is bounded by K_TILE. SLM bandwidth replaces register pressure — and SLM is the one budget we have headroom in.

BEFORE — one monolithic K=128 dot tt.dot (full K=128) 8192 FMAs unrolled flat → 345 KB spill → fail AFTER — four K=32 dots fed from SLM K=32 K=32 K=32 K=32 ~1 KB ~1 KB ~1 KB ~1 KB → each fits in GRF, no spill SLM: full A and B staged once (16 KB) memdesc_subslice + local_load per K-tile Same total work. Fewer registers live at once.
Before: one big dot, full unroll, spill exceeds the cap. After: SLM holds A and B, four small dots pull K-tiles back, each one fits in GRF.

Watch the Bytes Move

Step through how a single K-tiled, SLM-staged dot processes its operands. Notice that the register file only ever holds one K_TILE-wide slice at a time.

GRF
Register File
SLM
Shared Local Memory
K✂
K-Tile Loop
∑C
Accumulator
Click "Next Step" to begin
Step 0 / 6

Why SLM Is the Right Place to Spill

We are not eliminating the data movement — the operands are big, they have to live somewhere. The insight is which budget to spend.

🛑
Spilling to PTSS (the default)

Hard 256 KB per-thread cap — overflow means no binary. Backed by DRAM via L3 with high miss latency, private per thread so nothing is reused, and there is no TTGIR-level knob to control it.

Staging in SLM (this pass)

A 64 KB workgroup budget with real headroom at these tile sizes, one- to two-cycle access instead of DRAM latency, shared across the workgroup so operands can be reused, and explicitly sized and placed by the pass itself.

03

The Algorithm & the IR Rewrite

Five steps that turn one oversized tt.dot into a tiled, SLM-staged loop

The Pass, Step by Step

The pass walks every tt.dot in the module. For each one that lands on the FMA path and trips the size gate (Module 4), it performs this rewrite:

1
Allocate SLM scratch

Reserve two ttg.local_alloc buffers — one for the A operand tile, one for B — sized to the operand tiles in their native dtype.

2
Store operands to SLM

Emit ttg.local_store for A and B before the dot. The full operands now live in the scratchpad, not in registers.

3
Build the K-tile loop

Wrap the accumulation in an scf.for over K / K_TILE iterations, carrying the C accumulator as a loop-carried value.

4
Load slice, dot, accumulate

Inside the loop, ttg.local_load one K_TILE-wide slice of A and B, run a small tt.dot on it, and add into the accumulator.

5
Yield the result

After the loop, the carried accumulator is the original dot's result. Replace all uses of the old tt.dot with it and erase the original.

Before → After, in TTGIR

Here is the shape of the rewrite. On the left, the original single oversized dot; on the right, what the pass leaves behind.

BEFORE — one flat dot
// K = 128, fully unrolled by the
// FMA lowering -> 345 KB spill
%c = tt.dot %a, %b, %c0
  : tensor<64x128xf16>
  * tensor<128x128xf16>
  -> tensor<64x128xf32>
AFTER — staged + tiled

%sA = ttg.local_alloc / %sB = ttg.local_alloc — SLM scratch for both operands.

ttg.local_store %a, %sA and %b, %sB — operands written to SLM once.

%c = scf.for %k = 0 to 128 step K_TILE iter_args(%acc = %c0) — the K-tile loop.

Inside: local_load a K_TILE slice of each, a small tt.dot, then scf.yield %acc + %partial.

All uses of the old result are RAUW'd to the loop's result; the original dot is erased.

🧩
It stays in TTGIR

Every op the pass emits — local_alloc, local_store, local_load, scf.for, tt.dot — already has a lowering. The pass composes existing primitives; it introduces no new codegen.

Check Your Understanding

Which ops does the pass use to move operands into and out of SLM?

04

Picking K_TILE & the Four Gates

A byte budget decides the tile size; four predicates decide whether to fire at all

The SLM Footprint

Before tiling, the pass checks that staging both operands in SLM actually fits the workgroup budget. For the running 64×128×128 f16 example:

SLM BUDGET CHECK
# A tile: 64 x 128 f16
A = 64 * 128 * 2  = 16384 B

# B tile: 128 x 128 f16
B = 128 * 128 * 2 = 32768 B

total = 48 KB  <  64 KB SLM ✓
WHY IT MATTERS

Staging both operands costs 48 KB of the 64 KB workgroup SLM — it fits, with 16 KB to spare.

If the operands did not fit, the pass would bail rather than blow the SLM budget and serialize the workgroup.

K_TILE then controls only how much of each operand is live in registers per loop iteration — the SLM copy stays whole.

Choosing K_TILE

The tile size is the one tuning knob. Pick it too large and the per-iteration live set spills again; too small and loop overhead and SLM traffic dominate.

⬆️

Too large

A wide slice keeps too many columns live per iteration — register pressure climbs back toward the PTSS cliff we were escaping.

⬇️

Too small

Many tiny iterations mean more loop bookkeeping and more local_load round-trips — SLM bandwidth and loop overhead start to dominate.

🎯

Just right

The largest K_TILE whose live set still fits the GRF budget — maximal work per iteration with no spill. The pass solves for this directly.

The Four Gates

The pass is conservative by design. A dot is rewritten only if it passes all four gates — otherwise it is left exactly as-is, guaranteeing the pass can never make a healthy kernel worse.

1
Gate 1 — FMA path only

The target must lack a DPAS unit. On DPAS hardware the dot is already cheap, so the pass refuses to touch it.

2
Gate 2 — Size threshold

The dot's estimated spill must actually exceed the budget. Small dots that schedule fine are left alone.

3
Gate 3 — Tileable K

K must be large enough, and cleanly divisible, to split into K_TILE chunks without a ragged remainder.

4
Gate 4 — SLM fits

The staged operands must fit the workgroup SLM budget. If staging would overflow SLM, the pass declines.

🛡️
Fail-safe by construction

Because all four gates must pass and the fallback is "do nothing," the worst case for any kernel the pass doesn't help is a no-op. That property is what makes it safe to enable by default.

Check Your Understanding

How many of the four gates must a dot pass to be rewritten?

05

Pipeline Placement & Design Choices

Where the pass sits in the compiler, and how the shipped design diverged from the first sketch

Where It Runs

Placement matters as much as the rewrite. The pass runs in the TTGIR phase — after layouts are assigned (so it knows the operand shapes and the FMA-vs-DPAS decision) but before the software pipeliner and SLM allocation finalize.

1
After layout assignment

The pass needs the resolved operand layouts and the FMA/DPAS verdict to evaluate its gates. Running earlier would mean guessing.

2
Before the pipeliner

The new scf.for K-loop and SLM ops are handed to the existing pipeliner and allocator, which schedule and place them like any other loop.

3
Inside the Intel backend

It lives in the intel third-party tree as a target-specific TTGIR pass, so it only ever runs for Intel GPU compilation.

🔗
Composability is free

Because the pass emits ordinary scf.for + SLM ops, the downstream pipeliner, allocator, and lowerings treat its output as a normal loop. No special-casing anywhere else in the stack.

Five Ways the Final Design Diverged

The shipped pass is not the napkin sketch from the original proposal (PR #7276). Five deliberate changes hardened it for production.

1
TTGIR, not LLVM IR

The sketch patched late, near codegen. The shipped pass works in TTGIR so it can reuse local_alloc/local_load and ride the existing pipeliner.

2
Explicit FMA gate

The sketch assumed the caller only invoked it on FMA dots. The shipped pass checks the path itself, so it is safe to run unconditionally.

3
Both operands staged

Staging only the larger operand left the smaller one spilling. The final design stages A and B for a clean, bounded live set.

4
Solved K_TILE, not hard-coded

A fixed tile size regressed some shapes. The pass now derives K_TILE from the register and SLM budgets per dot.

5
Four gates, fail-safe

The sketch always rewrote. The shipped pass added the size, tileability, and SLM-fit gates so the fallback is always a guaranteed no-op.

06

Validation, Prior Art & Risk

What the pass actually recovers on ARL-S, where the idea comes from, and why it is safe

Measured on ARL-S

The pass was validated on an Arrow Lake-S iGPU across three groups of kernels: ones that used to fail, ones that already worked but were slow, and ones that should be untouched.

Group A — Previously failing: 9 / 9 recovered

Every one of the nine kernels that hit the INVALID_NATIVE_BINARY cliff now builds and runs correctly. The PTSS overflow is gone.

🚀
Group B — Already working: 1.86× geomean speedup

Kernels that compiled but spilled heavily got faster too — a 1.86× geometric-mean speedup from trading PTSS round-trips for bounded SLM traffic.

⚖️
Group C — Should be untouched: verified no-op

DPAS-path and small-dot kernels produced byte-identical IR with the pass on or off — confirming the four gates hold.

Not a New Idea — A Ported One

Staging operands in scratchpad and tiling the contraction dimension is the oldest trick in the high-performance GEMM book. This pass brings it to a path that was missing it.

CUTLASS NVIDIA's GEMM templates stage tiles through shared memory and iterate over K in its main loop — the canonical reference for this pattern.
Composable Kernel AMD's CK library does the same LDS-staged, K-tiled blocking for its matmul pipelines.
Triton pipeliner Upstream Triton's software pipeliner already stages DPAS/MMA operands in shared memory — this pass extends the idea to the FMA fallback the pipeliner skips.
CUTLASS / CK / upstream Triton circular multi-buffer across iterations Iter 0 Iter 1 Iter 2 Iter 3 smem[0] smem[1] smem[0] smem[1] This pass single SMEM, sub-slice within one iteration one allocation: full A and B in SLM K-tile 0 K-tile 1 K-tile 2 K-tile 3 Trade-off: this pass gives up cross-iteration prefetch overlap in exchange for a hard cap on per-thread register pressure. The two patterns compose — software pipelining still buys the overlap.
Same SMEM tool, used differently: across iterations vs within one iteration.
🧭
The gap it fills

Every mature GEMM stack tiles K through scratchpad — but only on its fast path. The non-DPAS FMA fallback on Intel iGPUs never got that treatment. This pass closes that specific gap.

Risk & Composability

The pass ships behind the four gates and emits only standard TTGIR. That bounds its blast radius tightly.

🔒

Bounded downside

If any gate fails, the dot is untouched. The worst case for an unhelped kernel is a no-op — never a regression.

🧩

Composes cleanly

Output is ordinary scf.for + SLM ops, so the pipeliner, allocator, and lowerings need no special-casing.

👁️

Watch SLM pressure

Staging consumes SLM that other parts of a kernel might want. Gate 4 guards the budget, but occupancy on SLM-heavy kernels is worth monitoring.

Final Check

On ARL-S, what happened to the nine kernels in Group A that previously failed to build?

What is the worst-case outcome for a kernel that doesn't clear the gates?

📦
Ships in

This pass landed as intel/intel-xpu-backend-for-triton#7291, evolving the original sketch from PR #7276.