ML Interview Notes
28 min read10 sections
Part 1 · The core serving loop · 01-05

Chunked prefill

Status
SOURCE PINNED
Primary sources
  • vllm/v1/core/sched/scheduler.py
  • vllm/config/scheduler.py
  • python/sglang/srt/managers/schedule_policy.py
Edition pins
vllm a556f3f · sglang 7d89325

One 32k-token prompt lands in a server that is happily streaming tokens to thirty other users. Every one of them stops receiving tokens for about a second and a half. Chunked prefill is the scheduler surgery that cuts that stall down to roughly one chunk's worth of compute — and the price it charges is paid in HBM traffic, which is why the chunk cannot be made arbitrarily small.

§1

The problem

Take Llama-3-8B on one H100 SXM. Thirty-two requests are decoding, each around 2k tokens of context. A thirty-third request arrives with a 32,768-token prompt — a code file, a long document, a stuffed RAG context. The scheduler admits it. What happens to the thirty-two decoders?

Work the two iteration costs. Symbols follow FORMULAS: $L=32$, $h=32$, $h_{kv}=8$, $d_h=128$, $P = 8.03\times10^{9}$, $b = 2$ bytes (bf16). Two hardware numbers are assumed and stated up front, because everything below scales with them: an achieved $\pi_{\text{eff}} = 500$ TFLOP/s in bf16 (about half of the H100's 989 TFLOP/s dense peak) and an achieved $\beta_{\text{eff}} = 2.6$ TB/s (about 78% of HBM3's 3.35 TB/s) — measure yours in §0.4 and substitute.

Cost of the prefill iteration (derived). Prefill FLOPs for a prompt of $S$ tokens are the weight GEMMs plus causal attention:

$$\text{FLOPs}_{\text{prefill}}(S) \;\approx\; 2PS \;+\; 2\,L\,h\,d_h\,S^{2}$$

The second term is the $\text{FLOPs}_{\text{tok}}$ attention term of FORMULAS summed over positions $1..S$, which halves the $4$ to a $2$. At $S = 32768$: $2PS = 5.26\times10^{14} = 526$ TFLOP, and $2\cdot32\cdot32\cdot128\cdot S^2 = 2.82\times10^{14} = 281$ TFLOP. Total 807 TFLOP, so $t_{\text{prefill}} = 807/500 = \mathbf{1.61\ s}$.

Cost of a decode iteration (derived). One decode step must stream all weights plus every resident KV element once. Per-token KV bytes across all layers are $2 \cdot L \cdot h_{kv} \cdot d_h \cdot b = 2\cdot32\cdot8\cdot128\cdot2 = 131{,}072$ B — exactly 128 KiB per token, a number worth memorising for this model. Weights are $2P = 16.06$ GB; the 32 sequences at 2048 tokens hold $65{,}536$ tokens $\times$ 128 KiB $= 8.59$ GB. Total 24.65 GB, so $t_{\text{decode}} = 24.65/2600 = \mathbf{9.5\ ms}$.

1.61 s
32k prefill, derived
9.5 ms
decode step, B=32, derived
170×
ITL spike on admission

That is the symptom. Every decoding request sees one inter-token gap of 1.61 s instead of 9.5 ms — a 170× spike — and the maximum gap exceeds 50 ms by a factor of 32. Whether p99 breaches depends on the fraction of affected gaps in the measurement window. Nothing is broken and nothing is misconfigured; one long prompt simply owns the GPU for a second and a half. Continuous batching does not help: it removed the wait for a batch to finish, not the cost of a single iteration.

§2

Mental model

Stop treating a prefill as an atomic unit of work. A request in flight is nothing but a pair (num_computed_tokens, num_tokens), and the KV cache is the record of how far along it is: if the cache holds K and V for positions $[0, k)$, the next forward pass can pick up at position $k$ with no state beyond that cache. The scheduler is therefore free to hand the model any number of new positions per iteration. Chunked prefill is the policy of handing it a bounded number.

Figure 1 — the same 32k prefill, unchunked and chunked. Iteration wall-clock is derived arithmetic on the H100 assumptions above. Note what does not happen: the ITL spike is capped, not removed. Ctrl/Cmd + wheel to zoom · drag to pan · double-click to fit

Loading…

Two things in Figure 1 deserve to be uncomfortable. Total prefill wall-clock is essentially unchanged — seventeen chunks summing to the same 807 TFLOP still take 1.61 s, plus about 56 ms for the decode tokens now riding along — because chunking does not change the FLOP count. And ITL does not return to 9.5 ms; it lands at one chunk's compute time, which is not even constant: chunk 17 attends to 32k keys where chunk 1 attended to 2k, so it costs 134 ms against the first chunk's 70 ms. Chunked prefill converts an unbounded latency spike into a bounded one whose size you choose, and that choice is the whole engineering content of this chapter.

