all_lessons/ai_compilers / lessons/09 · codegenlesson 10 / 20

Part IV — Back end

Code generation

Lesson 08 chose a schedule for the laid-out graph: tile sizes, loop order, what to stage into SRAM, where to vectorize and unroll. That is a complete recipe for how the loops should run — and not one byte of it executes. A schedule is a plan written in the compiler's own notation; the GPU runs instructions, and there is no instruction in a schedule. This lesson is where the plan becomes machine code: codegen, the lowering of a scheduled IR onto a real backend. The fork in the road is which backend — emit low-level LLVM IR → PTX → SASS and let LLVM do register allocation and instruction selection, or emit a tile DSL like Triton and let its compiler map tiles onto warps and threads. Modern stacks took the second road, and this lesson is the argument for why.

The previous step left this broken
Lesson 08 separated the algorithm (what to compute) from the schedule (when and where the loops run), and picked a schedule: tile the matmul 128×128×32, stage the A and B tiles into SRAM, vectorize the inner load, unroll by 4. Every one of those decisions is now a fact in the IR. But the IR is still a data structure inside the compiler — a description of a loop nest, not a sequence of opcodes the streaming multiprocessor can fetch and execute. There is no kernel binary, no launch grid, no register assignment, nothing the CUDA driver can hand to the GPU. The schedule says "a tile is 128×128"; it does not say which registers hold the accumulator, which HMMA instructions issue the tensor-core matmul, or how the host even launches the thing. Until something emits real target code, lesson 08's careful plan is inert.
Linear position
Forced by: a schedule is a plan in the compiler's IR — nothing in it is an instruction the GPU runs; the loop nest must become a real kernel binary plus host launch code for a concrete target.
New idea: codegen / lowering to a backend — emit either (a) LLVM IR → NVVM → PTX → SASS (the classic path: LLVM does register allocation and instruction selection), or (b) a tile DSL like Triton as the backend (you emit Triton/MLIR; its compiler does the within-block thread mapping). The codegen target is a rung of the abstraction↔specialization trade — raw PTX buys peak but locks you to one ISA, Triton buys portability for a little peak. Inductor chose Triton. Compile once, cache the artifact, run it millions of times.
Forces next: codegen can emit any schedule — but which tile sizes, num_warps, num_stages? The space is huge and shape-dependent, and no human picks it per kernel. That forces a search: autotuning (lesson 10).
The plan
Five moves. (1) What codegen actually produces — a device kernel plus host launch/grid code, the two-sided artifact. (2) Path A: lower to LLVM IR, let NVVM emit PTX → SASS; what LLVM gives you (regalloc, instruction selection) and where it falls short for tensor code. (3) Path B: emit Triton — why Inductor chose it, you write tiles and Triton's MLIR compiler handles warps and threads. (4) The worked snippet: the fused bias + gelu from lesson 05 as generated Triton, contrasted with the PTX path. (5) Caching the compiled artifact and the compile-time cost. Then drive the picker until raw PTX wins peak but loses on lines-of-code and portability, and Triton wins everywhere else.

1 · What codegen produces: device code + host launch code

Codegen does not emit "a kernel." It emits two coupled things, and forgetting the second one is the classic beginner's blind spot. The device code is the kernel itself — the program each GPU thread runs, expressed eventually as SASS (the GPU's actual machine instructions). The host code is the CPU-side glue that launches it: it allocates or reuses output buffers, computes the launch grid (how many thread blocks, of what shape), packs the kernel arguments (pointers, strides, scalar params), and issues the launch on a CUDA stream. The schedule from lesson 08 fixes both: the tile size sets the grid (grid = ⌈M/BLOCK_M⌉ × ⌈N/BLOCK_N⌉), and the loop body becomes the device kernel.

device code
The per-thread-block program. Lowered to SASS, the GPU's real ISA. Holds the loads, the tensor-core math, the stores. This is what "the kernel" usually means.
host code
CPU-side launch: compute grid from tile size, pack args (pointers, strides, scalars), pick stream, call the launch. Cheap to emit, fatal to omit.
grid / launch config
Number and shape of thread blocks, derived from the schedule's tiling. grid = ⌈M/BLOCK_M⌉ × ⌈N/BLOCK_N⌉. Set here, tuned in lesson 10.

