← Back to playground

🚦LLM Inference Serving

A system-design walkthrough of serving large language models β€” the prefill/decode asymmetry, KV-cache capacity math, the single-GPU toolkit, and the cluster layer above it β€” with vLLM, TensorRT-LLM, and SGLang as the running examples. Companion to the vLLM engineering doc, which covers one engine's internals in depth.

TL;DR

  • LLM inference is two different workloads stapled together. Prefill (process the prompt, one pass, compute-bound) and decode (emit one token per pass, memory-bandwidth-bound) stress opposite GPU limits. Nearly every technique in this doc β€” batching, paging, speculation, disaggregation β€” exists to exploit or manage that asymmetry.
  • The unit of capacity is the KV-cache token: 2 Γ— layers Γ— kv_heads Γ— head_dim Γ— dtype_bytes per token (128 KiB for Llama-3.1-8B in BF16). Memory arithmetic β€” not FLOPS β€” decides how many requests one GPU can hold, and bandwidth decides how fast they decode.
  • Optimize goodput, not throughput. Past the saturation knee, every extra request/s makes all in-flight requests stream tokens more slowly. Capacity plans, schedulers, and autoscalers should target SLO-meeting throughput.
  • vLLM, TensorRT-LLM, and SGLang converged on the same core toolkit β€” paged KV, continuous batching, EAGLE-style speculation, prefix reuse, xgrammar. They differ in design center: portable default vs. peak-NVIDIA vs. cache-first agentic serving. The comparison is about operational fit, not features.

The workload: why LLM inference β‰  normal request serving

Classic request serving assumes three things: requests are roughly uniform in cost, workers are stateless, and the cost of a request is known when it arrives. An LLM chat request breaks all three. A transformer generates text autoregressively β€” one token per forward pass, each pass conditioned on every token that came before. To avoid recomputing attention over the whole history at every step, the engine caches each token's attention keys and values (the KV cache). That turns a request into a session: per-request state that grows linearly with every emitted token and must stay resident in GPU memory until the request finishes. And because generation stops whenever the model emits an end-of-sequence token, output length β€” which dominates cost β€” is unknown at admission time. Two requests that look identical at the load balancer can differ in cost by two orders of magnitude.

Serving one request therefore has two phases with opposite performance characters. Prefill processes all N prompt tokens in a single forward pass: large matrix multiplies, high parallelism, bounded by the GPU's compute throughput. It produces the first output token, so it determines time-to-first-token. Decode then emits one token per forward pass β€” and every pass must stream all model weights and the request's whole KV cache from HBM into the compute units to produce a single token's worth of math. Decode is bounded by memory bandwidth, not compute, and it determines the gap between tokens.

PHASE 1 Β· PREFILL β€” one pass Why is the sky blue ? model forward β€” all 6 tokens at once writes KV cache for every prompt token large matmuls Β· compute-bound Β· sets time-to-first-token PHASE 2 Β· DECODE β€” one pass per token Because sun light … model forward β€” ONE new token streams ALL weights + ALL KV from HBM append & repeat ~1 FLOP per byte Β· bandwidth-bound Β· sets inter-token gap A 500-token answer = 1 prefill pass + 500 decode passes β€” each decode pass re-reads every weight and every KV entry.

The two phases of one request. Prefill amortizes a weight read over the whole prompt; decode pays a full weight read for every single token.

The cleanest way to see why this matters is arithmetic intensity β€” useful FLOPs per byte moved from memory. In BF16, a forward pass does roughly 2 FLOPs per parameter per token and each parameter is 2 bytes. Batch-1 decode therefore sits at about 1 FLOP/byte: the GPU streams 16 GB of weights out of HBM to do what is, by its standards, almost no math. Prefill over a 2,000-token prompt reuses each weight read 2,000 times β€” roughly three orders of magnitude higher intensity. On the roofline model, an H100's ridge point sits near 989 TFLOPS Γ· 3.35 TB/s β‰ˆ 295 FLOPs/byte. Decode lives far to the left of the ridge, pinned to the memory roof; prefill lives on the compute roof.

arithmetic intensity β€” FLOPs per byte moved (log scale) attainable performance 1 10 100 1,000 memory roof β€” 3.35 TB/s compute roof β‰ˆ 989 TFLOPS (BF16, dense) ridge β‰ˆ 295 FLOPs/byte decode, batch 1 (~1 FLOP/byte) decode, batch 64 prefill, 2K-token prompt batching moves decode right β†’

Roofline for an H100 SXM (80 GB, 3.35 TB/s, 989 dense BF16 TFLOPS). Batch-1 decode achieves ~0.3% of peak compute; batching multiplies decode's intensity almost for free β€” until the ridge.