§3

First principles

Why splitting a prefill is correct, for free

The non-obvious part is that no compensation is needed. Consider query position $i$ in chunk $j$, which covers positions $[jc, (j{+}1)c)$ for chunk size $c$. Causal attention says $i$ attends only to keys at positions $0 \ldots i$. Positions $< jc$ had their K and V written to the cache by chunks $0 \ldots j{-}1$; positions in $[jc, i]$ are in the current chunk's own K and V. So the softmax denominator for position $i$ ranges over exactly the same key set it would have in a single 32k forward pass. No rescaling, no stitching of partial softmax statistics, no recomputation. The causal mask is what makes it free — a bidirectional encoder cannot be chunked this way, which is why vLLM disables the feature for encoder-decoder models.

vllm/config/scheduler.py:L227-L236 vLLM
    def __post_init__(self, max_model_len: int, is_encoder_decoder: bool) -> None:
        if is_encoder_decoder:
            # Chunked prefill should be disabled for encoder-decoder models.
            self.disable_chunked_mm_input = True
            self.enable_chunked_prefill = False
            self.long_prefill_token_threshold = 0
            logger.info(
                "Encoder-decoder models do not support chunked prefill nor"
                " prefix caching; disabling both."
            )

The one piece of bookkeeping that is required: a non-final chunk's last position is not the end of the prompt, so the token sampled there is garbage. vLLM samples it anyway and discards it, which is cheaper than special-casing the sampler. The two blocks below are the V1 model runner, whose NumPy version of the predicate is the readable one; the V2 runner a dense model actually gets computes the same fact as is_prefilling_np = num_computed_prefill_tokens_np < prefill_len_np (vllm/v1/worker/gpu/model_runner.py:L1089) and carries it through the batch state (§11.4):

vllm/v1/worker/gpu_model_runner.py:L2302-L2311 vLLM
        use_spec_decode = len(scheduler_output.scheduled_spec_decode_tokens) > 0
        if not use_spec_decode:
            # NOTE(woosuk): Due to chunked prefills, the batch may contain
            # partial requests. While we should not sample any token
            # from these partial requests, we do so for simplicity.
            # We will ignore the sampled tokens from the partial requests.
            # TODO: Support prompt logprobs.
            logits_indices = query_start_loc[1:] - 1
            spec_decode_metadata = None
            num_sampled_tokens = np.ones(num_reqs, dtype=np.int32)

The discard is a one-line predicate — partial means sequence length still short of prompt:

vllm/v1/worker/gpu_model_runner.py:L2160-L2165 vLLM
        # Record which requests should not be sampled,
        # so that we could clear the sampled tokens before returning
        self.discard_request_mask.np[:num_reqs] = (
            self.optimistic_seq_lens_cpu[:num_reqs].numpy() < num_tokens_np
        )
        self.discard_request_mask.copy_to_gpu(num_reqs)

The token budget, and why one slot is not one unit of work

Neither engine has a "chunk size" in the primitive sense. Both schedule against a per-iteration token budget: an integer count of query positions the next forward pass may contain. Requests draw from it in priority order until it is empty. Chunked prefill is simply what happens when a prefill request wants more positions than the budget has left — it takes what is left and comes back next iteration.

Figure 2 — one token budget, three iterations. Budget 2048, 32 decoding requests, one 32768-token prefill in flight. Decode tokens are placed first and each costs one slot regardless of how much work it represents. Ctrl/Cmd + wheel to zoom · drag to pan · double-click to fit

Loading…

Here is the asymmetry that causes most tuning confusion. A budget slot is a row of the batched GEMM, and prefill and decode tokens each occupy one row for the same $2P$ FLOPs of weight math. But attention cost is proportional to the number of keys the query attends to: a decode token at 32k context reads 32,768 keys while a fresh prefill token at position 500 reads 500. And a decode-only pass has too few rows to cover the 16.06 GB weight stream, so it is bandwidth-bound and its per-slot cost explodes. Price a few iteration shapes:

Derived — Llama-3-8B on H100, πeff = 500 TFLOP/s, βeff = 2.6 TB/s. Prefill slots cost about the same regardless of chunk size; decode slots cost up to 64× more.
Iteration contentsSlotsDominant termTimeµs / slot
2048-token prefill chunk, fresh prompt204834 TFLOP compute68 ms33
256-token prefill chunk, fresh prompt2564.1 TFLOP compute8.3 ms32
256 decode tokens at 2k context25684.8 GB HBM32.6 ms127
32 decode tokens at 2k context3224.7 GB HBM9.5 ms297
16 decode tokens at 32k context1684.8 GB HBM32.6 ms2038

One budget slot buys 33 µs of prefill or 2 ms of long-context decode. The budget bounds batch width, not iteration duration, and it proxies for duration only when the batch is prefill-dominated. That is why "just lower max_num_batched_tokens until ITL is fine" stops working once resident contexts get long: the decode half of the iteration, which the budget barely constrains, becomes the term that sets ITL.

