Batching LLM Requests: Static, Dynamic and Continuous

Static batching at output-length CV of about 1 keeps only 33% of its decode slots useful, and throughput falls from 168 to 61 tok/s. Each fix, from timeouts to continuous batching to chunked prefill, creates the next problem, measured on a GPT-2 engine built from scratch.

Contents
  1. 1. Static Batching and Its Zombie Rows
  2. 2. Dynamic Batching: Fixing When, Not What
  3. 3. The Cache Has to Change First
  4. 4. Continuous Batching
  5. 5. Prefill Stalls Decode
  6. 6. All Policies Compared
  7. 7. What Is Still Broken

Static batching, when output lengths vary a lot (a coefficient of variation, CV, of about 1: the standard deviation is as large as the mean), keeps only 33% of its decode slots doing useful work. Throughput falls from 168 tok/s to 61 tok/s on the same hardware (measured).

Nothing about the chip changed. The batch just kept computing rows for requests that had already finished. This post is about the three ways of fixing that, and what each one broke.

Part 2 showed that batching decode is almost free, then assumed a tidy batch: everyone starts together and finishes together. It ended with three open items: users who arrive and leave at different times, the cache that grows with torch.cat, and long prefills that stall everyone’s decode. All three are here.

Everything runs on one engine and one serving loop, GPT-2 small on an Apple M1 CPU in fp32. A batching policy is about 30 lines on top of that loop, so any difference between two results is the schedule and nothing else. I label each number measured (real engine, wall clock) or computed (arithmetic from the workload or config). Part 2 covers the roofline, the ridge point, KV sizing and goodput, so I don’t repeat them.

1. Static Batching and Its Zombie Rows

Static batching is the version everyone writes first. Take B requests, run them together, and when the longest one finishes, take the next B.

Time runs left to right. # is a useful row, . is a row computed for a request that has already finished:

slot 0  ##########
slot 1  ####......
slot 2  ######....
        |-- batch 1 --|   batch 2 starts only when slot 0 ends

I gave it every advantage. Saturated arrivals, no prompt padding, a preallocated cache. Only the schedule’s waste is left. The test: 8 slots, 48 requests, output length drawn from a lognormal whose spread (sigma) I swept.

A request that finishes early keeps its row until the longest request in the batch ends. I call these zombie rows. They are computed every step and thrown away.

Slot efficiency is useful rows divided by computed rows. For a static batch it should equal the mean output length divided by the mean of the per-batch maximum. I computed that from the workload before running anything.

Output-length sigmaLength CV (computed)Slot efficiency, expected (computed)Slot efficiency (measured)Avg live rows per step (measured)Throughput, tok/s (measured)
00.001.001.008.0167.7
0.40.390.590.594.7109.6
0.80.750.410.413.375.5
1.21.010.330.332.661.4

The measured efficiency matches the computed one to three decimals. So the loss is purely the schedule, not some engine effect.

Static batching: the useful share of decode rows falls from 100% to 33% as output-length CV rises from 0 to 1.01, matching the value computed from lengths alone, and throughput falls from 168 to 61 tok/s.

Real static batching is worse than this. It would also pad prompts to a common length, which section 5 touches on.

Static batching spends its decode slots on requests that are already done, and the waste grows with the spread of output lengths.

The first idea is that the problem is waiting. If a batch can’t fill, a timeout would let it start anyway.

2. Dynamic Batching: Fixing When, Not What

Dynamic batching adds a timeout. Launch when the queue reaches B, or when the oldest request has waited max_wait seconds. Triton and TorchServe call this dynamic batching.

It bounds the wait to form a batch. It changes nothing after launch. The batch still runs until its longest member ends, so zombie rows stay.

static    wait until 3 requests are queued ------------> launch
dynamic   wait until 3 are queued or max_wait passes --> launch with what is there

slot 0  ##########
slot 1  ####......     zombie rows exactly as before
slot 2  (empty)        smaller batch, shorter wait

