Module 18: LLM inference serving 45 min11 min quick readLLMs
LLM inference
Explain where the time and memory go when an LLM generates text (prefill vs decode, the KV cache, memory bandwidth) and estimate the effect of GQA, quantization, FlashAttention, speculative decoding, and mixture-of-experts.
Before this: The transformer architecture, Decoding
Visual explainer: Why long contexts eat GPU memory, a 7-minute scroll-through where the lab moves as you read. Or press Watch on the lab below.
Quick read: the intuition, interactive visualization, summary, and interview questions. The math, implementation, engineering, and knowledge-check sections are hidden.
Mental model. Generating text is two jobs. Prefill reads the whole prompt at once, like a chef doing all the prep in one go: lots of work, done in parallel, limited by how fast the chef can chop (compute). Decode then produces one token at a time, and for each token the chef has to walk to the pantry and carry every ingredient (all the weights and the saved notes on earlier tokens) back to the counter. Decode is limited by the walk (memory bandwidth), not the chopping.
You will learn to
- Distinguish prefill from decode and explain why decode is memory-bandwidth bound.
- Compute the KV-cache size for any model shape, context length, and batch size.
- Estimate an upper bound on decode speed from bandwidth and bytes read per step.
- Explain how MHA, MQA, and GQA, weight and KV quantization, FlashAttention, speculative decoding, and mixture-of-experts change memory, speed, or quality.
- Define TTFT, TPOT (inter-token latency), end-to-end latency, and throughput, and compute one from the others.
Why it matters. Every LLM product decision about context length, model size, batch size, hardware, and price comes down to a few lines of arithmetic. Engineers who can do that arithmetic can tell whether a model fits on a GPU, why a long-context feature halved throughput, and which optimization is worth trying first. Interviewers for ML and AI engineering roles ask for exactly this.
1. Intuition
Two phases. A request arrives with a prompt of, say, 2,000 tokens.
- Prefill. The model processes all 2,000 prompt tokens in one forward pass. Each weight matrix multiplies a block of activations, so every weight loaded from memory is reused 2,000 times. The GPU's arithmetic units are the bottleneck. Prefill produces the first output token and fills the KV cache. Its duration is most of the time to first token.
- Decode. The model then generates one token per forward pass. Each pass multiplies a single row of activations by every weight matrix. Every weight is read from memory and used once. The arithmetic units mostly wait for data. This is why a GPU that can do hundreds of trillions of operations per second still produces only tens to hundreds of tokens per second for a single user.
The KV cache. In causal attention, a token's key and value vectors never change once computed (self-attention). So the model stores them: for every layer, every KV head, every past token. The next token computes only its own query, key, and value and attends over the cache. Without the cache, every new token would recompute the whole prefix. The price is memory: the cache grows linearly with context length and with the number of sequences in the batch, and at long contexts or large batches it is bigger than the model.
Batching rescues decode. If 16 users decode at once, each weight read from memory is used 16 times instead of once. The step takes only slightly longer, but produces 16 tokens. That is why servers batch, and why the KV cache (which grows with batch size) is what usually limits how many users fit on one GPU.
The levers. Every inference optimization attacks one of three costs:
| Lever | What it cuts | Cost |
|---|---|---|
| GQA / MQA (fewer KV heads) | KV-cache bytes per token | decided at training time; MQA can cost some quality |
| Weight quantization (INT8, FP8, INT4) | weight bytes read per step and stored | some accuracy loss, more at 4 bits |
| KV-cache quantization (FP8, 4-bit) | KV bytes stored and read | small accuracy loss, worse on long-context recall |
| FlashAttention | attention memory traffic (prefill especially) | none in quality: it is exact |
| Speculative decoding | number of sequential target-model passes | extra compute; gains depend on acceptance rate |
| Mixture-of-experts | compute per token | memory still holds every expert |
2. Visualization
KV cache and inference memory
Synthetic model shapes and accelerator specs (illustrative, not specific products). GB = 10⁹ bytes; activations and overhead not included.
Try it (predict first, then check):
- Start from the lesson example (8B-style, 8k tokens, batch 16). Predict the KV-cache size if you switch attention to MHA. Then switch and check the multiple.
- Pick 70B on one GPU. Predict whether INT4 weights alone make it fit on the 80 GB accelerator at batch 8 and 8k context. Then try it, and try FP8 KV as well.
- Raise the batch from 1 to 64 at 8k context. Predict which number rises and which falls: aggregate tokens/s or per-sequence tokens/s. Where does memory run out?
- Switch to the Small MHA preset. Compare its KV bytes per token with the 8B-style GQA model. Which model has the bigger cache per token, and why?
3. The math
Symbols
| Symbol | Meaning | Example (8B-style) |
|---|---|---|
| number of transformer layers | 32 | |
| number of query heads | 32 | |
| number of key/value heads | 8 | |
| dimension of each head | 128 | |
| bytes per stored number (BF16 = 2, FP8 = 1, INT4 = 0.5) | 2 | |
| tokens in context (prompt plus generated so far) | 8,192 | |
| sequences in the batch | 16 | |
| parameter count | ||
| memory bandwidth, bytes per second | ||
| peak compute, FLOPs per second |
At each layer, the cache holds a key tensor and a value tensor of shape .
KV-cache size
The leading 2 counts K and V. Note what is not in the formula: and the parameter count. Only the number of KV heads matters.
Worked example (synthetic 8B-style GQA model, BF16 cache).
Per token: . Step by step: ; ; ; bytes = 128 KiB per token.
One sequence of : bytes = 1.074 GB (GB means bytes).
Batch of 16: bytes = 17.18 GB.
Weights in BF16: GB. Total: GB, which fits on an 80 GB accelerator. The largest batch that fits at 8k context (ignoring activations and overhead) is .
MHA, MQA, GQA
- Multi-head attention (MHA): . Cache per token bytes. At , that is 68.72 GB, more than four times the weights.
- Multi-query attention (MQA): , all query heads share one K and one V. bytes per token, 2.15 GB for the same batch. Smallest cache, but sharing a single KV head can cost quality.
- Grouped-query attention (GQA): , each group of query heads shares one KV head. 17.18 GB. Close to MHA quality at a quarter of the cache, which is why most current open-weight models use it.
Grouping does not change the parameter count much (the K and V projections shrink a little); it changes the cache.
Why decode is memory-bandwidth bound
A forward pass for one token does about floating-point operations (one multiply and one add per parameter). At batch 1, it reads every weight once: bytes. Arithmetic intensity is FLOPs per byte read:
An accelerator can only keep its arithmetic units busy if intensity exceeds its ridge point, . For the synthetic accelerator: FLOP/byte. Decode at batch 1 sits at 1, so it uses roughly of peak compute. That is the roofline picture: below the ridge point, time is set by bytes moved, not FLOPs done.
With batch , the weights are read once and used times, so intensity is about FLOP/byte for the weight reads. Prefill of prompt tokens has intensity about , which is why a 2,000-token prefill is compute-bound.
Upper bound on decode speed
Each decode step reads all weights plus every sequence's KV cache, and produces one token per sequence:
Worked example at bytes/s:
- Batch 1, short context (ignore KV): tokens/s, or ms per token.
- Batch 1, : tokens/s.
- Batch 16, : steps/s. Each user sees 90.4 tokens/s; the server produces tokens/s.
Real systems reach a fraction of these bounds (kernel overheads, attention compute, communication), but the ratios hold: batching multiplies total throughput while each user slows down, and long contexts slow everyone because the KV cache must be read every step.
Prefill lower bound. A 2,000-token prompt needs about FLOPs. At FLOP/s that is 32 ms if the GPU ran at peak. In practice utilization is lower, and attention adds a term that grows with .
Quantization
Weight memory is : 16 GB in BF16, 8 GB in FP8 or INT8, 4 GB in INT4 (plus a few percent for per-group scale factors). Because decode reads every weight each step, halving weight bytes nearly doubles the batch-1 speed bound. Common forms:
- Weight-only INT8/INT4: weights stored in low precision with scales per channel or per group of, say, 128 values, dequantized on the fly; activations stay 16-bit. Best for memory-bound decode. INT4 usually needs a calibration-based method to keep accuracy.
- FP8 (weights and activations): an 8-bit floating-point format supported natively by recent accelerators. It speeds up compute-bound prefill too.
- KV-cache quantization (FP8 or 4-bit): halves or quarters the cache. FP8 cache at our example: GB.
What you lose: small, task-dependent accuracy drops, usually largest on math, code, and long-context retrieval, and larger at 4 bits than at 8. Always re-run your own evals after quantizing (evaluation).
FlashAttention
Naive attention writes the full score matrix of shape to GPU main memory (HBM), reads it back for softmax, and again to multiply by . At in 16-bit, one head's score matrix is bytes (134 MB); 32 heads make 4.29 GB for a single layer. FlashAttention splits , , and into tiles that fit in fast on-chip SRAM, computes softmax incrementally with a running max and running sum (online softmax), and never writes the matrix to HBM. The result is exact, not an approximation. Attention memory drops from to , and memory traffic falls enough that long-context prefill runs several times faster. It does not shrink the KV cache.
Speculative decoding
In speculative decoding, a small draft model proposes tokens cheaply. The large target model checks all in one forward pass (a short prefill, so it costs about the same as one decode step because decode is memory-bound). Tokens are accepted left to right until the first rejection; at the rejection the target supplies its own token. With a rejection-sampling acceptance rule, the output distribution is exactly the target model's. If each draft token is accepted with probability independently, the expected tokens per target pass is
Worked example: , . . tokens per target pass. If one draft pass costs of a target pass, a round costs target passes, so the speedup is . At it falls to . Acceptance depends on how predictable the text is: code and boilerplate accept well, creative text at high temperature less. Variants use n-gram lookups from the prompt or extra prediction heads instead of a separate draft model.
Mixture-of-experts
A mixture-of-experts (MoE) layer replaces one feed-forward block with expert blocks and a router that sends each token to the top . Synthetic example: 4B shared parameters (attention, embeddings, router) plus 16 experts of 2B each, top-2 routing.
- Total parameters: B. Memory in BF16: 72 GB. All of it must be resident.
- Active parameters per token: B. Compute per token: FLOPs, the same as a dense 8B model.
So MoE buys the quality of a larger model at the compute of a smaller one, but pays in memory. At batch 1 decode reads only the active experts; at large batch, different tokens route to different experts, so nearly every expert is read each step.
Latency metrics
| Metric | Definition |
|---|---|
| TTFT (time to first token) | request arrival to first output token: queueing plus prefill |
| TPOT / ITL (time per output token, inter-token latency) | average gap between output tokens during decode |
| End-to-end latency | TTFT TPOT |
| Throughput | output tokens per second across all requests (sometimes requests per second) |
Worked example: TTFT 200 ms, TPOT 25 ms, 300 output tokens. s. The user reads at tokens/s. Long prompts hurt TTFT; long outputs and big batches hurt end-to-end latency through TPOT.
4. Implementation
The arithmetic fits in a few lines, and it is worth keeping as a script next to any deployment plan.
def kv_cache_bytes(layers, n_kv, d_head, bytes_per, tokens, batch):
return 2 * layers * n_kv * d_head * bytes_per * tokens * batch # K and V
def decode_tokens_per_s(weight_bytes, kv_bytes, bandwidth, batch):
steps = bandwidth / (weight_bytes + kv_bytes) # every step reads all weights + all KV
return steps, steps * batch # per sequence, aggregate (upper bounds)
kv = kv_cache_bytes(32, 8, 128, 2, 8192, 16) # 17,179,869,184 bytes = 17.18 GB
print(decode_tokens_per_s(16e9, kv, 3.0e12, 16)) # (90.4, 1446.7)Build it from scratch
The arithmetic above predicts; now build the three mechanisms and check them. The full script is code/18-llm-inference/kv_spec_quant_scratch.py (NumPy only, synthetic weights).
The KV cache. Attention for the newest token needs the keys and values of every earlier token. Without a cache, each step projects the whole prefix again. Both versions share one attention function over a query vector q of shape [D] and cached K, V of shape [t, D]:
def attend(q, K, V):
s = K @ q / np.sqrt(D) # [t] one score per cached position
w = np.exp(s - s.max())
return (w / w.sum()) @ V # [D]
def decode_uncached(X):
outs, proj = [], 0
for t in range(1, len(X) + 1):
K, V = X[:t] @ Wk, X[:t] @ Wv # recompute K and V for the whole prefix
proj += 2 * t
outs.append(attend(X[t - 1] @ Wq, K, V))
return np.array(outs), projPredict: for a 12-token sequence, how many K/V row projections does this do in total?
The cached version keeps K and V and appends one row each per step. Past rows never change, because in a causal model a token cannot see the future.
def decode_cached(X):
outs, K, V, proj = [], np.empty((0, D)), np.empty((0, D)), 0
for x in X:
K, V = np.vstack([K, x @ Wk]), np.vstack([V, x @ Wv]) # append one row each
proj += 2
outs.append(attend(x @ Wq, K, V))
return np.array(outs), projPredict: the outputs match the uncached version to about 1e-16. What would break that equality if the model were bidirectional instead of causal?
int8 weight quantization. Store each weight as an 8-bit integer plus a floating-point scale. Absmax scaling maps the largest magnitude to 127. The only design choice is how many weights share one scale: the whole tensor, or one row (output channel).
def quant_int8(W, axis=None):
scale = np.abs(W).max(axis=axis, keepdims=axis is not None) / 127 # absmax maps to 127
q = np.clip(np.round(W / scale), -127, 127).astype(np.int8)
return q, scale
def rel_error(W, W_hat):
return np.linalg.norm(W - W_hat) / np.linalg.norm(W)Predict: the script scales three rows of a [64, 256] matrix by 40×. Which choice of axis suffers, and why? (One shared scale is set by the outliers, so every small weight is rounded on a coarse grid.)
Speculative sampling. The draft proposes a token from its distribution ; the target's distribution is . Accept with probability . On rejection, sample from the leftover mass , renormalized. That pair of rules makes the output distribution exactly , so the speed-up is free of quality loss.
def spec_one_token(q, p, rng):
d = rng.choice(len(p), p=p) # draft proposes from p
if rng.random() < min(1.0, q[d] / p[d]):
return d, True # target agrees often enough: keep the draft token
resid = np.maximum(q - p, 0) # where the target wants more mass than the draft gave
return rng.choice(len(q), p=resid / resid.sum()), False
def expected_tokens(alpha, gamma):
return (1 - alpha ** (gamma + 1)) / (1 - alpha)Predict: the exact acceptance rate is . If the draft equals the target (), what are and the expected tokens per target pass for ?
At temperature 0 both distributions are one-hot, so the rule reduces to greedy matching: accept draft tokens while each equals the target's argmax, then take the target's token at the first mismatch.
Use the library
With a real model, read the shape from its config instead of guessing: num_hidden_layers, num_attention_heads, num_key_value_heads, and head_dim (or hidden_size / num_attention_heads).
from transformers import AutoConfig, AutoModelForCausalLM, AutoTokenizer
cfg = AutoConfig.from_pretrained("Qwen/Qwen2.5-7B-Instruct")
d_head = getattr(cfg, "head_dim", None) or cfg.hidden_size // cfg.num_attention_heads
print(kv_cache_bytes(cfg.num_hidden_layers, cfg.num_key_value_heads, d_head, 2, 8192, 16) / 1e9, "GB")
# Assisted (speculative) generation: a small draft from the same tokenizer family
target = AutoModelForCausalLM.from_pretrained("Qwen/Qwen2.5-7B-Instruct", device_map="auto")
draft = AutoModelForCausalLM.from_pretrained("Qwen/Qwen2.5-0.5B-Instruct", device_map="auto")
inputs = AutoTokenizer.from_pretrained("Qwen/Qwen2.5-7B-Instruct")("Explain KV caches.", return_tensors="pt").to(target.device)
out = target.generate(**inputs, assistant_model=draft, max_new_tokens=256)A serving engine does all three for you. In vLLM, LLM(model=..., quantization="fp8", kv_cache_dtype="fp8") quantizes weights and the KV cache, and speculative_config={"model": draft_name, "num_speculative_tokens": 4} turns on draft-model speculation. The KV cache is always on. What the scratch version made explicit: the cache is just appended rows, a quantized weight is just an integer times a scale, and speculation is just one accept test plus a residual. Engines add fused GPU kernels, paged cache memory, and calibrated scales (AWQ, GPTQ, FP8) that protect salient weights better than plain absmax.
Then measure: run your serving engine with a fixed prompt and output length, record TTFT and TPOT at batch 1, 8, and 32, and compare with the bounds.
Run it
From guide/:
uv run code/18-llm-inference/kv_spec_quant_scratch.py[kv] max |uncached - cached| = 4.4e-16
[kv] K/V projections: uncached 156, cached 24 (6.5x fewer with cache)
[quant] int8 per-tensor : relative error 0.0325
[quant] int8 per-channel: relative error 0.0067
[spec] acceptance: theory 0.840, simulated 0.841
[spec] max |empirical - target| = 0.0016 (output follows q, not p)
[spec] expected tokens per target pass, gamma=4: 3.64Uncached work grows as projections; cached work is . Per-channel scales cut the error about 5× because each row gets its own grid. The empirical token frequencies match the target to within 0.002 across 50,000 samples, while 84% of draft tokens are kept.
The arithmetic script (every number in this lesson, plus the figure) is code/18-llm-inference/llm_inference.py: uv run code/18-llm-inference/llm_inference.py.
5. Engineering
Size before you buy. Weights + KV cache at your p95 context length and target batch + headroom for activations and fragmentation (often 10 to 20%). If the weights alone exceed one GPU, you need quantization or tensor parallelism (next lesson).
Know which phase hurts. Long prompts and short answers (RAG, classification, summarization) are prefill-heavy: TTFT and compute matter, and prefix caching and FP8 compute help. Short prompts and long answers (chat, code generation, reasoning traces) are decode-heavy: bandwidth, batch size, KV size, and speculative decoding matter.
Order of optimizations for decode. Use a model with GQA; quantize weights to FP8 or INT8 (low risk); batch continuously; quantize the KV cache if memory caps your batch; try INT4 weights and speculative decoding last, after measuring quality and acceptance rates on your own traffic.
Reasoning models change the budget. Models that think for thousands of tokens before answering are decode-dominated and hold large KV caches for a long time. Plan capacity on output tokens, not request counts.
Watch outFailure modes. Out-of-memory errors that appear only when several long requests coincide; a quantized model that passes chat evals but regresses on math or long-document recall; speculative decoding that slows things down at high batch (the GPU is no longer idle, so the extra draft and verification work is not free) or on low-acceptance traffic; MoE models that fit the compute budget but not the memory budget.
Common mistakes
- Using instead of in the KV-cache formula, which overestimates a GQA model's cache by .
- Forgetting the factor of 2 for keys and values, or forgetting the batch dimension.
- Quoting FLOPs to predict decode speed. Decode speed follows bandwidth at small batch.
- Treating "tokens/s" as one number: per-user speed and aggregate throughput move in opposite directions as batch grows.
- Assuming an MoE model needs only its active parameters in memory.
- Thinking FlashAttention approximates attention or shrinks the KV cache. It is exact and it reduces attention's memory traffic, not the cache.
6. Knowledge check
Quiz: LLM inference
A synthetic model has 40 layers, 40 query heads, 8 KV heads, and . The KV cache is stored in BF16 (2 bytes). How many bytes of KV cache does one token of one sequence need?
Practice exercise. A synthetic 70B-style model has 80 layers, 64 query heads, 8 KV heads, and . Compute its BF16 KV cache per token, then for one 32k-token () sequence, and for a batch of 4. With BF16 weights, how many 80 GB GPUs do you need at minimum? And with FP8 weights?
Solution
Per token: bytes (320 KiB). One sequence: bytes = 10.74 GB. Batch of 4: 42.95 GB. BF16 weights: GB. Total 182.95 GB, so three 80 GB GPUs (240 GB) at minimum, before activations. FP8 weights: 70 GB, total 112.95 GB, which fits on two (160 GB). Query heads never enter the calculation.
Implementation challenge. Write max_context(model, gpu_gb, batch, weight_bytes, kv_bytes, headroom=0.1) that returns the longest context that fits, and speculative_speedup(alpha, gamma, c). Find the that maximizes speedup for with .
Solution sketch
def max_context(L, n_kv, d_head, params, gpu_gb, batch, wb, kvb, headroom=0.1):
free = gpu_gb * 1e9 * (1 - headroom) - params * wb
return max(0, int(free // (2 * L * n_kv * d_head * kvb * batch)))
def speculative_speedup(a, g, c):
return (1 - a ** (g + 1)) / (1 - a) / (1 + g * c)
for a in (0.6, 0.8, 0.9):
best = max(range(1, 16), key=lambda g: speculative_speedup(a, g, 0.05))
print(a, best, round(speculative_speedup(a, best, 0.05), 2))Higher acceptance rates justify longer drafts. With the best is 4 at (1.92×), 8 at (3.09×), and 13 at (4.67×). The curves are flat near the optimum, and past it extra draft tokens are rarely accepted but still cost draft passes. Real acceptance is not independent across positions, so tune on measured traffic.
Summary
- Prefill processes the prompt in parallel and is compute-bound; decode generates one token per pass and is memory-bandwidth bound.
- KV cache ; GQA and MQA shrink it by cutting , and KV quantization by cutting .
- Decode speed is bounded by bandwidth divided by bytes read per step (weights plus all KV). Batching raises total throughput and lowers per-user speed.
- Quantization trades a little accuracy for fewer bytes; FlashAttention is exact and cuts attention memory traffic; speculative decoding trades extra compute for fewer sequential passes; MoE trades memory for compute.
- Report TTFT, TPOT, end-to-end latency, and throughput separately; they respond to different levers.
Next: Serving LLMs
Related: Self-attention · The transformer architecture · Decoding · Reliability, cost, and observability · Cheat sheet: LLM inference
Go deeper, code-first (AI Engineering from Scratch): Inference Optimization · Speculative Decoding and EAGLE-3 · Edge Inference — Apple Neural Engine, Qualcomm Hexagon, WebGPU/WebLLM, Jetson
Parts of this lesson are adapted from AI Engineering from Scratch by Rohit Ghumare (MIT license, notice): KV Cache, Flash Attention & Inference Optimization, Speculative Decoding — Draft, Verify, Repeat, Quantization: Making Models Fit, Production Quantization — AWQ, GPTQ, GGUF K-quants, FP8, MXFP4/NVFP4.
Interview angle
Why is LLM decoding memory-bandwidth bound when prefill is compute-bound?
It comes down to arithmetic intensity. In decode, each forward pass handles one new token per sequence, so every weight matrix multiplies a single activation row. Each weight is read from HBM and used for about two FLOPs, which is roughly 1 FLOP per byte in BF16. A modern accelerator needs a few hundred FLOPs per byte to keep its arithmetic units busy, so at batch 1 they sit idle waiting on memory, and time per token is about (weight bytes + KV bytes) ÷ bandwidth. Prefill pushes the whole prompt through at once. With prompt tokens, each weight is reused times, intensity is about , and the bottleneck becomes FLOPs. That's why batching helps decode a lot (it reuses each weight read across sequences) and does little for prefill.
You need to double the context length you serve on the same GPUs. Do you quantize the weights or the KV cache?
At long context I'd quantize the KV cache first, because that's the part that grows with context. Weights are a fixed cost. For an 8B-style GQA model, BF16 weights are 16 GB, while a batch of 16 at 8k context already holds 17 GB of KV, and doubling the context doubles that to 34 GB. FP8 KV halves the cache and usually costs little accuracy, though I'd check long-context retrieval evals specifically, because that's where cache precision shows up. Weight quantization frees a one-time 8 GB (BF16 to FP8) and speeds up decode at small batch, so it's a good next step. If the model has full multi-head attention, no quantization helps as much as moving to a GQA variant, which cuts the cache by .
After launching a 32k-context feature, throughput per GPU halved and you see occasional out-of-memory errors. What do you check?
First the KV-cache budget. Per-sequence cache scales with , so going from 8k to 32k quadruples it, and the scheduler can fit far fewer concurrent sequences. Fewer sequences means a smaller effective batch, which lowers throughput. I'd check the engine's reported KV-block usage, how often requests are preempted or swapped, and the p95 prompt length, not just the mean. The OOMs suggest memory is being reserved for the configured maximum length, or activation headroom is too thin when several long prefills land together. Fixes in order: cap or chunk prefill, enable FP8 KV, make sure prefix caching shares common system prompts, set the max-concurrency limit from the real KV budget, and route long-context requests to a separate pool so they don't starve short ones.
Quick estimate: 70B-style model, 80 layers, 8 KV heads, head dimension 128, BF16, 32k context, batch 4. How much memory?
KV per token is bytes, about 320 KiB. One 32,768-token sequence is GB, so batch 4 is about 43 GB. BF16 weights are GB, giving about 183 GB before activations and overhead. That needs at least three 80 GB GPUs, and in practice four with tensor parallelism, since TP degrees are usually powers of two. With FP8 weights it's 70 + 43 = 113 GB, which fits on two 80 GB GPUs. The decode bound at 2 × 3 TB/s is roughly 6e12 ÷ 113e9 ≈ 53 steps/s, so about 53 tokens/s per user and 212 tokens/s in aggregate.