DeepConcepts

LLM internals / inference / serving / scheduling

Continuous Batching Is Not a Bigger Batch

The misconception

That continuous batching is a batching optimisation which makes the batch bigger, so it always raises throughput and the lever is the batch-size setting. It is a scheduling-granularity change, not a batching change: the unit of scheduling moves from the request to the single forward pass. All of its gain comes from removing slots that idle while the longest sequence in a batch finishes, so it is worth several times the throughput when output lengths vary and worth almost nothing when they do not. And the batch it forms is capped by how much KV cache is free, never by max_num_seqs. Two engines resolve that cap differently and the difference is invisible in the config: Orca reserves each request's declared max_tokens up front, so it never preempts but wastes most of the memory it reserves, while vLLM allocates on demand and must preempt when it runs short, discarding finished prefill and recomputing it. Turning max_num_seqs up on the second one buys preemption thrashing, not throughput.

16 min

A serving engine does not fill a batch and then run it. Before every single forward pass it decides again who is in the batch — retiring whoever just finished, admitting whoever fits, and sometimes throwing a running sequence out to make room. The batch is not a container you size. It is a decision the scheduler makes thousands of times a second, and the setting you think controls it is usually not the thing that binds.

Three terms, because everything below is built from them. A forward pass is one trip through the model's weights; the GPU runs exactly one at a time. Prefill is the pass that reads a whole prompt and produces a first output token. Decode is a pass that produces one further token for each sequence in it. Every sequence in a batch carries a KV cache — the keys and values, one pair per token per layer, that attention needs in order not to re-read the whole context — and that cache is what a sequence occupies while it is resident on the card.

Static batching is the mental model most people bring: collect n requests, run them together, return them together, collect the next n. It is what model.generate() on a padded tensor does. The problem is that requests in one batch do not finish together. In the workload below, a request that wants 50 tokens sits in the batch next to one that wants 781, and its slot is dead weight for 731 passes. Continuous batching — Orca's paper calls it iteration-level scheduling, and NVIDIA's TensorRT-LLM calls it in-flight batching — moves the unit of scheduling from the request to the pass, so that slot is refilled the moment it comes free.

Below is a scheduler serving 256 queued requests to exhaustion. It is already set the way you would run it: membership decided per pass, cache blocks allocated as they are needed, and --max-num-seqs at 256 so nothing is artificially held back. Read the second readout before you touch anything, then take --max-num-seqs down.

Unit of scheduling
KV cache allocation

The workload is fixed: 256 requests, all queued at time zero, 350,309 tokens of prompt and 65,536 output tokens between them. Output-length spread redistributes those output tokens — at 1× every request generates exactly 256, at 64× the longest generates about sixty times the shortest — but never changes how many there are in total (rounding moves it by a handful), so throughput comparisons across it are fair.

output tokens / second
what capped the batch
largest batch reached
mean sequences per pass
preemptions
prompt tokens recomputed
held cache that holds a token
time to drain the queue
Sequences resident on the card, pass by pass

One bar per slice of the run, height = how many sequences were in the batch. the scheduler admitted everything it was allowed to — the batch is at --max-num-seqs, or nothing is waiting. it wanted to admit more and the KV cache had no room: memory is the cap, not your config. work was lost — either a slot sat empty with a request queued behind it, or a running sequence was evicted and will have to be prefilled again.

Twenty of the 256 requests, from arrival to last token

Grey is waiting in the queue; the bar is the request resident on the card. Magenta means it was evicted at least once and lost its prefill. All 256 arrive at time zero, so every grey stretch is queueing.

Qwen3-8B on one H100 80GB, shapes from its published config.json, NVIDIA's published bandwidth and dense bfloat16 figures. Each pass is costed from its own counts of bytes moved and floating-point operations — the weight set, every resident sequence's cache, the new keys and values written — at 80% of peak bandwidth and 75% of peak tensor-core rate, taking whichever is larger. The per-pass token budget is fixed at 8,192, vLLM's server default on this card. Kernel launch overhead, sampling and the scheduler's own Python are ignored. The ratios are the lesson; the absolute seconds are a sketch.

At the settings it loads with, the readout that matters is the second one. --max-num-seqs says 256. The largest batch the scheduler ever assembled was 83. The other 173 slots were never contended for, because the KV cache ran out first — and reaching for them cost 38 preemptions and 49,535 prompt tokens that had to be computed a second time.

Now drag --max-num-seqs down to 64. Throughput goes up, from 2,911 to 3,036 tokens per second, preemptions go to zero, and the bars turn from amber to teal. You made the configured batch smaller and the machine got faster. That is not a rounding error and it is not a quirk of this simulation: it is what happens when a scheduler is allowed to admit more sequences than its cache can carry.

