Continuous batching & scheduling
Batching amortizes the expensive weight-read across many requests — the biggest throughput lever. Continuous batching keeps the GPU saturated by swapping requests every step.
Learning objectives
- Explain why batching is the biggest throughput lever for memory-bound decode.
- Distinguish static batching from continuous (in-flight) batching.
- Reason about how a scheduler packs requests to keep the GPU busy.
- Tune batch/concurrency settings against your latency budget.
30-second recap
- The inference cost model: Cost and latency come from two phases: prefill (reading the prompt, compute-bound) and decode (writing tokens one by one, memory-bound).
- The KV-cache: During decoding the model stores each token's attention keys and values so it doesn't recompute them; that cache is what fills GPU memory.
code/ic4-batching/ in the course, with a README. Run the scripts or copy the configs directly.Why batching wins intermediate
Here's a number that reorganizes how you think about serving. Take a 7B model on a good GPU. Send it one request and it might spit out 40 tokens a second. Send it thirty-two requests at the same time and it doesn't slow to a crawl — it happily produces well over a thousand tokens a second, and each of those thirty-two users still sees roughly the same speed they'd get alone. You just served 32× the work for almost the same money. That is not a trick or an optimization at the margins; it is the reason GPU inference is affordable at all, and it's why the same model costs one lab pennies per million tokens and costs a naive self-hoster a fortune.
Why is that even possible? Here's the analogy. Imagine a librarian who has to haul one gigantic, heavy encyclopedia from the basement up to the desk every single time someone asks a question — then carry it back down. If one person is waiting, the librarian spends almost the whole time on the stairs and only a heartbeat actually answering. That trip is fixed cost: it's the same whether one person is at the desk or fifty. So the smart move is obvious — let a crowd gather, haul the encyclopedia up once, and answer everyone's question from that single trip. The model's weights are that encyclopedia; reading them out of GPU memory is the trip up the stairs (this is the memory-bound reality you met in IC1). One weight-read can answer the "what's the next token?" question for the whole crowd in nearly the same time it takes to answer for one — so throughput climbs almost linearly with batch size while each user's latency barely moves.
The mistake this kills: newcomers instinctively measure a served model by firing one request and timing it, then multiply that latency by their expected traffic to "size" the hardware. That math is wildly pessimistic — it assumes every request pays for its own stair-climb, when in reality they share. The right mental model is the opposite: a GPU running one request is mostly idle, and your job in this whole chapter is to fill that idle time with other people's requests. The concept below, continuous batching, is just the cleverest way to keep that crowd at the desk as densely packed as possible.
Decode re-reads all the model's weights for every token (IC1). If you're doing that for one request, the GPU is mostly idle waiting on memory. Batch many requests through the same weight-read and you serve them almost for free — throughput rises far more than latency. Batching is the single biggest efficiency lever in serving.
This shows why serving many users at once is cheaper per user. The costly part of each step is reading the model's weights out of memory; batching lets one weight-read serve lots of requests at the same time.
- Many requests (concurrent users) — several people are asking the model something in the same window of time.
- Scheduler (packs them) — a traffic controller inside the server groups those requests together into a single batch so they can ride along on the same computation.
- One batched forward pass (shared compute) — the model reads its weights once and produces the next token for every request in the batch together. The expensive memory read is shared, so each extra request is nearly free.
- Tokens to all (streamed back) — each user gets their own token from that shared pass, streamed back to them.
- Read left to right: gather users → pack into a batch → one shared pass → everyone gets output.
In short: Batching = many requests share one weight-read. That's why throughput (users served) shoots up while the cost per user drops — the biggest efficiency lever in this whole track.
Static vs continuous batching intermediate
Batching is a huge win — but the naive way to batch quietly throws half of it away. Here's the problem in one picture. You gather four requests, run them together, and start streaming tokens. But requests don't all finish at the same time: one user asked for a one-line answer and stops after 3 tokens; another asked for an essay and keeps going for 200. If your batch is fixed — the four ride together from start to finish — then the three short requests sit in their seats, done, doing nothing, while the whole batch waits for the essay-writer to finish. Their GPU slots are occupied but idle. That's static batching, and on real traffic (where output lengths vary wildly) it can waste half your GPU or more.
The analogy: a bus versus a taxi rank. Static batching is a bus that leaves full, drives the whole route, and won't pick anyone up until every original passenger has been dropped off and it returns to the depot — even if three seats emptied at the first stop. Continuous (in-flight) batching is a taxi rank at the airport: the instant one cab pulls away with its fare, the next person in line steps into that spot. No slot ever sits empty while someone is waiting. Concretely, continuous batching re-decides the batch every single decode step: at each step it checks which sequences finished, evicts them, and pulls waiting requests in to fill the freed slots. The batch is a living, churning set, not a frozen group.
The common misconception is that continuous batching is a fundamentally different or heavier algorithm — some exotic parallelism. It isn't. It's the same shared weight-read you already understand, just with the membership of the batch refreshed on every token instead of once per group. That one change — add and evict per step rather than per batch — is the entire difference between "good" throughput and the throughput that makes vLLM and TGI worth running. Note the honest scope, though: you almost never implement this yourself. The scheduler lives inside the serving engine (IC6). What you own is understanding it well enough to reason about why your utilization graph looks the way it does and which knob to turn — which is exactly what the table and the tuning section below give you.
Static batching waits to collect a fixed batch, runs it to completion, then starts the next — but requests finish at different lengths, so the GPU idles on the stragglers. Continuous (in-flight) batching adds and removes requests every step: as one finishes, a new one takes its slot. This keeps the GPU saturated and is why modern servers (vLLM/TGI) get their throughput.
Both halves show the same four GPU slots (rows) running across six decode steps (columns) — read each row left-to-right in time, and compare the left panel (static) with the right (continuous).
- Each row is one slot in the batch; each column is one decode step. Green cells are useful work; the model does the same shared weight-read on every column.
- On the left (static), requests A, B, C ask for short answers and finish at step 3 — but their slots turn red and stay idle, because the fixed batch can't move on until the long straggler D finishes at step 6.
- On the right (continuous), the instant A/B/C free their slots they are refilled with waiting requests E/F/G (the amber cells) — no slot is ever red, so the GPU stays saturated.
In short: Static leaves finished slots idle until the slowest request ends; continuous evicts-and-refills every step, so the same shared weight-read always carries a full batch.
| Static batching | Continuous batching | |
|---|---|---|
| When batch changes | per batch | every decode step |
| GPU utilization | drops on stragglers | stays high |
| Throughput | good | much better |
| Used by | naive servers | vLLM, TGI |
Tuning to a latency budget advanced
If batching is so good, why not just crank the batch size to the moon? Because throughput and latency pull in opposite directions, and someone is always paying the latency bill. Every request you add to a batch makes each decode step a hair slower for everyone in it — and each user feels that as their tokens arriving a little later. Push the batch too high and you'll post gorgeous throughput numbers on a dashboard while your actual users watch their answers stutter out one slow token at a time. Tuning batching isn't "find the maximum"; it's "find the biggest batch that still keeps your slowest users fast enough." That constraint — the speed you promise users — is the thing everything else serves.
The analogy is a rideshare pool. Packing four strangers into one car is efficient for the driver (throughput), but every extra pickup and drop-off makes your ride longer (latency). There's a sweet spot: pool enough to be cheap, but not so much that riders bail. For a served model that promise is written as a latency SLO — a service-level objective like "95% of tokens arrive within 50 ms of the last one" (that per-token gap is TPOT, time-per-output-token, from IC1). You tune the batch up against that ceiling and stop the moment you'd breach it. Two numbers to hold in your head as you read the steps: the batch is how densely you pack one GPU, and replicas are how many GPUs you run — they solve different problems, and confusing them is the classic tuning mistake.
The mistake to avoid: reacting to a latency spike by adding more hardware, or reacting to a capacity shortfall by shrinking the batch. They're crossed wires. If p95 latency is breaching, the batch is too big — shrink it, even though that lowers per-GPU throughput. If you simply can't keep up with the request rate, no batch setting saves you — add replicas. One knob protects the promised speed; the other protects raw capacity. Keep them straight and the four-step recipe below reads like common sense; mix them up and you'll thrash. Now, concretely:
Read it left-to-right as you grow the batch size along the bottom axis; watch the two curves and the dashed red ceiling that represents your promised latency (the SLO).
- The blue curve is throughput (users served per second). It rises steeply at first, then flattens into a knee — past that point a bigger batch buys almost no extra throughput.
- The amber curve is per-token latency. It creeps up gently while the batch is small, then turns sharply upward as each request waits on a fuller batch.
- The dashed red line is your latency SLO ceiling (e.g. p95 TPOT < 50 ms). The green dot marks where the latency curve touches that ceiling — the largest batch that still keeps users fast enough. Anything to the right breaches the promise.
In short: Grow the batch until latency meets the SLO ceiling, not until throughput maxes out — the green dot (max-num-seqs) is where those two goals stop agreeing.
How to tune batching for your workload
- Set your latency SLO (e.g. p95 TPOT < 50 ms) — the constraint everything serves.
- Raise max concurrent sequences until throughput plateaus or you breach the SLO.
- Watch KV-cache memory (IC3): too many sequences and you'll OOM or start evicting.
- For bursty traffic, cap concurrency so a spike can't blow the latency budget for everyone.
loadtest.pyimport asyncio, time
from openai import AsyncOpenAI
client = AsyncOpenAI(base_url="http://localhost:8000/v1", api_key="x")
async def one():
t0 = time.time()
await client.chat.completions.create(
model="local-model", max_tokens=128,
messages=[{"role": "user", "content": "Summarize continuous batching."}])
return time.time() - t0
async def bench(concurrency):
t0 = time.time()
lat = await asyncio.gather(*[one() for _ in range(concurrency)])
wall = time.time() - t0
print(f"conc={concurrency:3d} p95={sorted(lat)[int(len(lat)*0.95)-1]:.2f}s "
f"throughput={concurrency/wall:.1f} req/s")
for c in (1, 4, 16, 64):
asyncio.run(bench(c)) # watch throughput climb and p95 latency rise
This script load-tests a server by firing many requests at once and measuring what happens. It shows the core tradeoff of batching: as you send more requests together, you serve more per second (throughput up) but each one can take a bit longer (latency up).
- It uses
AsyncOpenAIandasyncioso many requests can be in flight at the same time instead of one after another — that's how we create real concurrency. async def one():sends a single request and returns how long it took (time.time() - t0). That per-request time is its latency.bench(concurrency)launchesconcurrencycopies ofone()at once withasyncio.gather(...), waits for them all, and times the whole thing (wall).- The print shows p95 latency (
sorted(lat)[...0.95...]— the slow-ish request that 95% of users beat) and throughput (concurrency/wall= requests finished per second). - The loop
for c in (1, 4, 16, 64):repeats the test at rising concurrency so you can watch the two numbers move in opposite directions.
What the output means: Four lines, one per concurrency level. Throughput (req/s) climbs as concurrency rises; p95 latency stays flat at first, then starts rising once the server is saturated.
Try this: Find the knee: the concurrency where throughput stops climbing much but p95 latency starts spiking. That point is roughly the most load this server should take before users feel it.
Exercise IC4.1 — Find your knee
Context: Every served model has a “knee” where latency spikes for little further throughput, and that knee — not a theoretical max — is your capacity setting.
Your task: Load-test a served model at rising concurrency, plot throughput and p95 latency vs concurrency, and find the knee past which latency spikes for little throughput gain.
Requirements:
- Drive rising concurrency against a served model
- Record throughput and p95 latency at each level
- Plot both against concurrency
- Identify the knee where latency spikes for little throughput gain
- Use that knee as the capacity setting
💡 Hint: The knee is where the throughput curve flattens while the latency curve turns sharply up — read it off the two plots together.
🪜 Practice ladder beginner → industry
Six graded exercises, easy to real-world. Try each before opening its solution.
Context: Batching shares one weight-read across many requests, and since a decode step is memory-bound its time barely changes with batch — so throughput rises almost linearly.
Your task: Model it simply: a step takes base_ms regardless of batch, so throughput = batch / step-time; print tokens/sec for batch 1, 8, 32.
Requirements:
throughput = batch / (step_ms / 1000)- Treat step time as constant with batch (memory-bound)
- Print tok/s for batches 1, 8, 32
- Show throughput rising almost linearly with batch
- Frame batching as the single biggest efficiency lever
💡 Hint: If the step time is fixed, throughput is just batch over step time — the linear rise is the whole insight.
Show solution
The core batching win in three lines:
def throughput(batch, step_ms=25):
return batch / (step_ms / 1000) # tokens per second (1 tok/req/step)
for b in (1, 8, 32):
print(f"batch {b:>2}: {throughput(b):.0f} tok/s")
# batch 1: 40 tok/s
# batch 8: 320 tok/s
# batch 32: 1280 tok/s
Because the step time barely changes with batch (it is dominated by reading weights), throughput rises almost linearly — the single biggest efficiency lever in serving.
Context: Bigger batches raise throughput but each request waits for the whole batch, so batch size is a knob you set against an SLO, not one you maximize blindly.
Your task: Model per-request latency as step-time growing slightly with batch and show the classic curve — throughput up, per-request latency up.
Requirements:
- Make step time grow slowly with batch (a small per-extra term)
- Loop over batches 1/8/32/128
- Print step time, per-token latency, and throughput each
- Show throughput climbs ~40× while latency only ~triples
- Conclude batch size is set against an SLO, not maximized
💡 Hint: Let step time creep up gently with batch — throughput still wins big, but the latency creep is why you tune rather than max the batch.
Show solution
Show both curves so the tradeoff is explicit:
def step_ms(batch, base=25, per_extra=0.4):
return base + batch * per_extra # step grows slowly with batch
for b in (1, 8, 32, 128):
s = step_ms(b)
thru = b / (s/1000)
print(f"batch {b:>3}: step {s:5.1f} ms | latency {s:5.1f} ms/tok | {thru:6.0f} tok/s")
# batch 1: step 25.4 | 25.4 ms/tok | 39 tok/s
# batch 128: step 76.2 | 76.2 ms/tok | 1680 tok/s
Throughput climbs ~43x from batch 1 to 128 while per-token latency only triples — but that latency growth is real, so the batch size is a knob you set against an SLO, not maximize blindly.
Context: Static batching waits for the slowest request in the batch; continuous batching swaps a finished slot immediately — which is where vLLM/TGI get their throughput.
Your task: Simulate a batch of requests with different output lengths and compare GPU-step utilization for static vs continuous batching.
Requirements:
- Use output lengths with one straggler (e.g. [3,3,3,10])
- Static utilization = useful work / (requests × max length) ≈ ~half
- Continuous utilization ≈ ~100% (finished slots freed and refilled)
- Explain short requests hold idle slots under static batching
- Connect continuous batching to vLLM/TGI throughput
💡 Hint: Under static batching the short requests sit idle until the longest finishes; continuous batching refills those slots the moment they free up.
Show solution
A deterministic sim of the two schedulers over discrete steps:
lengths = [3, 3, 3, 10] # output tokens per request; one straggler
# Static: batch runs until the LONGEST finishes; short reqs idle their slot after done
steps_static = max(lengths)
work = sum(lengths)
slots = len(lengths) * steps_static
print("static: util =", f"{work/slots*100:.0f}%", # 47%
f"({work} useful / {slots} slot-steps)")
# Continuous: a finished slot is refilled; here no queue, so idle slots simply free up.
# Utilization = useful work / (slot-steps actually occupied)
occupied = sum(lengths) # each slot occupied exactly as long as it runs
print("continuous: util =", f"{work/occupied*100:.0f}%") # 100% of occupied slot-steps
The straggler forces static batching to hold idle slots for 7 extra steps, wasting ~53% of the GPU. Continuous batching never holds a finished slot idle, which is why vLLM/TGI get their throughput.
Context: Choosing the batch size is the actual tuning task: find the largest batch that still meets a p95 TPOT budget — the math behind vLLM's max-num-seqs.
Your task: Given a p95 TPOT budget and the step-time-vs-batch model, find the largest batch that still meets the budget.
Requirements:
- Reuse the step-time-grows-with-batch model
- Increase batch while the step time stays within the budget
- Sweep several budgets and report each max batch
- Show a looser budget permits a bigger batch and more throughput
- Tie the result to vLLM's
max-num-seqs
💡 Hint: Walk the batch up until the modeled step time would breach the budget, then stop — the last passing batch is your max-num-seqs.
Show solution
Search the knob against the SLO — exactly the tuning move:
def step_ms(batch, base=25, per_extra=0.4):
return base + batch * per_extra
def max_batch_under(budget_ms):
b = 1
while step_ms(b + 1) <= budget_ms:
b += 1
return b, round(step_ms(b), 1)
for budget in (40, 60, 100):
b, s = max_batch_under(budget)
print(f"budget {budget} ms/tok -> max batch {b} (step {s} ms)")
# budget 40 -> max batch 37
# budget 100 -> max batch 187
A looser latency budget lets you pack a bigger batch and win more throughput. You configure max-num-seqs; this is the math behind choosing it.
Context: Continuous batching is on by default in vLLM — you don't write the scheduler, you tune two batch knobs against your SLO.
Your task: Map the tuning knobs to real vLLM flags: show the launch with the batching-relevant flags, note which chapter each maps to, and label it as needing a GPU.
Requirements:
- Launch the vLLM OpenAI-compatible server
- Set
--max-num-seqs(IC4: max concurrent sequences) - Set
--max-num-batched-tokens(IC4: token budget per step) - Set
--gpu-memory-utilization(IC3: VRAM for weights + KV) - Note continuous batching is default: raise for throughput, lower if p95 breaches
💡 Hint: You configure, not code — the two batch flags plus the memory-utilization flag are the whole tuning surface.
Show solution
The real launch — needs a GPU + pip install vllm:
# serve.sh — continuous batching is on by default in vLLM
python -m vllm.entrypoints.openai.api_server \
--model mistralai/Mistral-7B-Instruct-v0.3 \
--max-num-seqs 128 \ # IC4: max concurrent sequences in a batch
--max-num-batched-tokens 8192 \ # IC4: token budget per scheduler step
--gpu-memory-utilization 0.90 # IC3: VRAM for weights + KV-cache
# Then point any OpenAI client at http://localhost:8000/v1
You do not write the scheduler — vLLM does continuous batching for you. Your job is the two batch knobs: raise them for throughput, lower them if p95 latency breaches the SLO.
Context: Under a traffic spike, latency and capacity are separate problems: shrink the batch to protect p95, then scale replicas horizontally for QPS — one knob can't fix both.
Your task: At peak QPS your p95 TPOT breaches 60 ms and the queue grows. Given a batch model, decide whether to lower max batch (protect latency) or add replicas (protect throughput) and print a reasoned plan.
Requirements:
- Show the current batch breaches the p95 budget
- Find the largest safe batch under the budget
- Compute per-replica throughput vs required throughput (peak QPS × out tokens)
- Ceil-divide to size the replica count
- Conclude: shrink batch for latency, scale replicas for QPS
💡 Hint: The safe batch fixes p95; the throughput gap it leaves is closed by replicas — two levers for two independent problems.
Show solution
Diagnose whether the pressure is latency or capacity, then act:
def step_ms(batch, base=25, per_extra=0.4):
return base + batch * per_extra
peak_qps, out_tok = 40, 200
current_batch = 128
p95 = step_ms(current_batch)
print("current p95 step:", p95, "ms") # 76.2 ms -> breaches 60 ms
# Option A: cap batch to meet latency
safe_batch = 1
while step_ms(safe_batch+1) <= 60: safe_batch += 1
cap_thru = safe_batch / (step_ms(safe_batch)/1000)
need_thru = peak_qps * out_tok
print(f"cap batch to {safe_batch}: {cap_thru:.0f} tok/s vs need {need_thru} tok/s")
replicas = -(-need_thru // int(cap_thru))
print(f"=> cap batch AND run {replicas} replicas to hold both SLO and QPS")
Capping the batch fixes p95 but drops per-replica throughput below demand, so latency and capacity are separate problems: shrink the batch to protect p95, then scale replicas horizontally to carry the QPS. One knob cannot fix both.
✓ Checkpoint — you can move on when you can…
- Explain why batching raises throughput more than latency.
- Contrast static and continuous batching.
- Describe how a scheduler keeps the GPU saturated.
- Tune concurrency to a latency SLO and find the knee.
Knowledge check check yourself
Why does batching raise throughput far more than it raises latency for memory-bound decode?
Show answer
Contrast static batching with continuous (in-flight) batching and explain why the latter keeps the GPU busier.