Skip to content

Kernel Fusion and the Memory Wall

7 min read

Toy chain: 6.0 tensor transfers across three kernels versus 2.0 across one fused kernel. Counts include input load and final store; equal tensor sizes are assumed. Animation timing is schematic.

Scroll horizontally to inspect the diagram.

Consider three operations: a matrix multiply, a bias addition, and ReLU. If each launches a separate kernel, intermediate results can be written to GPU memory and then read back by the next operation. A fused implementation can keep some of those values close to the arithmetic units instead.

Fusion can save both intermediate memory transfers and kernel-launch overhead. Its benefit depends on the operation’s shape and bottleneck.

Compute needs a supply of bytes

A GPU has several storage levels. HBM holds large tensors; caches retain some recently used data; shared memory and registers support work within a streaming multiprocessor. Registers have finite capacity and access costs, and shared memory is explicitly managed rather than simply another automatic cache.

Where a GPU kernel keeps its values

HBM / device memory
Large off-chip store for weights, activations, and KV cache. Global loads and stores can move data through the cache hierarchy.
L2 cache
Shared on-chip cache. Reusing a recently accessed address may avoid another HBM transfer, but residency is limited.
Shared memory
On-chip storage explicitly managed by cooperating threads in a block. Tiles compete with other blocks for each SM's capacity.
Registers
Thread-local values used by arithmetic instructions. Too many live values can reduce occupancy or spill into local memory backed by device memory.

Shared memory and registers are distinct resources, not nested boxes. Fusion tries to retain intermediates on chip, but register pressure, shared-memory limits, and synchronization can constrain the benefit.

Compare the capacity and sharing scope of each storage level.

Arithmetic intensity is the workload's arithmetic operations divided by the bytes transferred across the memory boundary being studied. The roofline compares that intensity with peak compute and bandwidth. A small BF16 matrix-vector product may perform roughly two FLOPs per two-byte weight read; a matrix product over many token positions reuses each weight more.

Roofline — A100 80GB SXM (dense FP16)
ridge at 156 FLOPs / byte

Idealized ceilings and workload intensities, not measured throughput. Kernel overheads and other memory traffic lower achieved performance.

1416642561k4k1416642561024arithmetic intensity (FLOPs / byte)performance ceiling (TFLOP/s)MEMORY-BOUNDCOMPUTE-BOUND

decode (batch 1)

Arithmetic intensity: 1 FLOPs/byte.

Idealized ceiling: 2.0 TFLOP/s (0.6% of peak).

Fusion can remove bytes or launch overhead; register pressure and occupancy can offset those savings.

Fusing bias and activation into a matrix-multiply epilogue avoids writing and rereading the intermediate result. The original inputs and weights must still be read, and the final output written.

y = ReLU(xW + b)

  1. 1. Matrix multiplication: xW

    ↓ Write the intermediate to global memory; the next kernel reads it.

  2. 2. Add bias: + b

    ↓ Write the intermediate to global memory; the next kernel reads it.

  3. 3. Apply ReLU: max(0, ·)

    Write the final output to global memory.

Intermediate full-tensor writes: 2; reads: 2.

This counts only intermediate traffic. Both versions still read inputs and weights and write the result; bias reads also remain. Whether an epilogue can be fused efficiently depends on the operation, layout, and kernel. Arbitrary attention and projection chains cannot be assumed to fuse this way.

Follow the intermediate tensors to count which memory round trips fusion removes.

Softmax needs a global row statistic

For a row of scores ss, stable softmax computes

pi=exp⁡(si−m)∑jexp⁡(sj−m),m=max⁡jsj.p_i=\frac{\exp(s_i-m)}{\sum_j\exp(s_j-m)},\qquad m=\max_j s_j.

An unfused implementation might read the row for its maximum, read it again to write exponentials, read those exponentials for their sum, then read them again to write normalized output. Ignoring the small reduction vectors, that is four reads and two writes of row-sized arrays.

A fused kernel can read and write the row once if it can retain the needed values on chip. If a row is too large, the algorithm needs tiling or additional passes. Normalizing each tile independently would be wrong because the denominator must include the whole row.

Row-wise softmax of an N × N matrix: global-memory passes

  1. 1. Find row maximum

    Read S and reduce each row. Store one maximum per row.

    1 full read + 0 full write

  2. 2. Exponentiate S − max

    Read S again and write the full intermediate E.

    1 full read + 1 full write

  3. 3. Sum exponentials

    Read E and reduce each row. Store one sum per row.

    1 full read + 0 full write

  4. 4. Normalize

    Read E again and write O = E / row_sum.

    1 full read + 1 full write

Unfused, after selected passes

6 × N² elements = 12 × N² bytes at 16 bits

