ML Interview Notes
32 min read11 sections
Part 1 · The core serving loop · 01-06

Prefill–decode disaggregation

Status
SOURCE PINNED
Primary sources
  • python/sglang/srt/disaggregation/prefill.py
  • python/sglang/srt/disaggregation/decode.py
  • python/sglang/srt/disaggregation/mooncake/
  • python/sglang/srt/disaggregation/nixl/
  • vllm/distributed/kv_transfer/
  • vllm/v1/core/sched/scheduler.py
Edition pins
vllm a556f3f · sglang 7d89325

Chunked prefill made the interference survivable. It did not make it go away: every chunk you admit still steals a decode step from every sequence in the running batch. Disaggregation is the structural answer — put prefill on one set of machines, decode on another, and pay for it by shipping the whole KV cache across a network. This chapter derives what that shipment costs, and reads the two very different subsystems vLLM and SGLang built to move it.

§1

The problem

Take a single H100 SXM serving Llama-3-8B in bf16, 64 sequences decoding, chunked prefill on with max_num_batched_tokens = 2048. A request arrives with an 8,192-token prompt. Five prefill chunks (four of 1984 tokens and a 256-token remainder) get interleaved into the decode stream. What does each one cost the sequences already in flight?

A decode step at batch 64 is weight-bound: it reads 16 GB of bf16 weights out of HBM at 3.35 TB/s, so the floor is $16 / 3.35 \times 10^3 \approx 4.8$ ms — the round figure; §0.4 tightens it to 4.48 ms by excluding the gathered embedding table, which moves nothing in the comparison below. A 2,048-token prefill chunk is compute-bound: $2NS = 2 \times 8\times10^9 \times 2048 = 3.28 \times 10^{13}$ FLOPs, and at 50 % MFU against the H100's 989 TFLOP/s dense bf16 that is 66 ms. The chunk does not replace the decode step, it is added to it.

14×
ITL spike from one 2048-token prefill chunk (derived)
4.8 ms
clean decode step, Llama-3-8B, batch 64
66 ms
one 2048-token prefill chunk

Shrink the chunk and the spike shrinks with it — but TTFT can grow from repeated weight reads, poorer efficiency, and launch overhead; it is not generally linear in inverse chunk size, and per-chunk kernel-launch and attention-metadata overhead starts to dominate. That is the whole of the chunked-prefill tuning problem from §1.5: one knob, two objectives pulling opposite ways.

The deeper problem is that the two phases do not merely want different batch sizes, they want different machines. Take the roofline from §0.4. The H100 SXM's ridge point is

$$ I^{*} = \frac{989 \times 10^{12}\ \text{FLOP/s}}{3.35 \times 10^{12}\ \text{byte/s}} \approx 295\ \text{FLOP/byte} $$

For the weight GEMMs, decode at batch $B$ does $2B$ FLOPs per bf16 weight, which is 2 bytes, so its arithmetic intensity is $I_\text{decode} \approx B$ FLOP/byte. You need $B \approx 295$ concurrent sequences before decode stops being bandwidth-bound. Prefill on a chunk of $T$ tokens has $I_\text{prefill} \approx T$, so at $T = 2048$ it is already 7× past the ridge and firmly compute-bound. One machine, one memory system, two workloads sitting on opposite sides of the knee.

Everything downstream inherits the compromise. Decode wants a high TP degree so the weight read is split across more HBM controllers; prefill gains little from TP past the point where the collectives cost more than the split saves. Decode wants every spare byte of VRAM as KV cache so $B$ can grow; prefill wants activation workspace. Colocated, you pick one number for each and both phases get a number that is wrong for them.

Figure 1 — What a prefill does to the decode stream, colocated vs disaggregated. Derived: Llama-3-8B bf16, one H100 SXM, batch 64, chunk 2048, 8192-token prompt. Times in milliseconds. Ctrl/Cmd + wheel to zoom · drag to pan · double-click to fit

Loading…

In the colocated lane, ITL for every already-running sequence goes 4.8 → 71 ms four times. In the disaggregated lane the decode worker never sees the prefill at all; the arriving sequence simply joins the batch at $t = 287$ ms. That is not a softening of the interference. It is its removal.

§2

Mental model

A disaggregated deployment is two independent inference clusters that share nothing except a rendezvous protocol and a fabric. A prefill pool (the P pool) runs engines configured for compute: whatever TP degree the model needs to fit, small KV pool, big token budget per batch. A decode pool (the D pool) runs engines configured for bandwidth and capacity: high TP, enormous KV pool, tiny token budget per step. A router in front of them picks one P worker and one D worker per request and hands both of them a shared correlation token. The KV cache produced on P is written directly into D's paged KV pool over RDMA, and only then does the request become schedulable on D.

Figure 2 — Deployment view. Two pools, one control plane, one RDMA fabric. Ctrl/Cmd + wheel to zoom · drag to pan · double-click to fit

Loading…

Two properties fall straight out of the picture. First, the pools scale independently: P capacity is a function of prompt tokens per second, D capacity is a function of concurrent sequences, and in a real workload those two numbers move for completely different reasons. Second, the arrow between them is the entire cost of the design. Everything else in this chapter is about that arrow.

§3

First principles: what the arrow costs

The KV cache for one token, in bytes, is the formula from §2.1:

$$ \beta_\text{tok} = 2 \cdot L \cdot h_\text{kv} \cdot d_h \cdot b $$

