Appendix A: VRAM arithmetic tables

Every serving and training decision in this book is an accounting problem against one number: the 16 GiB of GDDR7 on the RTX 5080 (the baseline machine). This appendix precomputes that accounting for every model and config the book uses, so that when a chapter says "the weights are GiB" or "this leaves for cache" the number is already sitting here with its formula above it. Everything is regenerable: each table is preceded by the equation that produced it, so when a model revision or a dtype changes you re-run the arithmetic rather than trusting a stale figure.

Two conventions hold throughout, both inherited from Tensors, autograd, and number formats and KV cache arithmetic:

  • GiB vs GB. Memory capacity is binary: . Vendor throughput and bandwidth are decimal: . The card is 16 GiB of capacity; its bandwidth is ~960 GB/s. I keep the units explicit because mixing them is a ~7% error that hides exactly at the margin where things stop fitting.
  • Measured vs derived. Every number here is derived arithmetic unless it carries the tag (measured on the baseline machine — record value, date, driver). Derived numbers are exact given their inputs; the inputs that must be confirmed on-device (activation overhead, allocator slack, real weight-file sizes) are flagged at each table.

Bytes per parameter, by dtype

The atom of every weight budget is numel × bytes_per_element, from Tensors, autograd, and number formats. For the quantized formats the effective figure includes the per-group scales, so it is not a clean power of two.

Reading the dtypes

A one-line gloss on the names in the column below, so the table reads without a detour:

  • FP32 / FP16 / BF16 / FP8 are floating-point: a sign bit, an exponent (which buys range), and a mantissa (which buys precision); fewer total bits buy memory. BF16 keeps FP32's full exponent, its whole range, at half the bytes, which is why mixed-precision training lives in it.
  • INT8 / INT4 are integer quantization: map weights onto a small grid of evenly spaced integers with a stored scale factor.
  • NF4 ("NormalFloat4") is the 4-bit format QLoRA freezes weights in; its 16 levels sit at the quantiles of a normal distribution (weights are roughly Gaussian), so it spends precision where the mass is. ~4.13 effective bits once the block scale is counted.
  • MXFP4 ("microscaling FP4") is the 4-bit format gpt-oss ships in: 4-bit floats sharing one 8-bit (E8M0) scale per small block.
  • AWQ / GPTQ are not storage dtypes but post-training quantization methods: they decide how to round a trained model's weights down to ~4 bits (AWQ protects activation-salient channels; GPTQ uses second-order / Hessian information), and the result is stored as INT4-with-group-scales.

Chapter 1.1 (Tensors, autograd, and number formats) walks the float formats bit by bit; chapter 2.3 (Quantization: theory and formats) derives the methods. Appendix D glosses the acronyms.

dtypelayout (s, e, m)bits/param (byte/param)notes
FP321, 8, 23324.0master weights, optimizer state
BF16 / FP161, 8, 7 / 1, 5, 10162.0default working dtype on Blackwell
FP8 (e4m3 / e5m2)1, 4, 3 / 1, 5, 281.0inference weights / KV; native on Blackwell
INT4 / AWQinteger + group scale~4.5~0.564 bits + FP16 scale per group-128
NF4 (+ double-quant)4-bit quantile + scale~4.13~0.516QLoRA base weights; block 64, DQ block 256
MXFP4 (e2m1 + E8M0)4-bit float + block scale~4.25~0.53gpt-oss; block 32, shared power-of-two scale

Where the fractional bits come from

INT4/AWQ store one FP16 scale (and sometimes a zero-point) per group of 128 weights: bits, and real checkpoints round up to ~4.5 with zero-points and packing overhead. NF4 with double quantization is (one FP32 absmax per block of 64) compressed by a second quantization of those absmaxes to bits. MXFP4 is bits. These are derived from the Quantization: theory and formats chapter; confirm the exact bit-width against the specific checkpoint, since packing conventions differ.

Weight budgets for the serving repertoire

Parameter counts are the published totals; verify against each model's config.json and safetensors index, since revisions move them.

Modeldtype (byte) (GiB)
Qwen3-8BBF162.015.3
Qwen3-8BFP81.07.6
Qwen3-14BBF162.027.6 (does not fit)
Qwen3-14BAWQ 4-bit0.567.7
gpt-oss-20b totalMXFP40.53~10.4*

