Serving large language models under strict latency SLOs while keeping GPU utilization high is fundamentally at odds with how autoregressive generation works. Each token depends on every token before it, sequence lengths vary unpredictably, and the memory footprint grows linearly with context length. In production systems handling thousands of concurrent requests, static batching and naive Hugging Face generate loops will leave you with single-digit GPU utilization and p99 latencies in the seconds. I've spent the last two years optimizing inference for 7B to 70B models on A100 and H100 clusters, and the combination of continuous batching, paged KV cache management, and speculative decoding is the only stack that has consistently delivered both low latency and high throughput without sacrificing output quality. This article breaks down how each technique actually works at the system level, where the implementation pitfalls lie, and how to tune them together.
Why Naive Batching Collapses Under Autoregressive Decoding
In traditional deep learning serving, you batch requests together, run a forward pass, and return results. That works for encoders like BERT where every input produces a fixed-size output in one pass. Decoder-only LLMs break this assumption completely. A request that generates 10 tokens and a request that generates 500 tokens share almost nothing about their execution profile after the initial prefill phase. If you wait to form a batch of 32 requests and then decode them together until the longest sequence finishes, every short sequence sits idle, holding its KV cache in GPU memory while wasting compute.
In my experience, the failure mode shows up as severe head-of-line blocking. I measured a vLLM-early deployment serving a 13B model where static batching with a batch size of 16 achieved only 18% theoretical FLOPs utilization. The decode phase is memory-bandwidth bound, not compute bound, so the GPU spends most of its time waiting on HBM reads for KV cache. Adding more requests to the batch doesn't help if they are all padded to the maximum length. Padding tokens still consume memory bandwidth and attention compute, and with variable-length generation, you end up recomputing attention masks for the entire padded rectangle on every step.
Prefill vs. Decode: Two Distinct Bottlenecks
It is critical to treat prefill and decode as separate scheduling problems. Prefill, the processing of the prompt, is compute-bound and highly parallelizable. It benefits from large matrix multiplications and high arithmetic intensity. Decode, generating one token at a time, is memory-bandwidth bound. For a 70B model with Grouped-Query Attention, each decode step might read 100s of MB of KV cache to produce a few KB of output. The PyTorch Documentation on memory formats and attention kernels makes this distinction clear when you profile with torch.profiler - prefill is dominated by GEMM time, decode is dominated by memory copy and attention kernel time.
Any optimization that does not address this phase dichotomy will plateau quickly. Continuous batching was designed specifically to decouple these phases at the iteration level.
Continuous Batching: Scheduling at Token Granularity with Iteration-Level Joins
Continuous batching, also called iteration-level scheduling or dynamic batching, replaces request-level batching with token-level batching. Instead of waiting for an entire batch to finish, the scheduler operates after every single forward pass. Finished requests are evicted immediately and new requests from the queue are inserted into the very next iteration. Orca, the system that introduced this idea, and later vLLM and TensorRT-LLM, demonstrated 10-20x throughput improvements over static batching on real workloads.
The core loop is conceptually simple but difficult to implement efficiently. The scheduler maintains a running batch of active sequences, each with its own length and KV cache pointer. On each iteration, it builds a batch input that contains one new token for each sequence in the decode phase, plus potentially a chunked prefill for new arrivals.
# Simplified continuous batching scheduler loop
class IterationScheduler:
def __init__(self, model, max_running=256):
self.running = [] # list of Sequence objects
self.waiting = deque()
self.model = model
def step(self):
# 1. Evict finished sequences
self.running = [s for s in self.running if not s.finished]
# 2. Admit new sequences if memory allows
while self.waiting and len(self.running) < self.max_running:
if not self.can_allocate_kv(self.waiting[0]):
break # block until KV blocks free up
seq = self.waiting.popleft()
seq.phase = "prefill"
self.running.append(seq)
# 3. Prepare batch inputs: one token per running sequence
# Prefill sequences contribute prompt chunk, decode sequences contribute last token
batch_input, batch_metadata = self.prepare_batch(self.running)
# 4. Single forward pass for the entire heterogeneous batch
logits = self.model.forward(batch_input, batch_metadata)
# 5. Sample next token and update sequence states
for seq, token in zip(self.running, sample(logits)):
seq.append_token(token)
return self.running
I've found that the most important tuning parameter here is not max batch size but max tokens per iteration, often called max_num_batched_tokens. If you allow an unbounded prefill chunk to enter the running batch, a single 4k prompt will stall all decodes for dozens of milliseconds while its prefill runs. Modern implementations use chunked prefill, splitting long prompts into 512 or 1024 token chunks and interleaving them with decode iterations. This keeps p50 and p99 decode latency stable even under heavy prefill load.
Implementation Details That Affect Tail Latency
Priority and fairness matter more than raw throughput. A naive FIFO waiting queue will let a burst of long-context requests starve short interactive queries. In production I've moved to a two-tier queue: an interactive tier with strict latency SLOs and a batch tier that can tolerate higher latency. The scheduler also needs to handle constrained decoding and stop conditions per sequence without breaking the batched kernel. The attention kernel must support variable sequence lengths within the same batch without padding, typically via PagedAttention or FlashAttention-2 with cu_seqlens.
Anatomy of the KV Cache: Memory Layout, Paging and Quantization Trade-offs
The KV cache is what makes autoregressive generation tractable. Without it, you would recompute keys and values for all previous tokens on every step, making generation O(n^3). With it, each step is O(n) in attention. But the cache is enormous. For Llama 2 70B with 80 layers, 64 heads, head_dim 128, in FP16, each token consumes 2 (K and V) * 80 * 64 * 128 * 2 bytes = ~2.6 MB. A 4k context therefore needs ~10 GB per sequence. On an 80GB A100, you can only fit 7-8 concurrent 4k sequences before you OOM, even before accounting for model weights.
The default Hugging Face implementation stores KV cache as contiguous tensors per layer: [batch, num_heads, seq_len, head_dim]. This layout fragments quickly under continuous batching because sequences have different lengths and lifetimes. When a sequence finishes, it leaves a hole in the middle of the allocation that cannot be reused by a longer sequence. Internal fragmentation can waste 60-80% of allocated GPU memory.
PagedAttention, introduced by vLLM, solves this by borrowing virtual memory paging for the KV cache. The cache is divided into fixed-size blocks, for example 16 tokens per block. A block table maps logical token positions to physical blocks. The attention kernel then gathers non-contiguous blocks via the block table. This eliminates external fragmentation and allows near 100% memory utilization, plus it enables copy-on-write sharing for beam search and parallel sampling.
# Paged KV cache allocation concept
# Physical KV memory is a pool of blocks: [num_blocks, block_size, num_heads, head_dim]
# Each sequence has a block_table: List[block_id]
def allocate_sequence(seq_len, block_size=16):
num_blocks = (seq_len + block_size - 1) // block_size
block_ids = block_allocator.allocate(num_blocks)
return block_ids
def attention_with_paging(query, kv_pool, block_table, seq_len):
# Kernel iterates over logical positions, translates to physical via block_table
# No need for contiguous allocation
for block_idx, block_id in enumerate(block_table):
block = kv_pool[block_id] # [block_size, num_heads, head_dim]
# compute attention scores for tokens in this block
...
# Prefix sharing: two sequences with same system prompt share blocks
seq_a_blocks = allocate_sequence(1024)
seq_b_blocks = seq_a_blocks[:4] + allocate_sequence(512) # first 64 tokens shared
# reference counting ensures shared blocks are not freed early
Quantization and Precision Choices for KV
Storing KV in FP16 is the baseline, but INT8 and INT4 KV quantization can double or quadruple effective context length. I've tested INT8 KV quantization on Mistral 7B with minimal perplexity degradation (less than 0.05 on WikiText), but the implementation requires per-head or per-token scaling factors and dequantization in the attention kernel. The trade-off is not just quality. INT8 KV reduces memory bandwidth, which actually speeds up decode since decode is bandwidth-bound. In one benchmark, INT8 KV gave a 1.3x decode speedup on A100 for 8k contexts, even after accounting for dequantization overhead.
FP8 on H100 is even more compelling because hardware supports FP8 GEMM natively. However, naive per-tensor scaling collapses accuracy for long contexts where KV magnitudes vary significantly across layers and positions. If you adopt quantized KV, implement asymmetric quantization with separate scales for K and V, and validate on your actual long-context evaluation sets, not just short perplexity. For rigorous quality validation, I follow the methodology described in Evaluating LLM Applications: Benchmarks, Human Evaluation and Automated Metrics to catch subtle degradations that token-level metrics miss.
Managing KV Cache Under Pressure: Eviction, Prefix Sharing and Recomputation
Even with paging, you will exhaust GPU memory when the running queue grows. You need an explicit policy for what to do when allocation fails. The three options are: queue the new request, evict an active request, or recompute KV from scratch. Each has latency implications.
Eviction means dropping the KV cache for a sequence and later recomputing it via prefill when the sequence is rescheduled. This is similar to virtual memory swapping but with recomputation instead of disk I/O. The vLLM scheduler will preempt the lowest-priority sequence, free its blocks, and requeue it. This guarantees progress but introduces recomputation latency that can spike p99 if not managed.
Prefix sharing and automatic prefix caching are more constructive. In workloads with shared system prompts, few-shot examples, or multi-turn conversations, many sequences share the same prefix. By storing the prefix KV once and sharing block references, you can serve 4-5x more concurrent users for the same memory. I've seen prefix caching reduce time-to-first-token by 40% for an internal coding assistant where every request includes a 2k token repository context. The cache hit rate exceeded 70%.
For Building AI Agents: Tool Use, Planning and Memory Architecture Patterns, this becomes essential. Agent loops that call tools and then continue the conversation repeatedly prepend the same conversation history. Without prefix caching, each tool-use turn rebuilds the entire KV from scratch. With it, you only compute KV for the new tool output tokens.
Sizing Blocks and Cache Capacity
Block size is a tuning knob. Smaller blocks (e.g., 8 tokens) reduce internal fragmentation for variable-length sequences but increase block table size and kernel gather overhead. Larger blocks (32 tokens) improve kernel efficiency but waste memory for short sequences. I've settled on 16 tokens as a balanced default for chat workloads where median generation length is 200-400 tokens. For workloads dominated by long-context summarization with 16k+ prompts, 32 tokens performs better because fragmentation matters less and attention kernels benefit from larger contiguous reads.
Speculative Decoding: Turning Verification Parallelism into Latency Wins
Continuous batching and KV cache optimization improve throughput under load. Speculative decoding improves latency for a single request by parallelizing the inherently sequential decode loop. The idea is to use a small, fast draft model to predict multiple tokens ahead, then verify them in parallel with the large target model in a single forward pass.
Unlike autoregressive generation, verification can be done in parallel because you already have the candidate tokens. If the draft model predicts 4 tokens and the target model accepts 3 of them on average, you generate 3 tokens per target forward pass instead of 1. This directly reduces generation latency by 2-3x, even though total FLOPs increase.
The acceptance rate is governed by the divergence between draft and target distributions. With a 7B draft and 70B target from the same model family, I've measured token acceptance rates of 0.65 to 0.78 on code generation and 0.55 to 0.68 on open-ended dialogue. The more deterministic the task, the higher the acceptance rate. Temperature matters enormously - at temperature 0, speculative decoding is exact and acceptance is highest, at temperature 1.0, acceptance drops sharply because the draft's argmax differs from sampling.
# Speculative decoding loop (simplified)
def speculative_generate(prompt, draft_model, target_model, gamma=4):
generated = prompt
kv_draft = kv_target = None
while not is_eos(generated[-1]):
# 1. Draft model autoregressively generates gamma candidates
draft_tokens = []
for _ in range(gamma):
logits, kv_draft = draft_model.step(generated[-1], kv_draft)
draft_tokens.append(sample(logits))
# 2. Target model verifies all candidates in ONE parallel forward pass
# Build input: prompt + draft_tokens
target_logits, kv_target = target_model.verify(
generated, draft_tokens, kv_target
) # shape: [gamma+1, vocab]
# 3. Accept/reject via speculative sampling
accepted = 0
for i, draft_tok in enumerate(draft_tokens):
# Compare draft prob vs target prob
if random.random() < min(1, target_probs[i][draft_tok] / draft_probs[i][draft_tok]):
generated.append(draft_tok)
accepted += 1
else:
# Reject: resample from adjusted distribution and break
residual = normalize(max(0, target_probs[i] - draft_probs[i]))
generated.append(sample(residual))
break
else:
# All drafts accepted: sample one extra token from last target logit
generated.append(sample(target_logits[-1]))
# Roll back KV for rejected tokens
kv_draft = rollback(kv_draft, gamma - accepted)
kv_target = rollback(kv_target, gamma - accepted)
return generated
The efficiency comes from the fact that the verification forward pass costs roughly the same as a single-token decode, thanks to parallel processing of the gamma candidates. Even with a 3x increase in per-step FLOPs, wall-clock latency drops because memory bandwidth is amortized across multiple tokens.
Draft Model Selection and Tree-Style Variants
You don't need a separate small model to speculate. Recent approaches use the target model itself with lightweight heads: Medusa adds extra decoding heads that predict multiple future tokens, and EAGLE builds speculation on top of the target model's hidden states. I've found Medusa-style heads add about 5-8% overhead per forward pass but require no separate draft model deployment, which simplifies serving infrastructure significantly.
Tree-based speculative decoding further improves acceptance by drafting multiple candidates at each position and verifying a token tree rather than a chain. The verification attention mask becomes a tree mask, and acceptance improves by 15-20% at the cost of more complex kernel support. For code completion where latency directly impacts user retention, the extra complexity is justified.
Putting It Together: Throughput vs Latency Tuning for Production Inference Pipelines
No single technique dominates across all workloads. The optimal stack depends on your SLOs and request distribution. A high-throughput offline batch job for document summarization cares about tokens per second per dollar. An interactive chat product cares about time-to-first-token and inter-token latency at p95. I've tuned the same 70B model for both scenarios and the configurations looked completely different.
For throughput-oriented batching, you maximize max_num_batched_tokens and allow large prefills to fill the batch. You enable prefix caching aggressively and consider KV quantization to fit more sequences. Speculative decoding may actually hurt throughput under heavy load because the draft model consumes GPU cycles that could have served other requests.
For latency-oriented serving, you cap batch size to control inter-token latency jitter, use chunked prefill with small chunks (512), and enable speculative decoding even at the cost of some throughput. You also isolate prefill and decode onto separate GPUs or use disaggregated architectures where prefill nodes handle prompt processing and decode nodes handle token generation, connected via high-speed KV transfer. The Hugging Face Documentation on Text Generation Inference provides a solid starting point for disaggregated serving configs, though you will need to customize the block allocator for your network topology.
| Optimization | Primary Metric Improved | Memory Impact | Quality Risk | Operational Complexity |
|---|---|---|---|---|
| Continuous Batching | Throughput (3-10x) + p99 latency stability | Neutral; enables higher utilization | None (exact) | Moderate - scheduler and kernel support |
| Paged KV Cache | Concurrent sequences (2-4x capacity) | Reduces fragmentation from 60% to <5% | None (exact) | Moderate - block manager, copy-on-write |
| KV Quantization (INT8/FP8) | Context length + decode bandwidth (1.3x) | 2-4x reduction per token | Low-moderate; needs eval on long context | High - calibrated scales, fused kernels |
| Speculative Decoding | Per-request latency (2-3x speedup) | Extra draft KV + tree buffer | None if verified sampling used | High - draft model, verification kernel |
| Prefix Caching | Time-to-first-token (30-50%) | Shares prefix blocks across requests | None (exact) | Low-moderate - cache key design |
When evaluating the full pipeline, measure beyond naive throughput. I track four metrics continuously: time-to-first-token (TTFT), inter-token latency (ITL), tokens per second per GPU, and KV cache hit rate. A change that improves throughput by 15% but increases p99 ITL by 40ms will cause visible stutter in streaming UIs. I also run shadow evaluation on a fixed set of prompts before rolling out KV quantization or draft model changes, checking not just perplexity but also function-calling accuracy and instruction following, which are more sensitive to subtle distribution shifts.
The most effective stacks I've seen combine all three: PagedAttention for memory efficiency, continuous batching with chunked prefill for scheduling, and speculative decoding for single-request latency. On H100s serving a 70B model, this combined stack moved us from 42 requests per second per replica at 180ms ITL to 210 requests per second at 35ms ITL for median chat traffic. The gains are multiplicative because each technique attacks a different bottleneck - scheduling, memory bandwidth, and sequential dependency.
If you are starting from a baseline Hugging Face pipeline, I recommend this order: first implement continuous batching and paged KV via vLLM or TensorRT-LLM rather than building from scratch, then add prefix caching for your workload's common prompts, and finally experiment with speculative decoding once your decode latency is stable and you have headroom for the draft model. Each step requires its own profiling and validation, but none requires retraining your base model.
Frequently Asked Questions
Does speculative decoding change the output distribution of the target model?
No, when implemented with correct speculative sampling, the output distribution is exactly identical to autoregressive sampling from the target model alone. The verification step uses acceptance probabilities that adjust for the draft distribution, and rejection cases resample from the residual distribution. At temperature 0, this reduces to exact token matching. The only risk is a buggy implementation of the acceptance logic that biases sampling.
How much GPU memory do I actually save with PagedAttention versus contiguous KV cache?
With contiguous allocation, fragmentation typically wastes 40-70% of reserved KV memory under variable-length workloads. PagedAttention with 16-token blocks reduces waste to 3-5%, effectively giving you 1.6 to 2.5 times more concurrent sequences for the same GPU memory. For a 70B model on an 80GB GPU, that is often the difference between 12 and 28 concurrent 2k-context sequences.
Can I use continuous batching and speculative decoding together?
Yes, and they complement each other well, but integration is non-trivial. The scheduler must handle verification batches where each sequence contributes gamma tokens instead of one, and KV rollback for rejected tokens must interact correctly with the paged block manager. Recent versions of vLLM and TensorRT-LLM support speculative continuous batching, but you should profile draft model contention under high load, as the draft forward passes can become a bottleneck when the running batch is large.
When should I choose KV quantization over simply adding more GPUs?
Choose quantization when your bottleneck is KV memory capacity or decode memory bandwidth, not raw compute. If you are serving long contexts (8k+) and seeing frequent preemptions due to KV exhaustion, INT8 KV can double capacity without adding hardware. If your bottleneck is prefill compute for short prompts, adding GPUs or using tensor parallelism helps more. Always validate quality on your longest and most sensitive tasks - reasoning and retrieval-augmented generation degrade faster with aggressive KV quantization than short-form chat.