Where eager PyTorch issued one library kernel per op with the launch handled by ATen (cross-link gpu_kernels · 18 (PyTorch to kernel)), the compiler must now generate both halves itself — because the fused kernels from lesson 05 do not exist in any library; only the compiler knows their shape. That is the whole point of getting here: bespoke kernels demand bespoke codegen.

2 · Path A — lower to LLVM IR → NVVM → PTX → SASS

The classic path treats the GPU like any other compiler target: lower the scheduled loop nest to LLVM IR — a low-level, typed, SSA instruction set with explicit loads, stores, arithmetic, and branches, the same IR Clang uses for C++ — then hand it to a target backend. For NVIDIA that backend is NVVM (NVIDIA's LLVM-based compiler), which emits PTX (Parallel Thread Execution: a stable, virtual assembly / ISA that is forward-compatible across GPU generations), which the driver's ptxas finally compiles down to SASS (the actual, architecture-specific machine code for one GPU, e.g. sm_90 for Hopper). XLA, TVM's LLVM backend, and Triton's own bottom all end here.

LLVM IR
Typed SSA: explicit load/store/fadd/br, loops as basic blocks. Target-agnostic but already scalar-level. LLVM runs regalloc + instruction selection here.
NVVM → PTX
Virtual GPU ISA. Stable across generations, forward-compatible. Threads, shared memory, and special registers appear; tensor-core ops as PTX mma intrinsics.
SASS (ptxas)
Real machine code for one architecture (sm_90, sm_80…). Final register schedule, instruction encoding, HMMA tensor-core instructions. What the SM actually fetches.

What LLVM gives you for free is enormous and is exactly why people reach for it: register allocation (deciding which values live in the GPU's limited registers vs spill to local memory), instruction selection (matching IR patterns to the best target opcodes), constant propagation, peephole optimization, and a mature backend per ISA. You describe the computation scalar-by-scalar; LLVM turns it into a tight register schedule.

What LLVM does not give you is the hard part of tensor code. LLVM reasons at the level of scalars and machine vectors; it has no native concept of a tile, of shared-memory staging, of warp-level tensor-core matmul, or of the cooperative dance where 256 threads load a 128×128 block, multiply it on the tensor cores, and write it back. To get a fast matmul out of the LLVM path you must hand-write all of that thread choreography — the shared-memory double-buffering, the mma fragment layouts, the bank-conflict-free swizzles — directly, in PTX or in CUDA-with-intrinsics. The IR is low; the abstraction it offers (a scalar machine with vectors) is the wrong shape for a tensor program. That is the gap the next path closes.

scheduled loop nest (lesson 08)
        │  lower scalars, loads/stores, branches
        ▼
   LLVM IR ──► regalloc + instruction selection  (LLVM gives you this)
        │      but: no tiles, no SRAM staging, no warp-MMA  (you hand-write these)
        ▼
   NVVM ──► PTX (virtual ISA, forward-compatible)
        │      ptxas
        ▼
   SASS (sm_90 machine code) ──► the SM executes it

3 · Path B — emit a tile DSL (Triton) and let it map threads

The second path raises the codegen target from a scalar machine to a tile machine. Instead of lowering all the way to LLVM yourself, you emit Triton — a Python-embedded kernel language whose unit of work is the tile, not the thread. You write the program for one block: which output tile it owns, which input tiles to tl.load into SRAM, the block-level matmuls and reductions on that hot data, the tl.store back. Triton's own compiler (built on MLIR) then does the part LLVM could not: it maps your tiles onto warps and threads, lays out shared memory, picks tensor-core mma instructions, schedules the loads to hide latency, and finally lowers that to LLVM → PTX → SASS. You handed off the thread choreography. The deeper treatment of the tile model and how Triton lowers is in gpu_kernels · 22 (writing Triton).

This is why Inductor — the codegen backend of torch.compile — generates Triton rather than raw CUDA/PTX (cross-link gpu_kernels · 23 (torch.compile)). The compiler's job after fusion (lesson 05) is to emit a kernel per fusion group, and there are thousands of distinct groups across real models. Emitting hand-tuned PTX for each is intractable; emitting Triton is a few dozen lines of templated codegen. The trade is explicit:

backendyou emitwho maps threadspeak perfportabilitycodegen surface
LLVM → PTX/SASSscalar IR + hand-written thread/SRAM/MMA choreographyyou (then LLVM regalloc)highest — full control of every instructionlocked to NVIDIA ISA; a second path for AMD/etc.huge — must generate the choreography per kernel
Triton (tile DSL)tile-level program (loads, tl.dot, stores)Triton's MLIR compiler~5–15% off peak for most kernelsportable — Triton has NVIDIA + AMD backendssmall — a templated tile kernel per fusion group

The verdict the field reached: for the long tail of fused pointwise/reduction kernels — the bulk of what a compiler emits — Triton's portability and tiny codegen surface dominate, and the few percent of peak you leave on the table is recovered by fusing more aggressively and by autotuning (lesson 10). Raw PTX/SASS is reserved for the handful of kernels where peak is worth a human's month: the flagship GEMM, FlashAttention (cs336/07), the cuDNN/cuBLAS-class library primitives. Everything else, the compiler generates as Triton.

4 · Worked: the fused bias + gelu, generated two ways

Take the fused bias + gelu kernel from lesson 05 — the elementwise epilogue that, fused, reads the activation from HBM once, adds the bias, applies GELU, writes once. Here is what Inductor-style codegen emits for it: a Triton kernel. Note the level — tiles, masks, block-wide math; no thread, no register named.

@triton.jit
def fused_bias_gelu(X, B, Y, n_elements, n_features, BLOCK: tl.constexpr):
    pid  = tl.program_id(0)                       # this block owns one tile
    offs = pid * BLOCK + tl.arange(0, BLOCK)      # the tile's element indices
    mask = offs < n_elements                      # guard the ragged last tile
    x = tl.load(X + offs, mask=mask)              # one HBM read of the tile
    b = tl.load(B + offs % n_features, mask=mask) # broadcast bias along features
    h = x + b                                      # bias add, in registers
    # tanh-approx GELU, all on hot data — never written to HBM:
    g = 0.5 * h * (1.0 + tl.math.tanh(0.79788456 * (h + 0.044715 * h * h * h)))
    tl.store(Y + offs, g, mask=mask)             # one HBM write of the tile
# host: grid = (triton.cdiv(n_elements, BLOCK),)   <-- launch code, generated too

A handful of readable lines of math plus a host launch line. The compiler picks BLOCK (and num_warps) — that is what lesson 10 tunes. Now contrast the shape of the same kernel down the LLVM/PTX path. You do not write tiles; you write the scalar loop, and the generated PTX is per-thread, verbose, and ISA-specific:

// sketch of the same bias+gelu as generated PTX (per-thread, abridged)
.visible .entry fused_bias_gelu(.param .u64 X, .param .u64 B, .param .u64 Y, .param .u32 n) {
    mov.u32    %r1, %ctaid.x;          // block id
    mov.u32    %r2, %ntid.x;           // block dim
    mov.u32    %r3, %tid.x;            // thread id
    mad.lo.s32 %r4, %r1, %r2, %r3;     // global index  = block*dim + thread
    setp.ge.s32 %p1, %r4, %n;          // bounds guard
    @%p1 bra   DONE;
    // ... compute byte offset, ld.global.f32 x, ld.global.f32 b ...
    add.f32    %f3, %f1, %f2;          // bias add
    // ... ~20 more PTX instrs: h*h*h, tanh poly, 0.5*h*(1+...) ...
    st.global.f32 [%rd_y], %f_out;     // store
DONE: ret;
}

Two facts jump out. First, length: the Triton version is ~6 lines of intent; the PTX is dozens of per-thread opcodes, and that ratio explodes for anything with a matmul or a reduction, where the PTX must spell out shared-memory staging and mma fragments by hand. Second, lock-in: the PTX is NVIDIA-only and you would write a separate file for AMD; the Triton compiles to either. For a single elementwise kernel the gap is annoying. For the thousands of fusion groups a compiler emits, it is the difference between a maintainable backend and an impossible one — which is precisely why Inductor emits the top snippet, not the bottom.

5 · Cache the artifact: compile once, run a million times

Codegen and the subsequent LLVM/ptxas (or Triton) compile are not free — emitting and compiling one kernel costs from a few tens of milliseconds (a simple Triton kernel) to seconds (a heavily-autotuned GEMM under max-autotune). If that cost were paid on every forward pass, the compiler would be a catastrophe. It is not, because of the one fact that makes the whole enterprise pay off: an ML workload runs the same shapes a million times. So codegen compiles each kernel once and stores the compiled artifact in a code cache, keyed by everything that would change the generated code — the source, the input shapes and dtypes, the schedule config, the target architecture. Every later call with a matching key is a cache hit: the kernel is already a binary, and you just launch it.

eventcostamortized over
first call (cache miss): codegen + compile≈30 ms – a few s per kernel (illustrative)one-time, at warmup
later calls (cache hit): launch the cached binary≈3–10 µs launch (the lesson-01 cost)every step, millions of times
on-disk cache (e.g. Inductor's ~/.cache/torch/inductor)survives process restartsacross runs; invalidate on torch/source bump

This is why torch.compile's first step is slow and the rest fly (cross-link gpu_kernels · 23): the first call is a compile storm, the steady state is pure cache hits. Two consequences matter in production. Cold start: a latency-sensitive service must warm up with representative shapes before serving, or the first user pays the compile. Cache key: anything that changes the key — a new shape, a new dtype — is a miss and a recompile, which is exactly the wall that dynamic shapes (lesson 11) crash into. For now, hold the win: codegen pays its cost once and the cache turns it into a rounding error.

6 · Drive it: pick the codegen target

The widget below makes the codegen-target trade a knob. For the fused bias + gelu (and, toggle on, a tiled matmul to see the gap widen), pick the backend — LLVM → PTX/SASS or Triton — and the tile/num_warps config. Watch four readouts: lines of generated code (the codegen-surface cost), portability (which hardware the artifact runs on), est. peak-% reachable (how close to hand-tuned), and compile time. The knob that breaks: pick PTX for the matmul and watch lines-of-code and compile time explode while portability collapses to one ISA — the few extra points of peak come at a cost no compiler can pay across thousands of kernels.

Codegen target picker: bias+gelu / matmul
Pick the backend and config. Triton = far fewer lines, portable, ~90% of peak — the choice for the long tail of fused kernels. Raw PTX/SASS = max peak but verbose and locked to one ISA — reserved for a few flagship kernels. Toggle the matmul to watch the PTX line-count and compile-time blow up while Triton stays flat.
lines of generated code
portability
est. peak-% reachable
compile time
peak reachable
codegen surface (lines, log)

Slide it and the verdict is stable: Triton sits near the floor on lines-of-code and compile time and runs on both vendors at ~90% of peak; PTX climbs to the last few points of peak only by paying a 10–40× line-count penalty, a slower compile, and a one-ISA lock-in. For the one GEMM that runs in every layer, a team pays that. For the thousands of fused kernels behind it, the compiler emits Triton and moves on.

Failure modes & checklist

Failure modes

  • Emitting the device kernel but not the host launch. A kernel with no grid/arg-packing code never runs. Signal: codegen "succeeds" but nothing executes, or a launch error about missing arguments / zero blocks.
  • Reaching for raw PTX for ordinary fused kernels. Hand-writing PTX for a bias+gelu to "get peak." Signal: a 40× codegen surface and a maintenance burden to save 3% on a kernel that is 1% of runtime — you optimized the wrong thing.
  • Expecting LLVM to handle tensor structure. Lowering a matmul straight to LLVM IR and assuming you get tensor-core MMA. Signal: the kernel runs on CUDA cores, not tensor cores; perf is ~15× below the tiled Triton/cuBLAS version (cross-link gpu_kernels/09).
  • Treating PTX as final machine code. Forgetting ptxas recompiles PTX → SASS per architecture. Signal: a kernel JIT-recompiles or fails on a different GPU generation because only PTX, not SASS, was cached.
  • No code cache (or a wrong cache key). Recompiling the same kernel every call, or a hit when the dtype actually changed. Signal: every step is slow (key too loose → miss) or numerically wrong (key too tight missed a dtype) — compile cost never amortizes.

Checklist

  • Emit both halves. Device kernel and host launch: grid from tile size, packed args, stream.
  • Default to a tile DSL. Generate Triton for the fused long tail; it is portable and small to emit.
  • Reserve PTX/SASS for the flagships. The one GEMM, attention — kernels worth a human's tuning month.
  • Let the backend do regalloc + ISel. Don't reinvent register allocation; LLVM/Triton already do it.
  • Cache by a complete key. Source + shapes + dtypes + config + target arch. Compile once, launch forever.
  • Warm up before serving. Pay the compile storm at startup, not on the first user request.

Checkpoint

Try it
Open the widget with fused bias + gelu and Triton: note the lines-of-code, the ~90% peak, and that portability reads "NVIDIA + AMD." Switch the backend to PTX: peak ticks up a few points, but lines-of-code roughly doubles and portability drops to "NVIDIA only." Now flip the kernel to tiled matmul and switch between backends again — watch the PTX line-count and compile time blow up far more than they did for bias+gelu (because the matmul backend must hand-write SRAM staging + MMA fragments), while Triton barely moves. Predict before you slide num_warps: does it change lines-of-code? (Barely — warps are a launch/scheduling knob the backend consumes, not source you write; it's exactly the kind of config lesson 10 will search.) The takeaway you should be able to state: codegen target is a portability↔peak dial, and modern compilers park it at "Triton" for everything but a handful of kernels.

Where this points next

Codegen closes the gap from plan to machine code: a scheduled loop nest becomes a device kernel plus host launch, emitted as Triton for the long tail and reserved-PTX for the flagships, then cached so its compile cost amortizes to nothing. But notice what we waved past. Codegen can faithfully emit any schedule you hand it — which silently assumes you already chose the right one. Lesson 08 picked a schedule by hand for one matmul; a real model has thousands of kernels, each with a configuration space of tile sizes (powers of two from 16 to 256 on each axis), num_warps (1–16), num_stages (pipeline depth), and unroll factors — a combinatorial space whose best point depends on the exact shape and the exact GPU. No human picks BLOCK=256, num_warps=4 per kernel per shape, and the widget's knobs you just slid are precisely the ones that need choosing. The only way out is to search the space — benchmark candidates, or predict them with a cost model — and keep the winner. That is lesson 10, Autotuning & cost models.

Takeaway
A schedule is a plan in the compiler's IR; nothing in it is an instruction the GPU runs, so codegen must lower it to a real target — a device kernel (lowered to SASS, the GPU's machine ISA) plus the host launch code that computes the grid from the tile size and packs the args. There are two backends, and they are a rung of the abstraction↔specialization trade. Path A lowers to LLVM IR and lets NVVM → PTX → SASS handle register allocation and instruction selection — but LLVM reasons in scalars and has no notion of a tile, SRAM staging, or warp-level tensor-core MMA, so you must hand-write all that choreography, in NVIDIA-locked, verbose PTX. Path B emits a tile DSL like Triton: you write the one-block program (loads, tl.dot, stores) and Triton's MLIR compiler maps tiles to warps and threads before lowering to PTX. The fused bias+gelu from lesson 05 is ~6 readable Triton lines vs dozens of per-thread PTX opcodes, and the gap explodes for matmuls. So Inductor generates Triton: it trades ~5–15% of peak for portability (NVIDIA + AMD) and a tiny codegen surface across the thousands of fusion groups it must emit, reserving raw PTX/SASS for the few flagship kernels. Because the same shapes run a million times, codegen compiles each kernel once, caches the artifact by a key of source + shapes + dtypes + config + arch, and amortizes the compile to a rounding error. The unanswered question — which tile sizes, warps, stages, out of a huge shape-dependent space — forces autotuning.

Interview prompts