AI EngineeringZero to ProductionHome·About·Contact
ML Systems Internals · Part 1

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.

⏱️ ~2 hours🧪 5 labs🎯 Advanced→Tech-lead

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:

ConsumerWhenRough size
Weightsinference + trainingparams × bytes/param
KV cacheinference (grows with tokens)2 × layers × heads × head_dim × seq × batch × bytes
Activationsmostly training (forward/backward)grows with batch × seq × hidden × layers
Optimizer statetraining 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.

Weights params×bytes KV cache grows w/ tokens Activations training fwd/bwd Optimizer training only Total VRAM must fit card
🗺️ How to read this diagram

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:

FormatBitsBytes/paramWhere used
fp32324legacy training, optimizer state
fp16 / bf16162standard training + serving
fp881H100-class training/inference
int881quantized serving (IC2)
int440.5aggressive 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.

Example code for learning — review, test, and adapt it before running against real or production systems. Commands can create, change, or delete resources. See the Terms & Disclaimer.
Python · weight-memory calculator (runs)
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
▶ How this works

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.

  1. vram_for_model(params_b, bytes_per_param) multiplies billions of params by the bytes each one takes, then divides by GIB (bytes in a gibibyte) to report GiB — the unit GPUs are sold in.
  2. BYTES maps each precision to its bytes/param: fp32=4, fp16/bf16=2, fp8/int8=1, int4=0.5. This is the constant that decides everything.
  3. 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.

✓ Knowledge check

A colleague says "Llama-3 70B is 70 GB, so it fits on an 80 GB A100." Where is the reasoning wrong?

Show answer
"70B" is a parameter count, not a byte count. At bf16 (2 bytes/param) the weights alone are 70e9 × 2 ≈ 130 GiB — it does not fit one 80 GB card. It only fits in int8 (~65 GiB) or int4 (~33 GiB), or across two cards. Always multiply params by bytes/param; never read the parameter count as gigabytes.

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.

Python · KV-cache size calculator (runs)
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
▶ How this works

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.

  1. 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.
  2. 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.
  3. 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.

Why KV cache dominates at serving timeWeights are a fixed one-time cost. KV cache scales with concurrency × context length — every extra user and every extra token of context adds to it. Doubling your context window or your batch size can double the cache. Capacity planning for inference is mostly KV-cache planning; the weights are the easy part.

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:

ComponentPrecisionBytes/paramNote
Weights (master)fp324the authoritative copy Adam updates
Gradientsfp16/bf162one per weight, each step
Adam moment mfp324running mean of gradients
Adam moment vfp324running 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.

Python · training vs inference memory (runs)
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
▶ How this works

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.

  1. inference_state_gb is only the bf16 weights — the whole memory bill for serving (plus KV cache, which this function omits).
  2. training_state_gb adds it up at 14 bytes/param: an fp32 master copy (4) + gradients (2) + Adam's mean m (4) + Adam's variance v (4).
  3. 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.

✓ Knowledge check

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
Training adds gradients (2 B/param) plus Adam's two fp32 moments (4 + 4 B/param) plus an fp32 master weight copy — ~14 B/param total ≈ 170 GiB for 13B, versus ~24 GiB to serve it in bf16. The optimizer state, not the weights, blows the budget. Fixes: LoRA/QLoRA (FT3) tune a tiny adapter so optimizer state is tiny, or shard across GPUs (MS2).

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.

Python · "does it fit on an 80 GB card?" checker (runs)
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
▶ How this works

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.

  1. fits_80gb reserves 10% headroom (usable ≈ 72 GiB) for CUDA context, allocator overhead, and temporary buffers — you never plan to 100% of the card.
  2. 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.
  3. 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).

Leave headroom — don't plan to 100%CUDA context, allocator fragmentation, temporary buffers during attention, and the occasional long request all eat VRAM you didn't budget. Plan to ~85–90% of the card and keep the rest as slack. A server sized to exactly 80.0 GB will OOM under real traffic.

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

  1. Read the allocator, don't guess. Log torch.cuda.memory_allocated() and max_memory_allocated() around load and around a forward pass to see what's resident vs. peak.
  2. 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.
  3. 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.
  4. Reduce activations if training. Enable gradient checkpointing, lower the micro-batch, use gradient accumulation to keep the effective batch while cutting peak memory.
  5. Fix fragmentation last. If allocated is well under total but you still OOM, set PYTORCH_CUDA_ALLOC_CONF=expandable_segments:True and avoid wild batch-size swings.
The production OOM that takes down the fleetA single unusually long request (say a 100k-token context) can spike KV cache far past the average and OOM the worker — which drops every in-flight request on that GPU, not just the offender. Always cap 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:

Python · ▶ needs a GPU + PyTorch (does NOT run offline)
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).
This block needs a GPU + PyTorchThe snippet above imports 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.

Predict, then verifyThe senior habit is: compute the budget with the offline calculators, pick the smallest hardware that fits with headroom, then confirm once with the real allocator (section 7). Teams that skip the arithmetic over-provision GPUs (burning money) or under-provision them (OOMing in production). The math is cheaper than either mistake.

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.

Exercise 1 · Bytes per parameterBeginner

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.

Exercise 2 · The KV-cache calculatorIntermediate

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.

Exercise 3 · Training memory blow-up (Adam)Advanced

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.

Exercise 4 · Does it fit? A fit-checkerExpert

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.

Exercise 5 · OOM triage decision treeProfessional

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.

Exercise 6 · Capacity plan: how many GPUs for the fleetIndustry scenario

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 + K KV 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.
© 2026 studybydoing.in · AI Engineering: Zero to Production · All rights reserved. · About · Privacy Policy · Terms · Contact
Educational content, provided as-is and without warranty. Code samples are examples — review, test, and adapt them before using in production. See the Terms of Use & Disclaimer. Use at your own risk.
© studybydoing.in