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

Prefill and decode are two different computers

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

A stack with a 9 ms median inter-token latency and a 2.1 s p99 does not have a latency problem. It has two different programs sharing one GPU, and no policy for which of them wins the next microsecond. This chapter is the physics of that conflict; the rest of Part 1 is the policy.

§1

The problem

Consider the Llama-3-8B tensor shapes on an H100, but use Llama-3.1-8B for the runnable 32k-context experiment: original Llama-3 has an 8k native limit. Assume at least 33 active-request slots. Thirty-two streams are decoding when a 32,768-token prompt arrives; an unchunked prefill can create a roughly two-second stall under the illustrative arithmetic model below.

Total throughput can increase while a long inter-token stall appears. Whether that stall changes p99 depends on how many gaps are affected and the measurement window; it is not automatically a 222-fold p99 increase.

2041 ms
one 32k prefill, Llama-3-8B (derived)
9.2 ms
one decode step, B=32, s=2048 (derived)
222×
ratio of the two

The two numbers above are not two points on a spectrum. They come from two workloads with opposite bottlenecks, opposite ideal batch shapes, and opposite latency SLOs, and they are multiplexed onto the same silicon by the same scheduler loop. Once you see that split, every design decision in both vLLM and SGLang — chunking, budgets, preemption, disaggregation — reads as an answer to the same question: who gets this step?

Scope

The metrics named here (TTFT, ITL, TPOT) are defined precisely in §1.2. The roofline and arithmetic intensity are established in §0.4 and used here without re-derivation.

§2

Mental model

A transformer forward pass is the same sequence of operators regardless of phase. What changes is one number: how many token rows go in. Prefill hands the block a matrix with $T$ rows — 2048, 8192, 32768 — and every weight matrix is used $T$ times per byte fetched. Decode hands the same block a matrix with one row per sequence — 32 rows for 32 sequences — and every weight matrix is used once or twice per byte fetched. The linear layers do not change; they change regime. A GEMM with $T=2048$ is a tensor-core problem. A GEMM with $T=1$ is a memory controller problem with a tensor core attached to it for decoration.

The attention operator is the exception and it moves the opposite way. In prefill, attention is a dense $T \times T$ causal score matrix — quadratic FLOPs, small state. In decode, attention is a single query row against the entire accumulated KV cache — linear FLOPs, and a read of every byte of cache the sequence owns. Prefill's attention grows the compute; decode's attention grows the traffic.

Figure 1 — one transformer block, two shapes. Llama-3-8B (L=32, d=4096, h=32, h_kv=8, d_h=128, FFN=14336). Same weights, same kernels, opposite regime. Ctrl/Cmd + wheel to zoom · drag to pan · double-click to fit

Loading…
§3

First principles: intensity is the batch size