*The ~10.4 GiB gpt-oss figure is the naive MXFP4-only estimate (all params at 0.53 byte). The shipped checkpoint is ~12-13 GiB because attention, embeddings, and the router are kept at higher precision; only the expert MLPs are MXFP4.

What actually fits, and why the repertoire is what it is

The card is 16 GiB. vLLM claims a fraction via --gpu-memory-utilization; the rest is weights + activation/CUDA-graph overhead (~1 GiB, measure it) + KV pool.

  • Qwen3-8B BF16 at 15.3 GiB is right at the ceiling: KV pool is near zero, so you run short --max-model-len and batch ~1, or drop to FP8 KV. This tension is the honest reason the repertoire also carries a 4-bit 14B.
  • Qwen3-14B AWQ at 7.7 GiB leaves ~5.9 GiB of KV pool at util=0.92. This is the workhorse: quantizing the weights buys concurrency and context.
  • gpt-oss-20b MXFP4 at ~10.4 GiB is the naive figure: it MXFP4-counts all params. The real checkpoint is larger, ~12-13 GiB, because attention, embeddings, and the router stay in higher precision and only the expert MLPs are MXFP4. So the KV pool it leaves is tighter than 10.4 suggests; confirm the on-disk safetensors size before promising context. Being an MoE, its decode cost is still set by active experts, not total params (see below).

The ~1 GiB overhead and the real weight-file sizes (gpt-oss especially) are the placeholders to pin on the machine: record value, date, driver.

Per-token KV-cache bytes

From KV cache arithmetic, one token caches a K and a V vector per layer, each of width :

with layers, KV heads (GQA makes this smaller than the attention-head count), head dim, bytes per cached element (2 for BF16, 1 for FP8), and the leading 2 for keys and values. Pull , , from config.json (num_hidden_layers, num_key_value_heads, head_dim).

Model BF16 FP8
Qwen3-4B368128144 KiB72 KiB
Qwen3-8B368128144 KiB72 KiB
Qwen3-14B408128160 KiB80 KiB
gpt-oss-20b2486448 KiB (ceiling)24 KiB

Worked, BF16: Qwen3-8B ; Qwen3-14B ; gpt-oss .

Note

gpt-oss-20b alternates sliding-window and full-attention layers, so its 48 KiB/tok is an upper bound that overcounts at long context (the sliding layers saturate at their window). Qwen3-4B and Qwen3-8B share , so they have identical per-token KV cost, which matters when the 4B is the training policy and the 8B is a serving target. Always confirm against the KV-block count vLLM prints at boot.

Total KV at representative context and concurrency

Context and concurrency trade off on a hyperbola: double the promised context and you halve the seats. Per-sequence KV (one sequence at the given context):

Qwen3-8B BF16Qwen3-8B FP8Qwen3-14B BF16gpt-oss BF16 (ceiling)
4,0960.56 GiB0.28 GiB0.625 GiB0.19 GiB
8,1921.12 GiB0.56 GiB1.25 GiB0.38 GiB
16,3842.25 GiB1.12 GiB2.50 GiB0.75 GiB
32,7684.50 GiB2.25 GiB5.00 GiB1.50 GiB

Concurrency for Qwen3-14B AWQ (KV pool ~5.9 GiB)

Pool: (BF16 KV).

KV/seqmax concurrent seqs
4,0960.625 GiB
8,1921.25 GiB
16,3842.50 GiB
32,7685.00 GiB

Switching KV to FP8 (--kv-cache-dtype fp8) halves and doubles every seat count for a fraction of a point of eval metric (measure it with the Quantization harness). The 5.9 GiB pool depends on the ~1 GiB overhead estimate; pin it on the machine and re-derive.

Embedding + generation co-residency for RAG

The RAG chapter (8.1) adds a second model to the card: a local embedding model that turns chunks and queries into vectors, alongside the generation model that answers. This is a co-residency budget, and it is cheaper than it looks because an embedding model runs one forward pass and reads out a pooled vector; it never autoregressively decodes, so it grows no KV cache. Its whole resident cost is weights plus one prefill's transient activation (chapter 8.1 equation 1.6), with none of the linear-in-tokens KV growth that dominates a generation budget. That is why a small embedding model is a cheap lodger and not a second full tenant.

