AnyLearn
All lessons
AIadvanced

Writing Fused Kernels Without Writing CUDA

The reason most teams never fuse anything is that CUDA asks you to manage threads, shared memory and synchronisation by hand. Triton moves the unit of programming from a thread to a block and hands the rest to a compiler. This lesson covers what that buys, what it still asks of you, how to decide a kernel is worth writing, and how to be sure it is correct.

Updated · AI-authored, review-gated · how lessons are made

Not signed in: your progress and quiz score won't be saved.
Progress1 / 10

What CUDA asks of you

Writing a fused kernel in CUDA means reasoning at the level of individual threads, and the obligations are substantial.

You decide how many threads run and how they are grouped into blocks. You compute, for each thread, which elements of which tensor it is responsible for, from its indices. You allocate shared memory explicitly and copy data into it. You insert synchronisation barriers wherever threads must agree on state, and both missing and unnecessary barriers are bugs, one producing wrong answers and the other producing slow ones.

Then the performance work begins. Memory accesses must be coalesced so that adjacent threads read adjacent addresses, or bandwidth collapses. Shared memory accesses must avoid bank conflicts. Register usage must stay low enough that enough blocks fit on a multiprocessor to hide latency. Tensor cores must be fed through specific data layouts.

All of that is learnable and the catalogue's CUDA path covers it. The problem is not difficulty; it is that the cost is high enough that a team with a promising fusion idea usually does not try it.

Full lesson text

All 10 steps on one page, for reading, reference, and search.

Show

1. What CUDA asks of you

Writing a fused kernel in CUDA means reasoning at the level of individual threads, and the obligations are substantial.

You decide how many threads run and how they are grouped into blocks. You compute, for each thread, which elements of which tensor it is responsible for, from its indices. You allocate shared memory explicitly and copy data into it. You insert synchronisation barriers wherever threads must agree on state, and both missing and unnecessary barriers are bugs, one producing wrong answers and the other producing slow ones.

Then the performance work begins. Memory accesses must be coalesced so that adjacent threads read adjacent addresses, or bandwidth collapses. Shared memory accesses must avoid bank conflicts. Register usage must stay low enough that enough blocks fit on a multiprocessor to hide latency. Tensor cores must be fed through specific data layouts.

All of that is learnable and the catalogue's CUDA path covers it. The problem is not difficulty; it is that the cost is high enough that a team with a promising fusion idea usually does not try it.

2. Raising the unit of programming

Triton's design decision is to make the block, rather than the thread, the thing you program.

You write code that operates on tiles: load a block of this tensor, load a block of that one, multiply them, reduce along an axis, store the result. There is no thread index anywhere in the source, and no explicit shared memory allocation.

The compiler takes responsibility for what was manual. It decides how to map your tile operations onto threads, where to place data in the memory hierarchy, where synchronisation is required, and how to schedule loads so they overlap with computation.

The practical effect is that a fused attention kernel is on the order of a hundred lines rather than a thousand, and reads like the algorithm rather than like an implementation of it.

What you give up is control at the margin. When the compiler's layout choice is wrong for your case, there is limited recourse, and the last few percent of peak performance usually still requires hand-written code.

For most work that trade is heavily favourable, which is why a large share of published research kernels are now written this way.

3. What you still have to decide

The compiler handles placement and scheduling. It does not design your algorithm, and four decisions remain yours.

The tiling strategy. Which loop is outer, which is inner, and what each iteration holds. This is the difference between the original FlashAttention and its successor, and it was worth a large speedup, so it is not a detail the compiler can recover from.

Block sizes. Chosen so the working set fits in on-chip memory, which means you must know what your working set is. Too large and it spills; too small and the tiles are inefficient. These are usually tuned empirically over a small grid.

What stays resident. Values reused across inner-loop iterations should be loaded once outside it. Recognising which those are is an algorithmic observation, not a compiler one.

And the numerics. Which accumulators need higher precision, where a running maximum is required for stability, and how reductions are ordered. The compiler will faithfully compile a numerically unstable kernel.

So the framework removes the mechanical work and leaves the thinking, which is the right split.

4. The shape of a tiled kernel

Almost every fused kernel of this kind has the same skeleton, and recognising it makes unfamiliar kernels readable.

A launch grid is declared, one program instance per output tile. Each instance derives which tile it owns from its program identifier.

The instance loads the inputs that stay resident for its whole run, and initialises accumulators in registers or on-chip memory.

Then a loop over the reduction axis. Each iteration loads the next input block, performs the arithmetic against the resident data, and updates the accumulators. Nothing is written to main memory inside this loop.

After the loop, any finalisation happens: dividing by a running sum, casting back to the storage type, applying a scale.

