TAI BUI
Back to curriculum
LearnPython

vLLM Serving Internals: PagedAttention, Continuous Batching, Chunked Prefill

vLLM's lead in 2026 comes from three defaults that compound, not a single trick. PagedAttention is the only KV allocator it ships. Continuous batching injects new requests into the active batch between decode iterations. Chunked prefill slices long prompts so decode tokens do not starve behind them. Turn all three on and a Llama 3.3 70B FP8 on one H100 SXM5 pushes 2,200-2,400 tok/s at 128 concurrent, roughly 25% above vLLM's own default and 3-4x a naive PyTorch loop. This lesson reads the scheduler and attention kernel at a level you can diagram, and ends with a toy continuous batcher in `main.py` that schedules prefill and decode the way vLLM does.

TIME: ~75 minutesPREREQUISITES: Phase 17 · 01 (Model Serving), Phase 11 (LLM Engineering)

Learning Objectives

  • Explain PagedAttention as a KV cache allocator: blocks, block tables, and why fragmentation stays under 4% at production load.
  • Diagram continuous batching at the iteration level: how finished sequences leave the batch and new ones join without draining.
  • Describe chunked prefill in one sentence and name which latency metric it protects (hint: it is the decode-side ITL tail, not mean throughput).
  • Name the 2026 vLLM v0.18.0 gotcha that bites teams enabling every optimization at once.

The Problem

A naive PyTorch serve loop runs one request at a time: tokenize, prefill, decode until EOS, return. At one user this works. At one hundred, it is a queue of patient people. The obvious fix is static batching, which pads every request to the longest prompt in the window, pads every decode to the longest expected output, and stalls the whole batch on the slowest sequence. You pay for padding you never use, and fast requests wait for slow ones.

vLLM attacks three problems at once. PagedAttention stops KV cache fragmentation from eating 60-80% of GPU memory the way classic contiguous allocation does. Continuous batching lets requests join and leave the batch between decode iterations, so the batch is full of real work. Chunked prefill breaks a 32k-token prompt into ~512-token slices that interleave with decode, so one long prompt does not freeze every other sequence's next token.

The 2026 production default has all three on. Learn what each one does, because the failure modes live in the scheduler, not the model.

Warm-up0/3
1. What does the KV cache store during LLM decoding?Warm-up
Four token boxes, each with a cache slot, sitting above a fifth token being generated. Dashed arrows run from the fifth token back to each of the four cache slots.
Setup: decode step 5 reading from the four earlier cache slots.
2. After a prompt has been processed, how does an autoregressive LLM produce its reply?Warm-up
Four forward passes in a row, each producing one word, with an arrow from each output word curving into the next pass.
Setup: four forward passes building one reply, one word at a time.
3. An operating system gives each program a contiguous-looking address space even though its data sits in scattered physical memory. What makes that possible?Warm-up
Four same-size virtual pages V0 to V3 above a row of scattered physical frames labelled F7, F2, F9 and F0, with a dashed arrow from each page to the frame holding its data.
Setup: everyday virtual memory, no program or frame layout is contiguous.
Check: The Problem0/1
1. What is the main problem classic static batching has with mixed-length requests?Explain
Three requests of different real lengths, short, medium and long, each drawn inside a bar padded to the same full width as the longest request.
Setup: one static batch holding requests of different lengths.

The Concept

PagedAttention as a virtual memory system

A KV cache is num_layers × 2 × num_heads × head_dim × seq_len × bytes_per_element per sequence. For Llama 3.3 70B at 8192 tokens, that is roughly 1.25 GB per sequence in BF16. If you pre-reserve 8192 slots for every request but the average request only uses 1500 tokens, you waste roughly 82% of the HBM you reserved. Classic batching pays this waste.

PagedAttention borrows the idea from OS virtual memory. KV cache is not contiguous per sequence. It is allocated in fixed-size blocks (default 16 tokens). Each sequence has a block table that maps its logical token positions to physical block IDs. When a sequence grows past its allocated blocks, one more block is added. When it finishes, its blocks return to the pool.

Fragmentation drops from 60-80% (classic) to under 4% (PagedAttention). There is no flag to enable PagedAttention. It is the only allocator vLLM ships. The knob is --gpu-memory-utilization (default 0.9), which tells vLLM how much HBM to reserve for KV blocks after loading weights and activations.

Continuous batching at the iteration level

