LeetLLM
My PlanLearnGlossaryTracksPracticeBlog
LeetLLM

Your go-to resource for mastering AI & LLM systems.

Product

  • Learn
  • Glossary
  • Tracks
  • Practice
  • Blog
  • RSS

Legal

  • Terms of Service
  • Privacy Policy

© 2026 LeetLLM. All rights reserved.

All Topics
Your Progress
0%

0 of 196 articles completed

🛠️Computing Foundations0/9
Git, Shell, Linux for AIDocker for Reproducible AIPython for AI EngineeringNumPy and Tensor ShapesCUDA for ML TrainingMPS & Metal for ML on MacData Structures for AISQL and Data ModelingAlgorithms for ML Engineers
📊Math & Statistics0/9
Gradients and BackpropVectors, Matrices & TensorsLinear Algebra for MLAdam, Momentum, SchedulersProbability for Machine LearningBayesian Inference from EvidenceStatistics and UncertaintyDistributions and SamplingHypothesis Tests, Intervals, and pass@k
📚Preparation & Prerequisites0/13
Neural Networks from ScratchCNNs from ScratchTraining & BackpropagationSoftmax, Cross-Entropy & OptimizationRNNs, LSTMs, GRUs, and Sequence ModelingAutoencoders and VAEsThe Transformer Architecture End-to-EndLanguage Modeling & Next TokensFrom GPT to Modern LLMsPrompt Engineering FundamentalsCalling LLM APIs in ProductionFirst AI App End-to-EndThe LLM Lifecycle
🧮ML Algorithms & Evaluation0/15
Linear Regression from ScratchLogistic Regression and MetricsDecision Trees, Forests, and BoostingReinforcement Learning BasicsValidation and LeakageStatistical Learning and GeneralizationConformal Prediction and UncertaintyClustering and PCAProbabilistic Generative ModelsCore Retrieval AlgorithmsDecoding AlgorithmsExperiment Design and A/B TestingCausal Inference and CounterfactualsPyTorch Training LoopsDataset Pipelines and Data Quality
📦Production ML Systems0/6
Feature Engineering for Production MLBatch and Streaming Feature PipelinesGradient Boosted Trees in ProductionRanking and Recommendation SystemsForecasting and Anomaly DetectionMonitoring Predictive Models
🧪Core LLM Foundations0/8
The Bitter Lesson & ComputeBPE, WordPiece, and SentencePieceStatic to Contextual EmbeddingsPerplexity & Model EvaluationFile Ingestion for AIChunking StrategiesLLM Benchmarks & LimitationsInstruction Tuning & Chat Templates
🧰Applied LLM Engineering0/25
Dimensionality Reduction for EmbeddingsCoT, ToT & Self-Consistency PromptingFunction Calling & Tool UseMCP & Tool Protocol StandardsContext EngineeringPrompt Injection DefenseResponsible AI GovernancePrivacy-Preserving Machine LearningData Labeling and Human FeedbackEvaluating AI AgentsProduction RAG PipelinesHybrid Search: Dense + SparseReranking and Cross-Encoders for RAGRAG Evaluation for Reliable AnswersLLM-as-a-Judge EvaluationBias & Fairness in LLMsHallucination Detection & MitigationLLM Observability & MonitoringExperiment Tracking with MLflow and W&BPrompt Optimization with DSPyModel Versioning & DeploymentSemantic Caching & Cost OptimizationLLM Cost Engineering & Token EconomicsModel Gateways, Routing, and FallbacksDesign an Automated Support Agent
🎓Portfolio Capstones0/9
Capstone: Delivery ETA PredictionCapstone: Product RankingCapstone: Demand ForecastingCapstone: Image Damage ClassifierCapstone: Production ML PipelineCapstone: Document QACapstone: Eval DashboardCapstone: Fine-Tuned ClassifierCapstone: Reproducible ML Study
🧠Transformer Deep Dives0/8
Sentence Embeddings & Contrastive LossEmbedding Similarity & QuantizationScaled Dot-Product AttentionVision Transformers and Image EncodersPositional Encoding: RoPE & ALiBiLayer Normalization: Pre-LN vs Post-LNMechanistic InterpretabilityDecoding Strategies: Greedy to Nucleus
🧬Advanced Training & Adaptation0/17
Scaling Laws & Compute-Optimal TrainingPre-training Data at ScaleBuild GPT from Scratch LabJAX for PyTorch ResearchersContinued Pretraining for Domain ShiftSynthetic Data PipelinesSupervised Fine-Tuning PipelineMixed Precision TrainingDistributed Training: FSDP & ZeROLoRA & Parameter-Efficient TuningTraining Run OperationsReward Modeling from Preference DataRLHF & DPO AlignmentConstitutional AI & Red TeamingRLVR & Verifiable RewardsKnowledge Distillation for LLMsModel Merging and Weight Interpolation
🤖Advanced Agents & Retrieval0/16
Vector DB Internals: HNSW & IVFAdvanced RAG: HyDE & Self-RAGGraphRAG & Knowledge GraphsRAG Security & Access ControlStructured Output GenerationReAct & Plan-and-ExecuteGuardrails & Safety FiltersCode Generation & SandboxingComputer-Use / GUI / Browser AgentsHuman-in-the-Loop Agent ArchitectureAI Coding Workflow with AgentsAgent Memory & PersistenceAgent Failure & RecoveryRecursive Language Models (RLM)Multi-Agent OrchestrationCapstone: Production Agent
⚡Inference & Production Scale0/31
Inference: TTFT, TPS & KV CacheMulti-Query & Grouped-Query AttentionKV Cache & PagedAttentionPrefix Caching and Prompt CachingFlashAttention & Memory EfficiencyContinuous Batching & SchedulingScaling LLM InferenceModel Parallelism for LLM InferenceAccelerator Architecture Field GuideCorrect GPU Profiling and BenchmarkingGPU Kernel Performance Engineering LabGPU Parallel Primitives LabGPU Compilers and Kernel LanguagesGEMM From Naive CUDA to Tensor CoresLow-Precision GPU ProgrammingGPU Runtime Execution LabAI-Generated Kernel EngineeringModel Quantization: GPTQ, AWQ & GGUFLocal LLM DeploymentSLM Specialization & Edge DeploymentSpeculative DecodingLong Context Window ManagementMixture of Experts ArchitectureMamba & State Space ModelsReasoning & Test-Time ComputeAdvanced MLOps & DevOps for AIGPU Serving & AutoscalingGPU Failure OperationsDistributed Inference Data PlaneServing Benchmark EngineeringA/B Testing for LLMs
🏗️System Design Capstones0/9
Content Moderation SystemCode Completion SystemMulti-Tenant LLM PlatformLLM-Powered Search EngineVision-Language Models & CLIPMultimodal LLM ArchitectureDiffusion Models: Images & TextReal-Time Voice AI AgentReasoning Agent System Design
🎤AI Lab Interviewing0/4
AI Lab Coding Interview: Python SystemsAI Lab System Design InterviewAI Lab Behavioral InterviewAI Lab Technical Presentation
🔬Project Deep Dives0/17
Deep Dive - vLLMDeep Dive - SkyRLDeep Dive - FlashAttentionDeep Dive - FlashInferDeep Dive - DeepGEMMDeep Dive - NCCLDeep Dive - MegatronDeep Dive - DeepSpeedDeep Dive - RayDeep Dive - MLflowDeep Dive - PyTorchDeep Dive - TransformersDeep Dive - SGLangDeep Dive - slimeDeep Dive - DeepEPDeep Dive - TinkerDeep Dive - Light-PEFT
Back to Topics
LearnInference & Production ScaleContinuous Batching & Scheduling
🚀HardInference Optimization

