Part II — Kernels that matter · Chapter 6

Neural networks as a workload

~50 min read2 interactive widgets5 plateslab 5 · ~40 min on your GPU

The chapter in one paragraph

Part I taught you the machine. Part II is about the few kernels that matter on it, so first you need to know what a neural network actually asks the GPU to do. At inference time a model is a fixed program: a list of operations on arrays whose shapes are known in advance, applied to weights that never change. Almost every operation falls into one of four classes. Matrix multiplies (every linear layer) do nearly all the arithmetic and can sit far right on the roofline. Elementwise operations (activations, bias and residual adds), row reductions (layernorm, softmax) and data movement (embedding lookups, transposes) do almost no arithmetic and are always memory-bound. So the work splits cleanly: make the matrix multiplies fast (chapters 7 and 8), and make everything else move as few bytes as possible, mostly by fusing it into its neighbours (chapter 9). This chapter builds that picture from an MLP block up to all of GPT-2 small, counts its parameters, FLOPs and bytes, and ends with the number formats (FP32 down to FP4) that decide how many bytes there are.

In this chapter

  1. What a model computes
  2. Tensors: shapes, layouts, and why a token is a row
  3. The linear layer is a matrix multiply
  4. Everything else: elementwise, reductions, data movement
  5. Four classes on one roofline
  6. GPT-2 small, counted
  7. Number formats: FP32 down to FP4
  8. The plan for Part II
  9. Lab 5: the MLP block, op by op
  10. Check your understanding

1. What a model computes

A neural network is a function with a very large number of adjustable constants. You give it an input x (a sentence, an image), it returns an output y (the next word, a label), and the constants, called parameters or weights, decide which function it is. Training searches for weights that make the outputs good, by running the model forward, measuring the error, and running backward to adjust every weight a little; that costs about three times the arithmetic of the forward pass alone, over trillions of examples. Inference uses the finished weights: forward only, weights frozen. This course is about inference, because that is where models spend their lives and where the money goes.

The function is built from layers: small, standard operations applied one after another, each one taking arrays of numbers in and giving arrays out. A modern language model uses maybe ten distinct kinds of layer, repeated dozens of times. Seen from the GPU, three facts make this workload unusual:

So from here on, “running a model” means: launch a known list of kernels, each one an operation on arrays. The rest of this chapter is about what those operations are and what each one costs.

2. Tensors: shapes, layouts, and why a token is a row

The arrays a model works on are called tensors: an array with a shape (the size of each dimension) and a data type (FP32, FP16…, section 7). A vector is a tensor of shape [768], a matrix [768, 3072]. Whatever the shape, memory is still one long line (chapter 2), so every tensor also has a layout: the rule that turns an index into an offset. Frameworks use row-major by default: the last dimension is contiguous.

A language model reads text as tokens, pieces of words, each one an integer ID from a fixed vocabulary (GPT-2’s has 50,257). The first layer turns each token into a vector of C numbers (768 in GPT-2 small); every later layer takes those vectors and returns new ones of the same size. So the data flowing through the model, the activations, has three dimensions: B sequences in the batch, T tokens per sequence, C channels per token. Shape [B, T, C].

The step in the middle of the figure is the most useful idea in this chapter. Almost every layer (linear layers, activations, layernorm) acts on each token on its own: token 5 of sequence 2 does not care which sequence it is in. So the kernels see just a matrix of M = B·T rows, one per token in flight, each C numbers wide. Only attention (chapter 10) mixes tokens, and only within a sequence. Keep M in mind: it is the number that moves every operation along the roofline.

Reshapes are free, transposes are not

Viewing [B, T, C] as [B·T, C] changes no bytes: it is the same line of memory with a different indexing rule. Frameworks call that a view. Swapping two dimensions, for example [B, T, H, D] → [B, H, T, D] to separate attention heads (chapter 10), changes which elements are neighbours in memory, so producing it contiguous means copying the whole tensor. Good kernels avoid the copy by reading the data in its original layout, with a different index formula.

