Back to Blog All Walkthroughs
01

The Power-of-2 Wall

Why GPU block-I/O hardware insists on power-of-two extents — and how a single awkward dimension can lock an entire kernel out of the fast path

Two Ways to Move a Tile

Every tiled GPU kernel has to get a 2D block of data from memory into registers. On modern hardware there are two very different ways to do it, and the gap between them is large.

🚀

Block I/O (fast)

A single tensor descriptor drives a hardware 2D-block transfer — one wide, coalesced read of the whole tile. On Intel Xe this is the 2D-block load; CUDA calls the analogous idea TMA.

🐌

Pointer gather (slow)

Compute a pointer per element (or per row), then load with explicit masks. Correct for any shape, but it leaves the block-transfer hardware idle and is often ~2× slower on memory-bound tiles.

📍
The whole course in one sentence

The fast path is the one you want — but it comes with a hardware constraint. This course is about satisfying that constraint for a shape that naively fails it, instead of falling back to the slow path.

The Constraint: Power-of-Two Inner Extent

The catch with block I/O is that the descriptor's inner (contiguous) block extent must be a power of two. This isn't a software style rule — it falls out of how the descriptor is encoded and what the hardware's validator will accept.

Inner extent = 512 512 = 2⁹ ✓ power of two → accepted Inner extent = 896 896 = 2⁷ · 7 ✗ not pow2 → rejected When the inner extent isn't a power of two, the descriptor is invalid. The easy response is to gate the fast path off for those shapes — safe, but it leaves performance on the table. The better answer: reshape the read so the extent is a power of two.
A non-power-of-two inner extent fails validation. Gating off the fast path avoids the failure but forfeits the speed — this course shows the third option.

A Concrete Place This Bites: Attention

A clean, real example is the query (Q) load inside a fused-attention kernel. The kernel tiles a block of query rows and loads them through a tensor descriptor whose inner extent is (query heads per group) × (head dimension).

When it's a power of two

If both the head count per group and the head dimension are powers of two, the inner extent is too — the descriptor validates and the fast block load fires.

When it isn't

A single non-power-of-two factor — say 7 query heads per group instead of 4 or 8 — makes the inner extent non-pow2 and knocks the whole Q load onto the slow pointer path.

🎯
What this course teaches

We use that attention Q load as the running example, but the technique is general: pad the awkward axis up to a power of two, read the wider block, and mask the slack. By the end you'll be able to apply it to any block-I/O constraint, not just this one.

Check Your Understanding

What must be a power of two for a block-I/O descriptor to validate?

What is this course's strategy for a shape that fails the power-of-two constraint?

02

GQA and the Power-of-2 Gate

How grouped-query attention packs query heads into a tile — and why a non-power-of-two group size gets locked out of the fast path

One KV Head, Many Query Heads

In grouped-query attention (GQA), several query heads share one key/value head. The ratio that matters is num_queries_per_kv = num_query_heads / num_kv_heads — call it nq. It is the number of query rows that ride along with each KV head.

4

Power-of-two group size

e.g. 32 query heads over 8 KV heads → nq = 4. A power of two. This is the common, well-trodden case the fast path was built for.

7

Non-power-of-two group size

e.g. 28 query heads over 4 KV heads → nq = 7. Not a power of two. Some real models land here, and this single fact is what trips the gate.

How the Kernel Tiles It

The unified-attention kernel processes a tile of BLOCK_M rows at a time. Those rows are a 2D pack: BLOCK_Q query positions × nq query heads per KV group. Each row is one (position, head) pair sharing the tile's KV.

ORIGINAL ROW PACKING
# num_queries_per_kv heads per KV group
BLOCK_M = (16 if nq <= 16
           else next_pow2(nq))
BLOCK_Q = BLOCK_M // nq

# row r -> which position, which head
query_pos      = base + r // nq
head_in_group  = r % nq
WHAT IT MEANS

A BLOCK_M-row tile is laid out as BLOCK_Q positions, each expanded into nq head rows.

For pow2 nq, BLOCK_M / nq divides cleanly and the packing is dense — every row is a real head.

The kernel recovers the position with r // nq and the head with r % nq to index into Q.

This is fine arithmetically for any nq — the trouble starts only when this tile must be loaded via a tensor descriptor.

Two Ways to Load Q

Once the rows are addressed, the kernel must actually fetch Q from memory. There are two code paths, and the choice is gated.

🚀

TD path (fast)

A tl.make_tensor_descriptor drives the Xe 2D-block load hardware — one descriptor, one wide hardware-accelerated read of the whole tile.

🐌

Pointer path (slow)