Continuous Batching & Scheduling

Understand how LLM schedulers use continuous batching, chunked prefill, and prefill-decode disaggregation to improve throughput without violating TTFT, TPOT, or inter-token latency targets.

32 min read
Learning path
Step 141 of 196 in the full curriculum
FlashAttention & Memory EfficiencyScaling LLM Inference

Personalize this lesson

Adapt explanations and teaching visuals to your background and preferred voice.

At 09:47 during a release freeze, an on-call engineer sees one shared code-assistant GPU receive three requests: a one-line completion (about 10 output tokens), an explanation of a failing test (about 50), and a migration plan (about 200). More work is already queued.

If the server starts those requests together, A disappears after 10 generation steps and B after 50. Their seats still belong to the batch while C runs for another 150 steps. The user-visible choice is simple but consequential: protect streams already producing tokens, or admit work that hasn't produced its first token?

FlashAttention makes one attention kernel cheaper. It doesn't decide who shares the next forward pass.

Static batching treats the trio as one job: fill a batch, run it, and wait for the longest completion before anyone new can start. Continuous batching (iteration-level scheduling) rebuilds the active set after every token step. When A stops, queued request D can take that slot on the next iteration. The model doesn't change. The GPU just keeps more useful work in flight.[1]Reference 1Orca: A Distributed Serving System for Transformer-Based Generative Models.https://www.usenix.org/conference/osdi22/presentation/yu[2]Reference 2Efficient Memory Management for Large Language Model Serving with PagedAttention.https://arxiv.org/abs/2309.06180

Admission still depends on free KV-cache memory, so the scheduler only makes sense after paged KV management.

Static batching leaves request A idle after 10 tokens and B idle after 50 while C runs to 200. Continuous batching reuses those slots: D replaces A and E replaces B while C keeps decoding.
On the left, A and B keep reserved seats until C finishes. On the right, D replaces A and E replaces B at the next decode boundary, so all three slots stay useful.

Why LLM batches don't stay the same shape

Image classifiers usually have a fixed input shape and finish in one forward pass. Autoregressive LLMs don't.

Prefill reads the prompt. The prefill phase can process those tokens in parallel. Decode then emits one new token per step. A one-liner might need ten decode steps. A migration plan might need two hundred.

The engine also doesn't know the output length up front. Generation stops on an end-of-sequence token or a length limit, so the scheduler can't freeze a batch shape that will still make sense 200 steps later.

Those two facts are why a request-level batch wastes capacity on mixed traffic. Once A is done, its seat is empty, but static batching still owns the whole group until C finishes.

RequestTaskOutput tokensStatic fate
AOne-line completion10Done at step 10, waits 190 more
BFailing-test explanation50Done at step 50, waits 150 more
CMigration plan200Finishes at step 200

If the GPU has three decode seats, static batching spends 3×200=6003 \times 200 = 6003×200=600 seat-steps to do 10+50+200=26010 + 50 + 200 = 26010+50+200=260 useful tokens. The other 340 seat-steps are idle. Requests A and B are finished, but their KV memory often stays reserved until C completes, so D and E can't join.

Count each active output token as one useful slot-iteration:

static-versus-continuous-slot-use.py
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%}")
Output
1useful slot-iterations: 260 2static idle slot-iterations: 340 3static utilization: 43.3%

Slot utilization isn't GPU utilization. 43.3% is batch-seat occupancy: how often a request-shaped seat is busy. Filling every seat still leaves tensor cores idle when decode is limited by memory bandwidth, and HBM can be nearly full while model FLOPs utilization (MFU) stays low.

RegimeSeat occupancyTypical decode MFU
Static batch, mixed lengthsLow after short finishesStill often bandwidth-bound
Continuous batch, full seatsHighStill often low MFU on pure decode
Larger token budget / chunked prefill co-batchHigh, more prefill tokens per stepHigher arithmetic intensity on that mixed step