3. The linear layer is a matrix multiply

The workhorse layer takes each token’s vector of K numbers and returns N numbers, each one a weighted sum of all the inputs plus a constant:

y[m, n] = b[n] + ∑k x[m, k] · W[k, n]

For all tokens at once that is Y = XW + b: an [M, K] matrix times a [K, N] weight matrix, the operation BLAS libraries call GEMM (general matrix multiply). Every output element is a dot product of length K: K multiplies and K adds.

Count it for the first MLP layer of GPT-2 small, which maps 768 channels to 3,072 (K = 768, N = 3,072), in FP32:

M = 1 (one user, decode)M = 512 (a prompt)
FLOPs, 2MKN4.7 M2.42 G
Bytes: X, W, Y once each9.46 MB (almost all W)17.3 MB
Intensity0.5 FLOP/B140 FLOP/B
Roofline time on your card16 µs, memory-bound78 µs, compute-bound

Same layer, same weights, a factor of 280 in intensity. At M = 1 each weight is used for one multiply-add and thrown away; at M = 512 it is used 512 times. (The roofline times use 88% of the 672 GB/s spec for memory and the 30.9 TFLOPS FP32 peak for compute.)

Your move: at M = 1, the second MLP layer (3,072 → 768) moves the same 9.4 MB of weights as the first. With the naive one-thread-per-output kernel from lab 5, which of the two do you expect to be slower, and by how much?

The second, and by a lot. One thread per output means the first layer launches 3,072 threads (12 blocks of 256) and the second only 768 threads: 3 blocks. On a 48-SM GPU, 3 blocks occupy 3 SMs and leave 45 idle, and each of those 768 threads walks a 3,072-long dot product one load after another. Chapter 3’s rule is that bandwidth needs bytes in flight: 3 SMs cannot keep enough requests outstanding to pull more than a small fraction of 672 GB/s. Expect the second layer to take several times longer than the first even though it moves the same bytes, and both to be far from the 16 µs roofline. It is chapter 2’s “many more blocks than SMs” rule, broken by a shape. Decode kernels fix it by splitting the K loop across many blocks (split-K, chapter 15). Lab 5 measures both.

Which way round is the weight?

The formula above stores W as [K, N] = [in, out], and that is how GPT-2’s original checkpoints store it. PyTorch’s nn.Linear stores the same numbers as [out, in] and computes XWT. Nothing about the math changes, but the kernel does: in one layout a column of W is contiguous, in the other a row is. BLAS libraries name the variants by which operand is transposed (NN, NT, TN), and a fast kernel for one is not automatically fast for the other. Checking the layout before writing the kernel is the cheapest bug you will ever avoid; lab 5 and the rest of Part II use [K, N], so Y = XW with no transpose.

Where the bias goes

The + b is a separate operation only on paper. Adding one number per output column costs nothing if the matmul kernel does it while the result is still in registers, just before writing it. That final step of a matmul kernel is called its epilogue, and bias, activation and residual add are all classic epilogue passengers (chapter 9). Lab 5 keeps them as separate kernels on purpose, so that you can see what they cost when they travel alone.

4. Everything else: elementwise, reductions, data movement

Stacking linear layers alone buys nothing: a linear function of a linear function is still one linear function, and all those weights would collapse into one matrix. Everything that makes a network more than a single matrix lives in the other layers. They do very little arithmetic, but there are many of them.

Elementwise operations

An activation function is applied to every number independently, which is what breaks the linearity. GPT-2 uses GELU, in its tanh approximation:

gelu(x) = ½x (1 + tanh(√(2/π) (x + 0.044715x3)))

Roughly: pass positive values through, squash negative ones towards zero, smoothly. Other elementwise operations in a transformer: bias adds, the residual add (out = x + layer(x), which lets each layer add a correction to its input instead of replacing it), scaling, and conversions between number formats.