where $L$ is layer count, $h_\text{kv}$ the number of key/value heads (8 for both Llama-3 sizes, they are GQA), $d_h$ the head dimension (128), $b$ the bytes per element (2 for bf16), and the leading 2 counts K and V. The bytes that must cross the wire for a prompt of $S$ tokens is $\beta_\text{tok} \cdot S$ — the whole prompt, because the D worker has none of it.

Derived — KV bytes on the wire, bf16, no KV quantization, no prefix reuse. Model shapes are the published Llama-3 configs.
ModelLh_kvd_hbytes/tokenS = 2,048S = 8,192S = 32,768
Llama-3-8B328128131,0720.27 GB1.07 GB4.29 GB
Llama-3-70B808128327,6800.67 GB2.68 GB10.7 GB

Now divide by link bandwidth. Three fabrics, all quoted as marketing line rate so the numbers are optimistic on purpose — real achieved bandwidth is lower, which only strengthens the conclusion:

Derived — time to move the KV cache of one 8,192-token prompt over one link. NVLink 4 is 900 GB/s aggregate per H100, halved for one direction. 400 Gb/s NDR InfiniBand is 50 GB/s; 100 Gb/s Ethernet is 12.5 GB/s. Bandwidths from the NVIDIA H100 product page; the division is mine.
FabricGB/s8B, 1.07 GB70B, 2.68 GB70B, TP=8 fan-out
NVLink 4, intra-node4502.4 ms6.0 ms0.75 ms
400 Gb/s NDR InfiniBand5021.5 ms53.7 ms6.7 ms
100 Gb/s Ethernet12.585.9 ms214.7 ms26.8 ms

The fourth column is the one that decides whether disaggregation is a good idea. Under tensor parallelism each rank holds only its shard of the KV heads. Llama-3-70B has $h_\text{kv} = 8$, so at TP=8 each rank owns exactly one KV head and therefore $2.68 / 8 = 0.335$ GB of the transfer — if the deployment provides independent NIC bandwidth per rank, transfers can proceed concurrently. Shared NICs, PCIe paths, and fabric contention invalidate an assumed TP-fold speedup. The transfer does not serialize across the fabric; it fans out across it. This is why the per-GPU InfiniBand device is a first-class configuration knob (--disaggregation-ib-device, which accepts a per-GPU JSON mapping in python/sglang/srt/server_args.py:L3155-L3159).

Compare against the TTFT budget. Prefill of 8,192 tokens on Llama-3-8B, one H100 at 50 % MFU, is $2 \times 8\times 10^9 \times 8192 / (0.5 \times 989\times10^{12}) = 265$ ms. For Llama-3-70B on 8×H100 at 40 % MFU it is $2 \times 70\times10^9 \times 8192 / (0.4 \times 8 \times 989\times10^{12}) = 362$ ms. So:

viable

NDR IB, TP fan-out

70B: 6.7 ms on a 362 ms prefill — 1.9 % TTFT overhead. 8B: 21.5 ms on 265 ms — 8.1 %. You are buying complete ITL isolation for single-digit percent TTFT.

marginal

100 GbE, TP fan-out

70B: 26.8 ms, 7.4 %. 8B at TP=1: 85.9 ms on 265 ms, 32 %. A third of your TTFT budget spent on wire time; only worth it under hard ITL SLOs.

absurd

100 GbE, short prompts

512-token prompt on 8B: prefill is 16.6 ms, transfer is 5.4 ms — 32 % again, but now on an absolute budget so small that the handshake round trips dominate. Colocate.

In the simplified projection-only model, both transfer bytes and prefill work grow linearly with prompt length S, so S cancels from their ratio below. Real viability remains length-dependent: prefill attention adds quadratic work, small transfers pay startup/handshake latency, achieved compute varies with shape, prefix hits change bytes, and queues or overlapping transfers alter end-to-end latency.

$$ \frac{t_\text{xfer}}{t_\text{prefill}} = \frac{2 L h_\text{kv} d_h b}{\text{BW}} \cdot \frac{\text{MFU} \cdot F}{2N} $$

with $N$ parameters and $F$ the device's dense FLOP/s. Long prompts do not make disaggregation worse; they make it exactly as good, on a bigger absolute budget. What does make it worse is a model with a fat KV cache relative to its parameter count — which is precisely why MLA models (§3.5), whose per-token KV is a fraction of a GQA model's, are the natural fit for disaggregated serving at scale.

Provenance

Every number above is arithmetic from published shapes and link bandwidths, not a measurement. Published end-to-end results exist and should be read for what the arithmetic cannot capture: DistServe (OSDI 2024) reports serving “7.4x more requests or 12.6x tighter SLO” than a colocated baseline, and Mooncake reports “up to a 525% increase in throughput in certain simulated scenarios” and 75 % more requests on Kimi's real workload.

§4

How production systems do it

vLLM and SGLang arrived at genuinely different architectures here, and the difference is instructive because it is not an accident of taste — it follows from where each project put the abstraction boundary.

SGLang: a dedicated subsystem with its own event loops

SGLang treats P and D as two different programs. A server is launched in one of three modes, and the mode selects an entire scheduler event loop:

python/sglang/srt/disaggregation/utils.py:L100-L103 SGLang
class DisaggregationMode(Enum):
    NULL = "null"
    PREFILL = "prefill"
    DECODE = "decode"

The prefill server runs event_loop_normal_disagg_prefill (python/sglang/srt/disaggregation/prefill.py:L569-L604), the decode server runs event_loop_normal_disagg_decode (python/sglang/srt/disaggregation/decode.py:L2428-L2463). Neither is the unified loop from §1.4. Each has its own queues: PrefillBootstrapQueue and disagg_prefill_inflight_queue on P, DecodePreallocQueue and DecodeTransferQueue on D.