Continuous batching is a capacity win because it keeps more useful work in flight. It doesn't promise that SM occupancy or MFU jump to 100%. The open question is how the server actually refills those seats without waiting for C.


Continuous batching: schedule every iteration

Orca named this iteration-level scheduling: after each generation step (or after each prefill chunk in engines that chunk prefills), the server checks who finished, frees their slots, and admits waiting work if memory and token-budget constraints allow.[1]Reference 1Orca: A Distributed Serving System for Transformer-Based Generative Models.https://www.usenix.org/conference/osdi22/presentation/yu

Start with [A, B, C]. At iteration 10, A finishes and D joins. At iteration 50, B finishes and E joins. C keeps decoding the whole time. The GPU stays at three busy seats until the waiting queue is empty.

StepActive setWhat changed
0A, B, CInitial batch
10D, B, CA done, D joins
50D, E, CB done, E joins
200D, E, (C done)C leaves; next queued work can take the seat

That loop has one invariant: at every boundary, rebuild the active set before the next forward pass. First retire finished sequences and free their KV. Then admit waiting work that fits the batch and token budgets.

If the projected next token would exceed the KV budget, preempt a victim before running the pass. Otherwise, run one forward pass. The order makes the trade-off visible: reuse freed capacity, protect the next token from an OOM, then do useful work.

Diagram showing Retire + free KV, Admit under budgets, Next token fits KV?, and Preempt a victim.
Retire + free KV, Admit under budgets, Next token fits KV?, and Preempt a victim.

Real engines add prefix-cache hits, cancellations, and speculative drafts on top of this loop. The invariant stays the same: the active set is allowed to change at every iteration boundary.

Seat count is the wrong capacity knob, though. One long prefill can consume more scheduler work than many short decode-only streams. Modern schedulers cap a per-step token budget (vLLM's max_num_batched_tokens) and a KV-cache block budget, not only max_num_seqs.

A prefix-cache hit doesn't speed decode by itself, but it does shrink how many prefill tokens compete for this step's budget. Decode-first policy then spends leftover tokens on a bounded prefill chunk.

Suppose 96 live completions already need a decode token each, and a 1,800-token test-log prefill is waiting. With max_num_batched_tokens = 1024, decode takes 96 slots first. The remaining 928 tokens can run as a prefill chunk. The other 872 prompt tokens wait for later steps.

The same 1,896 tokens of work: a stall step packs 96 decode tokens with an 1,800-token prefill, while a 1,024-token decode-first budget runs 96 decode tokens plus a 928-token prefill chunk and defers 872 prompt tokens.
Both bars contain the same 1,896 tokens of work. Decode-first scheduling keeps the live 96 streams in the step, spends the rest of a 1,024-token budget on a 928-token chunk, and defers 872 prompt tokens instead of stalling behind the whole log.
decode-first-token-budget.py
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}")
Output
1decode tokens scheduled first: 96 2prefill tokens scheduled now: 928 3prefill tokens left for later: 872

That budget only helps if the memory manager can actually hand D the blocks A just freed. Contiguous KV reservations often can't.


Paged KV is what makes refill possible

During decode, the model stores a KV cache: attention keys and values for every token so far. Under continuous batching, sequences join and leave at unpredictable times. A contiguous max_seq_len reservation per request then fails in two ways:

  1. Internal fragmentation. You pre-allocate for 4,096 tokens even if A generates 10.
  2. External fragmentation. When A leaves, it can punch a hole that D's prompt doesn't fit into, even if the GPU has enough free bytes overall.

The KV cache for a 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]Reference 3Qwen3.6-27Bhttps://huggingface.co/Qwen/Qwen3.6-27B A 4,096-token test log therefore needs roughly 0.25 GiB. 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. Modest over-allocation can strand enough VRAM to block D.

MKV=2×L×s×Hkv×d×bM_{KV} = 2 \times L \times s \times H_{kv} \times d \times bMKV​=2×L×s×Hkv​×d×b

LLL is the number of full-attention layers that store K/V, sss is sequence length, HkvH_{kv}Hkv​ is KV heads, ddd is head dimension, and bbb is bytes per element (2 for FP16). The leading 2 counts key and value tensors. Qwen3.6-27B has 64 layers total, but 48 of them are Gated DeltaNet; only the 16 Gated Attention layers grow this cache.

kv-cache-budget.py
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")
Output
1KV state per token: 64 KiB 2KV state at 4,096 tokens: 0.25 GiB

Which term makes KV-cache memory grow during decoding?

Answer

sss, the sequence length. Every new generated token extends the stored key and value tensors, so cache memory grows linearly with active sequence length.

PagedAttention[2]Reference 2Efficient Memory Management for Large Language Model Serving with PagedAttention.https://arxiv.org/abs/2309.06180 is the memory manager that makes iteration-level admission practical. It splits KV into fixed-size blocks (often 16 or 32 tokens) and maps logical block indices to physical GPU pages through a per-request block table. When A leaves, its pages return to the free list. D can take any of them, even if they aren't adjacent.

After short request A leaves, contiguous KV reservation leaves a 2-block hole that 3-block request D cannot use, while paged KV places D on any free physical blocks and keeps C and B in place.
After A leaves, a contiguous allocator can be stuck with a hole that D doesn't fit. Paged KV lets D take any free physical blocks while C and B stay put.

The vLLM paper reports near-zero KV-cache waste in its evaluated design versus large reservation waste in the baselines.[2]Reference 2Efficient Memory Management for Large Language Model Serving with PagedAttention.https://arxiv.org/abs/2309.06180 That claim is about dropping max_seq_len over-allocation, not about last-block rounding. Fixed blocks still leave slack in the final page of each sequence. On short sequences that slack looks large. On long ones it's at most block_size - 1 tokens per request.