The generation side is the book's Qwen3-8B FP8: GiB of weights, a KV pool of ~3.5 GiB at --max-model-len 8192, and ~0.6 GiB of CUDA context, so it occupies ~11.8 GiB and leaves ~3.7 GiB for the embedding lodger. Every embedding model in the repertoire clears that headroom, so the choice is quality-per-byte, not fit (pick it with a small recall@k / MRR bake-off, per 8.1).

Embedding modeldtype (weights)KV termfits under ~3.7 GiB headroom?
bge-small-en-v1.5FP16~0.07 GiBnone (no decode)yes, trivially
bge-large-en-v1.5FP16~0.67 GiBnoneyes
Qwen3-Embedding-0.6BBF16~1.2 GiBnoneyes

Two ways to fit both, chosen by whether retrieval must be live

The equation above only bites if you insist on holding both models resident at once. Chapter 8.1 gives two honest configurations:

  • Time-share (the 8.1 default). One server at a time, so peak VRAM is just whichever single model is up, and there is nothing new to budget beyond the 7.7 GiB generation figure. Phase A: vllm serve BAAI/bge-small-en-v1.5 --task embed --gpu-memory-utilization 0.9, embed the whole corpus and every eval query in one batch pass (the suite's queries are known in advance), build the LanceDB index, tear it down. Phase B: vllm serve Qwen/Qwen3-8B --quantization fp8 --max-model-len 8192 and run the eval reading the precomputed query vectors off disk, embedding model no longer resident.
  • Co-resident (for live retrieval, 8.2 / 8.3). Both servers up, the card split by --gpu-memory-utilization so the fractions sum under 1: generation at 0.75 (~11.6 GiB of weights + KV), embedding at 0.15 (bge-small: ~0.07 GiB of weights + activation + its own context), summing to 0.90 and leaving driver/context headroom. Get greedy (a 0.6B decoder-embedder at a large --max-model-len, or a fat generation KV pool) and the two servers race for blocks and one OOMs at boot; the fix is to shrink the generation KV pool or the embedder, never to hope for a fit.

The ~11.8 GiB generation occupancy and the ~3.7 GiB headroom depend on the KV-pool and CUDA-context figures; pin them on the machine (record value, date, driver) and re-derive.

Two pieces of the loop carry no GPU line at all

Not everything the loop runs competes for the 16 GiB. The 3.9 SDA data pipeline (Airflow 3, httpx, the spacetrack client, Pydantic/pandera, Parquet/DuckDB, DVC) and the 8.2 MCP tool server (FastMCP over the 3.9 clients and the 3.10 oracle) are CPU/IO-bound processes that never touch CUDA. They live in their own uv sub-projects (data/ and mcp/), fetch and normalize and propagate on the CPU, and have no weight, KV, or activation footprint on the card. So they carry no line in any table here: they can run co-resident with a busy vLLM server without taking a byte of VRAM from it. The only grounding component that does draw VRAM is the embedding model above, and only when it is served on the GPU.

Decode-throughput ceilings (bandwidth budget)

Not VRAM but the other side of the same coin. From Prefill, decode, and the roofline, decode at batch 1 is memory-bound, so tokens per second cannot exceed bandwidth divided by bytes read per token:

Model (byte/tok)
Qwen3-8B BF16~59
Qwen3-14B AWQ~116
gpt-oss-20b MXFP4 (active )~384

The gpt-oss is higher than the active-expert MLP bytes alone (): it adds the non-MXFP4 bytes read every token (attention, embeddings, and the router, all kept in higher precision) on top of the active-expert bytes, which is where the extra comes from.

These are ceilings, not predictions; the real measured tok/s sits under them by the kernel-overhead + sampling + KV-read gap the roofline lab plots. Record measured values with date and driver.

QLoRA training budget for a 4B policy

The training budgets are where the memory chapter's "4-16x inference" claim gets paid out. From LoRA and QLoRA, mathematically: the base weights are frozen in NF4 (no grads, no optimizer state); only the low-rank adapters are trainable, so optimizer state is tiny. The four accounts are base + adapters + optimizer + activations.

Base (frozen, NF4 + double-quant). with :

Adapter trainable params. For LoRA rank on a linear of shape , the update adds params. Summed over target modules q,k,v,o,gate,up,down and layers of Qwen3-4B (, q width , kv width , MLP width ) at :

Adapter, grad, optimizer. Adapters in BF16, grads in BF16, AdamW keeps two FP32 moments plus an FP32 master copy (from Where memory goes: training vs inference, AdamW = 2 extra copies):

AccountformulabytesGiB
base NF4 (frozen)1.92
adapter params (BF16)0.06
adapter grads (BF16)0.06
AdamW (FP32×2)0.25
FP32 master (optional)0.12
static subtotal~2.4
activations (ckpt)measured2-5

QLoRA on Qwen3-4B, r=16

The static footprint is ~2.4 GiB; the swing variable is activations, which scale with batch size, sequence length, and whether gradient checkpointing is on. With checkpointing (trade compute to recompute activations in backward), a seq-len-2048, small-batch QLoRA on the 4B fits with wide headroom on 16 GiB. The adapter+optimizer accounts are trivially small precisely because QLoRA freezes the base: that is the whole point. The activation figure is the one to measure on the machine (record value, date, driver); everything else is exact.

GRPO training budget on 16 GiB

GRPO (from GRPO and GRPO on 16GB) adds a generation phase to the QLoRA budget: for each prompt it samples a group of completions, scores them, and takes a group-relative advantage. So on top of the QLoRA static footprint you pay for (a) the KV cache of the generation rollouts and (b) a reference-policy forward for the KL term. With Unsloth the generation runs on the same 4-bit base, so there is no second copy of weights; the extra cost is KV and activations.

Generation KV. completions per prompt, each up to tokens plus a prompt of , at the 4B's . I use the actual GRPO on 16GB config (, , so ) so this line matches that chapter's budget:

For , , ():

This is the live KV floor; vLLM claims a larger slab (~2.5 GiB) sized by gpu_memory_utilization and pages completions into it. The table below counts the slab, and the 8-bit AdamW optimizer (Unsloth's default), so its total reconciles with GRPO on 16GB's ~6.9 GiB rather than the FP32-AdamW QLoRA table above.

AccountsourceGiB
CUDA context + allocator + Triton cachemeasure (per 7.2)~0.8
base NF4 (frozen, shared serve+train)QLoRA table1.93
adapter + grad + 8-bit AdamWQLoRA table (8-bit opt)~0.18
generation KV, vLLM slab (, 1024 tok; live floor 1.13)formula above~2.5
reference-policy KL (recompute, no weight copy)activationsmeasured
training activations (ckpt)measured~1.5
total~6.9

GRPO levers on 16 GiB, in order of bluntness

When GRPO OOMs, pull these in order (each is a term in the budget above):

  1. Group size : linear in generation KV. halves the 1.13 GiB live KV (and the slab that pages it).
  2. Generation length : linear in generation KV and in rollout time. Cap it to what the reward actually needs.
  3. KV dtype FP8: halves , halving generation KV.
  4. Batch / gradient accumulation: trade wall-clock for activation memory; accumulate over micro-batches instead of one big batch.
  5. Gradient checkpointing: already assumed on; it is the difference between "fits" and "OOM" for activations.
  6. Policy size: the repertoire caps training policies at B for exactly this reason; a 1.7B policy roughly halves base + activations.

The full budget lands at ~6.9 GiB (mirroring GRPO on 16GB line for line), leaving ~9 GiB of headroom at util=0.9. Activations and the reference-KL forward are the measured quantities; record them with date and driver, and reconcile against nvidia-smi peak.

Regenerating these tables

Every table above is parameters × formula. The one-file calculator that produces them (weights, KV, decode ceiling, QLoRA/GRPO budgets) lives with the KV lab (predict.py in KV cache arithmetic) and the roofline lab (ceiling.py in Prefill, decode, and the roofline). When a driver update, a vLLM release, or a model revision changes an input, re-run those scripts rather than editing a number here by hand, and re-stamp any measured overhead with its new date and driver.