The result is written once.

The crucial property is the one about the loop body. If your kernel writes to main memory inside its inner loop, it is not fused, and the whole exercise has failed regardless of how the code is written. Everything else is detail; that is the load-bearing constraint.

5. Deciding whether to write one

Most performance problems are not solved by a custom kernel, and the decision procedure is worth following in order because each step is cheaper than the next.

Profile first, and find where time is actually spent. Intuition about hot spots is unreliable, and a surprising share of apparent model slowness turns out to be data loading or synchronisation.

Compute arithmetic intensity for the hot operation. If it is compute-bound, fusion will not help and you need better algorithms or better use of tensor cores.

If it is memory-bound, check whether a library already covers it. Fused attention, fused normalisation and fused optimizers all exist, and the version you would write is unlikely to beat them.

If no library covers your case, try a compiler. Modern deep learning compilers fuse elementwise chains automatically and will handle many cases without a line of kernel code.

Only when profiling shows a memory-bound operation, no library covers it, and the compiler has not fused it, is a custom kernel the right answer. That intersection is narrower than enthusiasm suggests and it is real.

flowchart TD
A["Profile: where does time actually go?"] --> B["Compute arithmetic intensity"]
B --> C["Compute-bound: fusion will not help"]
B --> D["Memory-bound"]
D --> E["Does a library already fuse this?"]
E --> F["Yes: use it, you will not beat it"]
E --> G["No: will the compiler fuse it?"]
G --> H["Yes: no kernel code needed"]
G --> I["No: write a custom kernel"]

6. The cases where it is worth it

That funnel sounds discouraging, and the cases that survive it are more common than they appear, because they share one property: something about your model is slightly unusual.

A custom attention variant. A learned bias added to the scores, an unusual relative position scheme, a domain-specific masking pattern. The library's fast path applies to standard attention, and a small deviation silently drops you back to the materialised implementation.

A custom loss with a reduction. Anything computing per-example statistics and then combining them is the same pattern as softmax, and is usually written as several tensor operations that each round-trip a large intermediate.

A structured sparsity pattern your library does not know about. Block-sparse, banded, or document-boundary masks, where the block-skip argument from the previous lesson applies but nothing implements it for your shape.

A fused preprocessing or postprocessing chain, where several elementwise operations run over a large tensor.

And a novel architecture component, which by definition has no library. This is why research groups working on new architectures write kernels: without one, a promising idea benchmarks badly for reasons that have nothing to do with the idea.

7. Correctness before performance

Kernel bugs are unusually unpleasant because they often produce plausible wrong numbers rather than crashes, and a model trains to slightly worse loss with no other symptom.

So the discipline is to establish correctness first and never relax it.

Write a reference implementation in plain tensor operations, however slow. This is the specification, and it should stay in the test suite permanently.

Compare outputs against it with an explicit tolerance. Reassociation means the results will not be bitwise identical, so the tolerance has to be chosen deliberately rather than set to zero and then loosened until tests pass.

Test the boundaries, because that is where tiled kernels fail. Sequence lengths that are not multiples of the block size. A single row. Very long inputs. Head dimensions the block sizes were not tuned for.

Test gradients separately, against numerical differentiation or the reference implementation's autograd. A correct forward pass with a subtly wrong backward pass is the classic failure, and it shows up only as a model that trains worse than it should.

And test in the precision you will deploy in, since 32-bit tests can hide overflow that 16-bit inference will find.

8. Measuring against the right ceiling

A kernel is faster is not a result. The question is how close to the hardware's limit it gets, and there is a well-defined way to answer it.

Compute the theoretical minimum time two ways. Divide the bytes that must move by peak memory bandwidth, and divide the operations that must be performed by peak throughput. The larger of the two is the floor for any implementation.

Then measure your kernel against it. Reaching 70 to 85 percent of that floor is what a well-optimised kernel looks like, which is the range published fused attention implementations report. Achieving 20 percent means substantial headroom remains. Exceeding 100 percent means your model of what must move is wrong, usually because caching is helping more than you assumed.

Benchmark honestly. Warm up before timing, since the first call includes compilation. Run enough iterations to see variance. Synchronise before stopping the clock, or you time the launch rather than the work. Use realistic shapes, because performance is highly shape-dependent and a benchmark at a convenient power of two can be misleading.

And measure end to end. A kernel three times faster inside a model that spends most of its time elsewhere is a rounding error.

9. Portability is the recurring cost

The uncomfortable part of kernel work is that a kernel is tuned for a hardware generation, and hardware generations keep arriving.

