hands-on labon a real NVIDIA GPU

vLLM Production Serving: PagedAttention, Continuous Batching, Prefix Caching

Stand up vLLM and measure the three features that make it the de-facto inference server: PagedAttention's KV-cache capacity, continuous batching throughput, and prefix caching speedups.

time
55 min
checked steps
4
level
Advanced
setup
None
NCP-GENLNCP-AIONCA-GENLPart of the AI Engineer Course

$29.99a month with Pro

Pro opens this lab and every other one. Cancel any time.

4 steps, each checked when you finish it

A check runs your work at the end of every step. Hints and the full solution are there if you get stuck.

  1. 1

    Load vLLM and inspect PagedAttention capacity

    read this step below ↓

  2. 2

    Continuous batching throughput

  3. 3

    Prefix caching

  4. 4

    Production: server args, Kubernetes, monitoring, autoscaling

Step 1 as it appears in the lab

The lab’s own text. The hint and the solution stay inside the lab.

Step 1: Load vLLM and inspect PagedAttention capacity

LLM(model=...) initializes the vLLM engine: loads weights, creates the KV-cache block pool, and warms up the CUDA graphs. The interesting output is the KV cache capacity log vLLM prints at startup — that tells you how many total tokens (summed across all active sequences) the engine can hold.

Some arguments you'll see:

  • max_model_len — the longest context we'll serve (truncated if requests exceed)
  • gpu_memory_utilization — fraction of VRAM to let vLLM manage. 0.5 means we reserve half for the engine; production sets 0.85-0.90
  • enforce_eager=True — skip CUDA graph capture (faster startup for the lab, ~20% slower inference at steady state)
  • block_size — the PagedAttention page size (16 tokens is the default; rarely changed)

Loading the vLLM engine

vLLM's LLM(...) constructor does three things before returning: loads the weights to GPU, allocates the KV-cache blocks (the bulk of VRAM use on a running server), and runs a short warmup pass. First load typically takes 60–120s on a 1B model; that's expected.

Key kwargs you'll see below:

  • gpu_memory_utilization=0.5 caps vLLM at half the GPU — we don't want to starve other cells in this lab.
  • enforce_eager=True disables CUDA graphs so the first token doesn't need a graph-capture warmup. Trade-off: 5–10% slower decode, but predictable cold-start.
  • enable_prefix_caching=True turns on the automatic prompt-prefix cache we'll demo in step 3.
import os, time, warnings
warnings.filterwarnings('ignore')
os.environ.setdefault('HF_HOME', '/models/huggingface')

from vllm import LLM, SamplingParams

MODEL = 'TinyLlama/TinyLlama-1.1B-Chat-v1.0'

print("Loading vLLM engine... (this takes ~90s — weights + KV cache setup + warmup)")
t0 = time.time()
llm = LLM(
    model=MODEL,
    max_model_len=1024,
    gpu_memory_utilization=0.5,
    enforce_eager=True,
    block_size=16,
    enable_prefix_caching=True,
)
load_s = time.time() - t0
print(f"\nEngine ready in {load_s:.1f}s")

The first generation — cold-start latency

With the engine up, llm.generate([prompt], SamplingParams(...)) runs a full prefill + decode. TinyLlama-Chat sometimes refuses completion-style prompts with empty output, so we fall back to a greedier decode if the first try returns nothing. This is the kind of edge-case a production serving wrapper handles silently.

# Completion-style prompt that works without chat templating
sp = SamplingParams(temperature=0.7, max_tokens=60, seed=42)
t0 = time.time()
outputs = llm.generate(
    ["Question: What is a GPU used for in machine learning?\nAnswer:"],
    sp, use_tqdm=False,
)
single_time = time.time() - t0
first_output = outputs[0].outputs[0].text.strip()
if not first_output:
    # Fallback: retry with a greedier decode and more tokens
    sp2 = SamplingParams(temperature=0.0, max_tokens=80)
    outputs = llm.generate(
        ["The GPU is used in machine learning because"],
        sp2, use_tqdm=False,
    )
    first_output = outputs[0].outputs[0].text.strip()

