← Back to Table of Contents

Chapter 29 β€” Memory Hierarchy in Action: KV-Cache, Batching & Activations

Chapter 9 gave you the memory hierarchy β€” registers, SRAM, HBM, NVMe β€” as a stack of speed-versus-capacity tradeoffs. Chapter 11 gave you the roofline: the single number, arithmetic intensity, that tells you whether a kernel waits on the multipliers or on the wires. This chapter is where those two ideas stop being theory and start being the spreadsheet you keep open while you size a deployment. Every β€œwill it fit?”, every β€œhow many requests can I batch?”, every β€œwhy did my throughput collapse at 8k context?” is the memory hierarchy talking to you directly.

We will work through the three places the hierarchy bites hardest in practice: the KV-cache (the runtime memory that grows with context and batch, and routinely exceeds the weights), batching (the lever that drags memory-bound decode back toward compute-bound), and activation memory in training (the hidden tensor mountain that dwarfs your parameters at long sequence length). For each, we derive the byte math from first principles, plug in real model and hardware numbers, and write Python you can run to size your own case.

Throughout, our reference hardware is the NVIDIA H100 SXM: 80 GB HBM3 at ~3.35 TB/s, ~989 TFLOP/s dense BF16, ridge point β‰ˆ 295 FLOP/byte (from Chapter 11). Substitute your own GPU’s two numbers β€” capacity (GB) and bandwidth (TB/s) β€” and every formula here still holds.

The one-sentence version: LLM serving is an exercise in spending a fixed HBM budget β€” weights, KV-cache, and activations all compete for the same gigabytes and the same terabytes-per-second, and almost every serving decision you make is really a decision about which of those three to shrink.


Recap: Why Decode Is Bandwidth-Bound (and the Token-Rate Ceiling)

Autoregressive decode generates one token per forward pass. To produce that one token, the GPU must read every weight of the model from HBM exactly once (at batch size 1). The arithmetic per token is tiny β€” a stack of matrix-vector products (GEMVs), arithmetic intensity β‰ˆ 1 FLOP/byte β€” so the multipliers sit mostly idle and the HBM bus is the bottleneck. This is the memory wall in its purest form.

That gives a hard ceiling on token rate that depends only on two numbers:

\[\text{tokens/s} \;\lesssim\; \frac{\text{HBM bandwidth (bytes/s)}}{\text{bytes read per token}}\]

The denominator is β€œbytes read per token,” which at batch=1 is essentially the model size in bytes (weights), plus the KV-cache bytes you must stream for attention. The weights dominate, so to first order the ceiling is HBM_BW / model_bytes.

The Decode Token-Rate Ceiling (batch = 1)
Model weights
must stream all per token
Γ· HBM bandwidth
3.35 TB/s on H100
= time/token
β†’ tokens/s ceiling

Worked Example: A 70B Model at INT8

A 70-billion-parameter model quantized to INT8 (1 byte/weight) occupies ~70 GB of weights. Per generated token, all 70 GB must traverse the HBM bus:

\[t_{\text{token}} \approx \frac{70 \times 10^{9}\ \text{bytes}}{3.35 \times 10^{12}\ \text{bytes/s}} \approx 20.9\ \text{ms} \quad\Rightarrow\quad \approx 48\ \text{tokens/s}\]

That ~48 tok/s is a ceiling no amount of tensor-core horsepower can lift, because the tensor cores are not the constraint. The table below shows how the ceiling moves with precision and model size β€” read it as β€œthe only two knobs that change decode speed at batch=1 are fewer bytes per weight and fewer weights.”

Model Precision Bytes/weight Weight bytes HBM read/token tok/s ceiling (3.35 TB/s)
7B BF16 2 14 GB 14 GB ~239
7B INT8 1 7 GB 7 GB ~479
7B INT4 0.5 3.5 GB 3.5 GB ~957
70B BF16 2 140 GB (won’t fit on 80 GB) β€”
70B INT8 1 70 GB 70 GB ~48
70B INT4 0.5 35 GB 35 GB ~96