Fused, whole operation

2 × N² elements = 4 × N² bytes at 16 bits

The fused case reads each row once, retains it on chip for max, exp, sum, and division, then writes once. This requires the row to fit the kernel’s on-chip strategy. Counts omit O(N) row statistics and cache effects. They are traffic accounting, not runtime or FLOP measurements.

The visualization compares explicitly counted intermediate transfers. Its simplified traffic model excludes reduction vectors and hardware caching effects.

FlashAttention: avoid the full score matrix

Dense attention computes softmax(QK⊤/d)V\mathrm{softmax}(QK^\top/\sqrt d)V. Materializing the score matrix for one head needs N2N^2 elements. At FP16, N=8192N=8192 means 128 MiB for that matrix alone.

FlashAttention works on tiles and maintains online softmax statistics. It avoids storing the complete score and probability matrices in HBM. Its auxiliary memory is linear in sequence length, but its total HBM traffic is not universally linear: keys and values can be loaded repeatedly for different query tiles. The original paper analyzes an IO bound proportional to N2d2/MN^2d^2/M over its stated SRAM-capacity regime. FlashAttention paper.

Resident tensor sizes, one head, d = 128, FP16

Q, K, V, O2.00 MiB
One materialized score matrix8.00 MiB

FlashAttention avoids storing the full N × N matrix in HBM. Its tile buffers and row statistics require additional space. These bars show storage, not total memory traffic: tiled attention may read K and V more than once.

Compare resident tensor sizes. These bars do not count every read and write made by a tiled kernel.

For one query tile, retain a running maximum mm, normalization sum ℓ\ell, and unnormalized output numerator aa. When the next score tile has maximum mtm_t, use m′=max⁡(m,mt)m'=\max(m,m_t) and rescale the old accumulators by α=exp⁡(m−m′)\alpha=\exp(m-m'):

ℓ′=αℓ+∑jexp⁡(sj−m′),a′=αa+∑jexp⁡(sj−m′)Vj.\ell'=\alpha\ell+\sum_j\exp(s_j-m'),\qquad a'=\alpha a+\sum_j\exp(s_j-m')V_j.

After all key tiles, return a/ℓa/\ell. The rescaling makes earlier and later tiles share one softmax denominator.

FlashAttention tiling — never materialize N×N
Q-block 1 / 4 · K/V-block 1 / 4
QQᵢKᵀKⱼVᵀVⱼSᵢⱼ (in SRAM only)OOᵢLOADload K[0], V[0] into SRAMtiles processed: 0 / 16V shown transposed; unmasked toy

Scroll horizontally to inspect the diagram.

Query and key/value tiles contribute to one running row normalization. Tile-local probabilities cannot be normalized independently and then simply concatenated.

FlashAttention computes the same attention function, subject to floating-point differences from the order and precision of its operations. Output comparisons therefore need an appropriate numerical tolerance.

A small executable reference

This CPU example demonstrates the online reduction for one unmasked query. It is a correctness sketch, not a GPU implementation or a speed benchmark.

import math
import numpy as np
 
rng = np.random.default_rng(7)
q = rng.normal(size=4)
k = rng.normal(size=(11, 4))
v = rng.normal(size=(11, 3))
 
scores = k @ q / math.sqrt(q.size)
prob = np.exp(scores - scores.max())
prob /= prob.sum()
reference = prob @ v
 
maximum = -np.inf
normalizer = 0.0
numerator = np.zeros(v.shape[1])
for start in range(0, len(k), 3):
    tile_scores = scores[start:start + 3]
    new_maximum = max(maximum, tile_scores.max())
    correction = np.exp(maximum - new_maximum)
    tile_exp = np.exp(tile_scores - new_maximum)
    normalizer = correction * normalizer + tile_exp.sum()
    numerator = correction * numerator + tile_exp @ v[start:start + 3]
    maximum = new_maximum
 
result = numerator / normalizer
np.testing.assert_allclose(result, reference, rtol=1e-12, atol=1e-12)
print(result)

A causal kernel must also apply the correct mask, including partially masked tiles, and safely handle rows with no valid keys. Production implementations add parallel work partitioning, precision choices, and hardware-specific data movement.

FlashAttention-2 improves work partitioning and reduces non-matmul overhead. FlashAttention-3 exploits Hopper's asynchronous execution and low-precision facilities. FlashAttention-2, FlashAttention-3.

The attention operation ends with the weighted value sum; the transformer's learned output projection is a separate operation unless an implementation explicitly combines it. Likewise, an entire gated MLP is not necessarily one kernel. Inspect the actual execution trace before deciding which intermediate tensors were eliminated.

Next, PagedAttention addresses a different memory problem: where growing per-request KV data lives.

Additional reading