WORKBENCH
05 · InferenceLesson 5.3

How much memory does a model need?

How much memory do the weights, the KV cache and activations take, and how many users fit?

Listen · 12 min0:00 / 12:01

A running language model is two things sitting in memory: a few billion fixed numbers that are the model itself, and a pile of notes it keeps about every conversation it is in the middle of, which grows with every word. This is how to size both on paper, before you download a single file.

That arithmetic decides which models your Spark can run at all, how long each conversation can get, and how many people can use it at once. Get it wrong and the server refuses to start. On the Spark it can be worse: the GPU and the rest of the computer share one pool of memory, so overshooting doesn’t just fail your script, it can make Linux start killing programs. And “how much memory does a 70B model need?” is a classic interview question with a crisp answer that most people fumble.

You have already met pieces of this. Why ~17 tokens per second? showed how many bytes each past word of a conversation costs. What does the OS do with my memory? showed how the Spark’s shared memory behaves when it fills up. Here you put them into one budget and check it against your own server from the baseline. Next, The KV cache, by hand has you build those conversation notes yourself, and batching turns “how many people fit” into “how fast each of them gets served”.

Today

Before this, you want Why ~17 tokens per second? (the per-word memory formula, and the trick of letting attention heads share their stored keys and values), Baseline: my Spark vs Fireworks (your server and benchmark), and What does the OS do with my memory? (the Spark’s shared memory, Linux’s file cache, and the out-of-memory killer). Budget one evening of arithmetic and one session on the Spark.

The Problem

Here is the question from the diagnostic: how much memory does an 8B model need? The honest answer you gave was a shrug, and the common wrong answer is “8 GB, one byte per parameter”. Neither will get you far.

Now make it real. Your team wants to use the Spark as a shared assistant: Llama 3.1 8B, long chats, 32K tokens of context each. You start vLLM, it thinks for a minute, and prints a line like maximum concurrency for 32,768 tokens per request: 24x. Is that good? Is it right? Where did 24 come from? Then you start it again with a 70B model in 4 bits at 128K context and it prints something like 2x. Then a colleague runs a notebook on the same box and something gets killed.

Every one of those numbers comes from a few lines of arithmetic. By the end of this lesson you will do that arithmetic faster than vLLM boots.

Predict firstwrite it down

Llama 3.1 8B, stored in bf16, serving one user with 8,192 tokens of context. How many GB of memory does it need in total? Then: on the Spark’s 128 GB, how many such users fit at 32K context each?

Reveal

About 17 to 18 GiB for one user: 15 GiB of weights (8.03 billion × 2 bytes = 16.1 GB, which is 15.0 GiB), 1 GiB of KV cache (8,192 tokens × 128 KiB), and one to two GiB of runtime overhead and activations. The weights are the big fixed cost; one user’s cache is small.

At 32K context each user’s cache is 4 GiB. After the operating system (call it 10 GiB, you will measure it), the weights and overhead, roughly 100 GiB is left, so about 25 users. Whether 25 users get a usable speed is a different question, and the envelope lesson showed that bandwidth runs out first on the Spark. Today is about the capacity half.

The Idea

Four buckets

Think of a restaurant kitchen during service. There is equipment bolted to the floor: ovens, fridges, the walk-in. That is the same size whether you serve one table or forty. Then each table has an order ticket and a tray of their own food in progress, and that space grows with the number of tables and how long each one stays. While a dish is being plated there is a burst of counter space in use, gone a minute later. And there is the space you can never use: walkways, the fire-code clearance, the manager’s desk.

GPU memory during inference has exactly those four buckets.

  1. Weights. The model itself. Bolted to the floor: loaded once, shared by every user. Size = parameters × bytes per parameter.
  2. KV cache. Each user’s private tray. Grows with every token of every conversation. Size = bytes per token × context length × users.
  3. Activations. The temporary numbers inside one forward pass: the vector flowing through a layer, the wide MLP intermediate, the scores at the end. Freed as soon as the layer is done. Small in decode, a short spike during prefill.
  4. Overhead. Memory that is in use but holds none of the above: the CUDA runtime’s own setup, library scratch space, memory the allocator holds but isn’t using, and on the Spark, the whole operating system.
FIG_001[ THE FOUR BUCKETS ]
128 GiB · ONE POOL · CPU + GPUOSW 15KV 8FREE · ~93 GiBOS + DESKTOP + CPU PROCESSESFIXED · SPARK ONLY (UNIFIED MEMORY)WEIGHTS = PARAMS × BYTES/PARAMFIXED · SHARED BY ALL USERSKV = BYTES/TOKEN × CONTEXT × USERSGROWSOVERHEAD + ACTIVATION PEAKFIXED-ISH · ~1-3 GiBFREE = WHERE MORE USERS, OR LONGER CONVERSATIONS, WOULD GO
Llama 3.1 8B in bf16 on the Spark, eight users at 8K context. Only one bucket grows with users and context. Sizes are approximate; the OS share is an assumption you will replace with a measurement.

The whole lesson is one equation and the details that make it honest:

users that fit  =  (memory − OS − weights − overhead − activation peak)  ÷  (KV bytes per token × context)

Weights and KV cache are pure arithmetic, so we start there.

Bytes, GB and GiB