The FlashAttention sequence demonstrates this precisely. The second version reached 73 percent of peak on an A100 and about 35 percent on an H100, without any change to the code, because the newer architecture offered asynchrony and specialised units the kernel did not use. The third version was substantially a rewrite around those features, and it reached about 85 percent.

That is not a criticism of the earlier work. It is what optimising against a specific memory hierarchy and instruction set means.

Three consequences follow for anyone maintaining kernels.

Block sizes need retuning per architecture, and that should be automated rather than remembered.

Benchmarks must run on the hardware you deploy on, since results do not transfer across generations.

And the reference implementation stays in the codebase forever, both as a correctness oracle and as a fallback when a kernel does not support a shape or a device.

Using a library rather than maintaining your own kernel is largely a decision to have someone else absorb this cost, and it is usually the right decision.

10. What the skill actually is

The transferable capability from this path is not the ability to write a fused attention kernel, since one already exists and yours will be worse.

It is the ability to look at a computation and tell where its time goes, which is a diagnostic skill that pays constantly.

Concretely: given an operation, estimate the bytes it must move and the operations it must perform, form the ratio, and compare against the hardware's. That tells you which resource binds. Then ask whether the memory traffic is necessary or an artefact of how the computation was expressed, and whether any intermediate is larger than the inputs and outputs.

That question has an unreasonable hit rate. An intermediate larger than what surrounds it is almost always avoidable, and the reason it exists is almost always that someone wrote three operations in sequence.

When the answer says a kernel is warranted, block-level tooling makes writing one a day rather than a month, which changes it from an idea you abandon into one you test.

And when the answer says a kernel is not warranted, which is more often, you have saved that month and can go and fix the data loader instead.

Check your understanding

The lesson ends with a 5-question quiz. Take it in the player above to see your score.

  1. What is the central difference between programming in CUDA and in a block-level language like Triton?
    • Triton runs on the CPU while CUDA runs on the GPU
    • The unit of programming becomes a tile rather than a thread, so the compiler handles thread mapping, shared memory placement and synchronisation
    • Triton generates approximate results in exchange for speed
    • CUDA cannot express fused kernels at all
  2. Which decision does the compiler NOT make for you?
    • Where synchronisation barriers are needed
    • How tile operations map onto individual threads
    • The tiling strategy, meaning which loop is outer and what stays resident
    • Where data is placed in the memory hierarchy
  3. What single property tells you a kernel is genuinely fused?
    • It uses tensor cores for every matrix multiply
    • It launches only one grid
    • It has no explicit synchronisation barriers
    • It writes nothing to main memory inside its inner loop
  4. When is writing a custom kernel actually the right answer?
    • When profiling shows a memory-bound operation, no library covers it, and the compiler has not fused it
    • Whenever a model is running slower than expected
    • Whenever an operation is quadratic in sequence length
    • Whenever the hardware reports low utilisation
  5. What does the FlashAttention-2 to FlashAttention-3 transition illustrate about kernel work?
    • That Triton kernels outperform hand-written CUDA on new hardware
    • That block sizes are the only architecture-specific parameter
    • That approximate methods eventually overtake exact ones
    • That kernels are tuned to a hardware generation: the same code fell from 73 percent of peak on one architecture to about 35 percent on the next

Related lessons

AI
advanced

Finding the Next One: Fusion Beyond Attention

The pattern that made attention slow recurs across the stack, and once you know what to look for it is easy to find. This lesson applies the diagnosis to normalisation layers, optimizer steps, loss functions and inference decoding, covers why fused attention silently stops applying when a model deviates slightly from standard, and gives the profiling routine that decides where to look first.

10 steps·~15 min
AI
intermediate

What This Teaches About Measuring Anything

The exchange is a case study with transferable rules. A conclusion resting on failures needs a failure taxonomy. Every instance must be verified solvable before anyone is scored against it. Output format is a confound whenever answers get long. And when two explanations fit the same data, the productive move is to find the prediction on which they differ, then test it.

8 steps·~12 min
AI
intermediate

The Rebuttal: Three Ways to Score Zero Without Failing

The response disputed none of the data and argued the experiment measured something other than reasoning. Models had to print move lists exceeding their output limits, and said so in the transcripts. Some instances had no solution and were scored as failures anyway. And asking for a program instead of a move list produced high accuracy on instances reported as total collapse.

8 steps·~12 min
AI
intermediate

The Experiment: Puzzles With a Difficulty Dial

Apple researchers built an evaluation designed to fix a real problem with benchmarks: puzzles where difficulty turns up smoothly while the logic stays identical, and every step can be checked. They found accuracy collapsing to zero past a threshold, and not improving when the solution algorithm was handed to the model. This lesson covers the design and why it was a good one.

8 steps·~12 min