Skip to article frontmatterSkip to article content
Site not loading correctly?

This may be due to an incorrect BASE_URL configuration. See the MyST Documentation for reference.

ch11 · Disaggregating Prefill and Decode

The problem

ch10 · Chunked Prefill and Scheduling Policy spent an entire chapter refereeing between prefill and decode: which gets the budget, in what order, at whose expense. That work exists only because both jobs run on the same hardware, in the same loop, competing for the same step.

They are not similar jobs. ch03 · The Arithmetic of Inference established that prefill is compute-bound and decode is memory-bandwidth-bound. We have been buying one machine to do well at two things that want different machines.

The idea

Run them in separate pools. Prefill a request in one, ship its keys and values to the other, decode there. The pools can then be sized independently — and, in a real deployment, provisioned on different hardware entirely.

The consequences are appealing:

And one cost: every byte of KV cache has to cross between them.

disaggregated.py
    def _handoff(self, state: RequestState) -> None:
        """Move one sequence's KV cache from the prefill pool to the decode pool.

        Here that is a copy within one process. In a real deployment it is a network transfer, and
        its cost scales with the KV cache — which is why interconnect bandwidth, not compute,
        decides whether disaggregation is viable. A slow link turns every prefill into a stall at
        the other end.
        """

The build

The engine is a prefill pool, a handoff, and a decode pool:

disaggregated.py
        # -- prefill pool --------------------------------------------------------------
        room = self.max_batch_size - len(self.decoding)
        batch = self.waiting[: min(self.prefill_batch_size, max(room, 0))]
        if batch:
            self.waiting = self.waiting[len(batch) :]
            logits, caches = _prefill_batch(self.model, batch)
            for state, cache in zip(batch, caches, strict=True):
                state.past = cache
                self._handoff(state)
            outputs += self._emit(batch, _sample_batch(logits, batch))
            self.decoding += [s for s in batch if not s.finished]

        # -- decode pool ---------------------------------------------------------------
        ready = [s for s in self.decoding if s not in batch and not s.finished]
        if ready:
            outputs += self._emit(ready, _sample_batch(_decode_batch(self.model, ready), ready))

        return outputs

Two pool sizes rather than one — prefill_batch_size and max_batch_size — which is the entire architectural point, even though a single process cannot exploit it.

The measurement

ConfigurationTTFT p50TTFT p95ITL p50Output tok/sGoodput req/s
ch07 One pool, 8 req/s0.0301s0.0557s0.0127s248.17.378
ch11 Two pools, 8 req/s0.0333s0.0656s0.0131s247.67.362
ch07 One pool, 16 req/s0.0744s0.4518s0.0181s355.910.58
ch11 Two pools, 16 req/s0.0505s0.3856s0.0165s365.610.87

Conditions: TinyGPT (reference, random weights) (5,838,080 params), 4x x86_64 CPU, torch 2.14.0+cu130, poisson, prompt 64-256, output 16-48, seed 7, arrival rate 16/s, measured 2026-09-13T15:29:49+00:00.

Roughly a wash, which is the expected result: the handoff is a memcpy within one process, and at this model size a request’s whole KV cache is well under a megabyte. Nothing here argues for or against the architecture.

Now the number that decides it. The handoff is not a design detail — it is the design. Here is what one request’s cache costs to move, across realistic interconnects:

HandoffKV size10 GbE100 GbENVLink (~400 GB/s)
This book’s model, 256 tokens0.8 MB0.6 ms0.1 ms0.0 ms
8B GQA, 2k context268.4 MB214.7 ms21.5 ms0.7 ms
8B GQA, 32k context4.29 GB3436.0 ms343.6 ms10.7 ms

Read the bottom row. An 8B model serving a 32k-token context has a KV cache of several gigabytes per request. Over ordinary datacentre Ethernet, moving it takes seconds — far longer than the prefill that produced it, and vastly longer than any latency budget. Over NVLink it takes about ten milliseconds, which is affordable.

That single comparison contains the whole chapter:

Disaggregation is an interconnect decision, not a scheduling one. It is an excellent architecture when prefill and decode sit on the same fabric, and unusable when they do not. No amount of scheduler cleverness compensates for a slow link.

It also explains why the technique appeared when it did. Disaggregated serving became practical once high-bandwidth interconnects between accelerators became ordinary; on commodity networking it would have been obviously absurd, and the papers would never have been written.

The cost

Key takeaways

Looking ahead

Part III has taken the engine from one request at a time to a scheduler with paged memory, prefix reuse and an explicit prefill policy. Every remaining inefficiency is now in the mathematics itself rather than in how work is organised. ch12 · Attention at Speed [DRAFT] turns to attention: how to compute it with far less memory traffic, and how the architectures that shrink the KV cache change the arithmetic this entire part has been fighting.

Further reading

DistServe (Zhong et al., Appendix E) is the primary source and introduces the goodput framing this book has used since ch02 · Measuring What Matters; Splitwise covers the same ground with a stronger emphasis on hardware heterogeneity. Both are worth reading with this chapter’s handoff table beside them.