One unit trap first, because it will otherwise eat 7% of every answer. A GBA gigabyte: 1,000,000,000 bytes (10⁹). Used by disk makers, Hugging Face file sizes, and network speeds. is a billion bytes. A GiBA gibibyte: 2³⁰ = 1,073,741,824 bytes, about 7% more than a GB. Memory chips come in powers of two, so RAM and GPU memory are really sized in GiB, and PyTorch and nvidia-smi (which prints MiB) report in binary units. is 2³⁰ bytes. Llama 3.1 8B in bf16 is 16.06 GB and 14.96 GiB; both are right. Memory is built in powers of two, so when you compare against what a machine holds, work in GiB. The KV cache formula is naturally binary (128 KiB per token, so 8,192 tokens is exactly 1 GiB), which is one more reason to stay there.

Counting parameters from config.json

Every model on Hugging Face ships a config.json. It is a dozen numbers that completely determine the shape of every weight matrix. Here are the ones that matter for Llama 3.1 8B:

{
  "vocab_size": 128256,
  "hidden_size": 4096,
  "intermediate_size": 14336,
  "num_hidden_layers": 32,
  "num_attention_heads": 32,
  "num_key_value_heads": 8,
  "head_dim": 128,
  "tie_word_embeddings": false,
  "torch_dtype": "bfloat16"
}

You have written this model, so you know every piece. The job now is just to say how big each one is. Call hidden_size h, vocab_size V, intermediate_size I, heads n, KV heads k, head_dim d.

The embedding table. One row of h numbers per vocabulary entry: V × h = 128,256 × 4,096 = 525,336,576. That is half a billion parameters before you reach the first layer, which surprises everyone the first time.

Attention, per layer. Four projection matrices. The query projection maps h inputs to n × d outputs. The key and value projections map h inputs to only k × d outputs, because with GQAGrouped-query attention: the model has fewer key/value heads than query heads, and each K/V head is shared by a group of query heads. Llama 3.1 8B has 32 query heads and 8 K/V heads, so groups of 4. there are only 8 key/value heads. The output projection maps n × d back to h.

q_proj   4096 × (32 × 128) = 4096 × 4096 = 16,777,216
k_proj   4096 × ( 8 × 128) = 4096 × 1024 =  4,194,304
v_proj   4096 × ( 8 × 128) = 4096 × 1024 =  4,194,304
o_proj   (32 × 128) × 4096 = 4096 × 4096 = 16,777,216
                                   total  = 41,943,040

That asymmetry, K and V a quarter the size of Q, is Shazeer’s idea from the box above, sitting in your parameter count.

The MLP, per layer. Llama uses SwiGLUA feed-forward block with three matrices instead of two: a “gate” and an “up” projection run side by side, the gate goes through the SiLU function and multiplies the up output elementwise, then a “down” projection maps back. More expressive per parameter than the old two-matrix MLP., so there are three matrices, not two: gate (h × I), up (h × I), down (I × h).

3 × 4096 × 14336 = 176,160,768

The MLP is four times the attention block. Most of an LLM’s parameters live here.

Norms. Each layer has two RMSNormA normalization that divides each vector by its root-mean-square and multiplies by a learned scale. The scale is one number per hidden dimension, so it has h parameters and no bias. layers (one before attention, one before the MLP), each with h = 4,096 learned scales: 8,192 per layer. One more after the last layer: 4,096. Rounding error, but count them; it is how you get an exact match.

The output head. The lm_head maps h back to V scores, another 525,336,576. Unless tie_word_embeddings is true, in which case the model reuses the embedding table and this costs zero. Llama 3.1 8B: false, so it is a separate matrix. Llama 3.2 1B: true, so it is shared. The flag is worth 525 million parameters (1 GB in bf16); always read it.

FIG_002[ CONFIG.JSON → PARAMETERS ]
MATRIXSHAPEPARAMSembed_tokensV × h = 128,256 × 4,096525.3M× 32 LAYERSinput_layernormh4,096q_proj4,096 × 4,09616.8Mk_proj4,096 × 1,024GQA: 8 KV HEADS4.2Mv_proj4,096 × 1,0244.2Mo_proj4,096 × 4,09616.8Mpost_attn_normh4,096gate_proj4,096 × 14,33658.7Mup_proj4,096 × 14,33658.7Mdown_proj14,336 × 4,09658.7Mper layerATTN 41.9M + MLP 176.2M + NORMS218.1Mnorm (final)h4,096lm_headh × V · tie_word_embeddings: false525.3MTOTAL = 2 × 525.3M + 32 × 218.1M + 4,0968,030,261,248
Llama 3.1 8B, one matrix per row. Bars inside the layer box are to scale; the embedding and output head are each about nine MLP matrices' worth and are drawn as text. Total matches transformers' own count exactly.

Add it up:

per layer      = 41,943,040 + 176,160,768 + 8,192         =   218,112,000
32 layers      = 32 × 218,112,000                          = 6,979,584,000
embed + head   = 2 × 525,336,576                           = 1,050,673,152
final norm                                                 =         4,096
total                                                      = 8,030,261,248

8.03 billion. I checked this against transformers itself: building the model from this config on PyTorch’s meta device (which creates tensor shapes without allocating any memory) and summing numel() over its parameters gives 8,030,261,248, to the parameter. The same method gives 1,235,814,400 for Llama 3.2 1B (tied head) and 70,553,706,496 for Llama 3.1 70B. You will write the counter yourself in Build It.

