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.

ch10 · Chunked Prefill and Scheduling Policy

The problem

Every engine so far treats a prefill as indivisible. Admitting a request means running its entire prompt in one step, and everything already streaming waits for that step to end.

With short prompts nobody notices. With an eight-thousand-token prompt, every user mid-sentence stops for as long as that prefill takes. They are not queued behind a busy server; they are queued behind one other person’s paste.

The idea

Stop letting a step be as large as the work that arrives. Give each step a token budget, spend it on decode first — those tokens are what someone is watching — and fill whatever is left with a piece of a pending prefill. A long prompt then arrives over several steps instead of stopping the world once.

chunked.py
        # 1. Decode first. These tokens are what a waiting caller is watching, so they get the
        #    budget before any prefill does.
        decoding = [s for s in self.running if s.stored >= s.prefill_target]
        if decoding:
            outputs += self._decode(decoding)
        # Sequences that finished on this step have already had their blocks released, so they
        # must leave the running set before anything else looks at it. Leaving them in makes a
        # finished sequence look like one that still needs prefilling, against an empty block
        # table.
        self.running = [s for s in self.running if not s.finished]
        budget = max(0, self.token_budget - len(decoding))

        # 2. Continue prefills already in progress, oldest first, so nothing starves.
        pending = [s for s in self.running if s.stored < s.prefill_target]
        for state in pending:
            if budget <= 0:
                break
            before = state.stored
            out = self._prefill_chunk(state, budget)
            spent = state.stored - before
            budget -= spent
            if state.stored < state.prefill_target:
                self.chunked_prefills += 1
            if out is not None:
                outputs.append(out)

The ordering encodes the policy: decode before prefill, prefill-in-progress before new admissions. Reverse any of those and you get a different, defensible engine with different victims.

What it actually trades

The usual description — “chunking smooths out prefill stalls” — is true and incomplete. Three things change at once, and only the first is the advertised one:

  1. The worst-case stall shrinks. No single step contains an entire long prefill.

  2. Typical ITL rises. Every step now carries a prefill chunk, so all tokens get slower rather than a few getting much slower. Chunking converts a rare large delay into a constant moderate one.

  3. The prefilling request’s own TTFT grows, and badly. Its prompt now takes many steps, and it produces nothing until the last of them. Chunking protects everyone except the request being chunked.

Whether that is a good deal depends entirely on how often prefills are in flight. If they are rare, you trade a bad p99 for a slightly worse p50 and win. If nearly every step has a prefill pending, there is no quiet time to spread the cost into, and you simply pay more.

The build

A sequence mid-prefill is not the same as one mid-decode, and distinguishing them is fiddlier than it looks:

request.py
    #: how many tokens must be cached before this sequence can decode (ch10).
    #: Not len(all_token_ids): that grows with every generated token, so comparing against it
    #: makes a decoding sequence look like it is prefilling again.
    prefill_target: int = 0

That comment records a bug. The obvious test for “still prefilling” is stored < len(all_token_ids) — and it is wrong, because all_token_ids grows with every generated token, so a sequence that has decoded once looks like it needs prefilling again. It then takes the prefill path, which never grows the block table, and writes off the end of it.

A second, similar bug: a sequence that finishes during the decode phase has already had its blocks released, so it must leave the running set before the prefill phase looks at it. Otherwise it appears as a sequence needing prefill against an empty block table.

Both are the same mistake in different clothes — using a derived quantity to represent a state that deserves to be explicit.

Finally, carrying the cache between chunks matters more than it appears:

chunked.py
        # Carry the cache forward between chunks rather than re-reading it from the blocks.
        #
        # Re-gathering per chunk makes a chunked prefill quadratic in prompt length — a 1536-token
        # prompt at a 64-token budget would copy the whole cache twenty-four times — and that cost
        # swamps every scheduling benefit chunking is meant to deliver. A production engine avoids
        # it by having attention read the blocks in place; until chapter 13 writes that kernel, an
        # incremental contiguous cache during prefill gets the same asymptotics.

Re-reading a sequence’s cache from its blocks on every chunk makes a chunked prefill quadratic in prompt length. We wrote it that way first, measured, and found chunking catastrophically slow — a result that would have been a libel on the technique rather than a finding about it.

The measurement

First, something genuinely surprising. How long does one long prefill take, split different ways?

PassesTokens per passTotal time
115361303.8 ms
6256451.3 ms
2464378.7 ms

Splitting the prefill makes it nearly twice as fast. Not slower — faster. A single pass over 1536 tokens materialises a 1536×1536 attention matrix per head per layer, which is hostile to every cache in the machine. Several smaller passes, each attending to the accumulated KV cache, do the same arithmetic with far better locality.

So chunking is not merely a scheduling tool. It is also, at this size, a faster way to prefill.

Which makes the serving result harder to explain away:

ConfigurationTTFT p50TTFT p95ITL p50Output tok/sGoodput req/s
Budget 64 tokens/step4.299s9.16s0.0742s88.870.216
Budget 512 tokens/step2.122s4.374s0.0496s143.30.348
Budget 8192 (no chunking)1.986s3.258s0.0356s170.71.245

Conditions: TinyGPT (reference, random weights) (5,838,080 params), 4x x86_64 CPU, torch 2.14.0+cu130, mixed: 35% of prompts 1536 tokens, rest 16-32, output 32-64, seed 3, arrival rate 8.0/s, measured 2026-09-13T15:29:29+00:00.

On this trace, chunking loses on every axis. Goodput falls as the budget shrinks. Median ITL rises. TTFT gets worse, not better.

The trace is why: 35% of these requests carry a 1536-token prompt, so a prefill is almost always in flight. There is no quiet time to spread the work into, so every step carries a chunk, every token pays, and the long requests — a third of the workload — wait many steps for their first token. The mechanism works exactly as designed and the design does not suit the workload.

This is worth sitting with, because it is the first chapter where the technique everyone recommends does not help. The honest conclusion is not “chunked prefill is bad”. It is:

Chunked prefill is a redistribution, not a saving. It moves latency from streaming users to arriving ones. That is a good trade when prefills are occasional and a bad one when they are constant.

At production scale the usual case is the first: prompts of a few thousand tokens arriving into a pool serving hundreds of concurrent conversations, where a single unchunked prefill would stall every one of them for hundreds of milliseconds. Our trace is deliberately the other case, and it shows what happens when the assumption behind a technique does not hold.

The cost

Key takeaways

Looking ahead

Prefill and decode want different things from the hardware, and every chapter so far has made one engine serve both. ch11 · Disaggregating Prefill and Decode asks what happens if they simply stop sharing — separate pools, each scheduled for what it is good at, with the KV cache shipped between them.

Further reading

Sarathi-Serve (Agrawal et al., Appendix E) introduced chunked prefill and the throughput-latency framing used here; its evaluation covers the regime where it clearly wins, which is a useful counterweight to this chapter’s trace.