I predicted two things. At light load, TTFT drops a lot and batches get tiny. At saturation the timeout never fires, so dynamic equals static. Same workload, two loads, measured:

Load (x decode capacity)PolicyTTFT p50, sTTFT p99, sThroughput, tok/sSlot efficiencyAvg live rows per step
0.25static1.565.8233.90.493.9
0.25dynamic, wait 0.1 s0.682.0935.10.651.6
0.25dynamic, wait 0.3 s0.401.7635.80.631.7
0.25dynamic, wait 1.2 s1.052.0734.70.622.1
1.5static2.924.4184.60.493.9
1.5dynamic, wait 0.1 s3.125.1180.40.483.2
1.5dynamic, wait 0.3 s3.336.5870.40.443.1
1.5dynamic, wait 1.2 s2.784.1287.80.493.9

The light-load prediction held. A 0.3 s timeout cuts TTFT p50 from 1.56 s to 0.40 s. The price is batches of about 1.7 live rows instead of 3.9, so the batch is barely a batch. Slot efficiency looks better there only because there is little to waste. The best timeout is not the longest: 0.3 s beats both 0.1 s and 1.2 s.

The saturation prediction missed. I expected dynamic to match static. With short timeouts it lost throughput: 70.4 tok/s at 0.3 s against 84.6 tok/s for static, a 17% gap. With 0.6 s and 1.2 s it tied static, slightly ahead.

I don’t have a confirmed reason. My guess is that the short timeout launches a few early batches before the queue has filled, and with only 40 requests in the run that startup cost is a large share of the total. I haven’t logged batch sizes to check.

At the full-size headline workload (section 4), dynamic is no better than static either: 64.9 against 65.2 tok/s, and TTFT p50 of 6.00 s against 5.15 s.

One side experiment shows how much of the later win is just knowing lengths. I added an oracle that groups requests with similar output lengths into the same batch. It cheats, because it reads the true output length, which a real system would have to predict. On the headline workload it lifts throughput from 65.2 to 77.1 tok/s and slot efficiency from 0.39 to 0.59. Continuous batching, next, reaches 107.7 tok/s. Length awareness gets roughly a quarter of the way there (computed from those three numbers). No grouping can give the rest, because a request still waits for its batch-mates.

A timeout fixes how long a batch takes to form. It does nothing about zombie rows, and at saturation it can make things slightly worse.

The fix has to be different in kind. Decide the batch again every step, not once per batch. But my engine can’t do that yet.

3. The Cache Has to Change First

Part 2 left this open. The engine from Part 2 grows the KV cache with torch.cat on every decode step. Cat allocates a new tensor and copies the whole old cache into it.

At batch 16 and context 248, that copies 294 MB per step (computed: 2 x layers x batch x (context + 1) x hidden x 4 bytes). The copy grows with batch times context.

It is also the wrong shape for continuous batching. If the cache is one tensor of shape (batch, tokens), every request in it must advance in lockstep. A request can’t leave and another can’t join mid-flight without rebuilding the tensor.

So the cache becomes a set of slots. Each request owns a fixed row, preallocated to a maximum length. A decode step writes one new K and V into its slot in place. A finished request frees its slot and the next one takes it.

Batch 16, measured on the same machine:

ContextAppend via cat, msAppend in place, msCat copies, MB (computed)Decode step, cat engine, msDecode step, slot engine, ms
3226.50.3939291363
648.60.28776256
1288.00.111525254
19214.50.392285854
24811.70.112946355

Appending one token to the cache at batch 16 takes 8 to 26 ms with torch.cat and 0.1 to 0.4 ms in place, but the whole decode step is about 55 ms for both engines, except at context 32 where the slot engine is slower.

The append itself gets 30 to 100 times faster (ratios computed from the table). The full decode step does not. It is within about 10% either way from context 64 up, and the slot engine is slower at context 32, 363 ms against 291 ms.

