Personalize this lesson
Adapt explanations and teaching visuals to your background and preferred voice.
FlashAttention reduces memory traffic inside one attention kernel. The next serving question sits one layer higher: how does an inference server decide which requests share each forward pass?
Continuous batching is an inference scheduler problem: keep the GPU busy while requests arrive with different prompt lengths, output lengths, and deadlines. Production LLM servers batch work without waiting for every request to finish at the same time.
A shared code-assistant endpoint during a release freeze receives mixed work. Some developers ask for a one-line completion. Others paste a failing test log and ask for a migration plan. Hundreds of mixed-length requests hit the same serving pool at once.
A simple request-level baseline handles this with static batching. It fills a batch of requests, runs them together, and waits for the longest one to finish before starting the next batch. If one developer asks for a ten-paragraph migration plan, completed short answers leave idle batch slots until the long generation ends.
Continuous batching[1][2] fixes this waste by letting the server reuse a slot at the next token-generation step (the next iteration) after a request finishes. Under sustained mixed-length workloads, that can increase useful GPU work per step without changing the model itself. It pairs naturally with flexible Key-Value (KV) cache management, because admission still depends on available cache memory.

Why Batching Is Tricky for LLMs
To understand why static batching is so wasteful, recall two facts about how LLMs generate text.
First, LLMs are autoregressive: they produce text one token at a time. Processing the input prompt (the prefill phase) can be done in parallel, but generating each new token (the decode phase) happens sequentially. A one-line completion might need only ten decode steps. A long migration explanation might need two hundred.
Second, the output token count usually isn't known ahead of time. The engine stops when it hits a stop token or a length limit. That means the scheduler can't know in advance when a request will finish.
These two facts make LLM serving different from image-classification serving, where every request has the same fixed shape and finishes in a single forward pass.
The Problem with Static Batching
Static batching treats the batch as one unit of work. Once those two LLM facts are in play, varying prompt and output lengths make that design expensive.
With static batching, all requests in a batch must wait for the longest request to complete before the next batch can start. This synchronous execution model forces the engine to maintain the batch shape until each sequence is fully generated. That leaves large idle capacity and, in pipelined deployments, pipeline bubbles:
| Request | Tokens | Status |
|---|---|---|
| Request 1 | 100 tokens | Done at step 100, waits 300 more steps |
| Request 2 | 400 tokens | Finishes at step 400 |
| Request 3 | 50 tokens | Done at step 50, waits 350 more steps |
Most slots become idle as soon as their request finishes, so utilization can collapse in heterogeneous workloads. Requests 1 and 3 are done early, but their GPU memory stays reserved until Request 2 completes. No new requests can join because the batch shape is fixed. Throughput falls even though the hardware still has work it could be doing.
Continuous Batching (Iteration-Level Scheduling)
Continuous batching (also known as iteration-level scheduling) moves scheduling to the decode-step boundary. After each generation step (or after each prefill chunk in engines that chunk prefills), the server checks which requests finished, frees their slots, and admits new work if memory and token-budget constraints allow.
Static batching freezes the active request set until the longest response finishes. Even if two short completions finished hundreds of decode steps ago, their slots stay blocked until the slowest request ends. Continuous batching rebuilds the active set at decode boundaries: as soon as one sequence stops, the next queued sequence can take that slot. The GPU keeps more useful token work in flight.
Completed requests leave immediately, and queued requests can join on the next iteration. The scheduler effectively rebuilds the active running set for the next forward pass.
| Step | Batch Composition | Event |
|---|---|---|
| 0 | [Req1, Req2, Req3] | Initial batch |
| 50 | [Req1, Req2, Req4 (new)] | Req3 done, Req4 joins |
| 100 | [Req5 (new), Req2, Req4] | Req1 done, Req5 joins |
| 400 | [Req5, Req6 (new), Req4] | Req2 done, Req6 joins |
A worked example: three code-assistant requests
Make the waste concrete with numbers. Suppose the GPU can run three requests at once, and three code-assistant requests arrive:
| Request | Task | Output needed |
|---|---|---|
| Request A | One-line completion | 10 tokens |
| Request B | Failing-test explanation | 50 tokens |
| Request C | Migration plan | 200 tokens |
Static batching runs all three together. Request A finishes after 10 iterations, Request B after 50, and Request C after 200. For iterations 11 through 200, the slots for A and B sit idle. Total GPU iterations for this batch: 200. Wasted slot-iterations: 190 for A's slot plus 150 for B's slot = 340 idle slots.
Continuous batching starts with [A, B, C]. At iteration 10, Request A finishes and a new request (Request D) joins immediately. At iteration 50, Request B finishes and Request E joins. The GPU keeps all three slots busy until the queue empties. The only idle iterations happen when the waiting queue is empty.
In this trace, continuous batching fills slots that static batching would leave idle, so the server can finish substantially more useful completions per GPU hour without a hardware upgrade.
Under sustained load, slot reuse avoids idle work from completed short requests. Actual utilization still depends on token budgets, memory pressure, phase mix, and kernel efficiency.
This first calculation isolates slot reuse from GPU details. It treats each active output token as one useful slot-iteration:
1output_lengths = [10, 50, 200]
2batch_slots = len(output_lengths)
3static_capacity = max(output_lengths) * batch_slots
4useful_work = sum(output_lengths)
5idle_work = static_capacity - useful_work
6
7print(f"useful slot-iterations: {useful_work}")
8print(f"static idle slot-iterations: {idle_work}")
9print(f"static utilization: {useful_work / static_capacity:.1%}")1useful slot-iterations: 260
2static idle slot-iterations: 340
3static utilization: 43.3%Slot utilization isn't GPU utilization. The 43.3% figure is batch-slot occupancy: how often a request-shaped seat is busy under static batching. Filling every slot still leaves tensor cores idle on memory-bandwidth-bound decode, and HBM can be nearly full while model FLOPs utilization (MFU) stays low. Rough ladder:
| Regime | Slot util | Typical decode MFU / arithmetic intensity |
|---|---|---|
| Static batch, mixed lengths | Low (idle seats after short finishes) | Still often bandwidth-bound |
| Continuous batch, full seats | High | Still often low MFU on pure decode |
| Larger token budget / chunked prefill co-batch | High, more prefill tokens per step | Higher arithmetic intensity on the co-batch |
Continuous batching is a GPU capacity win because it keeps more useful work in flight. It doesn't promise that SM occupancy or MFU jump to 100%.

