all_lessons / modern_gpu / lessons / 13 · debug & lowering lesson 14 / 15

Part IV · the second capstone and the craft

Debugging warp-specialized kernels & how the compiler lowers them

Lessons 08–12 built kernels with a dozen barriers, warpgroups on opposite phases, scalar mailboxes, and fp32/fp16 aliasing in one TMEM allocation. Here is the brutal fact that makes this paradigm different from everything in gpu_kernels: no compiler pass verifies your phases or your byte counts. One wrong phase, one missing fence, one mis-scoped collective — and the kernel hangs or silently corrupts, and the compiler happily emits it. That is why this paradigm needs a debugging discipline, and why it helps to know how the kernel actually lowers to PTX. This lesson is the craft.

The bottleneck lessons 08–12 left us
We can now write a warp-specialized GEMM and an FA4 kernel. But every handoff we authored — every arrive/wait phase, every expect_tx byte count, every fence.proxy_async, every collective scope — is a correctness obligation the toolchain does not check for us. __syncthreads() at least had one well-known footgun (divergent control flow); a Blackwell async kernel has a dozen, and the failures are non-local: a wrong arrival count in the prologue hangs the kernel on the second K-tile, a missing fence corrupts a tile three iterations later. The forced move is not "write better the first time." It is to adopt a repeatable debugging discipline — a worksheet and a methodology — and to be able to read the generated CUDA, because the generated code is the ground truth the silent bugs hide in.
The headline rule
Do not start by rewriting the kernel. When an async kernel misbehaves, the instinct is to start permuting barriers until it works. That way lies madness — you will "fix" a deadlock by introducing silent corruption you won't notice for a week. Instead: first make sure the run is valid (it compiled for the right target, with the right API), then inspect the generated CUDA against a model of what the kernel should do. The discipline below is built around that one sentence.

The worksheet — the core mental tool

Before you change a single line, fill in four columns for the kernel. This is the model you validate the generated code against; without it you are pattern-matching symptoms blind. The worksheet forces you to make every implicit assumption explicit — and async bugs are almost always an implicit assumption that turned out false.

ColumnWhat you write down
ROLESThe exact threads / warps / warpgroups / CTAs that issue each async op. "The producer warp issues the TMA" is not enough — write down whether it is warp_id == 0 or wg_id == 0, and which single lane calls elect.sync. Most scope bugs are visible right here.
STORAGEWhere each live tile sits at each step: GMEM / SMEM / TMEM / registers. The A-tile is in SMEM stage 0; the accumulator is in TMEM columns 0–127; P aliases S's TMEM bytes after S is consumed. If you can't name the location, you can't reason about reuse.
HANDOFFFor every transfer: producer, consumer, the signal object (which named barrier), the arrival count, the phase each side expects, and the fence or drain required. This is the column where deadlocks and corruption live. One row per transfer; if a transfer has no row, that's your bug.
LIFETIMEThe earliest point each storage slot may be reused. The SMEM stage-0 buffer is free only after the MMA that reads it signals empty; TMEM may be deallocated only after the epilogue's last tcgen05.ld; a TMA store's source SMEM is reusable only after wait_group(0). Premature reuse is the silent-corruption engine.

Then validate the generated CUDA against the worksheet, checking exactly these:

The 8-step methodology

The same loop every time. It is deliberately boring; the discipline is the value. Note steps 1–3 come before you touch the kernel, and step 8 (measure) comes dead last.

  1. Reproduce at the MINIMUM failing shape. Shrink batch, sequence, K-tiles until the failure still reproduces. A bug that hangs on tile 2 is far easier to read than one that corrupts at sequence 32768.
  2. Verify compilation succeeds with the right API and target. A kernel built for the wrong compute capability silently falls off the TMA / tcgen05 path — and then "wrong results" is really "wrong build."
  3. Save & inspect the generated CUDA. Dump it to a file. This is the ground truth; your mental model is a hypothesis about it.
  4. Build the worksheet (roles / storage / handoff / lifetime) from your intent.
  5. Validate code vs worksheet using the five checks above.
  6. Classify the failure into one of the four classes below — the class tells you which tool to reach for.
  7. Change ONE handoff element at a time. One arrival count, one phase, one fence. Changing two means you can't attribute the result.
  8. Re-test correctness BEFORE measuring performance. A fast wrong answer is worthless; verify against the reference first, profile second.