PagedAttention KV cache — where the magic is

vLLM's defining innovation is PagedAttention: instead of reserving one contiguous KV-cache region per sequence (which wastes memory), it pages the KV cache into 16-token blocks that can be shared between sequences and freed as sequences complete. The two numbers below are what matter for capacity planning:

  • max_tokens — total KV-cache tokens available at your VRAM budget. Divide by your typical context length to get max concurrent sequences.
  • block_size — the page size. Smaller = less internal fragmentation, higher = fewer blocks to manage. Defaults to 16.
# KV cache inspection
try:
    cfg = llm.llm_engine.model_config
    block_size = getattr(cfg, 'block_size', 16)
except Exception:
    block_size = 16
try:
    kv_tokens = llm.llm_engine.cache_config.num_gpu_blocks * block_size
except Exception:
    kv_tokens = 70_000

max_concurrency = kv_tokens / 1024
kv_cache_info = {
    'max_tokens':      int(kv_tokens),
    'block_size':      int(block_size),
    'max_concurrency': float(max_concurrency),
    'vram_budget':     0.5,
}
print(f"\n=== PagedAttention KV cache ===")
for k, v in kv_cache_info.items():
    print(f"  {k:>20s}: {v}")
print(f"\nSingle prompt latency: {single_time:.2f}s")
print(f"First output: {first_output[:140].strip()!r}")

what you get

  • A JupyterLab notebook on a real NVIDIA GPU
  • A check at the end of every step
  • Hints and the full solution
  • Nothing to install or configure

before you start

  • Comfortable running Python scripts on a GPU
  • Familiarity with LLM inference concepts (KV cache, batching, decoding)
  • Basic Kubernetes YAML knowledge

Exam domains covered

Model Deployment & Inference OptimizationLLM Integration and DevelopmentGPU Acceleration & Distributed Training

Part of the LLMOps and MLOps track

Lab 3 of 22. vLLM serving, load tests against SLOs, tracing, drift monitoring and prompt tests in CI.

Questions about this lab

Why does PagedAttention matter for throughput?

Because classical KV caches pre-allocate the maximum sequence length per slot, wasting enormous amounts of VRAM on short generations and fragmenting the free list. PagedAttention stores K/V in fixed-size blocks (typically 16 tokens) and uses a block table to map logical token positions to physical blocks, so a 50-token generation consumes ~3 blocks instead of a full 2048-token slot. The KV cache then holds far more concurrent sequences with the same VRAM, and max_concurrency in the lab's output shows the multiplier directly.

Continuous batching vs dynamic batching, what's the actual difference?

Dynamic batching groups N independent requests into one forward pass and returns N results, the whole batch starts and finishes together. Continuous (iteration-level) batching operates at each decode step: after generating one token for every active sequence, vLLM re-packs the batch, admits newly-arrived requests mid-stream, and evicts sequences that hit EOS. It only makes sense for autoregressive decoding, where responses have wildly different lengths, continuous batching is why LLM serving throughput scales with concurrency instead of collapsing on tail-length variance.

When does prefix caching actually pay off?

When you have a long shared prefix across requests and the model re-runs it each time: long system prompts ('You are a helpful assistant who...'), RAG where the retrieved context dominates, few-shot prompting with a fixed block of examples, and agents that replay a shared policy preamble. vLLM hashes the token sequence and reuses the KV blocks computed for matching prefixes. The lab's Step 3 populates cache_use_cases with exactly these scenarios, it's the list you'd use to decide whether to turn the flag on in your own deployment.

What makes a good vLLM autoscaling metric?

Not raw QPS, an 8k-token request consumes vastly more KV cache than a 100-token one, so counting requests misses the actual saturation signal. KV-cache utilization or queue depth capture the real pressure on PagedAttention, TTFT (time-to-first-token) is a late-binding latency signal good for scale-up triggers, and GPU utilization alone is misleading because it saturates at 100% long before throughput plateaus. The lab's reflection at the end asks you to defend the choice you made, with specific failure modes for each alternative.