One subtle but important detail: modern schedulers are usually limited by a token budget or KV-cache budget, rather than a raw "number of requests" cap. One long prefill can consume more scheduler budget than many short decode-only requests.
Continuous batching pairs naturally with a flexible memory manager. Because requests come and go, their Key-Value (KV) caches are harder to store efficiently in contiguous tensors. PagedAttention (block-based memory management) is a practical companion to continuous batching.
Memory management: the hidden enabler
To understand why continuous batching was difficult to implement initially, we need to look at memory management. Recall that during the decode phase, the model stores a Key-Value (KV) cache: a running record of attention states for every token generated so far. Because requests leave and join the batch at unpredictable times, their KV caches are harder to store efficiently in contiguous tensors.
The fragmentation problem
In standard Transformer implementations, the KV cache for a request is often treated like a growing contiguous buffer sized for a maximum sequence length.
When requests have different lengths and arrive at different times:
- Internal Fragmentation: You have to pre-allocate memory for
max_seq_len(e.g., 4096 tokens) even if the request generates 50 tokens. - External Fragmentation: As requests finish and leave, they create variable-sized "holes" in GPU memory that new requests might not fit into cleanly.
The KV cache for a single sequence grows with every decode step. For Qwen3.6-27B's published Gated Attention geometry (16 full-attention layers, 4 KV heads, head dimension 256, FP16 cache), each new token adds about 64 KiB of KV state.[3] A 4,096-token sequence therefore needs roughly 0.25 GiB of cache. If those same 16 layers stored separate K/V tensors for all 24 query heads instead of GQA, the cache would grow to roughly 1.5 GiB at 4K tokens. That's why fragmentation hurts so much: even modest over-allocation can strand gigabytes of VRAM that could have admitted other requests.
A useful size estimate follows this formula:
Where is the number of layers, is the sequence length, is the number of KV heads, is the head dimension, and is bytes per element (2 for FP16). The leading 2 accounts for both key and value tensors.
Use the formula directly before choosing a concurrency target:
1layers, kv_heads, head_dim, bytes_per_value = 16, 4, 256, 2
2tokens = 4096
3bytes_per_token = 2 * layers * kv_heads * head_dim * bytes_per_value
4cache_bytes = bytes_per_token * tokens
5
6print(f"KV state per token: {bytes_per_token / 1024:.0f} KiB")
7print(f"KV state at {tokens:,} tokens: {cache_bytes / 1024**3:.2f} GiB")1KV state per token: 64 KiB
2KV state at 4,096 tokens: 0.25 GiBWhich term makes KV-cache memory grow during decoding?
Answer
, the sequence length. Every new generated token extends the stored key and value tensors, so cache memory grows linearly with active sequence length.
PagedAttention Solution
PagedAttention[2] solves this by borrowing the concept of virtual memory from operating systems, as illustrated below.