Why chunk size cannot go to 1

Chunking is FLOP-neutral. It is emphatically not byte-neutral. Split $S$ tokens into $N = S/c$ chunks and two costs appear, both scaling as $1/c$:

$$\text{bytes}_{\text{HBM}}(c) \;=\; \underbrace{\frac{S}{c}\cdot P b_w}_{\text{weights re-streamed}} \;+\; \underbrace{k\,c\,\frac{N(N-1)}{2}}_{\text{KV re-read}} \;\approx\; \frac{S}{c}\Big(Pb_w + \frac{kS}{2}\Big)$$

where $k = 2 L h_{kv} d_h b$ is the per-token KV footprint (128 KiB here). Every chunk is a separate set of kernel launches, so every chunk re-streams the full 16.06 GB of weights; and chunk $j$ must read the K and V that chunks $0 \ldots j{-}1$ wrote, which is $k \cdot jc$ bytes. At 32k the KV term is $kS/2 = 2.15$ GB against a weight term of 16.06 GB, so weight re-streaming dominates; the two cross at $S = 2Pb_w/k \approx 245$k tokens, and at 128k the KV re-read is already 53% of the weight cost.

Derived — 32,768-token Llama-3-8B prefill. "ms / chunk" is the mean chunk, max(compute, HBM bytes / βeff); the last chunk always costs more than the first. FLOPs are constant at 807 TFLOP; only the bytes move.
Chunk size cChunksms / chunkBound byTotal HBMTotal prefillvs. unchunked
81924404compute75 GB1.61 s1.00×
204816101compute293 GB1.61 s1.00×
5126425.2compute1.16 TB1.61 s1.00×
25612812.6compute2.33 TB1.61 s1.00×
1282567.0HBM4.66 TB1.79 s1.11×
3210247.0HBM18.6 TB7.17 s4.45×
1327687.0HBM596 TB229 s142×

The transition between rows 4 and 5 is the rule to remember. A chunk's weight GEMMs have arithmetic intensity $I \approx c$ FLOP/byte — each weight byte is read once and used for $c$ rows — and the roofline ridge point is $I^{*} = \pi/\beta$: $989/3.35 = 295$ on H100 spec sheets, $500/2.6 = 192$ on the achieved numbers assumed here. A chunk stops paying for its own weight stream at $c \approx 200$. Below that you buy lower ITL with pure waste.

Rule

Chunk size must stay above the roofline ridge point of your GPU in your dtype — a few hundred tokens on Hopper, higher on Blackwell. Both engines' smallest shipped default is 2048, an order of magnitude clear of it. Derive your own ridge point in §0.4; measure the chunk-size sweep in Lab 05.

§4

How production systems do it

vLLM: clamp against a shared budget

In vLLM V1 there is no prefill phase. Scheduler.schedule() opens with a comment that is the clearest statement of the design in either codebase:

vllm/v1/core/sched/scheduler.py:L484-L497 vLLM
    def schedule(self, throttle_prefills: bool = False) -> SchedulerOutput:
        self.current_step += 1
        # NOTE(woosuk) on the scheduling algorithm:
        # There's no "decoding phase" nor "prefill phase" in the scheduler.
        # Each request just has the num_computed_tokens and
        # num_tokens_with_spec. num_tokens_with_spec =
        # len(prompt_token_ids) + len(output_token_ids) + len(spec_token_ids).
        # At each step, the scheduler tries to assign tokens to the requests
        # so that each request's num_computed_tokens can catch up its
        # num_tokens_with_spec. This is general enough to cover
        # chunked prefills, prefix caching, speculative decoding,
        # and the "jump decoding" optimization in the future.

The budget is initialised once per call and drawn down by the running queue before the waiting queue is touched — that ordering is what "decode is prioritised" means concretely:

vllm/v1/core/sched/scheduler.py:L503-L507 vLLM
        num_scheduled_tokens: dict[str, int] = {}
        token_budget = self.max_num_scheduled_tokens
        spec = self.vllm_config.speculative_config
        draft_slots = spec.max_num_new_slots_for_drafting if spec is not None else 0
        input_budget = self.scheduler_config.max_num_batched_tokens

And this — five lines in the waiting-queue loop — is chunked prefill. There is no other implementation of it in vLLM:

vllm/v1/core/sched/scheduler.py:L935-L940 vLLM
                    request_token_budget = min(token_budget, input_budget - draft_slots)
                    # Number of tokens to be scheduled.
                    # We use `request.num_tokens` instead of
                    # `request.num_prompt_tokens` to consider the resumed
                    # requests, which have output tokens.
                    num_new_tokens = request.num_tokens - num_computed_tokens