Note the immediate practical consequence: a 70B model in BF16 (140 GB) does not even fit in one 80 GB GPU, let alone run fast. Quantization here buys two things at once β€” it makes the model fit (capacity) and stream faster (bandwidth). This is why local/single-GPU LLM serving lives at INT4/INT8, a thread we pick up quantitatively in Chapter 25 and turn into a decision rule in Chapter 30.

The ceiling is optimistic β€” it ignores the KV-cache reads, attention, kernel-launch overhead, and the fact that you never hit 100% of peak bandwidth (70–85% is typical). Real throughput is below it. But it tells you the order of magnitude before you write a line of serving code.


The KV-Cache: The Memory That Grows With Your Conversation

What It Is and Why It Exists

In attention, each new token must attend to all previous tokens. Naively, generating token $t$ would require recomputing the keys ($K$) and values ($V$) for tokens $1 \ldots t-1$ on every step β€” an $O(t^2)$ blow-up that would make long generations quadratically slow. The KV-cache is the fix: after a token’s $K$ and $V$ vectors are computed once, they are stored in HBM and reused for every subsequent step. Decode then only computes $K, V$ for the single new token and reads the cached rest.

This trades compute for memory β€” the canonical hardware bargain. The cost is that the cache is runtime memory: it did not exist when you loaded the model, it grows with every token, and it grows with every concurrent request.

KV-Cache: Store Once, Reuse Every Step
Without KV-cache
  • Token t recomputes K,V for all 1…t
  • Per-token cost grows as O(t), total O(TΒ²)
  • No extra memory, but compute explodes
With KV-cache
  • Compute K,V for the new token only
  • Read cached K,V for the past β€” per-token compute ~constant
  • Extra HBM that grows linearly with sequence & batch

The KV-Cache Size Formula

For a single sequence, the cache stores a $K$ and a $V$ tensor at every layer, for every cached position. The size in bytes is:

\[\text{KV bytes} = 2 \times L \times n_{kv} \times d_{head} \times \text{seq} \times \text{batch} \times \text{bytes}\]

where:

Symbol Meaning
$2$ one $K$ tensor and one $V$ tensor
$L$ number of transformer layers
$n_{kv}$ number of key/value heads (= query heads for MHA; fewer for GQA/MQA)
$d_{head}$ dimension per head (often $d_{model}/n_{heads}$)
$\text{seq}$ number of cached positions (prompt + generated so far)
$\text{batch}$ concurrent sequences
$\text{bytes}$ bytes per element (2 for FP16/BF16, 1 for INT8/FP8)

Note $n_{kv} \times d_{head}$ is the total KV width per token per layer. For a model with multi-head attention (MHA), $n_{kv} = n_{heads}$ and this equals $d_{model}$. For grouped-query attention (GQA) it is much smaller β€” which is the whole point (below).

Worked Example: Llama-3-style 8B at FP16

Take an 8B model with $L = 32$ layers, $d_{model} = 4096$, $n_{heads} = 32$, $d_{head} = 128$, and GQA with $n_{kv} = 8$ key/value heads. Per token per layer the cache stores $2 \times n_{kv} \times d_{head} = 2 \times 8 \times 128 = 2048$ elements. Across 32 layers that is $32 \times 2048 = 65{,}536$ elements/token, or at FP16 (2 bytes) 128 KB per token.

\[\text{KV bytes/token} = 2 \times 32 \times 8 \times 128 \times 2 = 131{,}072\ \text{bytes} = 128\ \text{KB}\]

Now scale by context and batch:

Context (seq) Batch KV-cache size vs 16.1 GB weights (FP16)
2,048 1 0.27 GB 1.7%
8,192 1 1.07 GB 6.7%
32,768 1 4.29 GB 27%
128,000 1 16.78 GB ~104% (β‰ˆ the weights!)
8,192 32 34.4 GB ~2Γ— the weights
8,192 64 68.7 GB ~4Γ— the weights