Two useful facts fall out of the breakdown. The MLP is 70% of the model, attention 17%, embedding and head 13%. And the shortcut for a quick estimate: per layer, attention is about 2h² + 2hkd and the MLP is 3hI, so for this family the layers come to roughly L × (2.5h² + 3hI). You can do that in your head in an interview: 32 × (2.5 × 16.8M + 176M) ≈ 7.0B, plus 1.05B of vocabulary matrices.

Predict firstwrite it down

Qwen2.5 7B’s config: vocab_size 152,064, hidden_size 3,584, intermediate_size 18,944, 28 layers, 28 attention heads, 4 key/value heads, untied head. One difference from Llama: its q, k and v projections have a bias vector (one extra number per output). How many parameters?

Reveal

head_dim = 3,584 / 28 = 128, and k = 4 KV heads, so k × d = 512.

  • Attention weights: q 3,584 × 3,584 = 12,845,056; k and v 3,584 × 512 = 1,835,008 each; o 12,845,056. Sum 29,360,128.
  • Attention biases: 3,584 + 512 + 512 = 4,608. Attention total 29,364,736.
  • MLP: 3 × 3,584 × 18,944 = 203,685,888. Norms: 2 × 3,584 = 7,168.
  • Per layer 233,057,792; × 28 layers = 6,525,618,176.
  • Embedding and head: 2 × 152,064 × 3,584 = 1,089,994,752. Final norm 3,584.

Total 7,615,616,512, which matches transformers exactly. The biases are only 129,024 parameters in all. You would never notice them in “≈7.6B”, and that is the point of counting exactly: if your counter is off by 129,024, you know precisely what you forgot.

Bytes per parameter

Weights in memory = parameters × bytes per parameter. The bytes depend on the format.

The cleanest example is llama.cpp’s GGUFThe single-file model format used by llama.cpp. It holds the weights (in many possible quantized types), the tokenizer, and the config in one file. type Q8_0: blocks of 32 weights, each stored as a signed 8-bit integer, plus one 16-bit scale per block. 32 bytes + 2 bytes = 34 bytes per 32 weights = 8.5 bits per weight.

FIG_003[ BITS PER WEIGHT ]
ONE Q8_0 BLOCK32 × INT8 WEIGHTS = 32 BYTESFP16 SCALE = 2 B34 B / 32 = 8.5 BITSWEIGHT ≈ INT8 × SCALE · ONE SCALE PER 32 NEIGHBOURSLLAMA 3.1 8BBITS/WSIZEFP323232.1 GBBF161616.1 GBFP8~9.0LAYERS 8 BIT, EMBED + HEAD 16~9.1 GBQ8_08.5~8.5 GBAWQ INT4~5.74 BIT + SCALES, EMBED + HEAD 16~5.7 GBQ4_K_M~4.9MIXED 4 + 6 BIT BLOCKS~4.9 GBSIZE = Σ (PARAMS IN TENSOR × ITS BITS) ÷ 8
Top: one Q8_0 block, 32 one-byte integers plus a 2-byte scale. Bottom: Llama 3.1 8B in each format. Effective bits include scales and any tensors left in 16-bit; GGUF and AWQ sizes are approximate and vary a little by file.

The other two 4-bit formats you will meet:

The general rule: file size on disk is a very good estimate of weight memory. Almost every byte of a safetensors or GGUF file is weight. When you do not know a format’s internals, ls -l the file and divide by the parameter count to get its true bits per weight.

The KV cache: per token, per request, per batch

You met the formula in the bandwidth lesson. Here it is again, because now it is the main event:

KV bytes per token  =  2 (K and V) × layers × kv_heads × head_dim × bytes per value

Llama 3.1 8B, bf16:   2 × 32 × 8 × 128 × 2  =  131,072 B  =  128 KiB

Read each factor. The 2 is because every layer stores a key vector and a value vector for every past token. Layers multiply because each layer has its own attention. And only the KV heads count, not the query heads, because queries are recomputed from the current token every step and never stored. That last point is why GQA’s 8-instead-of-32 heads is a 4x discount on memory.

From the per-token number, everything else is multiplication:

Llama 3.2 1BLlama 3.1 8BQwen3 30B-A3BLlama 3.1 70B
layers × kv_heads × head_dim16 × 8 × 6432 × 8 × 12848 × 4 × 12880 × 8 × 128
KV per token (bf16)32 KiB128 KiB96 KiB320 KiB
one request at 8K0.25 GiB1 GiB0.75 GiB2.5 GiB
one request at 128K4 GiB16 GiB12 GiB40 GiB
32 requests at 8K8 GiB32 GiB24 GiB80 GiB

Notice the 70B’s 32-user row: the KV cache alone would fill an H100. Notice the 8B at 128K: one user’s cache weighs more than the whole model. And notice the MoE: 30 billion parameters but a smaller cache per token than the 8B, because it has only 4 KV heads. Cache size depends on the attention shape, not on the parameter count.

Two practical modifiers. First, store the cache in FP8 and every number in that table halves; most serving engines offer it as one flag, with some accuracy cost you would measure. Second, “context” means the whole conversation so far, prompt plus everything generated. A request that allows 8K tokens grows toward that over its life; a serving engine has to decide whether to reserve the worst case up front or allocate as it goes. That decision is what paged attention is about.