The old "dynamic batching" waited for a window (say 10 ms) to fill a batch, then ran prefill + decode + decode + decode until every sequence finished. Fast sequences left early and sat idle while the GPU finished the slow ones.

Continuous batching operates between each decode step. Call the set of running sequences the RUNNING list. At each iteration:

  1. Any sequence in RUNNING that just hit EOS or max_tokens is removed.
  2. The scheduler looks at the waiting queue. If there are free KV blocks, it admits new sequences (prefill or resumed).
  3. The forward pass runs on whatever is now in RUNNING, emitting one new token per sequence.

The batch is not padded to a fixed size. Sequences at different positions in their output share one fused forward. In 2026 vLLM this is called the V1 scheduler. The key invariant: the scheduler runs once per decode iteration, not once per request.

Chunked prefill protects the ITL tail

Prefill is compute-bound. A 32k-token prompt on Llama 3.3 70B takes ~800 ms of pure prefill on one H100. While prefill runs, decode tokens for every other sequence in the batch wait. In a serving loop, the first-token latency (TTFT) of one long prompt becomes the inter-token latency (ITL) blip for dozens of other users.

Chunked prefill splits prefill into fixed-size chunks (default 512 tokens) and schedules each chunk as a unit. Between chunks the scheduler can advance decode sequences by one token. You trade a small absolute prefill latency hit (a few ms per chunk) for much lower decode-time jitter. P99 ITL under mixed load drops from ~50 ms to ~15 ms in published benchmarks.

The three defaults interact

All three features assume each other. PagedAttention gives the scheduler a fine-grained KV resource to trade against. Continuous batching needs that fine-grained resource so admitting a new sequence does not force a global reshuffle. Chunked prefill is a decision the scheduler makes on the same RUNNING list. It is one more scheduler policy, not a separate system.

You do not need to know every flag. You need to know what the scheduler optimizes: goodput under KV-block budget, subject to chunked prefill slicing.

The 2026 v0.18.0 gotcha

In vLLM v0.18.0 you cannot combine --enable-chunked-prefill with draft-model speculative decoding (--speculative-model). The documented exception is N-gram GPU speculative decoding in the V1 scheduler. Teams that flip every flag on without reading the release notes get a run-time error at startup, not a soft regression. If your speculative gain was worth enabling chunked prefill for, revisit the choice. The better answer in 2026 is often EAGLE-3 without chunked prefill, not a draft model plus chunked prefill that fails at startup.

Numbers you should remember

  • Llama 3.3 70B FP8, H100 SXM5, 128 concurrent, all three on: 2,200-2,400 tok/s.
  • Same model, default vLLM (no chunked prefill): ~1,800 tok/s.
  • Same model, naive PyTorch forward loop: ~600 tok/s.
  • KV fragmentation waste under PagedAttention at production load: <4%.
  • P99 ITL under mixed load: ~15 ms with chunked prefill, ~50 ms without.

What the scheduler looks like

while True:
    finished = [s for s in RUNNING if s.is_done()]
    for s in finished: release_blocks(s); RUNNING.remove(s)

    while WAITING and have_free_blocks_for(WAITING[0]):
        s = WAITING.pop(0)
        allocate_initial_blocks(s)
        RUNNING.append(s)

    # schedule prefill chunks + decode in one batch
    batch = []
    for s in RUNNING:
        if s.in_prefill:
            batch.append(next_prefill_chunk(s))   # e.g. 512 tokens
        else:
            batch.append(decode_one_token(s))     # 1 token

    run_forward(batch)                            # one fused GPU call

main.py follows this loop in stdlib Python with fake token counts and fake forward latency. Running it shows how chunked prefill keeps decode sequences moving during a long prefill.