paged-kv-slack.py
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%})")
Output
1active tokens: 161 2reserved block slots: 192 3last-block slack: 31 tokens (16.1%)

PagedAttention manages where KV lives. FlashAttention reduces HBM traffic inside the attention kernel. They're orthogonal. Engines such as vLLM combine them so you can both admit more sequences and run each step faster.

Memory is now flexible enough to refill seats. The next fight is that prefill and decode want different things from the same GPU.


Prefill and decode fight over the same step

LLM serving has two phases with opposing bottlenecks:

Prefill (prompt processing)

  • Processes input tokens in parallel.
  • Often compute-heavy because many prompt tokens hit matrix multiplies.
  • Runs once per request at the start (or in chunks).
  • Latency goal: Time To First Token (TTFT).

Decode (token generation)

  • Emits tokens one at a time.
  • Often memory-bandwidth limited at practical batch sizes: each step reads model weights and KV to produce few new tokens.
  • Runs once per output token.
  • Latency goal: inter-token latency (ITL), also called time between tokens (TBT). TPOT is related but not identical. DistServe defines TPOT as the average time per output token after the first.[4]Reference 4DistServe: Disaggregating Prefill and Decoding for Goodput-optimized Large Language Model Serving.https://arxiv.org/abs/2401.09670
PhaseArithmetic intensityBottleneckWhat you want to batch
PrefillOften highFrequently computePrompt tokens
DecodeOften lowerFrequently memory bandwidthActive streams

Mixing a full 4k test-log prefill with those 96 live streams can stall ITL. Every in-flight completion waits for the whole prompt before its next token. Chunked prefill, disaggregated pools, and decode-first priority are three ways to bound that interference.


Who gets the next seat

The scheduler rebuilds the active set every iteration. Policy decides which waiting work wins.

First-come-first-served (FCFS)

Admit in arrival order. Easy to reason about, usually fair, and a long migration plan at the head of the queue can block many one-liners.

Shortest-job-first (SJF)

Prioritize requests expected to need fewer output tokens. If you knew lengths, average completion time would drop because A wouldn't sit behind C. You don't know those lengths, so pure SJF is rare. Production systems approximate it with prompt-length buckets, tenant priorities, or token-budget caps.

Unknown lengths turn scheduling into a fairness problem too. A stream of short requests can keep winning an estimate-based SJF queue while a long request waits forever. Use estimates as hints, age waiting requests, or allocate per-tenant shares; cap consecutive wins when a class is falling behind.

Track p99 and maximum queue wait by request class. If short-job latency improves while long-job wait grows, the scheduler is trading fairness for mean completion time, not finding free capacity.

If output lengths were known, SJF would cut average completion time. They aren't, so this is an oracle bound:

shortest-job-first-bound.py
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")
Output
1FCFS average completion: 60.0 steps 2oracle SJF average completion: 35.0 steps 3production caveat: output length is not known up front

Decode-first and priority

Users notice ITL jitter immediately. That makes decode-first scheduling reasonable: protect streams already producing tokens, then spend leftover budget on new prefills. Per-tenant priorities and SLO-aware admission make that preference explicit.[4]Reference 4DistServe: Disaggregating Prefill and Decoding for Goodput-optimized Large Language Model Serving.https://arxiv.org/abs/2401.09670

Decode-first still needs a backstop. If decode is never preempted, waiting prefills miss TTFT. Cap decode-only iterations or age a waiting request until it wins a slot.

Preemption when KV is full

When GPU KV blocks run out, the scheduler has to pause work, drop state, or stop admitting. Two recovery designs:

  1. Recompute. Drop the victim's KV and replay its prompt when it's rescheduled.
    • Pros: no CPU copy; simple.
    • Cons: spends GPU compute; cheapest when the prompt is short.
  2. Swap-out. Move the victim's KV from GPU VRAM to CPU RAM.
    • Pros: keeps progress; can beat replay when the prompt is long.
    • Cons: burns host-device bandwidth and adds resume latency.

These are design options, not universal defaults. Current vLLM V1 uses RECOMPUTE because recomputation has lower overhead in that architecture, and its V1 guide marks GPU-to-CPU KV swapping as removed.[5]Reference 5Optimization and Tuning.https://docs.vllm.ai/en/latest/configuration/optimization.html[6]Reference 6vLLM V1 User Guidehttps://docs.vllm.ai/en/stable/usage/v1_guide/ Other runtimes still offer swap. If you tune a particular engine, read its current scheduler docs and trace preemption events.

The teaching scheduler below implements swap-out so the bandwidth path is visible in the code. schedule_step runs before every forward pass. finish_decode_step runs after that pass emits one token per active request. KV blocks grow as sequences get longer, and a tight budget forces one preemption.