The transport is behind an abstract interface in python/sglang/srt/disaggregation/base/conn.py. A backend supplies four classes — manager, sender, receiver, bootstrap server — and the contract between them is a five-state poll:

python/sglang/srt/disaggregation/base/conn.py:L93-L98 SGLang
class KVPoll:
    Failed = 0
    Bootstrapping = 1
    WaitingForInput = 2
    Transferring = 3
    Success = 4

The two halves of the handshake are the sender's init/send and the receiver's send_metadata. Note the direction carefully — it is the receiver that pushes metadata:

python/sglang/srt/disaggregation/base/conn.py:L207-L217 SGLang
    @abstractmethod
    def send_metadata(
        self,
        kv_indices: npt.NDArray[np.int32],
        aux_index: Optional[int] = None,
        state_indices: Optional[List] = None,
        decode_prefix_len: Optional[int] = None,
    ):
        """
        Notify the prefill server about the kv indices, aux index, and state_indices.
        """

D allocates its own KV pages first, then tells P the page indices to write into. The transfer is therefore an RDMA WRITE issued by P into memory D has already reserved. The state transition that unblocks P is explicit in the Mooncake manager's bootstrap thread:

python/sglang/srt/disaggregation/mooncake/conn.py:L1971-L1975 SGLang
    def start_prefill_thread(self):
        def bootstrap_thread():
            """This thread recvs pre-alloc notification from the decode engine"""
            # KVPoll.Bootstrapping -> KVPoll.WaitingForInput
            while True:

This single line is the load-bearing fact of the whole design: a prefill does not start until the decode side has reserved memory for its output. Backpressure is structural, not advisory.

vLLM: the KV connector, and no P/D subsystem at all

vLLM has no disaggregation/ directory. It has vllm/distributed/kv_transfer/, whose abstraction is a connector: an object with a scheduler-side half and a worker-side half that the ordinary V1 scheduler calls at fixed points in its ordinary loop. The base class docstring is the cleanest statement of the contract:

vllm/distributed/kv_transfer/kv_connector/v1/base.py:L8-L38 vLLM
    Scheduler-side: runs in the scheduler, binds metadata, which
    is used by the worker-side to load/save KV cache.
        get_num_new_matched_tokens() - get number of new tokens
            that exist in the remote KV cache. Might be called multiple
            times for a given request and should be side-effect free.
        update_state_after_alloc() - update KVConnector state after
            temporary buffer alloc by the CacheManager.
# ...
        request_finished() - called once when a request is finished,
            with the computed kv cache blocks for the request.
            Returns whether KV cache should be freed now or if the
            connector now assumes responsibility for freeing the
            the blocks asynchronously. Also optionally returns KV
            transfer params.
# ...
    Worker-side: runs in each worker, loads/saves KV cache to/from
    the Connector based on the metadata.
# ...
        start_load_kv() - starts loading all KVs (maybe async)
        wait_for_layer_load() - blocks until layer i load is done

        save_kv_layer() - starts saving KV for layer i (maybe async)
        wait_for_save() - blocks until all saves are done

        get_finished() - called with ids of finished requests, returns
            ids of requests that have completed async sending/recving.

Disaggregation is then expressed in that interface rather than implemented alongside it. On the D node, the request arrives carrying kv_transfer_params set by an external proxy; the connector reports the whole prompt as “already computed, remotely”:

vllm/distributed/kv_transfer/kv_connector/v1/nixl/pull_scheduler.py:L60-L66 vLLM
        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

The True is “asynchronously”. The scheduler allocates blocks, parks the request in RequestStatus.WAITING_FOR_REMOTE_KVS (vllm/v1/request.py:L364-L371), and the worker-side connector pulls the blocks over NIXL. On the P node, request_finished() returns True to say “do not free my blocks yet, I own them now.” Both sides converge in one function:

vllm/v1/core/sched/scheduler.py:L2836-L2864 vLLM
    def _update_from_kv_xfer_finished(self, kv_connector_output: KVConnectorOutput):
        """
        KV Connector: update the scheduler state based on the output.

        The Worker side connectors add finished_recving and
        finished_sending reqs to the output.
        * if finished_sending: free the blocks
        # if finished_recving: add to state so we can
            schedule the request during the next step.
        """
# ...
        for req_id in kv_connector_output.finished_recving or ():
            logger.debug("Finished recving KV transfer for request %s", req_id)
            assert req_id in self.requests
            req = self.requests[req_id]
            if req.status == RequestStatus.WAITING_FOR_REMOTE_KVS:
                self.finished_recving_kv_req_ids.add(req_id)
            else:
                assert RequestStatus.is_finished(req.status)
                self._free_blocks(self.requests[req_id])
        for req_id in kv_connector_output.finished_sending or ():
            logger.debug("Finished sending KV transfer for request %s", req_id)
            assert req_id in self.requests
            self._free_blocks(self.requests[req_id])

Twenty-nine lines. That is vLLM's entire disaggregation state machine in the scheduler, because everything else lives in the connector. The tradeoff is stark and symmetric:

Architectural comparison at the pinned SHAs (vLLM a556f3f, SGLang 7d89325).
DimensionvLLM — KV connectorSGLang — disaggregation subsystem
Where P/D livesInside a connector plugin; scheduler is unmodifiedDedicated event loops selected by --disaggregation-mode
Transfer directionPull (READ) by default; push (WRITE) available as NixlPushConnectorPush (WRITE) — D pre-allocates, P writes
Rendezvouskv_transfer_params injected by an external proxy; NIXL agent handshakeHTTP bootstrap server on P, bootstrap_room injected by the router
Same interface reused forCPU/disk offload, LMCache, HF3FS, Mooncake Store — one registry, 15+ connectorsNothing; offload has separate code paths
Cost of the choiceP/D semantics smeared across connector implementations; each backend re-implements the state machineTwo more event loops to keep in sync with the unified one; every scheduler feature needs a disagg port

The generality is real, not rhetorical: the same KVConnectorBase_V1 that carries P/D also carries CPU offload and remote KV stores, all registered in one table (vllm/distributed/kv_transfer/kv_connector/factory.py:L148-L242). That is why §2.6 and this chapter are about the same file in vLLM and about different files in SGLang.

Layer-wise transfer: who actually overlaps

The prize is to start moving layer $\ell$'s KV while layer $\ell+1$ is still computing, so the transfer hides entirely inside prefill. vLLM has the hook wired directly into the attention layer as a decorator:

vllm/model_executor/layers/attention/kv_transfer_utils.py:L37-L59 vLLM
    @wraps(func)
    def wrapper(*args, **kwargs):
        if not has_kv_transfer_group() or not is_v1_kv_transfer_group():
            return func(*args, **kwargs)

        layer_name = _resolve_layer_name(args[layer_name_index])

        # Extract attention context (metadata, layer, kv_cache, layer_slot_mapping)
        attn_metadata, _, kv_cache, _ = get_attention_context(layer_name)
        connector = get_kv_transfer_group()
        if attn_metadata is None or not connector.has_connector_metadata():
            return func(*args, **kwargs)

        # Wait for KV layer on entry
        connector.wait_for_layer_load(layer_name)

        # Execute the function
        result = func(*args, **kwargs)

        # Save KV cache layer on exit
        connector.save_kv_layer(layer_name, kv_cache, attn_metadata)

        return result

But most connectors decline it. NixlConnector.save_kv_layer is a bare pass with the comment “NixlConnector does not save explicitly” (vllm/distributed/kv_transfer/kv_connector/v1/nixl/connector.py:L326-L334), and so is MooncakeConnector's (vllm/distributed/kv_transfer/kv_connector/v1/mooncake/mooncake_connector.py:L580-L588). The connector that does use it is MoRIIO in WRITE mode, which issues the RDMA write per layer as the layer finishes:

vllm/distributed/kv_transfer/kv_connector/v1/moriio/moriio_connector.py:L2035-L2058 vLLM
            return
        if self.mode == MoRIIOMode.READ:
            return
        remote_engine_id = None

        for req_id, meta in metadata.reqs_to_save.items():
            # we only need to check if dp0 in rank
            remote_engine_id = (
                str(meta.remote_host) + ":" + str(meta.remote_handshake_port)
            )

            meta.remote_engine_id = remote_engine_id

            dp0_remote_engine_id = self.get_engine_name_with_dp(remote_engine_id, 0)
            if dp0_remote_engine_id not in self._remote_agents:
                # Initiate handshake with remote engine to exchange metadata.
                with self._handshake_lock:
                    if remote_engine_id not in self._remote_agents:
                        self._background_moriio_handshake(
                            req_id, remote_engine_id, meta
                        )

                        continue
            self._write_blocks_for_req(req_id, meta, layer_name, kv_layer)

SGLang overlaps on a different axis: not per layer, but per prefill chunk. Because prefill is chunked anyway, the scheduler fires a transfer for each completed chunk from inside process_prefill_chunk:

python/sglang/srt/disaggregation/prefill.py:L1059-L1066 SGLang
            elif self.enable_overlap:
                # Delay KV transfer to process_batch_result_disagg_prefill when overlap is enabled to ensure results are resolved
                req.tmp_end_idx = min(
                    req.extend_range.end,
                    len(req.origin_input_ids),
                )
            else:
                self.send_kv_chunk(req)

Chunk $k$'s KV flies while chunk $k+1$ computes. With a 2,048-token chunk on Llama-3-8B, each send is 268 MB, 5.4 ms over NDR IB against a 66 ms chunk — the transfer disappears into the compute completely except for the final chunk. Within a chunk, Mooncake batches all layers into a single RDMA call rather than one per layer, on the explicit grounds that it beats threading:

python/sglang/srt/disaggregation/mooncake/conn.py:L648-L655 SGLang
    def _transfer_data(self, mooncake_session_id, transfer_blocks):
        if not transfer_blocks:
            return 0

        src_addrs, dst_addrs, lengths = zip(*transfer_blocks)
        return self.engine.batch_transfer_sync(
            mooncake_session_id, list(src_addrs), list(dst_addrs), list(lengths)
        )

There is a further optimisation gated behind SGLANG_DISAGG_PREFILL_EARLY_SEND_CACHED_PREFIX: if the P worker's radix cache already holds a device-resident prefix of this prompt, it ships those pages before the forward pass is even enqueued (python/sglang/srt/disaggregation/prefill.py:L1082-L1120). That path has to record a CUDA event on the forward stream first, because under overlap scheduling the previous step may still be writing those exact pages.

The backends

SGLang's registry names five, plus a test double:

python/sglang/srt/disaggregation/utils.py:L589-L594 SGLang
class TransferBackend(Enum):
    MOONCAKE = "mooncake"
    MORI = "mori"
    NIXL = "nixl"
    ASCEND = "ascend"
    FAKE = "fake"
