Optimising prefill in vLLM: the knobs your TTFT will not forgive
Contents
TL;DR
Prefill is the phase in which vLLM processes your input prompt and produces the initial KV cache. It is compute-bound (unlike decode, which is memory-bound), it takes longer the longer the prompt is, and it blocks the decode of every other queued request. There are four levers in vLLM that radically change its behaviour: chunked prefill, prefix caching, FP8 KV cache and the per-batch token budget. With modest hardware, a 24 GB RTX 4090 or a 48 GB L40, the difference between ignoring them and using them well can be a 3× lower TTFT and 40% more aggregate throughput.
The analogy
Picture a printing shop from the early twentieth century. Setting the lead type (preparing the forme) is slow and blocks the press. Printing the pages already set is fast, but it needs the forme ready before it can start.
Prefill is setting the type. Decode is printing. A press that can only do one thing at a time, either set or print, leaves the machinery idle half the time. The historical solution was to have one worker setting the next page while the previous one was already on the press. That is, exactly, chunked prefill.
What prefill really is
When a request arrives at vLLM, the engine has to process every token of the prompt at once before it can emit the first token of the response. During that processing it computes, for each prompt token, its attention Key and Value vectors. The result, the initial KV cache, is stored in VRAM and used throughout the subsequent decode.
Unlike decode, where the model processes one new token per step, in prefill it processes N tokens in one go. That makes it far more efficient in FLOPs/token (GPUs are good at large matmuls), but it carries a quadratic cost in attention:
$$\text{prefill attention FLOPs} \approx 4 \cdot N^2 \cdot d_{model}$$With a prompt of 1,000 tokens and $d_{model} = 4096$ (Qwen2.5-7B): $4 \cdot 10^6 \cdot 4096 \approx 16 \times 10^9$ FLOPs in attention alone. With 4,000 tokens, 256× more, because of the quadratic nature.
Prefill (compute-bound):
prompt tokens → [attention O(N²)] → [FFN] → initial KV cache
Decode (memory-bound):
1 new token → [cross-attention over KV cache] → next token
Why prefill is a problem on small hardware
On an H100 with 3.35 TB/s of bandwidth, a long prefill amortises quickly. On an RTX 4090 (1.008 TB/s) or an L40 (864 GB/s), the bottleneck shows up sooner and has concrete consequences:
The head-of-line blocking problem. By default, vLLM processes a complete prefill before doing any decode. If you have 10 requests queued, 9 in decode and 1 with an 8,000-token prompt, those 9 requests stall while the GPU chews through the long prefill. Their users see the streaming freeze. This is called head-of-line blocking and it is the number one enemy of TTFT in production.
The four levers
1. Chunked prefill — --enable-chunked-prefill + --max-num-batched-tokens
Chunked prefill splits a long prefill into pieces (chunks) and interleaves them with decode steps in the same batch. In vLLM V1 (≥ 0.6) it is on by default.
vllm serve my-model \
--enable-chunked-prefill \
--max-num-batched-tokens 4096
--max-num-batched-tokens is the total token budget vLLM can process in a single engine step, counting prefill and decode together. It is the most important parameter for controlling the trade-off:
max-num-batched-tokens | Effect |
|---|---|
| Low (512–2048) | More decode steps per cycle → better ITL, worse TTFT |
| High (8192–32768) | Large prefill chunks → better TTFT and throughput, worse ITL |
For an RTX 4090 serving 7B–13B models with mixed contexts (256–4096 tokens):
--max-num-batched-tokens 8192 # a reasonable balance point
For an L40 (48 GB) with larger models and longer prompts:
--max-num-batched-tokens 16384
How it works internally: with a budget of 4,096 tokens and a prefill of 10,000, vLLM splits it into 3 chunks (4,096 + 4,096 + 1,808). Between chunks, it processes the pending decode steps. The requests in decode keep advancing; the long prefill takes longer to finish, but it freezes nothing.
Without chunked prefill:
t=0 [prefill 10k tokens]─────────────────────────────┐
t=1 └─[decode r1,r2...r9]
With chunked prefill (budget 4096):
t=0 [prefill chunk 4096][decode r1..r9]
t=1 [prefill chunk 4096][decode r1..r9]
t=2 [prefill chunk 1808][decode r1..r9]
t=3 [decode all, including the new one]
The TTFT of the long request rises slightly (3 steps instead of 1), but the ITL of the other 9 requests is not interrupted.
2. Prefix caching — --enable-prefix-caching
If several requests share the same prefix, a system prompt, a few-shot block, a context document, vLLM can compute the KV cache of that prefix once and reuse it.
vllm serve my-model \
--enable-prefix-caching
This is called Automatic Prefix Caching (APC). Internally, vLLM splits the KV cache into fixed-size blocks (16 tokens per block by default) and assigns them a content-based SHA hash. When a new request arrives, it checks whether any initial block is already in the cache. On a hit, that prefill is skipped.
The numerical impact: suppose a 512-token system prompt that appears in 80% of your requests, and a rate of 100 req/min:
- Without APC: 80 req/min × 512 tokens × prefill_cost = 41,000 tokens/min of redundant prefill
- With APC (80% hit rate): 20 req/min × 512 = 10,240 tokens/min of prefill → a 75% reduction
The TTFT of those 80 requests falls to whatever it costs to process only the new suffix.
Limitation with chunked prefill: when chunked prefill is active, only the first chunk of the prefill benefits from APC in the current vLLM implementation. For workloads where the cache hit rate is very high and the suffixes are short, consider lowering --max-num-batched-tokens so that the first chunk covers more of the shared prefix.
# Configuration optimised for a high prefix cache hit rate
vllm serve my-model \
--enable-prefix-caching \
--enable-chunked-prefill \
--max-num-batched-tokens 4096 # smaller chunks = prefix fits in chunk 1
3. FP8 KV cache — --kv-cache-dtype fp8
The KV cache takes up VRAM. The more VRAM it consumes, the fewer concurrent requests you can keep in flight. On a 24 GB RTX 4090, the Qwen2.5-14B model in BF16 already takes ~28 GB, so it does not fit. In Q4 it takes ~9 GB, leaving ~14 GB for the KV cache.
How many context tokens fit in 14 GB of BF16 KV cache for a 14B with GQA?
$$\text{KV size per token} = 2 \cdot n_{kv\_heads} \cdot d_{head} \cdot n_{layers} \cdot 2 \text{ bytes}$$For Qwen2.5-14B: $n_{kv\_heads}=8$, $d_{head}=128$, $n_{layers}=40$, BF16 → $2 \cdot 8 \cdot 128 \cdot 40 \cdot 2 = 163{,}840$ bytes ≈ 160 KB/token.
14 GB / 160 KB ≈ 87,500 tokens of total context. With 8 users in parallel and 4,096 tokens of context each: 32,768 tokens used out of 87,500. There is headroom, but it is finite.
Moving to FP8 (1 byte instead of 2):
$$\text{KV FP8} = 80 \text{ KB/token} \implies 14 \text{ GB} / 80 \text{ KB} = 175{,}000 \text{ tokens}$$Twice the context capacity with the same VRAM. That buys either more concurrency or longer contexts.
vllm serve my-model \
--kv-cache-dtype fp8 \
--calculate-kv-scales # calibrates the scales dynamically; without this there is degradation
Warning for RTX 4090 and L40: Ada Lovelace has FP8 instructions at the CUDA level but without Hopper’s (H100’s) dedicated scaling hardware. The memory reduction is real; the compute speedup is smaller than on an H100. Do not expect the same speedup as in a Hopper datacenter. On the L40S (the variant with optimised FP8 tensor cores) the benefit is larger than on an RTX 4090.
4. Context budget — --max-model-len
--max-model-len defines the maximum number of tokens vLLM can handle in a single request (prompt plus generation). It is the hard limit that determines how much VRAM is reserved for the KV cache in the worst case.
On small hardware, lowering it frees VRAM for more concurrency:
# 7B model on RTX 4090, typical context of 4K but the model supports 128K
vllm serve my-model \
--max-model-len 8192 \ # instead of 131072
--gpu-memory-utilization 0.92
With the context cut to 8,192 tokens, vLLM does not reserve KV cache for 131,072 potential tokens and can fit more simultaneous requests. The risk is obvious: requests above 8,192 tokens fail with an error. Tune it to the P99 of your real length distribution.
Interaction between parameters
The four parameters are not independent. A common mistake is enabling prefix caching without tuning the block size, or raising max-num-batched-tokens without checking that max-num-seqs allows it to be filled:
max-num-batched-tokens = 8192
max-num-seqs = 4
average prompt = 512 tokens → 4 × 512 = 2048 prefill tokens < 8192
The 8192 budget is never filled because max-num-seqs caps it first.
Fix: raise max-num-seqs or lower max-num-batched-tokens.
A balanced configuration for RTX 4090 + a 7B model:
vllm serve Qwen/Qwen2.5-7B-Instruct \
--gpu-memory-utilization 0.92 \
--max-model-len 8192 \
--enable-chunked-prefill \
--max-num-batched-tokens 8192 \
--max-num-seqs 64 \
--enable-prefix-caching \
--kv-cache-dtype fp8 \
--calculate-kv-scales
Configuration for an L40 (48 GB) + a 14B model:
vllm serve Qwen/Qwen2.5-14B-Instruct-AWQ \
--gpu-memory-utilization 0.90 \
--max-model-len 16384 \
--enable-chunked-prefill \
--max-num-batched-tokens 16384 \
--max-num-seqs 128 \
--enable-prefix-caching \
--kv-cache-dtype fp8 \
--calculate-kv-scales
How to measure that it is working
The metrics that confirm prefill is optimised:
# In vLLM's Prometheus metrics (port 8000/metrics):
vllm:time_to_first_token_seconds_bucket → TTFT distribution
vllm:gpu_cache_usage_perc → KV cache utilisation
vllm:prefix_cache_hit_rate → APC hit rate (if enabled)
vllm:num_running_seqs → simultaneous in-flight requests
A prefix_cache_hit_rate below 30% on workloads with a fixed system prompt means something in the hash is not working (a system prompt that varies by timestamp, a date format in the prompt, and so on).
Implications for on-premise inference
Chunked prefill and prefix caching are zero cost: they are enabled with flags and require no extra hardware. FP8 KV cache requires the model to be compatible (almost every modern transformer is) and that you are on Ada Lovelace or newer.
For sovereign ENS deployments where the hardware is fixed and you cannot scale horizontally on demand, a well-configured prefill is the difference between needing 4 nodes and needing 2 for the same load.
The second article in this series covers the decode optimisations: speculative decoding, KV cache tuning to maximise concurrency and how to configure gpu-memory-utilization without vLLM running out of VRAM at midnight.
See also
- how the KV cache works inside
- why FlashAttention changes memory consumption in prefill
- the foundation chunked prefill builds on
- when scaling a single node is no longer enough
- how to measure with Prometheus and OTel whether chunked prefill and prefix caching are working:
ttft p99,gpu_prefix_cache_hit_rateand the concrete alerts