Widget · symptom → diagnosis triage

The fastest way into the four classes is to start from what you actually observe. Pick the symptom you're seeing; the triage tells you the likely failure class, the first thing to check, and the right tool to reach for. Everything here is driven by the tables in the rest of the lesson — this is those tables, made queryable.

Async-kernel triage · symptom → class → first check → tool
Click the symptom you observe. The card below names the failure class, the single first thing to inspect, the most likely root cause, and the compute-sanitizer / Nsight tool that confirms it. Built straight from the failure tables.
failure class
first check
likely cause
the tool
symptom
corrupts silently?
restart Python?
measure perf yet?

The four failure classes

Every async-kernel bug lands in one of four buckets. The class is not cosmetic: it tells you whether to reach for synccheck or racecheck, whether you have to restart the process, and whether the bug is in your phases or your byte counts.

1 · DEADLOCKHangs, then an unspecified launch failure. Some thread is waiting on a barrier that will never flip — a counting or phase error.
2 · CRASH / POISONED CONTEXTAn illegal access / XID / "context poisoned." A misuse of allocation or scope tore down the CUDA context.
3 · WRONG RESULTSIt runs and finishes, but the numbers are off. The shape of the corruption tells you the cause.
4 · CORRECT BUT SLOWRight answer, wrong speed. Usually a primitive you thought you used never made it into the generated code.

Class 1 · Deadlock — symptom: hangs, then an unspecified launch failure

Every deadlock is the same shape: a wait that never returns because the thing it waits on never happens. The art is finding which arrival never came.

CauseWhy it hangsFix
Arrival-count mismatchinit(128) but a guard lets only one thread arrive → 127 arrivals are forever missing → the wait never returns.Set the init count to the actual number of arrivers, not the warpgroup size you assumed.
Barrier init nested in a warpgroup branchif wg_id == 1: guards the init, so it never runs on CTA thread 0; the consumer waits on an uninitialized barrier.Move all barrier inits to the top level, executed by CTA thread 0 only, before any role branch.
cta_sync() inside a warpgroup branchcta_sync needs all CTA threads to arrive; threads in the other warpgroup branch never reach it.Keep cta_sync at the top level, outside any divergent role branch (use warpgroup_sync for within-role sync).
Scheduler state skippedSome threads bypass next_tile() and never advance the work iterator → they wait for a tile that the others already consumed.Ensure every participating thread runs the scheduler step; don't guard it behind a role mask.
Phase desync (off-by-one tile count)TMA iterates K_TILES but MMA iterates K_TILES − 1 → their phases drift → deadlock on the second tile.Both producer and consumer must iterate exactly K_TILES. The "second tile" signature is diagnostic.
Initial-phase errorProducer and consumer both start on the same phase → the consumer's first wait sees "already flipped" or both block immediately.Opposite initial phases: producer starts phase = 1 (passes immediately), consumer starts phase = 0 (blocks until the first arrival). Lesson 10's ring.

Class 2 · Crash / poisoned context

A CUDA error does not clean up after itself — restart Python
After an illegal access, an XID, or a "context poisoned" error, the CUDA context is in an undefined state. Every later call keeps failing — even an unrelated torch.randn on the GPU — because the context is dead, not because your next fix is wrong. Restart the Python process before testing the next fix. Engineers lose hours "fixing" a bug that was already fixed two attempts ago, fooled by errors that are just the corpse of the first crash. When you hit class 2, kill the interpreter.
TriggerWhy it crashesFix
pool.alloc after pool.commitThe SMEM/TMEM pool is finalized at commit; allocating after it corrupts the pool's bookkeeping.Do all allocs before commit. Treat commit as sealing the allocation arena.
tcgen05.alloc / dealloc under a lane guardTMEM alloc/dealloc need full-warp participation; issuing them from one lane (lane_id == 0) is illegal and tears down the context.Issue tcgen05.alloc/dealloc from the whole warp, not behind an elect.sync / lane mask.
Missing cta_sync() before tcgen05.deallocTMEM is freed while the writeback path is still reading the accumulator out of it → illegal access on freed TMEM.Fence with cta_sync() after the last epilogue read, before dealloc (this is a LIFETIME-column violation).
Out-of-range GMEM / SMEM accessA scheduler index or a shape that isn't tile-aligned addresses past the buffer.Check the scheduler's tile indices and that the problem shape is aligned to the tile shape; pad if needed.