preemption-strategies.py
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 == 1
Output
1[['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=1

schedule_step() rebuilds the active set every iteration: retire, admit under the batch and KV budgets, then preempt if the next token wouldn't fit.

In a real server the control loop alternates schedule_step() and finish_decode_step(): pick the next forward pass, run it once, record the extra token and any new KV block, repeat. Prefix-cache reuse, cancellation, and speculative decoding sit on top of that loop. The tight-budget assertion proves the swap-out branch actually runs.

Recompute is the dual policy, and the one many engines default to:

recompute-preemption-sketch.py
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 == 0

Swap-out pays host-device bandwidth to keep KV. Recompute pays prefill FLOPs to rebuild it. Choose from measured prompt length, PCIe bandwidth, and how often preemption fires.

Continuous batching times speculative decoding

Speculative decoding isn't only a per-request speedup. It changes the token-budget math for the whole batch:

  1. Budget inflation. After verify, each active request may emit up to k + 1 tokens (draft span plus a possible bonus). Reserve max_num_batched_tokens as if those multi-token steps can land together, not as one token per request.
  2. 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 look fine, even when mean TPOT looks healthy.
  3. Mixed streams. Speculative and plain decode share one token budget. A burst of long draft verifies can starve ordinary decode seats the same way a fat prefill can.

The accept/reject rule lives in speculative decoding. Keep the iteration 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

The same ideas show up across stacks. Some rows are production engines. DistServe is a research system that shaped later pool-splitting designs:

FrameworkWhat it emphasizesWhere it shines
vLLM[2]Reference 2Efficient Memory Management for Large Language Model Serving with PagedAttention.https://arxiv.org/abs/2309.06180PagedAttention and continuous batchingGeneral-purpose high-throughput serving
TensorRT-LLM[7]Reference 7TensorRT-LLM: A High-Performance Inference Framework for LLMs.https://github.com/NVIDIA/TensorRT-LLMNVIDIA kernels, in-flight batching, paged KVTeams standardized on the NVIDIA stack
SGLang[8]Reference 8SGLang: Efficient Execution of Structured Language Model Programs.https://arxiv.org/abs/2312.07104Continuous batching plus prefix reuse (RadixAttention)Repeated prefixes or structured generation
DistServe[4]Reference 4DistServe: Disaggregating Prefill and Decoding for Goodput-optimized Large Language Model Serving.https://arxiv.org/abs/2401.09670Prefill/decode disaggregation and SLO-aware schedulingDeployments where TTFT and TPOT beat raw TPS

Feature matrices move quickly. Check current docs before you pick an engine.


Throughput vs goodput

A server can win raw tokens/sec and still feel slow for long prompts or live streams. Judge it by whether your mix of one-liners, test logs, and long plans stays inside TTFT, TPOT, and ITL targets.

SourceWhat it measuredWhy it matters
Orca[1]Reference 1Orca: A Distributed Serving System for Transformer-Based Generative Models.https://www.usenix.org/conference/osdi22/presentation/yuIteration-level scheduling against request-level baselinesPer-iteration scheduling can raise throughput and cut latency together
vLLM[2]Reference 2Efficient Memory Management for Large Language Model Serving with PagedAttention.https://arxiv.org/abs/2309.06180PagedAttention plus continuous batching against prior serving systemsShows why memory allocation and iteration-level scheduling must be evaluated together
DistServe[4]Reference 4DistServe: Disaggregating Prefill and Decoding for Goodput-optimized Large Language Model Serving.https://arxiv.org/abs/2401.09670Phase-disaggregated serving under TTFT/TPOT SLOsTreats prefill/decode placement as an SLO and goodput decision, not a raw-TPS contest

Raw tokens/sec can look great while a large fraction of requests miss their latency target. DistServe frames goodput as the maximum offered request rate, λ\lambdaλ, that keeps the required fraction, α\alphaα, of requests within chosen TTFT and TPOT objectives on each provisioned GPU.[4]Reference 4DistServe: Disaggregating Prefill and Decoding for Goodput-optimized Large Language Model Serving.https://arxiv.org/abs/2401.09670

Goodput=max⁡{λ:P(TTFT≤TTTFT and TPOT≤TTPOT)≥α}\text{Goodput} = \max\left\{\lambda : P(\text{TTFT} \le T_{\text{TTFT}} \text{ and } \text{TPOT} \le T_{\text{TPOT}}) \ge \alpha\right\}Goodput=max{λ:P(TTFT≤TTTFT​ and TPOT≤TTPOT​)≥α}

The original DistServe form uses TTFT and TPOT. When smooth streaming is part of your product contract, extend the acceptance predicate with ITL:

Goodputstream=max⁡{λ:P(TTFT≤TTTFT∧TPOT≤TTPOT∧ITL≤TITL)≥α}\text{Goodput}_{\text{stream}} = \max\left\{\lambda : P\left( \text{TTFT} \le T_{\text{TTFT}} \land \text{TPOT} \le T_{\text{TPOT}} \land \text{ITL} \le T_{\text{ITL}} \right) \ge \alpha\right\}Goodputstream​=max{λ:P(TTFT≤TTTFT​∧TPOT≤TTPOT​∧ITL≤TITL​)≥α}

Define ITL aggregation before measuring: per-stream p99 ITL or a required fraction of tokens under TITLT_{\text{ITL}}TITL​ are different tests. TTTFTT_{\text{TTFT}}TTTFT​, TTPOTT_{\text{TPOT}}TTPOT​, and TITLT_{\text{ITL}}TITL​ are the chosen limits; α\alphaα is the attainment fraction, for example 90%. Raise offered load and keep the highest rate whose attainment stays at or above α\alphaα. Optimize tokens/sec only after those targets exist.

One 10-second window doesn't establish that maximum. It can still report observed SLO-compliant throughput, which is what you use when raw TPS is hiding latency failures:

goodput-under-slo.py
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)}")
Output
1raw throughput: 0.40 req/s 2observed SLO-compliant throughput: 0.20 req/s 3SLO-compliant requests: 2/4

Why 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.

The toy window checks TTFT and TPOT only. A streaming endpoint should add its ITL rule to the same acceptance test, then hold that rule fixed across scheduler configurations.

Turn a mixed-load trace into an operating point

The 10-second window has one useful job: it makes SLO filtering concrete. It doesn't create an arrival process, queue pressure, or output-length mix, so it can't identify an endpoint's capacity. A hand-picked batch can pass while live traffic fails between iterations.

Predict the curve before you run it. As offered arrival rate λ\lambdaλ rises, queue wait and TTFT should climb first. Once prefills compete with decode, p99 ITL and TPOT should spread; past the operating point, SLO attainment and goodput fall even if raw throughput keeps rising. An open-loop client sends requests according to preassigned timestamps. A closed-loop client that waits for each response before sending the next request hides that queue transition.