Predict firstwrite it down

Llama 3.1 70B in Q4_K_M (about 43 GB on disk) on the Spark. Ten users at 32K context each. Does it fit? If not, what is the cheapest fix?

Reveal

Weights: 43 GB is about 40 GiB. KV per user: 32,768 × 320 KiB = 10 GiB. Ten users: 100 GiB. Total ≈ 40 + 100 + a couple of GiB overhead + ~10 GiB OS ≈ 152 GiB. No, it is about 24 GiB over.

The cheapest fix is an FP8 KV cache: 5 GiB per user, 50 GiB for ten, total about 102 GiB, which fits. Alternatives: cut context to 16K (same effect), or serve seven users. Notice that shrinking the weights further barely helps here; at this context the cache is most of the memory.

Activations: small, temporary, spiky

During a forward pass, each layer produces temporary tensors: the normed input, q, k and v, the attention output, and the MLP’s wide intermediates. Those are activationsThe intermediate values computed during a forward pass. In training you keep them for the backward pass; in inference you can throw each one away as soon as the next layer has used it.. At inference, nobody needs them after the next layer runs, so PyTorch frees them right away. Only one layer’s worth is alive at a time.

In decode, a step processes one new token per user. For Llama 3.1 8B the biggest temporaries are the gate and up projections, 14,336 numbers each. Per token, everything alive in one layer is roughly (4h + 3I) × 2 bytes ≈ 120 KB. Times 64 users is under 8 MB. Noise.

In prefill, the step processes the whole prompt at once, so every temporary is N tokens tall. At N = 8,192 the MLP alone briefly holds about 8,192 × 3 × 14,336 × 2 bytes ≈ 0.7 GB, and the full set is about 1 GB. It lasts milliseconds, but it is the peak that has to fit, not the average. Two things make the peak much worse if you are careless:

Real engines cap the activation peak on purpose. vLLM, for example, splits long prompts into chunks and never runs more than a set number of tokens through the model in one forward pass (its max_num_batched_tokens setting), so the spike has a known size no matter how long the prompt.

Overhead: the memory nobody asked for

This is the bucket that makes careful predictions wrong by a gigabyte or three, so it is worth knowing its parts.

The CUDA context. The first time a process touches the GPU, the CUDA driver sets up its state: loaded GPU code for every kernel library you link (PyTorch ships many), internal tables, scratch areas. That memory belongs to the driver, not to PyTorch, so PyTorch’s own counters never show it. On discrete GPUs it is typically a few hundred MB to around 1 GB per process, depending on versions and on whether the driver loads kernel code lazily. Measure yours; it is per process, so two Python processes using the GPU pay it twice.

The caching allocator. Asking the CUDA driver for memory (cudaMalloc) and giving it back is slow and forces the GPU to synchronize. So PyTorch keeps a caching allocatorPyTorch’s GPU memory manager. When a tensor is freed, it keeps the block for reuse instead of returning it to the driver, so the next allocation of a similar size is instant.: freed blocks stay in PyTorch’s pool for the next tensor. That gives you two numbers:

The gap between them is memory that is free to PyTorch but not to anyone else. If the pool’s free blocks are the wrong sizes for a new request (a 300 MB tensor needs 300 contiguous MB within a block, and the pool has ten 100 MB holes), PyTorch has to ask the driver for more. That waste is fragmentationFree memory broken into pieces too small or oddly placed to satisfy a request. The total free is enough; no single piece is., and it is why “out of memory” can happen when the numbers say there should be room. A tensor that grows by concatenation is the worst case: Hugging Face’s default DynamicCache appends each new token’s keys and values with torch.cat, which allocates a slightly bigger tensor and frees the old one, every step, in every layer.

I watched the caching behaviour on my Mac’s GPU backend, which has the same design: allocating a 256 MiB tensor made the driver hold 1,024 MiB (it reserves in big chunks); after del, live tensors were 0 MiB while the driver still held 1,024 MiB; only torch.mps.empty_cache() gave it back. On CUDA, torch.cuda.empty_cache() does the same job. You will see the CUDA version of this on the Spark.

Everything else. cuBLAS scratch space, NCCL buffers if you use several GPUs, and in serving engines, memory captured by CUDA graphs (pre-recorded sequences of kernel launches that cut Python overhead, each with its own fixed buffers). Budget about 1 to 3 GiB in total for a single-GPU server and then measure.

FIG_004[ ONE REQUEST, AS POOLS SEE IT ]
01522GiBCUDA CONTEXT · OUTSIDE PYTORCH’S COUNTERSLOADWEIGHTSPREFILL PEAK+4 GiB KV KEPT · +128 KiB/TOKENCACHE FREEDRESERVED STAYSmemory_allocated()memory_reserved()
Llama 3.1 8B bf16, one 32K-token prompt, then decoding. Illustrative shape, not a measurement: the solid line is live tensors, the dashed line is what PyTorch holds. After the prefill spike, reserved never comes back down on its own.

The Spark: one pool, shared with everything

On an H100 or a 4090, the GPU’s memory is its own. The operating system, your browser, your Python process’s CPU-side objects all live in separate system RAM. The 80 GB is yours.