mooncake

Transfer Engine, default

Wraps the Mooncake Transfer Engine. Buffers are registered once at startup via engine.batch_register(ptrs, lens); transfers are batch_transfer_sync against a session id resolved through the bootstrap server. Can allocate the KV pool from an NVLink- or BAREX-backed torch.cuda.MemPool so intra-node transfers skip the NIC entirely (python/sglang/srt/disaggregation/mooncake/utils.py:L29-L88).

nixl

Agent + pluggable plugin

Creates a NIXL agent plus one backend plugin chosen by SGLANG_DISAGGREGATION_NIXL_BACKEND — UCX, GDS_MT, UCCL, OBJ handled by name. Memory is registered as typed regions ("VRAM" for KV, "DRAM" for aux); transfers are make_prepped_xfer("WRITE", ...) then agent.transfer(handle). One level less concrete than the others, since NIXL is itself a transport abstraction.

mori

Raw RDMA verbs, AMD

MORI (Modular RDMA Interface) is AMD's GPU-direct RDMA stack. SGLang builds an IOEngine and explicitly creates an RDMA backend with a tunable queue-pair count — the lowest-level of the three, with no transport abstraction between it and the verbs.

fake

No transfer at all

Every poll returns Success immediately (python/sglang/srt/disaggregation/fake/conn.py:L50-L61). Used for warm-up requests and for exercising both event loops with no fabric — the only way to run the P/D code paths on a laptop.

python/sglang/srt/disaggregation/mori/conn.py:L362-L373 SGLang
        qp_per_transfer = envs.SGLANG_MORI_QP_PER_TRANSFER.get()
        post_batch_size = envs.SGLANG_MORI_POST_BATCH_SIZE.get()
        num_worker_threads = envs.SGLANG_MORI_NUM_WORKERS.get()

        rdma_cfg = RdmaBackendConfig(
            qp_per_transfer,
            post_batch_size,
            num_worker_threads,
            poll_mode,
            False,
        )
        engine.create_backend(BackendType.RDMA, rdma_cfg)

vLLM's equivalents are registered as connectors rather than backends: NixlConnector / NixlPullConnector / NixlPushConnector, MooncakeConnector, MoRIIOConnector, all in vllm/distributed/kv_transfer/kv_connector/factory.py:L176-L222. The same three transports, wearing the shape of whichever engine they are plugged into.

Unverified

I did not verify at which layer Mooncake's Transfer Engine chooses between RDMA verbs, TCP, and NVLink at run time — that decision lives in the external mooncake Python/C++ package, not in either engine's tree. The SGLang side only ever calls batch_register / batch_transfer_sync. Reader should check the Mooncake repository before relying on any claim about its protocol selection.

§5

Worked trace: one request across the boundary

Follow a single request through SGLang with the Mooncake backend, naming functions in order.

Figure 3 — One request crossing the P/D boundary, including the bootstrap handshake. Ctrl/Cmd + wheel to zoom · drag to pan · double-click to fit

Loading…
  1. Router fan-out. The load balancer picks a (P, D) pair, generates a 63-bit random bootstrap_room, and posts the same request body to both servers. The load balancer makes the intent explicit — tasks = [session.post(prefill_server...), session.post(decode_server...)], with the comment “Wait for both responses to complete. Prefill should end first.” (sgl-model-gateway/bindings/python/src/sglang_router/mini_lb.py:L135-L141, room generation at :L376-L382).
  2. P side registration. PrefillBootstrapQueue.addcreate_sender constructs a MooncakeKVSender bound to (bootstrap_addr, bootstrap_room) and sets max_new_tokens = 1 so the prefill adder's memory estimate is honest (python/sglang/srt/disaggregation/prefill.py:L299-L324, :L377-L381). The request now sits in the bootstrap queue polling Bootstrapping.
  3. D side registration and preallocation. DecodePreallocQueue.add_create_receiver_and_enqueue builds a MooncakeKVReceiver for the same room (python/sglang/srt/disaggregation/decode.py:L662-L681). Then pop_preallocated checks the token budget, allocates real KV pages for the entire prompt, and pushes their indices to P:
python/sglang/srt/disaggregation/decode.py:L1490-L1495 SGLang
            decode_req.kv_receiver.send_metadata(
                page_indices,
                decode_req.metadata_buffer_index,
                state_indices,
                **metadata_kwargs,
            )
  1. P unblocks. The Mooncake bootstrap thread receives that message, flips the room's status, and P's next pop_bootstrapped() sees WaitingForInput. It calls finalize_bootstrap, which allocates a metadata buffer slot and sizes the send:
python/sglang/srt/disaggregation/prefill.py:L343-L356 SGLang
        req.time_stats.set_bootstrap_done_time()
        decode_prefix_len = req.disagg_kv_sender.pop_decode_prefix_len()
        num_kv_indices = len(req.origin_input_ids)
        req.start_send_idx = decode_prefix_len
        # Base of the staging chunk grid (suffix-relative send coordinates).
        req.disagg_decode_prefix_len = decode_prefix_len
        num_kv_indices_to_send = num_kv_indices - decode_prefix_len
        num_pages = kv_to_page_num(
            num_kv_indices_to_send,
            self.scheduler.token_to_kv_pool_allocator.page_size,
        )
        req.disagg_kv_sender.init(num_pages, req.metadata_buffer_index)
        req.pending_bootstrap = False
        return True