The headline: at 128k context, the KV-cache for one sequence nearly equals the entire model’s weights. At batch 64 and 8k context, the KV-cache is four times the size of the weights. This is why long context and high concurrency are β€œmemory-hungry” β€” it is not vague; it is this formula. The KV-cache, not the weights, is usually what determines how many requests you can serve at once.

The Same Model With Multi-Head Attention (No GQA)

Had this model used full MHA ($n_{kv} = 32$ instead of 8), every number above multiplies by 4Γ—. The 8,192-token, batch-32 case would need ~137 GB of KV-cache β€” impossible on an 80 GB GPU. This single architectural choice (GQA) is the difference between β€œserves 64 concurrent users” and β€œOOMs at 16.”

Reducing the KV-Cache: GQA/MQA, Quantization, Paging, Prefix Reuse

Because the cache so often dominates, there is a whole toolbox aimed squarely at the four factors in the formula:

Four Levers on the KV-Cache Formula
Shrink n_kv β†’ GQA / MQA
Multi-Query (n_kv=1) and Grouped-Query (n_kv=4–8) share K/V across query heads. A 32β†’8 head reduction is a 4Γ— KV cut with negligible quality loss β€” now standard in Llama-3, Mistral, etc.
Shrink bytes β†’ KV quantization
Store K/V at INT8 or FP8 instead of FP16 β†’ 2Γ— cut; some systems push to INT4 for 4Γ—. Targets long context specifically. See Ch 25.
Stop wasting it β†’ PagedAttention
Allocate the cache in fixed blocks (pages) like OS virtual memory (Ch 10) instead of one contiguous slab per request. Kills fragmentation; lifts usable batch 2–4Γ—.
Don't recompute β†’ prefix caching
Shared prompt prefixes (system prompts, few-shot examples) are cached once and reused across requests β€” saves prefill compute and KV memory for the shared span.

GQA/MQA ([grouped/multi-query attention]) directly attack $n_{kv}$. With $n_{heads}$ query heads but only $n_{kv}$ key/value heads, several query heads share one K/V head. MQA ($n_{kv}=1$) is the extreme; GQA ($n_{kv}=4$ or $8$) is the sweet spot that keeps quality. The KV-cache shrinks by exactly $n_{heads}/n_{kv}$.

KV-cache quantization attacks the bytes factor. Keys and values are activations, so they are harder to quantize than weights (outliers β€” see Chapter 25), but per-token or per-channel scaling makes INT8/FP8 KV practical, halving the cache. It is the right lever precisely when context is long (the cache, not the weights, is your capacity problem).

PagedAttention (the idea behind vLLM) attacks waste. A naive serving system reserves a contiguous buffer sized for each request’s maximum length, so a request that generates 100 tokens but was sized for 8,192 wastes 98% of its reservation β€” internal fragmentation. Paging the cache into small fixed blocks (e.g., 16 tokens) and mapping them through a block table β€” exactly the virtual-memory trick from Chapter 10 β€” lets memory be allocated on demand, raising the number of concurrent sequences 2–4Γ—.

Prefix caching attacks redundancy: many requests share a long system prompt or few-shot preamble. Compute its KV once, reuse it for every request that shares the prefix. Saves both the prefill FLOPs and the duplicate KV bytes.


Batching: Trading Latency for Throughput by Raising Arithmetic Intensity

Why Batching Works

We saw in Chapter 11 that decode arithmetic intensity at batch $B$ is approximately:

\[\text{AI}_{\text{decode}}(B) \approx B \quad \text{(FLOP/byte)}\]

because $B$ sequences all multiply against the same weight matrix that was read from HBM only once. You read the weights once and do $B$ times the arithmetic. Below the ridge point (~295 on H100), throughput scales nearly linearly with $B$; above it, you become compute-bound and further batching stops helping per-token speed.