The Spark has no separate GPU memory. As you saw in the virtual memory lesson, its 128 GB is one pool shared by the CPU and GPU. So the budget has a new line at the top: the OS, the desktop if one is running, every CPU process, and the page cache all come out of the same 128 GB. Some of that is reclaimable (the kernel will drop cached file pages under pressure); some is not. And the failure mode is different: on a discrete card, running out gives you a polite Python exception. On the Spark, the pressure lands on the whole machine, and you may meet the kernel’s OOM killer instead, or a frozen desktop while the system thrashes.

Two consequences for measuring. Tools built for discrete GPUs may not know what to report: on unified-memory systems nvidia-smi’s memory columns can read “Not Supported” or show something other than what you expect, so check what yours prints and do not trust it blindly. And the honest “how much can I still use?” number comes from Linux: free -h’s available column, or MemAvailable in /proc/meminfo. PyTorch’s torch.cuda.mem_get_info() asks the CUDA driver the same question; compare the two on your machine, because on unified memory the driver’s idea of “free” may not count file cache the kernel would happily drop.

Serving engines take it all up front

A serving engine cannot allocate KV cache memory one request at a time with torch.cat and hope; it would fragment, and it would discover it was out of memory in the middle of a user’s answer. So engines like vLLM grab the cache’s memory once, at startup, and hand out fixed-size pieces of it.

vLLM’s version, roughly (the details move between versions; check the docs for yours):

  1. You set gpu_memory_utilizationA vLLM setting (default 0.9): the fraction of the GPU’s total memory this vLLM instance is allowed to use for everything, weights and cache included. (default 0.9). That is the instance’s budget: 0.9 × total memory.
  2. It loads the weights.
  3. It runs a fake forward pass at the largest size it will ever run (max_num_batched_tokens tokens) and records the peak activation memory, plus whatever non-PyTorch memory has appeared.
  4. Whatever is left of the budget becomes the KV cache pool, divided into blocks of a few tokens each. It logs the pool’s size in tokens, and the “maximum concurrency” it implies: pool tokens ÷ max_model_len.
FIG_005[ HOW VLLM CARVES THE BUDGET ]
H100 · 80 GBW 15KV POOL ≈ 54 GiB ≈ 440K TOKENS10%0.9 × TOTALACT + RUNTIME≈ 54 USERS AT 8K · ≈ 13 AT 32KDGX SPARK · 128 GiB UNIFIEDW 15KV POOL ≈ 98 GiBOS0.9 × TOTALTHE LAST ~13 GiB MUST HOLD THE OS, YOUR SHELL, YOUR NOTEBOOK, AND THE PAGE CACHE
Llama 3.1 8B bf16 at the default gpu_memory_utilization of 0.9. On an H100 the leftover 10% sits unused. On the Spark the same setting leaves 10% of one shared pool for the OS and everything else you run. Activation and overhead sizes are approximate.

This explains the opening scene. vLLM is not guessing when it says “maximum concurrency 24x”; it is dividing the pool it carved by the context you allowed. It also explains the colleague’s notebook. On the Spark, gpu_memory_utilization is not “how much of the GPU”, it is “how much of the whole computer”. NVIDIA’s DGX Spark playbooks show the settings they test with; look there before choosing yours, and leave real headroom. Newer vLLM versions also let you give the KV cache size directly in bytes instead of a fraction; check vllm serve --help for what your version supports.

Putting it together

Now play with all of it. Each bar is drawn to its own machine’s capacity. The Spark’s first segment is the operating system and everything else that lives in its shared pool; it is set to 10 GiB as an assumption until you measure it. Overhead is 1.5 GiB of runtime plus a computed activation peak for an 8K-token prefill chunk.

FIG_006[ THE MEMORY BUDGET ]
Model
Weights
KV cache
Weights
-
KV per token
-
KV per user
-
KV, all users
-

Weights, KV cache and overhead for one model on three machines. 'Max users' is (capacity − reserve − weights − overhead) ÷ KV per user. Effective bits per format and the overhead figures are approximations; the arithmetic is exact.

Things to try:

One thing this figure deliberately ignores: speed. A user who fits is not a user who is served well. On the Spark, 100 users at 8K means every decode step reads 15 GiB of weights plus 100 GiB of caches through 273 GB/s, about 0.45 seconds per step, or about 2 tokens per second each. The envelope lesson did that half. Capacity tells you who can be in the room; bandwidth tells you how fast the room moves.

Build It

Everything goes in inference/memory/ next to last lesson’s server and harness. The first three tasks run anywhere (do them on your Mac); the last three need the Spark.

Fetch real configsAgent: fine

You need only the small JSON files, not the weights. huggingface_hub downloads single files. Llama models are gated: accept the license on each model page first, and log in with the Hugging Face CLI (check its docs for the current login command).

# fetch_configs.py
import json, pathlib
from huggingface_hub import hf_hub_download

