Batching and Throughput

A single request leaves an expensive GPU almost entirely idle. Batching is how you fill it — and the leap from naive static batching to continuous batching is the single biggest throughput improvement in modern LLM serving, often several times more requests from the exact same hardware.

The first post showed that decode is memory-bandwidth-bound: each step reads the whole model from memory to produce one token, so one request barely uses the GPU’s compute. Batching is the technique that exploits this — serve many requests together so the weights loaded from memory are reused across many tokens at once. This post covers why batching works, why the naive version wastes most of its promise, and how continuous batching captures it.

Why batching works

The key fact from post one: in decode, the GPU spends most of its time loading model weights from memory, and the arithmetic for a single token barely dents its compute capacity. Those loaded weights are the same for every request. So if you process many requests’ decode steps together, you load the weights once and apply them to many tokens — turning near-idle memory-bound work into efficient, compute-utilizing work.

The payoff is dramatic and slightly counterintuitive: batching increases throughput at almost no latency cost per request, up to a point. Because the GPU was mostly idle serving one request, adding a second, fifth, or twentieth request to the batch costs little extra time per step — you’re using capacity that was being wasted. This is why LLM serving is fundamentally a batching problem: the difference between serving one user and twenty on the same GPU can be nearly free until you saturate compute or run out of KV cache memory.

The limits are exactly the two resources from earlier posts:

Static batching and why it wastes the GPU

The naive approach is static batching (or synchronous batching): collect a batch of requests, run them together through generation, wait for all of them to finish, then start the next batch. It’s simple, and it’s how batching worked in early serving systems — but it wastes most of the benefit because of a property unique to LLMs: requests in a batch finish at wildly different times.

Recall that each request generates a different number of tokens — one user asks for a one-line answer, another for a five-paragraph essay. In static batching, the whole batch is locked together until the longest request completes:

Static batching (batch of 4):
  Req A: ▓▓▓ done, then IDLE ────────────────  (waits)
  Req B: ▓▓▓▓▓▓▓▓▓▓▓▓▓ done (longest) ────────
  Req C: ▓▓▓▓ done, then IDLE ─────────────────  (waits)
  Req D: ▓▓ done, then IDLE ───────────────────  (waits)
         └── finished slots sit idle until B ends ──┘

The short requests finish early and their GPU slots sit idle, doing nothing, until the longest request in the batch completes. New waiting requests can’t take those freed slots because the batch is fixed until it fully drains. On realistic traffic where response lengths vary a lot, this wastes an enormous fraction of the GPU — the very resource batching was supposed to fill — and it also hurts latency, because a new request must wait for the entire current batch to finish before it can even start.

Continuous batching: the big win

Continuous batching (also called in-flight or iteration-level batching) fixes this by operating at the granularity of a single decode step rather than a whole request. Instead of locking a batch until everyone finishes, the scheduler makes decisions every iteration:

Continuous batching:
  Req A: ▓▓▓ done → slot instantly reused by Req E ▓▓▓▓▓...
  Req B: ▓▓▓▓▓▓▓▓▓▓▓▓▓ (long, keeps running)
  Req C: ▓▓▓▓ done → slot reused by Req F ▓▓▓...
  Req D: ▓▓ done → slot reused by Req G ▓▓▓▓...
         └── no idle slots: freed capacity is refilled every step ──┘

The effect is that the GPU stays full — freed slots are refilled the instant they open, so almost no capacity is wasted on finished-but-waiting requests. This is why continuous batching delivers such a large throughput gain over static batching (the vLLM work reported several-times improvements) on the same hardware: it eliminates the idle-slot waste that static batching’s all-finish-together model creates. Continuous batching is now the default in every serious serving engine, and it’s inseparable from good KV cache management — freeing and admitting requests every iteration requires being able to allocate and release KV cache dynamically, which is exactly what paged KV cache enables.

The throughput/latency trade-off

Batching is powerful but not free of trade-offs, and tuning it is central to serving:

The practical guidance is to use continuous batching (non-negotiable), size the maximum batch to your memory and latency SLOs, and recognize that most “how many users per GPU” questions are really “how much KV cache fits, and how full does continuous batching keep the GPU.” Batching turns the GPU’s structural idleness into served users — the highest-leverage lever in the whole stack.

Key takeaways

Further reading

Sources & References