decode_prefix_len is the number of leading tokens D already has in its own radix cache. P skips those entirely — prefix caching on the decode side directly shrinks the wire transfer, which is the reason --disaggregation-decode-enable-radix-cache exists.

  1. Prefill and streaming send. The request enters waiting_queue, gets batched, and process_prefill_chunk calls send_kv_chunk(req) after each chunk. send_kv_chunk gathers req_to_token[req_pool_idx, start:end], converts token indices to page indices, and calls sender.send(page_indices, ...), which enqueues a TransferKVChunk onto a FastQueue drained by MooncakeKVManager.transfer_worker (python/sglang/srt/disaggregation/mooncake/conn.py:L1617-L1640). Non-final chunks are truncated to a page boundary so no partial page is ever written twice.
  2. Last chunk carries the sampled token. On last_chunk=True, self.disagg_metadata_buffers.set_buf(req) packs the first sampled token id, logprobs and any hidden states into an aux buffer that rides along with the final RDMA write. This is why D can start decoding immediately: it receives the KV and token 1 together and never re-runs a forward pass over the prompt.
  3. P retires. process_disagg_prefill_inflight_queue polls every in-flight sender; on Success it unlocks the tree cache and streams a zero-length completion to the client so the router's prefill leg returns.
  4. D admits. DecodeTransferQueue.pop_transferred sees Success, calls _commit_transfer_to_req, and the request lands in D's ordinary waiting_queue — from here on it is an ordinary decode request and §1.4 takes over.
§6

Pitfalls and war stories

Figure 4 — The transfer state machine, and every way out of it. States are KVPoll values; edges are the events that move a request between them. Ctrl/Cmd + wheel to zoom · drag to pan · double-click to fit

Loading…

The decode pool is full when a prefill finishes

It cannot be, and that is the point. Because D preallocates before P is unblocked, a full decode pool means pop_preallocated never reaches the request, send_metadata is never sent, and P's sender stays in Bootstrapping — it never burns a single GPU-second on a prefill whose output has nowhere to land. The queue is on the P side, in the cheapest possible place.

The escape hatch is --optimistic-prefill-attempts, which lets P start computing before bootstrap completes (python/sglang/srt/server_args.py:L3198-L3202). If a properly bootstrapped request shows up while an optimistic one is mid-flight, the optimistic one yields: optimistic_release_and_requeue releases its KV, resets start_send_idx, and pushes it back to the front of the waiting queue (python/sglang/srt/disaggregation/prefill.py:L1310-L1343). You are trading recomputation risk for latency.

Bootstrap timeouts, and the log line you will actually see

python/sglang/srt/disaggregation/common/conn.py:L1282-L1299 SGLang
    def _check_bootstrap_timeout(self) -> Optional[KVPoll]:
        if self.init_time is None:
            return None
        elapsed = time.time() - self.init_time
        if elapsed < self.kv_mgr.bootstrap_timeout:
            return None
        logger.warning_once(
            "Some requests timed out when bootstrapping, "
            "which means prefill instances fail to receive the KV indices from the decode instance of this request. "
            "If a greater mean TTFT is acceptable, you can 'export SGLANG_DISAGGREGATION_BOOTSTRAP_TIMEOUT=600' (10 minutes) to relax the timeout condition. "
        )
        self.kv_mgr.record_failure(
            self.bootstrap_room,
            f"Request {self.bootstrap_room} timed out after {elapsed:.1f}s "
            f"in KVPoll.Bootstrapping",
        )
        self.kv_mgr.update_status(self.bootstrap_room, KVPoll.Failed)
        return KVPoll.Failed

This message means one of three things, in decreasing order of likelihood: the D pool is saturated and cannot preallocate; the router sent the two halves of the request to servers that disagree about the bootstrap port; or the ZMQ path from D to P is blocked by a firewall while the HTTP bootstrap path is not. Check bootstrap_room cardinality in the P and D logs first — if P sees rooms D never sees, it is the router.

The transfer fails mid-flight

Both sides tear down symmetrically. On P, handle_inflight_transfer_failure raises the backend's failure_exception() to get a real message, unlocks the tree cache, aborts with HTTP 500 and bumps transfer_failed_reqs (python/sglang/srt/disaggregation/prefill.py:L937-L964). The error string is "Prefill transfer failed for request rank=... req.rid=... req.bootstrap_room=...". On D, pop_transferred does the same with "Decode transfer failed for request rank=..." and then must decide whether it is safe to reuse the pages:

This is the subtlest correctness problem in the subsystem. If D initiated the abort, P may still have an RDMA write in flight targeting pages D is about to hand to another request. So D defers the release until P acknowledges that its writes have drained — _defer_release, an ABORT / ABORT_ACK exchange over ZMQ, and a timeout backstop (python/sglang/srt/disaggregation/decode.py:L2283-L2295; the P-side ack handling is at python/sglang/srt/disaggregation/mooncake/conn.py:L1996-L2020). If you see KV corruption under abort-heavy load, this is the machinery to look at.

Heterogeneous TP is supported, and it is not free

Running P at TP=4 and D at TP=8 means each P rank's KV shard must be split across two D ranks. Both engines implement the remapping — SGLang in send_kvcache_slice (python/sglang/srt/disaggregation/mooncake/conn.py:L1003-L1122), vLLM in a dedicated TPMapping computed once per remote engine during handshake (vllm/distributed/kv_transfer/kv_connector/v1/nixl/tp_mapping.py:L38-L56). The cost is that the transfer stops being one contiguous block per layer and becomes many strided sub-slices, which is exactly the access pattern RDMA is worst at. Measure before assuming hetero-TP is free.