Batching Walks Decode Up the Roofline
B=1
AIβ‰ˆ1, memory-bound
weights wasted
B=32
AIβ‰ˆ32, still memory-bound
32Γ— the work/byte
Bβ‰ˆ295
at the ridge
balanced
B=1024
compute-bound
tensor cores saturated

The Throughput vs Latency Tradeoff

Batching is free throughput but not free latency. Two metrics pull in opposite directions:

Metric What it is Effect of larger batch
Per-token latency (TPOT) time to emit one token for a given request rises modestly until ridge, then linearly
Throughput (total tok/s across all requests) aggregate generation rate rises ~linearly until ridge, then flat
Time-to-first-token (TTFT) latency of the prefill rises with queue depth / batch

A single user wants low TPOT and TTFT β†’ small batch. A serving fleet wants maximum aggregate tok/s per GPU-dollar β†’ large batch. The same hardware serves both, just at different points on the roofline. This is the central tension every inference team negotiates, and Chapter 30 turns it into explicit scenarios.

A useful way to hold it: batching converts idle HBM bandwidth into throughput. At batch=1 you pay for 3.35 TB/s and use it to stream weights you immediately discard; batching makes each of those expensive byte-reads do $B$ tokens’ worth of work.

Continuous (In-Flight) Batching

Classic β€œstatic” batching waits to assemble $B$ requests, runs them lockstep until all finish, then starts the next batch β€” so one long generation stalls a whole batch of short ones, wrecking utilization. Continuous batching (a.k.a. in-flight or iteration-level batching) instead schedules at the granularity of a single decode step: finished sequences leave the batch and new ones join every iteration. Combined with PagedAttention, this keeps the GPU near its target batch size at all times and is why modern servers (vLLM, TensorRT-LLM, TGI) get multiples of the throughput of naive batching.

The constraint on how large the running batch can grow is HBM capacity, and the thing that fills it is β€” once more β€” the KV-cache. Continuous batching and PagedAttention exist to pack as many concurrent KV-caches as possible into the gigabytes left over after the weights.


Activation Memory in Training: The Hidden Mountain

Inference is dominated by weights + KV-cache. Training adds a third, often largest, consumer: activations β€” the intermediate tensors saved during the forward pass so the backward pass can compute gradients. (We met the parameter/optimizer/gradient side of the training memory budget in Chapter 28; this is the fourth pillar.)

Why Activations Dominate at Long Sequence Length

For a transformer, the activations saved per layer scale with batch Γ— sequence Γ— hidden size, and the attention activations scale with batch Γ— heads Γ— sequenceΒ² before fusion. Crucially, activation memory grows with sequence length while parameter memory does not. So as context grows, activations come to dominate.

A rough per-layer activation estimate (forward, pre-checkpointing) is on the order of:

\[\text{act bytes/layer} \approx c \times B \times \text{seq} \times d_{model} \times \text{bytes}\]

with $c \approx 10\text{–}30$ depending on how many intermediate tensors the implementation stores (QKV, attention output, MLP intermediate, norms, etc.).

Worked example. A model with $d_{model}=4096$, $L=32$, training in BF16 at $B=8$, $\text{seq}=4096$, with $c \approx 16$:

\[\text{act/layer} \approx 16 \times 8 \times 4096 \times 4096 \times 2 \approx 4.3\ \text{GB} \quad\Rightarrow\quad \times 32\ \text{layers} \approx 137\ \text{GB}\]

That 137 GB of activations dwarfs the ~16 GB of weights and would never fit. Double the sequence to 8,192 and it doubles to ~274 GB. This is why long-context training is an activation-memory problem first and a compute problem second.

Activation / Gradient Checkpointing: Recompute Instead of Store

