Disaggregated prefill: prefill and decode on different instances
Code version: vLLM @ 3e44c0d · 2026-10-22
In one sentence
A prefill instance computes the prompt's KV cache, a decode instance pulls it and generates; a router outside vLLM joins the two calls.
The idea#
Prefill processes the whole prompt in one go and is compute-bound; decode makes one token at a time and is memory-bound (Prefill and decode). In one vLLM instance, both share each step. A long prefill chunk in a step makes that step slow, so every running stream waits longer for its next token. The docs give two reasons to split them: tune time-to-first-token (TTFT) and inter-token latency (ITL) separately, and control the tail ITL. They also say that it "DOES NOT improve throughput" (docs/features/disagg_prefill.md:8-16).
In P/D disaggregation, a KV producer instance (P) runs the prefill, and a KV consumer instance (D) runs the decode. A KV connector moves the KV blocks from P to D. The page KV connectors explains connectors in general; this page follows one request with NixlConnector, the main connector for P/D.
vLLM does not route requests between instances. Each instance is a normal vllm serve. A router in front sends the request to P first, takes the connector parameters from P's answer, and sends the request again to D. The vLLM repository has only a "toy" router for tests.
An everyday comparison. Think of a restaurant with a prep kitchen and a line kitchen. The prep kitchen chops and marinates the whole order and puts it on a labelled shelf. The waiter takes the shelf label to the line kitchen, which picks up the tray and cooks each plate to order. The comparison stops here. The line kitchen pulls the tray itself over a fast link (RDMA). The prep kitchen throws the tray away if nobody picks it up within its lease.
The picture#
Follow the numbers 1 to 7. The two HTTP calls are ordinary OpenAI requests; only the kv_transfer_params field links them. The KV bytes never pass through the router: D's worker reads them from P's GPU memory.
In the code#
Step 1: start a producer and a consumer#
Each instance gets a KVTransferConfig with a role. The docs show a minimal pair on one host (docs/features/nixl_connector_usage.md:68-96):
CUDA_VISIBLE_DEVICES=0 VLLM_NIXL_SIDE_CHANNEL_PORT=5600 vllm serve Qwen/Qwen3-0.6B --port 8100 \
--kv-transfer-config '{"kv_connector":"NixlConnector","kv_role":"kv_producer","kv_load_failure_policy":"fail"}'
CUDA_VISIBLE_DEVICES=1 VLLM_NIXL_SIDE_CHANNEL_PORT=5601 vllm serve Qwen/Qwen3-0.6B --port 8200 \
--kv-transfer-config '{"kv_connector":"NixlConnector","kv_role":"kv_consumer","kv_load_failure_policy":"fail"}'
python tests/v1/kv_connector/nixl_integration/toy_proxy_server.py --port 8192 \
--prefiller-hosts localhost --prefiller-ports 8100 --decoder-hosts localhost --decoder-ports 8200
The roles are kv_producer, kv_consumer and kv_both (vllm/config/kv_transfer.py:11-12). For NixlConnector, kv_both is deprecated and logs a warning (vllm/distributed/kv_transfer/kv_connector/v1/nixl/connector.py:97-103). Note what the role does not do: the NIXL scheduler decides "P or D" per request from kv_transfer_params, not from the role (vllm/distributed/kv_transfer/kv_connector/v1/nixl/pull_scheduler.py:212-213). The role still matters in vLLM itself. For example, a consumer with several batches in flight frees blocks later (vllm/v1/core/sched/scheduler.py:187-193). And a producer counts as a connector that "hands off KV that must be reliably delivered" (vllm/distributed/kv_transfer/kv_connector/v1/base.py:197-207).
Step 2: the router calls P with do_remote_decode#
The toy router copies the client's body and changes it for P:
async def send_request_to_service(
client_info: dict, endpoint: str, req_data: dict, request_id: str
):
"""Send a request to a service using a client from the pool."""
req_data = req_data.copy()
req_data["kv_transfer_params"] = {
"do_remote_decode": True,
"do_remote_prefill": False,
"remote_engine_id": None,
"remote_block_ids": None,
"remote_host": None,
"remote_port": None,
}
req_data["stream"] = False
req_data["max_tokens"] = 1
if "max_completion_tokens" in req_data:
req_data["max_completion_tokens"] = 1
if "stream_options" in req_data:
del req_data["stream_options"]
# These args are not supported for P
min_tokens = req_data.pop("min_tokens", None)
min_completion_tokens = req_data.pop("min_completion_tokens", None)max_tokens becomes 1, streaming is off, and kv_transfer_params says "the decode runs elsewhere". The request field kv_transfer_params exists on the chat and completion requests (vllm/entrypoints/openai/chat_completion/protocol.py:513-516). The server puts it into SamplingParams.extra_args (vllm/entrypoints/openai/chat_completion/protocol.py:771-773), and the engine core copies it into Request.kv_transfer_params (vllm/v1/request.py:119-122).
Step 3: P finishes and keeps the blocks#
P prefills the prompt and samples one token; with max_tokens 1 the request then finishes. When the scheduler frees it, the connector's request_finished decides what happens to the blocks:
# TODO: check whether block_ids actually ever be 0. If not we could
# remove the conditional below
delay_free_blocks = any(len(group) > 0 for group in block_ids)
remote_num_tokens = 0
blocks_expiry_time = None
if delay_free_blocks:
# Prefill request on remote. It will be read from D upon completion
request_kv_blocks_ttl = self._kv_lease_duration
if is_d_node:
# For blocks pinned on D, use a simpler timeout for now instead of a
# lease mechanism as turn2 request is client-driven.
request_kv_blocks_ttl = self.decoder_kv_blocks_ttl
logger.debug(
"NIXLConnector request_finished(%s) waiting for %d seconds "
"before releasing blocks",
request.request_id,
request_kv_blocks_ttl,
)
self._reqs_need_send[request.request_id] = (
time.perf_counter() + request_kv_blocks_ttl
)
if is_d_node:
# D blocks expiry time exported for the turn-2 readback.
blocks_expiry_time = self._reqs_need_send[request.request_id]
# NOTE HMA will "mark" empty/null blocks in groups with 0s (eg SWA ones),
# trimming down after allocating for the whole sequence length. Empty
# blocks are always at the start of the list.
# Here we "unpad" blocks to send the actual remote blocks to be read.
block_ids = self.get_exchange_clipped_blocks(block_ids)
remote_num_tokens = request.num_computed_tokens
return delay_free_blocks, dict(
do_remote_prefill=is_p_node,
do_remote_decode=is_d_node,
remote_block_ids=block_ids,
remote_engine_id=self.engine_id,
remote_request_id=request.request_id,
remote_host=self.side_channel_host,
remote_port=self.side_channel_port,
tp_size=self.transfer_tp_size,
dcp_size=self.vllm_config.parallel_config.decode_context_parallel_size,
pp_size=self.vllm_config.parallel_config.pipeline_parallel_size,
remote_num_tokens=remote_num_tokens,
remote_blocks_expiry_time=blocks_expiry_time,
remote_registration_generation=self._registration_generation,
transfer_mode=self._TRANSFER_MODE,
)- Line 248. P has blocks, so it delays freeing them.
- Lines 251-266. The blocks get an expiry time: the lease,
kv_lease_durationinkv_connector_extra_config, 30 s by default (vllm/distributed/kv_transfer/kv_connector/v1/nixl/base_scheduler.py:82-88). - Lines 278-292. The parameters for D:
do_remote_prefilltrue, P's block ids, engine id, request id, and the host and port of P's NIXL side channel.
The scheduler puts these parameters into the request's last output (vllm/v1/core/sched/scheduler.py:2323-2334, vllm/v1/core/sched/scheduler.py:2376), and the chat response returns them as a top-level field (vllm/entrypoints/openai/chat_completion/serving.py:1203). The side-channel port is VLLM_NIXL_SIDE_CHANNEL_PORT (default 5600) plus the DP index (vllm/distributed/kv_transfer/kv_connector/v1/nixl/base_scheduler.py:76-80, vllm/envs.py:1719-1725).
Step 4: the router calls D with P's parameters#
async def _handle_completions(api: str, request: Request):
try:
req_data = await request.json()
request_id = str(uuid.uuid4())
# Get the next prefill client in round-robin fashion
prefill_client_info = get_next_client(request.app, "prefill")
# Send request to prefill service
response = await send_request_to_service(
prefill_client_info, api, req_data, request_id
)
# Extract the needed fields
response_json = response.json()
await response.aclose() # CRITICAL: Release connection back to pool
kv_transfer_params = response_json.get("kv_transfer_params", {})
if kv_transfer_params:
req_data["kv_transfer_params"] = kv_transfer_params
# Get the next decode client in round-robin fashion
decode_client_info = get_next_client(request.app, "decode")
logger.debug("Using %s %s", prefill_client_info, decode_client_info)
# Stream response from decode service
async def generate_stream():
async for chunk in stream_service_response(
decode_client_info, api, req_data, request_id=request_id
):
yield chunk
return StreamingResponse(generate_stream(), media_type="application/json")The router reads kv_transfer_params from P's JSON and puts them into the original body. It picks P and D round-robin and streams D's answer back to the client. Both calls use the same X-Request-Id (lines 177 and 202 of the file).
Step 5: D waits for the remote KV#
D's scheduler asks the connector how many tokens it can load. With do_remote_prefill, the answer is the whole prompt, asynchronously:
params = request.kv_transfer_params
logger.debug(
"NIXLConnector get_num_new_matched_tokens: "
"num_computed_tokens=%s, kv_transfer_params=%s",
num_computed_tokens,
params,
)
if params is not None and params.get("do_remote_prefill"):
# Remote prefill: get all prompt blocks from remote.
token_ids = request.prompt_token_ids or []
actual = self._get_remote_prefill_token_count(len(token_ids))
count = actual - num_computed_tokens
if count > 0:
return count, True_get_remote_prefill_token_count keeps a few trailing tokens on D only for Mamba layers, bounded replay or multi-module MTP. For a plain model it returns the prompt length (vllm/distributed/kv_transfer/kv_connector/v1/nixl/base_scheduler.py:385-410). The scheduler then allocates the blocks and parks the request:
if load_kv_async:
# This path deliberately does not reach `prefill_admitted`
# below, so it does not restart the prefill cadence:
# `num_new_tokens` was set to 0 above, so the step carries
# no prefill compute for this request. The interval is
# timed from steps that actually prefilled; refreshing it
# here would start the cadence from a step that did none.
# If loading async, allocate memory and put request
# into the WAITING_FOR_REMOTE_KV state.
request.status = RequestStatus.WAITING_FOR_REMOTE_KVS
# Set num_computed_tokens even though KVs are not yet loaded.
# request.num_computed_tokens will not be used anywhere until
# the request finished the KV transfer.
#
# If a transfer error is reported by the connector,
# request.num_computed_tokens will be re-set accordingly in
# _update_requests_with_invalid_blocks.
#
# When the transfer is finished, either successfully or not,
# request.num_computed_tokens will correctly reflect the number
# of computed tokens.
# _update_waiting_for_remote_kv will then cache
# only the successfully loaded tokens.
request.num_computed_tokens = num_computed_tokens
self._inflight_prefills.add(request)
self._set_kv_fetch_stage(request, KV_FETCH_IN_PROGRESS)
skip_request(request_queue)
continueThe connector puts the request into its "to receive" list with D's new block ids (vllm/distributed/kv_transfer/kv_connector/v1/nixl/pull_scheduler.py:130-177). In the next step, each D worker starts a non-blocking NIXL READ from P's blocks into its own (vllm/distributed/kv_transfer/kv_connector/v1/nixl/pull_worker.py:49-77, vllm/distributed/kv_transfer/kv_connector/v1/nixl/pull_worker.py:554-567). The transfer carries a notification for P. While the request waits, D's workers send heartbeats that extend P's lease (vllm/distributed/kv_transfer/kv_connector/v1/nixl/pull_worker.py:123-125).
Step 6: the load finishes, and D decodes#
When a worker reports finished_recving, the scheduler marks the request ready (vllm/v1/core/sched/scheduler.py:3324-3331). The next schedule() caches the loaded blocks and moves the request back to WAITING (vllm/v1/core/sched/scheduler.py:3255-3257, vllm/v1/core/sched/scheduler.py:3278-3289). If the whole prompt was loaded, num_computed_tokens becomes the prompt length minus one. D recomputes the last prompt token, so the model can sample the first output token (vllm/v1/core/sched/scheduler.py:3269-3272). P's token from Step 3 is not used. On P, the read notification ends the lease, and the scheduler frees the blocks when the worker reports finished_sending (vllm/v1/core/sched/scheduler.py:3335-3338).
Deep dive#
The sections below give the full detail. Open them one at a time, or use Open all deep dives at the top of the page.
Deep divekv_transfer_params, field by field
| Key | Router → P | P → router → D | Meaning |
|---|---|---|---|
do_remote_decode | true | false | "I am P": keep the blocks for a remote reader |
do_remote_prefill | false | true | "I am D": load the prompt from remote |
remote_block_ids | None | P's block ids, per KV cache group | what D reads |
remote_engine_id, remote_request_id | None | P's engine id and internal request id | who to read from, and which request to notify |
remote_host, remote_port | None | P's side channel | NIXL handshake address |
tp_size, dcp_size, pp_size | – | P's sizes | lets D map P's ranks to its own (P and D may use different TP) |
remote_num_tokens, remote_blocks_expiry_time, remote_registration_generation, transfer_mode | – | bookkeeping | used by the multi-turn and bookkeeping paths |
remote_prefill_cached_tokens | – | P's prefix-cache hits | so D can report P's cache hits in its usage (vllm/v1/core/sched/scheduler.py:2329-2334) |
Source: vllm/distributed/kv_transfer/kv_connector/v1/nixl/pull_scheduler.py:278-293. D's scheduler accepts the transfer only if the block ids and all four remote fields are present. Otherwise it logs "Got invalid KVTransferParams" and does not use KV transfer for the request (vllm/distributed/kv_transfer/kv_connector/v1/nixl/pull_scheduler.py:135-184). After the first transfer, D sets do_remote_prefill to false, so one request triggers one transfer (lines 187-189).
Deep diveWorked example: a 2,000-token prompt with block size 16
- P: 2,000 prompt tokens fill
ceil(2000 / 16)= 125 blocks, all full. P never computes KV for its sampled token, because the request ends before the next step (derived). The request finishes after one token, andrequest_finishedkeeps all its blocks for 30 s. - The router gets P's JSON with 125 block ids (one KV cache group) and sends D the original body.
- D: no local prefix hit. The connector returns
(2000, True). D allocates 125 blocks and parks the request inWAITING_FOR_REMOTE_KVSwithnum_computed_tokens= 2000. - D's workers read 125 blocks per layer from P. Note the change of model: the Step 1 command serves Qwen/Qwen3-0.6B. For the byte count, this example assumes Llama-3.1-8B with TP = 1 on both sides. Then each block of one layer is
8 KV heads × 16 tokens × (128 + 128) × 2 bytes= 64 KiB. One request moves about 125 × 32 layers × 64 KiB = 250 MiB (derived; layer shape from Tensor and pipeline parallelism in practice). - The read finishes. D's next schedule sets
num_computed_tokens= 1999, prefills one token, and samples the first output token.
Deep diveFailures: what happens to a request when the KV load fails
kv_load_failure_policy decides (vllm/config/kv_transfer.py:88-91):
"fail"(default). The scheduler collects the affected requests and finishes them withFINISHED_ERROR. It logs "Failing N request(s) due to KV load failure" and evicts the bad blocks from the prefix cache (vllm/v1/core/sched/scheduler.py:3513-3527,vllm/v1/core/sched/scheduler.py:2404-2413). The client sees an error finish."recompute". The scheduler cutsnum_computed_tokensat the first bad block, keeps the valid prefix, and lets D compute the rest itself (vllm/v1/core/sched/scheduler.py:3340-3437). It logs "Recovered from KV load failure" (vllm/v1/core/sched/scheduler.py:3529-3539). The docs warn that this puts prefill work on a decode-tuned instance (docs/features/nixl_connector_usage.md:389-397).
Other failure paths:
- Lease expiry on P. If no heartbeat or read notification arrives within the lease, P frees the blocks. The docs list the counter
vllm:nixl_num_kv_expired_reqsand advise a longerkv_lease_duration(docs/features/nixl_connector_usage.md:125-128,docs/features/nixl_connector_usage.md:456-463). - D rejects the request before the engine sees it. If the serving layer returns an error, it notifies the connector so P's pinned blocks are freed (
vllm/entrypoints/generate/base/serving.py:266-299). - An aborted request on D that never reached the transfer still sends an empty notification, so P does not keep the blocks (
vllm/distributed/kv_transfer/kv_connector/v1/nixl/pull_scheduler.py:219-230).
Deep diveWhat vLLM ships, and what your router must do
| Piece | In vLLM | Where |
|---|---|---|
| KV connectors (NIXL, Mooncake, LMCache, MoRI-IO, …) | yes, 17 registered names | KV connectors |
kv_transfer_params in requests and responses | yes (chat, completions, Anthropic, Cohere routes) | Step 2 and Step 3 |
| request states, async loads, failure policy | yes | Steps 5-6 |
| a router that picks P and D and copies the parameters | only examples and test tools | tests/v1/kv_connector/nixl_integration/toy_proxy_server.py, examples/disaggregated/ |
A production router must do at least these things:
- Choose a P and a D instance.
- Send P a 1-token, non-streaming copy with
do_remote_decode. - Copy the returned
kv_transfer_paramsinto the D request. - Stream D's answer, and handle errors of either call.
The multi-turn mode also needs a stateful router that caches D's parameters per conversation (docs/features/nixl_connector_usage.md:135-141).
Deep diveEncoder disaggregation (E/P/D) with EC connectors
Multimodal models can split one more stage: the vision encoder. An encoder instance computes the image embeddings, and an EC connector moves them to the prefill or decode instance (docs/features/disagg_encoder.md:1-8). The config is --ec-transfer-config with ec_role ec_producer, ec_consumer or ec_both (vllm/config/ec_transfer.py:10-11, vllm/config/ec_transfer.py:41-43). The pattern is the same as for KV: a scheduler-side and a worker-side object, and ec_transfer_params in requests and responses (vllm/entrypoints/openai/chat_completion/protocol.py:518-523, vllm/entrypoints/openai/chat_completion/protocol.py:156-158). A consumer instance accepts requests without the image tensors, because it gets the embeddings from the connector (vllm/config/vllm.py:2840-2846). The page KV connectors lists the three built-in EC connectors; examples/disaggregated/disaggregated_encoder/disagg_epd_proxy.py is an example router.
What can go wrong#
- Port clashes on one host. Each instance needs its own
VLLM_NIXL_SIDE_CHANNEL_PORT, and DP ranks add their index to it (vllm/distributed/kv_transfer/kv_connector/v1/nixl/base_scheduler.py:77-80,docs/features/nixl_connector_usage.md:113-118). VLLM_NIXL_SIDE_CHANNEL_HOSTleft atlocalhostacross machines. P reports this host in its parameters, so D cannot reach P (vllm/envs.py:1719-1721,vllm/distributed/kv_transfer/kv_connector/v1/nixl/pull_scheduler.py:284-285).- Lost blocks under load. Assume D queues a request for longer than the lease, and its heartbeats do not arrive. Then P frees the blocks, the read fails, and the default policy fails the request.
- Router forgets
max_tokens= 1. P then decodes the whole answer before D starts, and the split gains nothing (derived from the router in Step 2). - Docs drift.
disagg_prefill.mdsays "Now supports 9 types of connectors"; the registry has 17 names (docs/features/disagg_prefill.md:20, KV connectors).
Why it matters. P/D splitting is the main tool when TTFT and ITL targets conflict. Its correctness depends on a few fields of an HTTP body and on two settings, the lease and the load policy. Most production problems come from those.
Check yourself#
- 1Which value ofeasy
kv_transfer_paramsdoes the router send to P, and which does P send back for D?Show answer
To P:
do_remote_decode: true(tests/v1/kv_connector/nixl_integration/toy_proxy_server.py:158-165). Back:do_remote_prefill: trueplus P's block ids, engine id, request id, host and port (vllm/distributed/kv_transfer/kv_connector/v1/nixl/pull_scheduler.py:278-292). - 2In which request state does D wait while the KV blocks arrive?easy
Show answer
WAITING_FOR_REMOTE_KVS(vllm/v1/core/sched/scheduler.py:1393). - 3D loaded the whole 2,000-token prompt. Why does it still run one token of prefill?medium
Show answer
To sample the first output token the model needs logits at the last prompt position. The scheduler sets
num_computed_tokensto the prompt length minus one when the whole prompt was loaded (vllm/v1/core/sched/scheduler.py:3269-3272). - 4The decode instance is overloaded and requests wait 60 s before D schedules them. What happens with the default settings, and what do you change?hard
Show answer
P keeps the blocks for
kv_lease_duration, 30 s by default (vllm/distributed/kv_transfer/kv_connector/v1/nixl/base_scheduler.py:82-86). D's heartbeats extend the lease while the request waits (vllm/distributed/kv_transfer/kv_connector/v1/nixl/pull_worker.py:123-125). If they do not arrive, P frees the blocks, D's read fails, and the default policy"fail"finishes the request with an error (vllm/config/kv_transfer.py:88-91). Raisekv_lease_duration, add D capacity, and watchvllm:nixl_num_kv_expired_reqs.