Two neighbours this is constantly confused with

Not this chapter

KV offload moves a KV cache from GPU to CPU or disk on the same machine to survive eviction — in vLLM it shares the connector interface (vllm/v1/kv_offload/, OffloadingConnector), which is why the two get conflated. It is a capacity mechanism, not a topology; §2.6 owns it. KV-aware routing picks which replica should serve a request based on whose prefix cache already holds the prompt; it is orthogonal to P/D and is owned by §9.4. Disaggregation is about splitting one request across two machines, every time.

§7

Hands-on

The commands below use the real Mooncake backend, not fake transfer. They require two suitable GPUs (one selected for each server), installed compatible engine/transport packages, model access, and a working supported transport. A fake backend can exercise some control paths but does not transfer valid KV or establish generated-output correctness; it is not a substitute for this end-to-end experiment.

Two SGLang servers plus the mini load balancer shell
# terminal 1 - prefill server, owns the bootstrap HTTP server on 8998
CUDA_VISIBLE_DEVICES=0 python -m sglang.launch_server --model-path meta-llama/Meta-Llama-3-8B-Instruct \
  --port 30000 --disaggregation-mode prefill \
  --disaggregation-transfer-backend mooncake --disaggregation-bootstrap-port 8998

# terminal 2 - decode server
CUDA_VISIBLE_DEVICES=1 python -m sglang.launch_server --model-path meta-llama/Meta-Llama-3-8B-Instruct \
  --port 30001 --disaggregation-mode decode \
  --disaggregation-transfer-backend mooncake

# terminal 3 - the router that injects bootstrap_room into both halves
python -m sglang_router.launch_router --pd-disaggregation \
  --prefill http://127.0.0.1:30000 8998 --decode http://127.0.0.1:30001 --port 8000

Three things to measure, in order of what they teach you:

  1. Where the time goes. SGLang records bootstrap_done_time, decode_transfer_queue_entry_time and prefill_kv_transfer_finish_time per request and exports kv_transfer_latency_ms and kv_transfer_speed_gb_s (python/sglang/srt/disaggregation/prefill.py:L908-L928). If speed_gb_s is under half your NIC's line rate, look at page size and at hetero-TP slicing.
  2. What backpressure looks like. Push concurrency until the D pool saturates and watch the P-side bootstrap queue grow while P's GPU utilisation falls. That falling utilisation is the design working.
  3. The ITL distribution. Run the same load colocated and disaggregated, and plot the ITL histogram, not the mean. Colocated is bimodal with a mode at the chunk cost; disaggregated should be unimodal. That difference is the entire product of this chapter.

For vLLM the equivalent is a proxy plus two engines with a --kv-transfer-config naming the connector and its role; the shipped harness is examples/disaggregated/disaggregated_serving/disagg_proxy_demo.py, which documents an XpYd launch in its module docstring (examples/disaggregated/disaggregated_serving/disagg_proxy_demo.py:L3-L15).

§8

Exercises

  1. Read and answer. In python/sglang/srt/disaggregation/prefill.py, find pop_bootstrapped. Which single KVPoll value causes a request to move into the waiting queue by the normal path, and what happens to a request whose poll is Bootstrapping when optimistic_prefill_attempts is 0?
  2. Derive. Compute the wire bytes and the NDR-InfiniBand transfer time for a 16,384-token prompt on DeepSeek-V3, whose MLA compressed KV is 576 elements per token per layer across 61 layers in bf16. Compare the ratio $t_\text{xfer}/t_\text{prefill}$ to Llama-3-70B's and explain the difference in one sentence.
  3. Predict, then verify. You enable --disaggregation-decode-enable-radix-cache on a workload where 60 % of every prompt is a shared system preamble. Predict the change in bytes transferred per request. Then read finalize_bootstrap in prefill.py and name the variable that makes your prediction true.
  4. Predict, then verify. A request is aborted by the client while its KV transfer is in flight. Predict whether the decode worker can immediately reuse the preallocated pages. Then find _defer_release in decode.py and say what has to happen first.
  5. Compare architectures. vLLM's NixlConnector pulls; SGLang pushes. Name one failure mode that is easier to handle in a pull design and one that is easier in a push design, using the two schedulers' code as evidence.
Answers

1. Only KVPoll.WaitingForInput takes the normal path, and only after finalize_bootstrap(req) returns True — if no metadata buffer slot is free, the loop continues and the request stays queued. With optimistic_prefill_attempts == 0, a Bootstrapping poll falls through the guard req.prefill_attempt_count < server_args.optimistic_prefill_attempts and the request simply remains in the queue for the next iteration. See python/sglang/srt/disaggregation/prefill.py:L438-L466.

2. The cache has $576\cdot61\cdot2=70,272$ bytes/token. A 16,384-token prompt transfers 1,151,336,448 bytes, about 1.15 GB, requiring an ideal 23.0 ms at 50 GB/s before startup and contention. To compare with prefill, use activated MoE projection work plus attention and routing costs, not all 671B resident parameters as if every expert were evaluated. MLA lowers transfer bytes, but these shapes alone do not establish an end-to-end ratio. Mooncake originated as the Kimi serving system; no joint-design claim with DeepSeek follows from that paper.

3. Bytes transferred drop by roughly 60 %, because D matches the preamble in its own radix cache before preallocating and reports the match length back. The variable is decode_prefix_len, returned by req.disagg_kv_sender.pop_decode_prefix_len(); it becomes req.start_send_idx, so P's first chunk send starts past the shared prefix.

