Building Triton GPU kernels from vector addition up to naive self-attention, finding and fixing one real correctness bug along the way, and naming honestly the wall that separates this from real FlashAttention.
Every PyTorch operation you've ever called, softmax, relu, matmul, is,
underneath, someone else's compiled GPU kernel. You never see the kernel. You
call the function, the tensor comes back, and the actual work of moving
numbers in and out of GPU memory, scheduling threads, and managing on-chip
caches happens somewhere you're not invited to look.
I wanted to know what was actually happening in there. Not at the level of
"attention is a weighted sum," which I already understood from
a previous post, but at the level of: what does the GPU
actually do, instruction by instruction, when you call torch.softmax?
So I wrote the kernels myself, in Triton, starting from the simplest thing
that could possibly be called a kernel and working up. This is what that
ladder looked like, including the one real bug I found and the wall I hit at
the end, named honestly, not smoothed over.
Why bother: the thing PyTorch hides
Most of what PyTorch calls a single operation is really several.
torch.softmax(x) internally finds the row max, subtracts it, exponentiates,
sums, and divides, and on a naive execution model each of those steps can
mean a separate pass over the data: read from GPU memory, compute, write
back to GPU memory, repeat. For an operation that conceptually happens
"once," your data can make several unnecessary round trips between the
GPU's slow global memory and its fast on-chip compute units.
A fused kernel does all of those steps in one pass: load the data into fast
on-chip memory once, do the whole computation, write the result back once.
That's the entire premise behind Triton, and it's the same underlying idea
that motivates FlashAttention, which I'll come back to at the end: fewer
memory round trips, not fewer FLOPs, is often the real lever for speed on a
GPU.
Fewer memory round trips, not fewer FLOPs, is often the real lever for speed on a GPU.
Why bother: the thing PyTorch hides
Kernel 1: vector add, or learning to think like a GPU
The simplest possible Triton kernel doesn't optimize anything, it just adds
two vectors, out = x + y. The point of writing this first wasn't the
operation, it was learning Triton's actual execution model before any
algorithm complexity got involved:
Triton launches a grid of programs, each identified by a pid (program
ID). Think of each program as one worker handling one chunk of the data.
Each program computes its own offsets into the input arrays,
block_start = pid * BLOCK_SIZE, then tl.arange(0, BLOCK_SIZE) for the
positions within its chunk.
A mask (offsets < n_elements) handles the case where the data size isn't
a clean multiple of the block size. Without it, the last block would read
and write past the end of the array.
Checked against plain x + y in PyTorch: matches exactly. Nothing
interesting happens here algorithmically, that's the point. pid, offsets,
and masking are the three ideas every later kernel in this post reuses, so
getting them right on the simplest possible operation first meant later bugs
(and there was one, further down) were never about "do I understand
Triton," only ever about the specific algorithm.
Kernel 2: fused ReLU and dropout, and the problem with testing randomness
The next step up: fuse two operations into one pass instead of one.
ReLUZero out every negative value, leave positive values unchanged.
and
dropoutRandomly zero a fraction p of values, scaling the survivors by 1 / (1 - p) to keep the expected sum unchanged.
are two ops that would normally be two separate kernel calls in a naive
implementation. Fusing them means one load, one combined computation, one
store.
Here's the part worth dwelling on: you can't validate this the way you
validate the vector-add kernel. allclose against a reference only works
when the output is deterministic. Dropout is stochastic by design, there is
no single "correct" output to compare against. Eyeballing five printed
values, which is what I did the first time, tells you almost nothing; it's
the same mistake as trusting a single run in the
birthday-paradox post instead of the distribution.
The actual test has to be statistical: run it on enough elements that
probability becomes measurable, then check two things, does the empirical
drop rate land near p, and are the survivors scaled by exactly
1 / (1 - p)?
Testing with size=100000, p=0.1 -> empirical rate 0.1007 (expected 0.10 +/- 0.01) OK
Testing with size=100000, p=0.5 -> empirical rate 0.4993 (expected 0.50 +/- 0.01) OK
Testing with size=100000, p=0.9 -> empirical rate 0.8999 (expected 0.90 +/- 0.01) OK
All three land within a hundredth of a percent of the target. That's the
right way to trust a kernel whose output is supposed to be random: proving
the distribution is correct, not any single sample.
Kernel 3: fused softmax, and the bug that mattered
Softmax is the first kernel here with real numerical-stability
considerations: naive exp(x) overflows for even moderately large x, so
every real softmax implementation subtracts the row max before
exponentiating. The kernel does this per row, in one pass:
BLOCK_SIZE was a fixed number, not tied to n_cols at all. For a small
input this is harmless, col_offsets spans more than enough room, and the
mask correctly zeroes out the unused tail. But nothing about the mask logic
checks whether BLOCK_SIZE is large enough, it only checks whether each
position is within n_cols. So the moment a real row is wider than 1024
columns, the kernel simply never loads, computes, or stores anything past
column 1024. It doesn't crash or warn you, it just returns a softmax over
the first 1024 columns and calls it done.
Before, reproduced explicitly at n_cols=2000:
Buggy Softmax Matches PyTorch (n_cols=2000)? False
Max abs diff: 0.00978
After, the fix is one line: stop guessing a fixed block size and size it to
the actual input.
The lesson isn't "remember to make BLOCK_SIZE dynamic," it's narrower and
more useful than that: a test that only covers the shape you happen to be
thinking about will pass right over a bug that only shows up at a different
shape. The (4, 5) test I ran first was real, it wasn't fake, it just
wasn't the test that mattered.
Drag n_cols past 1024 in "hardcoded" mode and the bar stops filling exactly where the bug does: everything past the line is real data the kernel never reads, writes, or warns about. Switch to "dynamic" and the same drag never breaks anything, because BLOCK_SIZE grows with n_cols instead of sitting fixed.
Kernel 4: bringing it together, a self-attention kernel
Everything up to here, offsets, masks, a fused numerically-stable softmax,
combines into one kernel implementing scaled dot-product attention directly:
Validated against torch.nn.functional.scaled_dot_product_attention across
several shapes, including deliberately mismatched Q/K sequence lengths and a
single-query edge case:
Given the softmax lesson above, testing more than one shape here wasn't
optional, it's the only reason I can trust this one.
Figure · the diagram follows your scroll
The wall, named honestly
Here's what this kernel is not: it is not FlashAttention, and it's worth
being precise about why, rather than letting the name imply more than the
code does.
Step 01
All of K and V, in one shot
This kernel loads the entire K and V into fast on-chip memory
(SRAMThe GPU's small, extremely fast on-chip memory, as opposed to its much larger but far slower off-chip global memory.)
in one shot before doing anything else.
Step 02
SRAM runs out
That's fine at the sequence lengths tested here, but SRAM is small (tens of
kilobytes per streaming multiprocessor, not gigabytes), and K/V grow linearly
with sequence length. At some sequence length, "all of K and V" simply stops
fitting, and this kernel breaks, not gracefully, it just runs out of room.
Step 03
Chunks, and a running max and sum
The real FlashAttention trick is to never need all of K/V in SRAM at once:
process it in chunks, and keep a running max and running sum as you go,
correcting the accumulated output every time a new chunk reveals a bigger
max than anything seen so far.
Step 04
The rung not yet climbed
That's a genuinely different, harder algorithm than anything in this post,
the online-softmax rescaling has no analogue in kernels 1 through 4, and it's
the specific thing I haven't built yet.
Score matrix Q·KᵀStep 01 / 04
Query blockLive tileAccumulatedNeeded, no roomNot built
Running max m
—
Running sum ℓ
—
K/V blocks at once
4 / 4
Naming that clearly here, instead of leaving it unsaid, is the whole point: this post
is "I climbed four rungs of a real ladder, correctly, and found a real bug
along the way," not "I built FlashAttention."
Short and unforced: the useful thing wasn't the final kernel, it was the
softmax bug. It's the one moment on this ladder where "it passed my test"
and "it's actually correct" turned out to be different claims, the same gap
that showed up when I mixed up the median and the mean in the
birthday-paradox post. Different domain, same shape of mistake, same fix:
test the case you didn't think to test.
The tiled, online-softmax version is the next rung. I haven't climbed it
yet.