Then put --max-num-seqs back at 64 and switch the unit of scheduling to the request, with allocation on reserve up front — static batching, the thing the mental model describes. 3,036 becomes 1,579. Same GPU, same model, same 256 requests, same slot cap. The lanes tell you where it went: requests that arrived at time zero are still sitting grey thirty seconds later, waiting for a batch they were not selected into to finish draining.

What the scheduler does on one step

vLLM's V1 scheduler makes the whole decision in one function, schedule(), and it runs once per forward pass. It has two phases and the order matters more than anything else in this lesson.

Phase one is the requests already running. It walks them in arrival order and asks each how many tokens it wants next. A sequence that is decoding wants one. A sequence still working through its prompt wants the rest of it, capped by the per-pass token budget — that is chunked prefill, and it is why there is no separate "prefill phase" in the scheduler at all. The source says so directly:

# NOTE(woosuk) on the scheduling algorithm:
# There's no "decoding phase" nor "prefill phase" in the scheduler.
# Each request just has the num_computed_tokens and num_tokens_with_spec.
# At each step, the scheduler tries to assign tokens to the requests
# so that each request's num_computed_tokens can catch up its
# num_tokens_with_spec.

For each request it then tries to allocate the cache blocks those tokens will need. If the allocation fails, it does not queue, wait or shrink the batch gracefully. It evicts somebody:

while True:
    new_blocks = self.kv_cache_manager.allocate_slots(request, num_new_tokens, ...)
    if new_blocks is not None:
        break                      # the request can be scheduled
    # The request cannot be scheduled.
    # Preempt the lowest-priority request.
    preempted_req = self.running.pop()

self.running.pop() takes the last element, and under the default first-come-first-served policy the running list is in arrival order. So the victim is the most recently admitted sequence — newest out first, which is what keeps the oldest requests from starving. The PagedAttention paper states the rule in words: the earliest arrival is served first and the latest preempted first.

What eviction costs is the part people meet as a surprise. The preempted request's blocks are freed, and then:

request.status = RequestStatus.PREEMPTED
request.num_computed_tokens = 0
...
self.waiting.prepend_request(request)

num_computed_tokens = 0. Not "resume from where you were" — every token of prefill this request had already done is discarded, and when it is readmitted it prefills from the beginning. That is where the "prompt tokens recomputed" readout comes from. At the default settings it is 49,535 tokens on a workload whose prompts total 350,309, so preemption added 14% more prefill work to a run that was already prefill-heavy. (It is partly refundable: if prefix caching is on and the freed blocks have not been evicted from the cache by the time the request comes back, the recompute is a cache hit instead. That is a race, not a guarantee.)

Phase two is the waiting queue, and it is guarded by one line that explains a symptom people find baffling:

if not preempted_reqs and self._pause_state == PauseState.UNPAUSED:
    while (self.waiting or self.skipped_waiting) and token_budget > 0:
        ...
        if num_running >= self.max_num_running_reqs:
            break

A pass that preempted anything admits nobody. So under memory pressure the server does not degrade smoothly; it alternates between steps that evict and steps that refill, and the queue behind it stops moving during the evicting ones. And max_num_running_reqs — which is exactly --max-num-seqs — is read in only one place in the whole scheduling decision: here, as a break in the admission loop. It is a ceiling on admission. It is not a target, not a reservation, and nothing in the engine tries to reach it.

Where the batch size actually comes from

The number of sequences that fit is arithmetic, and you can do it before you launch anything. Each token of context costs 2 × layers × kv_heads × head_dim × bytes of cache. For Qwen3-8B that is 2 × 36 × 8 × 128 × 2 = 147,456 bytes, or 144 KiB per token — the 8 rather than 32 is grouped-query attention already having cut it by four. A 16 GiB pool is therefore 116,496 tokens of cache, or 7,281 blocks of 16 tokens each. A request whose prompt is 1,368 tokens and which generates 256 ends up holding 102 blocks. Divide: about 71 sequences at their final size, and more than that early on when their caches are still short.

That is the whole story of the second readout. The scheduler reached 83 and then started evicting, because 83 sequences at their current lengths fit and 83 sequences at their eventual lengths do not. The cap is not a number you set. It is floor(pool ÷ bytes per resident sequence), and it moves during the run.