Check: The Concept0/27
1. What is PagedAttention?Define
Five equal-size blocks labelled block 0 to block 4, each 16 tokens, belonging to one sequence, above a row of three free blocks in a shared pool.
Setup: one sequence's KV cache as same-size blocks, next to the shared free pool.
2. What is a block table in PagedAttention?Define
A two-column table for one sequence: logical position ranges 0-15, 16-31 and 32-47 mapped to physical block 7, block 2 and block 9, in no particular order.
Setup: one sequence's own logical-to-physical mapping.
3. What is continuous batching?Define
RUNNING at iteration i holds sequences A, B and C. At iteration i+1, A is gone and sequence D has been newly admitted alongside B and C.
Setup: two consecutive iterations' running lists.
4. What is chunked prefill?Define
A long prompt's prefill split into three 512-token slices in a row (the prompt's total length is illustrative), with a thin decode-token marker between each pair of slices.
Setup: one long prompt's prefill broken into fixed-size pieces. Prompt length is illustrative; the 512-token slice size is the lesson's default.
5. What does TTFT measure?Define
A bar split into queue, network and prefill segments, with a bracket spanning the whole bar labelled up to the point the first token is emitted.
Setup: the span from arrival to the first generated token. Segment widths are illustrative.
6. What does ITL measure, and what mostly drives it?Define
Three decode tokens in a row for one sequence, with an arrow marking the gap between the first pair.
Setup: the gap between two consecutive decode tokens for one sequence.
7. What is goodput?Define
Two requests, A meeting its targets and B missing its ITL target, above a bar split into a counted half and a not-counted half, with A's side implied on the counted half.
Setup: which of two requests' tokens land in which half of the tally.
8. What is the V1 scheduler in 2026 vLLM?Define
Three sequence boxes, A, B and C, with a loop arrow circling back onto the group, labelled one pass over this list.
Setup: the scheduler's running list, looked at as one group.
9. What does --gpu-memory-utilization (default 0.9) control?Define
An HBM bar split into weights plus activations, a KV block pool, and memory left free, with an arrow from the KV block pool to a note naming the flag that sizes it.
Setup: where the KV block pool sits in HBM, after weights and activations.
10. How does continuous batching differ from the older dynamic batching that waited on a time window?Explain
Two rows for the same moment. In dynamic batching, seq A sits idle after finishing early while B and C keep decoding. In continuous batching, a new seq D fills that slot while B and C keep decoding.
Setup: the same moment under the two batching schemes.
11. Why does continuous batching depend on PagedAttention's fine-grained KV blocks?Explain
Sequences A and B running, sequence C waiting, and an arrow from C down to a small free-block pool it draws from, with a note that A and B are untouched.
Setup: what admitting C touches, and what it leaves alone.
12. How does PagedAttention cut KV cache fragmentation from 60-80% to under 4%?Explain
Two equal-width meters of wasted KV memory: about 70 percent filled for classic contiguous allocation and a small sliver, about 4 percent, for PagedAttention. Below it a box asking for the mechanism, with a question mark.
Setup: the two reported waste figures. 70% stands for the lesson's 60-80% range.
13. Which latency metric does chunked prefill primarily protect under mixed load?Explain
A long prompt's prefill next to several already-decoding sequences, with a note that chunking lets the short sequences advance between slices, and a box asking which metric this protects.
Setup: the mixed-load scenario. Which metric benefits is left open.
14. Why does one 32k-token prompt hurt other users' inter-token latency when prefill is not chunked?Explain
One forward pass bar: most of it is a 32k-token prefill, with a thin dashed section for other sequences' tokens, and a note that the whole pass takes as long as its biggest part.
Setup: one shared forward pass containing a long prefill alongside other sequences' tokens.
15. A config uses a KV block size of 16 and a prefill chunk size of 512. What does each number slice?Explain
Two unlabelled strips: one cut into six units of 16, another cut into four units of 512. A box below asks which mechanism applies to what.
Setup: two fixed-unit mechanisms, without saying what either is used for.
16. Under mixed load, how does chunked prefill move P99 ITL, and why does the tail shrink?Explain
Two boxes, unchunked and chunked, above a box asking for P99 inter-token latency under each, marked only with question marks.
Setup: the two scheduling choices under the same mixed load. No latency figures shown.
17. Why does chunked prefill, taken in isolation, not raise mean throughput?Explain
One wide bar labelled one unchunked pass next to four narrower bars of the same combined width, labelled four slices with the same total tokens.
Setup: the same total prefill work, drawn as one pass versus several slices.
18. In vLLM v0.18.0, which speculative decoding variant stays compatible with --enable-chunked-prefill?Explain
Chunked prefill plus a draft-model speculative config leads to a startup error. A line notes one speculative-decoding variant is a documented exception, without naming it.
Setup: the v0.18.0 conflict. The compatible exception is left unnamed.
19. Which ranking of Llama 3.3 70B FP8 throughput on one H100 SXM5 at 128 concurrent matches the reported numbers, from lowest to highest?Apply
Three setup cards in a row: default vLLM without chunked prefill, naive PyTorch loop, and all three features on, each marked throughput unknown.
Setup: three setups from the lesson, in no particular order. Figures are left out.
20. For Llama 3.3 70B at 8192 tokens in BF16, what is the KV cache per sequence, and how much reserved HBM is wasted if the average request uses only 1,500 tokens?Apply
The shape of the KV cache size formula with seq_len 8192 given, result left as a question mark, plus a note that the average request uses 1,500 tokens and a question mark for the waste.
Setup: the formula's inputs from the lesson. Neither output is filled in.
21. In the scheduler loop, a sequence emits EOS during a forward pass and WAITING holds a sequence that fits in the freed blocks. What happens at the start of the next iteration?Apply
Sequence A emits EOS during a forward pass, while sequence D waits and fits in the blocks that would free up. A box asks what happens at the start of the next iteration.
Setup: the moment A finishes and D is waiting.
22. Mean throughput looks healthy, but users mid-reply see their token stream freeze for hundreds of ms whenever someone submits a very long document. Which ingredient is missing?Analyze
Mean throughput looks healthy while mid-reply users see their stream freeze for hundreds of ms whenever a very long document is submitted. A box asks what is missing.
Setup: the symptom, as described in the stem.
23. Your team serves Llama 3.3 70B on vLLM v0.18.0. After enabling --enable-chunked-prefill alongside --speculative-model with a small draft model, the server fails at startup. What is the best fix?Analyze
Chunked prefill plus a small draft speculative model fails at startup, the same conflict as the v0.18.0 gotcha.
Setup: the failing combination.
24. The WAITING queue keeps growing while GPU compute is underused, and every KV block is allocated. Which part of the scheduler is limiting admission?Analyze
Five requests waiting, a GPU compute meter showing low usage, and a box asking which part of the scheduler is limiting admission.
Setup: the growing waiting queue and the underused compute meter.
25. You lower --gpu-memory-utilization from 0.9 to 0.5 on a busy server. Compute is underused, yet the WAITING queue keeps growing. What explains it?Analyze
A KV pool bar at 0.9 next to a smaller one at 0.5, with a note that compute is underused yet the WAITING queue keeps growing, and a box asking what explains it.
Setup: the two memory fractions, given in the stem.
26. You serve short chat turns with a P99 ITL target plus occasional 32k-token documents. Which setup fits?Design
Short chat turns with a P99 ITL target, alongside occasional 32k-token documents, and a box asking which scheduler setup fits.
Setup: the mixed workload. No configuration is named.
27. Config X reaches 2,400 tok/s but a third of requests miss their ITL target. Config Y reaches 2,000 tok/s with every request inside its targets. Which do you ship for a latency-bound service?Design
Config X at 2400 tok/s with a third of requests missing ITL, and config Y at 2000 tok/s with every request meeting its targets. A box asks which to ship.
Setup: the two configs' stated numbers.