Class 3 · Wrong results — the symptom shape classifies the cause

This is the most useful diagnostic table in the lesson. You do not guess at wrong-results bugs — you look at the shape of the error tensor, and the pattern names the cause.

Symptom shapeWhat it means
ROW-STRIPE corruption (whole rows wrong)Phase mismatch, tile-index error, or role-ownership confusion — a consumer read the wrong round's tile, or two roles disagree on who owns which rows.
NaNDescriptor / operand setup error, or uninitialized accumulators — the math ran on garbage.
FINITE but patternedA stale or partial tile, or an undrained store — the data is real, just from the wrong moment in time.

And the specific fixes, each one a concrete handoff bug:

BugMechanismFix
tcgen05.commit issued outside elect.syncAll 32 lanes issue the commit → 31 of them produce empty commit groups that signal the barrier prematurely → TMA overwrites the SMEM tile while the MMA is still reading it → corruption.Issue tcgen05.commit only from the single elected lane (inside elect.sync).
Missing fence.proxy_async("shared::cta") before a TMA storeThe TMA (async proxy) engine does not see the SMEM writes the threads just made → it stores stale data.Insert the proxy fence so thread-domain SMEM writes are visible to the async-copy proxy before the store is issued.
Missing cp.async.bulk.commit_group + wait_group(0) after a storeThe destination SMEM is reused before the store has drained → the next iteration overwrites bytes still in flight.Commit and wait on the store group before reusing the staging SMEM (a LIFETIME violation on the store path).
Persistent-kernel intermittent failures at small sizesPhase-reset races between consecutive tiles — one tile's phase reset overlaps the next tile's first wait.Serialize the per-tile phase reset; the small-size / intermittent signature points straight at a reset race.

Class 4 · Correct but slow

The kernel is right but far off the roofline. Nine times out of ten, a primitive you thought you used never made it into the generated code — which is exactly why this lesson's second half is about lowering. Check the generated CUDA first; the missing instruction is the bug.

Symptom in the generated code / profileCauseWhere to look
No cp.async.bulk.tensor in the generated CUDATMA lowering failed — the copy fell back to a thread copy.The dispatch flag (dispatch="tma"), the target compute capability, and the operand layout the descriptor requires.
No tcgen05 pathTensor-core instructions didn't generate; the MMA fell back to a slower path.The MMA dispatch (dispatch="tcgen05") and operand/accumulator layout.
TMA and MMA not overlapping (Nsight Systems timeline alternates instead of overlapping)Shallow pipeline or phase serialization — the producer and consumer are taking turns, not running together.Pipeline depth (too few stages), and phases that accidentally serialize the ring.
Good at small shapes, poor at largeRegister spill / occupancy / staging-buffer pressure as the tile grows.Register usage (setmaxnreg budget), staging buffer count, and the EPI_N chunking that keeps the epilogue off the spill path.

The toolchain — which tool for which class

The real tools, mapped to the classes they catch. The point of classifying first (step 6) is that the class chooses the tool; you don't run all of them blindly.

ToolCatchesMaps to
compute-sanitizer --tool synccheckDivergent or invalid barrier usage.Class 1, causes #2/#3 (init in a branch, cta_sync in a branch).
compute-sanitizer --tool racecheckSMEM read-before-write and buffer-reuse hazards.Class 3, the stale-tile bugs (premature reuse, missing drain).
compute-sanitizer --tool memcheckOut-of-bounds / misaligned accesses → illegal access.Class 2, the crashes (OOB GMEM/SMEM, freed-TMEM read).
compute-sanitizer --tool initcheckUninitialized reads.Class 3, the NaN path (uninitialized accumulators / operands).
Nsight Systems timelineCopy-engine vs SM activity — they should overlap, not alternate.Class 4, the no-overlap symptom.
Nsight Compute Warp State / stall reasonsstall_long_scoreboard = memory wait · stall_barrier = barrier wait · stall_not_selected = occupancy. The SASS / instruction-mix view confirms tcgen05 / cp.async.bulk are present.Class 4 (which stall dominates), and confirming class-4 missing-instruction.
cuda-gdb · info cuda threadsWhich warps are parked on a barrier.Class 1 — see exactly which role is stuck and on which barrier.
Reading a deadlock with cuda-gdb
When the kernel hangs, attach and run info cuda threads: it shows every warp's PC, so you can see which warps are parked on the wait and which are off doing something else. If the producer warps are all sitting at a wait and the consumer never reached its arrive, you've localized the missing arrival — now go to the worksheet's HANDOFF row for that barrier and check the count and phase.