Three consequences fall straight out of this picture, and the rest of the doc is built on them. First, batching decode is nearly free: a step that decodes 64 requests streams the weights once and does 64 tokens of math in roughly the time one token took, because the memory traffic β€” not the math β€” was the bottleneck. (Each request still reads its own KV cache, so it isn't perfectly free, and the KV share of the traffic grows with context length.) Second, bandwidth is the spec to read first when you size hardware for decode-heavy workloads; TFLOPS govern prefill. Third, prefill and decode want different optimizations, which is why schedulers mix them carefully on one GPU (Β§ toolkit) and why large deployments physically separate them (Β§ cluster).

One more distinction shapes everything downstream: online vs. offline serving. An interactive chat endpoint must hit latency targets on every request, so it runs below saturation on purpose. A batch job β€” generate embeddings, score a corpus, distill training data β€” has no latency target at all; it should run the GPU as close to saturation as memory allows. The same engine serves both, tuned to opposite ends of the same tradeoff curve, which is the subject of the next section.

Metrics & SLOs: the numbers that define "fast"

Because responses stream, "latency" is not one number. Four metrics describe a streamed response, and β€” this is the part interviewers probe β€” each is moved by a different mechanism, so "make it faster" is an underspecified requirement until you say which one.

  • TTFT (time to first token) = queueing delay + prefill time. Moved by: admission control, prefill compute, prefix caching, shorter prompts.
  • ITL (inter-token latency) = the gap between consecutive streamed tokens; one decode step of the batch the request is riding in. Moved by: decode batch size, interference from other requests' prefills, model size vs. bandwidth.
  • TPOT (time per output token) = mean ITL over the response = (E2E βˆ’ TTFT) Γ· output tokens. The smoothed version of ITL; SLOs are usually set on TTFT + TPOT (or a percentile of ITL).
  • E2E latency = TTFT + TPOT Γ— output length. For long answers it is dominated by output length β€” which the server doesn't control. This is why per-token metrics, not E2E, are what you put SLOs on.
time β†’ queued prefill … ITL spike β€” another request's prefill cut in TTFT = queue + prefill ITL end-to-end latency TPOT = mean ITL = (E2E βˆ’ TTFT) / out tokens

Anatomy of one streamed response. TTFT is a queueing + prefill story; ITL is a decode-scheduling story; E2E mostly measures how long the answer was.

The second idea is the latency–throughput frontier. Admitting more concurrent requests raises tokens/second β€” decode batching is nearly free, per the roofline β€” but every request added to a decode batch adds its KV reads to each step, and once the batch saturates compute or bandwidth, steps simply take longer. ITL climbs slowly at first, then steeply near saturation. There is no setting that maximizes both throughput and latency; you choose a point on the frontier and defend it with admission control.

That choice has a name. SLO-aware throughput β€” goodput β€” is the framing the DistServe paper built its title on: the maximum request rate that can be served while both the TTFT and TPOT targets hold. A saturated system can post impressive tokens/second while nearly every request blows its per-token SLO β€” high throughput, near-zero goodput. Goodput per GPU is the number capacity plans and autoscalers should optimize, and the gap between it and raw throughput is where most "we added load and everything got slow" incidents live.

offered load (requests / s) P99 inter-token latency ITL SLO (e.g. 50 ms P99) max goodput SLO met β€” this throughput is goodput tokens still flow, SLO violated each admitted request raises tokens/s β€” and every other request's ITL

The frontier and the knee. Operating left of the cutoff is a capacity decision, not an inefficiency β€” past it you are buying throughput with everyone's SLO.

Finally, think in percentiles, not means. P99 TTFT is usually dominated by queueing and by prefill interference β€” a long prompt landing in front of yours, or your decode steps being repeatedly preempted by other requests' prefills β€” rather than by model speed. The two standard fixes, chunked prefill on a single GPU and prefill/decode disaggregation across GPUs, are exactly the techniques of the next sections. When ITL is fine at P50 and ugly at P99 under load, it is almost always scheduling interference, not the model.

Capacity math: the KV cache is the budget

The capacity question for an LLM server is not "how many QPS per core" but "how many tokens of KV cache fit next to the weights, and how fast can I stream memory." Both halves are first-principles arithmetic, and being able to do it from scratch is the single highest-leverage skill for this topic β€” in interviews and in capacity reviews.

Each token of context stores one key vector and one value vector per layer. With grouped-query attention, the cached width is the KV heads, not the query heads:

// bytes of KV cache per token of context
kv_per_token = 2 Γ— n_layers Γ— n_kv_heads Γ— head_dim Γ— dtype_bytes
//             ↑ K and V

// Llama-3.1-8B, BF16:  2 Γ— 32 Γ— 8 Γ— 128 Γ— 2 = 131,072 B = 128 KiB/token
// Llama-3.1-70B, BF16: 2 Γ— 80 Γ— 8 Γ— 128 Γ— 2 = 327,680 B = 320 KiB/token

Now the budget. An engine reserves a fraction of VRAM (vLLM's gpu_memory_utilization defaults to 0.9), loads the weights, keeps headroom for activations and CUDA-graph capture, and hands everything left to the KV cache. On one H100 serving Llama-3.1-8B in BF16: 72 GB usable βˆ’ 16 GB weights β‰ˆ 56 GB of KV budget β†’ ~427K concurrent tokens β†’ ~52 concurrent requests at 8K average context. The 70B model in BF16 is 141 GB of weights β€” it doesn't fit on the GPU at all; you shard it across 4 GPUs with tensor parallelism or quantize it.

1Γ— H100 Β· 80 GB HBM3 Β· Llama-3.1-8B in BF16 weights 16 GB reserve ~8 GB KV cache budget β‰ˆ 56 GB β‰ˆ 427,000 tokens at 128 KiB each req A Β· 32K tok BΒ·4K C Β· 12K D Β· 8K free β†’ admit 56 GB Γ· 128 KiB/token β‰ˆ 427K tokens β†’ ~52 requests @ 8K avg context Llama-3.1-70B in BF16 = 141 GB of weights β†’ doesn't fit one GPU: tensor-parallel over 4, or quantize.

Where the 80 GB goes. The KV budget β€” not compute β€” caps concurrency, which is why "add context length" is a capacity decision.

Because KV size scales with n_kv_heads, attention architecture is a serving decision made at pretraining time. Classic multi-head attention (Llama-2-7B: 32 KV heads) costs 512 KiB/token β€” 4Γ— the budget of Llama-3.1's 8-head GQA for a similar-size model. MQA is the extreme (1 KV head). MLA compresses further: DeepSeek-V3 caches one 576-dim latent per layer β€” 61 Γ— 576 Γ— 2 B β‰ˆ 69 KiB/token for a 671B-parameter model, about half of Llama-3.1-8B's. That is why a model 80Γ— larger can be cheaper per token of context than a small one.

MHA one KV pair per query head query heads cached K/V per head Llama-2-7B Β· 32 KV heads 512 KiB / token GQA β€” today's default query heads share KV heads 4 query heads per cached KV Llama-3.1-8B Β· 8 KV heads 128 KiB / token MLA cache one compressed latent latent c_kv Β· 576 dims up-project at read time DeepSeek-V3 Β· 671B params ~69 KiB / token KV bytes/token: 2 Γ— layers Γ— kv_heads Γ— head_dim Γ— dtype β€” the middle term is the design lever

Attention architecture is a KV-budget decision. MLA makes a 671B model cheaper per context token than an 8B GQA model.

The decode speed ceiling

Bandwidth also bounds speed, not just capacity. A single-stream decode step must move at least the weights through HBM, so tokens/second ≀ bandwidth Γ· bytes-per-step: for the 8B model in BF16 on an H100, 3,350 GB/s Γ· 16 GB β‰ˆ ~209 tokens/s, best case, before any KV reads, kernel inefficiency, or sampling overhead. No software makes batch-1 decode beat this number on this hardware β€” only smaller weights (quantization), fewer weight-reads per token (speculative decoding), or faster memory. Everything a GPU has left after hitting the ceiling is what batching converts into throughput.

Capacity calculator

β€”
KV cache per token
β€”
max concurrent KV tokens
β€”
max requests @ context
β€”
single-stream decode ceiling (optimistic: ignores KV reads)
weights reserve (10%) KV cache budget

Assumes 90% of VRAM usable (vLLM's default gpu_memory_utilization); weights divided evenly across tensor-parallel GPUs; decode ceiling = aggregate bandwidth Γ· weight bytes. Model configs from the official HF config.json; GPU specs from NVIDIA datasheets. Inputs persist locally.

Three readings of the calculator worth internalizing: FP8 KV cache doubles concurrent tokens for free capacity; INT4 weights almost double the single-stream decode ceiling because decode streams half the bytes; and at long contexts the batch column collapses fast β€” at 128K context, that same H100 holds three concurrent requests, which is why long-context serving is its own capacity-planning problem.

The single-GPU toolkit

Four techniques define modern single-GPU serving, and all three major engines ship all four. The deep mechanics β€” block tables, copy-on-write, scheduler internals β€” are covered in the vLLM doc; this section is what each technique is, why it exists, and where the engines genuinely differ.

Paged KV cache

Early engines allocated each request's KV cache contiguously at its maximum possible length, and the vLLM paper measured the result: only 20–38% of KV memory held actual token state β€” the rest was reserved-but-unused or fragmentation. PagedAttention (SOSP 2023) applied the OS-paging move: fixed-size KV blocks (16 tokens in vLLM), allocated on demand, addressed through a per-request block table. Near-zero waste, and with the freed memory feeding bigger batches, 2–4Γ— throughput at the same latency. It also makes KV shareable β€” the foundation for everything in the prefix-reuse section. Today paged KV is table stakes in all three engines (TensorRT-LLM calls the unified scheduling+paging combination "paged attention with in-flight batching"). Mechanics: vLLM doc Β§ PagedAttention.

Continuous batching

Static batching forms a batch of N requests, runs them to completion together, and admits the next batch. Because output lengths vary wildly, finished requests' slots sit idle while the longest one drags on, and arriving requests wait for the whole batch. Orca (OSDI 2022) introduced iteration-level scheduling: re-form the batch at every decode step, so a finished request's slot is refilled on the next step, and a new request's prefill can ride along with everyone else's decodes. Orca measured 36.9Γ— throughput over NVIDIA's static-batched FasterTransformer at the same latency β€” the single biggest win in the serving literature, which is why every engine adopted it.

STATIC batches β€” run all 4 to completion, then admit A B C D 18 / 56 slots idle E waits CONTINUOUS batching (Orca) β€” re-form the batch every step A B C D E F G 0 slots idle prefill step decode steps refilled by next request wasted (waiting for batch to finish)

The waste view: same four requests, same finish times. Static batching idles 18 of 56 step-slots and makes request E wait; continuous batching refills every freed slot on the next step. (The scheduler's view of mixed prefill/decode steps is diagrammed in the vLLM doc.)

Two refinements matter operationally. Chunked prefill (Sarathi-Serve, OSDI 2024) answers "what happens when a 20K-token prompt arrives while 40 requests are mid-decode?" Without it, that prefill monopolizes whole steps and every streaming request stutters β€” the classic P99-ITL spike. Chunking splits the prefill into pieces sized to a per-step token budget and rides each piece along with the ongoing decodes: slightly slower TTFT for the long prompt, stall-free ITL for everyone else. It is on by default in vLLM and standard practice everywhere. Preemption answers "what happens when the KV budget itself runs out?" β€” decode steps grow every request's cache, so over-admission is discovered mid-flight. Someone gets evicted: their blocks are dropped and recomputed later, or offloaded to CPU. Engines do this transparently, but recompute burns prefill capacity, so a system that preempts often has an admission-control problem, not a scheduling problem.

Speculative decoding

Decode wastes bandwidth on math the GPU could do for free β€” so spend the slack buying more tokens per weight-read. A cheap drafter proposes Ξ³ tokens sequentially; the target model verifies all of them in one forward pass (prefill-style parallelism over the proposals); accepted tokens are kept, and the first rejection is replaced by a token sampled from the target's own distribution. The rejection-sampling scheme (Leviathan et al., Chen et al.) guarantees the output distribution is exactly the target model's β€” speculation is lossless, a pure systems trick.

1 Β· draft proposes Ξ³=5 cheap model, sequential the cat sat on my 2 Β· target verifies ONE forward pass for all 5 target model β€” scores all 5 proposals in parallel one weight-read amortized over up to Ξ³+1 tokens 3 Β· accept / reject the βœ“ cat βœ“ sat βœ“ on βœ— my β†’ "upon" (corrected, free) discarded E[tokens per target pass] = (1 βˆ’ Ξ±^(Ξ³+1)) / (1 βˆ’ Ξ±) Β· Ξ± = 0.7, Ξ³ = 5 β†’ β‰ˆ 2.9 tokens/pass

Draft, verify, accept. Acceptance rate Ξ± is everything: at Ξ± = 0.7 each expensive pass yields ~2.9 tokens; at low Ξ± you do the drafting work and still pay one pass per token.

The drafter has evolved: separate small models gave way to auxiliary heads on the target itself β€” Medusa's parallel heads, then EAGLE's feature-level autoregressive head, which is what all three engines now center on (EAGLE-3 reports up to 6.5Γ— at batch 1; current docs list EAGLE-3 plus draft-model, n-gram lookup, and DeepSeek-style MTP heads as the menu in vLLM, TensorRT-LLM, and SGLang alike; Medusa has quietly dropped out of all three). The economics, though, are the interview-grade part: verification is only cheap while decode has bandwidth slack. As batch size grows and decode approaches the compute roof, the "free" parallel verify starts competing with other requests' work β€” speculation boosts low-batch latency and can cost high-batch throughput, so engines tune or disable it under load (Ξ³ shrinks, or speculation turns off past a batch-size threshold).

Prefix & KV-cache reuse

If two requests share a prefix β€” same system prompt, same few-shot examples, same conversation history β€” the KV for that prefix is byte-identical, so prefill it once and share the blocks. The savings are pure TTFT and prefill compute (decode work is untouched), and they're enormous in the workloads that dominate production: multi-turn chat re-sends the whole conversation every turn, and agentic loops re-send a growing transcript dozens of times. The engines agree on the idea and differ in the index structure:

vLLM β€” hash table of blocks req 1 h(sys₁) h(sysβ‚‚) h(q1) req 2 h(sys₁) h(sysβ‚‚) h(q2) h(q2β€²) req 3 h(sys₁) h(q3) physical block pool (one copy per hash) sys₁ Γ—3 sysβ‚‚ Γ—2 q1 q2 q2β€² q3 hash = (parent-chain, block tokens) β†’ ref-counted block lookup: hash each full block of the new prompt β†’ O(1) per block eviction: LRU over ref-count-0 blocks SGLang β€” radix tree (RadixAttention) system prompt few-shot set A few-shot set B user q1 user q2 user q3 evicted LRU leaf match: walk the tree token-by-token β€” longest shared prefix found automatically, even mid-block; branching is natural eviction: recursively trim cold leaves same idea, different index β€” pay prefill once per shared prefix

Two index structures over the same paged blocks. TensorRT-LLM's block-reuse feature also keeps filled blocks in a radix search tree, so the tree approach now covers two of the three engines.

vLLM's automatic prefix caching hashes each full block against its parent chain β€” O(1) lookups, block-granular matches, on by default (details). SGLang made the radix tree the organizing principle of the whole runtime: RadixAttention matches at token granularity, handles branching workloads naturally (one system prompt forking into N parallel agent calls shares everything up to the fork), and the scheduler orders requests to maximize tree hits β€” the SGLang paper's up-to-6.4Γ— throughput numbers come from structured programs with heavy sharing. The flip side defines its limits: one-shot unique prompts hit nothing, and a cache that big is worth routing for β€” which is how the cluster layer (Β§ cluster) gets pulled into cache design. Reuse also composes with storage: prefixes evicted from GPU can be parked in CPU RAM or SSD and restored cheaper than recompute (LMCache, Dynamo's KV manager β€” same section).

Making one GPU fast

The toolkit above decides what work runs each step; this section is about making the step itself fast. Three levers: move fewer bytes (quantization), move them better (kernels), and stop stalling between kernels (graphs and schedulers).

Quantization is three separate decisions

"Quantize the model" conflates three independent choices β€” weights, activations, and KV cache β€” with different physics:

  • Weight-only INT4 (GPTQ, AWQ): weights shrink 4Γ—, so decode β€” which streams weights β€” gets dramatically faster at small batch, and the model fits smaller GPUs. The math still runs in FP16 after dequantization, so prefill barely improves. Best for latency-sensitive, low-concurrency serving.
  • FP8 weights + activations (W8A8): Hopper's tensor cores execute FP8 matmuls at ~2Γ— BF16 rate, so this is the one that helps prefill and decode. The production default on H100-class fleets.
  • FP8 KV cache: halves the cost per context token β†’ double the concurrent tokens in the same budget. (TensorRT-LLM adds NVFP4 KV on Blackwell.) Pure capacity, modest accuracy risk, calibrate and evaluate.
Weights BF16 (base) FP8 INT4 Β· GPTQ / AWQ / NVFP4 Activations BF16 (base) FP8 (β†’ W8A8) KV cache BF16 (base) FP8 (2Γ— tokens) fewer bytes streamed β†’ faster DECODE, more KV capacity INT4 weights Β· FP8/NVFP4 KV cache (dequant to FP16 math β€” prefill unmoved) faster tensor-core math β†’ faster PREFILL and decode FP8 W8A8 on Hopper Β· FP4 on Blackwell ~2Γ— matmul rate vs BF16 guardrail: evaluate quantized models on YOUR task β€” perplexity deltas hide instruction-following and long-context regressions

Three independent dtype decisions, two distinct physical wins. Pick by phase: INT4 buys decode latency, FP8 W8A8 buys throughput, FP8 KV buys concurrency.

Tooling follows ecosystem lines: NVIDIA's Model Optimizer produces pre-quantized checkpoints TensorRT-LLM consumes (and vLLM/SGLang can run); the vLLM-adjacent llm-compressor covers FP8/INT8/INT4 in the compressed-tensors format all three engines read. Hugging Face hosts pre-quantized variants of every major model, so in practice you select rather than quantize.

Kernels: attention is special

Attention's naive implementation materializes an NΓ—N score matrix in HBM β€” at 32K context that's the whole memory budget gone. FlashAttention made attention IO-aware: tile the computation through on-chip SRAM, never write the score matrix, recompute what's cheaper to recompute than to store. Exact attention, an order of magnitude less memory traffic. Serving adds a twist β€” the KV cache is paged, scattered across non-contiguous blocks, with ragged batch shapes β€” which is what FlashInfer (MLSys 2025 best paper) specializes in: block-sparse KV layouts and JIT-compiled kernel variants per attention configuration. Engines treat attention backends as pluggable and pick per hardware: SGLang auto-selects FlashAttention-3 on Hopper and TensorRT-LLM's MHA kernels on Blackwell, with FlashInfer elsewhere; vLLM similarly ships several. You should know why backends exist (paged + ragged + new GPUs each need specialized kernels), not the matrix of which is default where β€” it changes quarterly.

Graphs and schedulers: the CPU is the silent bottleneck

A decode step is thousands of small kernel launches; at 5–10 Β΅s of CPU launch overhead each, a Python-driven engine can leave the GPU idle between kernels. Two standard fixes. CUDA graphs record the whole step's launch sequence once and replay it as a single unit β€” vLLM captures full-step graphs for decode by default (its V1 engine pairs them with torch.compile). Overlap scheduling hides the rest of the CPU work: TensorRT-LLM's scheduler (on by default) and SGLang's "zero-overhead" scheduler prepare step n+1 β€” batch formation, sampling bookkeeping, stop-criteria checks β€” while the GPU executes step n, so the GPU never waits for Python. These two are why "it's written in Python" stopped being a serving-performance argument.

Compile ahead-of-time, or JIT?

The historical architectural split: classic TensorRT-LLM compiled each model into a serialized TensorRT engine β€” peak fused kernels, at the cost of a minutes-to-hours build per model Γ— GPU Γ— configuration, and rebuilds on any change. vLLM and SGLang load checkpoints directly into a PyTorch runtime and recover kernel performance with the techniques above. The market voted: TensorRT-LLM 1.0 (September 2025) made its PyTorch-based runtime the default and filed the TensorRT-engine flow under "legacy" β€” its release notes call the PyTorch architecture "the default experience." The convergent stack everywhere is now: PyTorch runtime + hand-tuned attention/GEMM kernel libraries + torch.compile-style fusion + CUDA graphs.

Ahead-of-time compile β€” classic TensorRT-LLM (now the legacy path) HF checkpoint safetensors trtllm-build Β· minutes–hours per model Γ— GPU arch Γ— max shapes serialized engine opaque, GPU-specific serve peak fused kernels ⚠ rebuild on any change Β· NVIDIA-only Β· max shapes fixed at build time JIT runtime β€” vLLM Β· SGLang Β· TensorRT-LLM 1.x default HF checkpoint safetensors load directly seconds–minutes torch.compile + kernel libs FlashAttention-3 / FlashInfer / GEMMs CUDA graphs β†’ serve warmup at startup Β· portable βœ“ no build step Β· any checkpoint day-0 Β· pay a startup warmup instead TensorRT-LLM 1.0 (2025): PyTorch runtime becomes "the default experience" β€” the split is closing

AoT vs JIT. The AoT row still exists (and still squeezes out the last few percent in fixed deployments), but all three engines now default to the bottom row.

Serving features that shape the design

Two product-level features matter architecturally β€” they reach into the scheduler and the cache, which is why they belong in a systems doc and an interview answer.

Structured output

JSON mode and schema-constrained generation work by masking: at every decode step, the engine compiles the grammar's current state into a bitmask over the vocabulary and zeroes out the logits of every token that would violate it. Done naively over a 100K+ token vocabulary, that walk costs milliseconds per token β€” a tax on every step. xgrammar fixed the economics: most grammar states' valid-token sets don't depend on runtime context, so precompile them, and overlap the rest with the GPU's forward pass β€” near-zero overhead. It is now the default backend in SGLang, the primary one in vLLM (alongside llguidance-family options), and one of TensorRT-LLM's two guided-decoding backends. SGLang adds jump-forward decoding: when the grammar forces a run of tokens ({"name": "), emit them directly with zero model passes.

The systems insight to volunteer: constrained decoding changes the workload. Masking perturbs the output distribution your speculative drafter was trained to predict, so acceptance rates drop and speculation gains shrink; and jump-forwards make per-step timing irregular. "We turned on JSON mode and our ITL profile changed" is a real production story.

Multi-LoRA serving

Per-customer fine-tunes don't scale as full model replicas β€” but a LoRA adapter is megabytes, so one base model plus hundreds of resident adapters can serve hundreds of "different models" from one GPU. The enabling kernels (the Punica/S-LoRA lineage) batch requests across different adapters into one step: the dense base-model math stays batched, and the small per-adapter matrices are gathered per request. All three engines ship this, with dynamic load/unload. The two design hooks: the router must know adapter residency (an adapter miss costs a load), and the adapter ID must be part of the prefix-cache key β€” vLLM folds the LoRA ID into its block hashes β€” or you serve one tenant's cache to another.

Scaling beyond one GPU: the cluster layer

When the model outgrows one GPU, you shard the model: tensor parallelism inside a node (cuts per-GPU memory and per-GPU bytes-streamed, so decode gets faster), pipeline parallelism across nodes (adds latency, scales capacity), expert parallelism for MoE models. That story is told in the vLLM doc. The staff material is what happens when you scale the fleet β€” because every property from earlier sections (two-phase requests, giant per-request state, cache hit rates worth 10Γ— TTFT) breaks the assumptions of standard load balancing and autoscaling.

Disaggregated prefill/decode

Chunked prefill manages prefill/decode interference; DistServe asked why the two phases share GPUs at all. Colocated, they fight: prefills spike decode ITL, decodes steal prefill compute, and β€” the deeper point β€” each phase wants different resourcing. Prefill is compute-bound, gains from aggressive tensor parallelism, and is sized against TTFT; decode is bandwidth-bound, wants maximum batch, and is sized against ITL/TPOT. Splitting them into separate pools lets you optimize and scale each against its own SLO; DistServe measured up to 7.4Γ— more requests (or 12.6Γ— tighter SLOs) at the same goodput target. The cost: the prompt's KV cache now has to move from a prefill GPU to a decode GPU. Note what disaggregation buys β€” vLLM's docs say it plainly: it does not improve throughput; it buys SLO control (predictable ITL) and independent scaling. Moonshot's Mooncake (the serving platform behind Kimi, FAST 2025 best paper) is the canonical production validation: a KV-cache-centric disaggregated architecture handling ~75% more requests under real SLOs.

colocated baseline: every long prefill spikes ITL for every co-located decode β€” and the two phases want different parallelism PREFILL pool GPU Β· TP=4 compute-bound GPU Β· TP=4 compute-bound sized & scaled against TTFT SLO deep TP β†’ fast first token new prompts enter here DECODE pool GPU big batch GPU big batch GPU big batch sized & scaled against ITL/TPOT SLO bandwidth-bound β†’ batch wide tokens stream from here KV cache transfer β€” NIXL / Mooncake payload: 2K tok Γ— 128 KiB β‰ˆ 0.27 GB NVLink / RDMA β€” must cost β‰ͺ TTFT budget KV cache tiers β€” GPU HBM β†’ CPU RAM β†’ SSD / remote store LMCache Β· Dynamo KV Block Manager β€” evicted prefixes restored cheaper than recompute, shared across instances and sessions

Separate pools, separate SLOs, separate scaling. The KV transfer is the tax β€” cheap inside an NVLink domain, real across nodes, decisive at long prompt lengths.

KV transfer is the new plumbing. A 2K-token prompt on the 8B model is ~270 MB of KV; a 32K-token agent context on a 70B model is ~10 GB β€” per request. Moving that fast enough to hide inside the TTFT budget requires NVLink- or RDMA-class paths and zero-copy engineering, which is why it grew dedicated infrastructure: NIXL abstracts the transport (GPU↔GPU, CPU, storage backends) for Dynamo and vLLM's connector; SGLang ships Mooncake's transfer engine as its default and NIXL as an alternative; vLLM exposes all of this behind an experimental KV-connector API. The same machinery enables KV tiering β€” LMCache and Dynamo's KV Block Manager park cold prefixes in CPU RAM, SSD, or remote stores and restore them cheaper than recomputing, turning the prefix cache from a per-GPU optimization into a fleet-wide, even cross-session, one.

Cache-aware routing

Now route requests into that fleet. Round-robin β€” correct for stateless services β€” is wrong twice here: it ignores that request costs vary by 100Γ—, and it actively destroys prefix-cache hit rates by scattering a tenant's identical-prefix requests across replicas, each of which prefills the same system prompt from scratch. The fix is a stateful router that scores replicas on prefix affinity and load. SGLang's Model Gateway maintains an approximate copy of each worker's radix tree and routes to the longest match unless load skew exceeds a threshold (its v0.4 measurements: cache hit rate 20%β†’75%, ~2Γ— throughput). In Kubernetes-land, llm-d β€” a CNCF sandbox project founded by Red Hat, Google Cloud, IBM Research, CoreWeave, and NVIDIA β€” packages the same idea as an "endpoint picker" plugged into the Gateway API: score pods on KV-affinity, queue depth, and load, then pick. NVIDIA's Dynamo ships KV-aware routing plus the disaggregation orchestration as an engine-agnostic layer over vLLM, SGLang, and TensorRT-LLM. The tension to name in an interview: affinity routing concentrates load exactly where the cache is hottest, so it needs an explicit balance valve against hot-spotting a replica.

client client client cache-aware router prefix-affinity score Γ— load score Β· per-tenant state SGLang Model Gateway Β· llm-d endpoint picker Β· Dynamo router prefix hit replica 1 KV cache 70% full cached: tenant-A sys replica 2 KV cache 35% full cached: tenant-A sys replica 3 KV cache 85% full cached: tenant-B sys replica 4 β€” warming (minutes) autoscaler scale on queue depth Β· KV utilization Β· SLO attainment NOT on "GPU utilization" vllm:num_requests_waiting Β· vllm:kv_cache_usage_perc

The cluster layer: a stateful, cache-aware load balancer and an autoscaler watching engine-level signals. This composes with disaggregation β€” prefill and decode pools each get this treatment.

Autoscaling on the right signal

GPU utilization is the wrong autoscaling signal for LLM serving β€” a decode-bound GPU reads ~100% "utilized" while leaving most of its compute idle, so the classic CPU-style policy never fires or always fires. The signals that track real headroom are the ones engines export natively: queue depth (vLLM's vllm:num_requests_waiting), KV-cache utilization (vllm:kv_cache_usage_perc), and SLO attainment β€” goodput per replica. Dynamo bakes this in as an SLA-driven "Planner" that profiles workloads and right-sizes prefill and decode pools separately, which is the shape to sketch in a design interview: two pools, two SLOs, two scaling loops. And remember scale-out is slow β€” pulling tens of GB of weights and warming CUDA graphs takes minutes, so production fleets keep warm pools and scale ahead of demand, not in response to it.

vLLM vs TensorRT-LLM vs SGLang

By 2026 the feature matrices have largely converged β€” every row of the table below was a differentiator once and is now near-universal. So the comparison that matters is about design center: what each engine optimizes for when tradeoffs bite, and what ecosystem you are buying into. (For the spec-sheet view β€” KV layout, codegen, licenses, plus TGI and llama.cpp β€” see the vLLM doc's table.)

vLLMTensorRT-LLMSGLang
Design center The portable default β€” OpenAI-compatible serving for any HF checkpoint, day-0 model support Peak performance on NVIDIA hardware, vertically integrated with the NVIDIA stack (Dynamo, NIM, Triton) Cache-first engine for agentic and structured workloads β€” RadixAttention is the organizing principle
Lineage UC Berkeley; now a PyTorch Foundation project NVIDIA LMSYS; PyTorch-ecosystem member
Scheduling edge Unified per-step scheduler; chunked prefill on by default In-flight batching; overlap scheduler on by default (CPU preps step n+1 while GPU runs step n) "Zero-overhead" scheduler runs one batch ahead; scheduling is cache-aware (orders work to maximize radix-tree hits)
Prefix reuse Hash-block prefix cache, on by default Block reuse, indexed by a radix search tree Radix tree at token granularity, on by default
Quantization path llm-compressor / compressed-tensors (FP8, INT8, INT4, AWQ, GPTQ); runs ModelOpt checkpoints too NVIDIA Model Optimizer: FP8, AWQ/GPTQ W4, NVFP4 + MXFP4 on Blackwell, FP8/NVFP4 KV cache Broadest input formats: FP8, FP4, INT4/8, AWQ, GPTQ, plus online quantization
Speculative decoding EAGLE-3, MTP, draft model, n-gram, suffix decoding EAGLE-3, MTP, draft-target, n-gram, PARD EAGLE-2/3, MTP, standalone draft, n-gram
Structured output xgrammar + llguidance (auto-selected) xgrammar + LLGuidance xgrammar default; jump-forward decoding
Cluster story KV-connector API (NIXL, LMCache, Mooncake); llm-d and production-stack; Dynamo-supported NVIDIA Dynamo: P/D disaggregation, KV-aware routing, SLA-driven planner Model Gateway (cache-aware routing); P/D disaggregation with Mooncake / NIXL transfer built in
Hardware NVIDIA, AMD, Intel, TPU, AWS accelerators, CPU NVIDIA only (Ampere and newer) NVIDIA, AMD, Intel CPU/XPU, TPU, Ascend
Pick it when… You want the safe default: broadest model/hardware coverage, largest community, every feature present All-NVIDIA fleet, chasing the last 10–20% (or Blackwell FP4), and buying the NVIDIA serving stack end-to-end Heavy prefix sharing β€” agents, few-shot pipelines, multi-turn β€” or structured-output throughput is the bottleneck

Read as philosophies: vLLM optimizes for reach β€” neutral governance, every model and accelerator, the choice that's never wrong and rarely maximal. TensorRT-LLM optimizes for the hardware β€” NVIDIA builds it next to the silicon and ships Blackwell-format quantization first; the price is single-vendor lock at both layers. SGLang optimizes for the cache β€” it bet earliest that production traffic is dominated by shared prefixes and structured output, and built the engine outward from that bet.

The closing argument for an interview: convergent evolution is the strongest fact about this space. Paged KV, continuous batching, chunked prefill, EAGLE-3, xgrammar, FP8, P/D disaggregation β€” each appeared in one engine and was in all three within roughly a year. Radix-tree cache indexing is in two of three and counting. So don't choose on a feature checkbox that will be obsolete in two releases; choose on operational fit β€” hardware commitments, Kubernetes story, governance, and which community your team can debug alongside at 2 a.m.

Interview crib

Canonical questions for this topic, with the compressed answers. Each one is unpacked in a section above.

Why does batching help decode enormously but barely help prefill?

Decode is memory-bandwidth-bound: a step's cost is dominated by streaming the weights, which a batch shares β€” 64 requests get 64 tokens for roughly one token's memory traffic. Prefill is already compute-saturated by one request's matmuls, so batching adds little. (Β§ workload)

TTFT is fine, but P99 ITL spikes when traffic rises. What's happening?

Arriving requests' prefills are hijacking whole decode steps, so streaming requests stall whenever a long prompt lands. Fixes, in order of escalation: chunked prefill (cap prefill tokens per step), then disaggregate prefill and decode into separate pools. (Β§ toolkit, Β§ cluster)

How much KV memory does a 70B GQA model need per token β€” derive it.

2 (K and V) Γ— 80 layers Γ— 8 KV heads Γ— 128 head_dim Γ— 2 bytes (BF16) = 320 KiB/token. The lever is KV heads: same math at 32 MHA heads would be 1.25 MiB. (Β§ capacity)

What bounds single-stream decode speed, and at what number?

HBM bandwidth Γ· bytes-per-step. Every decode step must re-read all weights: 8B in BF16 on an H100 β†’ 3,350 Γ· 16 β‰ˆ 209 tok/s, optimistically, before KV reads. Quantization shrinks the numerator's bytes; speculation amortizes reads over multiple tokens; nothing else moves it. (Β§ capacity)

When does speculative decoding hurt?

When decode has no bandwidth slack to spend: large batches (verify passes now compete with real work), low acceptance rate (draft overhead with no payoff), and constrained decoding (masking shifts the distribution the drafter learned, tanking Ξ±). It's a low-batch latency tool, not a throughput tool. (Β§ toolkit)

Why physically separate prefill and decode instead of just scheduling smarter?

Interference is only half the reason β€” the phases also want different parallelism and different scaling signals (TTFT vs TPOT). Separate pools optimize each independently; the price is shipping the prompt's KV cache between pools fast enough to hide in the TTFT budget. It buys SLO control, not throughput. (Β§ cluster)

Why does round-robin load balancing underperform for LLM fleets?

Request costs vary ~100Γ— (unknown output lengths), and round-robin scatters identical prefixes across replicas, destroying prefix-cache hit rates β€” every replica re-prefills the same system prompt. Cache-aware routers score replicas on prefix affinity plus load. (Β§ cluster)

Why not autoscale on GPU utilization?

A bandwidth-bound decode GPU reports ~100% utilization while idling most of its compute β€” the metric carries no headroom signal. Scale on queue depth, KV-cache utilization, and SLO attainment (goodput), which engines export directly. (Β§ cluster)

INT4 weights vs FP8 W8A8 β€” when each?

INT4 weight-only quarters the bytes decode streams β†’ best single-stream latency and smallest footprint, but math stays FP16, so prefill barely moves. FP8 W8A8 runs on Hopper's FP8 tensor cores β†’ ~2Γ— the matmul rate, helping prefill and decode β€” the throughput default on H100s. (Β§ fast)

A prefix-cache hit improves which metric β€” TTFT, ITL, or both?

TTFT only. The hit skips prefill compute for the shared prefix; decode still does identical work per token. (Corollary: prefix caching does nothing for decode-bound throughput β€” its wins are TTFT and prefill capacity.) (Β§ toolkit)

How can maximizing throughput ruin goodput?

Past the saturation knee, added load still raises tokens/s while pushing every in-flight request's ITL over the SLO β€” tokens flow, but zero requests count. Capacity is the throughput at the SLO intersection, not the curve's maximum. (Β§ metrics)

Further reading

Primary sources only β€” the papers that named the ideas and the engines' own documentation.