vllm/v1/core/sched/scheduler.py:L959-L975 vLLM
                    threshold = self.scheduler_config.long_prefill_token_threshold
                    if 0 < threshold < num_new_tokens:
                        num_new_tokens = threshold

                    # chunked prefill has to be enabled explicitly to allow
                    # pooling requests to be chunked
                    if (
                        not self.scheduler_config.enable_chunked_prefill
                        and num_new_tokens > request_token_budget
                    ):
                        # If chunked_prefill is disabled,
                        # we can stop the scheduling here.
                        break

                    num_new_tokens = min(num_new_tokens, request_token_budget)
                    assert num_new_tokens > 0

Read the disabled path carefully: with enable_chunked_prefill=False the scheduler does not run a smaller prefill, it breaks out of admission entirely and the request waits for an iteration with a big enough budget — §1's head-of-line block, restored on purpose.

An in-flight prefill returns to the running queue, where the identical clamp appears at vllm/v1/core/sched/scheduler.py:L566-L575. One predicate then marks the request as mid-prefill, which downstream code uses to suppress structured-output advancement and speculative drafting:

vllm/v1/core/sched/scheduler.py:L1400-L1412 vLLM
        num_scheduled_tokens = scheduler_output.num_scheduled_tokens
        for req_id, num_scheduled_token in num_scheduled_tokens.items():
            request = self.requests[req_id]
            request.num_computed_tokens += num_scheduled_token
            request.num_in_flight_tokens += num_scheduled_token
            if self.defer_block_free:
                # Record the in-flight step, to fence deferred block freeing.
                request.last_sched_seq = self.sched_step_seq
            request.is_prefill_chunk = request.num_computed_tokens < (
                request.num_tokens + request.num_output_placeholders
            )

Defaults at a556f3f. enable_chunked_prefill is True in SchedulerConfig (vllm/config/scheduler.py:L74-L76), resolved per model from model_config.is_chunked_prefill_supported (vllm/engine/arg_utils.py:L2678-L2683). The budget default is not the DEFAULT_MAX_NUM_BATCHED_TOKENS = 2048 class constant at vllm/config/scheduler.py:L41-L43, whose own docstring calls it "mainly for convenience when testing". The real default is chosen by device memory and usage context:

vllm/engine/arg_utils.py:L2606-L2628 vLLM
        # NOTE(Kuntai): Setting large `max_num_batched_tokens` for A100 reduces
        # throughput, see PR #17885 for more details.
        # So here we do an extra device name check to prevent such regression.
        if device_memory >= 160 * GiB_bytes:
            # for GPUs like B200/B300 with >= 160GB memory, use the largest defaults
            default_max_num_batched_tokens = {
                UsageContext.LLM_CLASS: 16384,
                UsageContext.OPENAI_API_SERVER: 16384,
            }
            default_max_num_seqs = {
                UsageContext.LLM_CLASS: 1024,
                UsageContext.OPENAI_API_SERVER: 1024,
            }
        elif device_memory >= 70 * GiB_bytes and "a100" not in device_name:
            # For GPUs like H100 and H200, use larger offline defaults.
            default_max_num_batched_tokens = {
                UsageContext.LLM_CLASS: 16384,
                UsageContext.OPENAI_API_SERVER: 8192,
            }

So an H100 serving over the OpenAI-compatible endpoint defaults to a budget of 8192, the same H100 driven from the offline LLM class defaults to 16384, and anything below 70 GB — or any A100, by explicit name check — falls to 2048 for the API server and 8192 offline (vllm/engine/arg_utils.py:L2629-L2639).

long_prefill_token_threshold is a second, separate control. It defaults to 0, meaning disabled (vllm/config/scheduler.py:L70-L72), and it is applied before the budget clamp in both loops. The difference matters: the budget clamp is opportunistic, so a 32k prompt arriving at an idle server with a 16384 budget gets all 16384 tokens and produces a 400 ms iteration. The threshold is absolute — set it to 2048 and no request ever gets more than 2048 prefill positions in one pass, however empty the budget is. It is the knob for "bound my worst-case ITL" as distinct from "bound my batch width".

SGLang: one in-flight chunked request, carried by hand

SGLang reaches the same outcome through a visibly different structure. Its loop picks a batch kind per iteration rather than filling one unified batch:

python/sglang/srt/managers/scheduler.py:L3188-L3196 SGLang
        if new_batch is not None:
            # Run prefill first if possible
            ret = new_batch
        else:
            # Run decode (skip for prefill-only batches)
            if not running_batch.is_empty() and not running_batch.is_prefill_only:
                running_batch = self.update_running_batch(running_batch)
                ret = running_batch if not running_batch.is_empty() else None
            else:

The budget lives in PrefillAdder, which holds two counters: rem_input_tokens (from --max-prefill-tokens, default 16384) and rem_chunk_tokens (from --chunked-prefill-size). None for the latter means chunking is off. Decode tokens that will ride along are subtracted from both up front:

python/sglang/srt/managers/schedule_policy.py:L537-L546 SGLang
        self.rem_input_tokens = rem_input_tokens - num_mixed_decode_tokens
        self.rem_chunk_tokens = rem_chunk_tokens
        self.dllm_config = dllm_config

        if self.dllm_config is not None:
            self._init_dllm_meta(dllm_config)

        if self.rem_chunk_tokens is not None:
            self.rem_chunk_tokens -= num_mixed_decode_tokens
        self.rem_total_token_offset = num_mixed_decode_tokens

The split itself is the else branch of add_one_req. Note the page alignment — SGLang truncates the chunk down to a multiple of page_size in two places, once on the chunk length and once on the resulting absolute prefix length, so that a chunk boundary is always a KV page boundary:

python/sglang/srt/managers/schedule_policy.py:L1386-L1424 SGLang
            else:
                # Make sure at least one page is available
                trunc_len = chunk_tokens_limit // self.page_size * self.page_size

                if trunc_len <= 0:
                    return AddReqResult.OTHER
# ...
                now_input_len = trunc_len + len(req.prefix_indices)
                now_input_len = now_input_len // self.page_size * self.page_size
                trunc_len = now_input_len - len(req.prefix_indices)

                if trunc_len <= 0:
                    return AddReqResult.OTHER
# ...
                # Chunked prefill
                req.set_extend_range(
                    len(req.prefix_indices), len(req.prefix_indices) + trunc_len
                )

                self.can_run_list.append(req)
                self.new_chunked_req = req

And here is the real design difference. SGLang tracks exactly one in-flight chunked request on the scheduler object, and the invariant is enforced by an assertion:

python/sglang/srt/managers/scheduler.py:L3461-L3467 SGLang
        if adder.new_chunked_req is not None:
            # Update chunked prefill
            assert self.chunked_req is None
            self.chunked_req = adder.new_chunked_req

        if self.chunked_req is not None:
            self.chunked_req.inflight_middle_chunks += 1

Next iteration, that request is re-entered explicitly at the head of the adder, before any waiting request is considered:

python/sglang/srt/managers/scheduler.py:L3341-L3343 SGLang
        if self.chunked_req is not None:
            self.chunked_req.init_next_round_input()
            self.chunked_req = adder.add_chunked_req(self.chunked_req)

add_chunked_req returns the request if it is still truncated and None when the last chunk fits, which is how the field clears itself (python/sglang/srt/managers/schedule_policy.py:L1036-L1053).

Figure 3 — two ways to carry a half-finished prefill. vLLM keeps the request in one queue and lets the same clamp fire again; SGLang parks it in a dedicated slot and re-admits it by hand. Ctrl/Cmd + wheel to zoom · drag to pan · double-click to fit

Loading…

Why the difference? A mid-chunk request holds a lock on its radix-cache tree node, and permitting several simultaneous partial prefills would multiply the pinned, half-written subtrees the cache has to reason about; the single-slot invariant keeps that analysable. The cost is that a second long prompt cannot start until the first finishes chunking, so under a burst of long prompts SGLang serialises where vLLM interleaves — and vLLM pays for that flexibility with an _inflight_prefills set and per-request state on every path that cares.

Defaults at 7d89325. chunked_prefill_size is None in ServerArgs (python/sglang/srt/server_args.py:L811-L815) and auto-tuned from GPU memory, together with the decode CUDA-graph batch size, in _handle_gpu_memory_settings:

Cited — python/sglang/srt/server_args.py:L4866-L4925, read at 7d89325.
GPU memoryExample cards, per the commentschunked_prefill_sizedecode max_bs
< 20 GBT4, 408020488
< 35 GBA10, 4090, 5090204824 / 80
< 60 GBA100 40GB, L40409632 / 160
< 90 GBH100, A1008192256 / 512
< 160 GBH20, H2008192256 / 512
≥ 160 GBB200, MI30016384512
unknownfallback4096160

The two split values are tp_size < 4 versus otherwise. The same docstring explains why this is not purely a latency knob: mem_fraction_static — SGLang's single knob for how much device memory the weights and KV pool may claim, owned by §2.6 — is derived from it, since "the activation memory is proportional to the chunked_prefill_size", via reserved_mem = chunked_prefill_size * 1.5 + max_bs * 2 in the heuristic's MiB-scale accounting, not GB. Raising the chunk size on SGLang silently shrinks the KV pool.

Piggybacking: a chunk and a decode in one pass

vLLM mixes unconditionally — the running loop and the waiting loop write into the same num_scheduled_tokens dict, so a chunk and thirty-two decodes become one forward pass with 2048 rows. SGLang gates it behind a flag that is off by default:

python/sglang/srt/server_args.py:L996-L1000 SGLang
    enable_mixed_chunk: A[
        bool,
        "Enabling mixing prefill and decode in a batch when using chunked prefill.",
        NS("schedule"),
    ] = False

When on, ScheduleBatch.mix_with_running sets ForwardMode.MIXED and appends each running request as a one-token extend (python/sglang/srt/managers/schedule_batch.py:L2758-L2788). Price the 32 decodes both ways (derived): standalone, 16.06 GB of weights plus 8.59 GB of KV, i.e. 9.5 ms. Folded into a 2016-token chunk the prefill has already paid for the weight stream, so their marginal cost is 32 extra GEMM rows (about 1.0 ms) plus their own 8.59 GB of KV attention (3.3 ms) — 4.3 ms instead of 9.5 ms, a 2.2× reduction. The saving shrinks as the decode batch grows, because a wide decode batch is compute-bound on its own and no longer needs to borrow the weight read.

§5

Worked trace

One 32,768-token request through vLLM V1 at a556f3f, budget 2048, 32 requests already decoding. Function names in call order.

iter t+1

Admission and first clamp

Scheduler.schedule() sets token_budget = 2048 (L504). The running loop spends 32 slots on decodes. The waiting loop reaches our request, computes request_token_budget = min(2016, ...) (L935), then num_new_tokens = 32768 - 0 (L940), then min(num_new_tokens, 2016) (L974). _inflight_prefills.add(request) fires at L1150 because 0 + 2016 < 32768.

issue

Post-schedule bookkeeping

Scheduler._update_after_schedule() advances num_computed_tokens += 2016 and sets is_prefill_chunk = True (L1400-L1409) before the model runs, so the next schedule() can be issued without waiting for output.

forward

The pass itself

GPUModelRunner.execute_model() builds discard_request_mask = seq_lens < num_tokens (L2162), which is True for our request. logits_indices = query_start_loc[1:] - 1 (L2309) samples a token for it anyway; L3818 collects the discard indices and drops it.

iter t+2..t+17

Return via the running queue

The request is now RUNNING, so the running loop serves it, with the same min at L566-L575. Fifteen more passes of 2016 and one of 512 finish the prompt; on that last one is_prefill_chunk flips to False, the sampled token survives, and TTFT is recorded.

Seventeen iterations, not sixteen, because the decodes take 32 slots off the top of each: $\lceil 32768/2016 \rceil = 17$. That is also why max_num_batched_tokens < max_num_seqs is rejected; equality is allowed — at max_num_seqs = 2048 with a budget of 2048, decodes would take every slot and no prefill would ever advance.

§6

Pitfalls and war stories

startup

Budget below context length, chunking off

Disabling chunked prefill turns the budget into a hard cap on prompt length, so vLLM refuses to start: max_num_batched_tokens (2048) is smaller than max_model_len (32768). This effectively limits the maximum sequence length to max_num_batched_tokens and makes vLLM reject longer sequences.vllm/config/scheduler.py:L248-L261. The fix is almost never to raise the budget; it is to stop disabling chunked prefill.

startup

Starving prefill entirely

max_num_batched_tokens (1024) must be greater than or equal to max_num_seqs (2048).vllm/config/scheduler.py:L263-L268. Guarantees at least one slot per running sequence but equality can still consume the entire budget with decode rows, leaving no prefill headroom.

startup

Chunk not page-aligned

SGLang asserts chunked_prefill_size must be divisible by page_size (python/sglang/srt/server_args.py:L9288-L9291) whenever chunking is on and the node is not a disaggregated decode worker. Set --page-size 64 and --chunked-prefill-size 3000 and you will not get a server.

silent

Chunking silently disabled

SGLang turns it off for multimodal models on the Transformers backend, logging Chunked prefill is disabled for multimodal models with the Transformers backend to avoid partial multimodal chunk mismatches. (python/sglang/srt/managers/scheduler.py:L1170-L1180). Your ITL regression is a log line, not a bug.

tuning

Lowering the budget stops helping

Once resident contexts are long, ITL is set by the decode half of the mixed batch, whose cost the budget barely constrains — see the 2038 µs/slot row above. Cap KV residency or use --long-prefill-token-threshold instead of shrinking the budget further. On SGLang the opposite trap: mem_fraction_static is derived from chunked_prefill_size * 1.5 + max_bs * 2 in MiB-scale reserved-memory accounting, not GB, so doubling the chunk to buy TTFT can cost enough KV pool to trigger preemption.

Two debugging entry points. vLLM logs its budget once at startup: Chunked prefill is enabled with max_num_batched_tokens=%d. (vllm/config/scheduler.py:L241-L246) — if that number is not what you passed, something in _set_default_max_num_seqs_and_batched_tokens_args clamped it, most likely min(max_num_seqs * max_model_len, ...) at vllm/engine/arg_utils.py:L2851-L2856. On SGLang, an in-flight chunk increments inflight_middle_chunks every iteration (python/sglang/srt/managers/scheduler.py:L3466-L3467); a counter that keeps climbing means chunks are being truncated far below the configured size, usually by KV pressure in add_chunked_req.