Inspecting the generated CUDA

The generated code is where the silent bugs live, so reading it is a core skill, not a last resort. In the toolchain you dump the device source from the compiled module:

// dump the generated CUDA for the device kernel
ex.mod.imports[0].inspect_source("cuda")

Then search for a handful of landmarks. Each one confirms (or refutes) a specific worksheet assumption:

Search forIt confirms
if (threadIdx.x < 1)The CTA-thread-0 guard — usually the barrier-init block. Verify your mbarrier_init calls live here, before any role branch.
mbarrier_initThe barriers exist and are initialized; cross-check the counts against the HANDOFF column.
tcgen05The tensor-core path generated (its absence is a class-4 bug).
cp.async.bulk.tensorTMA lowering succeeded (its absence is a class-4 bug).
cta_sync();The collectives are where you expect — and crucially, not nested inside a role guard.

The guard-expression mapping — a debugging tool

When you wrote the kernel you thought in roles: "warpgroup 0," "warp 0," "lane 0." The compiler lowers those to arithmetic on threadIdx.x. To validate the generated code, you have to read the arithmetic back into roles. Memorize this table — it turns an opaque if into a role.

Your intentGenerated guard
wg_id == 0 (warpgroup 0)(warp_id_in_cta >> 2) == 0
warp_id == 0(warp_id_in_cta & 3) == 0
lane_id == 0(threadIdx.x % 32) == 0
The bug you're hunting for
If a collective like cta_sync — or a TMEM alloc, or a barrier init — ends up inside one of these masks, you've found the bug. A cta_sync() under (warp_id_in_cta >> 2) == 0 will hang forever (class 1): three of the four warpgroups never reach it. A tcgen05.alloc under (threadIdx.x % 32) == 0 will poison the context (class 2): TMEM alloc needs the full warp. Reading the guard back into a role is how you catch a mis-scoped collective before you've burned an afternoon on the symptom.

Widget · spot the bug — barrier-init placement

The single most common class-1 deadlock: a barrier init that the compiler hoists into a role branch (or that you wrote there), so CTA thread 0 — the only thread that should run it — never does. Toggle where the mbarrier_init lives and watch the consumer either proceed or hang. The generated guard is shown so you can read the scope back the way the previous section teaches.

Barrier-init placement · top-level vs nested in a wg_id branch
Flip the toggle. Top-level: the init runs on CTA thread 0 before the role split, the consumer's wait flips, the kernel completes. Nested in if wg_id == 1: CTA thread 0 (which is in warpgroup 0) never runs the init, the barrier is uninitialized, the consumer waits forever — class-1 deadlock.
init ran on T0?
barrier state
consumer wait
outcome

How the compiler lowers it

Now the second half, and the reason class-4 bugs even exist. You've been told to "read the generated CUDA." But what produced it? Knowing the lowering pipeline tells you where a missing tcgen05 or cp.async.bulk went — and it makes the central claim of this lesson concrete: there is no pass that checks your phases.

The ladder, from the tile-level IR you author down to two outputs:

authored tile IR
Tile primitives: copy, gemm, reduction, with TileLayout-typed accesses and scope ids like T.cta_id / T.thread_id. This is the level you actually wrote.
↓  BindTarget — attach the hardware target (compute capability, etc.)
~18-pass module pipeline
A sequence of roughly eighteen passes over the module. The most important one is LowerTIRx (below). After it, the module is "plain TIR."
↓  SplitHostDevice — separate host orchestration from device kernel
host side
C / LLVM — the launch, the descriptor setup, the host orchestration.
device side
CUDA / PTX — the kernel itself, with the tcgen05 / cp.async.bulk instructions (or, if lowering failed, without them).

The pass that matters: LowerTIRx

