ML Interview Notes
14 min read5 sections
Lab 05 · for chapter 01-05

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.

Hardware

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.

Not executed here

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.

§1

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.

The two things chunking trades — both are reported, so you can see the price
QuantityChunking offChunking onWhy
Worst ITL gapone whole prefillone chunk plus the decode half of that iteration The scheduler hands the model a bounded number of new positions per iteration.
Long-prompt TTFTroughly the sameroughly the same, slightly worse Chunking is FLOP-neutral. It changes how many iterations the prefill spans, not how much arithmetic it contains.
Aggregate tok/shigher during the stall, lower overallsteadier 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:

python/sglang/srt/server_args.py:L1480-L1484 SGLang
    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:

vllm/entrypoints/openai/completion/protocol.py:L79-L83 vLLM
    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
python/sglang/srt/entrypoints/openai/protocol.py:L366-L371 SGLang
    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
§2

Running it

Two servers, one at a time, then a comparison.

shell — vLLM, chunking on then off shell
# 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:

vllm/config/scheduler.py:L250-L261 vLLM
        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:

vllm/config/scheduler.py:L241-L246 vLLM
        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

shell — SGLang, chunk size and mixed batching are independent shell
$ 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
python/sglang/srt/server_args.py:L811-L815 SGLang
    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
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

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.

Startup will refuse this

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:

python/sglang/srt/server_args.py:L9286-L9291 SGLang
        # 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

  1. 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.
  2. 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.
  3. 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.
§3

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:

Diagnosing a result that does not look like the chapter
SymptomLikely causeCheck
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:

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

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.

The other knob

--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.

vllm/config/scheduler.py:L70-L72 vLLM
    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)."""
§4

Exercises

  1. 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?
  2. Sweep --max-num-batched-tokens over 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?
  3. Do the arithmetic, then verify. Using your measured π from lab 01 and P = 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?
  4. Predict, then verify. Run with --long-count 2 against 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.
  5. Read the source. Turn on --enable-mixed-chunk on 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
  1. 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.
  2. 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 at c ≈ 200 on achieved H100 numbers. That is why both engines' smallest shipped default is 2048.
  3. Derived: c ≈ π(ITL_target − t_decode) / 2P. With π = 500 TFLOP/s achieved and a 9.5 ms decode step, a 40 ms target gives c ≈ 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 adds 4 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.
  4. SGLang: close to 2×, because self.chunked_req holds 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.
  5. 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.
§5

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.

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