The cost model is the simplest one in the course. Each element is read once and written once, 8 bytes in FP32, for a handful of FLOPs: intensity around 1 FLOP per byte at best, fifty times left of the ridge. Every elementwise kernel is memory-bound, and its time is its bytes divided by bandwidth. GELU on a [512, 3,072] FP32 activation moves 12.6 MB, so about 21 µs on your card, however few or many FLOPs the tanh takes. Unless it is tiny: at M = 1 the same GELU moves 25 KB, which takes 0.04 µs of DRAM time and a few µs of launch overhead. Small elementwise kernels are overhead-bound.

Row reductions: layernorm and softmax

Layer normalization rescales each token’s vector to mean 0 and variance 1, then applies a learned scale γ and shift β per channel. It keeps the numbers in a well-behaved range as they pass through dozens of layers:

μ = mean(x), σ2 = mean((x − μ)2), y = γ · (x − μ) / √(σ2 + ε) + β

Softmax turns a row of arbitrary scores into positive weights that sum to 1, and it is at the heart of attention (chapter 10):

softmax(x)i = exi − max(x) / ∑j exj − max(x)

(Subtracting the maximum changes nothing mathematically and prevents overflow: in FP32, ex is infinite for any x above about 88.7.)

Both have the same shape: reduce a row to one or two numbers, then apply an elementwise formula that uses them. The reduction is chapter 5’s, one per row, and like elementwise ops both are firmly memory-bound. Their special problem is that the formula needs the reduction’s result before it can start, so a naive implementation reads each row two or three times: once for the max or mean, once for the sum or variance, once to write the output. Chapter 9 gets that down to one read (Welford’s algorithm, online softmax), and chapter 14 uses online softmax to build FlashAttention.

Data movement: embeddings and transposes

The first layer is an embedding lookup: token ID t selects row t of a [50,257, 768] table. That is chapter 3’s gather: rows scattered across the table, but each row contiguous, so one warp per row is coalesced. Zero FLOPs, 3 KB per token. The other movers are the transposes and head splits of attention, which good kernels fold into their index arithmetic instead of running as copies.

Your move: a framework runs bias add, GELU and a dtype conversion as three separate kernels on a [512, 3,072] FP32 tensor. Estimate the time on your card, and what one fused kernel would take.

Each separate kernel reads and writes the whole tensor: 2 × 6.3 MB = 12.6 MB, about 21 µs each at ~590 GB/s, so roughly 64 µs for the three (the conversion writes FP16, a bit less). A fused kernel reads the FP32 input once and writes the FP16 output once: 6.3 + 3.1 = 9.4 MB, about 16 µs. Four times faster, and the arithmetic is unchanged. Fusing it into the matmul’s epilogue removes even that read. This is the whole argument for fusion, and the reason “count the bytes, not the FLOPs” is the first rule for everything that is not a matmul.

5. Four classes on one roofline

ClassExamplesFLOP per byte (FP32)BoundWhere we optimize it
Matrix multiplylinear layers; attention’s QKT and ·V≈ M/2 for small M, up to hundredsmemory at small M, compute at large Mchapters 7, 8, 12
ElementwiseGELU, bias, residual add, scale, cast0.08–1memory (or launch overhead when tiny)fusion, chapter 9
Row reductionlayernorm, RMSNorm, softmax≈ 0.5–1memory; extra passes over the rowchapter 9; attention: chapter 14
Data movementembedding gather, transpose, concat0memoryfold into the neighbours’ indexing

Two consequences shape the rest of Part II. First, the matmuls are the only operations where the kernel’s arithmetic skill matters: for everything else, a perfect kernel is one that runs at the DRAM ceiling, and the only way to beat that is to move fewer bytes. Second, the small operations are cheap only in FLOPs. In a large-M forward pass they are a real slice of the time, and at M = 1 they are dominated by launch overhead. The next section puts numbers on both.

6. GPT-2 small, counted

GPT-2 small is the model your spine project will run. Its whole configuration fits in one line: vocabulary V = 50,257 tokens, context up to 1,024 tokens, L = 12 layers, width C = 768, 12 attention heads of 64 channels, MLP width 4C = 3,072. Every layer (a transformer block) has the same two halves, each wrapped in a residual connection:

Parameters

PieceShapeParameters
Token embedding (also the LM head)50,257 × 76838,597,376
Position embedding1,024 × 768786,432
Per block: Q, K, V projection768 × 2,304 + 2,3041,771,776
Per block: attention output768 × 768 + 768590,592
Per block: MLP up768 × 3,072 + 3,0722,362,368
Per block: MLP down3,072 × 768 + 7682,360,064
Per block: two layernorms4 × 7683,072
12 blocks12 × 7,087,87285,054,464
Final layernorm2 × 7681,536
Total124,439,808

That is the “124M” in GPT-2 small’s name. In FP32 it is 498 MB, which is chapter 1’s “0.5 GB”: the number of bytes every decode step must read.

FLOPs per token

Each token passes through every linear layer once, for 2 FLOPs per weight. The four linear layers of a block hold 7,077,888 weights, so 12 blocks cost 170 MFLOPs per token. The LM head multiplies by the whole 38.6M-entry embedding table: 77 MFLOPs more, nearly a third of the total, spent on scoring every word in the vocabulary. Attention adds 4 × 768 FLOPs per layer for every earlier token the new one looks at, 37K per token of context over the 12 layers: 38 MFLOPs at a full 1,024-token context, small next to the rest. Total: about 250 MFLOPs per token, which is chapter 1’s “2 FLOPs per parameter” (2 × 124M = 249M). The rule works because the embedding table, used as a free gather at the input, is paid for in full as the LM head at the output.

Your move: a 1,024-token prompt (prefill, M = 1,024) in FP32. How long, at best, on your card, and which bound?

About 1,024 × 247 MFLOPs for the linear layers and head, plus attention, where the average token looks back over half the prompt: roughly 0.27 TFLOPs in total. At 30.9 TFLOPS that is about 9 ms, compute-bound (the linear layers sit at intensity in the hundreds). Reading the 498 MB of weights once would take 0.8 ms, so memory is not the limit. That is why prefill is measured in thousands of tokens per second and decode in hundreds: prefill uses every byte of weights 1,024 times, decode once.

Here is decode as a machine. The weights are a plate cylinder that has to turn once for every step, and a batch is several sheets fed through on the same turn.

Where the time goes

Now put the pieces together. The census below lists every operation of one forward step of GPT-2 small (bias adds folded into their matmuls), with its bytes, its FLOPs, and its roofline time: the larger of FLOPs/peak and bytes/bandwidth. It models one decode step for a batch of M sequences, each with t tokens of context already cached. Start at M = 1, then raise it, switch to FP16, and turn on the launch floor.

Three things to take away from playing with it:

  1. At M = 1 the linear layers are the time, and all of it is weight bytes: tokens per second is bandwidth divided by model size, exactly chapter 1’s estimate. FP16 halves the bytes and doubles the ceiling.
  2. At M = 1 the small operations are nearly free in the roofline sense, and not free at all in practice. With the launch floor on, over a hundred kernels of a few microseconds each add up to a large share of the step. That is the overhead-bound regime of chapter 1, and why engines fuse and capture CUDA graphs.
  3. As M grows, the matmuls turn compute-bound and attention takes over. Every sequence reads its own cached keys and values, so attention bytes grow with M × t and never get reused: chapter 4’s “attention does not batch”, now as a share of the step. Part III is about that row.

7. Number formats: FP32 down to FP4

Every byte count above assumed FP32, 4 bytes per number. The simplest way to make a memory-bound workload faster is to make the numbers smaller, and ML has been doing exactly that for a decade. A floating-point number is a sign, an exponent (which sets the range) and a mantissa (which sets the precision): value = ±1.mantissa × 2exponent. Formats differ in how they split their bits:

FormatLargestSpacing just above 1Typical role
FP323.4 × 10381.2 × 10−7accumulators, references, your first engine
TF323.4 × 10389.8 × 10−4tensor-core inputs for FP32 matmuls (chapter 8)
BF163.4 × 10387.8 × 10−3training, weights of most open models
FP1665,5049.8 × 10−4inference weights and activations
FP8 E4M34480.125inference, with scale factors
FP8 E5M257,3440.25gradients in FP8 training
FP4 E2M160.5weights, with per-block scales (Part IV)

Neural networks tolerate this because they are statistical: a weight of 0.0213 stored as 0.0214 changes the output by less than the noise the model was trained to ignore. But there is one place where precision cannot be cut, and you have already met it in chapter 5’s reduction: long sums. A dot product of length 768 adds 768 products into one running total. Once the total is large, each new small product is below the spacing of the format and simply vanishes. Try it:

Hence the rule every fast kernel follows: store and multiply in a narrow format, accumulate in FP32. The inputs are rounded once, which the model tolerates; the sum is never rounded to the narrow format until the very end. Tensor cores are built around exactly this: FP16, BF16 or FP8 inputs, FP32 accumulators (chapter 8). Your spine engine starts in FP32 because it must match PyTorch’s outputs to about 10−5 before anything gets clever, and a mismatch in FP32 is a bug, not rounding.

Your move: FP16 has 10 mantissa bits and BF16 only 7. Why did training move from FP16 to BF16 anyway?

Range. Gradients and some activations span many orders of magnitude, and FP16 overflows above 65,504 and loses precision below 6 × 10−5, so FP16 training needed loss scaling: multiply the loss by a large factor to keep gradients representable, and back it off when anything overflows. BF16 has FP32’s 8-bit exponent, so anything FP32 can represent, BF16 can too, only more coarsely. Losing 3 bits of precision turned out to hurt less than babysitting the range. For inference, where values are better behaved and known in advance, FP16 is still common, and the formats below 16 bits bring back the range problem in a new form: that is what the scale factors are for.

8. The plan for Part II

Put the census and the roofline side by side and the next five chapters write themselves:

ChapterWhat it does to the census
7. Matmul ITakes the linear layers from the naive kernel (a few percent of peak) to shared-memory tiling, and finds out why tiling alone stops near 10%.
8. Matmul IIRegister blocking, warp tiling and tensor cores: the linear layers within reach of cuBLAS, in FP32 and FP16.
9. Softmax, layernorm, fusionThe small operations at the DRAM ceiling in one pass, and fused into their neighbours so their bytes disappear.
10. The transformer from the GPU’s viewAttention in detail, the KV cache, and the full block as kernels.
11. Lab: GPT-2 forward passThe spine project’s milestone: all of it assembled, matching PyTorch, benchmarked.

The order is not arbitrary: the matmul is where the FLOPs are and where the hardest kernel skills are learned, and those skills (tiling, register reuse, warp-level cooperation) are what every other kernel in Part III is built from.

9. Lab 5: the MLP block, op by op

Lab 5GPT-2’s MLP sublayer, seven kernels, three sizes~40 min · mlsys/labs/lab05-mlp-block/

One program, mlp, runs the MLP half of a GPT-2 block with one kernel per operation: layernorm, the naive matmul (768 → 3,072), bias, GELU, the naive matmul (3,072 → 768), bias, residual add, plus a fused bias_gelu. It checks all of them against a double-precision CPU reference, then times each one at M = 1, 64 and 512 tokens and prints its bytes, FLOPs, GB/s, GFLOP/s and share of the total.

cd ~/unibo-lessons/mlsys/labs/lab05-mlp-block
make test        # every kernel vs the CPU reference: all PASS
make bench       # the op-by-op table at M = 1, 64, 512
make ncu-table   # optional: DRAM / SM throughput and grid size per kernel

Before you run make bench, write down your predictions:

QuestionWhat the chapter predicts
M = 512: share of the time in the two matmulsabove 95%: they are the only compute-heavy ops, and the naive kernel runs them at a few percent of peak
M = 512: GELU, bias, residual GB/snear your lab 0 ceiling (~590 GB/s)
M = 512: bias+GELU fused vs bias then GELUfused ≈ half the sum of the two
M = 1: elementwise opsa few µs each whatever their size: launch-bound
M = 1: matmul fc vs matmul projproj several times slower (3 blocks vs 12, section 3)
M = 512: naive matmul GFLOP/sa few percent of the 30.9 TFLOPS peak: chapter 7’s starting point

Paste back the PASTE: lines (one from make test, three from make bench). Your numbers replace the predictions here, and the M = 512 matmul line becomes chapter 7’s baseline.

Stretch

Write bias_gelu_residual for the second half of the sublayer (out = x + v + b) and measure it against bias then residual. Then predict, before measuring, how long all of layernorm would take if you fused it with nothing but made it read each row only once instead of twice. (Hint: the second read of a 3 KB row mostly hits L1. What does that do to your prediction?)

Check your understanding

Why can a batch of B sequences of T tokens be treated as one matrix of B·T rows by most layers?

Linear layers, activations and normalizations act on each token’s vector independently: the output for one token does not depend on any other token. So which sequence a token belongs to does not matter, and the kernels see one [B·T, C] matrix. Only attention mixes tokens, within a sequence.

A linear layer 768 → 3,072 in FP32: intensity at M = 1 and at M = 512, and which roof applies to each?

About 0.5 FLOP/B at M = 1 (each weight, 4 bytes, used for one multiply-add): memory-bound. About 140 FLOP/B at M = 512 (each weight used 512 times): past the FP32 ridge of ~46, compute-bound.

GELU takes a dozen FLOPs per element and a residual add takes one. Which is faster on a [512, 3,072] tensor?

Neither by much, and the residual add is slightly slower: both are memory-bound, so time is bytes over bandwidth, and the residual add moves 12 bytes per element (two inputs, one output) against GELU’s 8. FLOPs do not enter.

Why does a naive layernorm or softmax read each row more than once, and why does that matter even though the second read may hit in cache?

The elementwise formula needs the row’s reduction (max, sum, mean, variance) before it can start, and computing that reduction requires the whole row. So the kernel reads the row to reduce it, then again to normalize it. For short rows the second read hits L1 and costs little; for long rows (softmax over thousands of attention scores) it goes back to L2 or DRAM and doubles the traffic. Chapter 9 removes it.

Where does the “2 FLOPs per parameter per token” rule come from, and where does GPT-2 small bend it?

Each weight of a linear layer is used in one multiply-add per token. It fails for parameters that are not matmul weights: the embedding table used as an input lookup costs no FLOPs, layernorm parameters cost a few. GPT-2 happens to balance it out because the tied embedding table is also the LM head, a full matmul at the output, so the rule gives 249M against a detailed count of ~250M.

At M = 1, the census says the whole non-matmul part of GPT-2 costs a few percent of the roofline time. Why can it still be a third of the real time?

Each operation is a kernel launch, and a launch costs a few microseconds no matter how little work it does. GPT-2 small runs over a hundred small kernels per token, each far below the launch floor, so they cost launches, not bytes. The fixes are fusion (fewer kernels) and CUDA graphs (cheaper launches).

Why do tensor cores and good kernels accumulate in FP32 even when the inputs are FP16 or FP8?

A long sum in a narrow format stops growing once the running total is so large that each new term is smaller than half the format’s spacing at that magnitude: the terms round away. Rounding the inputs costs a little accuracy once; rounding the running total costs accuracy at every step. So inputs are narrow, accumulators are FP32.

BF16 and FP16 are both 2 bytes. What does each give up?

BF16 keeps FP32’s 8 exponent bits (same range) and has 7 mantissa bits (spacing ~0.8% near 1). FP16 has 10 mantissa bits (~0.1%) but only 5 exponent bits, so it overflows above 65,504 and starts losing precision below ~6×10−5 (the smallest normal number; below it only subnormals remain, down to ~6×10−8).

Sources and further reading