The fix is the same bargain as the KV-cache, run in reverse: trade compute for memory by not storing most activations. With activation (gradient) checkpointing, you save only a few activations per layer (the β€œcheckpoints”) and recompute the rest during the backward pass by re-running the forward of that segment.

Checkpointing: Memory ↔ Compute Trade
Store everything (default)
  • Activation memory ~ O(L) β€” all layers kept
  • Backward needs no recompute
  • OOMs at long sequence / large batch
Checkpoint (recompute)
  • Activation memory ~ O(√L) or O(1) per segment
  • ~+33% compute (one extra forward) for big memory savings
  • Recompute is cheap because the forward is often memory-bound anyway

The classic result: checkpointing every layer reduces activation memory from $O(L)$ to roughly $O(\sqrt{L})$ at the cost of one extra forward pass (~33% more compute). In PyTorch it is a one-line wrap with torch.utils.checkpoint.checkpoint. The recompute is comparatively cheap because β€” as Chapter 11 showed β€” much of the forward pass is memory-bound, so the β€œextra” FLOPs are partly hidden behind bandwidth you were paying for anyway.

Offloading: Spilling to CPU and NVMe

When even checkpointing is not enough, you can offload tensors down the hierarchy from Chapter 9: push optimizer states, gradients, or activations to CPU DRAM (~50–100 GB/s over PCIe) or even NVMe (~3–14 GB/s). This is what ZeRO-Offload / ZeRO-Infinity do (Chapter 28). It buys capacity at a steep bandwidth cost: moving a tensor to CPU and back over PCIe 5.0 (~64 GB/s) is ~50Γ— slower than HBM, so offloading is a β€œfit it at all” tool, not a β€œmake it fast” tool. The art is overlapping the transfers with compute so the PCIe latency hides behind the GPU’s work.


Prefill vs Decode: Two Workloads, Two Optimizations

A single LLM request has two phases with opposite roofline character, and conflating them is a common and expensive mistake.

One Request, Two Regimes
Prefill (process the prompt)
All prompt tokens at once β†’ big GEMM (matrixΓ—matrix), AI high β†’ compute-bound. Builds the initial KV-cache. Determines time-to-first-token. Optimize FLOPs: FlashAttention, FP8/INT8 matmul, tensor-core utilization.
Decode (generate tokens)
One token at a time β†’ GEMV (matrixΓ—vector), AIβ‰ˆ1 β†’ memory-bound. Streams all weights + KV per token. Determines tokens/s. Optimize bytes: quantize weights & KV, batch hard, GQA.
Aspect Prefill Decode
Operation shape GEMM ($\text{seq} \times d$) GEMV ($1 \times d$) per step
Arithmetic intensity high (compute-bound) β‰ˆ1 (memory-bound)
Bottleneck tensor-core FLOP/s HBM bandwidth
Latency metric time-to-first-token (TTFT) time-per-output-token (TPOT)
Best levers FlashAttention, low-precision matmul weight + KV quantization, batching, GQA

Chunked prefill is the modern reconciliation: a long prompt’s prefill is broken into chunks and interleaved with ongoing decode steps of other requests in the same iteration. This stops a big prefill from blocking everyone’s decode (a TTFT-vs-TPOT fairness fix) and keeps both the tensor cores (prefill) and the bandwidth (decode) busy simultaneously β€” using the one request type to fill the roof the other leaves empty.


Python: Sizing the Memory Hierarchy

The three calculators below are the ones you actually reach for: KV-cache sizing across (model, seq, batch, dtype), the decode token-rate ceiling, and the batch-vs-arithmetic-intensity curve. They have no dependencies beyond the standard library.

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
from dataclasses import dataclass

# ---- Hardware (NVIDIA H100 SXM; swap for your GPU) ----
HBM_CAPACITY_GB = 80
HBM_BW_BYTES    = 3.35e12      # 3.35 TB/s
PEAK_FLOPS_BF16 = 989e12       # dense BF16 tensor-core FLOP/s
RIDGE = PEAK_FLOPS_BF16 / HBM_BW_BYTES   # β‰ˆ 295 FLOP/byte