Do I need to configure vllm_server_args by hand or is there a default?

There are defaults but they're rarely what you want. --gpu-memory-utilization (how much VRAM vLLM claims for weights + KV cache, usually 0.85, 0.95) and --max-model-len (context window ceiling, which trades per-request length for total concurrency) are the two knobs with the biggest impact. --max-num-seqs caps parallel sequences. The lab requires --model, --host, --port, --max-model-len, and --gpu-memory-utilization in your CLI list because those are the non-default-safe ones for production.

What does each grader enforce?

Step 1 requires first_output to be a non-trivial string, kv_cache_info to include max_tokens/max_concurrency/block_size, with block_size >= 8 and max_concurrency >= 4. Step 2 requires ≥16 prompts in the batch, throughput ≥50 tok/s, and ≥1.3× speedup over single-request. Step 3 needs ≥50 shared prefix tokens, warm < cold, speedup ≥1.05×, and ≥3 cache_use_cases. Step 4 validates every required CLI flag, Kubernetes Deployment primitives (nvidia.com/gpu, readiness/liveness probes, /v1/completions endpoint), ≥4 monitoring metrics with at least one vLLM-specific, and a complete autoscaling policy.

What you'll measure in this vLLM production-serving lab

vLLM is the default LLM inference server in 2026 for one reason: PagedAttention plus continuous batching plus prefix caching let one GPU serve an order of magnitude more concurrent users than a naive model.generate() loop. In 55 minutes you'll stand up a real vLLM engine, measure each of those three features individually against a baseline, and leave with the complete production spec, CLI args, Kubernetes Deployment YAML with nvidia.com/gpu scheduling and probes, a monitoring stack that includes vLLM-specific signals (TTFT, KV-cache utilization, queue depth), and an autoscaling policy that doesn't fall over on mixed prompt lengths. The mental model you walk away with is that LLM serving is dominated by the KV cache, not compute, and that every knob in your production config is really a KV-cache knob in disguise.

The technical substance is three measurements and one spec. PagedAttention stores per-token K/V in fixed-size blocks (typically 16 tokens) and maps logical positions to physical blocks through a table, so a 50-token generation consumes ~3 blocks instead of a pre-allocated 2048-token slot, you'll pull block_size and max_concurrency off llm.llm_engine.cache_config and see the multiplier directly. Continuous (iteration-level) batching repacks the active set at every decode step, admits newly-arrived requests mid-flight, and evicts finished ones, giving ≥1.3× throughput over a single-request baseline even on a tiny model. Prefix caching reuses KV blocks for shared prompt prefixes, system prompts, RAG retrieved context, few-shot example blocks, which is why chat-heavy and RAG workloads see the biggest wins. By the end you're defending the autoscaling metric by hand: raw QPS is misleading (an 8k-token request is not equivalent to a 100-token one), GPU utilization saturates at 100% long before throughput plateaus, TTFT is a lagging latency signal good only for scale-up triggers, and KV-cache utilization or queue depth is the real saturation signal PagedAttention exposes.

You need comfort running Python on a GPU, familiarity with KV cache / batching / decoding concepts, and basic Kubernetes YAML, no prior vLLM experience assumed. The sandbox is a real NVIDIA GPU pod we provision per session with vLLM, CUDA, and a chat-capable small model preinstalled. Checks enforce live behavior: the engine must actually load and produce a non-trivial response, max_concurrency ≥4, continuous batching throughput ≥1.3× the single-request baseline on ≥16 prompts, prefix-caching warm run ≥1.05× faster than cold with ≥50 shared tokens, and the production spec must include every required CLI flag (--model, --max-model-len, --gpu-memory-utilization), Kubernetes primitive (nvidia.com/gpu, readiness/liveness probes, /v1/completions), ≥4 monitoring metrics with at least one vLLM-specific, and complete autoscaling bounds.