4. No. A prefill-side RDMA write may still be targeting those exact pages. _defer_release parks the request until P acknowledges that its in-flight writes have drained (the ABORT / ABORT_ACK ZMQ exchange), or until a timeout fires. Releasing early is a silent cross-request KV corruption, not a crash.

5. Pull is easier when the producer dies: the consumer initiated the transfer, so it observes the failure directly and can abort locally — vLLM's D-side simply never gets finished_recving. Push is easier for admission control: because the consumer must publish destination pages before the producer starts, a full decode pool exerts backpressure for free, which is exactly the BootstrappingWaitingForInput gate in SGLang. vLLM has to reconstruct that with an external proxy's routing decisions.

§9

Key takeaways

  • The colocation penalty is quantifiable and large: one 2,048-token prefill chunk adds ~66 ms to a ~4.8 ms decode step on an H100 running Llama-3-8B. Chunking spreads that cost; it does not remove it. Disaggregation removes it.
  • Prompt length cancels only in a linear projection-only compute model with constant achieved rates and negligible startup cost. It depends on $2Lh_\text{kv}d_hb$ over bandwidth, versus $2N$ over device FLOP/s. Fat-KV, small-parameter models are the bad case; MLA models are the good one.
  • Tensor parallelism fans the transfer out across ranks and NICs. Llama-3-70B's 2.68 GB at 8k tokens becomes 335 MB per rank at TP=8, giving an ideal 6.7 ms per-rank transfer only with sufficient independent links instead of one shared 50 GB/s bottleneck. Never quote the aggregate figure without saying what TP degree it assumes.
  • SGLang's push design makes backpressure structural: the decode worker preallocates and publishes destination pages before the prefill worker is allowed to start, so a saturated decode pool costs zero wasted prefill FLOPs. vLLM's pull design gets generality instead — the same connector interface carries P/D, CPU offload, and remote KV stores — and pays for it by pushing admission control out to an external proxy.
  • Overlap happens on different axes: vLLM exposes per-layer save_kv_layer hooks inside the attention layer, but only MoRIIO in WRITE mode uses them; SGLang overlaps per prefill chunk, which hides the transfer inside compute for every chunk but the last.
  • The dangerous failure is not a dropped transfer, it is an aborted one: pages the consumer wants to reuse may still be the target of an in-flight remote write. Both engines need an explicit drain acknowledgement, and both have one.
§10

Further reading

Lab

Turn transfer failures into executable contracts

The real Mooncake experiment above requires GPUs and a supported transport. The CPU transfer-fault lab isolates a narrower prerequisite: safe ownership when completion, cancellation and room reuse occur in different orders. It ships a standard-library model with a deterministic event trace and seeded tests, not an RDMA implementation or a performance benchmark.

Cancellation is a control decision, not a memory fence

Both sides of an in-flight transfer retain storage obligations. The producer must not overwrite source KV while it is being read, and the consumer must not repurpose destination pages while they may still be written. The model represents these obligations with separate pins. Aborting the request removes destination request ownership but preserves both pins until an explicit drain acknowledgement. Source mutation uses copy-on-write on a pinned partial page, so the eventual completed snapshot stays stable.

Four abort reasons share the same safety rule: user cancellation, timeout, producer failure and consumer failure. A timeout or process-failure notification alone does not authorize page reuse. The relevant question is whether the backend guarantees that no queued or remote operation can still touch those allocations. The model's acknowledge_drained() is the place where a real implementation must supply that guarantee. Without it, retaining memory is intentional quarantine, not a proven leak that can be fixed by freeing pages.

Fence attempts, not just request names

The lab assigns each transfer attempt a monotonically increasing key and treats the room name as a reusable routing label. Duplicate live rooms are rejected. A room becomes reusable only after the old attempt drains or its published output is released; a callback for the old key cannot affect its successor. Success exposes destination KV to decode only after completion. Aborting first makes subsequent completion notifications nonpublishing; completing first instead requires ordinary output release.

Generation-qualified callbacks protect metadata but do not themselves prevent a late device write. A production protocol also needs transport-level revocation, completion or incarnation fencing before address reuse, plus bounded terminal-record retention. Worker restart, registration lifetime, partial-copy corruption, incompatible cache layouts and model-version mismatch need additional real-backend tests. The CPU exercise verifies none of those by implication.

Acceptance criteria for a backend adapter

  1. For each abort reason, retain source and destination allocations until the transport proves quiescence; do not publish partially transferred KV.
  2. Inject duplicate completion, duplicate cancellation, delayed drain and old-attempt callbacks after room reuse. Check that the new attempt's ownership and output are unchanged.
  3. Fail destination allocation midway. Verify that all temporary pages and source pins are rolled back, while the producer's original KV remains readable.
  4. Cancel the producer while its request is being preempted. Verify that replay cannot reuse pinned pages, then succeeds once the transport has drained.
  5. Separate eventual cleanup from safety. A missing acknowledgement must not cause early reuse; an operational test must also demonstrate the backend's actual recovery path and deadline.

The included tests cover the abstract contracts for all four abort reasons and compare published rows with an independent token-history oracle. Port these assertions to backend-specific fixtures before drawing conclusions about real engines, failure recovery time or available serving capacity.

The Inference Engineering Course · code read at vllm@a556f3fccb and sglang@7d893255c3, pinned 2026-08-21. See SOURCES for the full provenance record.

Explore the library

Reading preferences

Appearance
18 px