The reason is attention. In the slot engine, requests have different lengths, so attention runs as a Python loop over sequences. The cat engine uses one batched attention call. The loop eats most of what the in-place write saves. A real engine fuses it into one variable-length kernel. I haven’t profiled how much the loop costs.

The context-32 row looks like noise to me. Both engines sit near 290 to 360 ms there against about 55 ms everywhere else, and Part 2 measured 39.7 ms for the same batch and context. I haven’t re-run it to find out.

So this is an enabler, not a speedup. It closes the cache-allocation item Part 2 left open, and it makes joining and leaving free. The price shows up in section 7: every slot reserves its full maximum length up front.

The slot cache doesn’t make a decode step faster. It makes the batch something that can change between steps.

4. Continuous Batching

Now the batch can be re-decided every step. That is iteration-level scheduling (the idea from the Orca paper). Before each forward pass, finished requests leave and waiting requests take their slots. Nobody waits for batch-mates, so there are no zombie rows, and TTFT stops including someone else’s decode.

Letters are different requests. Each slot is refilled the step after its request ends:

slot 0  AAAAAAAAAADDDD
slot 1  BBBBEEEEEEEEEE
slot 2  CCCCCCFFFFFFFF
            ^ B ends, E takes its slot on the next step

Because requests in one step are at different stages, the weight matmuls are batched across all of them, and attention runs per sequence. That is why section 3 had to come first.

Decode rows per step over time. Left, static batching computes 8 rows throughout while the number of live requests falls to 1 within each batch. Right, continuous batching keeps the live rows near the slot limit of 8.

The figure comes from the step 4 run, a workload without long prompts. Static used 49% of its computed rows, continuous used 100%, and throughput went from 85.0 to 110.3 tok/s (measured).

The harder comparison is the headline run: 64 requests, 10% long prompts, 8 slots, offered load 0.8x. All policies see identical arrivals. Goodput, from Part 2, counts tokens from requests that met both limits: TPOT up to 121 ms and TTFT up to 1.01 s. Both limits are relative to this machine’s full-batch decode step.

Continuous batching against static, measured:

  • Throughput: 107.7 tok/s against 65.2.
  • Goodput: 107.7 tok/s against 11.3. Static misses the TTFT limit for most requests.
  • TTFT p50: 0.08 s against 5.15 s. TTFT p99: 0.47 s against 9.75 s.
  • Slot efficiency: 1.00 against 0.39.

Static can’t keep up with 0.8x of the nominal capacity. Zombie rows make its real capacity lower, so its queue grows and its TTFT is seconds. Continuous keeps up with the arrivals.

It has a cost, and I predicted it. Inter-token latency (ITL) is the gap between two consecutive tokens of one request. Its p99 is 170 ms for continuous and 45 ms for static. TPOT p50 also rises, from 42 to 51 ms.

The cause is the default policy, prefill-first. When a new request is admitted, that step runs only its prompt. Every request that is decoding produces nothing until it finishes. Static never does this, since it prefills all rows together at the start.

A throughput bar alone would hide this. Averages stay fine, and only the tail gets worse.

Continuous batching removes the zombies and the queueing, and pays for it with stalls in every request’s token stream.

5. Prefill Stalls Decode

A prefill step is compute-bound, and its time grows with the prompt length. While it runs, every decoding request waits.

Two variants, and then the one that works:

  • Mixed. Put the new prompts into the same step as the decodes. The decodes don’t skip a step, but the step is as slow as the biggest prompt in it.
  • Chunked prefill. Give each step a token budget. Fill it with one token for every running decode first, then a chunk of prompt tokens in whatever budget is left. A prompt that doesn’t fit continues in the next step, with its K and V so far still in its slot.

d is one decode token, P and p are prompt tokens, one step per column group:

prefill-first   d d  PPPPPPPPPPPP  d d     decoders wait through one long step
chunked         d d  d+p d+p d+p d+p       every step is short, the prompt arrives in slices