Take a single weight GEMM inside the block: activations $X$ of shape $[T, d]$ times weights $W$ of shape $[d, d']$, in bf16. Symbols as in FORMULAS: $T$ is the number of token rows in this forward pass, $d$ the hidden size, $d'$ the output width, and $b=2$ bytes per element.

$$ \text{FLOPs} = 2\,T\,d\,d' \qquad \text{bytes}_{\text{HBM}} = b\,d\,d' \;+\; b\,T\,d \;+\; b\,T\,d' $$

The first byte term is the weight matrix, read once. The other two are the input and output activations. Arithmetic intensity, $I = \text{FLOPs} / \text{bytes}$:

$$ I \;=\; \frac{T\,d\,d'}{d\,d' + T(d + d')} \;\xrightarrow[\;T \ll d\;]{}\; T $$

The arithmetic intensity of a weight GEMM is, to first order, the number of tokens in the batch. That single line explains the entire chapter. Prefill has $T$ in the thousands. Decode has $T$ equal to the number of concurrent sequences, which is bounded by how much KV cache fits in VRAM (§2.1).

Now place both on the H100 roofline from §0.4. Using the NVIDIA SXM5 spec figures $\pi = 989$ TFLOP/s dense bf16 and $\beta = 3.35$ TB/s, the ridge point is $I^\star = \pi/\beta = 295$ FLOP/byte. Setting $I = I^\star$ with $d = d' = 4096$ and solving for $T$:

T = 345
tokens per step at which a 4096×4096 bf16 GEMM crosses the H100 ridge (derived)
295
FLOP/byte ridge point, H100 bf16 (derived from spec sheet)

Below 345 tokens in flight you are on the bandwidth roof and the only levers that help are moving fewer bytes — quantization, MLA, KV compression. Above it you are on the compute roof and the only levers are more FLOP/s — better kernels, lower-precision tensor cores, more SMs. Prefill lives thousands of tokens above the ridge. Decode at any batch size a single H100 can hold lives below it.

Working the numbers for Llama-3-8B

Model FLOPs per token, from FORMULAS, are $2P + 4\,L\,h\,d_h\,s$ with $P = 8.03 \times 10^9$. KV bytes per token are $2 \, L \, h_{kv} \, d_h \, b = 2 \cdot 32 \cdot 8 \cdot 128 \cdot 2 = 131{,}072$ bytes, i.e. 128 KiB per token across all 32 layers. Weights in bf16 are 16.06 GB.

Derived — arithmetic from Llama-3-8B shapes and H100 SXM5 spec figures. Prefill assumes 40% of the 989 TFLOP/s dense bf16 peak; decode assumes 80% of the 3.35 TB/s HBM peak. Nothing here was measured.
StepFLOPsHBM bytesI (FLOP/byte)RoofStep time
Prefill, T=204834.0 T16.3 GB2082compute86 ms
Prefill, T=8192149 T17.1 GB8705compute377 ms
Prefill, T=32768808 T20.4 GB39682compute2041 ms
Decode, B=1, s=204817.1 G16.3 GB1.05bandwidth6.1 ms
Decode, B=32, s=20480.55 T24.7 GB22.2bandwidth9.2 ms

Read the last row against the roofline. Achievable rate is $\min(\pi, \beta I) = 3.35 \times 10^{12} \times 22.2 = 74$ TFLOP/s — 7.5% of the card's bf16 peak. That is not a kernel bug. It is what the roofline says a batch-32 decode step is allowed to reach on this hardware. The tensor cores are idle by construction, and no amount of kernel tuning changes it; only raising $T$ (bigger batch, or speculative decoding, which is §6.2) or lowering bytes does.

Read the first row the same way: $I = 2082$ is seven times past the ridge, so prefill is pinned to the compute roof and the useful lever is MFU, not bandwidth. Note also that at $T = 2048$ the weight GEMMs contribute 32.9 of the 34.0 TFLOP and attention only 1.1; by $T = 32768$ attention has grown to 282 of 808 TFLOP, because the causal score matrix is quadratic. Long-prompt prefill is not just "more prefill" — its cost curve bends upward.

Why they fight

bottleneck

Opposite roofs

Prefill is compute-bound at I ≈ 2000; decode is bandwidth-bound at I ≈ 20. An optimisation that helps one is usually irrelevant to the other.

batch shape

Opposite widths

Prefill saturates the GPU with one request. Decode needs dozens of requests batched together to reach even 7% of peak. They want incompatible batch compositions.

SLO

Opposite clocks

Prefill latency is TTFT, paid once. Decode latency is ITL, paid on every token. A 2 s prefill is a tolerable TTFT and a catastrophic ITL.

And they share one GPU, one CUDA stream, one set of weights. The scheduler cannot run them concurrently on separate resources; on a single device it can only interleave them in time, or merge them into one forward pass. Those are the only two moves, and both engines make both.

§4

How production systems name the phases

The most instructive difference between the two engines is not an algorithm. It is that one of them has a first-class name for the phase and the other has deliberately deleted it.

SGLang: the phase is an enum

SGLang carries a ForwardMode on every batch, all the way from the scheduler into the attention backend. It is the cleanest statement of the phase split in either codebase.

python/sglang/srt/model_executor/forward_batch_info.py:L100-L124 SGLang
class ForwardMode(IntEnum):
    # Extend a sequence. The KV cache of the beginning part of the sequence is already computed (e.g., system prompt).
    # It is also called "prefill" in common terminology.
    EXTEND = auto()
    # Decode one token.
    DECODE = auto()
    # Contains both EXTEND and DECODE when doing chunked prefill.
    MIXED = auto()
    # No sequence to forward. For data parallel attention, some workers will be IDLE if no sequence are allocated.
    IDLE = auto()

    # Used in speculative decoding: verify a batch in the target model.
    TARGET_VERIFY = auto()
    # Used in speculative decoding: extend a batch in the draft model.
    DRAFT_EXTEND_V2 = auto()

    # Used in disaggregated decode worker
    # Represent a batch of requests having their KV cache ready to start decoding
    PREBUILT = auto()

    # Split Prefill for PD multiplexing
    SPLIT_PREFILL = auto()

    # Used in dLLM
    DLLM_EXTEND = auto()

Three things worth extracting. First, the primary name is EXTEND, not PREFILL — because with prefix caching a "prefill" usually only computes the suffix that is not already in the radix tree, so extending a sequence by $n$ tokens is the general operation and a cold prefill is the special case $n = $ prompt length. Second, MIXED exists as its own mode: prefill and decode tokens in the same forward pass. Third, the phase is not a bookkeeping label — downstream code branches on it:

python/sglang/srt/model_executor/forward_batch_info.py:L150-L183, L177-L183 SGLang
    def is_decode(self):
        return self == ForwardMode.DECODE

    def is_mixed(self):
        return self == ForwardMode.MIXED

# ...

    def is_cuda_graph(self):
        return (
            self == ForwardMode.DECODE
            or self == ForwardMode.TARGET_VERIFY
            or self == ForwardMode.IDLE
            or self == ForwardMode.DLLM_EXTEND
        )

is_cuda_graph() is the phase split leaking into the runtime. Decode steps have a fixed, small, enumerable shape, so they can be captured into CUDA graphs and replayed with almost no launch overhead; this particular decode-graph predicate excludes EXTEND; general or piecewise graph capture can still support prefill (§8.1). At 9.2 ms per decode step and dozens of kernel launches per layer, launch overhead is a real fraction of a decode step and generally a smaller fraction of a sufficiently large prefill step.

The scheduler loop consumes the phase as a hard branch. Each iteration asks for a prefill batch first; only if there is none does it run the decode batch:

python/sglang/srt/managers/scheduler.py:L3188-L3197 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:
                ret = None

That is the whole policy in nine lines, as of 7d89325: prefill has priority; decode is what runs when there is nothing to prefill. The consequence — a long prefill starves every decoding stream — is exactly the symptom in §1, and the mitigations are chunking and mixing, below.

vLLM V1: there is no phase

vLLM's V1 scheduler opens with a comment that is the single most quotable line in either repository on this topic:

vllm/v1/core/sched/scheduler.py:L484-L495 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 scheduler's state per request is a pair of integers: how many tokens have been computed, and how many exist. Scheduling is closing that gap under a global token budget. The core of the running loop is:

vllm/v1/core/sched/scheduler.py:L565-L574 vLLM
            num_new_tokens = (
                request.num_tokens_with_spec
                + request.num_output_placeholders
                - request.num_computed_tokens
            )
            if 0 < self.scheduler_config.long_prefill_token_threshold < num_new_tokens:
                num_new_tokens = self.scheduler_config.long_prefill_token_threshold
            num_new_tokens = min(
                num_new_tokens, token_budget, input_budget - draft_slots

For a decoding request, num_tokens_with_spec - num_computed_tokens is 1 and the min is a no-op. For a request whose prompt has not been computed, it is thousands and the min clamps it to what is left of max_num_batched_tokens. Same code path, same variable, different magnitude. The phase is an emergent property of the number, not a flag.

The artefact handed to the worker says the same thing. SchedulerOutput has no phase field at all — it has a dictionary of token counts:

vllm/v1/core/sched/output.py:L205-L222 vLLM
@dataclass
class SchedulerOutput:
    # list of the requests that are scheduled for the first time.
    # We cache the request's data in each worker process, so that we don't
    # need to re-send it every scheduling step.
    scheduled_new_reqs: list[NewRequestData]
    # list of the requests that have been scheduled before.
    # Since the request's data is already cached in the worker processes,
    # we only send the diff to minimize the communication cost.
    scheduled_cached_reqs: CachedRequestData

    # req_id -> num_scheduled_tokens
    # Number of tokens scheduled for each request.
    num_scheduled_tokens: dict[str, int]
    # Total number of tokens scheduled for all requests.
    # Equal to sum(num_scheduled_tokens.values())
    total_num_scheduled_tokens: int

A step where num_scheduled_tokens is {"a": 1, "b": 1, "c": 1} is a decode step. One where it is {"d": 2048} is a prefill step. One where it is {"a": 1, "b": 1, "d": 2046} is a mixed step. The scheduler never named any of them.

The runner then re-derives the phase when it needs it — for CUDA graph dispatch, since that is the one place the distinction is load-bearing. Both runners do; the V1 one below spells the predicate out, while the V2 runner that a dense model such as Llama-3-8B actually gets calls the shared get_uniform_decode_token_count (vllm/v1/worker/utils.py:L601-L607), which answers the same question by returning the per-request token count or None (§11.4 on which runner you are on):

vllm/v1/worker/gpu_model_runner.py:L3989-L4007 vLLM
    def _is_uniform_decode(
        max_num_scheduled_tokens: int,
        uniform_decode_query_len: int,
        num_tokens: int,
        num_reqs: int,
        force_uniform_decode: bool | None = None,
    ) -> bool:
        """
        Checks if it's a decode batch with same amount scheduled tokens
        across all requests.
        """
        return (
            (
                (max_num_scheduled_tokens == uniform_decode_query_len)
                and (num_tokens == max_num_scheduled_tokens * num_reqs)
            )
            if force_uniform_decode is None
            else force_uniform_decode
        )

Why the difference matters

Design comparison at vLLM a556f3f and SGLang 7d89325. Read in source this session; no benchmark implied.
vLLM V1SGLang
Phase representationnone; num_scheduled_tokens per requestForwardMode enum on every batch
Step decisionfill one token budget, running requests first, then waitingbuild a prefill batch; if none, advance the decode batch
Mixed batchesthe default and only shapeopt-in via --enable-mixed-chunk
Chunkingimplicit — a long prompt simply cannot exceed the budgetexplicit chunked_prefill_size, one chunked request at a time
Phase-specific runtime_is_uniform_decode re-derives it for graph dispatchForwardMode.is_cuda_graph() answers directly

Neither is obviously better, and they buy different things. vLLM's token-budget formulation makes chunked prefill, prefix caching and speculative decoding the same mechanism — every one of them is "this request has $n$ uncomputed tokens, schedule some of them" — which is what the NOTE claims and what the code delivers. The cost is that phase is invisible in the scheduler, so anything that genuinely needs to know (graph capture, backend metadata, DP synchronisation) must reconstruct it from token counts and can get it subtly wrong.

SGLang's explicit mode makes phase-specific behaviour trivial to express and to read, and gives speculative decoding and disaggregation their own first-class modes (TARGET_VERIFY, PREBUILT, SPLIT_PREFILL) rather than overloading a token count. The cost is combinatorial: nine modes and a dozen predicate helpers, and every new feature must decide which mode it is and update the predicates.

Figure 2 — what each scheduler decides at the top of one iteration. Ctrl/Cmd + wheel to zoom · drag to pan · double-click to fit

Loading…
§5

Worked trace: mixed batches, and why they are legal

Both engines can put prefill and decode tokens in one forward pass. Why that is even possible is worth being precise about, because only attention cares about which request a token belongs to. Every other operator in a decoder block — the QKV projection, the output projection, the MLP, RMSNorm, the residual adds — is either a per-row pointwise function or a GEMM against a shared weight matrix. Both are invariant to how you group the rows. So you flatten: concatenate all scheduled tokens from all requests into one $[N_{\text{tokens}}, d]$ matrix, run the whole block on it, and hand attention a side-channel describing where each request's rows start and how long its cached prefix is.

In vLLM that flattening is the first thing _prepare_inputs does — quoting the V1 runner, whose NumPy version of the arithmetic is legible; the V2 runner does the identical flattening inside Triton kernels — and the worked example is in the comment:

vllm/v1/worker/gpu_model_runner.py:L2041-L2049 vLLM
        # Get request indices.
        # E.g., [2, 5, 3] -> [0, 0, 1, 1, 1, 1, 1, 2, 2, 2]
        req_indices = np.repeat(self.arange_np[:num_reqs], num_scheduled_tokens)

        # cu_num_tokens: [2, 5, 3] -> [2, 7, 10]
        # self.query_pos.np[:10]: [0, 1, 0, 1, 2, 3, 4, 0, 1, 2]
        cu_num_tokens = self._get_cumsum_and_arange(
            num_scheduled_tokens, self.query_pos.np
        )

Three requests scheduled for 2, 5 and 3 tokens become a flat run of 10 rows plus a cumulative end-offset array [2, 7, 10]; prepend zero to obtain query_start_loc=[0, 2, 7, 10] for the attention backend. Substitute [1, 1, 2046] and you have a mixed batch: two decodes and one prefill chunk, indistinguishable to every kernel except attention, which reads the offsets and the per-request sequence lengths and does the right thing per segment.

SGLang builds the same thing but has to say so explicitly, because its batches are typed. When mixed chunking is on, the decode batch is converted into an extend batch of length 1 per request and concatenated onto the prefill batch:

python/sglang/srt/managers/schedule_batch.py:L2758-L2784 SGLang
    def mix_with_running(self, running_batch: ScheduleBatch):
        self.forward_mode = ForwardMode.MIXED
        running_bs = running_batch.batch_size()

        for req in running_batch.reqs:
            req._refresh_fill_ids()
            full_len = len(req.full_untruncated_fill_ids)
            req.set_extend_range(full_len - 1, full_len)

# ...

        self.merge_batch(running_batch)
        self.out_cache_loc = out_cache_loc

# ...

        # NOTE: prefix_indices is what has been cached, but we don't cache each decode step
        self.prefix_lens = self.prefix_lens + [
            len(r.origin_input_ids) + len(r.output_ids) + delta
            for r in running_batch.reqs
        ]
        self.extend_lens = self.extend_lens + [1] * running_bs
        self.extend_num_tokens = self.extend_num_tokens + running_bs

self.extend_lens + [1] * running_bs is the whole idea in one expression: a decode step is an extend of length one. Its prefix length is everything generated so far; its extend length is 1. That is why the MIXED mode can exist at all, and it is the same insight vLLM encodes by never distinguishing the cases.

The gate is opt-in and carries real restrictions:

python/sglang/srt/managers/scheduler.py:L3513-L3526 SGLang
        # Mixed-style chunked prefill
        if (
            self.is_mixed_chunk
            and not running_batch.is_empty()
            and not (new_batch.return_logprob or running_batch.return_logprob)
            # mix_with_running cats input_ids but not input_embeds — shapes would mismatch
            and new_batch.input_embeds is None
        ):
            # TODO (lianmin): support return_logprob + mixed chunked prefill
            running_batch.filter_batch()
            if not running_batch.is_empty():
                running_batch.prepare_for_decode()
                new_batch.mix_with_running(running_batch)
                new_batch.decoding_reqs = running_batch.reqs

Note self.is_mixed_chunk is only true when chunked prefill is enabled and --enable-mixed-chunk was passed, and that requesting logprobs on any request in either half silently disables mixing for that step. That is a live source of "why did my tail latency get worse when I turned on logprobs" reports.

§6

Head-of-line blocking, and the shape of the fix

Now put the arithmetic and the scheduling policy together. SGLang's loop prefers prefill; vLLM's loop will happily spend its whole token budget on one long prompt. Either way, if a 32,768-token prefill is admitted as a single step, every decoding request waits for it.

Figure 3 — one long prefill against 32 decoding streams. Derived from Llama-3-8B shapes on one H100: 40% of 989 TFLOP/s bf16 peak for prefill, 80% of 3.35 TB/s for decode. Not measured.

0 500 1000 1500 2000 milliseconds of GPU time -- derived, 40 percent of H100 bf16 peak no chunking one EXTEND step 32768-token prefill -- 808 TFLOP -- 2041 ms decode decode all 32 running requests wait 2050 ms for their next token chunk = 2048 16 MIXED steps 16 chunks, each carrying the 32 decode tokens too worst step 169 ms -- same total work, tail cut 12x

Chunking caps each prefill contribution and lets decode rows run alongside it. Sixteen chunks of 2048 prefill tokens require a budget of at least 2080 when 32 decode rows share the step; with a total budget of 2048 the prompt receives 2016 rows per full step and needs 17 chunks. Attention to the growing prefix changes chunk cost, and the final shorter chunk need not be the most expensive.

Both engines ship chunking on by default (enable_chunked_prefill: bool = True at vllm/config/scheduler.py:L74), with the cap expressed differently. vLLM's is the scheduler's token budget, whose class-level fallback is 2,048:

vllm/config/scheduler.py:L42-L44 vLLM
    DEFAULT_MAX_NUM_BATCHED_TOKENS: ClassVar[int] = 2048
    DEFAULT_MAX_NUM_BATCHED_TOKENS_FOR_BATCHED_DP: ClassVar[int] = 256
    DEFAULT_MAX_NUM_SEQS: ClassVar[int] = 128

That 2,048 is not what an H100 server actually runs on. max_num_batched_tokens is left as None on EngineArgs and resolved by EngineArgs.get_batch_defaults (vllm/engine/arg_utils.py:L2580-L2671), which returns 8,192 for UsageContext.OPENAI_API_SERVER and 16,384 for UsageContext.LLM_CLASS on any device with at least 70 GiB that is not an A100. The 2,048 fallback is what a smaller card — or a 4090 — gets. SGLang's cap is a separate knob, auto-tuned from device memory the same way:

python/sglang/srt/server_args.py:L4894-L4899 SGLang
            elif gpu_mem < 90 * 1024:
                # H100, A100
                # (chunked_prefill_size 8k, max_bs 256 if tp < 4 else 512)
                if self.chunked_prefill_size is None:
                    self.chunked_prefill_size = 8192
                if decode_cuda_graph_config.max_bs is None:

On an H100 the two engines therefore land on the same number, 8,192, by two different routes — and it is worth knowing that neither of them is the number written in either config dataclass. The tradeoff the knob controls runs both ways: a smaller cap gives smoother ITL and worse prefill MFU (2,048 and 8,192 both sit far above the ridge, but the smaller step pays more per-step fixed cost), a larger cap gives better TTFT and a fatter ITL tail. This is the first knob in the book you cannot set without knowing your own SLO — which is why §1.2 comes next.

Failure mode

Setting max_num_batched_tokens below your longest prompt without chunked prefill enabled makes vLLM reject the request rather than chunk it. The config validator raises on exactly this in vllm/config/scheduler.py:L249-L261 when max_num_batched_tokens < max_model_len and enable_chunked_prefill is false, with the message that the setting "makes vLLM reject longer sequences".

§7

Hands-on

Watch each engine's own log. SGLang prints the phase directly, because it has one:

python/sglang/srt/managers/scheduler_components/metrics_reporter.py:L604-L612, L811 SGLang
        msg = (
            f"Prefill batch{iter_msg}, "
            f"#new-seq: {prefill_stats.num_new_seqs}, "
            f"#new-token: {prefill_stats.log_input_tokens}, "
            f"#cached-token: {prefill_stats.log_hit_tokens}, "

# ... L811:
        msg = f"Decode batch{iter_msg}, #running-req: {num_running_reqs}, {token_usage_msg}"

vLLM cannot print a phase, so it prints two throughputs and lets you infer which one was busy:

vllm/v1/metrics/loggers.py:L261-L266 vLLM
        # Format and print output.
        log_parts = [
            "Avg prompt throughput: %.1f tokens/s",
            "Avg generation throughput: %.1f tokens/s",
            "Running: %d reqs",
            "Waiting: %d reqs",

Then reproduce the stall on one GPU: start each server with chunking effectively disabled, fire a steady stream of short chat requests, inject one long prompt, and watch the short requests' token streams pause.

shell — run against your own GPU; nothing here was executed for this chapter shell
# vLLM: raise the budget so a 32k prompt lands in one step
vllm serve meta-llama/Llama-3.1-8B-Instruct \
  --max-num-batched-tokens 32768 --max-model-len 65536 --max-num-seqs 64

# then the same with an explicitly configured 2048 budget and compare the ITL tail
vllm serve meta-llama/Llama-3.1-8B-Instruct \
  --max-num-batched-tokens 2048 --max-model-len 65536 --max-num-seqs 64

# SGLang: disable chunking entirely, then enable it and mix
python -m sglang.launch_server --model-path meta-llama/Llama-3.1-8B-Instruct \
  --chunked-prefill-size -1
python -m sglang.launch_server --model-path meta-llama/Llama-3.1-8B-Instruct \
  --chunked-prefill-size 2048 --enable-mixed-chunk

The measurement harness for the ITL tail, and the lab that walks this end to end, are Lab 05 — Chunked prefill and ITL jitter, which belongs to §1.5.

§8

Exercises

  1. Read and answer. Open python/sglang/srt/model_executor/forward_batch_info.py at 7d89325. Which ForwardMode values does is_extend() return true for, and why is TARGET_VERIFY among them even though it is a decoding operation?
  2. Compute. Redo the ridge-point crossover for Llama-3-70B's MLP GEMM ($d = 8192$, $d' = 28672$) on an H100. At how many tokens per step does that GEMM cross $I^\star = 295$? Is it more or fewer tokens than the 4096×4096 case, and why?
  3. Predict, then verify. A request has a 6000-token prompt and vLLM is running with max_num_batched_tokens=2048 and eight other requests decoding. Predict the num_scheduled_tokens dictionary for each of the next four scheduler steps. Then read vllm/v1/core/sched/scheduler.py:L565-L574 and the waiting-queue clamp near L974 and check whether the running loop or the waiting loop gets the budget first.
  4. Predict, then verify. You enable --enable-mixed-chunk on SGLang and also pass logprobs on every request from your client. Predict the effect on p99 ITL, then find the condition in python/sglang/srt/managers/scheduler.py:L3513-L3520 that explains it.
  5. Design. vLLM's _is_uniform_decode requires num_tokens == max_num_scheduled_tokens * num_reqs. Construct a batch that is semantically all-decode but fails this test. What does the runner do instead, and what does it cost?
Answers

1. EXTEND, MIXED, TARGET_VERIFY, SPLIT_PREFILL, DLLM_EXTEND, and optionally DRAFT_EXTEND_V2 (forward_batch_info.py:L129-L137). TARGET_VERIFY is in the list because the shape is what is_extend() selects on, not the intent: verifying $k$ draft tokens means feeding $k+1$ query rows per sequence against a cached prefix, which is structurally an extend, and every attention kernel and metadata path downstream needs the extend layout.

2. Solve $T d d' / (d d' + T(d + d')) = 295$ with $d = 8192$, $d' = 28672$. Rearranged, $T = 295\,dd' / (dd' - 295(d + d')) = 69{,}289{,}902{,}080 / 224{,}006{,}144 \approx 309$ tokens. The weight term ($dd' = 2.35\times10^{8}$) is far larger relative to the activation term ($d + d' = 36{,}864$ per token) than in the square case, so activation dilution is weaker and the crossover falls below the 4096×4096 case's 345. The asymptotic answer is the same: $I \approx T$, and the ridge is a few hundred tokens on H100 regardless of layer shape.

3. The running loop runs first (vllm/v1/core/sched/scheduler.py:L531, "First, schedule the RUNNING requests"; the waiting loop only opens at L755), so the eight decodes take 8 of the 2048 tokens and the new request gets 2040 in the step it is admitted. Expect roughly {8 decodes: 1 each, new: 2040}, then {..., new: 2040}, then {..., new: 1920}, and on the fourth step the prompt is done and the request joins the decodes at 1 token. Exact splits depend on prefix-cache hits.

4. p99 ITL gets worse. not (new_batch.return_logprob or running_batch.return_logprob) is part of the mixing condition, so with logprobs requested the batches are never mixed and every prefill chunk is a step in which no decode token is produced.

5. Any batch where one request is mid-speculation and carries $k+1$ query rows while the others carry 1, or where one request was resumed after preemption and is replaying a chunk. Then max_num_scheduled_tokens > 1 and the equality fails, so the runner takes the non-uniform path: a general (or piecewise) CUDA graph or eager execution instead of the dense decode graph, whose launch overhead depends on how much is captured, on a step whose compute is only a few hundred microseconds.

§9

Key takeaways

  • Arithmetic intensity of a weight GEMM is the token count. $I \approx T$ up to activation dilution. The H100 bf16 ridge is 295 FLOP/byte, which a 4096×4096 GEMM reaches at $T = 345$ tokens per step (derived). Everything below that number is a memory problem; everything above is a compute problem.
  • A batch-32 decode step on Llama-3-8B can reach 7.5% of an H100's bf16 peak, and that is the correct answer, not a bug. $\beta I = 3.35\,\text{TB/s} \times 22.2 = 74$ TFLOP/s. Tuning the kernel cannot beat the roof; only raising $T$ or moving fewer bytes can.
  • vLLM V1 deliberately has no phase. Scheduling is closing the gap between num_computed_tokens and num_tokens_with_spec under a token budget, which is why chunked prefill, prefix caching and speculative decoding are one mechanism rather than three. The phase is reconstructed downstream only where the runtime needs it, in _is_uniform_decode.
  • SGLang makes the phase a typed enum that reaches the kernels. ForwardMode gates CUDA-graph eligibility, attention metadata layout, and the scheduler's prefill-before-decode branch. That buys readability and first-class modes for speculation and disaggregation, at the cost of nine modes every new feature must classify itself against.
  • A mixed batch is legal because a decode step is an extend of length one. Everything except attention is invariant to how token rows are grouped; attention gets per-request offsets. SGLang writes this literally as extend_lens + [1] * running_bs; vLLM writes it by never separating the cases.
  • Chunking trades MFU for tail latency and changes no total work. The same 808 TFLOP either blocks for 2041 ms or spreads over sixteen steps of 86–169 ms. Which you want is a property of your SLO, not of your engine.
§10

Further reading

Where these landed

Resolvable offline — git log -S <symbol> --reverse over the pinned checkouts finds the introducing commit, and merge subjects carry the PR number. vLLM's chunked prefill came in over a series, #3538 (data update) and #3853 (scheduler), reaching #3884, “[Core][5/N] Fully working chunked prefill e2e” (67b4221a61). SGLang's first attempt, #797, was reverted two commits later by #799 and re-landed as #800, “Chunked prefill” (7cd4f244a4) — worth knowing, because that revert is why the feature's history reads discontinuously. --enable-mixed-chunk came separately with #1013, “Mixed style of chunked prefill” (3694f8f996).

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