vLLM Serving Optimizations¶
Reference to vLLM's official docs:
Serving Processes¶
| Process | Count | Device | Job | Data |
|---|---|---|---|---|
| API server | A (defaults to DP) |
CPU only | HTTP, chat template, tokenize, multi-modal loading, detokenize, SSE streaming | Tokenizer, chat template rendering, image/audio decode |
| EngineCore | 1 per DP rank | CPU only | Scheduler busy loop, KV cache manager, request state | KV cache manager (KV cache block tables, block hashes, more on the PagedAttention), Waiting/running queues, per-request state machine, Grammar/FSM compilation for structured output |
| GPU worker | N (number of devices) |
CPU proc driving 1 GPU | Loads weights, builds input tensors, runs forward, samples | model weights, KV cache, activations, scratchpad buffers, computation graphs |
| DP coordinator | 1, only if DP > 1 |
CPU only | Load balance across DP ranks, sync forward passes for MoE | Waiting/running queues, per-request state machine |
User Sessions¶
Consider a user session: user sends request of their questions to the vLLM server, gets an answer, and sends a new question (with the context of all previous communications).
Life of one request¶
- HTTP hits the API server process. Chat template applied, tokenizer turns the request body into token IDs.
- Multimodal inputs fetched and preprocessed on CPU threads; the vision encoder itself runs later on GPU.
- Request serialized over ZMQ to an EngineCore.
- Scheduler hashes the prompt's blocks, looks them up in the prefix-cache table. Hits are reused by pointer. Misses get fresh blocks from the free pool.
- Request admitted into a batch subject to
max_num_seqsand themax_num_batched_tokensbudget; long prompts are split by chunked prefill so decodes of other requests aren't starved. - (CPU -> GPU) (block tables + token slices) → workers.
- Worker builds input tensors, runs the forward (Embed token IDs into hidden embeddings, run decoders, lm_heads, and finally sampling).
- (GPU → CPU) Output sampled IDs.
- EngineCore appends the token, decides whether the request continues, and loops to step 5.
- Token IDs stream back to the API server process, which incrementally detokenizes, checks stop conditions, and emits an SSE chunk.
- On finish, blocks are returned to the free pool but kept content-addressable — an LRU cache, evicted only under pressure.
What a "session" actually is¶
There is no server-side session. Each turn resends the whole conversation; vLLM re-tokenizes it and finds the prior turns' KV already cached at step 4. So multi-turn efficiency is entirely a prefix-cache hit-rate property.
Structured Outputs¶
In coding tasks, a user wants structured outputs (for example want a JSON object or python script). Instead of injecting a prompt that "I want legit JSON", vLLM compiles your schema into an automaton, and at every decode step it builds a bitmask over the vocabulary and sets the logits of every token that would break the grammar to -inf. The model cannot emit a malformed token because that token has probability zero.
Batching and Scheduling¶
Note that decode process is memory bound, the model need to load the full weight to only compute for a token's hidden vector of size (1, hidden_dim). Therefore, it's naturally to parallelize multiple requests' decode at the same forward pass.
As an example, roofline for Llama-3.1-8B in FP16 on an H100 (16 GB weights, 3.35 TB/s HBM):
| Batch size | Min time per step (weight streaming) | Tokens/step | Throughput |
|---|---|---|---|
| 1 | ~4.8 ms | 1 | ~210 tok/s |
| 32 | ~4.8 ms | 32 | ~6,700 tok/s |
| 128 | ~5–6 ms (GEMMs start to matter) | 128 | ~22,000 tok/s |
Continuous Batching¶
If we naively batch the requests (static batching): gather BS requests, runs it to completion, then form the next one. The problem is that generation lengths vary wildly, and the ending time always depends on the longest generation.
Example, four requests arrive together, output lengths 10 / 200 / 30 / 500:
static batching, 500 steps:
R1 |===== | 10 tok, then 490 steps of dead slot
R2 |========================== | 200 tok, then 300 steps of dead slot
R3 |======== | 30 tok, then 470 steps of dead slot
R4 |===================================================| 500 tok
^ batch admitted ^ batch retires, next one can start
useful work = (10+200+30+500) / (4 × 500) = 740/2000 = 37% slot utilization
Continuous batching (a.k.a. iteration-level scheduling) rebuilds the batch before every forward pass. A finished sequence is removed the step it emits its stop token; a waiting request joins the very next step. Slot utilization goes to ~100% as long as the waiting queue is non-empty.
Continuous batching also requires a scheduler, before a waiting request joins the forward pass, its kv cache has to be present on the device.
Note that continuous batching requires paged attention. Rows in the batch are at different, constantly changing lengths. With one contiguous KV buffer per sequence you'd have to allocate max_model_len for each.
Disaggregated Inference¶
Prefill is compute-bound, decode is memory-bound, and the two phases want opposite hardware, parallelism, and batch configurations. Disaggregation runs them in separate instance pools and ships the prompt's KV cache across the network in between — trading a transfer cost for independent tuning and independent scaling of the two phases.
The problem¶
| Prefill | Decode | |
|---|---|---|
| Saturates GPU at | batch size 1, if the prompt is long | large batch only |
| Latency metric | time to first token (TTFT) | tokens per second (TPS) |
| Wants | high TP (splits the compute, cuts latency) | low TP + more DP replicas, max KV space |
| Duration | one long burst, 10s–100s of ms | 1/100ms per pass |
Problems
- Sharding: one single server requires to have consistent sharding - otherwise we need to transfer weights whenever switch better prefill and decode.
- Overhead for switch computation graph: the device need to load the compute graph, which can take ~10us.
- Batching and scheduling: need to prefill all sequences within a batch before starting decode. Since prefill is compute bound, TTFT grows linearly with the number of batches.
KV Cache Transfer¶
Remind that prefill is the step to fill the KV cache (and output the first token), and decode is to use these cache to generate tokens. Therefore, we need to transfer the KV cache from the prefill server to the decode server.
Plus a few bytes of metadata: the first sampled token (prefill produces it), request ID / transaction ID, and block layout info.
Some examples of bytes transferred
| Model | Bytes/token | 8K-token prompt |
|---|---|---|
| Llama-3.1-8B (32L, 8 KV heads, FP16) | 128 KiB | 1.0 GiB |
| Llama-3.1-70B (80L, 8 KV heads, FP16) | 320 KiB | 2.5 GiB |
| Llama-2-7B, no GQA (32 KV heads) | 512 KiB | 4.0 GiB |
| DeepSeek-V3 class, MLA | ~70 KiB | ~0.5 GiB |
We can further reduce the transfer latency by Layer-wise pipelining. Connectors stream per layer rather than waiting for the full prefill. Layer i's KV is finished when layer i's forward completes, so transfer overlaps the remaining layers' compute.
Overhead¶
- The KV transfer. Need enough server to server bandwidth (for example infiniband for NVIDIA).
- Minimum viable deployment is 2 instances.
- Possible idle devices if prefill/decode server ratio is mismatched. A 1P:3D deployment facing sudden prefill-heavy traffic has an idle decode pool and a queued prefiller.