Chapter 11 β The Memory Wall & Bandwidth
You have just read how memory hierarchies are structured (Chapter 9) and how the operating system manages them (Chapter 10). Now we ask the question that ties everything together for machine learning: how fast can data actually move, and when does that speed β not compute β become the bottleneck?
The answer lies in one of the most important phenomena in computer architecture: the memory wall. Compute throughput has grown faster than memory bandwidth for decades. The gap between what hardware can calculate and what memory can supply has widened into a chasm. For AI inference β especially LLM token generation β this is not background trivia. It is the governing constraint on your system. Every quantization decision, every batching strategy, every kernel fusion trick is, at root, a response to the memory wall.
The one-sentence version: A hardware kernel is either limited by the speed at which it can do arithmetic (compute-bound) or the speed at which it can read and write memory (memory-bound), and for almost all LLM inference the answer is memory β which is why streaming fewer bytes (quantization, sparsity) speeds things up even when it adds computation.
The Memory Wall
In 1994, Wulf and McKee published a paper titled βHitting the Memory Wall: Implications of the Obvious.β The observation was simple but brutal: DRAM bandwidth and latency were improving far more slowly than processor FLOP rates. If you plotted the two on the same graph, the gap grew exponentially.
Here are the rough numbers that illustrate the trend:
| Year | Approx peak CPU FLOP/s | Approx DRAM bandwidth | Ratio (FLOP/byte) |
|---|---|---|---|
| 1990 | ~0.05 GFLOP/s | ~0.13 GB/s | ~0.4 |
| 2000 | ~3 GFLOP/s | ~1.6 GB/s (DDR1) | ~1.9 |
| 2010 | ~50 GFLOP/s | ~12 GB/s (DDR3) | ~4.2 |
| 2020 | ~3,000 GFLOP/s (GPU) | ~900 GB/s (HBM2) | ~3.3 |
| 2024 | ~989,000 GFLOP/s (H100 BF16) | ~3,350 GB/s (HBM3) | ~295 |
Compute has grown roughly 10,000Γ in that window. Bandwidth grew roughly 25,000Γ β faster, but starting from a much lower base. Today a modern GPU can execute ~295 floating-point operations for every single byte it fetches from main memory. As we will see in a moment, this number (the ridge point) is the dividing line between two very different worlds.
The memory wall is not a temporary engineering gap that will be closed next year. It reflects a deep physical asymmetry: transistors do arithmetic cheaply; moving electrical charge across long wires is expensive in energy and time. Memory is inherently less parallel than logic.
Bandwidth vs Latency β and How Latency Is Hidden
Before going further, we need to be precise about two often-confused terms.
- How much data moves per unit time
- Measured in GB/s or TB/s
- Determines throughput for streaming workloads
- Example: HBM3 ~3.35 TB/s, PCIe 4.0 x16 ~32 GB/s
- Scales with parallelism (more channels, wider bus)
- How long a single request takes
- Measured in nanoseconds or clock cycles
- Determines performance for random-access workloads
- Example: HBM ~100 ns, L1 cache ~1 ns
- Hard to shrink β constrained by physics and distance
A helpful analogy: a highway and a racing motorcycle. The highway has high bandwidth β many cars wide, many cars long. The motorcycle has low latency β it gets to the destination fast. A single load instruction cares about latency. A DMA transfer streaming an entire weight matrix cares about bandwidth.
For large ML workloads, bandwidth is almost always what matters, because we are streaming enormous arrays sequentially. A single matrix multiply reads $M \times K$ bytes from one operand and $K \times N$ bytes from another β at large sizes, this is gigabytes, and we can pipeline the requests to keep the memory bus saturated. Latency is hidden by parallelism and prefetching: the GPU launches thousands of concurrent threads, so while one warp is stalled waiting for a memory response, hundreds of others are computing. The latency becomes invisible behind the queue depth. This is called latency hiding through throughput, and it is one of the central architectural principles behind the GPUβs success at DNN workloads.
Latency still matters in two places: (1) small, irregular accesses β e.g., sparse index lookups, tree-traversal β where you cannot pipeline, and (2) the decode phase of LLM serving, where the KV cache lookup is effectively random-access per-token.
Arithmetic Intensity and the Roofline Model
Defining Arithmetic Intensity
For any computational kernel, we can define a single number that predicts which resource limits its performance:
\[\text{Arithmetic Intensity (AI)} = \frac{\text{Total FLOPs}}{\text{Total bytes transferred from/to memory}}\]The unit is FLOP/byte (sometimes called βoperational intensityβ). High AI = lots of math relative to data movement. Low AI = mostly shuffling bytes.
Letβs compute arithmetic intensity for a matrix multiply $C = A \cdot B$ where $A \in \mathbb{R}^{M \times K}$, $B \in \mathbb{R}^{K \times N}$, $C \in \mathbb{R}^{M \times N}$, all in FP16 (2 bytes/element):
| Quantity | Formula | Example $M=N=K=4096$ |
|---|---|---|
| Total FLOPs (multiply-adds) | $2 \cdot M \cdot N \cdot K$ | $2 \times 4096^3 \approx 137$ GFLOPs |
| Bytes read: $A$ + $B$ | $2 \cdot (M \cdot K + K \cdot N)$ bytes | $2 \times 2 \times 4096^2 \approx 67$ MB |
| Bytes written: $C$ | $2 \cdot M \cdot N$ bytes | $2 \times 4096^2 \approx 33$ MB |
| Total memory traffic | $2(MK + KN + MN)$ bytes | $\approx 100$ MB |
| Arithmetic Intensity | $\frac{2MNK}{2(MK+KN+MN)}$ | $\approx 1365$ FLOP/byte |
That is enormous β a square matmulβs arithmetic intensity grows as $O(M)$ (for large $M$, the byte count is $O(M^2)$ but FLOPs are $O(M^3)$). This is why large matrix multiplies are compute-bound on modern hardware.
The Roofline Model
The Roofline model (Williams, Waterman, Patterson β 2009) says: your kernelβs achievable performance is the minimum of two roofs.
\[\text{Attainable Performance} = \min\left(\text{Peak FLOP/s},\; \text{AI} \times \text{Peak Bandwidth}\right)\]Plotted on log-log axes with AI on the x-axis and attainable FLOP/s on the y-axis, this creates the characteristic βrooflineβ shape:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
Peak FLOP/s βββββββββββββββββββββββββββββββββββββββββββββββββββββββ β compute roof
(e.g. 989 TFLOP/s) β±
β± β "roofline"
β± (slope = bandwidth)
β±
β±
memory-bound region β± compute-bound region
β±
βββββββββ
β ridge β β AI* β 295 FLOP/byte
βββββββββ
ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββΊ AI (FLOP/byte)
0.1 1 10 100 300 1000 10000
(softmax) (LayerNorm) (GEMV) (large GEMM)
The Ridge Point
The ridge point $\text{AI}^*$ is where the two roofs meet β the arithmetic intensity above which a kernel becomes compute-bound:
\[\text{AI}^* = \frac{\text{Peak FLOP/s}}{\text{Peak Bandwidth}}\]For the NVIDIA H100 SXM (BF16, dense):
- Peak BF16 FLOP/s with Tensor Cores: ~989 TFLOP/s (989 Γ 10ΒΉΒ² FLOP/s)
- Peak HBM3 bandwidth: ~3.35 TB/s (3.35 Γ 10ΒΉΒ² bytes/s)
Any kernel with arithmetic intensity below ~295 FLOP/byte is memory-bound on the H100. Any kernel above ~295 FLOP/byte is compute-bound. This single number tells you more about what to optimize than almost anything else.
Letβs verify with our matmul example:
- $M=N=K=4096$, FP16: AI β 1,365 FLOP/byte β compute-bound (good, this is the training workload)
- $M=1, N=4096, K=4096$ (a single-token GEMV during LLM decode): AI β 1 FLOP/byte β memory-bound (the decode bottleneck)
Classifying ML Operations by Arithmetic Intensity
Letβs look at a few operations analytically:
Elementwise (ReLU, GELU, add): $M \times N$ elements, 1β2 FLOPs, 2β3 reads/writes per element. AI β 0.5β2 FLOP/byte. Solidly memory-bound.
LayerNorm over $[B, T, D]$: Must read the entire tensor twice (once to compute mean/variance, once to normalize). ~10 FLOPs/element, ~3 reads/writes. AI β ~1β5 FLOP/byte. Memory-bound.
Softmax over $[B, H, T, T]$ attention logits: Reads $B \times H \times T^2 \times 2$ bytes (FP16), ~5 FLOPs/element. AI β 2β3 FLOP/byte. Memory-bound.
GEMM (large, square): As derived above, AI β $M/3$ for $M=N=K$, so $M=4096$ gives AI β 1365 FLOP/byte. Compute-bound.
GEMV at batch=1 (LLM decode): $y = Wx$, $W \in \mathbb{R}^{N \times K}$: $2NK$ FLOPs, $(NK + N + K) \times 2$ bytes. For large $N,K$, AI β $\frac{2NK}{2NK} = 1$ FLOP/byte. Memory-bound.
The Key Insight: Why LLM Decode Is Memory-Bandwidth-Bound
This deserves its own section because it is the most important practical consequence of the roofline for practitioners.
During autoregressive token generation (the decode phase of an LLM), the model generates one token per forward pass. The forward pass of a transformer layer looks like:
1
2
3
4
5
6
For each layer:
- Attention: Q, K, V projections (GEMV: [d_model] Γ [d_model, d_head*n_heads])
- Attention score computation (small matmul, mostly memory-bound)
- KV cache read (streaming previous K, V tensors)
- Output projection (GEMV)
- FFN: two or three GEMVs (e.g. W1, W2, W_gate in SwiGLU)
Each layer contains roughly $12 \times d^2$ parameters (for $d$-dimensional model with standard sizes). For a 7B parameter model, $d \approx 4096$, and the total parameter count is ~7 billion, each stored as BF16 (2 bytes) = ~14 GB.
At decode with batch size 1, every single parameter must be read from HBM exactly once per token generated. That is 14 GB of memory reads. At H100βs HBM3 bandwidth of 3.35 TB/s:
\[t_{\text{token}} \approx \frac{14 \times 10^9 \text{ bytes}}{3.35 \times 10^{12} \text{ bytes/s}} \approx 4.2 \text{ ms/token}\]That gives roughly $1 / 0.0042 \approx 238$ tokens/second β which matches observed H100 decode throughput for 7B models at batch=1. The arithmetic units are largely idle; the bottleneck is the HBM bandwidth.
This is the single most important number in practical LLM serving: at batch=1, a 7B BF16 model on H100 is bounded to ~238 tokens/s by HBM bandwidth alone, regardless of how fast the tensor cores are.
How Batching Rescues Arithmetic Intensity
The fix is to increase batch size: if you decode $B$ sequences simultaneously, you do $B$ times more FLOPs while reading the same weight matrix once:
\[\text{AI}_{\text{decode}}(B) \approx \frac{2 \cdot B \cdot d_{\text{model}}^2}{\text{bytes per weight matrix}} = \frac{2B \cdot d_{\text{model}}^2}{2 \cdot d_{\text{model}}^2} = B\]At batch=1: AI β 1 FLOP/byte (memory-bound). At batch=32: AI β 32 FLOP/byte (still memory-bound but 32Γ better utilization). At batch=295: AI β 295 FLOP/byte (at the ridge point β compute and bandwidth co-limited). At batch=1024: AI β 1024 FLOP/byte (compute-bound β weight-reading is amortized).
This is why continuous batching and large batch sizes are so important for inference serving. It is not primarily about better GPU utilization in a vague sense β it is about moving arithmetic intensity past the ridge point.
Python: Arithmetic Intensity Calculator
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
import numpy as np
from dataclasses import dataclass
from typing import Literal
# H100 SXM5 specs (approximate)
H100_TFLOPS_BF16 = 989e12 # BF16 tensor-core FLOP/s (with sparsity: 1979e12)
H100_BW_HBM3 = 3.35e12 # bytes/s HBM3
@dataclass
class RooflineResult:
flops: int # total floating-point ops
bytes_transferred: int # total bytes read + written
arithmetic_intensity: float # FLOP/byte
bound: Literal["memory", "compute", "balanced"]
attainable_tflops: float # TFLOP/s achievable (roofline prediction)
peak_tflops: float # hardware peak TFLOP/s
peak_bandwidth_tbs: float # hardware peak bandwidth TB/s
ridge_point: float # FLOP/byte at ridge
def matmul_roofline(
M: int, K: int, N: int,
dtype_bytes: int = 2, # 2=FP16/BF16, 4=FP32, 1=INT8
peak_flops: float = H100_TFLOPS_BF16,
peak_bw: float = H100_BW_HBM3,
) -> RooflineResult:
"""
Compute arithmetic intensity and roofline prediction for [M,K] @ [K,N].
Assumes inputs read once, output written once (no fusion with preceding ops).
Ignores on-chip SRAM re-use (which improves AI in practice via tiling).
"""
flops = 2 * M * K * N # multiply-accumulate = 2 FLOPs each
bytes_in = dtype_bytes * (M * K + K * N)
bytes_out = dtype_bytes * (M * N)
total_bytes = bytes_in + bytes_out
ai = flops / total_bytes
ridge = peak_flops / peak_bw
# Roofline: min of compute ceiling and memory ceiling
attainable = min(peak_flops, ai * peak_bw)
if ai < ridge * 0.9:
bound = "memory"
elif ai > ridge * 1.1:
bound = "compute"
else:
bound = "balanced"
return RooflineResult(
flops=flops,
bytes_transferred=total_bytes,
arithmetic_intensity=ai,
bound=bound,
attainable_tflops=attainable / 1e12,
peak_tflops=peak_flops / 1e12,
peak_bandwidth_tbs=peak_bw / 1e12,
ridge_point=ridge,
)
def print_roofline(r: RooflineResult, label: str = ""):
print(f"\n{'β'*55}")
if label:
print(f" {label}")
print(f" FLOPs: {r.flops/1e9:>10.1f} GFLOPs")
print(f" Bytes: {r.bytes_transferred/1e6:>10.1f} MB")
print(f" AI: {r.arithmetic_intensity:>10.1f} FLOP/byte")
print(f" Ridge point: {r.ridge_point:>10.1f} FLOP/byte")
print(f" Bound: {r.bound.upper():>10}")
print(f" Attainable: {r.attainable_tflops:>10.1f} TFLOP/s "
f"(peak: {r.peak_tflops:.0f} TFLOP/s)")
# --- Examples ---
# 1. Large square GEMM (training, large batch)
r1 = matmul_roofline(M=4096, K=4096, N=4096, dtype_bytes=2)
print_roofline(r1, "Large GEMM M=N=K=4096, BF16")
# 2. LLM decode: single token through one FF layer (d_model=4096 β 11008)
# W: [4096, 11008], x: [1, 4096] β y: [1, 11008]
r2 = matmul_roofline(M=1, K=4096, N=11008, dtype_bytes=2)
print_roofline(r2, "LLM decode GEMV batch=1 (7B FF layer)")
# 3. LLM decode at batch=32
r3 = matmul_roofline(M=32, K=4096, N=11008, dtype_bytes=2)
print_roofline(r3, "LLM decode GEMV batch=32")
# 4. Same GEMV but quantized to INT8 (1 byte/weight)
r4 = matmul_roofline(M=1, K=4096, N=11008, dtype_bytes=1)
print_roofline(r4, "LLM decode GEMV batch=1, INT8 weights")
# 5. Batch sweep: show AI vs batch size for a single FF layer
print("\nBatch size sweep β AI for 7B FF projection [d_model=4096, N=11008]:")
print(f" {'Batch':>6} {'AI (FLOP/byte)':>16} {'Bound':>10}")
for b in [1, 2, 4, 8, 16, 32, 64, 128, 256, 512]:
r = matmul_roofline(M=b, K=4096, N=11008, dtype_bytes=2)
print(f" {b:>6} {r.arithmetic_intensity:>16.1f} {r.bound:>10}")
Sample output (abridged):
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
Large GEMM M=N=K=4096, BF16
FLOPs: 137.4 GFLOPs Bytes: 100.7 MB
AI: 1364.6 FLOP/byte Ridge: 295.2 FLOP/byte
Bound: COMPUTE
LLM decode GEMV batch=1 (7B FF layer)
FLOPs: 0.1 GFLOPs Bytes: 90.2 MB
AI: 1.0 FLOP/byte
Bound: MEMORY
LLM decode GEMV batch=32
AI: 31.9 FLOP/byte Bound: MEMORY
LLM decode GEMV batch=1, INT8 weights
AI: 2.0 FLOP/byte Bound: MEMORY (but 2Γ less data!)
Batch size sweep:
Batch AI (FLOP/byte) Bound
1 1.0 memory
8 7.9 memory
32 31.9 memory
128 127.6 memory
256 255.3 memory
512 510.7 compute
Note that INT8 at batch=1 is still memory-bound (AI β 2 vs ridge β 295), but it halves the byte count β so you get ~2Γ throughput at the same bandwidth. That is the mechanism behind quantization speedups for decode.
Operator Fusion and Why It Helps
When you chain multiple memory-bound operations β LayerNorm followed by a dropout followed by an add β the naive implementation reads and writes the full tensor for each step:
1
2
Naive: HBM β LayerNorm β HBM β Dropout β HBM β Add β HBM (4 round-trips)
Fused: HBM βββββββββββββββββββββββββββββββββββββββββββ HBM (1 round-trip)
Operator fusion (as done in FlashAttention, Triton kernels, torch.compile + inductor) keeps the intermediate results in SRAM (the GPUβs on-chip βL1 cacheβ / shared memory β ~192 KB/SM on H100) instead of bouncing them through HBM. The effective arithmetic intensity rises dramatically, because the numerator (FLOPs) stays the same but the denominator (HBM bytes) shrinks.
FlashAttention is the canonical example: the naive softmax attention reads/writes $O(T^2)$ bytes to HBM (the $T \times T$ attention score matrix, $T$ = sequence length). FlashAttention tiles the computation so intermediate scores live in SRAM, reducing HBM traffic to $O(T)$ β a massive win for long sequences.
Why This Matters for Model Optimization
The practical upshot, stated plainly:
- LLM decode is memory-bandwidth-bound. Quantize weights to save bytes; batch requests to amortize weight reads.
- LLM prefill is compute-bound (long sequences, large GEMMs). FlashAttentionβs memory savings are more important here than for decode.
- Training is compute-bound for large GEMM workloads. The bottleneck is tensor core utilization; FP8 saves a factor of 2 in bandwidth but the gain is primarily in compute throughput (BF16 β FP8 doubles tensor core throughput on H100).
- Activation memory during training is memory-bound (checkpointing saves HBM by re-computing; the re-compute is cheap because itβs memory-bound in the first place).
Key Takeaways
- The memory wall reflects a decades-long divergence: compute throughput grew faster than memory bandwidth. On an H100, the ridge point is ~295 FLOP/byte.
- Arithmetic intensity (FLOP/byte) classifies any kernel as memory-bound (AI < ridge) or compute-bound (AI > ridge). The Roofline model gives a tight upper bound on achievable performance.
- LLM autoregressive decode at batch=1 has arithmetic intensity ~1 FLOP/byte β solidly memory-bound. You are paying full HBM bandwidth for every parameter, every token.
- Batching raises arithmetic intensity proportionally. Doubling batch size doubles AI; the ridge point for a 7B model on H100 is around batch β 295.
- Quantization accelerates memory-bound kernels by reducing bytes streamed, not by reducing FLOPs. INT4 weights β 4Γ fewer bytes β up to 4Γ better decode throughput (bandwidth-limited).
- Operator fusion (FlashAttention, torch.compile, Triton) reduces HBM round-trips by keeping intermediates in on-chip SRAM, raising effective arithmetic intensity.
- FLOPs alone are a misleading performance metric. Always ask: which roof am I under?
Next: Chapter 12 β Buses & Interconnects, where we follow data as it moves between chips β from on-chip NoC fabrics to PCIe, NVLink, and cluster-scale networks.