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.
1 · Where the VRAM actually goes essential
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
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:
| 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
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:
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
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:
| 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
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.
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
"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:
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
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:
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
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.
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.