REPOS = [
    "meta-llama/Llama-3.2-1B",
    "meta-llama/Llama-3.1-8B",
    "meta-llama/Llama-3.1-70B",
    "Qwen/Qwen2.5-7B",
    "Qwen/Qwen3-8B",
    "Qwen/Qwen3-30B-A3B",
]
out = pathlib.Path("configs")
out.mkdir(exist_ok=True)
for repo in REPOS:
    name = repo.split("/")[1]
    cfg = json.load(open(hf_hub_download(repo, "config.json")))
    (out / f"{name}.json").write_text(json.dumps(cfg, indent=2))
    try:  # sharded safetensors checkpoints have an index whose metadata gives total bytes
        idx = json.load(open(hf_hub_download(repo, "model.safetensors.index.json")))
        cfg_total = idx.get("metadata", {}).get("total_size")
        print(f"{name:20s} total_size={cfg_total}")
    except Exception as e:
        print(f"{name:20s} no index ({type(e).__name__})")

The total_size in the index is the checkpoint’s weight bytes. For a bf16 checkpoint, divide by 2 and you have the official parameter count to test against. For Llama 3.1 8B it should be 16,060,522,496, which is exactly 2 × 8,030,261,248.

Write the parameter counterHands only

Write params.py with one function that takes a config dict and returns a breakdown. No transformers, no torch: just the arithmetic from Figure 002.

def count_params(cfg: dict) -> dict[str, int]:
    """Return {'embed': ..., 'attn': ..., 'mlp': ..., 'norms': ..., 'lm_head': ..., 'total': ...}
    summed over all layers. Dense Llama/Qwen-style decoders only (MoE is a stretch goal)."""
    ...

Then a check that must pass:

EXPECTED = {
    "Llama-3.2-1B": 1_235_814_400,
    "Llama-3.1-8B": 8_030_261_248,
    "Llama-3.1-70B": 70_553_706_496,
    "Qwen2.5-7B": 7_615_616_512,
    "Qwen3-8B": 8_190_735_360,
}

Hints, in the order you will need them:

  • head_dim is not always in the config. When it is missing, it is hidden_size // num_attention_heads. When it is present, use it: Qwen3 sets it explicitly, and n × d need not equal h in general.
  • tie_word_embeddings decides whether lm_head costs anything. Treat a missing key as false for these models, but print a warning; it is the single most expensive line in the config.
  • Qwen2.5 has biases on q, k and v (not o). Look for attention_bias in the config, and if it is absent, know your model family. Count each bias once.
  • Qwen3 adds two small RMSNorms inside attention, on the queries and keys, each of size head_dim, per layer. If you are 9,216 short on Qwen3-8B (36 layers × 2 × 128), that is them.
  • Print your breakdown as percentages. The MLP share should be about 70% for Llama 3.1 8B.
  • Stretch: Qwen3-30B-A3B is a mixture of experts. Its config has num_experts, num_experts_per_tok and moe_intermediate_size, and each layer has a small router matrix (hidden × experts). Return total and active parameters separately; you should get about 30.5B and 3.3B.
Write the memory calculatorHands only

Build on count_params. Same file or plan.py:

GiB = 2**30

FORMATS = {  # effective bits per weight: (layers, embedding + lm_head)
    "bf16": (16, 16), "fp8": (8, 16), "q8_0": (8.5, 8.5), "awq": (4.16, 16), "q4_k_m": (4.9, 4.9),
}

def weight_bytes(cfg: dict, fmt: str) -> float: ...
def kv_bytes_per_token(cfg: dict, kv_bytes: int = 2) -> int: ...
def plan(cfg, fmt, ctx, users, capacity_gib, reserve_gib=0.0, overhead_gib=1.5, kv_bytes=2) -> dict:
    """Return each bucket in GiB, the total, whether it fits, and max_users at this ctx."""
    ...

Then print a table for each model in configs/, for the Spark (capacity 128, reserve: your measured number from t4, use 10 until then), an H100 (80) and a 4090 (24), at 8K and 32K. Check a few rows against Figure 006; they should agree to within rounding.

Hints: max_users is a floor, not a round. The activation peak depends on how many tokens you push through at once; start with a flat overhead, and replace it with your t5 measurement. Do the arithmetic in bytes and convert to GiB only when printing.

Read the real numbers on the SparkAgent: explain only

Before you can check a prediction you need a reliable way to read memory. Here is the probe; it is plumbing, so take it. Run it inside the same PyTorch container as last lesson.

# probe.py: one line of every memory number we care about.
import os, time, torch

GiB = 2**30

def meminfo_available() -> float:
    with open("/proc/meminfo") as f:
        for line in f:
            if line.startswith("MemAvailable:"):
                return int(line.split()[1]) * 1024 / GiB  # value is in KiB
    return float("nan")

def rss() -> float:
    with open(f"/proc/{os.getpid()}/status") as f:
        for line in f:
            if line.startswith("VmRSS:"):
                return int(line.split()[1]) * 1024 / GiB
    return float("nan")

def snap(label: str) -> dict:
    if torch.cuda.is_initialized():
        torch.cuda.synchronize()
        free, total = torch.cuda.mem_get_info()
        alloc, resv, peak = (torch.cuda.memory_allocated(), torch.cuda.memory_reserved(),
                             torch.cuda.max_memory_allocated())
    else:
        free = total = alloc = resv = peak = float("nan")
    row = dict(label=label, t=time.time(), alloc=alloc / GiB, reserved=resv / GiB, peak=peak / GiB,
               cuda_free=free / GiB, cuda_total=total / GiB, os_available=meminfo_available(), rss=rss())
    print(" ".join(f"{k}={v:.2f}" if isinstance(v, float) else f"{k}={v}" for k, v in row.items()))
    return row