MLPerf's Server/Interactive rules make this workload distinction explicit: its load generator sends queries with a Poisson arrival process, tests multiple offered QPS values, and reports the maximum supported rate under scenario latency and quality requirements. It also samples the performance trace from a fixed library with a deterministic seed.[9]Reference 9MLPerf Inference Benchmark Rules.https://github.com/mlcommons/inference_policies/blob/master/inference_rules.adoc Use that discipline for an API benchmark without claiming MLPerf compliance: replay a public or production trace, or publish a fixed-seed distribution with the realized prompt and output lengths.

Freeze the contract before tuning. Otherwise a scheduler change can quietly change the workload it's being judged on:

Contract itemRecord before the sweep
Endpoint and systemEndpoint URL and protocol, streaming mode, model and tokenizer revision, engine commit, GPU model/count/topology, driver/runtime, and precision or quantization
WorkloadA replayable public/production trace or fixed-seed distribution; prompt-length and output-length buckets, sampling and stop rules, and the exact request order
Arrival and concurrencyOpen-loop arrival timestamps or the named arrival process at each offered λ\lambdaλ; client concurrency, max in-flight requests, and observed server concurrency
Warmup and windowWarmup requests or duration until compilation, allocators, and chosen caches are steady; measurement duration, drain time, and the samples excluded from warmup
BaselineThe exact scheduler/engine configuration being compared, with equivalent tuning and the same trace, system, and SLOs
CorrectnessDeterministic or fixed-seed decoding where available, output validity, reference outputs or task-quality checks, token-count checks, and error, cancel, and timeout counts
Metrics and operating pointQueue wait, TTFT, per-token ITL, TPOT, raw throughput, p50 and p99 for each latency signal, KV pressure, preemptions, SLO attainment, and goodput; report the highest λ\lambdaλ that passes every latency and correctness gate plus its neighboring pass/fail points

One synthetic fixture is enough to expose why one uniform batch is weak: 60% short requests with 128-256 prompt tokens and 16-64 output tokens, 30% medium requests with 512-2,048 prompt tokens and 128-512 output tokens, and 10% long requests with 2,048-4,096 prompt tokens and 512-2,048 output tokens. Those proportions are a test fixture, not a claim about production traffic. A public trace or measured workload can replace it, but keep the realized lengths and arrival timestamps so every scheduler sees the same work.

For streaming, p50 alone can hide a stutter. Etalon shows why TTFT, TPOT, and related averages don't fully capture user-facing behavior, and proposes a fluidity-index for that gap.[10]Reference 10Etalon: Holistic Performance Evaluation Framework for LLM Inference Systems.https://arxiv.org/abs/2407.07000 You don't have to adopt its metric, but you do need an explicit ITL aggregation and a p99 report if smooth token delivery is an SLO.

Read the curve with the trace. Stable p50 with rising p99 points to queue variance or starvation. Rising TTFT with stable ITL points to admission or prefill wait. Rising p99 ITL with stable TTFT points to prefill interference or an oversized mixed step. Rising raw throughput with flat or falling goodput means the system has passed its SLO-qualified operating point.

With that contract in place, chunked prefill is the usual next knob when colocated prefill still stalls decode.


Chunked prefill keeps streams moving

Naive continuous batching still lets a 4,000-token test log monopolize a step. Sarathi addresses that with chunked-prefills plus decode-maximal batching: split the prompt into equal-sized chunks, then pair one chunk with as many decode requests as the batch can hold. The chunk keeps the GPU's compute units busy. The decodes "piggyback" on the same step, which is the paper's term for that mix.[11]Reference 11Sarathi-Serve: Efficient LLM Inference by Piggybacking Decodes with Chunked Prefills.https://arxiv.org/abs/2308.16369

Without chunking, a 4k test-log prefill occupies four scheduling steps while a live completion waits. Chunked prefill splits that prompt so the live stream keeps decoding on every step.
Left, the 4k log owns four steps and the live completion waits. Right, each step mixes a prompt chunk with decode, so tokens keep appearing on screen.

Without chunking, concurrent decode requests wait for the whole prompt. Average TBT can still look fine while the stream hitch is obvious in the UI.

With a 512-token chunk, that 4,000-token log becomes eight pieces (seven 512-token chunks plus a 416-token tail). The scheduler can pair an admitted chunk with decode work that fits the same token budget, then reconsider the mix before the next chunk. The long prompt pays extra scheduling and kernel boundaries. One admitted chunk no longer consumes the entire prompt in one step.

vLLM V1 documents chunked prefill as enabled by default whenever possible: decode requests go first, leftover max_num_batched_tokens budget goes to prefills, and a prefill that doesn't fit is chunked. Larger token budgets tend to help TTFT and throughput. Smaller budgets tend to help ITL.[5]Reference 5Optimization and Tuning.https://docs.vllm.ai/en/latest/configuration/optimization.html

chunked-prefill-plan.py
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}")
Output
1chunk count: 8 2first/last chunk: 512/416 tokens 3decode scheduling boundaries: 7

Chunking still shares one GPU between phases. When prefill and decode want different hardware, the next move is to split the pools.


Disaggregated prefill and decode

Disaggregated serving runs prefill and decode on separate worker pools. Splitwise[12]Reference 12Splitwise: Efficient Generative LLM Inference Using Phase Splitting.https://arxiv.org/abs/2311.18677 and DistServe[4]Reference 4DistServe: Disaggregating Prefill and Decoding for Goodput-optimized Large Language Model Serving.https://arxiv.org/abs/2401.09670 study this split directly: prefill often wants dense matrix throughput, decode often wants KV capacity and memory bandwidth.

It isn't the default for every cluster. Short prompts, small fleets, or high prefix-cache hit rates can make "prefill on the decode worker" simpler than a KV hop. Compare goodput under defined TTFT, TPOT, and ITL objectives, including transfer overhead.

