vLLM Explained

Disaggregated prefill: prefill and decode on different instances

AdvancedvLLM conceptsabout 8 min read + 5 min deep dives

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#

Figure: one chat request across a router, a prefill instance and a decode instance with NixlConnector (panel 1), and the request states on the decode side (panel 2).
Open full size ↗

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,
        )
  1. Line 248. P has blocks, so it delays freeing them.
  2. Lines 251-266. The blocks get an expiry time: the lease, kv_lease_duration in kv_connector_extra_config, 30 s by default (vllm/distributed/kv_transfer/kv_connector/v1/nixl/base_scheduler.py:82-88).
  3. Lines 278-292. The parameters for D: do_remote_prefill true, 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)
                    continue

The 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 dive

kv_transfer_params, field by field

KeyRouter → PP → router → DMeaning
do_remote_decodetruefalse"I am P": keep the blocks for a remote reader
do_remote_prefillfalsetrue"I am D": load the prompt from remote
remote_block_idsNoneP's block ids, per KV cache groupwhat D reads
remote_engine_id, remote_request_idNoneP's engine id and internal request idwho to read from, and which request to notify
remote_host, remote_portNoneP's side channelNIXL handshake address
tp_size, dcp_size, pp_size–P's sizeslets 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–bookkeepingused by the multi-turn and bookkeeping paths
remote_prefill_cached_tokens–P's prefix-cache hitsso 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 dive

Worked example: a 2,000-token prompt with block size 16

  1. 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, and request_finished keeps all its blocks for 30 s.
  2. The router gets P's JSON with 125 block ids (one KV cache group) and sends D the original body.
  3. D: no local prefix hit. The connector returns (2000, True). D allocates 125 blocks and parks the request in WAITING_FOR_REMOTE_KVS with num_computed_tokens = 2000.
  4. 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).
  5. The read finishes. D's next schedule sets num_computed_tokens = 1999, prefills one token, and samples the first output token.
Deep dive

Failures: what happens to a request when the KV load fails

kv_load_failure_policy decides (vllm/config/kv_transfer.py:88-91):

Other failure paths:

Deep dive

What vLLM ships, and what your router must do

PieceIn vLLMWhere
KV connectors (NIXL, Mooncake, LMCache, MoRI-IO, …)yes, 17 registered namesKV connectors
kv_transfer_params in requests and responsesyes (chat, completions, Anthropic, Cohere routes)Step 2 and Step 3
request states, async loads, failure policyyesSteps 5-6
a router that picks P and D and copies the parametersonly examples and test toolstests/v1/kv_connector/nixl_integration/toy_proxy_server.py, examples/disaggregated/

A production router must do at least these things:

  1. Choose a P and a D instance.
  2. Send P a 1-token, non-streaming copy with do_remote_decode.
  3. Copy the returned kv_transfer_params into the D request.
  4. 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 dive

Encoder 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#

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#

  1. 1
    Which value of kv_transfer_params does the router send to P, and which does P send back for D?
    easy
    Show answer

    To P: do_remote_decode: true (tests/v1/kv_connector/nixl_integration/toy_proxy_server.py:158-165). Back: do_remote_prefill: true plus P's block ids, engine id, request id, host and port (vllm/distributed/kv_transfer/kv_connector/v1/nixl/pull_scheduler.py:278-292).

  2. 2
    In 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).

  3. 3
    D 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_tokens to the prompt length minus one when the whole prompt was loaded (vllm/v1/core/sched/scheduler.py:3269-3272).

  4. 4
    The 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). Raise kv_lease_duration, add D capacity, and watch vllm:nixl_num_kv_expired_reqs.