Chunked prefill and ITL jitter
Measure inter-token-latency spikes with and without chunked prefill under a mixed long/short workload.
A single long prompt arriving at a busy server freezes every request already decoding on it. Chunked prefill bounds that freeze; it does not remove it. This lab measures the freeze, twice — once with chunking on and once with it off — and the number that matters is not an average of anything.
One GPU that can hold a 7–8B model at a long context: 24 GB serves Llama-3-8B at
--max-model-len 32768 with room for the batch. On a smaller card, halve
--long-input-len and --max-model-len together — the effect
survives, weaker. Run the two configurations one at a time on the same card; two engines
sharing a GPU measure contention, not chunking. If you have no GPU, read this page against
§1.5, whose 1.61 s
unchunked stall and 70–134 ms chunked stall are derived arithmetic on
published H100 peaks, not measurements — and expect real ITL to be worse than both.
run.py was written against the API fields, server flags and defaults cited below,
and its argument handling was exercised, but it has not been run against a live engine
— no GPU was available while writing. There is no measured figure anywhere on this page.
Everything the script prints is yours; everything quoted here is source at the pinned SHAs.
What you measure
The workload is one long prompt injected into a stream of short ones, and the instrument is a timestamp on every token arrival:
- ITL percentiles across the whole run — p50, p90, p99, max.
- The same percentiles in three windows: before the injection (a clean baseline), during the long prefill, and after it completes.
- The worst single gap, and how many seconds it sat from the injection. That is the headline.
- The long request's TTFT, which is what chunking costs you and which should barely move.
Percentiles alone would hide the effect. A 32k prefill blocking a batch of sixteen decoders produces roughly one enormous gap per decoder, which is a handful of samples out of thousands — it does not reach p99, and it is exactly the thing your users complain about. Hence the max, and hence the timestamps: a spike whose offset from the injection matches the long prompt's TTFT is the long prefill; a spike anywhere else is something else, and you should find out what.
| Quantity | Chunking off | Chunking on | Why |
|---|---|---|---|
| Worst ITL gap | one whole prefill | one chunk plus the decode half of that iteration | The scheduler hands the model a bounded number of new positions per iteration. |
| Long-prompt TTFT | roughly the same | roughly the same, slightly worse | Chunking is FLOP-neutral. It changes how many iterations the prefill spans, not how much arithmetic it contains. |
| Aggregate tok/s | higher during the stall, lower overall | steadier | Weights are re-streamed once per chunk, so small chunks add HBM traffic (§1.5). |
Three ways to measure nothing
Each of these silently produces a plausible, wrong answer.
1. The server bundles tokens into one SSE frame. ITL is measured client-side, one timestamp per frame. If a frame carries four tokens, every gap you record is a four-token gap and the spike is smeared. SGLang exposes this directly and defaults it to 1 — leave it there:
stream_interval: A[
int,
"The interval (or buffer size) for streaming in terms of the token length. A smaller value makes streaming smoother, while a larger value makes the throughput higher",
NS("serving"),
] = 1
2. The prompt is served from the prefix cache. run.py builds every prompt
from distinct pseudo-random integers so nothing is shared; a repeated long prompt would prefill
once and then cost nothing, and you would conclude chunking is unnecessary. Prefix caching is
lab 04's subject.
3. Responses stop early. Both engines accept ignore_eos on their
OpenAI-compatible routes as an extension field, and the script sets it, so every short request
generates exactly --short-output-len tokens and the decode load is constant across
the injection:
stop_token_ids: list[int] | None = []
include_stop_str_in_output: bool = False
ignore_eos: bool = False
min_tokens: int = 0
skip_special_tokens: bool = True
repetition_penalty: float = 1.0
stop_token_ids: Optional[List[int]] = None
stop_regex: Optional[Union[str, List[str]]] = None
no_stop_trim: bool = False
ignore_eos: bool = False
skip_special_tokens: bool = True
Running it
Two servers, one at a time, then a comparison.
# A. chunked prefill on (the default), batch width bounded at 2048
$ vllm serve meta-llama/Llama-3.1-8B-Instruct \
--max-model-len 32768 --max-num-batched-tokens 2048 --port 8000
$ python3 run.py --port 8000 --label chunked --json chunked.json
# B. off — the pathological baseline. The budget must now cover the whole prompt.
$ vllm serve meta-llama/Llama-3.1-8B-Instruct \
--max-model-len 32768 --no-enable-chunked-prefill \
--max-num-batched-tokens 32768 --port 8000
$ python3 run.py --port 8000 --label unchunked --json unchunked.json
$ python3 run.py --compare chunked.json unchunked.json
Note what B forces you to do. With chunking off, the token budget becomes a hard cap on prompt length, and vLLM refuses to start if it is smaller than the context window:
if (
self.max_num_batched_tokens < max_model_len
and not self.enable_chunked_prefill
):
raise ValueError(
f"max_num_batched_tokens ({self.max_num_batched_tokens}) is "
f"smaller than max_model_len ({max_model_len}). "
"This effectively limits the maximum sequence length to "
"max_num_batched_tokens and makes vLLM reject longer "
"sequences. Please increase max_num_batched_tokens or "
"decrease max_model_len."
)
That error is the whole chapter in one exception: without chunking, the widest iteration you are willing to run is also the longest prompt you can accept. Confirm which mode you actually got from the startup log — the line is emitted once and carries the resolved budget, not the one you passed:
if self.enable_chunked_prefill:
logger.info_once(
"Chunked prefill is enabled with max_num_batched_tokens=%d.",
self.max_num_batched_tokens,
)
The third and fourth runs: SGLang, and the two knobs it separates
$ python3 -m sglang.launch_server --model-path meta-llama/Llama-3.1-8B-Instruct \
--context-length 32768 --chunked-prefill-size 2048 --port 30000
$ python3 run.py --port 30000 --label sgl-2048 --json sgl-2048.json
# chunking off
$ python3 -m sglang.launch_server --model-path meta-llama/Llama-3.1-8B-Instruct \
--context-length 32768 --chunked-prefill-size -1 --port 30000
$ python3 run.py --port 30000 --label sgl-off --json sgl-off.json
# and with decodes riding along inside the chunk's iteration
$ python3 -m sglang.launch_server --model-path meta-llama/Llama-3.1-8B-Instruct \
--context-length 32768 --chunked-prefill-size 2048 --enable-mixed-chunk --port 30000
chunked_prefill_size: A[
Optional[int],
"The maximum number of tokens in a chunk for the chunked prefill. Setting this to -1 means disabling chunked prefill.",
NS("schedule"),
] = None
enable_mixed_chunk: A[
bool,
"Enabling mixing prefill and decode in a batch when using chunked prefill.",
NS("schedule"),
] = False
This is a real difference from vLLM and it shows up in this exact measurement. vLLM mixes prefill
and decode tokens in one flat batch unconditionally — its running and waiting loops write
into the same per-request token dict, traced in
§1.5. SGLang gates the same
behaviour behind --enable-mixed-chunk, off by default. With it off, an iteration is either a chunk or a
decode step, so the short requests still stall for each chunk's duration — bounded, but not
overlapped. Turning it on should reduce the residual ITL elevation during the prefill window
without changing the worst gap much.
SGLang asserts that the chunk size is divisible by the KV page size, so
--chunked-prefill-size 3000 with --page-size 64 is not a slow server,
it is no server:
# Skip validation if chunked prefill is disabled (i.e., size <= 0).
# Skip validation if disaggregation mode is decode.
if self.chunked_prefill_size > 0 and self.disaggregation_mode != "decode":
assert (
self.chunked_prefill_size % self.page_size == 0
), "chunked_prefill_size must be divisible by page_size"
Predict first
- The unchunked worst gap. Compute it: a 32k prefill on Llama-3-8B is about 807 TFLOP
(§1.5 derives it), so divide
by the
πyou measured in lab 01. - The chunked worst gap, from the same arithmetic applied to one chunk plus the decode half of that iteration. Remember the last chunk attends to 32k keys where the first attends to 2k, so budget for the last one.
- The ratio of the two long-prompt TTFTs. This one should be close to 1, and if your prediction is not, re-read why chunking is FLOP-neutral.
What to expect
Chunking should move the worst gap by orders of magnitude and the long prompt's TTFT hardly at all. If either of those fails, something specific is wrong, and the failures are enumerable:
| Symptom | Likely cause | Check |
|---|---|---|
| Worst gap barely differs between the two runs | Chunking was never on, or never off. Both are easy to get wrong: vLLM's budget default is chosen from device memory and usage context rather than from the class constant, and an A100 is singled out by name — the resolution is traced in §1.5. | Grep the startup log for Chunked prefill is enabled with
max_num_batched_tokens= and read the number. |
| Worst gap improved but TTFT got much worse | Chunk below the ridge point. Each chunk re-streams the full weight set for too few
tokens, so total HBM traffic scales as 1/c. |
Compare your chunk size against the I* you measured in lab 01 —
a few hundred tokens on Hopper. |
| Spike appears in the wrong place | Something other than the long prefill: a CUDA-graph capture, a preemption, or another tenant on the card. | The JSON records every gap with its offset from injection. Plot it. |
| ITL is quantised into a few discrete values | Tokens are being bundled per SSE frame. | --stream-interval 1 on SGLang; on vLLM, check nothing between you and the
server is buffering. |
| Two injected long prompts serialise on SGLang but interleave on vLLM | Working as designed, and worth seeing. | The invariant is asserted, below. |
| No spike at all, either way | The long prompt is not long enough relative to a decode step, or it hit the prefix cache. | Raise --long-input-len; confirm the prefix-cache hit rate in the server
log is near zero. |
The last-but-one row is the most instructive experiment in this lab. Run it with
--long-count 2 against both engines. SGLang tracks exactly one in-flight chunked
request, and enforces it:
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
so the second long prompt's TTFT should be roughly double the first's — head-of-line blocking, by design, to keep radix-cache locking analysable. vLLM has no such invariant: both sit in the running queue and the same clamp fires per request, so with a large enough budget they genuinely interleave and both TTFTs land in between. Neither behaviour is better; they price the same tradeoff differently, and this is the measurement that shows it.
--long-prefill-token-threshold is separate from the budget and defaults to
disabled. The budget clamp is opportunistic — a long prompt arriving at an idle server with
a 16384 budget takes all 16384 and produces one very long iteration. The threshold is absolute.
It is the knob for "bound my worst-case ITL" as distinct from "bound my batch width", and it is
the one to reach for when this lab's worst gap is still too big at a budget you cannot afford to
lower.
long_prefill_token_threshold: int = Field(default=0, ge=0)
"""For chunked prefill, a request is considered long if the prompt is
longer than this number of tokens. 0 disables the cap (default)."""
Exercises
- Run both vLLM configurations and report the worst-gap ratio and the long-TTFT ratio. Are they the two numbers the chapter predicts — a large ratio and a ratio near 1?
- Sweep
--max-num-batched-tokensover 512, 1024, 2048, 8192 with chunking on, and plot worst gap against chunk size. Predict the shape first. Where does it stop improving, and what happens to aggregate tok/s below that point? - Do the arithmetic, then verify. Using your measured
πfrom lab 01 andP = 8.03 × 10^9, derive the chunk size that would give you a 40 ms worst-case ITL, check it against your measured ridge point, and then run it. How far off is the measurement, and in which direction? - Predict, then verify. Run with
--long-count 2against SGLang at--chunked-prefill-size 2048, and predict the ratio of the second long prompt's TTFT to the first's before looking. Then do the same against vLLM and explain the difference from the code, not from the docs. - Read the source. Turn on
--enable-mixed-chunkon SGLang and re-run. Which of the reported numbers changes most — worst gap, ITL p50 during the prefill window, or long-prompt TTFT? Explain the answer from what mixing does to a single iteration.
Answers
- Not answerable here — nothing was run. The expected shape, derived in §1.5 on H100 spec-sheet numbers, is a worst gap of about 1.61 s unchunked against 70–134 ms chunked at 2048, with the long prompt's TTFT essentially unchanged because the FLOP count did not change. Your real numbers will be worse than both; the ratio is what should survive.
- Roughly linear improvement in worst gap as chunk size falls, until the chunk drops below
the roofline ridge point, at which point the gap stops improving and aggregate throughput
starts collapsing. Below the ridge each chunk cannot amortise its own weight stream: total HBM
traffic grows as
1/c, and §1.5 puts the crossover atc ≈ 200on achieved H100 numbers. That is why both engines' smallest shipped default is 2048. - Derived:
c ≈ π(ITL_target − t_decode) / 2P. Withπ = 500TFLOP/s achieved and a 9.5 ms decode step, a 40 ms target givesc ≈ 500e12 × 0.0305 / 1.606e10 ≈ 950, so 1024 after page alignment. Expect the measurement to come in above the target: that formula counts only the weight GEMMs, and the attention term adds4 L h d_h c · s_prefix / πon top, which roughly doubles the cost of the last chunk relative to the first. Budget for the last chunk, not the average. - SGLang: close to 2×, because
self.chunked_reqholds at most one request and the assertion at L3463 enforces it — the second prompt cannot begin until the first finishes chunking. vLLM: substantially less than 2×, because both requests sit in the running queue and the per-request clamp fires in queue order; with a large budget they interleave. The caveat worth stating in your write-up is that vLLM's interleaving is a consequence of budget size, not an invariant, so a small budget can make it look like SGLang. - ITL p50 during the prefill window should improve most; the worst gap should barely move. Mixing lets decode tokens ride inside the chunk's iteration, so they stop waiting for a whole separate decode step and their marginal cost is a few extra GEMM rows plus their own KV reads — the prefill already paid for the weight stream. It does not shrink the chunk, so the ceiling on a single iteration, and therefore the worst gap, is unchanged.
Key takeaways
- The result of this lab is a maximum, not a percentile. One long prefill produces a handful of enormous gaps in thousands of samples; p99 will not see them and your users will.
- Chunking bounds the spike at one chunk's cost. It does not restore the quiet-window ITL, and it does not reduce the prefill's total work — long-prompt TTFT should barely move.
- Chunk size has a floor set by the roofline ridge point. Below it you buy latency with pure wasted bandwidth, and past a certain point you buy nothing at all.
- Timestamp every token and keep the offsets. A spike in the wrong place is a different bug, and only the offsets can tell you that.
- Two concurrent long prompts separate the two designs cleanly: SGLang serialises them by an asserted single-slot invariant, vLLM interleaves them as a side effect of budget size. Measure it rather than reading about it.