Chapter 6 · Production serving
Serving with vLLM
Every argument that matters in vLLM 0.27.1, what it does, and exactly when to raise or lower it. Worked throughout on H100 96 GB hardware running Gemma 4 31B dense and Gemma 4 26B-A4B mixture-of-experts, at 15k and 30k context, finishing with a configuration for ten million inferences.
[!] Version and hardware assumptions
Every default quoted in this chapter was read from the vLLM 0.27.1 source rather than from documentation, because the two drift apart and defaults change between releases more often than anything else in the stack. Check yours with vllm serve --help before copying a number.
Model shapes for Gemma 4 are stated as explicit assumptions wherever they affect a calculation, so you can substitute the real values from your checkpoint's config.json. The method for arriving at each number is the durable part; the numbers themselves depend on your build.
6.1 Why a serving engine at all
You can run a model with fifty lines of PyTorch. Doing that in production wastes most of a very expensive GPU, and understanding exactly how is the foundation for every parameter in this chapter.
The naive loop looks reasonable: take a request, tokenise it, run the model forward one token at a time until it emits a stop token, return the text. It is correct. It is also perhaps 5% efficient, for three separate reasons that compound.
- The GPU sits idle between tokens Generating one token requires reading every weight in the model from memory. For a 31B model in bf16 that is roughly 58 GiB read per token. The arithmetic performed on that data is trivial by comparison, so the GPU's compute units spend nearly all their time waiting for memory. Serving one request at a time means paying that entire read to produce a single token.
- Requests wait for the slowest sibling Batch naively — group eight requests, run them together — and the batch finishes when its longest member finishes. Seven requests that wanted 50 tokens sit completed but unreturned while the eighth grinds through 2,000. Their KV cache stays allocated the whole time.
- Memory is reserved for the worst case A naive implementation allocates KV cache for the maximum possible sequence length, because it cannot know how long the output will be. A request that generates 100 tokens holds space for 30,000. At scale this is where the GPU actually goes.
[def] vLLM
An inference and serving engine, originally from a 2023 paper by Kwon and colleagues at Berkeley. Its central contribution is PagedAttention, which fixes the memory problem, and around that it builds continuous batching, prefix caching and a scheduler. The whole system exists to keep a GPU that is fundamentally memory-bandwidth-bound as close to saturated as possible.
[+] The single idea to hold onto
Decoding is memory-bound, not compute-bound. Reading the weights costs the same whether you are generating one token or a hundred, because the weights are read once per step and shared across every sequence in the batch. So the batch is very nearly free, and the entire job of tuning vLLM is making the batch as large as memory allows. Every parameter in this chapter is ultimately about that. Once you see the tuning problem this way, the arguments stop being a list to memorise and become a single trade-off with several dials.
6.2 PagedAttention
The idea vLLM was built around, borrowed wholesale from how operating systems manage memory. It is worth understanding properly, because it explains why several later parameters exist at all.
The problem it solves is fragmentation. A KV cache must grow as a sequence generates tokens, and a naive implementation reserves one contiguous block per sequence sized for the worst case. Reserve 30,000 tokens of space for a request that produces 200 and you have wasted 99% of that allocation — and because it is contiguous, no other request can use the hole.
[def] PagedAttention
Store the KV cache in fixed-size blocks (16 tokens by default) that need not be contiguous in memory, with a per-sequence block table mapping logical positions to physical blocks. A sequence is allocated blocks as it grows, one at a time. This is virtual memory paging, applied to attention state, and the analogy is exact enough that the original paper makes it explicitly.
| Without paging | With paging |
|---|---|
| Reserve max length per sequence, up front | Allocate one 16-token block at a time, on demand |
| Waste is proportional to the gap between reserved and used | Waste is at most one partly filled block per sequence |
| Sharing a prompt between requests means copying it | Two sequences can point at the same physical block |
| Concurrency is limited by the worst case | Concurrency is limited by actual usage |
[+] Sharing blocks is what makes prefix caching possible
Because a block is just a pointer, two sequences beginning with the same tokens can share the physical blocks holding that prefix, with a reference count. Nothing is copied. This is the mechanism behind prefix caching in 6.12, and behind cheap parallel sampling: generating four completions for one prompt stores the prompt once. If you remember why paging matters, remember this — the memory saving is good, but the sharing is what unlocks the biggest wins later.
[!] The one parameter this exposes
--block-size controls the tokens pock. In 0.27.1 it is resolved automatically based on your attention backend, and you should leave it alone. Smaller blocks reduce the leftover waste in the final partial block but add block-table overhead and can slow the attention kernel, which is often optimised for a specific block size. This is a genuine "trust the default" case: the automatic choice accounts for hardware and backend details that a manual value will get wrong.
6.3 Continuous batching
Traditional batching groups requests and waits. Continuous batching lets each request leave the moment it is done, and admits a new one into the vacant slot on the next step.
Because generation happens one step at a time for every sequence simultaneously, there is a natural boundary between steps at which the batch can be rebuilt. vLLM rebuilds it every step. A sequence that emits its stop token is removed and its blocks are freed immediately; a waiting request is admitted into the freed capacity.
Static batching
- Batch forms, runs, and completes as a unit
- Everyone waits for the longest generation
- Finished sequences hold memory doing nothing
- New requests wait for the whole batch to drain
- GPU utilisation collapses as the batch drains
Continuous batching
- The batch is rebuilt every forward step
- Each sequence returns as soon as it finishes
- Blocks are freed the instant they are done
- Waiting requests join at the next step
- The GPU stays full while work exists
[+] Why this matters more with variable output lengths
If every request generated exactly 200 tokens, static batching would lose little. Real traffic is nothing like that: one request answers in 20 tokens and another writes 2,000. Under static batching the short ones are hostages. The more variable your output lengths, the more continuous batching wins — which is why it matters enormously for chat and for the RAG answers of chapter 5, where length depends on the question.
[!] Preemption: the symptom to recognise
Continuous batching admits requests optimistically, assuming they will not all run to maximum length. When they do, the KV cache runs out and vLLM must preempt a running sequence: evict its blocks and either recompute them later or swap them out. You will see it in the logs as a preemption warning with a cumulative count.
Occasional preemption is healthy — it means you are running the memory close to full, which is the point. Sustained preemption is thrashing: work is being done twice and throughput collapses. The fixes, in order of preference: lower --max-num-seqs so fewer sequences are admitted, lower --max-model-len if your real requests are shorter than you provisioned for, or free KV memory by moving to an fp8 cache as in 6.8.
6.4 Prefill and decode are different machines
Inference has two phases with opposite performance characteristics. Almost every tuning mistake comes from treating them as one thing.
| Prefill | Decode | |
|---|---|---|
| What it does | Processes the whole prompt and builds its KV cache | Generates output tokens, one per step |
| Parallelism | All prompt tokens at once | Strictly sequential — each token needs the previous one |
| Bottleneck | Compute (the GPU's maths units) | Memory bandwidth (reading weights) |
| Effect of batching | Little — one long prompt already saturates compute | Enormous — the weight read is shared by the whole batch |
| Scales with | Total input tokens | Output tokens ÷ batch size |
| User-visible as | Time to first token | Tokens per second thereafter |
[def] Arithmetic intensity, and why the phases differ
Prefill multiplies a matrix of 15,000 tokens by the weights: an enormous amount of arithmetic per byte of weight read, so the compute units are the limit. Decode multiplies a single token by those same weights: the same bytes read, almost no arithmetic. The GPU finishes the maths and waits. That is why adding sequences to a decode batch is nearly free — you are filling idle compute alongside a read you were already paying for — and why adding tokens to a prefill batch is not.
[!] The interference problem
These two phases fight each other. A 15,000-token prefill occupies the GPU for hundreds of milliseconds, and every sequence currently decoding is stalled for that whole period. Users experience it as generation that stutters whenever someone else submits a long prompt. This single problem is what chunked prefill in 6.11 exists to solve, and it is the most important scheduler setting in the chapter.
[retail] Both deployments in this chapter are prefill-heavy
Worth noticing early. A RAG request with 12,000 tokens of retrieved context and a 3,000-token answer spends the majority of its GPU time in prefill, because prefill processes four times as many tokens. That inverts the usual advice written for chat, where prompts are short and outputs long. When you read a vLLM tuning guide, check which regime it assumes — guidance tuned for 200-token prompts will actively mislead you here.
6.5 Where 96 GB actually goes
Before touching a single flag, work out your memory budget on paper. Almost every serving problem is a memory problem wearing a disguise, and five minutes of arithmetic prevents a day of guessing.
An H100 96 GB card divides into four claims, in this order:
- Model weights — fixed, and you pay for all of them Parameters times bytes per parameter. At full bf16 precision that is 2 bytes each. For a 31B dense model, about 57.7 GiB. For the 26B MoE it is about 48.4 GiB, not 7.5 GiB — every expert must be resident even though only a few run per token. Sparsity buys compute, never memory. This is the single most expensive misunderstanding in MoE planning.
- Activations and workspace — roughly 2 to 4 GiB Intermediate tensors for the current forward pass, plus CUDA graph capture buffers and the allocator's own headroom. It scales with batch size and hidden dimension rather than with model size.
- KV cache — whatever is left, and the only part you control vLLM measures free memory after loading weights and claims essentially all of it. This number determines your maximum concurrency, and therefore your throughput.
- The reserve — what gpu-memory-utilization holds back Space deliberately left unallocated so that fragmentation and transient spikes do not trigger an out-of-memory failure mid-request.
[def] KV cache per token
The formula worth committing to memory, because every capacity decision derives from it:
bytes/token = 2 × layers × kv_heads × head_dim × bytes_per_element
The leading 2 is for K and V — forgetting it is the classic error and halves your estimate. Note that it is kv_heads, not attention heads: with grouped-query attention those differ, often by a factor of four or more, and that ratio is precisely the KV saving GQA was designed to deliver. Read num_key_value_heads from your checkpoint's config.json rather than assuming.
"""KV cache and batch sizing for Gemma 4 on an H100 96 GB.
Every number quoted in chapter 6 comes from here rather than from memory.
The arithmetic is simple but extremely easy to get wrong by a factor of two
(the classic error is forgetting the factor of 2 for K *and* V), and a wrong
KV figure means a wrong max_num_seqs, which means either wasted GPU or
crashes under load.
Model shapes are stated as assumptions at the top so a reader can substitute
the real config.json values for their checkpoint.
"""
from __future__ import annotations
from dataclasses import dataclass
GIB = 1024 ** 3
@dataclass(frozen=True)
class ModelShape:
"""The handful of config.json fields that decide KV cache size.
Note it is the KV-head count that matters, not the attention-head count.
Grouped-query attention shrinks KV heads while leaving query heads alone,
and that ratio is exactly the KV cache saving.
"""
name: str
total_params_b: float # billions, for weight memory
active_params_b: float # billions; differs from total only for MoE
layers: int
kv_heads: int # num_key_value_heads
head_dim: int
def weight_bytes(self, bytes_per_param: int = 2) -> int:
"""Weights are sized by TOTAL parameters, even for MoE.
A common and expensive planning error: an MoE with 4B active still
needs all 26B resident in HBM. Sparsity buys compute, not memory.
"""
return int(self.total_params_b * 1e9 * bytes_per_param)
def kv_bytes_per_token(self, bytes_per_elem: int = 2) -> int:
"""2 (K and V) x layers x kv_heads x head_dim x bytes."""
return 2 * self.layers * self.kv_heads * self.head_dim * bytes_per_elem
# Shapes assumed for the worked examples. Substitute your checkpoint's values.
GEMMA4_31B = ModelShape("Gemma 4 31B (dense)", 31.0, 31.0, 48, 8, 128)
GEMMA4_26B = ModelShape("Gemma 4 26B-A4B (MoE)", 26.0, 4.0, 40, 8, 128)
def kv_budget_gib(shape: ModelShape, gpu_gib: int = 96,
gpu_util: float = 0.92, bytes_per_param: int = 2,
overhead_gib: float = 3.0) -> float:
"""GiB left for KV cache after weights and working memory.
vLLM carves out gpu_memory_utilization of the card, loads weights, then
gives essentially all the remainder to the KV cache. The overhead term
covers activations, CUDA graphs and the allocator's own fragmentation.
"""
usable = gpu_gib * gpu_util
weights = shape.weight_bytes(bytes_per_param) / GIB
return usable - weights - overhead_gib
def concurrent_seqs(shape: ModelShape, context_len: int, kv_gib: float,
kv_bytes_per_elem: int = 2) -> float:
"""How many sequences of this length fit in the KV budget at once."""
per_seq = shape.kv_bytes_per_token(kv_bytes_per_elem) * context_len
return kv_gib * GIB / per_seq
def report(shape: ModelShape, contexts=(15_000, 30_000), tp: int = 1) -> None:
per_tok = shape.kv_bytes_per_token()
print(f"\n{shape.name} (TP={tp})")
print(f" weights (bf16, total params) : {shape.weight_bytes()/GIB:7.1f} GiB")
print(f" active params (compute cost) : {shape.active_params_b:7.1f} B")
print(f" KV per token : {per_tok/1024:7.1f} KiB")
# With TP the weights and the KV cache both shard across GPUs.
kv_gib = kv_budget_gib(shape) * tp + (tp - 1) * 0 # aggregate budget
if tp > 1:
usable = 96 * 0.92 * tp
kv_gib = usable - shape.weight_bytes() / GIB - 3.0 * tp
print(f" KV budget (aggregate) : {kv_gib:7.1f} GiB")
for ctx in contexts:
per_seq_gib = per_tok * ctx / GIB
seqs = concurrent_seqs(shape, ctx, kv_gib)
print(f" ctx {ctx:>6,}: {per_seq_gib:5.2f} GiB/seq -> "
f"{seqs:6.0f} concurrent sequences")
if __name__ == "__main__":
for shape in (GEMMA4_31B, GEMMA4_26B):
for tp in (1, 2, 4):
report(shape, tp=tp)
print("\n-- 31B with fp8 KV cache (1 byte per element) --")
kv = kv_budget_gib(GEMMA4_31B)
for ctx in (15_000, 30_000):
print(f" ctx {ctx:>6,}: "
f"{concurrent_seqs(GEMMA4_31B, ctx, kv, 1):6.0f} concurrent sequences")
Running that for both models at TP 1, 2 and 4 produces the table this entire chapter leans on. The single-GPU rows are the ones that surprise people:
| Configuration | Weights | KV budget | KV/token | @15k ctx | @30k ctx |
|---|---|---|---|---|---|
| 31B dense, TP=1 | 57.7 GiB | 27.6 GiB | 192 KiB | 10 seqs | 5 seqs |
| 31B dense, TP=2 | 57.7 GiB | 112.9 GiB | 192 KiB | 41 seqs | 21 seqs |
| 31B dense, TP=4 | 57.7 GiB | 283.5 GiB | 192 KiB | 103 seqs | 52 seqs |
| 26B MoE, TP=1 | 48.4 GiB | 36.9 GiB | 160 KiB | 16 seqs | 8 seqs |
| 26B MoE, TP=2 | 48.4 GiB | 122.2 GiB | 160 KiB | 53 seqs | 27 seqs |
| 26B MoE, TP=4 | 48.4 GiB | 292.9 GiB | 160 KiB | 128 seqs | 64 seqs |
[!] Ten sequences is not a serving system
Look at the first row. A 31B model at 15k context on a single H100 fits ten concurrent sequences. At 30k it fits five. Since section 6.1 established that throughput is essentially proportional to batch size, this configuration wastes most of a very expensive GPU no matter how carefully you tune the scheduler. The weights have eaten 60% of the card and left almost nothing for the cache. This is why the deployments in 6.20 and 6.21 both use tensor parallelism — not for speed, but to buy KV capacity.
[+] Why TP scaling is better than linear
Going from TP=1 to TP=2 on the 31B takes concurrency from 10 to 41 — a factor of four from twice the hardware. The reason is that weights are a fixed cost that gets sharded: each GPU now holds only half the weights, so the space freed on both cards goes to KV cache. You are not just adding memory, you are simultaneously shrinking the thing that was consuming it. This superlinear effect is the single strongest argument for tensor parallelism on large-context workloads, and it is invisible if you think of TP purely as a way to fit bigger models.
6.6 gpu-memory-utilization
The fraction of each GPU vLLM is allowed to claim. The most consequential single number you will set, and the one most often left at a value someone copied from a blog post.
- Flag
- --gpu-memory-utilization
- Default in 0.27.1
- 0.92 — note this, because most guides still say 0.90. It was raised, and copying an old value silently costs you cache.
- What it controls
- The share of total GPU memory vLLM may use for weights, activations and KV cache combined. Everything above the fraction is left untouched.
The mechanism matters for understanding when to change it. vLLM profiles a forward pass at startup, measures what weights and activations consume, then allocates the remainder of its budget to KV cache and holds it for the process lifetime. It does not grow later. So this single number sets your concurrency ceiling permanently at boot.
| Situation | Direction | Reasoning |
|---|---|---|
| Dedicated GPU, one vLLM process, offline batch job | Raise to 0.95–0.97 | Nothing else needs the memory. Each point is directly more KV cache and more concurrency. On the 31B at TP=2, moving 0.92 to 0.96 buys roughly 4 GiB per card. |
| Anything else shares the card | Lower to 0.80–0.85 | Another process, a monitoring agent or a second model will be starved, or will starve you. vLLM's allocation is not polite; it takes its share and keeps it. |
| Occasional out-of-memory crashes under load | Lower by 0.03–0.05 | Fragmentation and transient spikes need headroom. Crashing at 0.97 to save 3 GiB is a poor trade. |
| Latency-sensitive interactive serving | Keep near the default | A huge cache admits a huge batch, which raises per-token latency for everyone. Big caches serve throughput, not responsiveness. |
[!] It is a fraction of the whole card, not of what is free
A frequent and confusing failure. If another process already holds 20 GiB of your 96 GiB card, setting 0.92 asks for 88 GiB of a card with 76 GiB available, and startup fails — often with an error about being unable to allocate KV cache rather than a clear message about the real cause. Check with nvidia-smi first and compute the fraction against total memory. In containers, also confirm you can see the whole card.
[+] How to set it deliberately
Start at the default, launch, and read the startup log line reporting the number of GPU blocks allocated. Multiply by your block size to get cached tokens, then divide by your context length: that is your true concurrency, and it should match the table in 6.5. If it is far lower than expected, something else is on the card. Raise the value in steps of 0.02 while watching for preemption warnings and OOM errors under a realistic load test — never under an idle one, because the spikes that kill you only appear when the batch is full.
6.7 max-model-len
The longest sequence the engine will accept, input plus output combined. It looks like a safety limit. It is really a memory allocation decision, and leaving it at the default is one of the most expensive mistakes available.
- Flag
- --max-model-len
- Default
- Unset, meaning vLLM reads the model's advertised maximum from config.json — which for a modern Gemma may be 128k or more.
- Counts
- Prompt tokens plus generated tokens. A request is rejected if input plus requested output exceeds it.
[!] Why the default is dangerous
vLLM must be able to honour the maximum it advertises, so a large max-model-len forces conservative admission: the scheduler assumes any admitted request could grow to that length. Leave it at 128k when your real traffic is 15k and you get a small batch, poor throughput, and a GPU that looks busy while doing very little. Worse, it is silent — nothing errors, throughput is just quietly a fraction of what the hardware could deliver.
Setting it for the two scenarios
Your deployments are defined as input plus output totalling 15k and 30k. That makes the setting straightforward, with one caveat worth respecting.
| Scenario | Setting | Why not tighter, why not looser |
|---|---|---|
| 15k deployment | --max-model-len 16384 | A little above 15,000 so a request at the stated ceiling is not rejected by an off-by-a-few-tokens template change. Powers of two are conventional and align with block boundaries. |
| 30k deployment | --max-model-len 32768 | Same reasoning. Note this doubles the per-sequence KV cost and therefore halves concurrency at equal memory, which is why the two deployments need different scheduler settings entirely. |
| Tempting but wrong | Leaving it unset | Reserves for 128k of headroom you will never use, and collapses your batch size. |
| Also wrong | Setting exactly 15000 | Any request that lands on the boundary fails at admission. The failure surfaces as a client error under load, which is an unpleasant way to discover it. |
[+] Separate deployments beat one permissive one
You were right to frame these as two deployments rather than one engine handling both. A single engine at 32k serves 15k traffic with roughly half the concurrency it could manage, because admission is governed by the advertised maximum rather than by what requests actually use. Two pools — a large-batch 16k pool and a smaller-batch 32k pool — with a router in front sending each request to the right one, will beat one permissive engine substantially. That is the same routing argument as section 5.22, applied to infrastructure.
6.8 KV cache dtype
You asked to run the models themselves at full precision. That constraint applies to the weights; the KV cache is a separate decision, and quantising it is the cheapest concurrency you can buy.
- Flag
- --kv-cache-dtype
- Default
- auto — matches the model dtype, so bf16 for a full-precision Gemma 4, at 2 bytes per element.
- Useful value
- fp8 (resolving to fp8_e4m3 on Hopper) — 1 byte per element, halving KV memory.
[+] What halving the cache actually buys
From the calculator in 6.5, the 31B dense model at 15k context on a single H100 fits 10 sequences with a bf16 cache and 20 with fp8. At 30k it goes from 5 to 10. That is a doubling of concurrency, and since decode throughput scales with batch size, close to a doubling of throughput — from one flag, with the model weights still at full bf16 precision. Few settings offer that ratio of benefit to effort.
[!] The honest caveats
- It is a quality change, not a free lunch. Usually very small — often within noise on standard benchmarks — but it is not zero, and "full precision" in your requirements may or may not have been intended to cover the cache. Decide deliberately rather than by omission.
- Long contexts are the sensitive case. Quantisation error accumulates across the sequence, so a 30k deployment is more exposed than a 15k one. Test at your real context length, not at 512 tokens.
- Measure on your own task. Run your evaluation set from section 3.17 or 5.34 with both settings. This is a twenty-minute experiment that answers the question definitively for your workload, which is worth more than any general claim.
[def] fp8_e4m3 versus fp8_e5m2
Two ways to spend eight bits. e4m3 gives 4 exponent bits and 3 mantissa bits — less range, more precision. e5m2 gives 5 and 2 — more range, less precision. For KV cache on Hopper hardware, e4m3 is the standard choice and what fp8 resolves to, because KV values are well-behaved in range and benefit more from the extra mantissa bit. Use e5m2 only if you observe overflow, which in practice you will not.
[retail] The recommendation for your two deployments
Use fp8 for the 30k deployment; test it for the 15k one. At 30k the memory pressure is severe enough that the concurrency gain almost certainly outweighs a small quality cost, and without it you will be running uneconomically small batches. At 15k you have more room, so treat it as a measured trade rather than an automatic yes. For the 10M-request batch job in 6.22, where total wall-clock is the objective and there is no interactive user, fp8 is close to a default.
6.9 max-num-seqs
The ceiling on how many sequences can be in flight at once. Your batch size, and therefore the direct lever on decode throughput.
- Flag
- --max-num-seqs
- Default in 0.27.1
- 128
- What it controls
- The maximum number of sequences the scheduler will run concurrently. A cap, not a reservation — if only three requests exist, only three run.
[!] The default is wrong for both of your deployments, in opposite directions
128 is tuned for short-context chat. From the table in 6.5, the 31B at 15k context on two H100s fits 41 sequences. Setting 128 does not give you 128 — memory decides that — it just means vLLM optimistically admits requests it cannot finish and then preempts them, which is strictly worse than admitting fewer. Meanwhile on a 4-GPU MoE deployment at 15k you can fit 128, and the default is now the thing capping you. Either way, the number to set comes from your memory arithmetic, not from the default.
How to arrive at the right value
- Compute the memory ceiling KV budget divided by (KV bytes per token × max-model-len). That is the hard limit, and it is what the calculator in 6.5 prints.
- Subtract a safety margin Set max-num-seqs to roughly 80–90% of that ceiling. Running at exactly 100% guarantees preemption whenever requests run long, because your average request is shorter than the maximum but some are not.
- Verify against the startup log vLLM reports the GPU blocks it allocated. Blocks × block size gives total cached tokens; divide by max-model-len for the true concurrency. If this disagrees with your arithmetic, trust the log and find out why.
- Load-test and watch for preemption Sustained preemption warnings mean the value is too high. Zero preemption under peak load with memory to spare means it is too low, and you are leaving throughput on the table.
| Deployment | Memory ceiling | Set to |
|---|---|---|
| 31B dense, TP=2, 16k | 41 | 32 |
| 31B dense, TP=4, 16k | 103 | 88 |
| 31B dense, TP=4, 32k | 52 | 44 |
| 26B MoE, TP=2, 16k | 53 | 44 |
| 26B MoE, TP=4, 16k | 128 | 108 |
| 26B MoE, TP=4, 32k | 64 | 54 |
[+] Raise it for throughput, lower it for latency
A bigger batch means each forward step serves more sequences, so total tokens per second rises — but every individual sequence waits behind a larger step, so per-user tokens per second falls. For the 10M batch job in 6.22 you want this as high as memory permits. For an interactive assistant you deliberately cap it below the memory ceiling to protect tail latency. Same flag, opposite direction, decided entirely by which number your users experience.
6.10 max-num-batched-tokens
How many tokens the scheduler may process in a single forward step. The companion to max-num-seqs, and the one that governs the prefill/decode balance you asked about.
- Flag
- --max-num-batched-tokens
- Default in 0.27.1
- 2048
- What it controls
- The token budget for one engine step, shared between prefill chunks and decode tokens. Each decoding sequence consumes one token of the budget; prefill consumes as many as it is given.
Both limits apply simultaneously. The scheduler fills a step until it hits either the sequence cap or the token budget, whichever comes first. Understanding the interaction is what makes the setting tractable:
max_num_batched_tokens = 8192 max_num_seqs = 32
step N: 40 sequences decoding -> capped at 32 by max_num_seqs
32 decode tokens used
8160 tokens of budget left over -> spent on prefill chunks
step N+1: 32 decode tokens + one 8160-token prefill chunk
= 8192 tokens, budget exactly full
[def] The relationship worth internalising
Decode is cheap per token and there is one token per sequence, so decode uses at most max-num-seqs tokens of the budget. Everything left over goes to prefill. So max-num-batched-tokens minus max-num-seqs is, in effect, your prefill throughput per step. Raising the token budget speeds up prompt processing; it does not speed up generation.
| Symptom or goal | Direction | Why |
|---|---|---|
| Time to first token too slow; long prompts queueing | Raise (8192 → 16384) | More budget per step means prompts are consumed in fewer chunks. |
| Generation stutters when long prompts arrive | Lower (8192 → 4096) | Smaller prefill chunks means decode gets its turn sooner. This is the classic tail-latency fix. |
| Offline batch job, latency irrelevant | Raise hard (16384–32768) | Maximises GPU efficiency per step. Nobody is waiting, so stutter costs nothing. |
| Out of memory during prefill spikes | Lower | Activation memory scales with tokens per step, and this is often the real cause of an OOM that looks like a KV problem. |
[!] The default of 2048 is far too low for 15k prompts
Do the arithmetic: a 12,000-token prompt at 2048 tokens per step needs six full steps before generation can even begin, and it is competing with decode tokens for that budget the whole time. Time to first token suffers badly. For your context sizes this is the second flag to change after max-model-len, and leaving it alone is one of the most common causes of "vLLM is slow on our long-context workload".
[+] A rule of thumb that works
Start at max-num-seqs + (typical prompt length ÷ 2), rounded to a power of two. For the 15k deployment with 32 sequences and 12k prompts that gives roughly 6,000, so 8192. For the 30k deployment with 24k prompts, roughly 12,000, so 16384. Then adjust on measurements: raise it if time to first token is your complaint, lower it if inter-token latency is.
6.11 Chunked prefill
The mechanism that stops a long prompt from freezing everyone else's generation. On by default in 0.27.1 — but the default token budget it operates under is not tuned for 15k or 30k contexts, so it needs your attention rather than your trust.
- Flag
- --enable-chunked-prefill
- Default in 0.27.1
- True. This changed from earlier versions where it was opt-in; guides written against 0.5 or 0.6 will tell you to switch it on.
- Governed by
- --max-num-batched-tokens — the chunk size is the leftover token budget from 6.10. There is no separate chunk-size flag.
The problem it solves
Without chunking, a prefill is atomic. A 15,000-token prompt occupies an entire forward step, and every sequence currently generating waits for all of it. At perhaps 400 ms for that prefill, thirty users see their token stream freeze for nearly half a second because one other person submitted a long document.
Without chunking
- Prefill runs to completion in one step
- All decoding stalls for its full duration
- Inter-token latency spikes badly and visibly
- Worse the longer your prompts are
With chunking
- Prefill is split across several steps
- Decode tokens ride along in every step
- Latency stays smooth and predictable
- Time to first token rises slightly — the trade
[def] Piggybacking
The reason chunked prefill is nearly free rather than a compromise. A decode step is memory-bound: it reads the weights and leaves the compute units mostly idle. A prefill chunk is compute-bound. Running them in the same step uses the idle compute alongside a weight read you were already paying for, so the chunk is close to free. This is why the two phases are mixed rather than alternated, and why chunking usually raises total throughput instead of costing it.
Arriving at the right chunk size
Since the chunk is whatever the token budget leaves after decode, setting the chunk size means setting max-num-batched-tokens. Here is how to derive it rather than guess.
- Decide your inter-token latency budget Interactive chat wants a step under about 50 ms so generation feels fluid. An offline job has no such constraint and should use large chunks.
- Measure how many tokens fit in that budget Run a prefill-only benchmark at several chunk sizes and record milliseconds per step. On an H100 with a 31B model, expect very roughly 4,000–8,000 tokens in a 50 ms step; measure rather than trusting that range, because it moves with TP size and attention backend.
- Add the decode tokens The budget must also cover one token per decoding sequence, so add max-num-seqs to your chunk target.
- Round to a power of two and verify Then load-test with a realistic mix of long prompts and active generations, watching both time to first token and inter-token latency. Those two move in opposite directions; you are looking for the knee, not a maximum.
[!] When to turn it off
Rarely, and deliberately. The one defensible case is a pure offline batch job where every request has a long prompt and short output, no user is waiting, and you want each prefill to run at maximum efficiency in one shot. Even then, measure both ways — the piggybacking effect means chunking often wins even when latency does not matter. Do not disable it because a guide from an older vLLM version implied it was experimental.
[+] long-prefill-token-threshold
A finer control worth knowing: --long-prefill-token-threshold (default 0, meaning off) marks any prefill longer than this as "long" and limits how many such requests are scheduled concurrently. It stops several 30k prompts arriving together and monopolising the token budget while short requests starve behind them. If your traffic mixes 1k and 30k prompts, setting this to something like a third of your max-model-len gives noticeably fairer latency. Leave it at 0 when prompt lengths are uniform, as in the two deployments here.
6.12 Prefix caching
Reuse the KV cache of a prompt prefix that has already been computed. For RAG workloads with a shared system prompt this is close to free throughput, and in 0.27.1 it is on by default.
- Flag
- --enable-prefix-caching / --no-enable-prefix-caching
- Default in 0.27.1
- True. Another one that flipped — older guides describe it as experimental and opt-in.
- Mechanism
- The block sharing from PagedAttention in 6.2. Blocks are hashed by their token contents; an identical prefix maps to blocks already in the cache, which are shared by reference rather than recomputed.
[retail] Why RAG benefits enormously
Consider the 15k deployment: a 900-token system prompt with instructions and format rules, then roughly 11,000 tokens of retrieved documents, then the question. The system prompt is byte-identical on every single request.
With prefix caching those 900 tokens are prefilled once and shared by every request thereafter. That is 900 tokens of prefill saved per request — around 7% of prompt processing for free. And if your retrieval returns the same popular documents to many users, which in retail it absolutely does, the savings compound well beyond the system prompt.
[+] Order your prompt to maximise the shared prefix
This is the actionable part, and it is free. Caching only works on an exact prefix match from the very first token, so anything variable early in the prompt destroys reuse for everything after it. Put the stable content first: system prompt, then few-shot examples, then retrieved documents, then the user's question last. A timestamp or a user ID at the top of your template silently disables prefix caching for your entire deployment, and nothing will warn you.
[!] When to disable it
Cached blocks occupy KV cache that running sequences could otherwise use, and there is a small hashing cost per block. If your prompts share no meaningful prefix — every request is a different document with no common preamble — you are paying both costs for no benefit, and at the tight memory of a 30k single-GPU deployment that is worth reclaiming. Measure the cache hit rate vLLM reports before deciding: if it is near zero, turn caching off; if it is above roughly 20%, keep it comfortably.
6.13 Async scheduling and CUDA graphs
Two settings that reduce the overhead between forward passes rather than the cost of the passes themselves. Both matter more than they sound, because at high batch sizes the gaps add up.
Async scheduling
- Flag
- --async-scheduling
- Default in 0.27.1
- None, which enables it where the executor supports it. Set it to false only to disable.
- What it does
- Prepares the next step's batch on the CPU while the GPU is still executing the current one, so the GPU does not idle waiting for the scheduler to decide what comes next.
The gap it closes is small per step — a few milliseconds of Python deciding which sequences to run and building tensors. But with steps arriving many times per second, those milliseconds are a real fraction of your throughput. Leave this enabled. Disable it only when debugging, because it makes the ordering of events easier to follow.
CUDA graphs
- Flag
- --enforce-eager (a boolean that disables graphs)
- Default
- False — so CUDA graphs are on, which is what you want.
- What they do
- Record the sequence of GPU operations for a forward pass once, then replay the whole recording as a single submission instead of dispatching hundreds of individual kernels from Python each step.
[def] Why kernel launch overhead matters at all
A decode step for a 48-layer model dispatches hundreds of small GPU operations. Each carries a few microseconds of launch overhead from the host, and for decode — where the actual work per kernel is tiny — that overhead can rival the compute. CUDA graphs replay the entire recorded sequence with one submission, which is why they help decode far more than prefill. Prefill kernels are large enough that launch cost disappears into them.
[!] The cost of enforce-eager, and when to accept it
Graphs are captured at startup for a set of batch shapes, which adds tens of seconds to boot and consumes 1–3 GiB of GPU memory for the buffers. That tempts people to set --enforce-eager to reclaim the memory. Do not do this for a throughput deployment — you will typically lose 10–20% of decode performance to save a couple of gigabytes, which is a bad trade when the whole point of the memory is throughput. Legitimate uses: debugging a crash where you need a readable stack trace, or a genuinely memory-desperate configuration where those gigabytes decide whether the model loads at all.
6.14 TP, PP, DP and EP
Four ways to spread a model across GPUs. They solve different problems and combine, and choosing wrongly is expensive in a way that is hard to see afterwards.
- Tensor parallel (TP)
- --tensor-parallel-size, default 1. Splits every layer's weight matrices across GPUs. All GPUs work on the same token simultaneously and synchronise twice per layer.
- Pipeline parallel (PP)
- --pipeline-parallel-size, default 1. Gives each GPU a different set of layers. Tokens flow through the stages like an assembly line.
- Data parallel (DP)
- --data-parallel-size, default 1. Independent replicas of the whole model, each serving different requests. No communication during inference.
- Expert parallel (EP)
- --enable-expert-parallel, default False. For MoE only: distributes experts across GPUs rather than slicing each expert.
| Solves | Communication | Use when | |
|---|---|---|---|
| TP | Model or KV cache too big for one GPU; also cuts latency | Heavy — two all-reduces per layer, every step | Within a node, over NVLink. Your default choice on an 8-GPU H100 box. |
| PP | Model too big for one node | Light — activations passed between stages only | Across nodes on slower interconnect. Adds pipeline bubbles, so avoid within a node. |
| DP | Not enough throughput; model already fits | None during inference | Scaling out once a single replica is well tuned. The best scaling axis for batch jobs. |
| EP | MoE expert weights dominating memory | Moderate — tokens routed to whichever GPU holds their expert | Large MoE models, usually combined with TP. |
[+] For your hardware, the ordering is clear
On a single 8×H100 node with NVLink: use TP up to the point where it buys you the KV cache you need, then use DP for everything else. TP within the node is cheap because NVLink is fast; TP across nodes is not, and you should not do it. Once one replica is tuned, add replicas rather than widening TP — DP scales almost perfectly, while TP hits diminishing returns as synchronisation costs grow.
[!] TP is not free, and the constraint that catches people
Every layer synchronises across all TP ranks twice, so TP=8 spends meaningfully more time communicating than TP=2. Throughput per GPU falls as TP grows even while total throughput and KV capacity rise. There is also a hard constraint: the attention head count must divide evenly by the TP size. A model with 8 KV heads cannot run TP=16, and with grouped-query attention the KV head count is the binding limit rather than the larger query-head count. Check num_key_value_heads before planning a topology.
6.15 MoE versus dense: 26B-A4B against 31B
Your two models look similar in size and behave completely differently. Understanding why decides which one to deploy for which job.
[def] Reading the name "26B-A4B"
26 billion total parameters, roughly 4 billion active per token. The model holds many expert sub-networks; a router picks a small number of them for each token. So the memory cost is 26B and the compute cost is 4B. Those two numbers drive completely different parts of your planning, and conflating them is the classic MoE mistake in both directions — people expect it to be cheap to host, or expect it to be slow like a 26B dense model.
| 31B dense | 26B-A4B MoE | |
|---|---|---|
| Weights in bf16 | 57.7 GiB | 48.4 GiB |
| Active parameters | 31B — all of them, every token | ~4B — roughly 8× less compute |
| Prefill speed | Slow: compute-bound on 31B of maths | Fast: only active experts run |
| Decode speed | Bandwidth-bound on 57.7 GiB per step | Bandwidth-bound on 48.4 GiB — better, but not 8× |
| KV per token | 192 KiB | 160 KiB |
| Extra parallelism | TP only | TP, plus expert parallel |
[!] The MoE decode disappointment
Notice the asymmetry in that table, because it surprises people. MoE gives you roughly eight times less compute but only 16% less memory traffic. Prefill is compute-bound, so it gets dramatically faster. Decode is bandwidth-bound and the weights still have to be read, so it improves only slightly. Anyone expecting an MoE to generate tokens eight times faster will be disappointed — the win is in prefill, which happens to be exactly where your long-context RAG workload spends most of its time.
[+] Which to choose for your workloads
For 15k and 30k contexts with moderate outputs, the workload is prefill-dominated, and the 26B-A4B MoE is the better engine on every axis that matters: less memory for weights, more room for KV cache, lower KV cost per token, and far cheaper prefill. The throughput model in 6.22 puts the gap at roughly 2.6× on total wall-clock for the ten-million-request job. Choose the 31B dense model only if your evaluation shows a quality difference that justifies that cost — which is a measurement, not an assumption.
[def] When to enable expert parallel
--enable-expert-parallel places whole experts on different GPUs instead of slicing every expert across all of them. It reduces per-GPU memory and avoids some communication, at the cost of routing tokens to whichever GPU owns their expert — which is uneven, because expert usage is not uniform. For a 26B model that already fits comfortably in a TP=2 or TP=4 group, leave it off. It earns its complexity on much larger MoE models where expert weights genuinely dominate.
6.16 How speculative decoding works
The one technique that breaks the sequential nature of decoding. Done right it is close to free throughput; done wrong it makes things slower, and the difference is one number you have to measure.
Recall from 6.4 that decode is memory-bandwidth-bound: a forward pass reads every weight to produce one token, leaving the compute units idle. That idle compute is the opportunity. If you could guess the next few tokens cheaply, you could verify all of them in a single forward pass — for almost the same cost as verifying one.
[def] Speculative decoding
A cheap drafter proposes k tokens. The full model then processes all k in one forward pass and checks each against what it would have produced. Accepted tokens are kept; at the first rejection, that token is corrected and the rest are discarded. The output is mathematically identical to what the target model would have generated alone — this is a pure speed optimisation with no quality cost, which is what makes it so attractive.
drafter proposes 4: "the" "customer" "should" "return"
target verifies all in ONE forward pass:
"the" "customer" "can" ...
OK OK reject
result: 3 tokens produced this step ("the", "customer", "can")
instead of 1. The last draft token is thrown away.
[+] Why verification is nearly free
Processing 4 tokens in one pass costs almost exactly what processing 1 costs, because the expensive part — reading 48 GiB of weights — happens once either way. You are spending idle compute to avoid repeated memory reads. This is the same bandwidth argument that makes batching effective, applied along the time axis instead of across requests.
[!] Why it can make things slower
Speculation is a bet, and it costs something every time. You pay for the drafter on every step whether or not its guesses are accepted, plus a slightly more expensive verification pass. If acceptance is low, you pay both and gain little. Worse, the trade changes with load: at large batch sizes the GPU is no longer idle, so the "free" compute you were exploiting no longer exists, and speculation can cost real throughput. This is the single most important thing to understand about it, and it is why 6.19 is about turning speculation down under load rather than up.
6.17 Assistant draft models
The most straightforward form of speculation: a small model from the same family proposes, the big model verifies. Gemma 4 ships purpose-built assistant checkpoints for exactly this.
[def] The -assistant convention
vLLM 0.27.1's model registry pairs Gemma 4 targets with assistant drafters — for example google/gemma-4-E4B-it with google/gemma-4-E4B-it-assistant. An assistant model is a small model distilled from and aligned to its target, which matters: the closer the drafter's distribution is to the target's, the more tokens get accepted, and acceptance is the entire economics of speculation.
vllm serve google/gemma-4-31B-it \
--tensor-parallel-size 4 \
--speculative-config '{
"model": "google/gemma-4-31B-it-assistant",
"num_speculative_tokens": 4,
"draft_tensor_parallel_size": 1
}'
- num_speculative_tokens
- How many tokens to propose per step. The main dial, tuned in 6.19.
- draft_tensor_parallel_size
- TP for the drafter, usually 1. A small model gains little from sharding and pays synchronisation cost on every draft, so keep it on one GPU even when the target is spread across four.
- method
- Detected automatically when you supply a model. Set it explicitly only for methods with no draft checkpoint, such as ngram.
[!] The drafter competes with the target for memory
The draft model's weights and its own KV cache come out of the same budget you allocated in 6.5. A 4B drafter in bf16 is roughly 8 GiB of weights plus its cache — on the 31B at TP=2 that is a noticeable bite out of your 112 GiB KV budget, and it directly reduces max-num-seqs. Speculation trades batch size for tokens per step. If your batch is already small because the context is long, that trade can easily be a net loss. Re-measure concurrency after enabling it rather than assuming your earlier sizing still holds.
[+] Choosing a drafter size
The drafter wants to be roughly 10 to 20 times smaller than the target. Too large and drafting costs as much as it saves; too small and its guesses are rejected so often that you pay the draft cost for nothing. For a 31B target, a 2B to 4B assistant is the sensible range. Always prefer an official assistant checkpoint over an unrelated small model of the same size — alignment to the target's distribution matters far more than parameter count, and a mismatched drafter can halve your acceptance rate.
6.18 MTP and the other methods
A separate draft model is the obvious approach, not the best one. Multi-token prediction removes the second model entirely, and vLLM 0.27.1 supports it for Gemma 4 specifically.
[def] MTP — multi-token prediction
Extra lightweight prediction heads attached to the target model itself, trained to predict tokens two, three and four positions ahead. The model drafts its own continuation. vLLM 0.27.1 registers Gemma4MTPModel as a supported type, so this is a first-class option for your models rather than a research curiosity.
[+] Why MTP usually beats a separate drafter
- Almost no extra memory. A few small heads instead of a whole 4B model with its own KV cache. On the memory-tight configurations in this chapter, that difference alone can decide it.
- Higher acceptance. The heads share the target's own hidden states, so their predictions come from the same computation that produces the real answer — far closer to the target distribution than a separate model can manage.
- One checkpoint. Nothing extra to load, place, version or upgrade.
| Method | How it drafts | When to use it |
|---|---|---|
| MTP | Extra heads on the target model | First choice when your checkpoint supports it. Best acceptance, least memory. |
| draft_model | A separate small model, such as an assistant checkpoint | The general fallback. Works with any model pair, costs memory. |
| EAGLE | A small head predicting the target's feature sequence | Strong acceptance, but needs a trained EAGLE head for your model. |
| ngram | Copies repeated token sequences out of the prompt | Free, with no model at all. Surprisingly effective when output quotes the input heavily. |
| suffix | A suffix tree over the prompt and past responses | Like ngram, but cached across requests. Good for repetitive workloads. |
| medusa, mlp_speculator | Additional trained prediction heads | Older approaches, largely superseded by MTP and EAGLE. |
[retail] ngram is nearly free for RAG, and worth trying first
Do not skip past the ngram row. A RAG answer frequently quotes its retrieved context verbatim — product names, specifications, policy clauses, prices. When the model is copying a span that already exists in the prompt, ngram drafting predicts it perfectly at essentially zero cost, because it is a string lookup rather than a model. It requires no draft checkpoint and no extra memory, which makes it the cheapest experiment in this chapter: enable it, measure acceptance, and keep it if the number is good. For extraction and summarisation workloads it can rival a real drafter.
[!] Verify support before planning around it
MTP needs a checkpoint that actually ships the extra heads. The registry entry existing in vLLM does not mean your particular Gemma 4 download has them. Confirm the architecture in the checkpoint's config.json, and have the assistant-drafter path from 6.17 as your fallback. This is exactly the kind of assumption that is cheap to check now and expensive to discover during a deployment.
6.19 Tuning acceptance
Speculation lives or dies on one measured number. This section is how to find it and what to do with it.
[def] Acceptance rate
The fraction of drafted tokens the target model accepts. vLLM reports it in its metrics, and it is the only number that tells you whether speculation is helping. Related and more directly useful is the accepted tokens per step: with num_speculative_tokens=4 and 60% acceptance you get roughly 2.4 extra tokens per step, so each forward pass produces about 3.4 tokens instead of 1.
Choosing num_speculative_tokens
Every drafted token costs drafting time and makes verification slightly more expensive. The value of an extra draft token falls as you add more, because it only pays off if all preceding drafts were accepted. Acceptance compounds against you: at 70% per token, the fourth token is only accepted 24% of the time.
| Observation | Action | Reasoning |
|---|---|---|
| Acceptance above 80% | Raise k to 5–8 | The drafter tracks the target closely. Longer draft chains keep paying off. |
| Acceptance 50–80% | Keep k at 3–4 | The healthy range. This is where most well-matched drafters land. |
| Acceptance 30–50% | Lower k to 2 | Still worth something, but long chains are wasted work. |
| Acceptance below 30% | Turn speculation off | You are paying draft cost on every step for almost nothing. Fix the drafter or abandon it. |
| Throughput fell after enabling it | Check batch size first | See the warning below. This is usually load, not acceptance. |
[!] Speculation and large batches are enemies
The most important and least intuitive point in this part. Speculation exploits idle compute during memory-bound decode. A large batch already fills that idle compute with other sequences' work. So the benefit shrinks exactly as your batch grows, and beyond some batch size speculation becomes a net cost — you are spending compute you now need on guesses you no longer profit from.
This is why speculation is a latency optimisation far more than a throughput one. At batch 4 it can nearly double tokens per second for a single user. At batch 64 it may slow you down. Never enable it on the strength of a single-user benchmark and assume the gain survives production load.
[+] num_speculative_tokens_per_batch_size
vLLM 0.27.1 resolves the above tension directly. This setting takes a list of (range_start, range_end, k) tuples and varies the draft length by current batch size — speculate aggressively when the engine is quiet, back off automatically as load rises, and stop entirely at large batches. For a deployment with variable traffic this is strictly better than a fixed value, and it is the single most useful speculative feature added in this release.
--speculative-config '{
"model": "google/gemma-4-31B-it-assistant",
"num_speculative_tokens": 5,
"num_speculative_tokens_per_batch_size": [
[1, 8, 5], # quiet: draft aggressively
[9, 24, 3], # moderate load: back off
[25, 64, 1], # busy: minimal speculation
[65, 512, 0] # saturated: stop entirely
]
}'
6.20 The 15k deployment
Input plus output capped at 15,000 tokens. Everything from the previous nineteen sections, assembled into one command line with the reasoning for each flag.
[def] Assumed workload
Roughly 12,000 tokens of input (a system prompt plus retrieved RAG context) and up to 3,000 tokens of output, on one node of 8×H100 96 GB. Interactive: users are waiting, so tail latency matters alongside throughput.
vllm serve google/gemma-4-26B-A4B-it \
--tensor-parallel-size 4 \
--max-model-len 16384 \
--max-num-seqs 108 \
--max-num-batched-tokens 8192 \
--gpu-memory-utilization 0.92 \
--kv-cache-dtype auto \
--speculative-config '{
"method": "ngram",
"num_speculative_tokens": 3,
"prompt_lookup_max": 4,
"num_speculative_tokens_per_batch_size": [
[1, 16, 3], [17, 48, 2], [49, 512, 0]
]
}'
| Flag | Value | Reasoning |
|---|---|---|
| tensor-parallel-size | 4 | Not for speed. TP=1 gives only 16 concurrent sequences; TP=4 gives 128, because sharding the weights frees KV space on every card. Two replicas of TP=4 fill the node. |
| max-model-len | 16384 | Just above the 15k ceiling so boundary requests are not rejected. Never leave this unset — the model advertises far more and your batch collapses. |
| max-num-seqs | 108 | About 85% of the 128 the memory allows, leaving margin so normal variation does not trigger preemption. |
| max-num-batched-tokens | 8192 | The rule of thumb from 6.10: seqs + half the typical prompt, rounded up. Leaves roughly 8,000 tokens per step for prefill chunks after decode takes its share. |
| gpu-memory-utilization | 0.92 | The 0.27.1 default, appropriate for an interactive service. Raise only if the GPUs are exclusively yours and you have load-tested for OOM. |
| kv-cache-dtype | auto | TP=4 already provides ample cache at this context length, so keep full precision here and hold fp8 in reserve. |
| speculative-config | ngram, adaptive | Free to run, no memory cost, and RAG answers quote their context often enough to make it pay. It disables itself above batch 48, where it would otherwise cost throughput. |
[+] Deliberately left at defaults
--enable-chunked-prefill (already true, and essential here), --enable-prefix-caching (already true, and the shared system prompt makes it valuable), --block-size (auto-resolved per backend), --async-scheduling (on where supported) and CUDA graphs (on, since --enforce-eager defaults to false). In 0.27.1 these defaults are correct for this workload. Setting them explicitly adds noise to your command line and risks pinning a value that a later release improves.
[retail] If you must run the 31B dense model
Same topology, but --max-num-seqs 88 and expect materially lower throughput — the 6.22 model puts dense prefill at roughly eight times the cost of the MoE. If quality requires the dense model, budget for more replicas rather than trying to tune your way out of it. This is a hardware decision disguised as a configuration one.
6.21 The 30k deployment
Doubling the context does not double the difficulty — it roughly halves your concurrency, which changes several decisions at once. This is why it deserves a separate deployment rather than a larger limit on the same one.
[!] What actually changes at 30k
- KV per sequence doubles. 4.58 GiB instead of 2.29 GiB on the MoE, so concurrency halves from 128 to 64 at TP=4.
- Prefill cost doubles per request, so time to first token roughly doubles unless you raise the token budget.
- Attention cost grows faster than linearly in sequence length, so the later chunks of a long prefill are more expensive than the early ones.
- Preemption becomes far more damaging. Recomputing a preempted 24,000-token prefill is enormously expensive, so the margin below your memory ceiling needs to be wider.
vllm serve google/gemma-4-26B-A4B-it \
--tensor-parallel-size 4 \
--max-model-len 32768 \
--max-num-seqs 54 \
--max-num-batched-tokens 16384 \
--gpu-memory-utilization 0.94 \
--kv-cache-dtype fp8 \
--long-prefill-token-threshold 10240 \
--speculative-config '{
"method": "ngram",
"num_speculative_tokens": 3,
"prompt_lookup_max": 4,
"num_speculative_tokens_per_batch_size": [
[1, 12, 3], [13, 32, 2], [33, 512, 0]
]
}'
| Flag | 15k → 30k | Reasoning |
|---|---|---|
| max-model-len | 16384 → 32768 | The defining change. Everything else follows from it. |
| max-num-seqs | 108 → 54 | Memory allows 64 with an fp8 cache; 54 leaves the wider margin that long prefills demand. |
| max-num-batched-tokens | 8192 → 16384 | Prompts are twice as long, so the chunk budget doubles to keep time to first token from doubling with them. |
| gpu-memory-utilization | 0.92 → 0.94 | Memory pressure is severe enough to justify claiming more, provided the card is exclusively yours and you have load-tested it. |
| kv-cache-dtype | auto → fp8 | The most valuable single change here. Halving KV takes concurrency from 32 back to 64 — recovering everything the longer context cost you. |
| long-prefill-token-threshold | 0 → 10240 | Now worth setting. It stops several 30k prompts arriving together and monopolising the token budget while shorter requests starve. |
[+] fp8 is what makes 30k affordable
Worth stating plainly, since it is the crux of this configuration. Without an fp8 KV cache the 30k deployment runs at about half the concurrency of the 15k one, and therefore roughly half the throughput. With it, you are back to 64 concurrent sequences — the same order as before. The quality cost of fp8 KV is small and the concurrency cost of skipping it is not. Measure it on your evaluation set, but expect to enable it.
[retail] Route rather than merge
If your traffic contains both sizes, run both pools and route by measured token count — not one 32k engine serving everything. A single permissive engine applies the 30k admission maths to your 15k requests and gives up roughly half the throughput on the majority of your traffic. Count the prompt tokens at the edge and send each request to the right pool. The routing logic is trivial; the throughput difference is not.
6.22 Sizing a ten-million-request job
A different problem from serving. Nobody is waiting for any individual answer, so the objective is total wall-clock for the whole batch — and several settings invert.
The first job is an estimate good enough to choose a fleet size. The model below is deliberately simple: prefill is compute-bound and scales with input tokens, decode is bandwidth-bound and scales with output tokens divided by batch size. Both include a realistic efficiency factor rather than assuming peak hardware numbers.
"""Throughput and wall-clock planning for a 10-million-request batch job.
The question "how long will 10M inferences take" has no single answer, but
it has a defensible estimate, and the estimate is what tells you whether to
buy 8 GPUs or 64. This model is deliberately simple and states its
assumptions loudly: it is for choosing a fleet size and a configuration, not
for promising an SLA.
Two regimes matter and they behave completely differently:
- PREFILL is compute-bound. Cost scales with total input tokens.
- DECODE is memory-bandwidth-bound. Cost scales with output tokens and is
helped enormously by batching, because the weights are read once per
step regardless of how many sequences are in flight.
"""
from __future__ import annotations
from dataclasses import dataclass
H100_BF16_TFLOPS = 989.0 # dense bf16 with sparsity off, marketing peak
H100_HBM_TBS = 3.35 # HBM3 bandwidth, TB/s
MFU_PREFILL = 0.45 # realistic fraction of peak achieved on prefill
MBU_DECODE = 0.70 # realistic fraction of peak bandwidth on decode
@dataclass(frozen=True)
class Workload:
requests: int
input_tokens: int
output_tokens: int
@dataclass(frozen=True)
class Deployment:
name: str
active_params_b: float # drives BOTH flops and bytes moved per step
total_params_b: float
gpus: int
batch: int # sequences decoded concurrently
accept_rate: float = 0.0 # speculative tokens accepted per draft token
def prefill_seconds(w: Workload, d: Deployment) -> float:
"""Total prefill time across the fleet.
Forward pass costs roughly 2 FLOPs per active parameter per token. For an
MoE only the ACTIVE experts run, which is the entire point of the
architecture.
"""
total_tokens = w.requests * w.input_tokens
flops = 2 * d.active_params_b * 1e9 * total_tokens
fleet_flops = H100_BF16_TFLOPS * 1e12 * MFU_PREFILL * d.gpus
return flops / fleet_flops
def decode_seconds(w: Workload, d: Deployment) -> float:
"""Total decode time across the fleet.
Decode reads the whole weight matrix once per forward step and serves the
entire batch from that single read, which is why batching is nearly free
on decode and why small batches waste the GPU so badly.
A tensor-parallel group of 8 GPUs behaves as one fast engine, not eight
independent ones: each holds a shard and they read in parallel. So the
fleet is modelled as (gpus / tp) independent replicas, each running its
own batch.
"""
tp = 8 # one replica per 8-GPU node
replicas = max(1, d.gpus // tp)
steps_per_seq = w.output_tokens / (1 + d.accept_rate)
steps_per_replica = w.requests * steps_per_seq / d.batch / replicas
# Each GPU reads its own 1/tp shard of the weights concurrently.
bytes_per_step = d.total_params_b * 1e9 * 2 / tp
seconds_per_step = bytes_per_step / (H100_HBM_TBS * 1e12 * MBU_DECODE)
return steps_per_replica * seconds_per_step
def summarise(w: Workload, deployments: list[Deployment]) -> None:
print(f"workload: {w.requests:,} requests x "
f"({w.input_tokens:,} in / {w.output_tokens:,} out)")
print(f"{'deployment':<34}{'prefill':>10}{'decode':>10}{'total':>10}"
f"{'req/s':>10}")
print("-" * 74)
for d in deployments:
p, dec = prefill_seconds(w, d), decode_seconds(w, d)
total = p + dec
print(f"{d.name:<34}{p/3600:9.1f}h{dec/3600:9.1f}h{total/3600:9.1f}h"
f"{w.requests/total:10.1f}")
if __name__ == "__main__":
JOB = Workload(requests=10_000_000, input_tokens=12_000, output_tokens=3_000)
summarise(JOB, [
Deployment("26B-A4B MoE, 8 GPU, batch 48", 4.0, 26.0, gpus=8, batch=48),
Deployment("26B-A4B MoE, 8 GPU, batch 48, spec", 4.0, 26.0, gpus=8,
batch=48, accept_rate=1.6),
Deployment("31B dense, 8 GPU, batch 32", 31.0, 31.0, gpus=8, batch=32),
Deployment("31B dense, 8 GPU, batch 32, spec", 31.0, 31.0, gpus=8,
batch=32, accept_rate=1.6),
Deployment("26B-A4B MoE, 64 GPU, batch 48", 4.0, 26.0, gpus=64, batch=48),
])
workload: 10,000,000 requests x (12,000 in / 3,000 out)
deployment prefill decode total req/s
--------------------------------------------------------------------------
26B-A4B MoE, 8 GPU, batch 48 74.9h 481.2h 556.1h 5.0
26B-A4B MoE, 8 GPU, batch 48, spec 74.9h 185.1h 260.0h 10.7
31B dense, 8 GPU, batch 32 580.5h 860.7h 1441.1h 1.9
31B dense, 8 GPU, batch 32, spec 580.5h 331.0h 911.5h 3.0
26B-A4B MoE, 64 GPU, batch 48 9.4h 60.2h 69.5h 40.0
[!] Read these as ratios, not promises
A model this simple ignores scheduling gaps, tokenisation, network time, ragged batches at the end of the job and the fact that real requests vary in length. Treat the numbers as relative comparisons that tell you which configuration wins and by roughly how much. The moment you have real hardware, measure a 10,000-request sample and scale from that instead — a measured sample beats any model.
What the numbers say
- The MoE wins decisively, and mostly on prefill 75 hours of prefill against 580 for the dense model — nearly eight times — exactly the ratio of active parameters. Since this workload is prefill-heavy, that difference dominates. Total job time is roughly 2.6× better even before speculation.
- Decode dominates anyway, so batch size is the main lever Even for the MoE, decode is 481 hours against 75 for prefill. Decode time is inversely proportional to batch size, which means every memory decision that buys concurrency — TP, fp8 KV cache, a tighter max-model-len — converts directly into hours saved.
- Speculation more than halves decode here 556 hours falls to 260. This is the one context where speculation is unambiguously a throughput win rather than a latency one: with 10M queued requests you can keep batches moderate and still saturate the fleet, so the idle compute speculation needs actually exists.
- Scaling out is close to linear 8 GPUs to 64 takes 556 hours to 69.5 — a factor of exactly 8. Data parallelism has no inference-time communication, so replicas scale almost perfectly. This is the reliable lever: tune one replica properly, then multiply it.
[+] The recommendation
26B-A4B MoE, TP=4, two replicas per 8-GPU node, fp8 KV cache, speculation on, and as many nodes as your deadline requires. At eight nodes (64 GPUs) with speculation the model puts the job at well under two days. Choose the node count from the arithmetic: total GPU-hours is roughly constant, so nodes and wall-clock trade directly against each other, and the only question is your deadline.
6.23 Batch-mode tactics
With no user waiting, several settings move in the opposite direction from everything recommended so far. Here is the full inversion, and the tactics that only make sense offline.
| Setting | Interactive | Batch job |
|---|---|---|
| max-num-seqs | Capped below the memory ceiling to protect tail latency | As high as memory allows — latency is irrelevant |
| max-num-batched-tokens | Moderate, to keep decode smooth | Large (16k–32k) for peak step efficiency |
| gpu-memory-utilization | 0.90–0.92 for safety | 0.95–0.97 — nothing else wants the card |
| kv-cache-dtype | Measured trade-off | fp8 almost always — concurrency is the objective |
| Chunked prefill | Essential, protects generation from stalling | Still usually on, but the reason has gone; measure both ways |
| Speculation | Helps at low load, hurts at high | Helps — queue depth lets you hold batches moderate |
| Failure handling | Retry the request | Checkpoint progress — losing 40 hours of work is unacceptable |
Tactics that only apply offline
- Sort requests by length before submitting The single highest-value trick in this section. Batching a 30k prompt alongside a 2k one wastes the padding difference on every step they share. Sorting into length-homogeneous groups can lift throughput 20–30% for nothing but a sort() before the job starts. This is only possible offline, because it requires seeing all the work before beginning it.
- Group by shared prefix to exploit caching If requests share system prompts or retrieved documents, sort so identical prefixes are adjacent. Prefix caching from 6.12 then hits constantly rather than being evicted between unrelated requests. Combine with the length sort: group by prefix first, then sort by length within each group.
- Use the offline LLM class, not the HTTP server LLM.generate() with a list of prompts skips HTTP, JSON serialisation and per-request overhead entirely. For 10M requests that overhead is real money. The server exists for interactive traffic; a batch job does not need it.
- Cap max-tokens tightly and honestly The scheduler reserves against the maximum a request might generate. If your answers are 500 tokens, setting max_tokens=3000 "just in case" costs concurrency on every single request. Measure your real output distribution and set the cap just above the 99th percentile.
- Checkpoint continuously A 40-hour job will meet a node failure. Write results incrementally, keyed by request id, and make the driver resumable so a restart skips completed work. This is ordinary batch engineering, and it is the difference between a delay and a disaster.
[!] The straggler problem at the end
As a job drains, fewer requests remain than your batch size, so the last stretch runs at steadily falling GPU utilisation — the same effect that made static batching inefficient in 6.3, arriving at the end of every batch job. With length-sorted input it is worse, because all the longest requests are grouped together at one end. Two fixes: keep the queue fed from a shared work pool across all replicas rather than pre-partitioning the work, and put the longest requests first so the job ends on short ones.
6.24 Defaults: what to keep, what to set
You asked which knobs to leave free and what goes wrong if you do. Here is the complete answer in one place, sorted by how much damage the default can do.
Always set these
| Flag | Default | What goes wrong if you leave it |
|---|---|---|
| max-model-len | Model's advertised max | The worst default for your workload. A 128k limit on 15k traffic collapses your batch, silently, with no error. Always set it. |
| tensor-parallel-size | 1 | A 31B model on one H100 leaves 27 GiB of KV cache and 10 concurrent sequences. Wastes most of the node. |
| max-num-batched-tokens | 2048 | Tuned for short chat. A 12k prompt needs six steps to prefill, so time to first token suffers badly. |
| max-num-seqs | 128 | Either causes constant preemption (if memory cannot support 128) or caps you below what memory allows. Rarely right by accident. |
Set these deliberately, after measuring
| Flag | Default | When to change it |
|---|---|---|
| gpu-memory-utilization | 0.92 | Raise to 0.95+ for a dedicated batch job; lower to 0.85 if anything shares the card. |
| kv-cache-dtype | auto (bf16) | fp8 doubles concurrency for a small quality cost. Near-mandatory at 30k; measured at 15k. |
| speculative-config | off | Big win at low batch sizes and for batch jobs. Can hurt at high concurrency. Always load-test. |
| long-prefill-token-threshold | 0 (off) | Set to roughly a third of max-model-len when prompt lengths vary widely. |
Leave these alone
[+] Correct by default in 0.27.1
- enable-chunked-prefill (true) — essential for long contexts, and already on. Older guides telling you to enable it are out of date.
- enable-prefix-caching (true) — nearly free, and valuable with shared system prompts. Disable only if you measure a near-zero hit rate.
- block-size (auto) — resolved from your attention backend. A manual value is very likely worse.
- async-scheduling (on where supported) — closes GPU gaps between steps. Disable only for debugging.
- enforce-eager (false, so CUDA graphs on) — setting it costs 10–20% of decode to reclaim 1–3 GiB. Bad trade.
- enable-expert-parallel (false) — unnecessary for a 26B MoE that already fits in a TP group.
[!] The general danger of copied configurations
Defaults in vLLM change more often than anything else in the stack. Chunked prefill and prefix caching were both opt-in not long ago and are now on; gpu-memory-utilization moved to 0.92. A configuration copied from a blog post written against 0.6 can pin values that a later release has improved, or explicitly re-enable something that is already the default while missing the two flags that actually matter. Run vllm serve --help against your own installed version and set flags because you decided to, not because someone else's command line had them.
6.25 Key takeaways
The eleven things worth remembering
- Decode is memory-bandwidth-bound, not compute-bound. Reading the weights costs the same for one sequence or a hundred. Nearly every parameter in this chapter exists to make the batch as large as memory allows.
- Prefill and decode are different machines. Prefill is compute-bound and scales with input tokens; decode is bandwidth-bound and scales with output tokens divided by batch size. Guidance tuned for one regime can actively mislead in the other.
- Weight memory is set by total parameters, always. A 26B-A4B MoE needs 26B worth of weights resident even though only 4B run per token. Sparsity buys compute, never memory.
- max-model-len is a memory decision, not a safety limit. Leaving it at the model's advertised maximum silently collapses your batch size with no error message anywhere.
- Tensor parallelism scales KV capacity superlinearly on large-context workloads, because sharding weights frees space on every card simultaneously. It is worth using even when you don't need the extra compute.
- Chunked prefill and prefix caching are already on in 0.27.1. The work is tuning the token budget they share with decode, not deciding whether to enable them.
- fp8 KV cache is close to free concurrency at a small, measurable quality cost. Near-mandatory at 30k context; worth testing at 15k.
- Speculative decoding exploits idle compute during decode — which shrinks as batch size grows. It is a latency win at low load and can be a throughput loss at high load. Tune it by batch size, not once.
- MTP beats a separate draft model when your checkpoint supports it — higher acceptance, almost no extra memory, one checkpoint to manage.
- Two deployments beat one permissive one. A single engine sized for your longest context wastes roughly half its throughput on your shorter requests.
- Batch jobs invert several interactive defaults. Latency stops mattering, so max-num-seqs, memory utilisation and speculation all move toward their most aggressive settings, and length-sorting the input is worth more than any single flag.
[def] The one-sentence version
Work out your memory budget on paper before touching a flag, size max-model-len and the batch to your real traffic rather than the model's ceiling, let chunked prefill and prefix caching do their job, spend fp8 and speculation where the arithmetic says they pay, and re-measure every one of these decisions once your workload's shape actually changes.
6.26 Interview drills
vLLM questions test whether you understand the memory-bandwidth argument or have only copied flags from a README. Every strong answer below explains the mechanism first and states the number second.
1. Why is decoding memory-bound rather than compute-bound?
Generating one token requires reading every weight in the model from HBM, and the arithmetic performed on that data is tiny by comparison. For a 31B model in bf16 that is roughly 58 GiB read to produce a single token's logits. The GPU's compute units finish their work and sit idle waiting for the next read.
The consequence is that batching is nearly free during decode: the weight read is shared across every sequence in the batch, so serving 32 sequences costs barely more than serving one. That is why nearly every serving optimisation, from continuous batching to speculative decoding, is really an attempt to either enlarge the batch or fill the idle compute with something useful.
Follow-up to expect: "Is prefill the same?" No — prefill processes many tokens at once and is compute-bound, which is why batching barely helps it and chunking exists to protect decode from it instead.
2. A 26B-A4B MoE model. How much GPU memory do the weights need?
Roughly 26B parameters worth, in whatever precision you load — about 48 GiB in bf16. The "A4B" describes active parameters per token, which sets the compute cost, not the memory footprint. Every expert must be resident in HBM because the router can send any token to any expert, so sparsity buys FLOPs, not gigabytes.
This is the single most common MoE planning mistake: expecting a sparse model to be cheap to host because it is cheap to run. It is cheap to run. It is not cheap to hold in memory, and the KV cache budget has to account for the full weight size exactly as it would for a dense model of that total size.
3. Your service is slow to produce the first token on long prompts, but generation once it starts is fine. Which knob?
max-num-batched-tokens. Time to first token is a prefill problem, and prefill is chunked into pieces no larger than this budget. At the default of 2048, a 12,000-token prompt needs six sequential scheduler steps before generation can even begin, and it is sharing that budget with every sequence currently decoding.
I would raise it toward roughly max-num-seqs plus half the typical prompt length, rounded to a power of two, then load-test. Raising it trades away some of the stutter protection that chunking exists to provide, so I would watch inter-token latency at the same time and stop at the point where the two trade off acceptably rather than maximising one blindly.
4. Explain why chunked prefill helps decode latency without wasting the GPU.
Without chunking, a long prefill is atomic and occupies a whole forward step, so every sequence currently decoding stalls for its entire duration — users see their token stream freeze whenever someone else submits a long prompt. Chunking splits the prefill into pieces small enough to run alongside decode tokens in the same step.
It isn't purely a latency trade because of piggybacking: decode is memory-bound and leaves compute idle, while a prefill chunk is compute-bound. Running them in the same step uses that idle compute alongside a weight read you were already paying for, so the chunk is close to free rather than a tax on decode. That's why chunked prefill often raises total throughput rather than costing it.
5. When would speculative decoding make a deployment slower?
Speculation exploits idle compute during memory-bound decode. At small batch sizes that idle compute is abundant, so drafting several tokens and verifying them in one pass is nearly free. As the batch grows, other sequences' work already fills that idle compute, so the free capacity speculation was exploiting disappears — and you're still paying for the drafter's forward pass and a slightly more expensive verification step on every iteration.
Past some batch size the cost exceeds the benefit and speculation becomes a net loss. That's exactly why vLLM 0.27 added num_speculative_tokens_per_batch_size: it lets you specify a schedule that speculates aggressively when the engine is quiet and backs off, or disables it entirely, as the batch grows. I would never enable speculation on a single-user benchmark and assume the win survives production load without checking.
6. Compare a separate draft model against multi-token prediction (MTP) for speculative decoding.
A draft model is a small independent checkpoint from the same family — an assistant model, say — that proposes tokens which the target then verifies. It works with any compatible model pair, but it costs real memory: its own weights plus its own KV cache, competing with the target for the same budget, and its predictions come from a genuinely different model so acceptance is capped by how well the two distributions match.
MTP instead attaches lightweight extra prediction heads directly onto the target model, trained to predict several tokens ahead using the target's own hidden states. Because the draft comes from the same computation that produces the real answer, acceptance is typically higher, and the memory cost is a few small heads rather than a whole second model. I'd choose MTP whenever the checkpoint supports it, and fall back to a draft model otherwise — verifying support in the checkpoint config rather than assuming the architecture exists just because the serving engine supports the model type.
7. Design vLLM configurations for two RAG deployments: one capped at 15k tokens total, one at 30k. Why not run one engine for both?
max-model-len decides the concurrency ceiling at boot, because the scheduler must assume any admitted request could grow to that length. A single engine set to 32k applies that conservative admission to every request, including the 15k ones that never needed it, and gives up roughly half the throughput on what is likely the majority of traffic.
I'd run two pools instead: a 16k pool with a larger max-num-seqs and a smaller prefill chunk, and a 32k pool with fp8 KV cache to recover the concurrency the longer context costs, a larger token budget for prefill, and long-prefill-token-threshold set so several long prompts can't monopolise the step together. A router counts prompt tokens at the edge and sends each request to the right pool. The routing logic is trivial compared to the throughput it protects.
What is being tested: whether you understand max-model-len as a capacity decision rather than a validation limit.
8. You need to run 10 million inferences as cheaply as possible. What changes from serving a live endpoint?
The objective changes from tail latency to total wall-clock, and several settings invert. max-num-seqs and the token-batch budget go as high as memory allows rather than being capped for responsiveness. gpu-memory-utilization pushes toward 0.95 or higher since nothing else needs the card. fp8 KV cache becomes close to a default rather than a measured trade, because concurrency is the only thing that matters. Speculation stops being risky, because queue depth lets me hold batches at a moderate size deliberately rather than letting load push them up.
Beyond flags, the highest-value move is sorting requests by length before submitting, since padding a 2k prompt against a 30k one wastes the difference on every shared step — that alone can be worth 20 to 30% of throughput. I'd use the offline generate call rather than the HTTP server to cut per-request overhead, cap max_tokens to the real 99th-percentile output length rather than a defensive maximum, and checkpoint incrementally, because a 40-hour job losing a node without resumable progress is a much bigger problem than any single tuning parameter.
Where this leaves you
You can now read a vLLM config with real intent behind it: work out a memory budget on paper, explain why a 26B MoE and a 31B dense model behave nothing alike despite similar parameter counts, size the scheduler to your actual context length rather than the model's advertised ceiling, and decide when speculation is a genuine win versus an expensive habit.
The thread through this chapter is the one from section 6.1: decoding is memory-bandwidth-bound, so almost every parameter is ultimately about making the batch as large as the memory allows, or about spending idle compute on something useful while that batch runs. Chunked prefill, prefix caching, tensor parallelism, fp8 KV cache and speculative decoding are five different answers to the same underlying question. Once you see them that way, a new flag in a future vLLM release is something you can reason about rather than something you have to look up.
That is also the honest limitation of everything here: defaults and even flag names change release to release, faster than almost anything else in this stack. The method in this chapter — compute the KV budget, understand which phase a workload spends its time in, measure acceptance and hit rates rather than assuming them — will outlive the specific numbers quoted for 0.27.1. Rerun the calculators in this chapter against your own checkpoint's config.json before trusting a number that was computed against a different model or a different vLLM version.