LowerTIRx is where your tile primitives become hardware. It is itself two sub-passes, and class-4 bugs are born in the first one:

TilePrimitiveDispatchReplaces each tile primitive — copy, gemm, reduction — with the dispatcher-selected backend body. This is exactly where dispatch="tma" picks the TMA-vs-cp.async copy backend, and dispatch="tcgen05" picks the MMA backend. If the dispatch flag, target, or operand layout is wrong, the dispatcher picks a fallback — and this is where class-4's "no TMA / no tcgen05 in the output" originates.
LayoutApplierResolves TileLayout-typed accesses into concrete addresses via addr = data + elem_offset + layout.apply(coord), and lowers scope ids — T.cta_id, T.thread_id — to hardware thread axes via launch_thread. This is where "lane 0" becomes (threadIdx.x % 32) == 0: the guard-expression mapping from earlier is this pass's output.

After LowerTIRx the module is plain TIR — the tile abstraction is gone, replaced by concrete addresses, thread-axis guards, and backend instruction bodies.

The only verifier in the pipeline

There is no pass that checks your phases or your byte counts
The pipeline has exactly one verifier: VerifyMemory, and all it gates is host-vs-device pointer dereference (you can't deref a host pointer on the device, or vice versa). That's it. Nothing verifies barrier phase correctness. Nothing verifies that an expect_tx byte count matches the bytes a TMA actually moves. Nothing checks that a collective isn't trapped in a role mask. The compiler will lower a kernel with a phase desync or a wrong byte count into perfectly valid PTX and emit it without a word of complaint. That absence is the entire reason the worksheet and the 8-step discipline exist. The correctness of every handoff is on you, because no pass is checking.

Why it's built this way — progressive lowering

This is MLIR-style progressive lowering: each stage rewrites the program from one dialect to a lower one — tile primitives → plain TIR → CUDA — preserving the math while moving to a representation closer to the hardware. (The companion ai_compilers track is the whole story of how this lowering works as a discipline.)

The contrast that explains the burden: a compiler like Triton runs a pipeline that does auto-insert cp.async plus the commit/wait pairing for you — its passes own the pipelining, so its passes own that correctness. Here, you author the barriers, the phases, and the byte counts explicitly in the tile IR. The compiler faithfully lowers exactly what you wrote. The flip side of the control that let you hand-build FA4's twelve-barrier choreography is that you own the correctness of every one of those barriers — there is no pass to fall back on.

Takeaway
The async paradigm moves the correctness burden from the compiler to you, because the only verifier in the pipeline checks host-vs-device pointers and nothing else — not phases, not byte counts, not collective scope. So the craft is a discipline, not a guess: build the four-column worksheet (roles / storage / handoff / lifetime), run the 8 steps (reproduce small → verify the build → read the generated CUDA → validate vs worksheet → classify → change one handoff → re-test correctness before measuring), and let the failure class pick the tool — synccheck for deadlocks, racecheck for stale tiles, memcheck for crashes, initcheck for NaN, Nsight for no-overlap, cuda-gdb for parked warps. Read the symptom shape (row-stripe = phase, NaN = uninit, finite-patterned = stale). Read the generated guards back into roles. And when the context is poisoned, restart Python before you trust the next result. The compiler lowered your tile primitives through LowerTIRx — but it never checked them.
Where this connects
The phase and reuse bugs here are the failure modes of the mbarrier model from lesson 06 · mbarriers and the phase model; the role-scope and opposite-phase bugs are the failure modes of the warp specialization built in lesson 10 · GEMM III and pushed to twelve barriers in lesson 12 · FA4 choreography. The lowering pipeline — dialect → dialect, the one-verifier design — is the subject of the ai_compilers track (the compiler's view of everything we author by hand here). For the profiling tools — Nsight Systems timelines, Nsight Compute stall reasons, reading a roofline — see gpu_kernels/20 · the profiling toolkit.
Handing off to lesson 14
You can now build these kernels (08–12), read the CUDA they lower to, and debug them when a phase or a byte count is wrong. The craft is complete. What's left is to step back and see the whole paradigm at once — the supply chain from TMA to TMEM, the contract triad that must always agree — and to place it in the real toolchain: where CUTLASS, cuDNN, Triton, and the serving stacks live relative to everything we built. That synthesis is lesson 14.