@dataclass
class Model:
    name: str
    n_params: float       # total parameters
    n_layers: int         # L
    d_model: int
    n_heads: int          # query heads
    n_kv_heads: int       # key/value heads (= n_heads for MHA, fewer for GQA/MQA)
    @property
    def d_head(self) -> int:
        return self.d_model // self.n_heads

# A few reference models
LLAMA3_8B  = Model("Llama-3-8B",  8.03e9, 32, 4096, 32, 8)    # GQA 8 kv-heads
LLAMA3_70B = Model("Llama-3-70B", 70.6e9, 80, 8192, 64, 8)    # GQA 8 kv-heads
MISTRAL_7B = Model("Mistral-7B",  7.24e9, 32, 4096, 32, 8)


def kv_cache_bytes(m: Model, seq: int, batch: int, bytes_per_elem: int = 2) -> int:
    """KV-cache size = 2 * L * n_kv * d_head * seq * batch * bytes."""
    return 2 * m.n_layers * m.n_kv_heads * m.d_head * seq * batch * bytes_per_elem


def weight_bytes(m: Model, bytes_per_weight: float = 2.0) -> float:
    """Model weight footprint in bytes (2=BF16, 1=INT8, 0.5=INT4)."""
    return m.n_params * bytes_per_weight


def decode_tokens_per_sec_ceiling(m: Model, bytes_per_weight: float = 2.0,
                                  seq: int = 0, batch: int = 1,
                                  kv_bytes_per_elem: int = 2) -> float:
    """
    Upper bound on decode tok/s at batch=1: HBM_BW / bytes_read_per_token.
    bytes_read = all weights + (optionally) the KV-cache streamed for attention.
    """
    wb = weight_bytes(m, bytes_per_weight)
    kv = kv_cache_bytes(m, seq, batch, kv_bytes_per_elem) if seq else 0
    bytes_per_token = wb + kv
    return HBM_BW_BYTES / bytes_per_token * batch  # Γ—batch: each step emits `batch` tokens


def decode_arith_intensity(batch: int) -> float:
    """AI of decode β‰ˆ batch (weights read once, reused across the batch)."""
    return float(batch)


def fits(m: Model, seq: int, batch: int,
         bytes_per_weight: float = 2.0, kv_bytes_per_elem: int = 2,
         capacity_gb: float = HBM_CAPACITY_GB) -> bool:
    total = weight_bytes(m, bytes_per_weight) + kv_cache_bytes(m, seq, batch, kv_bytes_per_elem)
    return total <= capacity_gb * 1e9


# ---------- 1) KV-cache across context & batch ----------
print(f"{'='*64}\nKV-cache for {LLAMA3_8B.name} (FP16 KV), weights={weight_bytes(LLAMA3_8B)/1e9:.1f} GB")
print(f"{'context':>9} {'batch':>6} {'KV (GB)':>10} {'% of weights':>14}")
for seq in (2048, 8192, 32768, 128000):
    for batch in (1, 32, 64):
        kv = kv_cache_bytes(LLAMA3_8B, seq, batch) / 1e9
        pct = 100 * kv / (weight_bytes(LLAMA3_8B) / 1e9)
        print(f"{seq:>9} {batch:>6} {kv:>10.2f} {pct:>13.0f}%")

# ---------- 2) Decode token-rate ceiling ----------
print(f"\n{'='*64}\nDecode tok/s ceiling (weights only, batch=1)")
print(f"{'model':>14} {'BF16':>8} {'INT8':>8} {'INT4':>8}")
for m in (MISTRAL_7B, LLAMA3_8B, LLAMA3_70B):
    bf16 = decode_tokens_per_sec_ceiling(m, 2.0)
    int8 = decode_tokens_per_sec_ceiling(m, 1.0)
    int4 = decode_tokens_per_sec_ceiling(m, 0.5)
    print(f"{m.name:>14} {bf16:>8.0f} {int8:>8.0f} {int4:>8.0f}")