Use It

main.py simulates a vLLM-style scheduler in four modes on one 60-request workload:

  • NAIVE: one request at a time, no batching.
  • STATIC: pad and wait, classic batching with a batch of 16.
  • CONTINUOUS: iteration-level admission and release.
  • CONTINUOUS + CHUNKED: prefill slices interleaved with decode.

Each row prints throughput (tokens per virtual second), mean TTFT, and P99 ITL. The latency constants are illustrative, not measured, so read the shape of the rows and not the absolute values. The cleanest signal is P99 ITL between the two continuous rows, where chunking pulls the tail down. Throughput is murkier: the toy's STATIC mode charges decode cost on a simplified curve, so check the output before you trust any claim that one row wins everywhere.

Check: Use It0/7
1. In main.py, a request has prompt_len 128 and output_len 100, and KV_BLOCK_SIZE is 16. What does blocks_needed() return?Apply
The real blocks_needed method from main.py, with prompt_len 128, output_len 100 and KV_BLOCK_SIZE 16 given, and the result left as a question mark.
Code copied from the lesson's main.py.
2. A 2048-token prompt runs alone through simulate_continuous with CHUNK_SIZE 512. How many iterations does it spend in prefill with chunked=True versus chunked=False?Apply
The real take= line from simulate_continuous in main.py, with CHUNK_SIZE 512 given, and a question for the number of prefill iterations under chunked and unchunked.
Code copied from the lesson's main.py.
3. In the toy, KV_BLOCKS_AVAILABLE is 1800 and each request has prompt_len 8192 and output_len 300. How many such requests can be admitted at once?Apply
The blocks_needed formula with prompt_len 8192 and output_len 300 given, KV_BLOCKS_AVAILABLE 1800 given, and the number of requests admitted left as a question mark.
Setup: the budget and the per-request size. The count is left open.
4. In simulate_static, a window of three requests has output lengths 50, 300 and 100. How many decode iterations run for the window, and how does the 50-token request fare?Apply
Three requests in one static window with output lengths 50, 300 and 100, a question mark for the number of iterations, and a question for how long req 1 sits idle once finished.
Setup: the three output lengths. Iteration count and idle time are left open.
5. With PREFILL_LATENCY_PER_TOKEN of 0.00004 s, how much prefill time does one iteration add for an unchunked 8192-token prompt, versus one 512-token chunk?Apply
Two boxes, unchunked at 8192 tokens and one chunk at 512 tokens, each with an unknown millisecond figure below it.
Setup: the token counts per iteration. The per-token rate is given, the totals are not.
6. In a toy run, STATIC reports higher throughput than both continuous rows. What is the right reading?Analyze
A bar chart from a real run of main.py: STATIC at 4055 tok/s, CONTINUOUS no chunk at 886, CONTINUOUS plus chunked at 885.
Captured by running the lesson's main.py, not typed or estimated.
7. You want the toy scheduler to allocate KV on demand as a sequence grows, instead of reserving prompt plus output up front. Which structure does that need?Design
The toy today reserves prompt plus output length up front, with no block table and no growth on demand. A box asks what structure is needed instead.
Setup: today's up-front reservation, as described in the Author's Note.