A step’s time is now bounded by the budget, so ITL has a ceiling no matter how long a prompt is. No engine change was needed. The engine takes a list of (slot, start, tokens), and a decode, a whole prompt and a chunk are the same call with different token counts.

The budget is a trade-off. A long prompt needs about ceil(prompt / budget) steps, so its TTFT rises as the budget shrinks. A very small budget also re-streams all the weights for few tokens. A budget just above the knee of the step-time curve lets decodes ride along for almost nothing.

On the knee: the cost model I fit to measured step times puts it at 38 tokens. Part 2’s reasoning from the ridge in HARDWARE.md (19.8 FLOP/byte on this CPU in fp32) predicted about 40. They roughly agree. I haven’t explained the small gap.

This test has 15% long prompts of 480 tokens, same engine, measured:

PolicyLong-prompt TTFT p50, sShort-prompt TTFT p50, sITL p50, msITL p99, msITL max, msMax step, msThroughput, tok/s
prefill-first0.200.064124131420972.5
mixed0.190.074117818818872.5
chunked, budget 320.640.143951585872.7
chunked, budget 1280.280.074174969672.8
chunked, budget 5120.190.074017818118172.9

I also ran budgets 64 and 256, which sit between these rows. Throughput is flat across all of them. The budget moves latency around, not throughput.

Step duration over time for three policies. Prefill-first and mixed show spikes of 170 to 210 ms whenever a long prompt is admitted, while chunked prefill with budget 128 stays below about 100 ms.

Budget 32 cuts the worst stall from 314 ms to 58 ms, but the long prompts wait 0.64 s instead of 0.20 s. Budget 512 is just mixed.

One aside on prompts. Batching prompts of different lengths into one tensor means padding the short ones. Prefill is compute-bound, so the padding is real work. With a ragged batch, which has no padding, prefill at 62% real tokens ran 1.46x faster than padded (measured). The computed ratio, batch x longest prompt / sum of lengths, was 1.60x. I don’t have an explanation for the gap. Decode barely changed (43 ms padded, 41 ms ragged), because it streams the weights once either way. My padded run feeds the pad tokens through the same kernels, which is a stand-in for a masked tensor, so the real cost of masking could differ.

Chunked prefill caps the stall by spending long-prompt latency, and the budget sets the exchange rate.

6. All Policies Compared

Here are all six policies on the headline workload from section 4, measured on the real engine. The “continuous” row is prefill-first, “mixed” and “chunked” are from section 5 (budget 128), and “bucketed” is the length oracle from section 2.

PolicyThroughput, tok/sGoodput, tok/sTTFT p50, sTTFT p99, sTPOT p50, msITL p99, msSlot efficiency
static65.211.35.159.7542450.39
dynamic64.910.56.0011.7542470.43
bucketed (oracle)77.117.82.779.9841450.59
continuous107.7107.70.080.47511701.00
continuous, mixed109.7109.70.070.27441221.00
chunked109.8109.80.080.2944681.00

Chunked is best or tied on throughput and goodput, and well ahead of the other continuous variants on the ITL tail. Its ITL p99 of 68 ms is still above static’s 45 ms. Nothing here removes that gap.

At 0.8x load, the policies that keep up all produce tokens at the arrival rate. Throughput only separates them at saturation, so one load can’t rank them. A sweep over load, output-length spread and slot count can.

Those sweeps are not real-engine runs. Each point would take minutes, and I need about a hundred of them. So I replayed them with a cost model fitted to real step times and checked it against real runs of the same six policies. It ranks policies by throughput well (Spearman 0.94, median error 2%). It ranks TPOT badly (Spearman 0.09). TTFT is in between, with a median error of 16% to 17%. I trust the simulated throughput rankings and distrust its latency details. I did not re-run these sweeps on the real engine, so treat the exact values as approximate.

Load sweep, simulated (median of 3 seeds). Load is a multiple of full-batch decode capacity.