Co-located serving runs a long prefill on the same GPU pool so live streams wait, while disaggregated serving keeps prefill and decode on separate pools and hands off about 0.25 GiB of KV state for a 4,096-token Qwen3.6-27B prompt.
Co-located serving makes TTFT and TPOT share SM time. Disaggregation specializes the pools, then pays a KV handoff. For this 4,096-token Qwen3.6-27B prompt that handoff is about 0.25 GiB before protocol overhead.

Prefill workers can be sized for matrix-math throughput. Decode workers can be sized for KV capacity and bandwidth. You scale the two pools independently when traffic skews toward long prompts or long generations.

The cost is the KV transfer. DistServe is a reminder that once you optimize for goodput instead of raw TPS, paying that hop can still win.[4]Reference 4DistServe: Disaggregating Prefill and Decoding for Goodput-optimized Large Language Model Serving.https://arxiv.org/abs/2401.09670

kv-handoff-cost.py
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")
Output
1KV handoff size: 0.25 GiB 2idealized transfer time at 50 GiB/s: 5.0 ms 3production check: add protocol, queueing, and synchronization overhead

Read the failure signature

A scheduler usually contradicts itself in its trace before it fails in a headline throughput number. If finished requests keep their seats, you're still treating the batch as one request. Retire work at the iteration boundary, free its KV, and refill only after budget checks.

If twenty short prompts fit at admission but generation later OOMs, request count hid KV growth. Cap blocks or tokens and leave headroom for long outputs and bursts.

If throughput looks healthy while new prompts wait past their TTFT target, decode preference has become starvation. Add a waiting-time timeout or decode-only cap, then admit or preempt work before the deadline.

A chat stream and a background summarizer can have different targets, but neither target is served by a chart that omits queue wait.


The thread to keep

Static batching leaves holes when lengths differ. Continuous batching closes those holes only when the scheduler can allocate KV flexibly, reserve token headroom, and rebuild the active set at each iteration.

When prefill and decode interfere, chunking bounds each prefill slice. When their hardware needs diverge, disaggregation trades a KV handoff for separate pools.

Judge each step on SLO-qualified goodput under the traffic you actually serve, not raw tokens/sec.

That is the progression: request-level batches, iteration-level refills, bounded prefill, then phase-level separation when the measured curve justifies its transfer cost.


Run a mixed-load trace

Replay the same mixed trace across scheduler settings. Warm up until the endpoint reaches its declared steady state, send requests with the chosen open-loop arrival process, and drain every response before ending the run. Record per-request queue wait, TTFT, ITL, output length, correctness, token-budget use, KV-block pressure, preemptions, and errors.

Plot offered λ\lambdaλ against p50 and p99 latency, raw throughput, SLO attainment, and goodput. Compare each curve with the policy you think you configured, then keep the trace and pass/fail operating points as the acceptance artifact for the next scheduler change.

Complete the lesson

Mastery Check

Answer every question, then check your score. Score 75% or higher to mark this lesson complete.

1.A GPU has 3 decode slots. Five code-assistant requests have expected output lengths [20, 40, 15, 30, 10] and are admitted FCFS with continuous batching. Static batching would run [20, 40, 15] and then [30, 10]. When does continuous batching finish all five, and how many iterations does it save versus static batching?

Correct answer: It finishes in 45 iterations, saving 25 iterations versus static's 70 because A and C free slots before B completes.

C finishes at iteration 15, so D starts with 30 iterations of work. A finishes at iteration 20, so E starts and finishes at 30. B finishes at 40, while D finishes at 15 + 30 = 45, setting the continuous-batching finish time. Static batching takes 40 iterations for [20, 40, 15], then 30 more for [30, 10], for 70 total. Continuous batching saves 70 - 45 = 25 iterations.

2.During a decode pass, one request emits EOS and another is canceled. Queued work is waiting, but the KV budget is tight. What should the scheduler do before the next forward pass?

Correct answer: Retire both requests, free their KV blocks, then admit queued work that fits before rebuilding the next active set.

EOS and cancellation both make a sequence inactive. At the iteration boundary, the scheduler can release each sequence's KV blocks and use the recovered budget when rebuilding the next active set. Keeping either sequence until the original batch ends would recreate static-batching waste.

3.An engineer raises max_batch_size because 20 short prompts fit at admission, but the server later OOMs while several requests generate long answers. Which scheduler limit addresses the failure mode most directly?

Correct answer: Limit admission and decoding by KV-cache blocks or a token budget, with headroom for cache growth during generation.

A raw request count hides the real capacity constraint. One long prompt or long generation can consume far more prefill work and KV memory than many short chats, and KV cache keeps growing as output tokens are produced.

4.PagedAttention uses 16-token KV blocks. Active sequence lengths are [17, 31, 48, 65] tokens. How many token slots are reserved, and how many are slack inside the last blocks?

Correct answer: 192 token slots are reserved and 31 are slack, because each sequence rounds up to a whole number of 16-token blocks.

The block-rounded reservations are 32, 32, 48, and 80 token slots, totaling 192. The sequences use 161 tokens, so 31 reserved token slots are unused. PagedAttention reduces large contiguous reservations, but fixed blocks can still leave last-block slack.

5.A decode-first scheduler has max_num_batched_tokens = 1024. It schedules 96 active decode requests first, and one waiting prefill has 1,800 prompt tokens left. With chunked prefill, how many prefill tokens can run in this iteration, and how many remain?

Correct answer: 928 prefill tokens run now and 872 remain, because decode tokens consume part of the iteration's token budget first.

The 96 decode streams use 96 token slots, leaving 1024 - 96 = 928 token slots for prefill. Chunking prevents the full 1,800-token prompt from monopolizing the scheduling step, so 872 prompt tokens are deferred.

6.Existing decode streams have smooth token delivery, but newly arrived prompts repeatedly miss their TTFT target. Which policy change addresses that objective while retaining a decode preference?

