AI EngineeringZero to ProductionHome·About·What’s new·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.
📋 Before you startThis page builds on: The inference cost model · Transformers. Jumping in? Skim those first — or open the recap.
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:

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:

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

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:

same 70B parameters · each step down halves the footprint fp32 · 4 B/param ≈ 260 GiB fp16 / bf16 · 2 B ≈ 130 GiB int8 · 1 B ≈ 65 GiB int4 · ½ B Read the bar widths, not the labels: the parameter count never changed — only the bytes each box occupies did. int4 is 8× smaller than fp32, for the very same model. Precision is box size, not box count. The bars are the same 70B weights at four precisions; each step down the ladder halves the bytes — which is exactly the lever quantization (IC2) pulls to make a model fit.
🗺️ How to read this diagram

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.

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

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:

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:

weights: fixed model weights same size always KV cache: two notes (K,V) per token, per layer, per user → batch = 1 seq_len → batch = 2 batch = 3 more users × longer context = bigger cache Weights sit still; the KV cache breathes with traffic. Every concurrent request keeps its own row of key/value notes, and each row lengthens with context — so batch × sequence length is what actually moves the memory bill at serving time.
🗺️ How to read this diagram

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.

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

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:

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:

serving weights · 2 B ≈ 2 B/param training (Adam, mixed precision) weights (bf16)· 2 B gradients(bf16) · 2 B Adam m (fp32)· 4 B Adam v (fp32)· 4 B + fp32 master · 4 B ≈ 14 B/param ≈ 7× serving Training stacks four more bills on the same weights. Serving pays ~2 B/param (just the weights); Adam training pays ~14 B/param — weights + gradients + two fp32 moments + an fp32 master copy — which is the ~7× gap, before a single activation.
🗺️ How to read this diagram

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 (m and v, 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.

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

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.

13B bf16 + KV · FITS 80 GB card weights ≈ 24 KV cache ≈ 25 free + 10% headroom 70B bf16 · OOM 80 GB card weights ≈ 130 overflows the ceiling usable ≈ 72 line Fit is a stacking problem against a fixed ceiling. Stack weights + KV cache and check the sum against usable VRAM (card minus ~10% headroom): the 13B leaves slack, the 70B in bf16 blows straight past the line — quantize or shard.
🗺️ How to read this diagram

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.

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

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:

attribute by WHEN the memory appears → attack that one read the allocator constant at load → weights climbs w/ concurrency → KV cache spikes at .backward() → activations / optimizer quantize (IC2) or shard (MS2) cap batch / context paged / quantized KV grad checkpointing LoRA (FT3), smaller batch Don't guess — attribute, then attack. The allocator tells you when the memory shows up; that timing names the consumer, and each consumer has its own cheapest lever. A bigger card is the last resort, never the first.
🗺️ How to read this diagram

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() and max_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

  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

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:

offline math vram_for_model() predict memory_allocated() resident now ≈ prediction max_memory_allocated() peak during fwd/bwd peak − resident = headroom needed the offline math proposes; the allocator disposes Predict, then verify. The offline calculator gives a number; the allocator confirms what's actually resident and what the peak reached — and the gap between resident and peak is the headroom you must budget, measured rather than guessed.
🗺️ How to read this diagram

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), and max_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.

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

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.

pull the levers in this order — cheapest first 1 · quantize weights (IC2) 2 · cap context & batch 3 · paged / GQA KV quantization 4 · shard GPUs (MS2) cheap expensive rising cost: accuracy → latency → cross-card communication escalate only when the cheaper lever is exhausted Levers, ordered by what they cost you. Quantizing weights is nearly free; capping batch/context trades latency; paged/quantized KV packs more per card; sharding adds cross-card communication — so it's the last reach, not the first.
🗺️ How to read this diagram

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.

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