# ---------- 3) Batch vs arithmetic intensity ----------
print(f"\n{'='*64}\nBatch vs arithmetic intensity (ridge β‰ˆ {RIDGE:.0f} FLOP/byte)")
print(f"{'batch':>6} {'AI':>8} {'regime':>14}")
for b in (1, 8, 32, 128, 256, 295, 512, 1024):
    ai = decode_arith_intensity(b)
    regime = "memory-bound" if ai < RIDGE * 0.9 else ("compute-bound" if ai > RIDGE * 1.1 else "balanced")
    print(f"{b:>6} {ai:>8.0f} {regime:>14}")

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
================================================================
KV-cache for Llama-3-8B (FP16 KV), weights=16.1 GB
  context  batch    KV (GB)   % of weights
     2048      1       0.27             2%
     8192      1       1.07             7%
     8192     32      34.36           214%
     8192     64      68.72           428%
   128000      1      16.78           104%
================================================================
Decode tok/s ceiling (weights only, batch=1)
         model     BF16     INT8     INT4
    Mistral-7B      231      463      925
    Llama-3-8B      209      417      834
   Llama-3-70B       24       47       95
================================================================
Batch vs arithmetic intensity (ridge β‰ˆ 295 FLOP/byte)
 batch       AI         regime
     1        1   memory-bound
    32       32   memory-bound
   256      256   memory-bound
   295      295       balanced
   512      512  compute-bound

Read the three tables together and the whole chapter is in front of you: KV-cache can exceed the weights (table 1), so it caps your batch; decode speed is set by weight bytes alone at batch=1 (table 2), so precision is your speed knob; and you must batch into the hundreds to escape the memory-bound regime (table 3), which is exactly what the KV-cache capacity in table 1 is fighting you on.


Putting It Together: The HBM Budget Equation

Everything in this chapter reduces to one accounting identity. The GPU’s HBM must simultaneously hold:

\[\text{HBM used} = \underbrace{\text{weights}}_{\text{fixed}} + \underbrace{\text{KV-cache}}_{\propto\, \text{seq} \times \text{batch}} + \underbrace{\text{activations / workspace}}_{\text{transient}} \;\le\; \text{HBM capacity}\]
1
2
3
4
5
β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€ 80 GB HBM (H100) ────────────────────────┐
β”‚ Weights (fixed)      β”‚ KV-cache (grows with seq Γ— batch) β”‚ work β”‚
β”‚ e.g. 70B INT8 = 70GB β”‚  ◄── this is what caps your batch ──►│ spaceβ”‚
β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜
        capacity problem            capacity + bandwidth problem
  • Quantizing weights (INT8/INT4) frees capacity and raises the decode ceiling (bandwidth). Pick this for β€œwon’t fit” and β€œtoo slow at batch=1.”
  • Quantizing the KV-cache (INT8/FP8) and using GQA free the capacity that limits your batch. Pick these for β€œOOMs at high concurrency / long context.”
  • PagedAttention + continuous batching reclaim wasted capacity, letting you run closer to the true budget. Pick these always (they are free).
  • Checkpointing / offloading trade compute or PCIe bandwidth for capacity in training. Pick these for β€œwon’t fit” during training.

Each lever buys back a specific scarce resource β€” capacity, bandwidth, or compute. Naming which one you are short of is the entire skill, and it is the subject of the next chapter.


Why This Matters for Model Optimization