Where a bigger batch stops helping. The reason batching helps at all is arithmetic intensity: a decode pass reads the entire 15.26 GiB weight set whether one sequence is in it or two hundred, so each extra sequence rides along on a memory transfer that was already happening. That is a strong effect and it saturates. Walk --max-num-seqs up from 1 with the pool at 16 GiB:

  • 1 — 158 tokens/s. One sequence pays for the whole weight read.
  • 8 — 1,020 tokens/s. Six and a half times the work for eight times the sequences.
  • 32 — 2,403 tokens/s. Still climbing, now clearly sublinear.
  • 64 — 3,036 tokens/s, and every pass is at the slot cap. This is the best this pool can do.
  • 128 — 2,911 tokens/s, batch peaking at 83, 38 preemptions. Worse.
  • 256, 512 — 2,911 tokens/s. Identical, to the token. The scheduler never gets past 83, so the number in the config has stopped being read.

Two things are worth staring at there. The first is that the mean batch size at 128 is higher than at 64 — 47.5 against 44.3 — and throughput is lower. A bigger average batch bought less output, because the extra residents were paid for in re-prefilled prompts. The second is that 256 and 512 are not merely similar, they are bit-identical: past the memory wall the setting is inert, and any tuning experiment you run on it will report no change and no reason.

Now drag the KV cache pool instead, with --max-num-seqs at 256. 2 GiB gives 987 tokens/s and 68 preemptions; 8 GiB gives 2,300; 16 GiB gives 2,911; 32 GiB gives 3,465; 64 GiB gives 3,529 with zero preemptions and all 256 requests resident at once. Every one of those is a different answer to "what is my batch size?" and none of them involved changing the batch size setting.

The allocator is the other half, and it is invisible in the config

Two engines can both do iteration-level scheduling and still reach very different batch sizes, because they answer "how much cache does this request need?" differently at admission time.

Orca, the paper that introduced the technique, reserves. Its scheduling algorithm keeps a running count n_rsrv of reserved token slots and admits a new request only if n_rsrv + req.max_tokens ≤ n_slots, releasing the reservation when the request finishes. The consequence is stated as a feature, and it is one: a request that is admitted can never run out of memory, so Orca has no preemption path at all. The cost is that max_tokens is whatever the client sent, and clients send round numbers. The vLLM paper measured this on real systems and found that the fraction of KV cache memory actually holding a live token was between 20.4% and 38.2%.

Switch KV cache allocation to reserve up front and watch two readouts move together. At the default 4× declared max_tokens, the largest batch drops from 83 to 55, throughput from 2,911 to 2,521, and "held cache that holds a token" falls from 100% to 53%. Nearly half the pool is reserved against tokens that will never be generated. Now drag declared max_tokens:

  • — clients declare exactly what they use. 2,962 tokens/s, batch 71, 89% live. Reservation is nearly free, and slightly beats on-demand because it never preempts.
  • — 2,828 tokens/s, batch 65, 73% live.
  • — 2,521 tokens/s, batch 55, 53% live.
  • — 2,084 tokens/s, batch 42, 35% live. Two thirds of the cache is holding nothing.

Drag the same control with allocation on on demand and the throughput readout does not move at all: 2,911 at every setting. vLLM never looks at max_tokens when deciding whether to admit, which is why a client that asks for max_tokens: 4096 and stops at 200 costs you nothing there and costs you half your batch on an engine that reserves. That is the entire practical difference between the two designs, and neither engine's configuration file mentions it.

The trade is real in both directions. Reservation buys a guarantee — no preemption, no recompute, a predictable tail — at the price of a batch sized by what clients claim. On-demand buys the batch back and pays for it with a failure mode that only appears under load. Paging is what makes the second one tolerable: allocating in fixed 16-token blocks is what keeps the unused remainder of a sequence's last block down to a few tokens instead of a few thousand.

The boundary: when this is worth nothing

Continuous batching is not a throughput technique. It is a technique for removing idle slots, and if your slots are not idle it does nothing.

Set output-length spread to , so all 256 requests generate exactly 256 tokens each, with --max-num-seqs at 64 and the pool at 16 GiB — settings where nothing preempts, so the only thing being measured is idle slots. Hold the allocator fixed and flip only the unit of scheduling. Reserving up front: 2,854 tokens/s per-request against 2,902 per-pass. On demand: 3,301 against 3,356. Both comparisons are a 1.7% improvement. The famous multiple has evaporated, and the residue is only the few passes it takes to fill the first batch.

Now put allocation back on reserve up front, leave the unit of scheduling on the request, and walk the spread up. At 1× it manages 2,854 tokens/s; at 16× the same total work takes 1,579. Static batching did not get slower because there was more to do — the output-token count is held constant — it got slower because the tail of the distribution decides when a batch is allowed to end. The gain from continuous batching is not a property of continuous batching. It is a property of your output-length distribution, and you can measure that without deploying anything.