Now write the experiment, in this order, calling snap() after each step:

  1. Python started, import torch only. (Don’t touch CUDA yet.)
  2. torch.zeros(1, device="cuda"): the CUDA context is now created. The drop in os_available and cuda_free with almost no change in alloc is the context.
  3. Load Llama 3.1 8B in bf16 straight to the GPU (device_map="cuda" in from_pretrained, so you never hold a CPU copy; see the virtual-memory lesson for why). alloc should be within a few MB of 14.96 GiB. If it is 29.9, something is in fp32.
  4. Run generate with a 1K prompt and 1 new token. Then 4K, 16K, 32K (use attn_implementation="sdpa"). Before each, call torch.cuda.reset_peak_memory_stats(). Record steady alloc while you still hold the returned cache (pass return_dict_in_generate=True and keep past_key_values) and peak.
  5. Delete the cache, then torch.cuda.empty_cache(), and snap again.

In another terminal, run free -h and nvidia-smi at the same moments and write down what each says. Ask an agent to explain any number that confuses you; don’t let it write the experiment.

Safety: on the Spark, running out of memory can take down more than your script. Run these experiments in a container with a memory limit (Docker’s --memory, or a cgroup as in the virtual-memory lesson) set below what the machine has, so the explosion stays in the box.

Grow context until it breaksHands only

Write edge.py. Your calculator predicts the largest number of sequences B at context L that fit. Find the real edge.

def fits(model, batch: int, ctx: int) -> bool:
    """Prefill `batch` random prompts of length ctx, decode 8 tokens, keep the cache.
    Return False on torch.cuda.OutOfMemoryError (clean up: del, gc.collect(), empty_cache())."""
    ...

def find_edge(model, ctx: int, lo: int, hi: int) -> int:
    """Binary search for the largest batch that fits at ctx."""
    ...

For each L in 4K, 16K, 32K: predict B from plan() first, write it down, then search. Use random token ids (torch.randint(0, vocab, (B, L))) so you don’t need real text. Log peak and reserved for the largest B that fit.

Hints: a failed allocation leaves the allocator in a messy state; free everything and empty_cache() before the next try. Prefilling a large batch at once has a huge activation spike; that spike, not the cache, may be what fails first. That is a finding, not a bug. Record which one it was by comparing peak to steady alloc. On the Spark, the container’s memory limit or the OOM killer may end the process instead of raising an exception; run each trial in a subprocess so the search survives.

Check vLLM's arithmeticAgent: explain only

Start vLLM with Llama 3.1 8B in bf16 inside NVIDIA’s container for the Spark (follow the DGX Spark playbook for the image and the flags they test with). Set --max-model-len 32768 and a --gpu-memory-utilization you have chosen on purpose, with your measured OS reserve in mind. Do not use 0.9 blindly on this machine.

Before it finishes starting, predict from your calculator: KV pool in GiB, pool in tokens (÷ 128 KiB), and maximum concurrency at 32K. Then read the startup log. Look for the lines that report the KV cache size in tokens and the maximum concurrency (wording differs between versions). Compare.

Then point last lesson’s bench.py at it with --concurrency just above the predicted maximum and long prompts that fill the context. Watch what vLLM does with the request that does not fit: does it queue it, or start it and later preempt someone? Its logs and metrics will tell you. That behaviour is the batching lesson’s subject; for now, just record that the limit you computed is a real wall.

Done when
  • count_params matches all five expected counts exactly, and you can say from memory roughly what fraction of Llama 3.1 8B is MLP.
  • plan() prints a table for six models on three machines, and agrees with Figure 006.
  • You have a probe log on the Spark from cold start through a 32K prefill, with the CUDA context size, the weights, and the KV growth each identified.
  • You have found the real max batch at three context lengths and know, for each, whether the cache or the prefill spike was what failed.
  • vLLM’s reported KV pool is within a few percent of your prediction, or you can explain the difference.

Measure

Fill these in. Predictions go in before you run anything.

Parameter counts (your counter vs the checkpoint’s total_size ÷ bytes per param):

ModelYour countOfficialOff by
Llama 3.2 1B1,235,814,400
Llama 3.1 8B8,030,261,248
Llama 3.1 70B70,553,706,496
Qwen2.5 7B7,615,616,512
Qwen3 8B8,190,735,360

The probe on the Spark, Llama 3.1 8B bf16:

StagePredicted allocallocreservedpeakos_available dropcuda_free drop
import torch0
CUDA context0
weights loaded14.96 GiB
+ 1K prefill+0.125
+ 4K prefill+0.5
+ 16K prefill+2
+ 32K prefill+4
cache freed + empty_cache14.96

The edge:

ContextPredicted max batchMeasured max batchWhat failed first (cache or spike)
4K
16K
32K

vLLM: predicted KV pool ___ GiB / ___ tokens; logged ___ tokens; max concurrency predicted ___, logged ___.

What the numbers should look like, and why. These are expectations to check, not results.

Break It

Ask for every logit. Replace generate with a plain model(input_ids) on a 32K prompt. Predict first: 32,768 × 128,256 × 2 bytes. Watch peak. The fix is one argument: request logits only for the last position (logits_to_keep=1 in current transformers; check the model’s forward signature in your version). Lesson: a single tensor nobody needed can be bigger than the whole KV cache.