Ship It

This lesson produces outputs/skill-vllm-scheduler-reader.md. Given a serving config (batch size, KV memory utilization, chunked prefill size, speculative config), it produces a scheduler diagnosis that names which of the three defaults is the bottleneck and what to tune.

Author's Note

I'd run main.py first and look at the P99 ITL column before anything else. Going from CONTINUOUS (no chunk) to CONTINUOUS + CHUNKED is the cleanest demonstration in the file: the only change is CHUNK_SIZE if chunked else remaining in the prefill branch, and the tail drops because a long prompt no longer occupies a whole iteration by itself.

Then read the throughput column skeptically. In my run STATIC came out ahead of both continuous rows, because the static path scales its decode cost by len(window) / 16 while the continuous path charges FORWARD_LATENCY_PER_TOKEN for every sequence in the batch. That is a property of the toy's cost model, not evidence about vLLM, and the closing print statement overstates what the output shows. Exercise 1 is a good place to poke at it.

Keep the toy in proportion on memory too. It treats the KV cache as a budget of 1800 blocks and reserves each request's full prompt plus output up front, so there are no block tables and no growth on demand. It is a sketch of admission control, and PagedAttention itself you have to take from the Concept section. If I were extending it, I'd start with allocating blocks as a sequence grows, since that is the part the toy skips.

Exercises

  1. Run main.py. Compare STATIC to CONTINUOUS on a workload with mixed short and long requests. Where does the gap come from: prefill efficiency, decode efficiency, or tail latency?
  2. Modify the toy scheduler to add --max-num-batched-tokens. What is the right value for an H100 running Llama 3.3 70B FP8? (Hint: it is a function of KV block size and number of free blocks, not raw HBM.)
  3. Re-read the vLLM v0.18.0 release notes. Which combinations of flags are mutually exclusive? List them.
  4. Compute the KV cache fragmentation waste for a trace of 1,000 requests with mean 1,500 output tokens, std 600 tokens, under (a) contiguous per-request allocation at 8192 max, (b) PagedAttention with 16-token blocks.
  5. Explain in one paragraph why chunked prefill helps P99 ITL but not throughput in isolation. Where does the throughput win come from in practice?

Key Terms

TermWhat people sayWhat it actually means
PagedAttention"the KV trick"Fixed-size block allocator for KV cache; fragmentation <4%
Block table"the page table"Per-sequence map from logical token position to physical KV block
Continuous batching"dynamic batching, but right"Admit/release decisions made every decode iteration
Chunked prefill"prefill splitting"Break long prefill into 512-token slices interleaved with decode
TTFT"first token time"Prefill + queue + network; dominated by prefill at long prompts
ITL"inter-token latency"Time between consecutive decode tokens; dominated by batch size
Goodput"throughput that meets SLO"Tokens/sec where every request still hit TTFT and ITL targets
V1 scheduler"the new scheduler"vLLM's 2026 scheduler; N-gram spec decode is the chunked-prefill-compatible path
--gpu-memory-utilization"the memory knob"Fraction of HBM reserved for KV blocks after weights and activations

Further Reading

Code

Ships With