§7

Hands-on

Pick a chunk size from an ITL SLO, don't guess it. The arithmetic is one line: a chunk of $c$ tokens costs roughly $2Pc/\pi_{\text{eff}}$ once $c$ is above the ridge point, so

$$c \;\approx\; \frac{\pi_{\text{eff}} \cdot \big(\text{ITL}_{\text{target}} - t_{\text{decode}}\big)}{2P}$$

For a 60 ms p99 ITL target on Llama-3-8B/H100 with $t_{\text{decode}} = 9.5$ ms: $c \approx 500\times10^{12}\times0.0505 / 1.606\times10^{10} = 1572$, so 1536 after page alignment — which is why 2048 feels slightly coarse for a chat SLO and 512 leaves throughput on the table. This is a lower bound on chunk cost: it counts only the weight GEMMs, and the attention term adds $4Lh d_h c \cdot s_{\text{prefix}}/\pi_{\text{eff}}$ on top, which is what makes the last chunk of a long prompt roughly twice the first. Budget for the most expensive full chunk, not the average; a shorter remainder can be cheaper than the preceding full chunk.

flip one flag at a time and diff the ITL histogram shell
# vLLM: bound the batch width, then bound the worst-case chunk independently
vllm serve meta-llama/Llama-3.1-8B-Instruct \
  --max-model-len 65536 --max-num-seqs 64 \
  --max-num-batched-tokens 2048 \
  --long-prefill-token-threshold 1536

# vLLM: the pathological baseline this chapter opened on
vllm serve meta-llama/Llama-3.1-8B-Instruct \
  --max-model-len 65536 --no-enable-chunked-prefill \
  --max-num-batched-tokens 65536

# SGLang: chunk size and the mixed batch are separate decisions
python -m sglang.launch_server --model-path meta-llama/Llama-3.1-8B-Instruct \
  --context-length 65536 --chunked-prefill-size 2048 --enable-mixed-chunk

# SGLang: chunking off
python -m sglang.launch_server --model-path meta-llama/Llama-3.1-8B-Instruct \
  --context-length 65536 --chunked-prefill-size -1

The workload that exposes the effect is one long prompt injected into a stream of short ones — exactly what Lab 05 runs. Watch p99 ITL, p99 TTFT, and output tokens/s together; any two can be made to look good by sacrificing the third. Methodology (open-loop arrivals, warmup, goodput rather than throughput) is §10.3.

Provenance

Every latency and byte figure in this chapter is derived — arithmetic on published model shapes and the two stated hardware assumptions. None of it was measured; the author has no GPU. Lab 05 is where these become measurements, and expect the derived chunk times to be optimistic by whatever your kernel-launch and scheduling overhead turns out to be.

§8

Exercises

  1. Read the source. In vllm/v1/core/sched/scheduler.py, the same clamp appears around L566 and around L974. One of them handles a request that has already been partially prefilled and one handles a fresh arrival. Which is which, and what would break if the running-queue clamp were removed?
  2. Predict, then verify. You run vLLM on an A100 80GB via the OpenAI server with no --max-num-batched-tokens. Predict the budget it will pick. Then read vllm/engine/arg_utils.py:L2606-L2639 and check.
  3. Do the arithmetic. Llama-3-70B is $L=80$, $h_{kv}=8$, $d_h=128$, $P = 70.6\times10^{9}$. Compute its per-token KV bytes and the total HBM traffic of a 32k prefill chunked at $c = 2048$ on TP=8. Which term dominates, and does the answer change at 128k context?
  4. Predict, then verify. On SGLang, set --chunked-prefill-size 2048 and send two 32k prompts simultaneously. Predict how many iterations pass before the second one begins prefilling, given the assert self.chunked_req is None at python/sglang/srt/managers/scheduler.py:L3463. Now predict the same for vLLM.
  5. Design. Your SLO is p99 ITL ≤ 25 ms and p99 TTFT ≤ 4 s for prompts up to 128k on Llama-3-8B/H100. Derive a chunk size, check it against the ridge point, then compute the resulting TTFT for a 128k prompt. Is the SLO satisfiable on one GPU? If not, name the chapter that fixes it.
Answers

1. L566 is the running-queue loop, serving requests already in RequestStatus.RUNNING — decodes and in-flight prefill chunks alike, since V1 does not distinguish them. L974 is the waiting-queue loop for fresh admissions. Remove the running-queue clamp and a resumed prefill asks for all its remaining tokens at once, blowing assert total_num_scheduled_tokens <= self.max_num_scheduled_tokens at L1179: chunking would apply only to each request's first chunk.