Correct answer: Add a waiting-time timeout or decode-only iteration limit, then admit the overdue prefill, preempting a low-priority decode if needed.

Decode-first scheduling protects inter-token latency, but an unlimited decode preference can starve prefills and violate TTFT. A bounded decode-only interval or waiting timeout preserves the preference while forcing admission when a request approaches its TTFT limit.

7.GPU KV-cache blocks are exhausted during generation, so the scheduler must preempt a low-priority request. One candidate has a short prompt that would be cheap to replay; another has a long prompt and a large KV state. Which statement matches the recomputation versus swap-out tradeoff?

Correct answer: Recompute drops GPU KV and later replays prompt tokens, which fits short prompts; swap-out saves KV in CPU RAM but spends transfer bandwidth and resume time.

Recomputation frees GPU KV by discarding the victim's cache and rerunning prompt work when the request resumes, which is attractive when replay cost is small. Swap-out preserves progress by moving KV to CPU RAM, but it consumes host-device bandwidth and adds resume latency. The cheaper choice depends on prompt length, bandwidth, and how expensive replaying prefill would be.

8.A disaggregated LLM serving system transfers a Qwen3.6-27B prompt's KV cache from a prefill worker to a decode worker. Using M_KV = 2 * L * s * H_kv * d * b with L=16 full-attention layers, s=4096 tokens, H_kv=4 KV heads, d=256, and b=2 bytes for FP16, what are the idealized KV handoff size and transfer time over a 50 GiB/s link, ignoring protocol and queueing overhead?

Correct answer: About 0.25 GiB of KV state and 5 ms of idealized transfer time, before protocol and queueing overheads.

For Qwen3.6-27B, use the 16 Gated Attention layers that store standard K/V tensors, not all 64 total layers. The formula gives 0.25 GiB for 4,096 tokens. At 50 GiB/s, 0.25 / 50 seconds is 0.005 seconds, or 5 ms. That transfer cost must be compared against the TTFT/TPOT goodput benefit of separating prefill and decode.

9.In a 10 second window, four requests complete with (TTFT ms, TPOT ms): (120, 25), (450, 22), (180, 42), and (190, 27). The targets are TTFT <= 300 ms and TPOT <= 30 ms. What are raw throughput and observed SLO-compliant throughput?

Correct answer: Raw throughput is 0.40 req/s and observed SLO-compliant throughput is 0.20 req/s, because two requests meet both latency targets.

Raw throughput counts all 4 completions over 10 seconds. Observed SLO-compliant throughput counts the first and fourth requests, so 2 / 10 = 0.20 req/s. Goodput requires a load sweep to find the maximum offered rate that meets a chosen attainment target.

10.Three jobs require 50, 5, and 20 sequential steps and arrive in that order. If an oracle runs shortest-job-first instead of FCFS, what is the average completion time, and why is pure SJF uncommon in LLM serving?

Correct answer: 35 steps; pure SJF is uncommon because final output lengths aren't known when requests arrive.

SJF orders the jobs as 5, 20, and 50 steps, giving completion times of 5, 25, and 75. Their average is 35 steps, compared with 60 under FCFS. The obstacle is that an LLM's final output length is normally unknown until generation stops, so production schedulers need estimates, priorities, token budgets, or fairness controls rather than oracle SJF.

10 questions remaining.

Next Step
Continue to Scaling LLM Inference

You can now rebuild a batch at every decode step under KV and token budgets. Scaling inference asks the capacity question under that loop: when weights, KV state, and concurrency compete for HBM, what actually limits tokens per second and cost.

PreviousFlashAttention & Memory Efficiency
Share this article
XFacebookLinkedInBlueskyRedditHacker NewsEmail
References

Orca: A Distributed Serving System for Transformer-Based Generative Models.

Yu, G.-I., et al. · 2022 · OSDI 2022

https://www.usenix.org/conference/osdi22/presentation/yu

Efficient Memory Management for Large Language Model Serving with PagedAttention.

Kwon, W., et al. · 2023 · SOSP 2023

https://arxiv.org/abs/2309.06180

Qwen3.6-27B

Qwen Team · 2026

https://huggingface.co/Qwen/Qwen3.6-27B

DistServe: Disaggregating Prefill and Decoding for Goodput-optimized Large Language Model Serving.

Zhong, Y., et al. · 2024 · OSDI 2024

https://arxiv.org/abs/2401.09670

Optimization and Tuning.

vLLM · 2026

https://docs.vllm.ai/en/latest/configuration/optimization.html

vLLM V1 User Guide

vLLM Project · 2026

https://docs.vllm.ai/en/stable/usage/v1_guide/

TensorRT-LLM: A High-Performance Inference Framework for LLMs.

NVIDIA · 2024

https://github.com/NVIDIA/TensorRT-LLM

SGLang: Efficient Execution of Structured Language Model Programs.

Zheng, L., et al. · 2023

https://arxiv.org/abs/2312.07104

MLPerf Inference Benchmark Rules.

MLCommons · 2026 · Official benchmark rules

https://github.com/mlcommons/inference_policies/blob/master/inference_rules.adoc

Etalon: Holistic Performance Evaluation Framework for LLM Inference Systems.

Agrawal, A., et al. · 2024 · arXiv preprint

https://arxiv.org/abs/2407.07000

Sarathi-Serve: Efficient LLM Inference by Piggybacking Decodes with Chunked Prefills.

Agrawal, A., et al. · 2023 · arXiv preprint

https://arxiv.org/abs/2308.16369

Splitwise: Efficient Generative LLM Inference Using Phase Splitting.

Patel, P., et al. · 2023

https://arxiv.org/abs/2311.18677

Discussion

Questions and insights from fellow learners.

Discussion loads when you reach this section.