A gather over per-row pointers with explicit masks. Correct for any shape, but it leaves the 2D-block I/O hardware idle — the source of the ~2× penalty.

THE OLD GATE
# Q/O take the fast TD path only if
# BOTH nq and head_size are pow2
_is_pow2_nq = (nq & (nq - 1)) == 0
_is_pow2_hs = head_size == \
              next_pow2(head_size)

use_td_qo = use_td and _is_pow2_nq \
            and _is_pow2_hs
THE CONSEQUENCE

_is_pow2_nq is the bit-trick test for "power of two": true for 1, 2, 4, 8, 16…

For nq = 7 it is false, so use_td_qo is false.

Result: a non-pow2 group size silently falls back to the slow pointer path for both the Q load and the O store — even though the hardware is perfectly capable of the load.

The technique in this course removes _is_pow2_nq from this gate. But first: why was it ever there?

Check Your Understanding

For a model with 28 query heads and 4 KV heads, what is num_queries_per_kv?

With the old gate, which path did a non-power-of-two group size take for its Q load?

03

Why the TD Path Demands a Power of 2

The tensor-descriptor validator has a hard rule about the inner extent — and a flattened nq × HEAD_SIZE axis violates it for nq = 7

The Descriptor Sees a Flattened Axis

The TD Q load doesn't describe a 3D (positions, heads, head_dim) tensor. It flattens the head and head-dim axes into one contiguous inner extent and describes a 2D block: (BLOCK_Q positions, nq × HEAD_SIZE columns).

THE Q DESCRIPTOR (BEFORE)
q_desc = tl.make_tensor_descriptor(
  base=q_base,
  shape=(q_block_local_len,
         nq * HEAD_SIZE),
  strides=(query_stride_0, 1),
  block_shape=(BLOCK_Q,
    nq * HEAD_SIZE_PADDED),  # <-- inner
)
THE INNER EXTENT

The inner block_shape dimension is nq × HEAD_SIZE_PADDED — all the heads of a KV group laid end to end.

HEAD_SIZE_PADDED is already next_pow2(head_size), so head-dim padding was a solved problem.

But the head count nq still multiplies in raw. For nq = 4, 4 × 128 = 512 — a power of two. For nq = 7, 7 × 128 = 896not a power of two.

The Validator's Rule

The Xe 2D-block I/O lowering requires the flattened inner extent of a tensor descriptor's block to be a power of two. This isn't a Triton style preference — it's what the hardware descriptor encoding and its validator accept.

nq = 4 4 × 128 = 512 columns 512 = 2⁹ ✓ pow2 → valid nq = 7 7 × 128 = 896 columns 896 = 2⁷·7 ✗ not pow2 → rejected The TD validator rejects an inner extent that isn't a power of two. The safe-but-blunt route is to gate the whole TD path off for non-pow2 nq. _is_pow2_nq = false → use_td_qo = false non-pow2 nq falls to the slow pointer path
The gate was correct but blunt: rather than reshape the descriptor, it disabled the fast path entirely for any non-pow2 head count.
💡
The key realization

The validator cares about the extent the descriptor reads, not how many heads are real. If we read a power-of-two number of head-slots and simply ignore the extras, the descriptor is valid — and the load is correct. That is the whole idea behind the fix.

There's Already a Precedent

The kernel already pads one axis for exactly this reason. HEAD_SIZE_PADDED = next_pow2(head_size) rounds an awkward head dimension (say 96 or 80) up to the next power of two, and the descriptor zero-pads the read past the real HEAD_SIZE. A dim_mask then drops the padding columns.

Head dim: already padded

The inner HEAD_SIZE axis is rounded to HEAD_SIZE_PADDED; the descriptor reads zeros past the real extent and dim_mask masks them out. Proven, shipping behavior.

💡
Head count: do the same thing

If padding the head dim to a power of two is safe and masked, then padding the head count nq to next_pow2(nq) — and masking the extra head-slots — is the same trick on a second axis.

Check Your Understanding

What must be a power of two for the TD load to validate?

What existing mechanism does the fix take as its precedent?

04

The Fix: Pad the Head Count, Mask the Slack

Pack BLOCK_M = BLOCK_Q × next_pow2(nq), read NQ_PADDED head-slots through the descriptor, then mask the padding rows in-kernel

Three Moves

Conceptually the change is three coordinated moves that turn the non-pow2 head count into a padded, masked, power-of-two read — small in code, but each move has to line up with the others.

1
Repack BLOCK_M around NQ_PADDED