The Memory Math Behind Every Serving Decision
"Will it fit?" is arithmetic
weights + KV-cache + workspace ≀ capacity. The KV term (2Β·LΒ·n_kvΒ·d_headΒ·seqΒ·batchΒ·bytes) is the one that surprises people β€” it can exceed the weights. Compute it before you deploy.
Decode speed = bytes Γ· bandwidth
At batch=1, tok/s β‰ˆ HBM_BW / model_bytes. The only knobs are fewer bytes/weight (quantize) and fewer weights (smaller/distilled model). Tensor-core peak is irrelevant here.
Batch to escape the memory wall
AI β‰ˆ batch. You must batch into the hundreds to reach the ridge. KV-cache capacity caps how far you can batch β€” which is why GQA/KV-quant/paging exist.
Prefill β‰  decode
Prefill is compute-bound (TTFT, optimize FLOPs); decode is memory-bound (TPOT, optimize bytes). Chunked prefill interleaves them so each fills the roof the other leaves empty.
Training: activations dominate
Activation memory grows with sequence length and dwarfs weights at long context. Checkpointing trades ~33% compute for O(√L) memory; offloading spills to CPU/NVMe.
Long context is a memory problem
KV-cache (inference) and activations (training) both scale with sequence. Doubling context roughly doubles each. "Support 128k context" is first a capacity-and-bandwidth budget question.

The practical workflow this chapter hands you:

  1. Before deploying, run the HBM budget equation. Sum weights + KV-cache (at your target seq and batch) + ~10% workspace. If it exceeds capacity, you have a capacity problem β†’ quantize weights, quantize KV, GQA, or shard (Chapter 28).
  2. Compute the decode ceiling (HBM_BW / model_bytes). If it is below your latency target, you have a bandwidth problem β†’ lower precision or a smaller model. No amount of tuning beats this ceiling.
  3. Find your batch target on the roofline. If throughput per GPU is too low, you are under-batching (memory-bound) β†’ enable continuous batching + PagedAttention and raise the batch until either you hit the ridge or KV-cache fills HBM.
  4. For training, account for activations separately and reach for checkpointing before offloading before sharding β€” cheapest resource first.

Key Takeaways

  • Decode is memory-bandwidth-bound. At batch=1 the token-rate ceiling is HBM_BW / model_bytes: a 70B INT8 model on an H100 (70 GB Γ· 3.35 TB/s) tops out near ~48 tok/s, regardless of tensor-core peak.
  • The KV-cache trades compute for memory and is sized by $2 \times L \times n_{kv} \times d_{head} \times \text{seq} \times \text{batch} \times \text{bytes}$. It grows with context and batch and routinely exceeds the weights (β‰ˆ98% at 128k context; 4Γ— the weights at batch 64 / 8k).
  • GQA/MQA cut $n_{kv}$ (a 32β†’8 head reduction is a 4Γ— KV cut), KV quantization cuts the byte width (2–4Γ—), PagedAttention kills fragmentation, and prefix caching removes redundant prefill β€” all to fit more concurrent sequences.
  • Batching raises arithmetic intensity (AI β‰ˆ batch). It buys throughput by amortizing weight reads, at the cost of per-token latency β€” the throughput-vs-latency tradeoff. Continuous batching keeps the GPU full; KV-cache capacity caps how far you can go.
  • Prefill is compute-bound (big GEMM, optimize FLOPs, governs TTFT); decode is memory-bound (GEMV, optimize bytes, governs TPOT). Chunked prefill interleaves them.
  • In training, activation memory grows with sequence length and dwarfs weights at long context; gradient checkpointing trades ~33% extra compute for $O(\sqrt{L})$ memory, and offloading spills to CPU/NVMe to fit at all.
  • Every serving decision is the HBM budget equation: weights + KV-cache + workspace ≀ capacity, streamed within the bandwidth ceiling. Name which of capacity / bandwidth / compute you are short of, and the right lever follows.

Next: Chapter 30 β€” How Datatypes Drive Your Daily Optimization Choices, where these memory and bandwidth budgets become a single decision framework: given your model, GPU, and regime, exactly which precision and technique to reach for.

← Back to Table of Contents