Use textbook attention. Load the model with attn_implementation="eager" and prefill 8K, then 16K tokens. Predict the score matrix: heads × N² × 2 bytes per layer, plus an fp32 softmax copy. At 16K that is tens of GiB for a single layer’s temporaries; it will fail where SDPA sailed through. Lesson: the activation peak depends on the algorithm, not just the model, which is the whole reason FlashAttention exists.

Run two at once. Start two vLLM instances on the Spark, each with the default gpu_memory_utilization. The second one will fail at startup, or, worse, the first one’s budget plus the OS will leave nothing for the second and the machine will start reclaiming and swapping. Then try a notebook that loads a model while one vLLM instance is running. Lesson: on unified memory, a serving engine’s budget is a claim on the whole machine. Write the reserve you need into your launch script.

Fragment the pool. Generate 4,000 tokens one at a time at batch 8 with DynamicCache, snapping every 250 tokens, and plot reserved − alloc. Then repeat with a pre-sized cache (transformers’ StaticCache, which allocates the full length once; check the docs for how to pass it to generate) and compare. Try also with PYTORCH_CUDA_ALLOC_CONF=expandable_segments:True, which lets the allocator grow a segment in place instead of making new ones. Lesson: allocating once and slicing beats growing, which is exactly what serving engines do.

Explain It Back

Answer each out loud before opening it.

1. How much memory does Llama 3.1 8B need to serve one user, and where does it go?

About 17 to 18 GiB at 8K context in bf16. The weights are 8.03 billion parameters × 2 bytes, 16.1 GB or 15 GiB, loaded once. The KV cache is 128 KiB per token (2 for K and V, × 32 layers × 8 KV heads × 128 dims × 2 bytes), so 1 GiB at 8K. Then one to two GiB of overhead: the CUDA context, library workspaces, and the activation peak during prefill. In 4-bit the weights drop to about 5 GB, but the cache and overhead don’t change. The answer people usually give, “8 GB”, assumes one byte per parameter and forgets the cache.

2. Count a model’s parameters from its config, in under a minute.

Embedding is vocab × hidden. Each layer has attention, which is q (hidden × heads × head_dim), k and v (hidden × kv_heads × head_dim each, smaller with GQA) and o (the same size as q), plus an MLP of three hidden × intermediate matrices for SwiGLU, plus two norms of size hidden. Multiply by the number of layers. Add a final norm and, if embeddings are not tied, an output head the same size as the embedding. For Llama 3.1 8B: 2 × 525M + 32 × 218M + 4K = 8,030,261,248. Check for biases, extra norms, or experts in other families, and check the result against the checkpoint’s total bytes.

3. How does memory scale with context, users, and model size? Which one bites first?

Weights scale with parameters and bytes per parameter and are paid once, no matter how many users. The KV cache scales linearly with context × users × (layers × KV heads × head_dim), so doubling either context or users doubles it. Activations scale with how many tokens you push through at once and are capped by chunking. For short chats and one user, the weights dominate. For many users or long contexts, the cache overtakes them quickly: at 128K context one user of an 8B model needs more cache than the whole model. So on a server, users × context is what you are really buying memory for, and FP8 KV caches, GQA and paging are the levers. On the Spark there is a second wall: everything you fit also has to be read every step, so bandwidth limits users before capacity does.

4. Why does vLLM grab 90% of GPU memory at startup, before any request?

So it never has to allocate during a request. At startup it loads the weights, runs a fake forward pass at its largest batch to measure the activation peak, and turns the rest of its budget (gpu_memory_utilization × total) into a pool of fixed-size KV blocks. Requests get blocks from the pool and give them back; there is no fragmentation and no surprise out-of-memory halfway through an answer. The cost is that the memory looks used even when idle. On a dedicated GPU that is fine. On the Spark, where the pool is the whole computer, the setting has to leave room for the operating system and everything else.

5. memory_allocated says 15 GiB, but the system says 17 GiB are gone. Where are the other 2?

memory_allocated counts only live tensors. memory_reserved adds the blocks PyTorch’s caching allocator is holding for reuse, which after a prefill spike can be gigabytes. Below that, the CUDA context and library code belong to the driver, invisible to PyTorch’s counters. Other processes using the GPU add their own. And on the Spark, the OS, CPU-side Python objects and the page cache share the same pool, so free -h sees all of it. To account for it: compare alloc, reserved, the drop in the driver’s free memory, and the drop in MemAvailable, step by step from a cold start.

Go Deeper

Connections

Built on: Why ~17 tokens per second? (the KV formula and GQA), Back-of-the-envelope (users per GPU, and why bandwidth is the Spark’s first wall), and virtual memory (unified memory, the page cache, and the OOM killer you will meet if you skip the container limit).

Next: The KV cache, by hand, where you build the cache this lesson only measured and watch your own GPT-2 speed up. Then batching, where “max users” turns into throughput, and paged attention, where the pool vLLM carved gets split into blocks and shared. Quantization is the other side of the weights bucket: you now know what a 4-bit file costs in bytes; that lesson measures what it costs in quality. And when you are ready for the bigger version of this lesson, Why training needs so much memory adds gradients, optimizer states and the activations that inference gets to throw away.