- KV Blocks: The KV cache is divided into fixed-size blocks (e.g., 16 or 32 tokens).
- Non-Contiguous Allocation: Blocks can be stored anywhere in GPU memory (VRAM). They don't need to be contiguous.
- Block Table: Each request keeps a mapping table from logical page to physical block that tells the runtime where its KV blocks live.
This allows the scheduler to allocate memory dynamically as tokens are generated. The vLLM paper reports near-zero KV-cache waste in its evaluated allocation design, in contrast with substantially higher reservation waste in its baseline systems.[2] Fixed blocks make dynamic admission practical without requiring large contiguous allocations.
This toy allocation computes the last-block slack for a few active sequences:
1block_size = 16
2sequence_lengths = [17, 31, 48, 65]
3reserved = sum(((length + block_size - 1) // block_size) * block_size for length in sequence_lengths)
4used = sum(sequence_lengths)
5slack = reserved - used
6
7print(f"active tokens: {used}")
8print(f"reserved block slots: {reserved}")
9print(f"last-block slack: {slack} tokens ({slack / reserved:.1%})")1active tokens: 161
2reserved block slots: 192
3last-block slack: 31 tokens (16.1%)PagedAttention is a memory management technique for non-contiguous KV blocks. FlashAttention is a compute optimization that tiles attention to reduce High Bandwidth Memory (HBM) I/O. They are orthogonal techniques, but systems such as vLLM often combine them to improve both memory capacity and compute speed.
Prefill vs. Decode Phases
LLM serving has two distinct computational phases with opposing characteristics:
Prefill (Prompt Processing)
- Processes all input tokens in parallel (like training).
- Often compute-heavy because it processes many prompt tokens in matrix multiplications.
- Runs once per request at the start.
- Latency goal: Minimize Time To First Token (TTFT).
Decode (Token Generation)
- Generates tokens one at a time (autoregressive).
- Often memory-bandwidth limited at practical batch sizes because each step processes few new tokens while reading model and KV state.
- Runs many times per request (once per output token).
- Latency goal: Minimize inter-token latency (ITL), also called time between tokens (TBT). TPOT is closely related but isn't identical: papers such as DistServe define Time Per Output Token (TPOT) as the average time per output token after the first.[4]
This table summarizes the key differences between the prefill and decode phases:
| Phase | Arithmetic Intensity | Bottleneck | Batching Strategy |
|---|---|---|---|
| Prefill | Often high | Frequently compute throughput | Batch prompt tokens |
| Decode | Often lower | Frequently memory bandwidth | Batch active streams |
The interference problem
Blindly mixing a full long prefill with decode work can harm inter-token latency. A large prefill request (for example, processing a 4K-token document) can delay decode steps for concurrent streams. Chunked prefill bounds how much prefill work competes in one scheduling step.
Token-budget scheduling makes that bound visible:
1max_num_batched_tokens = 1024
2decode_requests = 96
3pending_prefill_tokens = 1800
4prefill_budget = max_num_batched_tokens - decode_requests
5scheduled_prefill = min(pending_prefill_tokens, prefill_budget)
6
7print(f"decode tokens scheduled first: {decode_requests}")
8print(f"prefill tokens scheduled now: {scheduled_prefill}")
9print(f"prefill tokens left for later: {pending_prefill_tokens - scheduled_prefill}")1decode tokens scheduled first: 96
2prefill tokens scheduled now: 928
3prefill tokens left for later: 872Solutions include:
- Chunked prefill: Break long prefills into chunks, then co-schedule bounded chunks with decode work.
- Disaggregated prefill: Separate prefill and decode onto different GPU pools.[4]
- Priority scheduling: Prioritize decode steps to minimize latency.
Scheduling policies
The scheduler rebuilds the active set every iteration. Policy decides which waiting work gets scarce decode slots next.
First-Come-First-Served (FCFS)
FCFS admits requests in arrival order. That is easy to reason about and usually fair, but a long migration plan at the front can block many shorter completions behind it.
Shortest-Job-First (SJF)
The scheduler prioritizes requests that are expected to require fewer output tokens. In theory, this can improve average latency because short responses (like a single-line completion) don't get stuck behind a long design explanation. In practice, output length is unknown ahead of time, so pure SJF is rare in LLM serving. Real systems approximate it with heuristics such as prompt-length bucketing, tenant priorities, or token-budget limits.
This queue calculation shows the benefit SJF would have if output lengths were known. Production engines need estimates, priorities, or fairness controls because they don't know these lengths in advance:
1jobs = [("long", 50), ("quick", 5), ("medium", 20)]
2
3def average_completion(order):
4 elapsed = 0
5 completion_times = []
6 for _, tokens in order:
7 elapsed += tokens
8 completion_times.append(elapsed)
9 return sum(completion_times) / len(completion_times)
10
11fcfs = average_completion(jobs)
12oracle_sjf = average_completion(sorted(jobs, key=lambda job: job[1]))
13print(f"FCFS average completion: {fcfs:.1f} steps")
14print(f"oracle SJF average completion: {oracle_sjf:.1f} steps")
15print("production caveat: output length is not known up front")1FCFS average completion: 60.0 steps
2oracle SJF average completion: 35.0 steps
3production caveat: output length is not known up frontPriority and Decode-First Scheduling
Many production schedulers care more about keeping existing streams smooth than about starting every new request immediately. A user notices ITL jitter right away, while a modest TTFT increase on a newly arrived long prompt is often less damaging. That leads to policies such as decode-first scheduling, per-tenant priorities, and SLO-aware admission control.[4]
Preemption strategies
When GPU memory is full (KV cache blocks are exhausted), the scheduler must either pause work, evict state, or stop admitting new requests. Two recovery designs are:
- Recomputation: Drop the victim request's KV cache and re-run its prompt tokens when the request is rescheduled.
- Pros: Zero memory overhead on CPU; simple to implement.
- Cons: Wastes GPU compute; works best when the prompt is short (low prefill cost).
- Swap Out (CPU Offload): Move the victim request's KV cache from GPU VRAM to CPU RAM.
- Pros: Saves progress; can be cheaper than recomputation when discarded state would be expensive to rebuild.
- Cons: Consumes host-device bandwidth and adds resume latency.
These are design options, not universal defaults. Current vLLM V1 uses RECOMPUTE as its default preemption mode because recomputation has lower overhead in that architecture.[5] The cheaper choice in another runtime still depends on prompt length, host-device bandwidth, and how expensive replaying prefill would be. If you tune a particular engine, check its current scheduler documentation and trace preemption events rather than assuming one policy is active.
This simplified Python simulation of a continuous batch scheduler demonstrates the core iteration loop, including consistent KV-block accounting as requests grow token by token and one swap-out preemption path when the next step can't fit. This swap-out-only teaching model differs from current vLLM V1 preemption, which defaults to recompute. The schedule_step method is called before every forward pass, and finish_decode_step is called after that pass emits one token for each active request.
1from dataclasses import dataclass
2
3@dataclass
4class Request:
5 id: str
6 prompt_len: int
7 max_new_tokens: int
8 priority: int = 0 # Higher value = higher priority
9 generated_tokens: int = 0
10 allocated_blocks: int = 0
11 swapped_to_cpu: bool = False
12
13 @property
14 def total_tokens(self) -> int:
15 return self.prompt_len + self.generated_tokens
16
17 def is_finished(self) -> bool:
18 """Check if request has reached EOS or max_new_tokens."""
19 return self.generated_tokens >= self.max_new_tokens
20
21class ContinuousBatchScheduler:
22 """
23 Simplified iteration-level scheduler.
24
25 Real engines also track per-request token budgets, prefix-cache hits,
26 EOS detection, and beam-search groups. This example focuses on the core
27 loop: retire finished requests, resume paused work, admit new work, then
28 preempt if the next decode step would overflow KV-cache capacity.
29 """
30 BLOCK_SIZE = 16
31
32 def __init__(self, max_batch_size: int, max_memory_blocks: int):
33 self.max_batch_size = max_batch_size
34 self.max_memory_blocks = max_memory_blocks
35 self.running: list[Request] = [] # Currently generating on GPU
36 self.waiting: list[Request] = [] # Queued requests
37 self.swapped: list[Request] = [] # Preempted to CPU RAM
38 self.used_memory_blocks = 0
39 self.preemptions = 0
40
41 def schedule_step(self) -> list[Request]:
42 # 1. Retire finished requests and free their KV cache.
43 active = []
44 for req in self.running:
45 if req.is_finished():
46 self._free_kv_cache(req)
47 else:
48 active.append(req)
49 self.running = active
50
51 # 2. Resume preempted requests first so they don't starve.
52 self._admit(self.swapped, from_cpu=True)
53
54 # 3. Admit fresh waiting requests.
55 self._admit(self.waiting, from_cpu=False)
56
57 # 4. Preempt only if the next decode step would exceed the safe budget.
58 while self.running and not self._can_run_next_decode_step(self.running):
59 victim = min(self.running, key=lambda r: (r.priority, r.generated_tokens))
60 self.running.remove(victim)
61 self._swap_out_to_cpu(victim)
62 victim.swapped_to_cpu = True
63 self.swapped.append(victim)
64 self.preemptions += 1
65
66 return self.running
67
68 def finish_decode_step(self) -> None:
69 # Call this after the model emits one token for each running request.
70 for req in self.running:
71 req.generated_tokens += 1
72 required_blocks = self._blocks_for_tokens(req.total_tokens)
73 growth = required_blocks - req.allocated_blocks
74 if growth > 0:
75 self.used_memory_blocks += growth
76 req.allocated_blocks = required_blocks
77
78 def _admit(self, queue: list[Request], from_cpu: bool) -> None:
79 queue.sort(key=lambda r: r.priority, reverse=True)
80
81 while (
82 queue
83 and len(self.running) < self.max_batch_size
84 and self._can_allocate_current_state(queue[0])
85 ):
86 req = queue.pop(0)
87 if from_cpu:
88 self._swap_in_from_cpu(req)
89 else:
90 self._allocate_kv_cache(req)
91 req.swapped_to_cpu = False
92 self.running.append(req)
93
94 def _blocks_for_tokens(self, token_count: int) -> int:
95 return (token_count + self.BLOCK_SIZE - 1) // self.BLOCK_SIZE
96
97 def _can_allocate_current_state(self, req: Request) -> bool:
98 return self.used_memory_blocks + self._blocks_for_tokens(req.total_tokens) <= self.max_memory_blocks
99
100 def _allocate_kv_cache(self, req: Request) -> None:
101 req.allocated_blocks = self._blocks_for_tokens(req.total_tokens)
102 self.used_memory_blocks += req.allocated_blocks
103
104 def _free_kv_cache(self, req: Request) -> None:
105 self.used_memory_blocks -= req.allocated_blocks
106 req.allocated_blocks = 0
107
108 def _swap_out_to_cpu(self, req: Request) -> None:
109 self._free_kv_cache(req)
110
111 def _swap_in_from_cpu(self, req: Request) -> None:
112 self._allocate_kv_cache(req)
113
114 def _projected_growth_for_next_token(self, req: Request) -> int:
115 next_blocks = self._blocks_for_tokens(req.total_tokens + 1)
116 return max(0, next_blocks - req.allocated_blocks)
117
118 def _can_run_next_decode_step(self, batch: list[Request]) -> bool:
119 growth = sum(self._projected_growth_for_next_token(req) for req in batch)
120 return self.used_memory_blocks + growth <= self.max_memory_blocks
121
122scheduler = ContinuousBatchScheduler(max_batch_size=3, max_memory_blocks=16)
123scheduler.waiting.extend([
124 Request("req_A", prompt_len=8, max_new_tokens=3),
125 Request("req_B", prompt_len=8, max_new_tokens=1),
126 Request("req_C", prompt_len=8, max_new_tokens=2),
127 Request("req_D", prompt_len=8, max_new_tokens=2),
128 Request("req_E", prompt_len=8, max_new_tokens=1),
129])
130
131timeline: list[list[str]] = []
132peak_blocks = 0
133for _ in range(6):
134 active = scheduler.schedule_step()
135 timeline.append([req.id for req in active])
136 peak_blocks = max(peak_blocks, scheduler.used_memory_blocks)
137 scheduler.finish_decode_step()
138 peak_blocks = max(peak_blocks, scheduler.used_memory_blocks)
139
140print(timeline[:3])
141print(f"peak blocks: {peak_blocks}/{scheduler.max_memory_blocks}")
142
143assert timeline[0] == ["req_A", "req_B", "req_C"]
144assert timeline[1] == ["req_A", "req_C", "req_D"]
145assert "req_E" in timeline[2]
146assert peak_blocks <= scheduler.max_memory_blocks
147
148# A tight KV-block budget forces an actual preemption before decode.
149pressure = ContinuousBatchScheduler(max_batch_size=2, max_memory_blocks=2)
150pressure.waiting.extend([
151 Request("urgent", prompt_len=16, max_new_tokens=2, priority=1),
152 Request("background", prompt_len=16, max_new_tokens=2, priority=0),
153])
154active_under_pressure = pressure.schedule_step()
155print([req.id for req in active_under_pressure], f"preemptions={pressure.preemptions}")
156assert [req.id for req in active_under_pressure] == ["urgent"]
157assert pressure.preemptions == 11[['req_A', 'req_B', 'req_C'], ['req_A', 'req_C', 'req_D'], ['req_A', 'req_D', 'req_E']]
2peak blocks: 3/16
3['urgent'] preemptions=1schedule_step() rebuilds the active set at every iteration, scanning the running requests and admitting queued work under the batch and KV-cache budgets.
In a real server, the control loop alternates between schedule_step() and finish_decode_step(): schedule the next forward pass, run it once, record the extra token and any newly needed KV block, then repeat. Real schedulers add more machinery on top of this loop, such as prefix-cache reuse, cancellation handling, speculative decoding, and token-budget accounting. The tight-budget assertion proves that this example's swap-out preemption branch runs rather than merely existing in the code.
A recompute path is the dual policy (and the one many engines default to). Pseudocode for the victim branch:
1# Contrast with ContinuousBatchScheduler's swap-out path above.
2def preempt_by_recompute(self, victim: Request) -> None:
3 self._free_blocks(victim.allocated_blocks)
4 victim.allocated_blocks = 0
5 victim.generated_tokens = 0 # or keep text and only rebuild KV
6 victim.swapped_to_cpu = False
7 self.running.remove(victim)
8 self.waiting.append(victim) # re-prefill when admitted again
9 self.preemptions += 1
10# assert: victim returns to waiting with allocated_blocks == 0Swap-out pays host-device bandwidth to keep KV. Recompute pays prefill FLOPs to rebuild it. Choose from measured prompt length, PCIe bandwidth, and preemption frequency.
Continuous batching × speculative decoding
Speculative decoding is more than a per-request speedup; it changes the token-budget math for the whole continuous batch:
- Budget inflation. After verify, each active request may emit up to
k + 1tokens (draft span plus possible bonus). The scheduler must reservemax_num_batched_tokensas if those multi-token steps can land together, not as one token per request. - ITL jitter from acceptance variance. High acceptance packs more useful tokens per step; rejects free partial work unevenly and can stall one stream while neighbors stream smoothly, even when mean TPOT looks fine.
- Mixed streams. Speculative and non-speculative requests share one token budget. A burst of long draft verifies can starve plain decode seats the same way a fat prefill can.
The accept/reject rule lives in speculative decoding. Keep the continuous-batch loop and token budget from this lesson when you read it.
Why does continuous batching usually schedule by token or KV-cache budget instead of only request count?
Answer
Requests have different prompt lengths and generated lengths. One long prompt can consume far more prefill work and KV-cache memory than many short chats, so request count alone misstates GPU capacity.
Serving Engines and System Designs
These ideas show up across most modern inference stacks, but some entries below are production engines while others are research systems that shaped later engine design:
| Framework | What it emphasizes | Where it shines |
|---|---|---|
| vLLM[2] | PagedAttention and continuous batching for high-throughput serving | General-purpose high-throughput serving |
| TensorRT-LLM[6] | NVIDIA-optimized kernels, in-flight batching, paged KV cache | Teams standardized on the NVIDIA serving stack |
| SGLang[7] | Continuous batching plus aggressive prefix reuse (RadixAttention) | Workloads with repeated prefixes or structured generation |
| DistServe[4] | Explicit prefill/decode disaggregation and SLO-aware scheduling | Deployments where TTFT and TPOT targets dominate raw TPS |
Other servers implement many of the same ideas too. Feature matrices change quickly, so treat engine comparisons as moving targets and verify the current docs before making a production choice.
Throughput vs. Goodput
Benchmark numbers in this space are highly workload-dependent. The meaningful comparison isn't "Which server is fastest?" but "Which system stays within TTFT and TPOT targets for my traffic mix?"
The literature still gives useful anchor points:
| Source | What it measured | Why it matters |
|---|---|---|
| Orca[1] | Iteration-level scheduling against request-level baselines | Showed that per-iteration scheduling can improve throughput and latency together |
| vLLM[2] | PagedAttention plus continuous batching against prior serving systems | Reported 2-4x higher throughput at the same latency level compared with systems such as FasterTransformer and Orca in the paper's evaluated setups |
| DistServe[4] | Phase-disaggregated serving under TTFT/TPOT SLOs | Reported up to 7.4x more request capacity or 12.6x tighter SLOs while keeping more than 90% of requests within latency constraints |
Goodput: the production metric
Raw throughput can be misleading if a large fraction of requests miss their latency target. DistServe frames goodput as the maximum offered request rate, , that keeps the required fraction, , of requests within both TTFT and TPOT objectives.[4] A compact definition is:
Here, and are the latency limits, while is the required attainment fraction, such as 90%. To measure goodput, increase offered load and select the highest request rate whose attainment remains at or above . In interview settings, this is the right framing: optimize tokens/sec only after you define the TTFT, TPOT, and attainment targets that matter for users.
A single observation window doesn't establish that maximum. It can still report observed SLO-compliant throughput, which is useful when diagnosing why raw throughput alone hides latency failures:
1window_seconds = 10
2latencies_ms = [
3 {"ttft": 120, "tpot": 25},
4 {"ttft": 450, "tpot": 22},
5 {"ttft": 180, "tpot": 42},
6 {"ttft": 190, "tpot": 27},
7]
8ttft_target, tpot_target = 300, 30
9within_slo = [
10 item for item in latencies_ms
11 if item["ttft"] <= ttft_target and item["tpot"] <= tpot_target
12]
13
14print(f"raw throughput: {len(latencies_ms) / window_seconds:.2f} req/s")
15print(f"observed SLO-compliant throughput: {len(within_slo) / window_seconds:.2f} req/s")
16print(f"SLO-compliant requests: {len(within_slo)}/{len(latencies_ms)}")1raw throughput: 0.40 req/s
2observed SLO-compliant throughput: 0.20 req/s
3SLO-compliant requests: 2/4Why doesn't this 10-second window establish goodput?
Answer
It measures compliant completions at one offered load. Establishing goodput requires sweeping offered request rates and finding the maximum rate that still meets the chosen SLO-attainment target.
Advanced scheduling: chunked prefill
One remaining bottleneck in naive continuous batching is prefill/decode interference. Sarathi-Serve[8] addresses this with chunked-prefills plus decode-maximal batching. A single long prefill can delay other requests waiting for a decode step, producing visible gaps in token output when the phase mix is poorly scheduled.
Mechanism
Chunked prefill splits a long prefill phase into smaller blocks (for example, 512-token or 1,024-token chunks). Sarathi-Serve then uses decode-maximal batching to pair one prefill chunk with as many decode requests as possible, so the chunk keeps the GPU compute units busy while the decodes "piggyback" on the same batch. This flow shows how chunking prevents latency spikes:

Chunking protects decode cadence
Without chunked prefill, a long prefill can occupy a long scheduling interval. During this window, concurrent decode requests wait for their next token. Users can see irregular token output even if average TBT looks acceptable.
With chunked prefill, the same 4,000-token document can be split into smaller pieces. With a 512-token chunk size, for example, the scheduler processes about 8 chunks. It can pair an admitted chunk with decode work that fits the same token budget, then reconsider the active mix before the next chunk. The long prompt may pay overhead from additional scheduling and kernel boundaries, but one admitted prefill chunk no longer consumes the entire prompt in one step.
Current vLLM V1 documentation describes chunked prefill as enabled by default: the scheduler prioritizes decode requests and uses the remaining max_num_batched_tokens budget for prefills, chunking a prefill that doesn't fit. Its tuning guidance notes the tradeoff: larger token budgets tend to improve TTFT and throughput, while smaller budgets can improve inter-token latency.[5]
The same budget tradeoff becomes easier to inspect as a small schedule:
1prompt_tokens = 4000
2chunk_size = 512
3chunks = []
4remaining = prompt_tokens
5while remaining:
6 current = min(chunk_size, remaining)
7 chunks.append(current)
8 remaining -= current
9
10print(f"chunk count: {len(chunks)}")
11print(f"first/last chunk: {chunks[0]}/{chunks[-1]} tokens")
12print(f"decode scheduling boundaries: {len(chunks) - 1}")1chunk count: 8
2first/last chunk: 512/416 tokens
3decode scheduling boundaries: 7Disaggregated serving
A more advanced approach, disaggregated serving, splits prefill and decode across separate worker pools. Splitwise[9] and DistServe[4] study this design explicitly. The motivation is simple: prefill often wants dense matrix-math throughput, while decode often wants KV-cache capacity and memory bandwidth.
Disaggregation isn't the right default for every deployment. For short prompts, small clusters, or workloads with high prefix-cache hit rates, running prefill on the decode worker can be simpler than paying a KV-transfer hop. The right comparison is goodput under a defined TTFT and TPOT objective, including transfer overhead.
Architecture
Splitting these two phases creates a specialized pipeline where requests move to the hardware pool that fits their current state. The diagram traces that disaggregated architecture:

Benefits
-
Prefill workers: Can be sized and tuned for high matrix-math throughput.
-
Decode workers: Can be sized for KV-cache capacity and memory bandwidth without worrying about prefill interference.
-
Scaling: You can scale prefill and decode pools independently based on whether your traffic skews toward long prompts or long generations.
Cost
The KV cache (or equivalent prefill state) must be transferred between pools. Whether that transfer is worth it depends on prompt length, interconnect speed, and your TTFT/TPOT SLOs. DistServe is a good reminder that once you optimize for goodput instead of raw TPS, paying that transfer cost can still be the right tradeoff.[4]
Estimate the handoff bytes before assuming split pools improve latency:
1layers, kv_heads, head_dim, bytes_per_value = 16, 4, 256, 2
2prompt_tokens = 4096
3link_gib_per_second = 50
4kv_bytes = 2 * layers * kv_heads * head_dim * bytes_per_value * prompt_tokens
5transfer_ms = kv_bytes / (link_gib_per_second * 1024**3) * 1000
6
7print(f"KV handoff size: {kv_bytes / 1024**3:.2f} GiB")
8print(f"idealized transfer time at {link_gib_per_second} GiB/s: {transfer_ms:.1f} ms")
9print("production check: add protocol, queueing, and synchronization overhead")1KV handoff size: 0.25 GiB
2idealized transfer time at 50 GiB/s: 5.0 ms
3production check: add protocol, queueing, and synchronization overheadCommon mistakes in production
Even experienced engineers trip over a few predictable traps when tuning a continuous batching setup.
Static batching mindset
-
Symptom: You design the scheduler as if all requests in a batch must start and finish together.
-
Cause: Traditional model-serving systems often batch fixed-shape requests, so the batch is treated as one request-level unit of work.
-
Fix: Treat decoding as an iteration-level loop. Finished requests leave at the next boundary, cancellations free their KV blocks, and waiting requests enter when batch and token budgets allow.
The OOM oversimplification
-
Symptom: You increase
max_batch_sizeand the server crashes with an out-of-memory error mid-generation. -
Cause: You treated memory as a fixed cost per request, ignoring that the KV cache grows with every output token. A batch of ten short prompts fits easily, but if those prompts turn into long generations, the cache doubles or triples in size.
-
Fix: Tune by KV cache blocks or token budget, not by raw request count. Monitor peak memory during the longest generations in your workload, and leave headroom for bursts.
Ignoring Time to First Token (TTFT)
-
Symptom: Throughput benchmarks look great, but users complain that new requests take forever to start responding.
-
Cause: The scheduler is so focused on keeping the decode batch full that it delays admitting new prefills. A long queue of decode work starves the waiting queue.
-
Fix: Set a maximum decode-only iteration budget or a waiting-timeout. If a request sits in the queue longer than your TTFT target, preempt a low-priority decode stream and admit the new prefill. The right balance depends on your product: a chat app needs low TTFT, while a background summarization pipeline can tolerate a longer wait.
Scheduling rules to keep
-
Static batching wastes capacity when request lengths differ. Finished requests leave idle holes until the slowest request completes.
-
Continuous batching is common in high-throughput LLM serving. It schedules at the iteration level, adding and removing requests dynamically.
-
Memory management and scheduling are inseparable. Dynamic admission only works well if KV-cache allocation is flexible.
-
Prefill/decode interference is often the next bottleneck. Chunked prefill and disaggregation both exist to keep decode latency smooth.
-
Don't optimize only for tokens/sec. Optimize for goodput, TTFT, TPOT, and ITL under your actual workload.
The evolution of LLM serving is a move toward finer-grained control: batch-level static scheduling, then iteration-level continuous scheduling, then chunked prefill to reduce interference, then phase-level disaggregation when prefill and decode need different hardware pools.
Run a scheduling stress trace
Run one mixed workload with short and long prompts, record queue wait, TTFT, ITL, token-budget use, KV-block pressure, and preemptions, then compare the trace with your declared scheduler policy. Keep the table as the acceptance artifact for the next policy change.