HAMZA KAREEM / CONCEPTS hamzawi.dev

Lesson 752 min read

Continuous Batching

why your LLM server sits at 20% GPU and still feels slow

AI engineeringAI engineering / inference serving

The symptom

You self-host a 7B model behind vLLM/TGI on one A100. Load test: ~4 req/s, p99 latency 9s. nvidia-smi says 22% utilization. You raise batch size → throughput barely moves and latency gets worse. You add a second GPU → still bad.

The GPU isn't busy. So what is it waiting on?

The bug: static batching

Naive serving groups N requests into a batch and runs them lockstep until ALL are done. The batch finishes when the LONGEST generation finishes.

Batch of 8, output lengths:

[20, 25, 30, 40, 50, 60, 700, 800]

The batch runs 800 decode steps. Slot-steps available: 8 × 800 = 6400. Slot-steps doing useful work: 1725.

→ ~73% of your GPU's decode capacity is spent computing padding for finished sequences.

And the 20-token request? It was done in 0.4s but returns after 16s, because it can't leave the batch.

The fix: schedule per iteration, not per batch

Decode emits ONE token per step, and each sequence owns its own KV cache. So batch membership doesn't have to be fixed — it can change every single step.

Continuous batching (a.k.a. iteration-level scheduling):

  • sequence hits EOS → evicted immediately, slot freed that step
  • queued request → joins on the very next step, no waiting for the batch to drain

Same workload, same GPU: 2–4× throughput, and short requests stop being held hostage by long ones.

The parts people get wrong

1️⃣ Your real limit is KV cache memory, not max_num_seqs

Concurrency stops where GPU KV blocks run out. Set --max-model-len 32768 when your traffic is 2k prompts and you've reserved room for giants you never serve. Trim it and concurrency jumps.

2️⃣ Prefill stalls decode

A new 8k-token prompt joining the batch triggers one heavy prefill step. Every streaming user's tokens hitch at that moment — smooth output, then a visible stutter. Fix: --enable-chunked-prefill splits it across steps.

3️⃣ Preemption is a silent throughput cliff

When KV memory fills, running sequences get preempted and later recomputed. Throughput drops while the dashboard still looks "healthy."

Watch these, not just RPS:

vllm:num_requests_running / num_requests_waiting
vllm:gpu_cache_usage_perc        # >90% sustained = you're about to preempt
vllm:num_preemptions_total       # should be ~0

A reasonable start:

--max-num-seqs 256 --max-model-len 8192 \
--gpu-memory-utilization 0.92 --enable-chunked-prefill

Mental model

Static batching is a bus: it leaves when the last passenger arrives, and stops at every stop even if the seat is empty.

Continuous batching is a taxi rank: a seat opens, the next person takes it, that step.

Your exercise

Fire 100 concurrent requests with mixed lengths — 50 with max_tokens=16, 50 with max_tokens=512. Measure separately:

  • TTFT (time to first token)
  • inter-token latency
  • end-to-end p99 for the SHORT requests only

Then rerun with --max-model-len cut in half. If short-request p99 falls sharply, you were queueing on KV blocks, not on compute. What would you tune first if TTFT got worse while throughput improved?