GPU memory & the model
Every model you serve or train has a VRAM budget with four line items — weights, KV cache, activations, and (in training) optimizer state. Get the arithmetic right and you can predict, to the gigabyte, whether a model fits a card before you rent it.
Learning objectives
- Name the four consumers of VRAM and size each one.
- Do the precision math: bytes/param for fp32/fp16/bf16/fp8/int8/int4.
- Compute weight memory, KV-cache memory, and activation memory from first principles.
- Explain why inference is far cheaper in memory than training.
- Trade batch size and sequence length against the memory ceiling.
- Debug an OOM methodically instead of guessing.
30-second recap
- The inference cost model: Cost and latency come from two phases: prefill (reading the prompt, compute-bound) and decode (writing tokens one by one, memory-bound).
- Transformers: Attention lets every token look at every other token; stacking attention and feed-forward layers is the architecture behind every modern LLM.
1 · Where the VRAM actually goes essential
Here's the moment this lesson exists to prevent. You pick a model, you rent a GPU, you type the launch command — and thirty seconds later the log spits CUDA out of memory and the process dies. You try a bigger card. It happens again on a different request. Now you're guessing at hardware with the meter running, and every guess costs real money. The engineers who never hit this aren't luckier; they did the arithmetic before they rented anything, because GPU memory isn't mysterious — it's a budget with a fixed number of line items.
Think of the card's VRAM the way you'd think of a moving van with a strict weight limit. You can't just "hope it fits" — you add up the weight of every thing you're loading and compare it to the limit stamped on the side. A GPU is exactly that: an A100 gives you 40 or 80 GB, an H100 gives you 80, and if the sum of what you load crosses that line, nothing loads at all — it's a hard wall, not a slow-down. So the real skill isn't "make it faster," it's knowing what's in the van.
And the beautiful part is that there are only four things ever competing for that space, no matter how exotic the model:
- Weights — the model's actual learned numbers. A fixed, one-time cost the moment you load it, present whether you're serving or training.
- KV cache — scratch memory kept per token during generation. Zero at load, then it grows with every user and every token of context they send.
- Activations — intermediate results the backward pass must hold onto. Tiny at inference, large during training.
- Optimizer state — the bookkeeping an optimizer like Adam keeps per parameter. Zero at inference, often the biggest item when training.
The single most useful thing to internalize before the table below: serving and training pay different bills. Serving pays for weights + KV cache and little else, which is why one card can often serve a model. Training additionally pays for activations + gradients + optimizer state, which is why the same model that serves on one GPU needs a cluster to train. The common beginner mistake is assuming "if it loads, it trains" — it doesn't, and section 4 shows the gap is roughly 7×. Read the four line items as a checklist you run on every deployment: which boxes apply to my case, and do they sum under the ceiling?
A GPU's memory (VRAM) is a hard ceiling — an A100 has 40 or 80 GB, an H100 80 GB. When people say a model "doesn't fit", they mean the sum of four things exceeds that ceiling. Learn the four line items and you can predict fit before you launch:
| Consumer | When | Rough size |
|---|---|---|
| Weights | inference + training | params × bytes/param |
| KV cache | inference (grows with tokens) | 2 × layers × heads × head_dim × seq × batch × bytes |
| Activations | mostly training (forward/backward) | grows with batch × seq × hidden × layers |
| Optimizer state | training only | ~2× params (Adam moments) in fp32 |
Inference pays for weights + KV cache (plus a little working memory). Training adds activations + gradients + optimizer state — which is why the same model needs far more VRAM to train than to serve.
This is the whole mental model for GPU memory: whether a model "fits" is just the sum of these boxes measured against the card's VRAM ceiling. Read it left to right — each box is one thing competing for the same fixed memory.
- Weights (params×bytes) — the model itself. A one-time, fixed cost you pay to load it. Its size is the parameter count times the bytes each number takes (section 2).
- KV cache (grows w/ tokens) — scratch memory the model keeps for every token it has already seen, at inference time. It grows with how many users and how long their contexts are, so it's the box that moves the most.
- Activations (training fwd/bwd) — intermediate results backprop must hold onto to compute gradients. Mostly a training cost; at inference it's tiny.
- Optimizer (training only) — Adam's per-parameter bookkeeping. Zero at inference, but the biggest single item when training (section 4).
- Total VRAM (must fit card) — add the boxes that apply to your case. Serving = weights + KV cache; training = all four. If the total exceeds the card, you OOM.
In short: Serving pays for the first two boxes; training pays for all four. That one difference is why the same model needs one GPU to serve but a cluster to train.
2 · Precision — bytes per parameter essential
Start with the mistake almost everyone makes once. Someone glances at "Llama-3 70B," sees the number 70, and reasons "70 billion… so about 70 GB, that'll fit my 80 GB card." They're wrong by a factor of two, and the reason is a confusion so common it's worth burning into memory: a parameter count is not a byte count. "70B" tells you how many numbers the model has. It says nothing about how big each one is — and that second fact is the entire difference between "fits" and "OOM."
Here's the analogy that fixes it for good. Imagine a warehouse holding 70 billion boxes. Knowing the box count doesn't tell you how much floor space you need — you also need to know how big each box is. A parameter is a box; its precision is the box size, measured in bytes. Store each number in 32 bits (fp32) and each box is 4 bytes; in 16 bits (fp16/bf16) it's 2 bytes; in 8 bits (int8) it's 1 byte; in 4 bits (int4) it's half a byte. Same 70 billion boxes, but the floor space you rent swings by 8× depending purely on box size.
So the master formula for weight memory is refreshingly simple: bytes = params × bytes-per-param. Every capacity question in this lesson bottoms out in that one multiplication. The precision column below is therefore the most important constant on the page — not because the bit-splitting details matter day to day, but because it's the number you multiply by to turn a headline parameter count into a real gigabyte figure. Notice the pattern as you read it: each step down the precision ladder roughly halves the footprint. That halving is exactly why quantization (IC2) exists — it doesn't make the model smarter, it makes each box smaller so more of them fit on the card you already have.
Every number in the model occupies a fixed number of bytes set by its precision. This single constant drives the whole weight-memory calculation. Memorize the table:
Read the bars top-to-bottom: each row is the same model stored at a lower precision, and the shorter the bar, the less VRAM it needs.
- Every bar holds the identical 70 billion parameters — the count is fixed. What changes down the stack is only how many bytes each parameter takes.
- The widths tell the story: fp32 (4 bytes) is the widest, and each step — bf16, int8, int4 — cuts the bar roughly in half, from ≈260 GiB down to ≈33 GiB.
- The green int4 bar is the payoff: 8× smaller than fp32 for the same weights. That shrinkage is the whole reason quantization exists for serving big models.
In short: A parameter count is not a byte count — multiply by bytes/param, and dropping a precision level roughly halves the memory bill.
| Format | Bits | Bytes/param | Where used |
|---|---|---|---|
| fp32 | 32 | 4 | legacy training, optimizer state |
| fp16 / bf16 | 16 | 2 | standard training + serving |
| fp8 | 8 | 1 | H100-class training/inference |
| int8 | 8 | 1 | quantized serving (IC2) |
| int4 | 4 | 0.5 | aggressive quantized serving (IC2) |
fp16 and bf16 are both 2 bytes — they differ in how the 16 bits split between exponent and mantissa (bf16 trades precision for fp32's dynamic range, which is why it's the training default). For memory math they're identical. Quantization (IC2) is precision applied to the weights after training; here we care about the bytes it buys you.
vram_weights.pyGIB = 1024 ** 3 # bytes in a gibibyte
def vram_for_model(params_b, bytes_per_param):
"""Weight memory in GiB. params_b = billions of params."""
total_bytes = params_b * 1e9 * bytes_per_param
return total_bytes / GIB
BYTES = {"fp32": 4, "fp16": 2, "bf16": 2, "fp8": 1, "int8": 1, "int4": 0.5}
for size in (7, 13, 70):
line = [f"{size:>3}B:"]
for fmt in ("bf16", "int8", "int4"):
gib = vram_for_model(size, BYTES[fmt])
line.append(f"{fmt}={gib:6.1f} GiB")
print(" ".join(line))
7B: bf16= 13.0 GiB int8= 6.5 GiB int4= 3.3 GiB
13B: bf16= 24.2 GiB int8= 12.1 GiB int4= 6.1 GiB
70B: bf16= 130.4 GiB int8= 65.2 GiB int4= 32.6 GiB
This turns a parameter count into gigabytes — the single most useful piece of arithmetic in the lesson. "70B" is a count, not a size; you get the size by multiplying by bytes-per-parameter.
vram_for_model(params_b, bytes_per_param)multiplies billions of params by the bytes each one takes, then divides byGIB(bytes in a gibibyte) to report GiB — the unit GPUs are sold in.BYTESmaps each precision to its bytes/param: fp32=4, fp16/bf16=2, fp8/int8=1, int4=0.5. This is the constant that decides everything.- The loop prints three model sizes across three precisions, so you can read the size-vs-precision tradeoff as a grid.
What the output means: A 70B model is 130.4 GiB in bf16 (over one 80 GB card), 65.2 GiB in int8, and 32.6 GiB in int4 — which is why serving big models means quantizing them (IC2).
Try this: Change a size to 405 (a 405B model) and see that even int4 (~189 GiB) needs multiple cards. The arithmetic tells you the hardware before you rent it.
The formula is just params × bytes/param. A 70B model in bf16 is 70e9 × 2 bytes ≈ 130.4 GiB of weights alone — already over a single 80 GB card. In int4 (0.5 bytes) it's ≈ 32.6 GiB, which fits. That is the whole reason quantization exists for serving large models.
A colleague says "Llama-3 70B is 70 GB, so it fits on an 80 GB A100." Where is the reasoning wrong?
Show answer
3 · The KV cache — memory that grows with tokens intermediate
This is the section that explains a bug that has confused thousands of teams. You size your GPU carefully — weights fit with room to spare, you launch, everything's green. Then traffic ramps up, or one user pastes a very long document, and suddenly the same model that fit an hour ago throws CUDA out of memory. Nothing about the weights changed. What changed is the second, moving line item on the card: the KV cache. And unlike weights, which you can look up once, the KV cache is a live cost that breathes with your traffic.
To see why it exists, picture how the model generates text: one token at a time, and to produce each new token it "attends" to every token before it. The naive way would be to re-read and re-encode the entire conversation for every single new token — quadratically wasteful. So the model does the obvious optimization: the first time it processes a token it computes that token's key and value vectors and keeps them, so later tokens can look them up instead of recomputing (this is the attention mechanism from IC3). Think of it as the model's running notebook: every token it has ever seen leaves two notes (a K and a V) on every layer, and the notebook stays open in VRAM for the whole request.
That notebook is the trap, because it grows in four dimensions at once. The formula below spells it out, but read the intuition first — the cache size is proportional to:
- × sequence length — one more token means one more page of notes, per layer. Long contexts are expensive.
- × batch size — every concurrent request keeps its own notebook. Ten users is ten times the cache.
- × number of layers — the notes are kept at every transformer layer, not once.
- × 2 — because it's a Key and a Value stored per token, not one number.
The detail that trips people, and the one worth getting right: the formula uses n_kv_heads, the number of key/value heads — not the (usually larger) number of query heads. Modern large models deliberately use grouped-query attention (GQA), where many query heads share a smaller set of KV heads, precisely to shrink this cache. Read that as a design decision aimed straight at the memory bill, not an accident. The punchline you'll prove in the lab: on a busy server it's the KV cache, not the weights, that runs you out of memory first — so capacity planning at serving time is mostly KV-cache planning.
During generation the model caches the key and value vectors for every token it has already seen, at every layer, so it never recomputes attention over the past (IC3). That cache is pure VRAM overhead on top of the weights, and it grows linearly with sequence length and batch size. The formula:
Read left-to-right: the fixed block on the left is the weights; the growing rows of cells on the right are the KV cache, and each cell is the key/value notes for one token.
- The tall left box is the model weights — one fixed size no matter what traffic does. You size it once and forget it.
- Each row of small cells on the right is one concurrent request; the cells stretch rightward as
seq_len(the context length) grows — one more token, one more cell. - Stacking more rows (batch = 1, 2, 3…) is more users at once. The cache is the total of all those cells, which is why the red note reads "more users × longer context = bigger cache".
In short: Weights are a one-time cost; the KV cache scales with batch × sequence length, so it is the term that OOMs a busy server first.
kv_bytes = 2 × n_layers × n_kv_heads × head_dim × seq_len × batch × bytes_per_elem
The leading 2 is for K and V. Note n_kv_heads, not query heads — grouped-query attention (GQA) shrinks the cache by sharing KV across query heads, which is exactly why modern large models use it.
kv_cache.pyGIB = 1024 ** 3
def kv_cache_gb(n_layers, n_kv_heads, head_dim, seq_len, batch, bytes_per_elem=2):
"""KV-cache VRAM in GiB. Factor 2 is for K and V."""
elems = 2 * n_layers * n_kv_heads * head_dim * seq_len * batch
return elems * bytes_per_elem / GIB
# 70B-class model with grouped-query attention (8 KV heads), fp16 cache
cfg = dict(n_layers=80, n_kv_heads=8, head_dim=128, seq_len=8192)
for batch in (1, 8, 32):
gib = kv_cache_gb(batch=batch, **cfg)
print(f"batch={batch:>2} @ 8k ctx -> KV cache = {gib:6.2f} GiB")
batch= 1 @ 8k ctx -> KV cache = 2.50 GiB
batch= 8 @ 8k ctx -> KV cache = 20.00 GiB
batch=32 @ 8k ctx -> KV cache = 80.00 GiB
This sizes the KV cache — the memory that grows every time a user sends a longer prompt or you serve one more request at once. On a busy server this, not the weights, is what runs you out of memory.
- The element count is
2 × layers × kv_heads × head_dim × seq_len × batch. The leading 2 counts the Key and the Value stored per token. - It uses
n_kv_heads(8 here), not the larger number of query heads — grouped-query attention shares KV across query heads specifically to shrink this cache. - The loop holds context at 8k and raises only the batch (concurrent requests), so you can see the cache scale linearly with concurrency.
What the output means: One 8k sequence costs 2.5 GiB of cache; 32 of them cost 80 GiB — the entire card, on top of the weights. That's why concurrency and context length drive inference capacity.
Try this: Halve seq_len to 4096 and watch every number halve. KV cache is linear in both context length and batch — the two knobs you cap to avoid OOM.
For a 70B-class model (80 layers, 8 KV heads via GQA, head_dim 128) a single 8k-token sequence in fp16 is only ~2.5 GiB — but 32 concurrent sequences is ~80 GiB, the whole card on its own. KV cache, not weights, is usually what OOMs a busy inference server.
4 · Activations & the training memory blow-up advanced
Here's a failure that feels like a betrayal. You've been happily serving a 13B model on a single 80 GB card for weeks. Then you decide to fine-tune it — same model, same card — and it OOMs before the first step even completes. It feels wrong: how can a model that fits not fit? The answer is that training is not "inference plus a backward pass." It's a fundamentally hungrier regime, and the extra appetite comes from two consumers that are simply absent at inference time.
Walk through what training actually has to keep in memory that serving doesn't. First, activations: to compute gradients, the backward pass needs the intermediate values from the forward pass — you can't know how to nudge a weight without remembering what flowed through it. At inference you compute a layer's output, use it, and throw it away; at training you must hoard those intermediate results until the backward pass consumes them. Second, and usually larger, is the optimizer state. An optimizer like Adam doesn't just apply the raw gradient — it keeps a running mean and running variance of the gradients for every single parameter, so it can adapt the step size. That's bookkeeping the model never carries when it's only answering questions.
Now count the bytes per parameter for the classic mixed-precision Adam recipe, because the arithmetic is the whole lesson:
- an fp32 master copy of the weights Adam updates — 4 bytes,
- the gradient, one per weight each step, in fp16/bf16 — 2 bytes,
- Adam's first moment m (mean of gradients), fp32 — 4 bytes,
- Adam's second moment v (variance of gradients), fp32 — 4 bytes.
That's ~14 bytes per parameter of persistent state — and note we haven't counted a single activation yet. Compare it to serving, which pays roughly 2 bytes per parameter (just the bf16 weights). The ratio is about 7×, and it's why the lab shows a 7B model costing ~13 GiB to serve but ~91 GiB of state to train. The critical takeaway — and the thing to say out loud in an interview — is that the optimizer state, not the weights, blows the training budget. That reframing is what points you at the right fixes: LoRA/QLoRA (FT3) train a tiny adapter so there's almost no optimizer state to hold, and sharding (MS2) splits that state across cards. You attack the 12 bytes of grad+optimizer, not the 2 bytes of weights.
Inference is memory-light: weights + KV cache and you're done. Training is a different regime. Backprop must keep the forward activations around to compute gradients, and the optimizer keeps its own state per parameter. The four-way split for Adam-style training in mixed precision:
Compare the two sides: one short box on the left (serving) versus a tall stack on the right (training), both sized per parameter.
- The left box is the entire serving bill — 2 bytes per parameter for the bf16 weights. That is all inference holds (plus the KV cache from section 3).
- The right stack keeps those same 2-byte weights but adds a matching gradient, Adam's two fp32 moments (
mandv, 4 bytes each), and an fp32 master copy — the extra green/amber/purple bands. - Summing the right stack gives the ≈14 B/param label — about 7× the serving side. The optimizer bands (the two amber moments), not the weights, are what dominate.
In short: Serving = weights only (~2 B/param); Adam training = ~14 B/param, so a model that serves on one card can need a cluster to train — and the optimizer state is the culprit.
| Component | Precision | Bytes/param | Note |
|---|---|---|---|
| Weights (master) | fp32 | 4 | the authoritative copy Adam updates |
| Gradients | fp16/bf16 | 2 | one per weight, each step |
| Adam moment m | fp32 | 4 | running mean of gradients |
| Adam moment v | fp32 | 4 | running variance of gradients |
That's roughly 14 bytes/param just for weights + gradients + optimizer state in the classic mixed-precision Adam recipe (4 + 2 + 4 + 4) — before a single activation. A 7B model is thus ~91 GiB of state, which is why it does not train on one 80 GB card without sharding (MS2) or memory-saving tricks. Activations are extra on top and scale with batch × seq × hidden × layers; gradient checkpointing trades compute to shrink them.
train_memory.pyGIB = 1024 ** 3
def inference_state_gb(params_b, bytes_per_param=2):
"""Just the weights (bf16 serving)."""
return params_b * 1e9 * bytes_per_param / GIB
def training_state_gb(params_b):
"""Mixed-precision Adam persistent state, bytes/param:
fp32 master weights (4) + grads (2) + Adam m (4) + Adam v (4) = 14."""
bytes_per_param = 4 + 2 + 4 + 4
return params_b * 1e9 * bytes_per_param / GIB
for size in (7, 13):
inf = inference_state_gb(size)
tr = training_state_gb(size)
print(f"{size:>2}B: serve(bf16)={inf:6.1f} GiB | "
f"train(Adam)={tr:6.1f} GiB | {tr/inf:.1f}x more")
7B: serve(bf16)= 13.0 GiB | train(Adam)= 91.3 GiB | 7.0x more
13B: serve(bf16)= 24.2 GiB | train(Adam)= 169.5 GiB | 7.0x more
This shows why the same model needs far more memory to train than to serve. Serving holds just the weights; training also holds gradients and the optimizer's per-parameter state.
inference_state_gbis only the bf16 weights — the whole memory bill for serving (plus KV cache, which this function omits).training_state_gbadds it up at 14 bytes/param: an fp32 master copy (4) + gradients (2) + Adam's meanm(4) + Adam's variancev(4).- The printed ratio makes the gap concrete: training persistent state is ~7× the serving weights — and that's before activations.
What the output means: A 7B model is ~13 GiB to serve but ~91 GiB of state to train with Adam — a 7× jump that is why full fine-tuning needs sharding (MS2) or LoRA/QLoRA (FT3).
Try this: This is the arithmetic behind "why can't I fine-tune a 7B on my 80 GB card?" — the optimizer state, not the weights, is what blows the budget.
The same 7B model: ~13 GiB to serve in bf16, ~91 GiB of persistent state to train with Adam — a 7× gap, and that's before activations. This is the single most important reason training needs clusters while inference often needs one card.
You can serve a 13B model comfortably on one 80 GB card but it OOMs the moment you try to full-fine-tune it there. In one sentence, why?
Show answer
5 · Does it fit? Batch & sequence vs the ceiling professional
There are two ways to find out whether a model fits a card. The expensive way is to rent the GPU, launch, and watch — you either get lucky or you OOM in production with users watching. The professional way is to add up the four line items on paper first and only rent the hardware the arithmetic already told you would fit. This section is about turning "does it fit?" from a nerve-wracking experiment into a single line of math you can run before spending a cent.
The whole question reduces to one inequality: weights + KV cache + headroom ≤ card VRAM. Two of those terms you now know cold — weights from section 2 (fixed, precision × params) and KV cache from section 3 (grows with batch and context). The insight that makes this planning rather than accounting is which terms you can actually move. Weights are frozen once you pick a precision; you don't get to negotiate them at runtime. The KV cache, by contrast, is the dial you turn every time you set a batch size or a max context length — so batch and sequence length are your real capacity knobs, and they trade directly against how many users you can pack onto a card.
One term deserves its own emphasis because skipping it is the classic rookie error: headroom. You never plan to fill 100% of the card. CUDA needs its own context, the memory allocator fragments, attention builds temporary buffers, and the occasional long request spikes above average — all of that eats VRAM you didn't put in your spreadsheet. Budget to roughly 85–90% of the card and treat the rest as slack. A deployment sized to exactly 80.0 GiB of an 80 GB card isn't "efficient," it's a pager alert waiting for the first busy minute. The checker in the lab bakes this in with a 10% reserve, so read its "usable" number (≈72 GiB) as the line you actually plan against.
Capacity planning is one question: does weights + KV cache + headroom fit the card? Weights are fixed; KV cache is the dial you actually turn with batch size and context length. The professional move is to compute the answer, not to try-and-OOM in production.
Read each card bottom-to-top as a stack of what you load, and compare the top of the stack to the card's ceiling.
- Each outlined box is one 80 GB card. You fill it from the bottom: weights first (fixed), then KV cache on top (grows with batch and context).
- The purple band on the left card is the deliberate ~10% headroom you never fill — CUDA context, fragmentation, and transient buffers live there. The usable line sits at ≈72 GiB.
- The right card is the failure case: the 70B weights alone (≈130 GiB) overflow the whole card before any KV cache is added, so it OOMs — the red block spills past the ceiling.
In short: Add weights + KV cache, compare to usable VRAM (card × 0.9); if the stack clears the line it fits, if it spills you must quantize or shard.
fits_80gb.pyGIB = 1024 ** 3
def vram_for_model(params_b, bytes_per_param):
return params_b * 1e9 * bytes_per_param / GIB
def kv_cache_gb(n_layers, n_kv_heads, head_dim, seq_len, batch, bytes_per_elem=2):
elems = 2 * n_layers * n_kv_heads * head_dim * seq_len * batch
return elems * bytes_per_elem / GIB
def fits_80gb(params_b, bytes_per_param, kv_gb, headroom=0.10):
"""Does weights + KV cache fit an 80 GB card with headroom to spare?"""
card = 80.0
usable = card * (1 - headroom)
need = vram_for_model(params_b, bytes_per_param) + kv_gb
return need, usable, need <= usable
# 13B bf16 serving 8 users at 4k ctx
kv = kv_cache_gb(n_layers=40, n_kv_heads=40, head_dim=128, seq_len=4096, batch=8)
need, usable, ok = fits_80gb(13, 2, kv)
print(f"13B bf16 + KV {kv:.1f} GiB -> need {need:5.1f} / {usable:.1f} usable -> "
f"{'FITS' if ok else 'OOM'}")
# 70B bf16, weights alone
need, usable, ok = fits_80gb(70, 2, 0.0)
print(f"70B bf16 weights only -> need {need:5.1f} / {usable:.1f} usable -> "
f"{'FITS' if ok else 'OOM'}")
# 70B int4, weights alone
need, usable, ok = fits_80gb(70, 0.5, 0.0)
print(f"70B int4 weights only -> need {need:5.1f} / {usable:.1f} usable -> "
f"{'FITS' if ok else 'OOM'}")
13B bf16 + KV 25.0 GiB -> need 49.2 / 72.0 usable -> FITS
70B bf16 weights only -> need 130.4 / 72.0 usable -> OOM
70B int4 weights only -> need 32.6 / 72.0 usable -> FITS
This answers the capacity question directly: does weights + KV cache fit an 80 GB card with headroom to spare? It's the check a lead runs before launching hardware instead of OOMing to find out.
fits_80gbreserves 10% headroom (usable ≈ 72 GiB) for CUDA context, allocator overhead, and temporary buffers — you never plan to 100% of the card.- It adds the weight memory to a supplied KV-cache figure and returns whether the total lands under the usable budget, plus the numbers so you can see the margin.
- The three cases contrast a 13B that fits comfortably, a 70B in bf16 that doesn't fit at all, and the same 70B in int4 that fits — the quantize-vs-shard decision in one output.
What the output means: 13B bf16 + KV fits (49.2 of 72 GiB); 70B bf16 OOMs (130.4 needed); 70B int4 fits (32.6). The verdict is arithmetic, not a production experiment.
Try this: Add a KV-cache figure to the 70B int4 line (from kv_cache.py) and find the concurrency at which even the quantized model stops fitting one card.
Read the output as a capacity plan: a 13B model in bf16 on an 80 GB card leaves plenty of room for KV cache, so you can push batch/context high; a 70B model in bf16 doesn't fit at all and must be quantized or sharded. The knobs — precision, batch size, max sequence length — are exactly what you tune to stay under the ceiling while maximizing throughput (IC1/IC4).
6 · Debugging an OOM methodically professional
Watch what a panicking engineer does when they hit CUDA out of memory: they lower the batch size a bit, it OOMs again; they grab a bigger card, it OOMs on a different request; they toggle three flags at once from a Stack Overflow answer and pray. That's debugging by superstition, and it burns hours and GPU-hours both. The reason it fails is that they're treating the OOM as a random event instead of what it actually is — a budget that went over by a specific amount, on a specific line item, that you can name.
Because you now know there are only four consumers, an OOM stops being a mystery and becomes an accounting problem: something in {weights, KV cache, activations, optimizer state} grew past the ceiling, and your job is to figure out which one and shrink that one. The discipline is two moves — attribute, then attack — and the order matters more than any individual trick.
The attribution step is where the profiler earns its keep, and the signal is in when the memory appears. Weights are constant — resident the moment you load, unchanging after. If memory climbs as you add concurrent requests, that's the KV cache. If it spikes specifically during .backward(), that's activations and optimizer state. So don't guess: log torch.cuda.memory_allocated() and max_memory_allocated() around load and around a forward/backward pass, and let the numbers point at the culprit. Only then do you attack — and you attack the biggest line item first, with the cheapest lever that shrinks it, rather than sprinkling fixes at random. The ordered playbook below is that logic written out; the one habit to carry from it is that reaching for a bigger card is the last resort, never the first, because it's the most expensive lever and it hides the real bug instead of fixing it.
"CUDA out of memory" is not a mystery once you know the four consumers. Debug it as a budget problem, in order — attribute the memory, then attack the biggest line item:
Read it top-to-bottom: start at the allocator, follow the arrow that matches your symptom, and land on the fix for that specific consumer.
- The top box is step one — log
memory_allocated()andmax_memory_allocated()instead of guessing. - The middle row is attribution by timing: memory that is constant at load is weights; memory that climbs with concurrency is the KV cache; memory that spikes only during
.backward()is activations/optimizer. - The bottom row is the matching cheapest lever for each: quantize/shard the weights, cap batch or context for KV, checkpoint or use LoRA for training state. You attack the box the arrow led you to, not all of them at random.
In short: The timing of the memory names the culprit; pull the lever under that culprit, and reach for a bigger card only when the cheap levers are spent.
OOM debugging methodology
- Read the allocator, don't guess. Log
torch.cuda.memory_allocated()andmax_memory_allocated()around load and around a forward pass to see what's resident vs. peak. - Attribute it to a consumer. Weights are constant; if memory climbs with concurrency it's KV cache; if it spikes only during
.backward()it's activations/optimizer. - Attack the biggest line item. KV cache → cap batch/context, use GQA/paged attention, quantize the KV cache. Weights → quantize (IC2) or shard (MS2). Optimizer → LoRA/QLoRA (FT3), 8-bit optimizer, or shard.
- Reduce activations if training. Enable gradient checkpointing, lower the micro-batch, use gradient accumulation to keep the effective batch while cutting peak memory.
- Fix fragmentation last. If allocated is well under total but you still OOM, set
PYTORCH_CUDA_ALLOC_CONF=expandable_segments:Trueand avoid wild batch-size swings.
max_model_len and per-request context, size KV cache for the worst case you admit, and reject over-long requests at the gateway. An unbounded context length is an unbounded memory bug waiting for the wrong prompt.7 · Measuring it for real on a GPU advanced
Here's where a careful engineer keeps themselves honest. Everything so far has been paper arithmetic — powerful, cheap, and approximate. The formulas capture the big rocks (weights, KV cache, optimizer state), but real hardware always carries extra weight the spreadsheet omits: the CUDA context itself, allocator overhead, cuDNN workspace, temporary buffers a kernel grabs mid-attention. So the mature workflow is not "trust the math" or "just measure" — it's predict, then verify: size it offline to pick the smallest plausible card, then confirm once on the real thing before you commit.
The tool that closes the loop is the CUDA memory allocator, which PyTorch exposes directly. Two calls tell you almost everything. torch.cuda.memory_allocated() reports what's resident right now — call it after loading the model and it should land within a hair of your vram_for_model prediction (if a 7B in bf16 reports ~14 GiB, your arithmetic is trustworthy). torch.cuda.max_memory_allocated() reports the peak the allocator ever reached, which is the number that actually decides whether you OOM — because a forward or backward pass transiently spikes above the resident baseline. The gap between those two numbers is your headroom requirement, measured rather than guessed.
One honest caveat about the lab below, and why it's labeled the way it is: it imports torch and calls CUDA, so it genuinely cannot run in this offline, CPU-only environment — it's shown so you know exactly what to run on a real GPU box. That's the opposite of the calculators in labs 1–4, which are pure stdlib arithmetic you can run right here to predict what this snippet will later confirm. Read the two together as the full discipline: the offline math proposes, the allocator disposes.
Everything above is arithmetic you can do offline. On real hardware you confirm it with the CUDA allocator. This snippet is real and correct but needs a GPU + PyTorch — it will not run in this offline environment; it's here so you know exactly what to measure:
Follow the arrows left-to-right: the offline math feeds a prediction into two allocator readings, which then hand you the headroom figure.
- The left box is your paper arithmetic (
vram_for_model) — cheap and approximate. It predicts the memory before you rent anything. - The two middle boxes are the allocator on real hardware:
memory_allocated()is what's resident right now (should match your prediction), andmax_memory_allocated()is the transient peak a forward/backward pass reaches. - The right box is the payoff: peak minus resident is the headroom you actually need — the same slack the fit-checker reserved as ~10%, now measured instead of assumed.
In short: Offline math proposes the number; the CUDA allocator disposes — and the resident-to-peak gap is your headroom, confirmed on the real card.
measure_gpu.py# >>> needs a GPU + PyTorch: pip install torch transformers accelerate
# This is REAL and correct but will NOT run in an offline/CPU-only environment.
import torch
from transformers import AutoModelForCausalLM
def mib(n):
return n / (1024 ** 2)
# Load weights in bf16 to halve the memory vs fp32.
model = AutoModelForCausalLM.from_pretrained(
"mistralai/Mistral-7B-v0.3",
torch_dtype=torch.bfloat16,
device_map="cuda",
)
# Confirm the arithmetic from vram_weights.py against the real allocator.
print(f"allocated after load: {mib(torch.cuda.memory_allocated()):.0f} MiB")
ids = torch.randint(0, 32000, (1, 512), device="cuda")
with torch.no_grad():
_ = model(ids) # forward pass builds a little KV/working memory
print(f"peak allocated: {mib(torch.cuda.max_memory_allocated()):.0f} MiB")
# ~14 GiB resident for a 7B model in bf16 -> matches vram_for_model(7, 2).
torch and calls CUDA — it is labeled non-runnable on purpose and is not executed as part of this lesson. Run it on a real GPU box after pip install torch transformers accelerate. The offline calculators in labs 1–4 are what you run here to predict what this will report.8 · Tech-lead — VRAM as a capacity-planning discipline tech-lead
Two teams ship the same model; one is calm and one is on fire. The first over-provisioned "to be safe" and is quietly burning thousands a month on GPUs running at 40% utilization. The second sized to the edge and pages on-call every time traffic spikes. Both made the same mistake from opposite directions: they skipped the arithmetic and substituted a gut feeling. A tech lead's job on GPU memory is to make that gut feeling unnecessary — to hold a VRAM budget model in hand so hardware choices, batch limits, and cost forecasts fall out of numbers rather than nerves.
The shift in altitude here is from "will this run?" to "what's the cheapest fleet that meets the SLO?" — and it's the same four consumers, now parameterized by precision, batch, and context and multiplied across replicas. The calculators in this lesson are that model in miniature: they let a lead answer "which card, how many, at what cost per token" (IC1) in a spreadsheet instead of a procurement cycle. The discipline that separates seniors is ordering the levers by cost, cheapest first, and only escalating when the cheap ones are exhausted — because every lever trades away something (accuracy, latency, or communication overhead), and reaching for the expensive one first wastes the cheap savings you left on the table.
That ordering, roughly, runs: quantize the weights (IC2) to cut the fixed cost with minimal accuracy hit; cap context and batch to bound the KV cache; use GQA, paged KV, or KV quantization to pack more concurrency onto each card; and only then shard across GPUs (MS2), because sharding adds cross-card communication and complexity that you don't want until a single card genuinely cannot hold the model. Read this section as the executive summary of the whole page: the arithmetic you learned in sections 1–7 isn't trivia, it's the cheapest risk-reduction tool you have — cheaper than an over-provisioned fleet and far cheaper than a production OOM.
A lead doesn't launch instances to find out what fits. They keep a VRAM budget model — the four consumers, parameterized by precision, batch, and context — and use it to choose hardware, set max_model_len and batch limits, and forecast cost per token (IC1). The calculators in this lesson are that model in miniature: they turn "will it fit?" from an expensive experiment into a line of arithmetic.
The strategic levers a lead reaches for, in order of cheapness: quantize weights (IC2) to cut the fixed cost; cap context and batch to bound the KV cache; GQA / paged KV / KV quantization to serve more concurrency per card; and only then shard across GPUs (MS2) when a single card genuinely can't hold the model. Sharding adds communication cost, so it's the last resort, not the first reach.
Read the four boxes left-to-right — that order is the playbook, from the cheapest fix to the most expensive.
- Box 1 — quantize weights (IC2): cuts the fixed cost with the smallest downside (a little accuracy), so it's always tried first.
- Boxes 2 and 3 bound the moving cost: cap context/batch, then use GQA, paged KV, or KV quantization to pack more concurrency onto the same card.
- Box 4 — shard across GPUs (MS2) is red and last: it works, but it adds cross-card communication and complexity, so you escalate to it only when the cheaper levers are spent. The bottom arrow marks the rising cost from green to red.
In short: Order your fixes by cost — quantize, then cap, then paged/GQA KV, and shard only as a last resort.
Exercise MS1.1 — Size a 70B serving deployment
Context: Serving a large model is a sizing decision before it is an engineering one: you settle the memory math offline, then pick the smallest hardware that fits with headroom.
Your task: Using the weight, KV-cache, and fit calculators, decide how to serve a 70B model (80 layers, 8 KV heads, head_dim 128) to 32 concurrent users at 8k context on an 80 GiB card.
Requirements:
- Report weight memory at bf16 vs int4
- Report KV-cache memory at 32 concurrent users, 8k context
- State whether the total fits one 80 GiB card
- If it does not, pick the cheapest lever (quantize vs shard) that fits with 10% headroom and justify it
💡 Hint: Compute the two memory terms separately, sum them against usable VRAM, and only then reach for a lever.
Exercise MS1.2 — Explain a training OOM
Context: The fastest way to explain a training OOM is to attribute it to one of the four VRAM consumers and quote the bytes-per-param it costs.
Your task: A teammate's 7B full fine-tune OOMs on a single 80 GiB card. Compute the persistent training state, name which consumer is over budget, and recommend two fixes with the memory each saves.
Requirements:
- Size the training state (weights + gradients + Adam moments) in bytes/param
- Name which of the four consumers pushes it over the 80 GiB budget
- Give one fix that keeps a full fine-tune and one that does not
- State the bytes/param each fix saves
💡 Hint: The optimizer state is almost always the culprit; a fix that shards it and a fix that trades it away (LoRA) bracket the answer.
🪜 Practice ladder beginner → industry
Six graded exercises, easy to real-world. Try each before opening its solution.
Context: The whole VRAM budget starts from one constant: how many bytes a single weight occupies at a given precision. Halving that constant halves the footprint.
Your task: Write weight_gib(params_b, bytes_per) that returns a model's weight memory in GiB, given its size in billions of params and the bytes-per-param, and table it for 7B/13B/70B at bf16, int8, and int4.
Requirements:
- Weight bytes =
params × bytes_per; divide by 1024³ for GiB - Use the precision constants fp32=4, bf16/fp16=2, int8=1, int4=0.5
- Report the same model at several precisions side by side
- Show that going bf16 → int4 is a 4× cut on the weights
💡 Hint: Everything downstream sizes off this one number; the interesting observation is that each precision step simply halves the bytes.
Show solution
Weight bytes = params × bytes-per-param; divide by 1024³ for GiB. Runnable (stdlib):
def weight_gib(params_b, bytes_per):
total_bytes = params_b * 1e9 * bytes_per
return total_bytes / (1024 ** 3)
for name, p in [("7B", 7), ("13B", 13), ("70B", 70)]:
bf16 = weight_gib(p, 2)
int8 = weight_gib(p, 1)
int4 = weight_gib(p, 0.5)
print(f"{name:>4}: bf16={bf16:6.1f} GiB int8={int8:6.1f} GiB int4={int4:6.1f} GiB")
# 7B: bf16 ~13.0, int8 ~6.5, int4 ~3.3 -- halving bytes halves the footprint
This one constant drives the whole weight budget: quantizing from bf16 to int4 is a 4x memory cut for the weights.
Context: At serving time the KV cache, not the weights, is the term that turns a plan that 'fits on paper' into a production OOM, because it grows with every token held in flight.
Your task: Write a KV-cache calculator and reproduce the lesson's figures for a 70B-ish config (80 layers, 8 KV heads, head_dim 128, bf16) at batch 1, 8, and 32 at 8k context.
Requirements:
- Size =
2 · n_layers · n_kv_heads · head_dim · seq · batch · bytes(the 2 is K and V) - Use
n_kv_heads(grouped-query attention), not the query-head count - Show the result is linear in both batch and sequence length
- Convert to GiB and print one row per batch size
💡 Hint: The factor of 2 for K and V and the use of the smaller KV-head count are the two details people get wrong.
Show solution
Grouped-query attention uses n_kv_heads (fewer than query heads) to shrink this. Runnable:
def kv_gib(n_layers, n_kv_heads, head_dim, seq, batch, bytes_per=2):
elems = 2 * n_layers * n_kv_heads * head_dim * seq * batch
return elems * bytes_per / (1024 ** 3)
# a 70B-ish config: 80 layers, 8 KV heads, head_dim 128, bf16
for b in (1, 8, 32):
gib = kv_gib(n_layers=80, n_kv_heads=8, head_dim=128, seq=8192, batch=b)
print(f"batch={b:>2} @ 8k ctx -> KV cache = {gib:6.2f} GiB")
# batch 1 -> 2.50, batch 8 -> 20.00, batch 32 -> 80.00 : linear in batch AND seq
KV cache is linear in both batch and sequence length — it is the term that turns "fits on paper" into an OOM at long context.
Context: A model you can comfortably serve on one card may need a cluster to train — not because of the weights, but because of the optimizer state Adam carries in full precision.
Your task: Model why an Adam full-precision fine-tune needs roughly 7× the serving footprint by summing the per-parameter bytes of every training-time consumer, and compare serve-vs-train GiB for 7B and 13B.
Requirements:
- Serving pays only for weights (bf16 = 2 bytes/param)
- Training adds a gradient plus two Adam moments plus a master copy, in fp32
- Sum the per-param bytes and divide train by serve to get the multiplier
- State that the optimizer state, not the weights, dominates the training budget
💡 Hint: Count the bytes-per-param for weight + grad + first moment + second moment (+ master copy) and the ~7× falls straight out of the ratio.
Show solution
Per param: bf16 weight 2B + fp32 grad 4B + fp32 master 4B + 2×fp32 moment 8B = 18B, vs 2B to serve → 9x on weights alone; with a mixed-precision master copy folded in the lesson quotes ~7x. Runnable:
def serve_gib(params_b): # bf16 weights only
return params_b * 1e9 * 2 / (1024 ** 3)
def train_gib(params_b): # bf16 weight + fp32 grad + fp32 m + fp32 v
bytes_per = 2 + 4 + 4 + 4 # = 14 bytes/param (Adam, mixed precision)
return params_b * 1e9 * bytes_per / (1024 ** 3)
for name, p in [("7B", 7), ("13B", 13)]:
s, t = serve_gib(p), train_gib(p)
print(f"{name}: serve(bf16)={s:6.1f} GiB | train(Adam)={t:6.1f} GiB | {t/s:.1f}x more")
# ~7x more -- optimizer state, not the weights, dominates a training budget
The takeaway: a model you can serve on one card may need a cluster to train, purely from optimizer state.
Context: The point of the whole lesson is to predict an OOM before you rent the card: add up what you need, subtract headroom for activations and fragmentation, and check against the GPU's real usable VRAM.
Your task: Write fits(need_gib, card_gib, headroom=0.10) and use it to judge a 13B bf16 model plus 25 GiB of KV, and 70B at bf16 vs int4, against an 80 GiB card.
Requirements:
- Usable memory is the card minus a headroom fraction (default 10%)
- Compare total need (weights + KV) against usable, returning FITS or OOM
- 13B + 25 GiB KV fits; 70B bf16 OOMs (~130 GiB); 70B int4 fits (~33 GiB)
- Keep headroom a parameter so different fragmentation budgets are easy to try
💡 Hint: The verdict is just need <= card × (1 − headroom); the value is running it before provisioning.
Show solution
Usable memory is the card minus a headroom fraction; compare the total need against it. Runnable:
def usable(card_gib, headroom=0.10):
return card_gib * (1 - headroom)
def fits(need_gib, card_gib, headroom=0.10):
u = usable(card_gib, headroom)
verdict = "FITS" if need_gib <= u else "OOM"
return f"need {need_gib:6.1f} / {u:4.1f} usable -> {verdict}"
W = lambda p, bp: p * 1e9 * bp / (1024 ** 3)
print("13B bf16 + KV 25.0 GiB ->", fits(W(13, 2) + 25.0, 80))
print("70B bf16 weights only ->", fits(W(70, 2), 80))
print("70B int4 weights only ->", fits(W(70, 0.5), 80))
# 13B+KV fits; 70B bf16 OOMs (130 GiB); 70B int4 fits (33 GiB)
The fit-checker is the whole point of the lesson: predict OOM before you rent the card.
Context: When production throws a CUDA OOM, the senior move is not to grab a bigger card first — it is to name the dominant memory term and pull the cheapest lever that shrinks that term.
Your task: Encode the lesson's ordered OOM levers into a triage function that, given which term dominates (kv / weights / activations) and how far over budget you are, recommends the cheapest fixes first.
Requirements:
- Branch on the dominant term from a memory profile
- KV-bound → lower batch/concurrency, cap context or use paged-KV
- Activation-bound → checkpointing, smaller micro-batch with grad accumulation
- Weight-bound → quantize, then shard (tensor-parallel / FSDP)
- Fall back to a bigger card only when the cheap levers are exhausted
💡 Hint: Order the levers by cost to accuracy/latency; the bigger card is always the last resort, never the first.
Show solution
Order matters: try the change that costs least accuracy/latency first. Runnable decision logic:
def triage(dominant, over_by_gib):
# dominant term from a memory profile: 'kv', 'weights', or 'activations'
plan = []
if dominant == "kv":
plan += ["lower max batch / concurrent seqs",
"cap context length or enable paged-KV (vLLM)"]
elif dominant == "activations":
plan += ["enable gradient/activation checkpointing",
"reduce micro-batch, use grad accumulation"]
elif dominant == "weights":
plan += ["quantize weights (bf16 -> int8 -> int4)",
"shard weights across GPUs (tensor parallel / FSDP)"]
plan.append(f"still {over_by_gib:.0f} GiB over? move to a bigger card")
return plan
for step in triage("kv", 12):
print("-", step)
The discipline: read the profile, name the dominant term, then pull the cheapest lever that shrinks that term.
Context: Sizing a whole serving fleet is the same arithmetic scaled up: weights load once per replica, each concurrent user rents one KV slot, and those two facts set both the replica count and the monthly bill.
Your task: Build a capacity model that, for a 70B int4 model at 8k context on 80 GiB cards, computes users-per-replica, replicas needed for N users, and cost/month — noting which constant still has to be measured on real hardware.
Requirements:
- Per replica: shared weights +
KKV slots must fit usable VRAM - users_per_replica =
(usable − weights) // KV_per_user - replicas =
ceil(N / users_per_replica); cost = replicas × GPU-hour × hours - Flag that the usable-VRAM constant must be confirmed with
torch.cuda.max_memory_allocated()on the card (needs GPU)
💡 Hint: It is the fit-checker from the ladder wrapped in a ceil-division; the only thing the offline model cannot give you is the true per-card headroom.
Show solution
Per replica: weights (shared) + K concurrent KV slots must fit usable VRAM; that caps users/replica; replicas = ceil(N / users_per_replica). Runnable capacity model:
import math
def kv_per_user_gib(n_layers=80, n_kv_heads=8, head_dim=128, seq=8192, bytes_per=2):
return 2 * n_layers * n_kv_heads * head_dim * seq * bytes_per / (1024 ** 3)
def plan(users, card_gib=80, weight_gib=32.6, headroom=0.10, gpu_hr=2.0):
usable = card_gib * (1 - headroom)
per_user = kv_per_user_gib()
users_per_replica = int((usable - weight_gib) // per_user)
if users_per_replica < 1:
return "weights + one user do not fit -- need a bigger card"
replicas = math.ceil(users / users_per_replica)
monthly = replicas * gpu_hr * 24 * 30
return (f"KV/user={per_user:.2f} GiB, users/replica={users_per_replica}, "
f"replicas={replicas}, ~${monthly:,.0f}/mo")
print(plan(500))
# real per-card headroom must be MEASURED (torch.cuda.max_memory_allocated) -- needs GPU
The offline model sizes the fleet and the bill; a real rollout still verifies the usable-VRAM constant with torch.cuda.max_memory_allocated() on the actual card (needs GPU).
✓ Checkpoint — you can move on when you can…
- Name the four consumers of VRAM and size each from its formula.
- Convert a parameter count to gigabytes at any precision (bytes/param).
- Compute KV-cache memory and explain why it dominates at serving time.
- Explain the ~14 B/param training-state blow-up and why inference << training.
- Answer "does it fit?" with arithmetic and debug an OOM by consumer, not by guessing.