LoadStatic, tok/sContinuous, tok/sChunked, tok/sStatic TTFT p99, sContinuous TTFT p99, sChunked TTFT p99, s
0.470.470.970.96.630.150.23
0.882.6140.2141.339.862.081.24
1.584.0156.9175.061.8719.4314.69

Simulated sweep over offered load. Static and dynamic throughput flatten near 84 tok/s from load 0.6, continuous and chunked keep rising to 157 and 175 tok/s, and prefill-first has the highest inter-token latency p99.

At light load every policy produces the same throughput and the difference is all in latency. Past about 0.6x, static and dynamic flatten near 84 tok/s while the continuous variants keep climbing. Dynamic tracks static at every load. Bucketed sits between them.

The variance sweep, simulated at saturation, shows the same thing as section 1 from the other side. Across output-length CV from 0 to 0.98, static falls from 174 to 70 tok/s, continuous from 167 to 150, and chunked from 179 to 169. Continuous batching mostly shrugs off length spread.

One thing in the plot I haven’t looked into: at the lightest load, static’s simulated TTFT p99 is higher than at 0.4x, which is the wrong direction.

What to pick follows from these results, not from a separate test. Static is fine for an offline job where every output has about the same length, since it has no zombie rows to lose. A timeout (dynamic) only helps when load is light and bursty, and then at the cost of tiny batches. For anything with mixed lengths and live users, continuous batching is the one that holds up, with chunked prefill added when the ITL tail matters.

Continuous batching wins mostly through throughput at saturation and TTFT at every load. Chunked prefill keeps that win without the ITL tail.

7. What Is Still Broken

Throughput keeps rising with concurrency, so more slots look like the answer. Each slot reserves a full-length KV region. Here a slot reserves 416 tokens while the average request uses 122 (computed). 71% of the reserved KV memory is unused.

Slots against reserved memory, simulated at saturation:

SlotsKV reserved, MB (computed)Static, tok/sContinuous, tok/sChunked, tok/s
26145.059.460.9
412362.4101.6107.8
824584.0156.9175.0
16491107.9215.1244.6
32981125.5267.9296.9

Simulated throughput against reserved KV cache in MB for static, continuous and chunked batching. Chunked rises from 61 tok/s at 61 MB to 297 tok/s at 981 MB, and the reservation grows linearly with slots.

Reserved memory grows linearly with slots, and throughput grows slower. On a GPU the KV cache, not compute, is what caps the batch (Part 2). Reserving the worst case for every request makes that cap arrive much sooner than it should.

Here is what else is still unexplained or unbuilt:

  • Attention overhead. Per-sequence attention is a Python loop. I haven’t profiled it against a fused variable-length kernel. That needs a GPU and Triton.
  • Small-batch step time. The shape of the step-time curve at small token counts is taken as measured. I haven’t explained it, nor the gap between the 38-token knee and the ridge.
  • Dynamic batching at saturation. The 17% throughput miss in section 2 has only a guess behind it.
  • Static TTFT in the load sweep. In the simulated sweep, static’s TTFT p99 is higher at the lightest load than at 0.4x. I haven’t looked into it.
  • No preemption. When all slots are full, a long request at the head of the queue blocks everyone behind it (first come, first served).
  • Lengths come from the workload, not from EOS. Real length distributions are heavier-tailed and correlate with the prompt.
  • Greedy decoding only. Per-request sampling parameters and stop strings aren’t batched.
  • fp32 only. With fp16 or INT8 the knee moves, since each weight takes fewer bytes.
  • No prefix sharing. Requests with a common prompt prefix store it separately.

The next part is about the 71%. A request should get KV memory in small blocks as it grows, not a full-length region up front. That is paged KV memory (PagedAttention), along with block tables, prefix sharing, and what to do with a request when memory runs out.

Code, tests and benchmarks for every step: github.com/sanjay23singh/llm-inference-from-scratch.