Define NQ_PADDED = next_pow2(nq). Recompute BLOCK_Q = max(BLOCK_M // NQ_PADDED, 1) and BLOCK_M = BLOCK_Q × NQ_PADDED, so each query position now owns NQ_PADDED head-slots, not nq.

2
Widen the descriptor to NQ_PADDED × HEAD_SIZE_PADDED

The TD block's inner extent becomes a power of two. The descriptor zero-pads the read past the real nq × HEAD_SIZE — exactly like the head-dim padding already does.

3
Mask the padding head-slots

Rows where head_in_group >= nq are pure padding. Fold that into query_mask_1 so they never contribute to softmax and never alias the next KV group.

The New Row Arithmetic

Because each group now spans NQ_PADDED slots instead of nq, every index that used nq to split position from head switches to NQ_PADDED.

IN-KERNEL (AFTER)
# BLOCK_M = BLOCK_Q * NQ_PADDED
NQ_PADDED: tl.constexpr = \
  BLOCK_M // BLOCK_Q

head_in_group = offs_m % NQ_PADDED
query_pos = base + offs_m // NQ_PADDED

query_offset_1 = \
  kv_head_idx * nq + head_in_group
WHAT CHANGED

offs_m // nqoffs_m // NQ_PADDED for the query position; offs_m % nqoffs_m % NQ_PADDED for the head slot.

For pow2 nq, NQ_PADDED == nq, so every line is bit-for-bit identical to before. A genuine no-op for any pow2 group size.

For nq = 7, NQ_PADDED = 8: each group has 7 real head rows + 1 padding row that must be masked off so it doesn't bleed into the next KV group's output.

One KV group, nq = 7, NQ_PADDED = 8 h0 h1 h2 h3 h4 h5 h6 padslot 7 7 real heads — loaded & computed zero-read, masked Descriptor reads all 8 slots (8 × 128 = 1024 = 2¹⁰, a valid pow2 extent). query_mask_1 zeroes slot 7 so it never reaches softmax or the next group.
Pad 7 heads up to 8, read the whole power-of-two block, then mask the one padding slot. The descriptor is happy; the math is unchanged.

The Mask That Makes It Safe

The padding slots carry garbage-or-zero values. Two conditions must both hold for a row to count — the original out-of-range head check and the new padding check.

query_mask_1 (AFTER)
query_mask_1 = tl.where(
  (query_offset_1 < num_query_heads)
  & (head_in_group < nq),  # NEW
  1, 0,
).to(tl.int1)
WHY BOTH CLAUSES

query_offset_1 < num_query_heads — the original guard against running off the end of the head axis.

head_in_group < nq — the new guard. It kills the NQ_PADDED − nq padding slots per group (slot 7 when nq=7).

Without the second clause, slot 7's stale data would alias into kv_head_idx + 1's rows and silently corrupt the output. The mask is the correctness linchpin.

Watch One Group Flow Through

Step through how a single non-pow2 KV group (nq = 7) is loaded, padded, and masked on the new path.

nq=7
Real Heads
→8
Pad to NQ_PADDED
TD
2D-Block Load
✗7
Mask Slot
Click "Next Step" to begin
Step 0 / 5

Check Your Understanding

For nq = 7, what is NQ_PADDED?

What would happen if the padding head-slots were not masked?

05

The Load/Store Asymmetry

Why the TD load can pad but the TD store cannot — and the loop-bound bug the repacking would have introduced

A Padded Read Is Safe. A Padded Write Is Not.

The fix enables the TD path for the Q load on non-pow2 GQA — but deliberately keeps the O store on the pow2-only path. The asymmetry is fundamental, not an oversight.

⬇️

TD load — can pad

Reading extra padding columns is harmless: the descriptor returns zeros, and the mask discards them downstream. Nothing in memory is touched. Non-pow2 GQA can use it.

⬆️

TD store — cannot pad

A padded write would emit NQ_PADDED heads' worth of columns — and the padding head would overwrite the adjacent KV group's output. There is no mask on a wide block store. Pow2 only.

⚠️
The store would corrupt memory

The TD store writes the real nq heads contiguously. If it wrote NQ_PADDED heads, slot 7's bytes would land on top of the next group's head 0. A masked read drops slack; a wide write clobbers neighbors. So the store stays pow2-only and non-pow2 falls back to the masked pointer store.

Splitting the Gate in Two

The old code had a single use_td_qo flag controlling both Q load and O store. The fix splits it: a relaxed load gate plus a separate, stricter store gate.

THE TWO GATES (AFTER)
# host: load gate drops _is_pow2_nq
use_td_qo = use_td and _is_pow2_hs

# kernel: store stays pow2-nq only
USE_TD_STORE: tl.constexpr = \
  USE_TD_QO and \
  (BLOCK_M // BLOCK_Q == nq)
HOW IT READS

use_td_qo loses the _is_pow2_nq term — the TD Q load now fires whenever head size is pow2, regardless of head count.

BLOCK_M // BLOCK_Q == nq is true iff NQ_PADDED == nq, i.e. nq was already a power of two. That is the store's enabling condition.

Pow2 GQA: both gates fire — fast load and fast store, exactly as before. Non-pow2 GQA: fast load, masked pointer store. Strictly better than the old all-or-nothing.

Q load O store pow2 nq TD ✓ TD ✓ non-pow2 nq TD ✓ (new) pointer store
The matrix after the fix. Only one cell changed — the non-pow2 Q load went from pointer to TD — and that one cell is the speedup.

The Loop-Bound Bug Hiding in the Repack

Repacking BLOCK_M quietly broke a helper that computed how far each Q-block reaches. The old expression assumed BLOCK_M = BLOCK_Q × nq; after padding, that overshoots.

compute_tile_loop_bounds
# largest in-block query offset
# BEFORE:
- (BLOCK_M - 1) // nq
# AFTER:
+ (BLOCK_Q - 1)
THE OVERSHOOT

The largest in-block query-position offset is just BLOCK_Q − 1. The old form derived it as (BLOCK_M − 1) // nq.

With padding, BLOCK_M = BLOCK_Q × NQ_PADDED > BLOCK_Q × nq, so (BLOCK_M − 1) // nq computes a position offset that is too large — over-counting the sequence prefix and the query-row span.

Computing it from BLOCK_Q directly is correct for both cases: for pow2 GQA the two expressions are identical, so again a clean no-op.

🔎
The lesson

Any code that derived a query-position from BLOCK_M via // nq implicitly assumed dense packing. Once BLOCK_M carries padding slots, those derivations must use BLOCK_Q (positions) or NQ_PADDED (slots) — never the raw nq.

Check Your Understanding

Why does the TD store stay restricted to power-of-2 GQA?

What is the correct largest in-block query-position offset after repacking?

06

A Companion Fix: Widen the Prefill Tile

The same kernel had a second problem — a Q tile too small to feed the matrix engine — and the cure runs straight into the same power-of-2 rule

The Matrix Engine Is Starving

Getting Q onto the fast load path fixes movement. But prefill is dominated by the two matmuls inside attention — Q@K and P@V — and those dots can still be too small to keep the hardware busy.

📉

Default tile, starved engine

The common GQA≤16 case defaults to BLOCK_M = 16. That makes each Q@K / P@V dot tiny — measured at a flat ~5.7 TFLOPS, against a vendor kernel's 50+.

📈

Wider tile, fed engine

Doubling the Q tile to BLOCK_M = 32 for prefill gives the matrix engine enough work per instruction to approach its throughput — roughly on prefill.

📍
Two independent levers, one kernel

Module 4 was about the Q load — getting bytes in via the fast path. This is about the matmul tile — giving the compute units enough work. They are orthogonal: this fix stands alone and does not depend on the non-pow2 Q-load change.

The Change: Bump BLOCK_M to 32 for Prefill

The fix is a single guarded reassignment of the tile size, placed right after the default BLOCK_M / BLOCK_Q are computed.

PREFILL TILE WIDENING
_is_pow2_nq = \
  (nq & (nq - 1)) == 0

if max_seqlen_q > 1 \
   and use_td \
   and _is_pow2_nq \
   and BLOCK_M < 32:
  BLOCK_Q = max(32 // nq, 1)
  BLOCK_M = BLOCK_Q * nq
THE FOUR CONDITIONS

max_seqlen_q > 1 — prefill only. Decode (one query token) is latency/occupancy bound, and a wider tile can regress it.

use_td — only on the tensor-descriptor path, where the wide tile turns into an efficient block load.

_is_pow2_nq — the now-familiar gate. See the next screen for why it's load-bearing here.

BLOCK_M < 32 — only widen tiles that are actually too small; never shrink one that's already ≥ 32.

Why Pow-2 GQA — Again

This fix gates on _is_pow2_nq for the very same reason Module 2's gate existed. Widening BLOCK_M for a non-pow2 group size would produce an invalid tile.

nq = 4 (pow2) BLOCK_Q = 32 // 4 = 8 BLOCK_M = 8 × 4 = 32 = 2⁵ ✓ nq = 7 (non-pow2) BLOCK_Q = 32 // 7 = 4 BLOCK_M = 4 × 7 = 28 ✗ not pow2 tl.arange(0, BLOCK_M) and the TD block_shape both need a power-of-two BLOCK_M. 28 is invalid — so non-pow2 GQA keeps the default tile and this fix is a no-op there. Module 4 padded the load; here we simply don't widen.
The same power-of-two constraint shows up twice. Module 4 satisfied it by padding; here the cheaper answer is to leave non-pow2 tiles at the default.
🧩
Two answers to one constraint

Not every power-of-two wall is worth padding around. For the Q load, padding unlocked a 2× for an otherwise-stuck shape. For tile widening, the pow2 shapes already get the win and the non-pow2 path stays correct on the default — so the simplest safe move is to gate, not pad.

What It Buys

Like the Q-load fix, this was validated against a reference SDPA across the full prefill/decode shape matrix — and is a strict no-op everywhere it shouldn't fire.

🚀
~2.0× prefill for pow2 GQA

For a pow2 group size at a multi-thousand-token prefill, the wider tile feeds the matrix engine enough work to roughly halve kernel time.

⚖️
Decode & non-pow2: untouched

Decode (max_seqlen_q == 1) and non-pow2 GQA fail a guard and keep the default tile. Verified byte-identical to baseline.

🧭
Narrows, doesn't close, the bigger gap

It's an honest partial win: prefill throughput roughly doubles, but a residual structural gap to the hand-tuned vendor kernel remains a separate item.

Check Your Understanding

Why does widening BLOCK_M to 32 help prefill?

Why is the tile-widening gated to power-of-2 GQA?

07

Validation & The General Pattern

How to prove a pad-and-mask change is correct, the ~2× it recovers, and why the same trick generalizes far beyond attention

The Payoff: From Slow Path to Fast Path

The whole point of the exercise is to move a non-pow2 shape off the slow pointer gather and onto the hardware block load. On a memory-bound prefill tile, that swap is worth roughly 2×.

Non-pow2 GQA — prefill Q load pointer gather path (before) TD block-load path ~2× faster measured on an Intel Arc-class GPU vs a Torch reference
The non-pow2 penalty is gone — the shape now lands on the same fast block load that every power-of-two shape already used.
🚀
Why it's roughly 2×

On a prefill-sized Q tile the load is memory-bound. The pointer path issues many narrow, partially-masked accesses; the descriptor path issues one wide, coalesced block transfer. Removing that inefficiency is the entire win — no math changed.

Correctness: How to Trust a Mask

A padding-and-masking change is exactly the kind of fix that produces subtly wrong numbers if the mask is off by one. The discipline that earns trust is to diff against a reference across the full matrix of shapes.

Sweep the shape matrix

2D prefill, 3D decode, long-context, multi-sequence, mixed batches, sliding-window, and scattered block tables. Every case must match a trusted reference — the padding axis interacts with all of them.

🧪
Run the target and a control

Test the non-pow2 shape (the one you changed for) and a pow2 control side by side. Identical-to-baseline output on both confirms the mask drops exactly the padding slots and nothing else.

⚖️
Prove the no-op where it should be one

Because NQ_PADDED == nq for power-of-two shapes, every rewritten line collapses to its original form. Verifying byte-identical output on pow2 shapes proves the change is a true no-op there.

The Pattern Generalizes

Nothing about pad-and-mask is specific to attention. Any time a hardware fast path imposes a shape constraint you don't naturally satisfy, the same three-step recipe applies.

1
Pad the awkward axis

Round the offending dimension up to whatever the hardware accepts — a power of two, a multiple of the tile width, an alignment boundary.

2
Read (never blindly write) the wider region

A padded read is safe — the extra lanes come back as zeros. A padded write is only safe if it can't clobber a neighbor; otherwise keep the slow path for the write.

3
Mask the slack

Make sure the padding lanes never influence a result and never alias real data. The mask is the correctness linchpin — get it right and the speedup is free.

TMA / cp.async CUDA bulk-copy engines have their own shape and alignment rules; padding a tile to a legal box and masking the overhang is the same move.
Tensor cores MMA fragments require fixed tile shapes (e.g. 16×16). Padding ragged GEMM dimensions up to the fragment size and masking is standard practice.
Vectorized loads A float4 load needs a multiple-of-4, aligned extent. Pad the row, read vectorized, mask the tail — identical reasoning, smaller scale.

Final Check

Where does the ~2× speedup come from?

What best confirms the mask is correct?

🏁
The takeaway

A power-of-two hardware constraint doesn't have to gate off a whole shape. Pad the awkward axis, read the wider block, mask the slack — the same trick kernels already use on the head dimension, applied one axis over. Reach for it whenever a fast path's shape rule is the only thing standing between you and the hardware.