This is the reason published speedups scatter so widely. Orca reports 36.9× over FasterTransformer, and that number is real and specific: a 175B model across multiple inter-layer partitions, compared at a matched median normalised latency of 190 ms, where FasterTransformer sustained 0.185 requests per second and Orca sustained 6.81. It is a chat workload with heavily varying lengths on a pipeline-parallel deployment where a stalled micro-batch idles several GPUs at once. Run a batch job where every request summarises to exactly 128 tokens and you will measure 1.02×, and both numbers are honest.

There is a second boundary in the simulation, and it is the one that costs people production incidents. Take the pool to 2 GiB and --max-num-seqs to 512. Throughput 987 tokens/s, 68 preemptions, 99,981 prompt tokens recomputed — against 350,309 tokens of prompt in the whole workload, so 29% of all prefill work was done twice. The lanes turn magenta. Nothing in that configuration is an error; every knob is within its documented range, and the engine will start and serve. It is simply admitting more sequences than it can carry, and paying for the difference in repeated work.

Checking it on a real system

vLLM tells you the memory-bound batch size at startup, in a line most people scroll past. It is emitted once, from update_kv_cache_capacity, and reads:

INFO ... GPU KV cache size: 434,657 tokens, Maximum concurrency for 8,192 tokens per request: 53.06x

Maximum concurrency is the answer to this entire lesson. It is the pool divided by max_model_len tokens of cache — the number of requests that fit if every one of them runs to the context limit. The “tokens per request” in that line is max_model_len, the context limit, not the per-pass token budget that happens to carry the same default on this card. If it says 53 and you are running with the default --max-num-seqs, you have 971 slots that the scheduler will never reach at full context length. Raising the setting cannot help; the two numbers you can actually move are --gpu-memory-utilization (0.92 by default, and worth pushing to 0.95 if you know what else is on the card) and --max-model-len, which divides straight into that concurrency figure.

Know what the default is before you decide it is too low. vLLM's get_batch_defaults() gives the OpenAI-compatible server max_num_seqs = 1024 on any GPU with at least 70 GiB that is not an A100, and 256 otherwise. On an H100 the shipped slot cap is already about twenty times the concurrency most deployments can afford. Almost nobody who "increases the batch size" is doing anything.

In the steady-state log line, one field is conditional and that is what makes it useful:

Avg prompt throughput: 0.0 tokens/s, Avg generation throughput: 812.4 tokens/s,
Running: 83 reqs, Waiting: 41 reqs, Preemptions: 38,
GPU KV cache usage: 99.8%, Prefix cache hit rate: 12.3%

Preemptions is printed only when the count is greater than zero, so its presence is the alert. The Prometheus counter behind it is vllm:num_preemptions, documented as "cumulative number of preemption from the engine"; alert on any non-zero rate, not on a threshold. Read the three fields together, because they diagnose different problems:

  • Running well below --max-num-seqs with Waiting non-zero and KV cache usage near 100% — memory is your cap. Lower --max-model-len, raise --gpu-memory-utilization, quantize the cache, or shard it with tensor parallelism. Do not touch --max-num-seqs.
  • Running pinned at --max-num-seqs with Waiting non-zero and KV cache usage well under 90% — the slot cap really is binding and raising it really will help. This is the rare case, and it is the only one where the obvious fix is the right one.
  • Preemptions climbing at all — you are already past the wall. Every preemption is a discarded prefill, and the throughput you lose is roughly the preempted prompt tokens divided by your prefill rate.

One measurement to take before any of this, because it decides whether the subject is worth your time at all: pull the completion_tokens field off a day of responses and compute the median (p50) and the 99th percentile (p99). If p99 ÷ p50 is close to 1 — a classification service, a fixed-format extractor — iteration-level scheduling is buying you a couple of percent and your throughput problem is somewhere else entirely, most likely in how memory-bound your decode is. If it is 10 or more, which is what open-ended chat looks like, the scheduler is the largest single lever you have and the settings above are worth an afternoon.

A last structural note. This same shape — a scheduling unit so coarse that changing anything stops the whole assignment — is why Kafka spent years replacing its stop-the-world rebalance protocol; the fix there was likewise to make the unit smaller rather than to make the assignment bigger. If you have read consumer rebalancing, you have already met the argument in another accent.

Your vLLM server logs Running: 61 reqs, Waiting: 140 reqs, Preemptions: 4,102, GPU KV cache usage: 99.9%. You are on an H100 and never set --max-num-seqs, so it is 1024. What should you change?

Next: the memory this whole argument is about, the KV cache; the allocator that makes on-demand admission survivable, PagedAttention; what a prefill does to the decodes sharing its pass, chunked prefill; and the other way to get more tokens out of one forward pass, speculative decoding.

Why this concept is on the site

Topics are chosen from places engineers visibly get stuck, and the sources are kept with the lesson so the claim is checkable.