2. 2048. The A100 has 80 GB so it clears the >= 70 * GiB_bytes test, but the branch also requires "a100" not in device_name, added because larger budgets regressed A100 throughput (PR #17885, named in the comment). It falls to the else branch: 8192 offline, 2048 for the API server.

3. $k = 2\cdot80\cdot8\cdot128\cdot2 = 327{,}680$ B = 320 KiB/token. On TP=8 each rank streams 17.6 GB of weights and holds one KV head, i.e. 40 KiB/token. Per rank at 32k with $N = 16$: weights re-streamed $= 283$ GB, KV re-read $= 40\text{KiB} \times 2048 \times 120 = 10.1$ GB — weights dominate by 28×. At 128k, $N = 64$: weights 1.13 TB, KV re-read 169 GB, a ratio of 6.7×. The ratio keeps falling because the KV term is quadratic in $S$ and the weight term only linear.

4. SGLang: prompt two cannot start until prompt one finishes, because self.chunked_req holds at most one request and the assertion enforces it — 16 iterations of head-of-line blocking on its TTFT. vLLM: both sit in the running queue and the clamp fires per request in queue order, so with a small budget prompt one still consumes it each iteration; but that is a consequence of the budget, not an invariant, and a larger budget lets the two genuinely interleave.

5. $c = 500\times10^{12}\times(0.025 - 0.0095)/1.606\times10^{10} = 483$, round down to 480 for 16-token page alignment (or 256 if restricted to power-of-two chunks); rounding up to 512 violates this compute-only ceiling. This remains above the 192–295 ridge point, so no waste. But a 128k prefill is $2PS + 2Lhd_hS^2 = 6.6$ PFLOP, i.e. 13.2 s at 500 TFLOP/s: more than 3× over the 4 s TTFT budget, and chunk size cannot help because FLOPs are invariant. One GPU cannot satisfy it. The fixes are tensor parallelism (§5.1), prefix caching if the 128k is shared (§2.3), or moving prefill to its own machines — §1.6.

§9

Key takeaways

  • A partially prefilled sequence is not a special state — it is a sequence whose KV cache covers $[0,k)$, and causal masking makes chunk $j$'s softmax denominator identical to the unchunked one. The only bookkeeping is discard_request_mask = seq_lens < num_tokens.
  • Chunking bounds the ITL spike; it does not remove it. Here ITL goes from 1610 ms to 70–134 ms, not back to 9.5 ms — and it is not flat, because a late chunk attends to far more keys than an early one. The floor is one chunk of compute plus the decode half of the batch.
  • The chunk is not a parameter — it is the residue of a token budget shared with decode. Both engines subtract decode tokens first, and a decode slot can cost 60× a prefill slot in wall-clock, which is why the budget is a poor proxy for iteration duration at long context.
  • Shrinking the chunk costs HBM traffic linear in $1/c$: every chunk re-streams all weights and re-reads the KV earlier chunks wrote. The floor is the ridge point $\pi/\beta$ — below roughly 200 rows on H100 a chunk cannot pay for its own weight stream, and $c = 1$ turns 1.6 s into 229 s.
  • vLLM clamps every request against one shared budget in one unified batch, allowing several partial prefills at once; SGLang carries exactly one asserted chunked_req and picks a batch kind per iteration, which keeps radix-cache locking analysable at the cost of serialising concurrent long prompts.
  • Defaults move: at a556f3f vLLM's budget is 8192 on an H100 API server and 2048 on an A100 by explicit name check; at 7d89325 SGLang auto-tunes chunked_prefill_size from GPU memory (2048–16384) and leaves --enable-mixed-chunk off.
§10

Further reading

  • SARATHI: Efficient LLM Inference by Piggybacking Decodes with Chunked Prefills — the original chunk-plus-piggyback formulation. Linked directly from vLLM's own tuning guide at docs/configuration/optimization.md:L80.
  • Taming Throughput-Latency Tradeoff in LLM Inference with Sarathi-Serve (OSDI '24) — the stall-free-batching scheduler and the ITL/throughput curves this chapter's trade-off section restates. Also linked from the same vLLM doc line.
  • docs/configuration/optimization.md:L49-L81 in the vLLM tree — the maintainers' own tuning advice, including "smaller values (e.g. 2048) achieve better ITL" and "for optimal throughput, set max_num_batched_tokens > 8192".
  • vLLM PR #17885, named in the comment at vllm/engine/arg_utils.py:L2606-L2608, is the source of the A100-specific default. Worth reading for how a "bigger is better" default was found to regress one card.
  • Next: §1.6 takes the same problem to its conclusion — if prefill and decode want different schedulers, give them different machines. Chunked prefill is the single-node answer; disaggregation is the multi-node one, and they are not mutually exclusive.

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