Lab 01 — PagedAttention, Continuous Batching & Speculative Decoding
Phase: 09 — Inference Serving Internals (vLLM/TensorRT-class) Difficulty: ⭐⭐⭐⭐☆ (the off-by-one block math and the acceptance-rule proof are where people fall) Time: 4–6 hours
A serving engine like vLLM or TensorRT-LLM is, at its core, three mechanisms bolted onto a transformer: a paged KV-cache allocator so memory doesn't fragment, an iteration-level scheduler so the batch stays full, and (optionally) speculative decoding so you do fewer sequential target-model steps. This lab builds runnable miniatures of all three — the
BlockManager(KV in fixed pages, with a block table per sequence), theContinuousBatchingScheduler(admit and retire requests every step, beating static batching on wasted slot-steps), andspeculative_accept(themin(1, p/q)acceptance rule and the residual resample that provably preserves the target distribution).
What you build
blocks_needed(num_tokens, block_size)— ceil division. The whole paging scheme rides on getting the exact-multiple boundary right (blocks_needed(8, 4) == 2, not 3).kv_cache_blocks_fragmentation(seq_lens, block_size)— the proof that paged allocation wastes far fewer slots than naivemax_len-per-sequence reservation (the over-allocation PagedAttention removes).BlockManager(num_blocks, block_size)—allocatea prompt's blocks,appendone token (a new block only when the last is exactly full — the soul of the lab),freeto reclaim,num_free_blocks, andNoneon OOM. A per-sequence block table makes the cache non-contiguous.ContinuousBatchingScheduler(max_batch, block_manager)— a.step()that admits waiting requests if a slot + blocks are free, advances each running request one token, and retires finished ones in-flight; plusstatic_batching_steps(...)as the baseline to beat.speculative_accept(draft_tokens, target_probs, draft_probs, rng)— accept draft tokeniwith probmin(1, p_target/p_draft); on rejection resample from the normalized residualmax(0, p_target - p_draft); return(num_accepted, bonus_token).
Key concepts
| Concept | What to understand |
|---|---|
| Paging the KV-cache | store KV in fixed blocks (like OS pages) via a block table ⇒ non-contiguous, near-zero fragmentation |
| The off-by-one | a new block is needed iff len % block_size == 0 (the last block is exactly full), not when it's "nearly" full |
| Internal vs over-allocation | paged waste ≤ block_size-1 per sequence; naive max_len reservation wastes max_len - len per sequence |
| Continuous batching | admit/retire every step ⇒ no head-of-line blocking ⇒ 2–4× throughput vs static |
| Slot-steps, not wall-clock | the honest throughput proxy: occupied-but-idle slots are wasted work |
| Speculative acceptance | min(1, p/q) accept; residual resample on reject — exactly repairs the bias |
| Distribution preservation | the emitted-token distribution equals the target's, regardless of the draft (that's why it's "free" quality) |
Files
| File | Purpose |
|---|---|
lab.py | skeleton with # TODO markers — your implementation |
solution.py | complete reference; python solution.py runs a worked example |
test_lab.py | the proof — run it red, make it green |
requirements.txt | pytest only (pure stdlib otherwise) |
Run
pip install -r requirements.txt
pytest test_lab.py -v # against your lab.py
LAB_MODULE=solution pytest test_lab.py -v # against the reference
python solution.py # the worked example
Success criteria
- All tests pass against your implementation (and
LAB_MODULE=solution). - You can explain why
appendmust allocate a new block only whenlen % block_size == 0, and why getting it wrong silently leaks or over-allocates blocks (test_append_allocates_new_block_only_when_last_is_fullis the soul test). - You can explain why
kv_cache_blocks_fragmentationshows paged waste<<naive waste on a skewed batch, and why that gap is the motivation for PagedAttention. - You can explain why
test_continuous_batching_beats_static_on_wasted_slot_stepsholds — what head-of-line blocking is and why in-flight retirement removes it. - You can explain why
speculative_acceptpreserves the target distribution, and reproduce the one-line argument behindtest_spec_decode_preserves_target_distribution. - You can size a paged KV-cache and reason about OOM/preemption from
num_free_blocks.
How this maps to the real stack
| The miniature | The production mechanism | Where to verify it |
|---|---|---|
blocks_needed / BlockManager | vLLM's BlockSpaceManager: KV stored in fixed blocks via per-sequence block tables, paged like OS virtual memory | vLLM block_size, num_gpu_blocks; the PagedAttention paper |
kv_cache_blocks_fragmentation | why PagedAttention exists: naive contiguous allocation wastes 60–80% of KV memory to fragmentation/over-allocation | vLLM's reported "memory waste" vs HF generate |
| prefix sharing / CoW (extension) | shared system-prompt blocks across requests (ref-counted, copy-on-write) — automatic prefix caching | vLLM enable_prefix_caching; SGLang RadixAttention |
ContinuousBatchingScheduler | iteration-level / in-flight batching (Orca, vLLM): admit & retire each step, no head-of-line blocking | vLLM scheduler logs; max_num_seqs, max_num_batched_tokens |
static_batching_steps | the naive baseline (HF generate over a fixed batch) you measure against | HF model.generate batched throughput |
speculative_accept | speculative decoding's verify step: min(1, p/q) accept + residual resample, distribution-preserving | vLLM speculative_config; TensorRT-LLM speculative decoding; Medusa/EAGLE/n-gram drafters |
Limits of the miniature (say these in the interview): there is no real attention kernel — the
blocks here hold counts, not actual K/V tensors, so we model allocation, not the FlashAttention
gather over a block table; the scheduler advances "one token = one step" and ignores prefill cost,
chunked prefill, and preemption/swapping; speculative_accept verifies one sequence with given
distributions and skips the parallel target forward pass and the draft model itself; and "slot-steps"
is a throughput proxy, not measured latency (TTFT/TPOT live on real hardware).
Extensions (build these on real hardware)
- Prefix sharing / copy-on-write: ref-count blocks so two sequences sharing a prompt point at the same physical blocks; copy a block only when one diverges (the write). Show the KV savings for a fixed system prompt across 100 requests.
- Preemption & swapping: when
allocate/appendreturnsNone, evict the lowest-priority running sequence's blocks (recompute or swap to host), then re-admit — the real OOM path. - Chunked prefill: split a long prompt's prefill across steps so it interleaves with decode and doesn't spike TTFT for everyone else in the batch.
- Real KV blocks + a tiny attention kernel: store actual K/V vectors per block and implement the gather-over-block-table attention so PagedAttention is end-to-end.
- Tree/typical speculative acceptance (Medusa/EAGLE): verify a tree of draft tokens per step and accept the longest valid path; measure the acceptance-length distribution.
- Run a real vLLM server with
--speculative-modeland compare measured tokens/s and acceptance rate against your miniature's predictions.
Interview / resume
- Talking points: "Walk me through PagedAttention — why blocks, what's the block table, why near-zero fragmentation." "Static vs continuous batching — what's head-of-line blocking and why is continuous 2–4×?" "Speculative decoding — prove it preserves the target distribution." "When do you reach for a draft model vs Medusa/EAGLE vs n-gram?"
- Resume bullet: Built a from-scratch LLM-serving core — a PagedAttention KV-block allocator with per-sequence block tables, a continuous (in-flight) batching scheduler that eliminates head-of-line blocking, and a distribution-preserving speculative-decoding acceptance rule — verified with a 25-test suite including statistical checks that the speculative output matches the target distribution.