<?xml version="1.0" encoding="utf-8" standalone="yes"?><rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom"><channel><title>Tensorrt-Llm on lo0 — Tech Blog</title><link>https://blog.lo0.es/en/tags/tensorrt-llm/</link><description>Recent content in Tensorrt-Llm on lo0 — Tech Blog</description><generator>Hugo -- gohugo.io</generator><language>en</language><lastBuildDate>Tue, 16 Jun 2026 02:00:00 +0200</lastBuildDate><atom:link href="https://blog.lo0.es/en/tags/tensorrt-llm/index.xml" rel="self" type="application/rss+xml"/><item><title>LLM serving engines compared on the Pareto frontier: vLLM, SGLang, TRT-LLM and Dynamo</title><link>https://blog.lo0.es/en/posts/llm-serving-engines-pareto-frontier-comparison/</link><pubDate>Tue, 16 Jun 2026 02:00:00 +0200</pubDate><guid>https://blog.lo0.es/en/posts/llm-serving-engines-pareto-frontier-comparison/</guid><description>&lt;blockquote>
&lt;p>Notation: amounts in &lt;strong>euros (N €)&lt;/strong>, decimals with a point. The dollar symbol is not used
(on this site it is a formula delimiter). Reference hardware: generic configuration
4×H100 SXM 80 GB; the figures are illustrative for comparing engines, not production
metrics from any real infrastructure.&lt;/p>
&lt;/blockquote>
&lt;h2 id="tldr">TL;DR&lt;/h2>
&lt;p>Four OSS engines dominate on-premise LLM serving in 2026: &lt;strong>vLLM&lt;/strong>, &lt;strong>SGLang&lt;/strong>,
&lt;strong>NVIDIA TensorRT-LLM&lt;/strong> and &lt;strong>NVIDIA Dynamo&lt;/strong>. None is superior on every axis. In
raw throughput with a shared prompt, SGLang beats vLLM by 29 % on Llama-3.3-70B
FP8 over H100 (16,200 vs 12,500 tok/s). In decode latency, TRT-LLM keeps ITL more
stable at high concurrency (9–12 ms P50 against 18–22 ms for vLLM). Dynamo is not an
inference engine: it is the &lt;strong>orchestration layer&lt;/strong> on top of the previous three for
multi-node clusters, and it reports up to 7× additional throughput in disaggregated serving on Blackwell.
The right choice depends on three variables: &lt;strong>load pattern&lt;/strong> (batch/interactive),
&lt;strong>deployment scale&lt;/strong> (single node/multi-node) and &lt;strong>operational constraints&lt;/strong> (OSS
maturity, ease of operation). The Pareto decision table is at the end of the article.&lt;/p>
&lt;hr>
&lt;h2 id="framing-the-article-what-this-b8-is-and-is-not">Framing the article: what this B8 is and is not&lt;/h2>
&lt;p>This article (B8 of the benchmarking pillar) takes for granted the metrics from the
&lt;a href="https://blog.lo0.es/en/posts/llm-inference-benchmarking-frameworks-metrics-state-of-the-art/">introduction to benchmarking&lt;/a> and
the tool catalogue from &lt;a href="https://blog.lo0.es/en/posts/llm-benchmark-tool-catalogue-practical-deep-dive/">the card-by-card piece&lt;/a>.
It applies those concepts to engine selection. The protocol from &lt;a href="https://blog.lo0.es/en/posts/guidellm-validating-slo-under-load-sizing-knee/">GuideLLM for SLO
validation&lt;/a> and &lt;a href="https://blog.lo0.es/en/posts/genai-perf-llm-inference-profiling-in-depth/">AIPerf in depth&lt;/a>
detail how to run the harness; here the result is used. The fundamentals of
&lt;a href="https://blog.lo0.es/en/posts/continuous-batching-orca-vllm-chunked-prefill-goodput/">continuous batching&lt;/a>,
&lt;a href="https://blog.lo0.es/en/posts/disaggregated-serving-prefill-decode-specialised-pods/">disaggregated serving&lt;/a>,
&lt;a href="https://blog.lo0.es/en/posts/vllm-attention-backend-flashattention-flashinfer-prefill-decode-asymmetry/">the attention backend&lt;/a> and
&lt;a href="https://blog.lo0.es/en/posts/quantization-llm-inference-fp8-int4-gguf/">quantisation&lt;/a> are assumed known.&lt;/p>
&lt;hr>
&lt;h2 id="card-for-each-engine">Card for each engine&lt;/h2>
&lt;h3 id="vllm">vLLM&lt;/h3>
&lt;table>
&lt;thead>
&lt;tr>
&lt;th>Attribute&lt;/th>
&lt;th>Value&lt;/th>
&lt;/tr>
&lt;/thead>
&lt;tbody>
&lt;tr>
&lt;td>Reference version&lt;/td>
&lt;td>v0.9.x (June 2026)&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>Licence&lt;/td>
&lt;td>Apache 2.0&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>Maintainer / governance&lt;/td>
&lt;td>vLLM Project (Linux Foundation AI), led by UC Berkeley + Red Hat&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>Supported hardware&lt;/td>
&lt;td>NVIDIA (Ampere/Hopper/Blackwell), AMD ROCm, Intel Gaudi, TPU, CPU&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>Repository&lt;/td>
&lt;td>github.com/vllm-project/vllm&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>API&lt;/td>
&lt;td>OpenAI-compatible (completions, chat, embeddings)&lt;/td>
&lt;/tr>
&lt;/tbody>
&lt;/table>
&lt;p>Origin: PagedAttention (Kwon et al., 2023). The first implementation of continuous batching
with a paged KV cache. In 2026 it is the most deployed engine in OSS environments and the one that
accumulates the most integrations (Ray Serve, Kubernetes, llm-d, Dynamo).&lt;/p>
&lt;h3 id="sglang">SGLang&lt;/h3>
&lt;table>
&lt;thead>
&lt;tr>
&lt;th>Attribute&lt;/th>
&lt;th>Value&lt;/th>
&lt;/tr>
&lt;/thead>
&lt;tbody>
&lt;tr>
&lt;td>Reference version&lt;/td>
&lt;td>v0.4.x (June 2026)&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>Licence&lt;/td>
&lt;td>Apache 2.0&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>Maintainer / governance&lt;/td>
&lt;td>LMSYS Org (Berkeley, CMU, UCSD)&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>Supported hardware&lt;/td>
&lt;td>NVIDIA (Ampere/Hopper/Blackwell), AMD ROCm&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>Repository&lt;/td>
&lt;td>github.com/sgl-project/sglang&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>API&lt;/td>
&lt;td>OpenAI-compatible + SGLang runtime API&lt;/td>
&lt;/tr>
&lt;/tbody>
&lt;/table>
&lt;p>Origin: the SGLang paper, 2024 (Zheng et al.). It introduces RadixAttention as a generalisation of
prefix caching: a radix tree of KV blocks with an LRU policy shared across all
in-flight requests. A structural advantage in workloads with shared prefixes (long system
prompts, few-shot, RAG).&lt;/p>
&lt;h3 id="nvidia-tensorrt-llm-trt-llm">NVIDIA TensorRT-LLM (TRT-LLM)&lt;/h3>
&lt;table>
&lt;thead>
&lt;tr>
&lt;th>Attribute&lt;/th>
&lt;th>Value&lt;/th>
&lt;/tr>
&lt;/thead>
&lt;tbody>
&lt;tr>
&lt;td>Reference version&lt;/td>
&lt;td>v0.18.x (June 2026)&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>Licence&lt;/td>
&lt;td>Apache 2.0&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>Maintainer / governance&lt;/td>
&lt;td>NVIDIA (proprietary in practice, OSS in name)&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>Supported hardware&lt;/td>
&lt;td>NVIDIA only: Ampere (A100), Hopper (H100/H200/GH200), Ada (L40/L40S), Blackwell (B200/GB200)&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>Repository&lt;/td>
&lt;td>github.com/NVIDIA/TensorRT-LLM&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>API&lt;/td>
&lt;td>Python + C++ runtime; integrates with Triton Inference Server&lt;/td>
&lt;/tr>
&lt;/tbody>
&lt;/table>
&lt;p>The lowest-level engine: it compiles inference graphs with TensorRT, applies kernel fusion
and generates binary engines specific to model + hardware + precision. Maximum performance
on NVIDIA, with no portability to other hardware. It requires recompilation when the model or
configuration changes; build time ranges from minutes to hours depending on the model.&lt;/p>
&lt;h3 id="nvidia-dynamo">NVIDIA Dynamo&lt;/h3>
&lt;table>
&lt;thead>
&lt;tr>
&lt;th>Attribute&lt;/th>
&lt;th>Value&lt;/th>
&lt;/tr>
&lt;/thead>
&lt;tbody>
&lt;tr>
&lt;td>Reference version&lt;/td>
&lt;td>v1.1.1 (May 2026)&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>Licence&lt;/td>
&lt;td>Apache 2.0&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>Maintainer / governance&lt;/td>
&lt;td>NVIDIA + OSS community (ai-dynamo org on GitHub)&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>Supported hardware&lt;/td>
&lt;td>NVIDIA (through the underlying engine: vLLM, SGLang or TRT-LLM)&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>Repository&lt;/td>
&lt;td>github.com/ai-dynamo/dynamo&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>Role&lt;/td>
&lt;td>Orchestration layer, NOT an inference engine&lt;/td>
&lt;/tr>
&lt;/tbody>
&lt;/table>
&lt;p>Dynamo &lt;strong>does not run inference directly&lt;/strong>: it is the scheduler and router that
coordinates pools of workers (vLLM, SGLang, TRT-LLM) across multiple nodes. Implemented in Rust
(performance) + Python (extensibility). It adds: disaggregated prefill/decode serving across
nodes, KV-aware routing (avoiding prefill recompute if another worker already has the KV in
cache), automatic scaling by SLA, a KV Block Manager (KVBM) with offload to CPU/SSD/remote
and transfer via NIXL over NVLink/InfiniBand.&lt;/p>
&lt;h3 id="honourable-mentions-lmdeploy-and-hf-tgi">Honourable mentions: LMDeploy and HF TGI&lt;/h3>
&lt;p>&lt;strong>LMDeploy&lt;/strong> (OpenMMLab/InternLM, Apache 2.0): an engine with two backends, TurboMind (C++,
maximum performance on NVIDIA) and PyTorch (flexibility). KV cache blocking and persistent
batching; 4-bit inference up to 2.4× faster than FP16 in its own benchmark. A narrower
ecosystem; no AMD support.&lt;/p>
&lt;p>&lt;strong>HF TGI&lt;/strong> (Hugging Face, Apache 2.0): the reference engine for the HuggingFace ecosystem.
TGI v3.0 cuts latency by up to 13× on long prompts against earlier versions; it integrates
FlashAttention + PagedAttention. The usual entry point for teams already operating the
HF stack; it is not the most efficient in throughput at maximum load.&lt;/p>
&lt;hr>
&lt;h2 id="capability-matrix">Capability matrix&lt;/h2>
&lt;p>The table uses this notation: &lt;strong>Yes&lt;/strong> = supported and stable, &lt;strong>Beta&lt;/strong> = available but not
recommended for production, &lt;strong>No&lt;/strong> = not available or only with a manual workaround.&lt;/p>
&lt;table>
&lt;thead>
&lt;tr>
&lt;th>Capability&lt;/th>
&lt;th>vLLM&lt;/th>
&lt;th>SGLang&lt;/th>
&lt;th>TRT-LLM&lt;/th>
&lt;th>Dynamo (via backend)&lt;/th>
&lt;/tr>
&lt;/thead>
&lt;tbody>
&lt;tr>
&lt;td>&lt;strong>Continuous batching&lt;/strong>&lt;/td>
&lt;td>Yes&lt;/td>
&lt;td>Yes&lt;/td>
&lt;td>Yes&lt;/td>
&lt;td>Yes (delegates to the engine)&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>&lt;strong>PagedAttention&lt;/strong>&lt;/td>
&lt;td>Yes&lt;/td>
&lt;td>Yes&lt;/td>
&lt;td>Yes&lt;/td>
&lt;td>Yes&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>&lt;strong>RadixAttention / prefix caching&lt;/strong>&lt;/td>
&lt;td>Yes (chunked prefill + prefix)&lt;/td>
&lt;td>Yes (native RadixAttention)&lt;/td>
&lt;td>Yes (prefix caching)&lt;/td>
&lt;td>Yes + KV-aware routing&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>&lt;strong>Speculative decoding&lt;/strong>&lt;/td>
&lt;td>Yes (n-gram, EAGLE, DFlash, suffix)&lt;/td>
&lt;td>Yes (draft+verify)&lt;/td>
&lt;td>Yes (EAGLE-3, MTP)&lt;/td>
&lt;td>Yes&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>&lt;strong>FP8 quantisation&lt;/strong>&lt;/td>
&lt;td>Yes&lt;/td>
&lt;td>Yes&lt;/td>
&lt;td>Yes&lt;/td>
&lt;td>Yes&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>&lt;strong>MXFP4/NVFP4 quantisation&lt;/strong>&lt;/td>
&lt;td>Yes&lt;/td>
&lt;td>Beta&lt;/td>
&lt;td>Yes (Blackwell)&lt;/td>
&lt;td>Yes&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>&lt;strong>INT4 AWQ quantisation&lt;/strong>&lt;/td>
&lt;td>Yes&lt;/td>
&lt;td>Yes&lt;/td>
&lt;td>Yes&lt;/td>
&lt;td>Yes&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>&lt;strong>GPTQ quantisation&lt;/strong>&lt;/td>
&lt;td>Yes&lt;/td>
&lt;td>Yes&lt;/td>
&lt;td>Not native&lt;/td>
&lt;td>Yes (via vLLM)&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>&lt;strong>INT8 quantisation (SmoothQuant)&lt;/strong>&lt;/td>
&lt;td>Yes&lt;/td>
&lt;td>Yes&lt;/td>
&lt;td>Yes&lt;/td>
&lt;td>Yes&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>&lt;strong>Structured output&lt;/strong>&lt;/td>
&lt;td>Yes&lt;/td>
&lt;td>Yes (compressed FSM)&lt;/td>
&lt;td>Beta&lt;/td>
&lt;td>Yes&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>&lt;strong>Dynamic multi-LoRA&lt;/strong>&lt;/td>
&lt;td>Yes&lt;/td>
&lt;td>Yes&lt;/td>
&lt;td>Beta&lt;/td>
&lt;td>Yes&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>&lt;strong>Disaggregated prefill/decode&lt;/strong>&lt;/td>
&lt;td>Beta (experimental)&lt;/td>
&lt;td>Yes&lt;/td>
&lt;td>Yes&lt;/td>
&lt;td>Yes (first class)&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>&lt;strong>Chunked prefill&lt;/strong>&lt;/td>
&lt;td>Yes&lt;/td>
&lt;td>Yes&lt;/td>
&lt;td>Yes&lt;/td>
&lt;td>Yes&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>&lt;strong>Multimodal support (VLM)&lt;/strong>&lt;/td>
&lt;td>Yes&lt;/td>
&lt;td>Yes&lt;/td>
&lt;td>Yes&lt;/td>
&lt;td>Yes&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>&lt;strong>Multi-node tensor parallelism&lt;/strong>&lt;/td>
&lt;td>Yes&lt;/td>
&lt;td>Yes&lt;/td>
&lt;td>Yes&lt;/td>
&lt;td>Yes (+ pipeline across nodes)&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>&lt;strong>OpenAI API compatible&lt;/strong>&lt;/td>
&lt;td>Yes&lt;/td>
&lt;td>Yes&lt;/td>
&lt;td>Yes (via Triton)&lt;/td>
&lt;td>Yes&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>&lt;strong>Non-NVIDIA hardware&lt;/strong>&lt;/td>
&lt;td>Yes (AMD/Intel/TPU)&lt;/td>
&lt;td>Yes (AMD)&lt;/td>
&lt;td>No&lt;/td>
&lt;td>No&lt;/td>
&lt;/tr>
&lt;/tbody>
&lt;/table>
&lt;p>Sources for the matrix: docs.vllm.ai, docs.sglang.io, nvidia.github.io/TensorRT-LLM,
docs.nvidia.com/dynamo, github.com/ai-dynamo/dynamo (feature matrix, June 2026).&lt;/p>
&lt;hr>
&lt;h2 id="why-they-are-not-compared-lightly-the-variables-that-break-a-comparison">Why they are not compared lightly: the variables that break a comparison&lt;/h2>
&lt;p>Before the table of figures, the four decisions that invalidate any cross-engine benchmark:&lt;/p>
&lt;p>&lt;strong>1. A different harness.&lt;/strong> A benchmark run with each engine&amp;rsquo;s native micro-bench (vllm bench
serve vs SGLang bench) measures the client, not the engine. The discrepancy can be up to 7.2× at
high concurrency. The correct protocol: a single load generator (AIPerf or GuideLLM)
against every endpoint. The engine is the variable, the tool is the constant.&lt;/p>
&lt;p>&lt;strong>2. Different ISL/OSL.&lt;/strong> Input Sequence Length and Output Sequence Length determine the
prefill/decode ratio and therefore which engine is favoured. A benchmark with low ISL (64 tokens)
favours engines with fast decode; with high ISL (2,048 tokens) and shared prefixes, it
favours SGLang (RadixAttention). Fixing ISL and OSL is mandatory for the comparison
to be valid.&lt;/p>
&lt;p>&lt;strong>3. Different precision.&lt;/strong> FP16 and FP8 are not comparable: FP8 gives between 1.5× and 2× more
throughput on Hopper. Comparing vLLM FP16 with TRT-LLM FP8 says nothing about the engine.&lt;/p>
&lt;p>&lt;strong>4. Not reporting P99.&lt;/strong> Maximum throughput is reached at the point where TTFT P99 already
violates the SLO. The defensible number is goodput, throughput under the SLO, not the raw
maximum. The end-to-end latency formula:&lt;/p>
$$\text{latency}_{e2e} \approx \text{TTFT} + (N_{\text{out}} - 1) \times \text{TPOT}$$
&lt;p>where \(N_{\text{out}}\) is the number of output tokens, makes it clear that a high TTFT with low TPOT
has the same effect as the inverse case only for one specific \(N_{\text{out}}\):
both must be reported separately.&lt;/p>
&lt;p>The complete fair-comparison protocol is in
&lt;a href="https://blog.lo0.es/en/posts/llm-benchmark-tool-catalogue-practical-deep-dive/">the tools article&lt;/a>.&lt;/p>
&lt;hr>
&lt;h2 id="performance-on-the-pareto-frontier-illustrative-figures">Performance on the Pareto frontier: illustrative figures&lt;/h2>
&lt;p>Reference configuration for every row: &lt;strong>4×H100 SXM 80 GB NVLink&lt;/strong>,
model &lt;strong>Llama-3.1-70B-Instruct FP8&lt;/strong>, harness &lt;strong>AIPerf&lt;/strong> (formerly genai-perf, the multi-process successor),
dataset &lt;strong>ShareGPT&lt;/strong> (realistic length distribution), mean ISL ~512 tok, mean OSL
~256 tok, SLO TTFT P99 &amp;lt; 500 ms. The figures are illustrative and representative of benchmarks
published by the community (Cerebrium, LMSYS, Spheron, Red Hat MLPerf v5.1); they are not measurements
from any real infrastructure. Exact figures vary with engine versions, compilation
flags and load distribution.&lt;/p>
&lt;h3 id="throughput-vs-latency-pareto-table">Throughput vs latency: Pareto table&lt;/h3>
&lt;table>
&lt;thead>
&lt;tr>
&lt;th>Engine&lt;/th>
&lt;th>Config&lt;/th>
&lt;th>Throughput (tok/s)&lt;/th>
&lt;th>TTFT P50 (ms)&lt;/th>
&lt;th>TTFT P99 (ms)&lt;/th>
&lt;th>ITL P50 (ms)&lt;/th>
&lt;th>Goodput (@SLO)&lt;/th>
&lt;/tr>
&lt;/thead>
&lt;tbody>
&lt;tr>
&lt;td>&lt;strong>vLLM v0.9&lt;/strong>&lt;/td>
&lt;td>FP8, chunked prefill&lt;/td>
&lt;td>12,500&lt;/td>
&lt;td>160&lt;/td>
&lt;td>420&lt;/td>
&lt;td>18&lt;/td>
&lt;td>~96 %&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>&lt;strong>SGLang v0.4&lt;/strong>&lt;/td>
&lt;td>FP8, RadixAttention&lt;/td>
&lt;td>16,200&lt;/td>
&lt;td>140&lt;/td>
&lt;td>390&lt;/td>
&lt;td>21&lt;/td>
&lt;td>~97 %&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>&lt;strong>TRT-LLM v0.18&lt;/strong>&lt;/td>
&lt;td>FP8, compiled engine&lt;/td>
&lt;td>14,800&lt;/td>
&lt;td>190&lt;/td>
&lt;td>480&lt;/td>
&lt;td>10&lt;/td>
&lt;td>~93 %&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>&lt;strong>vLLM + Dynamo&lt;/strong>&lt;/td>
&lt;td>FP8, disagg. P/D 2+2&lt;/td>
&lt;td>18,500&lt;/td>
&lt;td>120&lt;/td>
&lt;td>310&lt;/td>
&lt;td>19&lt;/td>
&lt;td>~98 %&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>&lt;strong>SGLang + Dynamo&lt;/strong>&lt;/td>
&lt;td>FP8, disagg. P/D 2+2&lt;/td>
&lt;td>21,000&lt;/td>
&lt;td>110&lt;/td>
&lt;td>280&lt;/td>
&lt;td>22&lt;/td>
&lt;td>~99 %&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>&lt;strong>TRT-LLM + Dynamo&lt;/strong>&lt;/td>
&lt;td>FP8, disagg. P/D 2+2&lt;/td>
&lt;td>22,500&lt;/td>
&lt;td>130&lt;/td>
&lt;td>350&lt;/td>
&lt;td>11&lt;/td>
&lt;td>~97 %&lt;/td>
&lt;/tr>
&lt;/tbody>
&lt;/table>
&lt;p>Reading notes:&lt;/p>
&lt;ul>
&lt;li>&amp;ldquo;Disagg. P/D 2+2&amp;rdquo; = 2 prefill GPUs + 2 decode GPUs (the same 4×H100 budget).&lt;/li>
&lt;li>Goodput falls against raw throughput when the latency tail exceeds the SLO of
500 ms P99. TRT-LLM without Dynamo has the lowest ITL but the highest TTFT at high
concurrency, which brings its goodput down to ~93 % under this SLO.&lt;/li>
&lt;li>Dynamo adds ~30–40 % of effective throughput over 4 GPUs thanks to disaggregated serving
and KV-aware routing, in the scenario with reusable prefixes (ShareGPT).&lt;/li>
&lt;li>In workloads without shared prefixes (short ISL, unique prompts), Dynamo&amp;rsquo;s advantage
shrinks to ~10–15 % over the base engine.&lt;/li>
&lt;/ul>
&lt;h3 id="saturation-point-concurrency-sweep-vllm-fp8-4h100">Saturation point (concurrency sweep, vLLM FP8, 4×H100)&lt;/h3>
&lt;table>
&lt;thead>
&lt;tr>
&lt;th>Concurrency&lt;/th>
&lt;th>TTFT P99 (ms)&lt;/th>
&lt;th>ITL P50 (ms)&lt;/th>
&lt;th>Throughput (tok/s)&lt;/th>
&lt;th>Goodput (tok/s)&lt;/th>
&lt;/tr>
&lt;/thead>
&lt;tbody>
&lt;tr>
&lt;td>8&lt;/td>
&lt;td>210&lt;/td>
&lt;td>14&lt;/td>
&lt;td>5,200&lt;/td>
&lt;td>5,200&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>16&lt;/td>
&lt;td>370&lt;/td>
&lt;td>18&lt;/td>
&lt;td>9,800&lt;/td>
&lt;td>9,800&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>32&lt;/td>
&lt;td>420&lt;/td>
&lt;td>18&lt;/td>
&lt;td>12,500&lt;/td>
&lt;td>12,200&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>48&lt;/td>
&lt;td>680&lt;/td>
&lt;td>26&lt;/td>
&lt;td>13,800&lt;/td>
&lt;td>7,400&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>64&lt;/td>
&lt;td>1,200&lt;/td>
&lt;td>41&lt;/td>
&lt;td>14,100&lt;/td>
&lt;td>2,100&lt;/td>
&lt;/tr>
&lt;/tbody>
&lt;/table>
&lt;p>The knee is between 32 and 48: throughput rises only 10 % (12,500 → 13,800) but TTFT
P99 already violates the 500 ms SLO and goodput halves. The defensible operational
capacity is the one at concurrency 32 (12,200 useful tok/s). Reporting 14,100 tok/s (concurrency 64)
would be maximum throughput with 15 % goodput.&lt;/p>
&lt;div class="diagram" style="max-width:780px;margin:1rem auto;">
&lt;svg viewBox="0 0 780 260" role="img" aria-label="Pareto frontier of LLM engines: throughput on the X axis, TTFT P99 on the Y axis. Each engine occupies a different point in the space, and Dynamo shifts the points up and to the right." xmlns="http://www.w3.org/2000/svg">
&lt;style>.ax{fill:none;stroke:currentColor;stroke-width:1}.cv{fill:none;stroke:currentColor;stroke-width:1.5}.dsh{fill:none;stroke:currentColor;stroke-width:1;stroke-dasharray:4 3}.tl{font:700 12px sans-serif;fill:currentColor}.ts{font:11px sans-serif;fill:currentColor}.pt{r:6;fill:currentColor}&lt;/style>
&lt;line class="ax" x1="60" y1="30" x2="60" y2="210"/>
&lt;line class="ax" x1="60" y1="210" x2="740" y2="210"/>
&lt;text x="20" y="125" class="ts" transform="rotate(-90 20 125)">TTFT P99 (ms) ↑ lower is better&lt;/text>
&lt;text x="360" y="235" class="ts" text-anchor="middle">Throughput (tok/s) → higher is better&lt;/text>
&lt;line class="dsh" x1="60" y1="80" x2="740" y2="80"/>
&lt;text x="66" y="76" class="ts">SLO 500 ms&lt;/text>
&lt;circle class="pt" cx="290" cy="108"/>
&lt;text x="298" y="103" class="tl">vLLM&lt;/text>
&lt;text x="298" y="116" class="ts">12,500 tok/s · 420 ms&lt;/text>
&lt;circle class="pt" cx="380" cy="98"/>
&lt;text x="388" y="93" class="tl">SGLang&lt;/text>
&lt;text x="388" y="106" class="ts">16,200 tok/s · 390 ms&lt;/text>
&lt;circle class="pt" cx="340" cy="118"/>
&lt;text x="348" y="113" class="tl">TRT-LLM&lt;/text>
&lt;text x="348" y="126" class="ts">14,800 tok/s · 480 ms&lt;/text>
&lt;circle class="pt" cx="480" cy="72"/>
&lt;text x="488" y="67" class="tl">vLLM+Dynamo&lt;/text>
&lt;text x="488" y="80" class="ts">18,500 tok/s · 310 ms&lt;/text>
&lt;circle class="pt" cx="580" cy="62"/>
&lt;text x="588" y="57" class="tl">SGLang+Dynamo&lt;/text>
&lt;text x="588" y="70" class="ts">21,000 tok/s · 280 ms&lt;/text>
&lt;circle class="pt" cx="660" cy="85"/>
&lt;text x="554" y="165" class="tl">TRT-LLM+Dynamo&lt;/text>
&lt;text x="554" y="177" class="ts">22,500 tok/s · 350 ms&lt;/text>
&lt;line class="dsh" x1="660" y1="85" x2="610" y2="160"/>
&lt;path class="cv" d="M290,108 Q380,80 480,72 Q580,60 660,85" fill="none"/>
&lt;text x="66" y="210" class="ts">0&lt;/text>
&lt;text x="290" y="225" class="ts">12 K&lt;/text>
&lt;text x="380" y="225" class="ts">16 K&lt;/text>
&lt;text x="480" y="225" class="ts">18.5 K&lt;/text>
&lt;text x="580" y="225" class="ts">21 K&lt;/text>
&lt;text x="660" y="225" class="ts">22.5 K&lt;/text>
&lt;/svg>
&lt;/div>
&lt;hr>
&lt;h2 id="fair-comparison-methodology-the-harness-as-the-constant">Fair-comparison methodology: the harness as the constant&lt;/h2>
&lt;p>The protocol that makes numbers comparable across different engines. The engine is the only
variable; everything else is constant and pinned in the output JSON.&lt;/p>
&lt;h3 id="variables-that-are-fixed-constants-of-the-experiment">Variables that are fixed (constants of the experiment)&lt;/h3>
&lt;table>
&lt;thead>
&lt;tr>
&lt;th>Variable&lt;/th>
&lt;th>Value fixed in the reference experiment&lt;/th>
&lt;/tr>
&lt;/thead>
&lt;tbody>
&lt;tr>
&lt;td>Load tool&lt;/td>
&lt;td>AIPerf v2.x (multi-process)&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>Model&lt;/td>
&lt;td>Llama-3.1-70B-Instruct&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>Precision&lt;/td>
&lt;td>FP8&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>Hardware&lt;/td>
&lt;td>4×H100 SXM 80 GB NVLink&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>Dataset&lt;/td>
&lt;td>ShareGPT (realistic ISL/OSL distribution)&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>SLO&lt;/td>
&lt;td>TTFT P99 &amp;lt; 500 ms, ITL P50 &amp;lt; 30 ms&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>Warm-up&lt;/td>
&lt;td>200 requests discarded before measuring&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>Sweep&lt;/td>
&lt;td>concurrencies 1, 4, 8, 16, 32, 48, 64&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>Primary metric&lt;/td>
&lt;td>goodput (useful tok/s under SLO)&lt;/td>
&lt;/tr>
&lt;/tbody>
&lt;/table>
&lt;h3 id="harness-steps">Harness steps&lt;/h3>
&lt;ol>
&lt;li>Deploy the engine with the exact config (pinned flags, fixed version).&lt;/li>
&lt;li>Warm-up: 200 requests; discard the results.&lt;/li>
&lt;li>Concurrency sweep: &lt;code>aiperf profile --concurrency-range 1:64:step&lt;/code> with AIPerf.&lt;/li>
&lt;li>Extend the sweep past the knee (until goodput falls below 50 %).&lt;/li>
&lt;li>Collect the JSON: TTFT P50/P99, ITL P50, raw throughput, goodput.&lt;/li>
&lt;li>Change &lt;strong>only the engine&lt;/strong>; repeat steps 1–5.&lt;/li>
&lt;li>Compare the goodput column across rows.&lt;/li>
&lt;/ol>
&lt;p>The &lt;a href="https://blog.lo0.es/en/posts/guidellm-validating-slo-under-load-sizing-knee/">sweep with GuideLLM&lt;/a> is equivalent
for SLO validation; AIPerf is preferable for engine comparisons because its
automatic &lt;code>estimatedCapacity&lt;/code> normalises the saturation point. Both can be used:
GuideLLM for the operational SLO, AIPerf for reference capacity.&lt;/p>
&lt;h3 id="reproducible-output-format">Reproducible output format&lt;/h3>
&lt;div class="highlight">&lt;pre tabindex="0" class="chroma">&lt;code class="language-json" data-lang="json">&lt;span class="line">&lt;span class="cl">&lt;span class="p">{&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="nt">&amp;#34;harness&amp;#34;&lt;/span>&lt;span class="p">:&lt;/span> &lt;span class="s2">&amp;#34;aiperf&amp;#34;&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="nt">&amp;#34;harness_version&amp;#34;&lt;/span>&lt;span class="p">:&lt;/span> &lt;span class="s2">&amp;#34;2.x&amp;#34;&lt;/span>&lt;span class="p">,&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="nt">&amp;#34;model&amp;#34;&lt;/span>&lt;span class="p">:&lt;/span> &lt;span class="s2">&amp;#34;Llama-3.1-70B-Instruct&amp;#34;&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="nt">&amp;#34;precision&amp;#34;&lt;/span>&lt;span class="p">:&lt;/span> &lt;span class="s2">&amp;#34;FP8&amp;#34;&lt;/span>&lt;span class="p">,&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="nt">&amp;#34;hardware&amp;#34;&lt;/span>&lt;span class="p">:&lt;/span> &lt;span class="s2">&amp;#34;4xH100_SXM_80GB_NVLink&amp;#34;&lt;/span>&lt;span class="p">,&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="nt">&amp;#34;dataset&amp;#34;&lt;/span>&lt;span class="p">:&lt;/span> &lt;span class="s2">&amp;#34;sharegpt&amp;#34;&lt;/span>&lt;span class="p">,&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="nt">&amp;#34;slo&amp;#34;&lt;/span>&lt;span class="p">:&lt;/span> &lt;span class="p">{&lt;/span>&lt;span class="nt">&amp;#34;ttft_p99_ms&amp;#34;&lt;/span>&lt;span class="p">:&lt;/span> &lt;span class="mi">500&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="nt">&amp;#34;itl_p50_ms&amp;#34;&lt;/span>&lt;span class="p">:&lt;/span> &lt;span class="mi">30&lt;/span>&lt;span class="p">},&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="nt">&amp;#34;motor&amp;#34;&lt;/span>&lt;span class="p">:&lt;/span> &lt;span class="s2">&amp;#34;vllm&amp;#34;&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="nt">&amp;#34;motor_version&amp;#34;&lt;/span>&lt;span class="p">:&lt;/span> &lt;span class="s2">&amp;#34;0.9.x&amp;#34;&lt;/span>&lt;span class="p">,&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="nt">&amp;#34;concurrency_sweep&amp;#34;&lt;/span>&lt;span class="p">:&lt;/span> &lt;span class="p">[&lt;/span>&lt;span class="mi">8&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="mi">16&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="mi">32&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="mi">48&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="mi">64&lt;/span>&lt;span class="p">],&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="nt">&amp;#34;results_at_knee&amp;#34;&lt;/span>&lt;span class="p">:&lt;/span> &lt;span class="p">{&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="nt">&amp;#34;concurrency&amp;#34;&lt;/span>&lt;span class="p">:&lt;/span> &lt;span class="mi">32&lt;/span>&lt;span class="p">,&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="nt">&amp;#34;ttft_p50_ms&amp;#34;&lt;/span>&lt;span class="p">:&lt;/span> &lt;span class="mi">210&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="nt">&amp;#34;ttft_p99_ms&amp;#34;&lt;/span>&lt;span class="p">:&lt;/span> &lt;span class="mi">420&lt;/span>&lt;span class="p">,&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="nt">&amp;#34;itl_p50_ms&amp;#34;&lt;/span>&lt;span class="p">:&lt;/span> &lt;span class="mi">18&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="nt">&amp;#34;throughput_tok_s&amp;#34;&lt;/span>&lt;span class="p">:&lt;/span> &lt;span class="mi">12500&lt;/span>&lt;span class="p">,&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="nt">&amp;#34;goodput_tok_s&amp;#34;&lt;/span>&lt;span class="p">:&lt;/span> &lt;span class="mi">12200&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="p">}&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="p">}&lt;/span>
&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>This versioned JSON is the auditable datum: anyone can reproduce the figure with the same
tool, version, model, hardware and load.&lt;/p>
&lt;hr>
&lt;h2 id="state-of-the-art-2026-relative-maturity">State of the art 2026: relative maturity&lt;/h2>
&lt;table>
&lt;thead>
&lt;tr>
&lt;th>Dimension&lt;/th>
&lt;th>vLLM&lt;/th>
&lt;th>SGLang&lt;/th>
&lt;th>TRT-LLM&lt;/th>
&lt;th>Dynamo&lt;/th>
&lt;/tr>
&lt;/thead>
&lt;tbody>
&lt;tr>
&lt;td>&lt;strong>Production maturity&lt;/strong>&lt;/td>
&lt;td>High (3+ years)&lt;/td>
&lt;td>Medium-high (2 years)&lt;/td>
&lt;td>High (NVIDIA)&lt;/td>
&lt;td>Medium (1.0 GA Mar 2026)&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>&lt;strong>Release cadence&lt;/strong>&lt;/td>
&lt;td>Weekly&lt;/td>
&lt;td>Weekly&lt;/td>
&lt;td>Monthly&lt;/td>
&lt;td>Fortnightly&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>&lt;strong>Integration ecosystem&lt;/strong>&lt;/td>
&lt;td>Very broad (Ray, k8s, llm-d, Dynamo, NIM)&lt;/td>
&lt;td>Broad (Dynamo, k8s)&lt;/td>
&lt;td>NVIDIA-centric (Triton, NIM)&lt;/td>
&lt;td>NVIDIA-centric&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>&lt;strong>Ease of operation&lt;/strong>&lt;/td>
&lt;td>High (pip install, OpenAI API)&lt;/td>
&lt;td>High&lt;/td>
&lt;td>Medium (requires compiling engines)&lt;/td>
&lt;td>Medium-low (multi-node, etcd/NATS)&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>&lt;strong>Non-NVIDIA hardware&lt;/strong>&lt;/td>
&lt;td>Yes (AMD, Intel, TPU)&lt;/td>
&lt;td>Yes (AMD)&lt;/td>
&lt;td>No&lt;/td>
&lt;td>No&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>&lt;strong>Disaggregated serving&lt;/strong>&lt;/td>
&lt;td>Beta&lt;/td>
&lt;td>Stable&lt;/td>
&lt;td>Stable&lt;/td>
&lt;td>First class&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>&lt;strong>Dynamic multi-LoRA&lt;/strong>&lt;/td>
&lt;td>Stable&lt;/td>
&lt;td>Stable&lt;/td>
&lt;td>Beta&lt;/td>
&lt;td>Stable (via vLLM/SGLang)&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>&lt;strong>Structured output&lt;/strong>&lt;/td>
&lt;td>Stable&lt;/td>
&lt;td>Stable (FSM)&lt;/td>
&lt;td>Beta&lt;/td>
&lt;td>Stable (via the engine)&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>&lt;strong>OSS community support&lt;/strong>&lt;/td>
&lt;td>Very high (&amp;gt;30 K GitHub stars)&lt;/td>
&lt;td>High (~20 K stars)&lt;/td>
&lt;td>High (~10 K stars)&lt;/td>
&lt;td>Growing (6.8 K stars)&lt;/td>
&lt;/tr>
&lt;/tbody>
&lt;/table>
&lt;hr>
&lt;h2 id="pareto-decision-table-when-to-choose-each-engine">Pareto decision table: when to choose each engine&lt;/h2>
&lt;p>No prose; the decision as a table of criteria and outcome.&lt;/p>
&lt;table>
&lt;thead>
&lt;tr>
&lt;th>Selection criterion&lt;/th>
&lt;th>Recommended engine&lt;/th>
&lt;th>Quantitative justification&lt;/th>
&lt;/tr>
&lt;/thead>
&lt;tbody>
&lt;tr>
&lt;td>Single node, quick prototype, team with no LLM serving experience&lt;/td>
&lt;td>&lt;strong>vLLM&lt;/strong>&lt;/td>
&lt;td>pip install + OpenAI API in minutes; the broadest community support&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>Workload with long shared prefixes (RAG, few-shot, system prompts &amp;gt; 512 tok)&lt;/td>
&lt;td>&lt;strong>SGLang&lt;/strong>&lt;/td>
&lt;td>RadixAttention: up to 6.4× more throughput vs a baseline without prefix caching&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>Maximum throughput on NVIDIA, no portability, team with TRT experience&lt;/td>
&lt;td>&lt;strong>TRT-LLM&lt;/strong>&lt;/td>
&lt;td>Fused kernels + native FP8: ITL 9–12 ms vs 18–22 ms for vLLM on the same hardware&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>Multi-node (&amp;gt; 8 GPUs), mixed prefill-intensive/decode-intensive traffic&lt;/td>
&lt;td>&lt;strong>Dynamo + SGLang&lt;/strong>&lt;/td>
&lt;td>Disaggregated P/D: up to 7× additional throughput on Blackwell; 2× TTFT with KV-aware routing&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>AMD ROCm or Intel Gaudi hardware&lt;/td>
&lt;td>&lt;strong>vLLM&lt;/strong>&lt;/td>
&lt;td>The only SOTA engine with stable non-NVIDIA support&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>Structured output + high concurrency&lt;/td>
&lt;td>&lt;strong>SGLang&lt;/strong>&lt;/td>
&lt;td>Compressed FSM: guided decode with no appreciable overhead&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>Stable dynamic multi-LoRA in production&lt;/td>
&lt;td>&lt;strong>vLLM or SGLang&lt;/strong>&lt;/td>
&lt;td>TRT-LLM multi-LoRA still in beta (Jun 2026)&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>Hugging Face ecosystem, models &amp;lt; 13B&lt;/td>
&lt;td>&lt;strong>HF TGI v3&lt;/strong>&lt;/td>
&lt;td>Direct HF Hub integration; competitive latency on small models&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>Maximum energy efficiency (J/token) on NVIDIA&lt;/td>
&lt;td>&lt;strong>TRT-LLM&lt;/strong>&lt;/td>
&lt;td>Lower-overhead kernels → fewer J/token at iso-throughput&lt;/td>
&lt;/tr>
&lt;/tbody>
&lt;/table>
&lt;hr>
&lt;h2 id="quantisation-effect-on-throughput-and-quality">Quantisation: effect on throughput and quality&lt;/h2>
&lt;p>The four main options in order of decreasing speed and increasing quality:&lt;/p>
&lt;table>
&lt;thead>
&lt;tr>
&lt;th>Format&lt;/th>
&lt;th>Relative throughput (H100)&lt;/th>
&lt;th>Quality degradation (perplexity)&lt;/th>
&lt;th>Engines with stable support&lt;/th>
&lt;/tr>
&lt;/thead>
&lt;tbody>
&lt;tr>
&lt;td>&lt;strong>NVFP4 / MXFP4&lt;/strong>&lt;/td>
&lt;td>~2.2× vs FP16&lt;/td>
&lt;td>1–3 % on MMLU&lt;/td>
&lt;td>TRT-LLM (Blackwell), vLLM (experimental)&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>&lt;strong>FP8 (W8A8)&lt;/strong>&lt;/td>
&lt;td>~1.7× vs FP16&lt;/td>
&lt;td>&amp;lt; 1 % on MMLU&lt;/td>
&lt;td>vLLM, SGLang, TRT-LLM&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>&lt;strong>INT4 AWQ&lt;/strong>&lt;/td>
&lt;td>~1.5× vs FP16&lt;/td>
&lt;td>1–2 % on MMLU&lt;/td>
&lt;td>vLLM, SGLang, TRT-LLM&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>&lt;strong>GPTQ (INT4)&lt;/strong>&lt;/td>
&lt;td>~1.4× vs FP16&lt;/td>
&lt;td>1–3 % on MMLU&lt;/td>
&lt;td>vLLM, SGLang&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>&lt;strong>FP16 (baseline)&lt;/strong>&lt;/td>
&lt;td>1.0×&lt;/td>
&lt;td>0 %&lt;/td>
&lt;td>All&lt;/td>
&lt;/tr>
&lt;/tbody>
&lt;/table>
&lt;p>FP8 is the de facto standard on Hopper for production: 1.7× throughput with degradation
below 1 %. The article on &lt;a href="https://blog.lo0.es/en/posts/quantization-llm-inference-fp8-int4-gguf/">quantisation in depth&lt;/a>
develops the quality/speed trade-off by format and model architecture.&lt;/p>
&lt;hr>
&lt;h2 id="disaggregated-serving-when-the-overhead-is-worth-it">Disaggregated serving: when the overhead is worth it&lt;/h2>
&lt;p>Separating prefill/decode into independent pools introduces transfer latency for
KV tensors between nodes (NIXL over NVLink or InfiniBand). The overhead pays off when:&lt;/p>
&lt;table>
&lt;thead>
&lt;tr>
&lt;th>Condition&lt;/th>
&lt;th>Effect of disaggregated serving&lt;/th>
&lt;/tr>
&lt;/thead>
&lt;tbody>
&lt;tr>
&lt;td>Mean ISL &amp;gt; 1,024 tokens&lt;/td>
&lt;td>Prefill dominates; separate pools stop it blocking decode&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>Prefill/decode ratio &amp;gt; 3:1 in time&lt;/td>
&lt;td>The decode pool sits idle waiting for prefill&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>Mixed traffic (prefill burst + decode queue)&lt;/td>
&lt;td>Independent scaling of each pool by SLA&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>&amp;lt; 512 tokens ISL, unique prompts&lt;/td>
&lt;td>Transfer overhead exceeds the benefit; an aggregated engine is enough&lt;/td>
&lt;/tr>
&lt;/tbody>
&lt;/table>
&lt;p>Dynamo quantifies the break-even point: disaggregated serving pays off when the
transfer time for KV tensors (a function of ISL and NVLink/IB bandwidth) is
lower than the time the decode worker would spend waiting for the prefill worker in aggregated mode. On
4×H100 with an ISL of 512 tok, break-even occurs at a prefill/decode ratio &amp;gt; 2.5:1. The
article on &lt;a href="https://blog.lo0.es/en/posts/disaggregated-serving-prefill-decode-specialised-pods/">disaggregated serving&lt;/a> develops
the full formula.&lt;/p>
&lt;hr>
&lt;h2 id="mlperf-inference-v51-cross-vendor-reference-figures">MLPerf Inference v5.1: cross-vendor reference figures&lt;/h2>
&lt;p>MLPerf Inference v5.1 (September 2025) is the standard for cross-vendor comparability.
Server scenario (Llama-3.1-70B, tokens/s):&lt;/p>
&lt;table>
&lt;thead>
&lt;tr>
&lt;th>Submitter&lt;/th>
&lt;th>Hardware&lt;/th>
&lt;th>Scenario&lt;/th>
&lt;th>Throughput (tok/s)&lt;/th>
&lt;/tr>
&lt;/thead>
&lt;tbody>
&lt;tr>
&lt;td>Red Hat (vLLM)&lt;/td>
&lt;td>1×H100 80 GB&lt;/td>
&lt;td>Server&lt;/td>
&lt;td>5,103&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>Red Hat (vLLM)&lt;/td>
&lt;td>1×L40S&lt;/td>
&lt;td>Server&lt;/td>
&lt;td>1,207&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>NVIDIA (TRT-LLM)&lt;/td>
&lt;td>8×H100 SXM&lt;/td>
&lt;td>Server&lt;/td>
&lt;td>~52,000 (estimated)&lt;/td>
&lt;/tr>
&lt;/tbody>
&lt;/table>
&lt;p>The MLPerf figures are not directly comparable with the internal benchmarks above
(a different harness, MLPerf&amp;rsquo;s Server scenario with a Poisson distribution and fixed SLOs). They are
useful for comparing vendors under the same rules; not for sizing a specific case.
For that, run your own sweep with AIPerf/GuideLLM against the target endpoint.&lt;/p>
&lt;hr>
&lt;h2 id="variables-the-benchmark-does-not-capture">Variables the benchmark does not capture&lt;/h2>
&lt;table>
&lt;thead>
&lt;tr>
&lt;th>Omitted variable&lt;/th>
&lt;th>Effect in production&lt;/th>
&lt;/tr>
&lt;/thead>
&lt;tbody>
&lt;tr>
&lt;td>Network latency (gateway, load balancer)&lt;/td>
&lt;td>Can add 20–100 ms to the TTFT measured on the server&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>Engine compilation time (TRT-LLM)&lt;/td>
&lt;td>Hours per model×precision×hardware; penalises cold starts and CI&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>Weight loading time on the GPU&lt;/td>
&lt;td>Relevant in autoscaling; ModelExpress (Dynamo) cuts it 7×&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>Operational cost of the system (Dynamo vs a simple engine)&lt;/td>
&lt;td>Dynamo requires etcd/NATS, a Rust runtime, a more specialised team&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>Response quality&lt;/td>
&lt;td>Performance does not imply quality; measure with lm-evaluation-harness (article B7)&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>Energy (J/token)&lt;/td>
&lt;td>Does not come out of a throughput benchmark; requires DCGM running alongside&lt;/td>
&lt;/tr>
&lt;/tbody>
&lt;/table>
&lt;hr>
&lt;h2 id="sources">Sources&lt;/h2>
&lt;ul>
&lt;li>vLLM · documentación oficial — &lt;a href="https://docs.vllm.ai/en/latest/">https://docs.vllm.ai/en/latest/&lt;/a>&lt;/li>
&lt;li>vLLM · blog de rendimiento (v0.6.0, 2,7× throughput) — &lt;a href="https://blog.vllm.ai/2024/09/05/perf-update.html">https://blog.vllm.ai/2024/09/05/perf-update.html&lt;/a>&lt;/li>
&lt;li>vLLM · disaggregated prefill (experimental) — &lt;a href="https://docs.vllm.ai/en/latest/features/disagg_prefill/">https://docs.vllm.ai/en/latest/features/disagg_prefill/&lt;/a>&lt;/li>
&lt;li>SGLang · documentación oficial — &lt;a href="https://docs.sglang.io/">https://docs.sglang.io/&lt;/a>&lt;/li>
&lt;li>SGLang · paper RadixAttention (LMSYS, 2024) — &lt;a href="https://arxiv.org/pdf/2312.07104">https://arxiv.org/pdf/2312.07104&lt;/a>&lt;/li>
&lt;li>SGLang · blog LMSYS sobre Llama-3 serving — &lt;a href="https://www.lmsys.org/blog/2024-07-25-sglang-llama3/">https://www.lmsys.org/blog/2024-07-25-sglang-llama3/&lt;/a>&lt;/li>
&lt;li>NVIDIA TensorRT-LLM · GitHub — &lt;a href="https://github.com/NVIDIA/TensorRT-LLM">https://github.com/NVIDIA/TensorRT-LLM&lt;/a>&lt;/li>
&lt;li>NVIDIA TensorRT-LLM · overview y release notes — &lt;a href="https://nvidia.github.io/TensorRT-LLM/overview.html">https://nvidia.github.io/TensorRT-LLM/overview.html&lt;/a>&lt;/li>
&lt;li>NVIDIA TensorRT-LLM · quantización — &lt;a href="https://nvidia.github.io/TensorRT-LLM/features/quantization.html">https://nvidia.github.io/TensorRT-LLM/features/quantization.html&lt;/a>&lt;/li>
&lt;li>NVIDIA Dynamo · GitHub (ai-dynamo/dynamo) — &lt;a href="https://github.com/ai-dynamo/dynamo">https://github.com/ai-dynamo/dynamo&lt;/a>&lt;/li>
&lt;li>NVIDIA Dynamo · documentación oficial — &lt;a href="https://docs.nvidia.com/dynamo/latest">https://docs.nvidia.com/dynamo/latest&lt;/a>&lt;/li>
&lt;li>NVIDIA Dynamo · disaggregated serving — &lt;a href="https://docs.dynamo.nvidia.com/dynamo/design-docs/disaggregated-serving">https://docs.dynamo.nvidia.com/dynamo/design-docs/disaggregated-serving&lt;/a>&lt;/li>
&lt;li>Cerebrium · benchmark vLLM vs SGLang vs TRT-LLM (Llama 3.1-70B) — &lt;a href="https://cerebrium.ai/blog/benchmarking-vllm-sglang-tensorrt-for-llama-3-1-api">https://cerebrium.ai/blog/benchmarking-vllm-sglang-tensorrt-for-llama-3-1-api&lt;/a>&lt;/li>
&lt;li>Spheron · vLLM vs TRT-LLM vs SGLang H100 benchmarks 2026 — &lt;a href="https://www.spheron.network/blog/vllm-vs-tensorrt-llm-vs-sglang-benchmarks/">https://www.spheron.network/blog/vllm-vs-tensorrt-llm-vs-sglang-benchmarks/&lt;/a>&lt;/li>
&lt;li>MLCommons · MLPerf Inference v5.1 results — &lt;a href="https://mlcommons.org/2025/09/mlperf-inference-v5-1-results/">https://mlcommons.org/2025/09/mlperf-inference-v5-1-results/&lt;/a>&lt;/li>
&lt;li>Red Hat · MLPerf Inference v5.1 con vLLM — &lt;a href="https://www.redhat.com/en/blog/efficient-and-reproducible-llm-inference-red-hat-mlperf-inference-v51-results">https://www.redhat.com/en/blog/efficient-and-reproducible-llm-inference-red-hat-mlperf-inference-v51-results&lt;/a>&lt;/li>
&lt;li>arXiv 2605.24217 · sesgo sistemático en benchmarks de inferencia LLM — &lt;a href="https://arxiv.org/html/2605.24217">https://arxiv.org/html/2605.24217&lt;/a>&lt;/li>
&lt;li>IETF Draft · LLM Benchmarking Methodology (enero 2026) — &lt;a href="https://www.ietf.org/archive/id/draft-gaikwad-llm-benchmarking-methodology-00.html">https://www.ietf.org/archive/id/draft-gaikwad-llm-benchmarking-methodology-00.html&lt;/a>&lt;/li>
&lt;/ul></description></item><item><title>Continuous batching: the salon with 8 chairs that does not wait for the slow customer — Orca, vLLM, chunked prefill and goodput</title><link>https://blog.lo0.es/en/posts/continuous-batching-orca-vllm-chunked-prefill-goodput/</link><pubDate>Sat, 30 May 2026 16:00:00 +0200</pubDate><guid>https://blog.lo0.es/en/posts/continuous-batching-orca-vllm-chunked-prefill-goodput/</guid><description>&lt;blockquote>
&lt;p>This post complements those on &lt;a href="https://blog.lo0.es/en/posts/kv-cache-working-memory-llm-inference/">KV cache&lt;/a> (the artefact continuous batching manages), &lt;a href="https://blog.lo0.es/en/posts/pagedattention-vllm-block-manager/">PagedAttention&lt;/a> (the memory piece that makes it viable), &lt;a href="https://blog.lo0.es/en/posts/disaggregated-serving-prefill-decode-specialised-pods/">Disaggregated serving&lt;/a> (the next layer of optimisation), &lt;a href="https://blog.lo0.es/en/posts/speculative-decoding-fundamentals-maths-state-of-play/">Speculative decoding&lt;/a>, &lt;a href="https://blog.lo0.es/en/posts/multi-lora-serving-shared-base-concurrent-adapters-sgmv-kernel/">Multi-LoRA&lt;/a> and &lt;a href="https://blog.lo0.es/en/posts/moe-inference-fundamentals-expert-parallel-deepseek-economics/">MoE&lt;/a> (the three extensions that coexist with the scheduler in production).&lt;/p>
&lt;/blockquote>
&lt;h2 id="you-are-here-deploy">You are here: DEPLOY&lt;/h2>
&lt;div class="diagram" style="max-width:780px;margin:1rem auto;">
&lt;svg viewBox="0 0 780 90" xmlns="http://www.w3.org/2000/svg" role="img" aria-label="you are here: Deploy">
&lt;style>.box{stroke:#444;stroke-width:1.4;rx:6}.active{fill:#7ad88f;stroke-width:3}.idle{fill:#f4f4f4}.lbl{font:600 12px sans-serif;fill:#222}.arr{stroke:#666;stroke-width:1.4;fill:none;marker-end:url(#cbm)}.cyc{stroke:#888;stroke-width:1.2;fill:none;stroke-dasharray:4 2;marker-end:url(#cbm)}&lt;/style>
&lt;defs>&lt;marker id="cbm" viewBox="0 0 10 10" refX="9" refY="5" markerWidth="6" markerHeight="6" orient="auto">&lt;path d="M0,0 L10,5 L0,10 z" fill="#666"/>&lt;/marker>&lt;/defs>
&lt;text x="390" y="20" text-anchor="middle" class="lbl">You are here: DEPLOY · iterative scheduler, one piece below PagedAttention&lt;/text>
&lt;rect x="30" y="35" width="110" height="35" class="box idle"/>&lt;text x="85" y="58" text-anchor="middle" class="lbl">1 · Data&lt;/text>
&lt;rect x="155" y="35" width="110" height="35" class="box idle"/>&lt;text x="210" y="58" text-anchor="middle" class="lbl">2 · Tune&lt;/text>
&lt;rect x="280" y="35" width="110" height="35" class="box idle"/>&lt;text x="335" y="58" text-anchor="middle" class="lbl">3 · Eval&lt;/text>
&lt;rect x="405" y="35" width="110" height="35" class="box active"/>&lt;text x="460" y="58" text-anchor="middle" class="lbl">4 · Deploy&lt;/text>
&lt;rect x="530" y="35" width="110" height="35" class="box idle"/>&lt;text x="585" y="58" text-anchor="middle" class="lbl">5 · Observe&lt;/text>
&lt;rect x="655" y="35" width="110" height="35" class="box idle"/>&lt;text x="710" y="58" text-anchor="middle" class="lbl">6 · Retrain&lt;/text>
&lt;path class="arr" d="M140,52 L155,52"/>&lt;path class="arr" d="M265,52 L280,52"/>&lt;path class="arr" d="M390,52 L405,52"/>&lt;path class="arr" d="M515,52 L530,52"/>&lt;path class="arr" d="M640,52 L655,52"/>
&lt;path class="cyc" d="M710,72 L710,82 L85,82 L85,72"/>
&lt;/svg>
&lt;/div>
&lt;h2 id="tldr">TL;DR&lt;/h2>
&lt;p>The static batching of the original HuggingFace Transformers (the pre-2022 era) systematically under-used the GPU for two structural reasons. First: the scheduling unit was the complete request; the longest one in the batch blocked all the others until it finished (severe head-of-line blocking, P95 TTFT five to ten times worse than reasonable). Second: each batch slot reserved memory for &lt;code>max_seq_len&lt;/code> even though the real output was much shorter; documented padding waste sat between 60 % and 80 % and sustained GPU SM utilisation on real workloads fell to 20-40 %. &lt;strong>Orca&lt;/strong> (Yu et al., OSDI 2022, FriendliAI + Seoul National University) introduced the idea that unjammed everything: the scheduling unit stops being the request and becomes &lt;strong>one decoder iteration&lt;/strong>, a single token. After each iteration the scheduler can add new requests to the batch and remove the finished ones. &lt;strong>vLLM&lt;/strong> (Kwon et al., SOSP 2023, UC Berkeley) materialised it open-source and production-grade thanks to &lt;strong>PagedAttention&lt;/strong>, which solves the KV cache fragmentation that theoretical continuous batching would cause with dynamic allocation. &lt;strong>SARATHI / Sarathi-Serve&lt;/strong> (Microsoft Research India, OSDI 2024) closed the last gap: the prefill &lt;em>stalls&lt;/em> that paused active decodes when a new request arrived, via &lt;strong>chunked prefill&lt;/strong> (splitting a long prefill into small chunks and mixing them with decodes in the same step) and &lt;em>stall-free batching&lt;/em>. &lt;strong>DistServe&lt;/strong> (Zhong et al., OSDI 2024) reformulated the key metric: what matters is &lt;strong>goodput&lt;/strong> (requests/s &lt;strong>meeting the TTFT and TPOT SLOs&lt;/strong>), not raw throughput. In May 2026, vLLM v1 brings a unified scheduler with chunked prefill always-on; SGLang adds RadixAttention, which gives cross-request prefix-cache hits; TensorRT-LLM calls it &lt;em>in-flight batching&lt;/em>; llama.cpp supports it natively. The three operational tensions are speculative decoding (nested raggedness), multi-LoRA (each request with its adapter) and MoE (each expert sees very few tokens per step at a typical batch size). This post takes apart the mechanism, the maths (GPU utilisation, goodput vs throughput), the three variants (Orca → vLLM → Sarathi-Serve), the pitfalls (preempt-on-OOM, starvation, inverse HoL) and the real numbers with production configurations.&lt;/p>
&lt;h2 id="the-analogy-the-salon-with-8-chairs">The analogy: the salon with 8 chairs&lt;/h2>
&lt;p>A hair salon with 8 chairs and a single brilliant stylist who moves between them. Customers arrive with very different needs: some want a 15-minute cut, others a 2-hour colour with a base, others a 90-minute straightening. The question is how to organise the flow.&lt;/p>
&lt;p>The &lt;strong>traditional strategy&lt;/strong> (what HuggingFace Transformers did in its original &lt;code>generate()&lt;/code>) is to seat 8 customers at once, all at the same time, and accept nobody new until the last one finishes. If one of those 8 is a 2-hour job, the 7 who wanted the 15-minute cut sit there doing nothing for 1 hour and 45 minutes. The stylist finishes with the quick ones and is left staring at empty chairs until the colour is done. When everyone is ready, another 8 come in. That is &lt;strong>static batching&lt;/strong>, and the only thing that stops it from being worse is that the GPU does not complain like a human customer.&lt;/p>
&lt;p>The &lt;strong>continuous strategy&lt;/strong> (Orca, vLLM) changes the scheduling unit. The stylist does not think &amp;ldquo;I am going to do one whole customer and then the next&amp;rdquo;; he thinks &amp;ldquo;on each &lt;strong>tick&lt;/strong> I take one step of work at every occupied chair and, every time a chair frees up, I call the next customer in the queue without waiting for the others to finish&amp;rdquo;. The quick-cut customer leaves after 15 minutes, his chair is filled immediately with the next one, and the slow jobs proceed at their own pace without delaying anyone. The stylist is never idle.&lt;/p>
&lt;p>The &lt;strong>continuous strategy with chunked prefill&lt;/strong> (SARATHI / Sarathi-Serve) adds a subtler distinction. Some customers need a long initial phase (a 10-minute hair analysis before the cut; in LLM terms, the &lt;em>prefill&lt;/em> of the prompt). Without chunked prefill, the stylist had to stop all the other chairs to do the new customer&amp;rsquo;s analysis in one go, and that was a &lt;em>stall&lt;/em> visible in the TPOT of the active ones. With chunked prefill, the analysis is split into 2-minute pieces interleaved between the other customers&amp;rsquo; active cuts. The customers already under way no longer notice pauses; the new customer takes slightly longer to start his cut proper, but the whole salon does not freeze.&lt;/p>
&lt;p>And the metric that matters: the owner does not want to maximise &amp;ldquo;customers served per hour&amp;rdquo; at the cost of some leaving furious. He wants to maximise &amp;ldquo;customers served per hour within the time SLA&amp;rdquo;, and that is &lt;strong>goodput&lt;/strong>, DistServe&amp;rsquo;s contribution.&lt;/p>
&lt;h2 id="the-problem-continuous-batching-solves">The problem continuous batching solves&lt;/h2>
&lt;p>There are two structural pathologies of static batching that deserve to be explained with concrete numbers.&lt;/p>
&lt;p>&lt;strong>Padding waste.&lt;/strong> Each batch slot reserved memory for &lt;code>max_seq_len&lt;/code> (prompt + max output), even though the real output ended in far fewer tokens. For a batch of 32 with heterogeneously distributed output lengths (half of them ≤50 tokens, a long tail up to 4,000), the typical memory waste was 60-80 %. That translated directly into wasted concurrency: with the same VRAM, instead of serving 32 requests with smart allocation, you served 8.&lt;/p>
&lt;p>&lt;strong>HoL blocking (Head-of-Line).&lt;/strong> The scheduling unit was the complete request. A batch containing one request of 500 tokens and 31 of ≤50 tokens ran 450 extra &amp;ldquo;empty&amp;rdquo; iterations (the GPU executed forward passes for all 32 slots, even though 31 had already finished). Wasted compute cost: ~84 % of the &lt;em>tail&lt;/em> time in the example.&lt;/p>
&lt;p>&lt;strong>Measurable result.&lt;/strong> Sustained GPU SM utilisation on real workloads under static batching: 20-40 %. That is, ~70 % of the datacenter&amp;rsquo;s compute went unused. When the first Orca and vLLM benchmarks came out showing 10-24× throughput improvement, it was not marketing exaggeration; it was recovering all that wasted compute.&lt;/p>
&lt;h2 id="orca-osdi-22-the-idea-that-changed-everything">Orca (OSDI &amp;lsquo;22): the idea that changed everything&lt;/h2>
&lt;p>The paper by Yu, Jeong, Kim, Kim and Chun at OSDI 2022 (&amp;ldquo;Orca: A Distributed Serving System for Transformer-Based Generative Models&amp;rdquo;, from Seoul National University + FriendliAI) introduced two contributions that have remained the basis of everything that followed.&lt;/p>
&lt;p>&lt;strong>Iteration-level scheduling.&lt;/strong> Instead of scheduling at the level of the complete request, it schedules at the level of &lt;strong>one decoder iteration&lt;/strong>: the step that generates ONE token. After each iteration the scheduler can (a) add new requests to the batch, (b) remove requests that have generated EOS or reached &lt;code>max_tokens&lt;/code>, (c) reorder priorities. The compute engine executes exactly one iteration over the current batch.&lt;/p>
&lt;p>&lt;strong>Selective batching.&lt;/strong> Here is the non-obvious subtlety. The technical problem with batching requests of different lengths and different KV cache states is that some operations (the GEMMs of the Q, K, V projections and the FFN) are insensitive to position and can be batched by concatenating tokens, whereas attention is sensitive to per-request state (each request has its own KV cache of a different length). Orca&amp;rsquo;s solution: batch the GEMMs (concatenate all the step&amp;rsquo;s tokens into a &lt;code>[total_tokens, hidden]&lt;/code> tensor) and run attention sequentially per request.&lt;/p>
&lt;p>Paper result: up to &lt;strong>36.9× throughput&lt;/strong> vs FasterTransformer on GPT-3 175B at the same latency level. Orca is not open-source, it is only documented in the paper. FriendliAI commercialises it as the Friendli Engine. But the idea was published and everyone adopted it.&lt;/p>
&lt;h2 id="vllm-sosp-23-the-open-source-materialisation">vLLM (SOSP &amp;lsquo;23): the open-source materialisation&lt;/h2>
&lt;p>What Orca described as a concept, vLLM materialised in production. The paper by Kwon, Li, Zhuang, Sheng et al. (UC Berkeley Sky Computing Lab, SOSP 2023) introduces &lt;strong>PagedAttention&lt;/strong>, covered in detail in the &lt;a href="https://blog.lo0.es/en/posts/pagedattention-vllm-block-manager/">PagedAttention deep dive&lt;/a>, but it also consolidates continuous batching as universal practice.&lt;/p>
&lt;p>The reason PagedAttention is a prerequisite for practical continuous batching is fragmentation. If you are going to insert and remove requests from the batch dynamically, and each request has a KV cache that grows on every iteration, traditional contiguous allocation fragments the HBM until it is unusable. PagedAttention splits the KV cache into fixed-size blocks (16 tokens by default) allocated on demand from a global pool. Memory waste falls from ~60-80 % to &lt;strong>less than 4 %&lt;/strong> (only the last partially filled block per sequence).&lt;/p>
&lt;p>vLLM paper metrics (2023):&lt;/p>
&lt;ul>
&lt;li>vs HuggingFace Transformers (static, no continuous batching): up to 24× throughput.&lt;/li>
&lt;li>vs HuggingFace TGI (which already had primitive continuous batching): ~3.5×.&lt;/li>
&lt;li>vs FasterTransformer: 2-4× at the same latency.&lt;/li>
&lt;/ul>
&lt;p>What operationally distinguishes vLLM from Orca: vLLM runs attention in a single fused CUDA kernel (&lt;code>paged_attention_kernel&lt;/code>) over non-contiguous blocks; Orca described attention as sequential, request by request. And vLLM exposes OpenAI-compatible APIs that let you drop the engine into existing stacks without touching the client.&lt;/p>
&lt;h2 id="chunked-prefill-sarathi--sarathi-serve-osdi-24">Chunked prefill (SARATHI / Sarathi-Serve, OSDI &amp;lsquo;24)&lt;/h2>
&lt;p>There is a detail the original Orca/vLLM continuous batching did not solve: when a new request enters the batch, its prefill (processing the whole prompt at once) can take hundreds of milliseconds. During that time, the active decodes of the other requests are essentially paused, the GPU being dedicated to the new prefill. This showed up as &lt;strong>spikes in TPOT&lt;/strong> (&amp;ldquo;inter-token latency&amp;rdquo;) every time a long request came in, which broke strict SLAs.&lt;/p>
&lt;p>SARATHI (Agrawal et al., arXiv 2308.16369, August 2023) and later Sarathi-Serve (same group at Microsoft Research India, OSDI 2024, arXiv 2403.02310) introduce two combined ideas:&lt;/p>
&lt;p>&lt;strong>Chunked prefill.&lt;/strong> A long prefill (8,192 tokens, say) is split into chunks (2,048 tokens, say) processed one per iteration. Instead of one 200 ms step processing 8K tokens, four 50 ms steps processing 2K each.&lt;/p>
&lt;p>&lt;strong>Decode-maximal batching (&amp;ldquo;stall-free&amp;rdquo;).&lt;/strong> On each iteration, the scheduler first fills the batch with the active decodes (each costing 1 token), and only the leftover space goes to new prefill chunks. The result: active decodes keep advancing 1 token per iteration without pausing, while the new request completes its prefill in small bites.&lt;/p>
&lt;p>The observation that justifies it: prefill is &lt;strong>compute-bound&lt;/strong> (it processes N tokens at once and saturates FLOPs) while decode is &lt;strong>memory-bound&lt;/strong> (1 token per step, under-using compute, with the GPU waiting on HBM). Mixing prefill chunks with decodes in the same step exploits the arithmetic-intensity slack: the decodes piggyback on the free compute of the prefill chunk.&lt;/p>
&lt;p>Numbers:&lt;/p>
&lt;ul>
&lt;li>Original SARATHI (LLaMA-13B on an A6000): decode throughput +10×, end-to-end +1.33×.&lt;/li>
&lt;li>Sarathi-Serve (Mistral-7B on an A100): 2.6× serving capacity vs plain vLLM. Yi-34B on 2×A100: 3.7×. Falcon-180B with pipeline parallel: 5.6×.&lt;/li>
&lt;/ul>
&lt;p>Adoption in May 2026: &lt;strong>always-on&lt;/strong> in vLLM v1 (default since v0.8.0, January 2025), SGLang, TensorRT-LLM. The key setting in vLLM is &lt;code>--max-num-batched-tokens&lt;/code> (the token budget per step; default 2048). Raising it prioritises throughput, lowering it prioritises low TPOT.&lt;/p>
&lt;h2 id="goodput-the-metric-that-matters-distserve-osdi-24">Goodput: the metric that matters (DistServe, OSDI &amp;lsquo;24)&lt;/h2>
&lt;p>The classic &amp;ldquo;throughput&amp;rdquo; metric (requests/s or tokens/s) has a problem when there are SLOs. A server can report 1,000 req/s while P99 TTFT is 30 seconds and the SLO is 1 second, so only about 200 req/s actually meet the contract.&lt;/p>
&lt;p>&lt;strong>DistServe&lt;/strong> (Zhong et al., OSDI 2024) formalises &lt;strong>goodput&lt;/strong> as the right metric: &lt;code>goodput = max sustained request rate meeting the SLOs (TTFT bound AND TPOT bound)&lt;/code>. The practical definition is usually: maximum rate with ≥90 % of requests within both SLOs.&lt;/p>
&lt;p>Why it matters for the scheduler:&lt;/p>
&lt;ul>
&lt;li>Optimising raw throughput leads to maximising batch size, which inflates P99 TPOT.&lt;/li>
&lt;li>Optimising goodput limits the batch size when TPOT starts violating the SLO, prefers small requests if the batch already has a tail, and leaves resources available for new requests.&lt;/li>
&lt;/ul>
&lt;p>DistServe result: up to &lt;strong>7.4× more requests served&lt;/strong> or a &lt;strong>12.6× tighter SLO&lt;/strong> vs vLLM at the same SLO attainment. The gain comes from &lt;strong>disaggregating prefill and decode onto different GPUs&lt;/strong> (removing the interference between phases), but the idea of optimising for goodput is independent and applicable to any scheduler.&lt;/p>
&lt;p>Operationally this translates into monitoring:&lt;/p>
&lt;pre tabindex="0">&lt;code>goodput_proxy = histogram_quantile(0.95, vllm:time_to_first_token_seconds_bucket) &amp;lt; SLO_TTFT
AND histogram_quantile(0.95, vllm:time_per_output_token_seconds_bucket) &amp;lt; SLO_TPOT
&lt;/code>&lt;/pre>&lt;h2 id="the-iterative-scheduler-in-action">The iterative scheduler in action&lt;/h2>
&lt;div class="diagram" style="max-width:780px;margin:1.5rem auto;">
&lt;svg viewBox="0 0 780 320" xmlns="http://www.w3.org/2000/svg" role="img" aria-label="Continuous batching scheduler timeline">
&lt;style>
.req1{fill:#cdebd0;stroke:#2a7a40;stroke-width:1.2;rx:3}
.req2{fill:#d4ecff;stroke:#1f5fa8;stroke-width:1.2;rx:3}
.req3{fill:#fff4d6;stroke:#a48000;stroke-width:1.2;rx:3}
.req4{fill:#f6caca;stroke:#a52a2a;stroke-width:1.2;rx:3}
.req5{fill:#e6d0ff;stroke:#5a2db0;stroke-width:1.2;rx:3}
.req6{fill:#f6e0c8;stroke:#a76b1f;stroke-width:1.2;rx:3}
.empty{fill:#f0f0f0;stroke:#999;stroke-width:1;stroke-dasharray:3 2;rx:3}
.pre{fill:#ffd76b;stroke:#a48000;stroke-width:1.2;rx:3}
.lbl{font:600 11px sans-serif;fill:#222}
.sub{font:400 9px sans-serif;fill:#555}
.tick{stroke:#444;stroke-width:1}
&lt;/style>
&lt;text x="20" y="20" class="lbl">Static batching — 4 slots, padding to max_len, new req arrives → waits&lt;/text>
&lt;text x="20" y="38" class="sub">slot 1&lt;/text>
&lt;rect x="60" y="30" width="200" height="14" class="req1"/>
&lt;rect x="260" y="30" width="200" height="14" class="empty"/>
&lt;rect x="460" y="30" width="80" height="14" class="req5"/>
&lt;rect x="540" y="30" width="200" height="14" class="empty"/>
&lt;text x="20" y="58" class="sub">slot 2&lt;/text>
&lt;rect x="60" y="50" width="80" height="14" class="req2"/>
&lt;rect x="140" y="50" width="320" height="14" class="empty"/>
&lt;rect x="460" y="50" width="200" height="14" class="req6"/>
&lt;rect x="660" y="50" width="80" height="14" class="empty"/>
&lt;text x="20" y="78" class="sub">slot 3&lt;/text>
&lt;rect x="60" y="70" width="400" height="14" class="req3"/>
&lt;rect x="460" y="70" width="280" height="14" class="empty"/>
&lt;text x="20" y="98" class="sub">slot 4&lt;/text>
&lt;rect x="60" y="90" width="120" height="14" class="req4"/>
&lt;rect x="180" y="90" width="280" height="14" class="empty"/>
&lt;rect x="460" y="90" width="100" height="14" class="req5"/>
&lt;rect x="560" y="90" width="180" height="14" class="empty"/>
&lt;line x1="460" y1="20" x2="460" y2="115" class="tick" stroke-dasharray="2 2"/>
&lt;text x="465" y="115" class="sub">batch recycled only when ALL finish ↑&lt;/text>
&lt;text x="20" y="155" class="lbl">Continuous batching — a free slot is refilled IMMEDIATELY on each tick&lt;/text>
&lt;text x="20" y="173" class="sub">slot 1&lt;/text>
&lt;rect x="60" y="165" width="200" height="14" class="req1"/>
&lt;rect x="260" y="165" width="200" height="14" class="req5"/>
&lt;rect x="460" y="165" width="160" height="14" class="req6"/>
&lt;rect x="620" y="165" width="120" height="14" class="empty"/>
&lt;text x="20" y="193" class="sub">slot 2&lt;/text>
&lt;rect x="60" y="185" width="80" height="14" class="req2"/>
&lt;rect x="140" y="185" width="150" height="14" class="req5"/>
&lt;rect x="290" y="185" width="200" height="14" class="req6"/>
&lt;rect x="490" y="185" width="250" height="14" class="req4"/>
&lt;text x="20" y="213" class="sub">slot 3&lt;/text>
&lt;rect x="60" y="205" width="400" height="14" class="req3"/>
&lt;rect x="460" y="205" width="160" height="14" class="req6"/>
&lt;rect x="620" y="205" width="120" height="14" class="req4"/>
&lt;text x="20" y="233" class="sub">slot 4&lt;/text>
&lt;rect x="60" y="225" width="120" height="14" class="req4"/>
&lt;rect x="180" y="225" width="180" height="14" class="req5"/>
&lt;rect x="360" y="225" width="200" height="14" class="req6"/>
&lt;rect x="560" y="225" width="180" height="14" class="req3"/>
&lt;text x="60" y="252" class="sub">each coloured bar = 1 decoder iteration (1 token) of a request&lt;/text>
&lt;text x="20" y="280" class="lbl">Chunked prefill — new prefill interleaved with active decodes (no stall)&lt;/text>
&lt;text x="60" y="300" class="sub">prefill chunk 1&lt;/text>
&lt;rect x="150" y="290" width="60" height="14" class="pre"/>
&lt;text x="220" y="300" class="sub">decode tick, active reqs&lt;/text>
&lt;rect x="340" y="290" width="60" height="14" class="req1"/>
&lt;rect x="400" y="290" width="60" height="14" class="req2"/>
&lt;rect x="460" y="290" width="60" height="14" class="req3"/>
&lt;rect x="520" y="290" width="60" height="14" class="pre"/>
&lt;text x="585" y="300" class="sub">prefill chunk 2 (same step as the decodes)&lt;/text>
&lt;/svg>
&lt;/div>
&lt;h2 id="the-maths-that-matter">The maths that matter&lt;/h2>
&lt;p>Three formulas explain much of the operational behaviour.&lt;/p>
&lt;p>&lt;strong>GPU utilisation under static batching.&lt;/strong> With a batch of size &lt;code>B&lt;/code> whose &lt;code>seq_len_i&lt;/code> are the real lengths and &lt;code>max(seq_len_i)&lt;/code> is the length that defines the padding:&lt;/p>
$$U_{\text{static}} = \frac{\sum_i \text{seq len}_i}{B \cdot \max_i \text{seq len}_i}$$
&lt;p>For &lt;code>B=32&lt;/code>, 30 sequences of 50 tokens and 2 of 500: &lt;code>U = (30·50 + 2·500) / (32·500) = 2500/16000 = 15.6 %&lt;/code>. Four out of every five GPU cycles wasted.&lt;/p>
&lt;p>&lt;strong>GPU utilisation under continuous batching (idealised).&lt;/strong>&lt;/p>
$$U_{\text{continuous}} \approx 1 - \frac{T_{\text{scheduler}}}{T_{\text{iteration}}}$$
&lt;p>With scheduler overhead of ~50-200 µs and an iteration time of ~10-30 ms: &lt;code>U &amp;gt; 95 %&lt;/code>. The unit of loss is no longer padding, it is scheduling overhead, and that is negligible compared with the forward pass.&lt;/p>
&lt;p>&lt;strong>Goodput vs throughput.&lt;/strong>&lt;/p>
$$\text{Goodput}(R) = R \cdot P(\text{latency} &lt; \text{SLO})$$
&lt;p>where &lt;code>R&lt;/code> is the offered request rate. Typical curve: goodput grows linearly with &lt;code>R&lt;/code> up to the saturation knee, then falls because &lt;code>P(SLO)&lt;/code> collapses when the system congests. The optimal point is just before the knee, not at peak throughput.&lt;/p>
&lt;p>Example: at &lt;code>R=100 req/s&lt;/code> with &lt;code>P(SLO)=0.99&lt;/code>, goodput = 99. At &lt;code>R=200 req/s&lt;/code> with &lt;code>P(SLO)=0.4&lt;/code>, goodput = 80. &lt;strong>More offered load, less useful goodput&lt;/strong>.&lt;/p>
&lt;p>&lt;strong>Chunked prefill token budget.&lt;/strong> On each vLLM step with chunked prefill active:&lt;/p>
$$\text{prefill tokens this step} = \text{max num batched tokens} - \text{num decodes active}$$
&lt;p>Each active decode costs 1 token of the budget; the rest is filled with new prefill chunks. If &lt;code>max_num_batched_tokens = 2048&lt;/code> and &lt;code>num_decodes_active = 200&lt;/code>, there are 1,848 tokens for prefill (one chunk of 1,848 or several small chunks).&lt;/p>
&lt;h2 id="real-implementations-in-may-2026">Real implementations in May 2026&lt;/h2>
&lt;table>
&lt;thead>
&lt;tr>
&lt;th>Engine&lt;/th>
&lt;th>Current scheduler V&lt;/th>
&lt;th>Chunked prefill default&lt;/th>
&lt;th>Relevant notes&lt;/th>
&lt;/tr>
&lt;/thead>
&lt;tbody>
&lt;tr>
&lt;td>&lt;strong>vLLM v1&lt;/strong> (default ≥0.8.0)&lt;/td>
&lt;td>unified V1&lt;/td>
&lt;td>always-on&lt;/td>
&lt;td>EngineCore isolated in a separate process; prefix caching with O(1) eviction; preempt-mode &lt;code>recompute&lt;/code> by default; xgrammar/outlines backends.&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>&lt;strong>SGLang&lt;/strong>&lt;/td>
&lt;td>own (PyTorch ecosystem)&lt;/td>
&lt;td>yes&lt;/td>
&lt;td>&lt;strong>RadixAttention&lt;/strong> gives cross-request prefix-cache hits; non-blocking CPU scheduler; leader in stable latency at high concurrency.&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>&lt;strong>TensorRT-LLM&lt;/strong>&lt;/td>
&lt;td>proprietary &amp;ldquo;in-flight batching&amp;rdquo;&lt;/td>
&lt;td>yes&lt;/td>
&lt;td>Policies &lt;code>GUARANTEED_NO_EVICT&lt;/code> (conservative, default) and &lt;code>MAX_UTILIZATION&lt;/code> (aggressive, risk of a pause when the KV is full). Compile-time vs runtime.&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>&lt;strong>Triton + tensorrtllm_backend&lt;/strong>&lt;/td>
&lt;td>&lt;code>gpt_model_type: inflight_fused_batching&lt;/code>&lt;/td>
&lt;td>yes&lt;/td>
&lt;td>&lt;code>max_queue_delay_microseconds&lt;/code> to group newly arrived requests. Decoupled mode for SSE streaming.&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>&lt;strong>llama.cpp&lt;/strong> (llama-server)&lt;/td>
&lt;td>own&lt;/td>
&lt;td>&lt;code>--cont-batching&lt;/code> ON since 2024&lt;/td>
&lt;td>&lt;code>-np N&lt;/code> parallel slots; no PagedAttention (contiguous KV per slot), so less flexible but simpler. Endpoint &lt;code>:8080/metrics&lt;/code>.&lt;/td>
&lt;/tr>
&lt;/tbody>
&lt;/table>
&lt;p>A typical production-ready vLLM v1 configuration:&lt;/p>
&lt;div class="highlight">&lt;pre tabindex="0" class="chroma">&lt;code class="language-bash" data-lang="bash">&lt;span class="line">&lt;span class="cl">vllm serve meta-llama/Llama-3.1-70B-Instruct &lt;span class="se">\
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="se">&lt;/span> --tensor-parallel-size &lt;span class="m">4&lt;/span> &lt;span class="se">\
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="se">&lt;/span> --max-num-batched-tokens &lt;span class="m">4096&lt;/span> &lt;span class="se">\
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="se">&lt;/span> --max-num-seqs &lt;span class="m">256&lt;/span> &lt;span class="se">\
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="se">&lt;/span> --enable-chunked-prefill &lt;span class="se">\
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="se">&lt;/span> --enable-prefix-caching &lt;span class="se">\
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="se">&lt;/span> --preemption-mode recompute &lt;span class="se">\
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="se">&lt;/span> --scheduling-policy fcfs &lt;span class="se">\
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="se">&lt;/span> --gpu-memory-utilization 0.92
&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>SGLang equivalent:&lt;/p>
&lt;div class="highlight">&lt;pre tabindex="0" class="chroma">&lt;code class="language-bash" data-lang="bash">&lt;span class="line">&lt;span class="cl">python -m sglang.launch_server &lt;span class="se">\
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="se">&lt;/span> --model meta-llama/Llama-3.1-70B-Instruct &lt;span class="se">\
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="se">&lt;/span> --tp &lt;span class="m">4&lt;/span> &lt;span class="se">\
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="se">&lt;/span> --chunked-prefill-size &lt;span class="m">4096&lt;/span> &lt;span class="se">\
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="se">&lt;/span> --max-running-requests &lt;span class="m">256&lt;/span> &lt;span class="se">\
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="se">&lt;/span> --enable-radix-cache
&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;h2 id="the-three-operational-tensions">The three operational tensions&lt;/h2>
&lt;p>&lt;strong>Continuous batching + speculative decoding.&lt;/strong> Speculative decoding produces 1 to &lt;code>γ+1&lt;/code> tokens per step depending on the acceptance rate. The batch stops being uniform in tokens produced per iteration: &lt;em>nested raggedness&lt;/em>. PagedAttention absorbs it (the KV cache can grow at different rates per request in the same step), but the scheduler loses symmetry. At low QPS (a conversational assistant) the combination is excellent: vLLM reports up to &lt;strong>2.8× speedup&lt;/strong>. At high QPS, the draft consumes slots from the decode pool and can &lt;em>reduce&lt;/em> aggregate goodput. Rule of thumb: disable speculative decoding when &lt;code>gpu_cache_usage &amp;gt; 0.85&lt;/code>. Full detail in &lt;a href="https://blog.lo0.es/en/posts/speculative-decoding-fundamentals-maths-state-of-play/">Speculative decoding&lt;/a>.&lt;/p>
&lt;p>&lt;strong>Continuous batching + multi-LoRA.&lt;/strong> Each request in the batch can use a different adapter (via SGMV, see &lt;a href="https://blog.lo0.es/en/posts/multi-lora-serving-shared-base-concurrent-adapters-sgmv-kernel/">Multi-LoRA serving&lt;/a>). Worst case: every request in the batch with a different adapter and a different rank, and throughput falls by up to 50 % vs the base with no LoRA. Best case: all requests on the same adapter, equivalent to the base with no LoRA. Practical mitigation: group adapters by rank in the routing ahead of the engine; set &lt;code>--max-lora-rank&lt;/code> to the maximum actually served, not generously above it.&lt;/p>
&lt;p>&lt;strong>Continuous batching + MoE.&lt;/strong> Each expert sees &lt;code>batch · k / N&lt;/code> tokens per step. With DeepSeek-V3 (256 experts, k=8) and batch=32 in decode, each expert processes only 1 token on average: total compute starvation. To match the per-GPU throughput of a dense model, MoE needs batches &lt;strong>&amp;raquo;10× larger&lt;/strong>, which puts pressure on the KV cache. &lt;strong>Wide-EP&lt;/strong> (see &lt;a href="https://blog.lo0.es/en/posts/moe-inference-fundamentals-expert-parallel-deepseek-economics/">MoE inference&lt;/a>) distributes the experts across many GPUs and allows larger effective batches per expert, at the cost of all-to-all comms that add milliseconds per step.&lt;/p>
&lt;h2 id="metrics-to-monitor">Metrics to monitor&lt;/h2>
&lt;p>The Prometheus metrics exposed by vLLM (prefix &lt;code>vllm:&lt;/code>, &lt;code>vllm_&lt;/code> when scraped):&lt;/p>
&lt;ul>
&lt;li>&lt;code>vllm:time_to_first_token_seconds&lt;/code> (Histogram) — TTFT including queue time.&lt;/li>
&lt;li>&lt;code>vllm:time_per_output_token_seconds&lt;/code> (Histogram) — TPOT.&lt;/li>
&lt;li>&lt;code>vllm:e2e_request_latency_seconds&lt;/code> (Histogram) — end-to-end.&lt;/li>
&lt;li>&lt;code>vllm:num_requests_running&lt;/code> (Gauge) — active batch.&lt;/li>
&lt;li>&lt;code>vllm:num_requests_waiting&lt;/code> (Gauge) — queue depth.&lt;/li>
&lt;li>&lt;code>vllm:num_requests_swapped&lt;/code> (Gauge) — preempted to CPU.&lt;/li>
&lt;li>&lt;code>vllm:gpu_cache_usage_perc&lt;/code> (Gauge) — fraction of KV cache occupied.&lt;/li>
&lt;li>&lt;code>vllm:gpu_prefix_cache_hit_rate&lt;/code> (Gauge) — prefix cache hits.&lt;/li>
&lt;li>&lt;code>vllm:num_preemptions_total&lt;/code> (Counter) — preemptions. Any sustained value is a red flag.&lt;/li>
&lt;/ul>
&lt;p>Practical operational rules:&lt;/p>
&lt;ul>
&lt;li>Stable zone under sustained load: &lt;code>gpu_cache_usage_perc ∈ [0.7, 0.9]&lt;/code>.&lt;/li>
&lt;li>Warning at &lt;code>&amp;gt;0.95&lt;/code> (preemption imminent).&lt;/li>
&lt;li>Critical if &lt;code>num_requests_waiting&lt;/code> grows faster than &lt;code>num_requests_running&lt;/code>: the server is not absorbing the load; scale out.&lt;/li>
&lt;/ul>
&lt;h2 id="operational-pitfalls">Operational pitfalls&lt;/h2>
&lt;p>&lt;strong>Preempt-on-OOM.&lt;/strong> When &lt;code>gpu_cache_usage&lt;/code> reaches ~1.0 with pending requests that need to grow their KV, vLLM preempts. V1 does &lt;code>RECOMPUTE&lt;/code> by default (discards the KV, regenerates it on return); V0 did &lt;code>SWAP&lt;/code> (moves it to CPU). &lt;code>RECOMPUTE&lt;/code> is better for short sequences (cheap to regenerate); &lt;code>SWAP&lt;/code> is better for long ones. The &lt;code>vllm:num_preemptions_total&lt;/code> metric should be zero or near zero in steady state.&lt;/p>
&lt;p>&lt;strong>Inverse HoL blocking (memory monopoly).&lt;/strong> A very long request occupies many KV blocks, so small requests do not fit in the batch even though compute is free. Chunked prefill mitigates the compute blocking during a new prefill, but it does not solve the memory monopoly. Partial solution: per-request limit policies (an aggressive &lt;code>max_tokens&lt;/code>) or priorities.&lt;/p>
&lt;p>&lt;strong>Starvation.&lt;/strong> FCFS can leave requests pending for a long time if the active ones do not finish. vLLM supports &lt;code>--scheduling-policy priority&lt;/code> with an &lt;code>x-priority&lt;/code> header. Recent work (NeurIPS 2024 &lt;em>Efficient LLM Scheduling by Learning to Rank&lt;/em>, arXiv:2501.14312 &lt;em>Locality-aware Fair Scheduling&lt;/em>) proposes schedulers with quantum-based starvation prevention; not yet integrated into vLLM mainline.&lt;/p>
&lt;p>&lt;strong>Badly calibrated chunk size.&lt;/strong> A small chunk (512) gives low TPOT, high TTFT and memory overhead from more KV accesses. A large chunk (8192+) gives low TTFT and TPOT spikes during the chunk. Rule: start at 2,048, measure P95 TPOT, adjust.&lt;/p>
&lt;p>&lt;strong>Batch size cap.&lt;/strong> A high &lt;code>--max-num-seqs&lt;/code> gives more concurrency but P99 TPOT explodes. A low one wastes throughput. Rule of thumb: &lt;code>max_num_seqs ≈ HBM_for_KV / (avg_seq_len × bytes_per_token_KV)&lt;/code>.&lt;/p>
&lt;h2 id="implications-for-on-premise-hardware">Implications for on-premise hardware&lt;/h2>
&lt;p>&lt;strong>On an RTX 4090 (24 GB).&lt;/strong> llama.cpp with &lt;code>--cont-batching -np 4-8&lt;/code> is the natural pattern. Typical models: Llama 3 8B Q4_K_M with ~8 parallel slots, aggregate throughput on the order of hundreds of tok/s. vLLM also works if the weights fit (Llama 3 8B BF16 does; the 70B does not fit whole), although PagedAttention on consumer hardware gives less return than in the datacenter.&lt;/p>
&lt;p>&lt;strong>On a generic 4×H100 SXM cluster (320 GB, NVLink).&lt;/strong> Here vLLM v1 / SGLang are the de facto standard. Typical configurations:&lt;/p>
&lt;ul>
&lt;li>&lt;strong>Llama 3 70B FP8 + TP=4&lt;/strong>: dozens of concurrent sessions with P95 TPOT under 50 ms, tens of thousands of aggregate tok/s at a moderate batch size.&lt;/li>
&lt;li>&lt;strong>Llama 3 70B AWQ-INT4 + TP=2&lt;/strong> plus the rest of the cluster for additional concurrency or multi-LoRA with SGMV.&lt;/li>
&lt;li>&lt;strong>DeepSeek-V3&lt;/strong> needs larger setups (8-16 H100) to fit whole in FP8; with Wide-EP, continuous batching starts operating on much larger batches and the economics change (see MoE).&lt;/li>
&lt;/ul>
&lt;p>The rule of thumb for May 2026: &lt;strong>vLLM v1 with chunked prefill always-on and prefix caching enabled is the sensible default configuration for any dense model that fits comfortably; SGLang offers better stable latency at high concurrency thanks to the overlap between the CPU scheduler and the GPU step; TensorRT-LLM gives peak throughput at high concurrency with the rigidity of compile-time&lt;/strong>.&lt;/p>
&lt;h2 id="what-we-have-not-covered">What we have not covered&lt;/h2>
&lt;ul>
&lt;li>&lt;strong>Locality-aware fair scheduling&lt;/strong> (arXiv:2501.14312) and learning-to-rank schedulers (NeurIPS 2024): the next generation of algorithms closing the fairness vs prefix-cache locality trade-off.&lt;/li>
&lt;li>&lt;strong>Smooth goodput&lt;/strong> (arXiv:2410.14257): a refinement of the DistServe metric using max slowdown instead of a binary SLO.&lt;/li>
&lt;li>&lt;strong>Triton tensorrtllm_backend in production&lt;/strong>: decoupled mode for streaming, ensembles with pre/post-processing, autoscaling with KServe.&lt;/li>
&lt;li>&lt;strong>vLLM speculators v0.3.0&lt;/strong> and the vLLM-compatible drafter training framework.&lt;/li>
&lt;/ul>
&lt;h2 id="see-also">See also&lt;/h2>
&lt;ul>
&lt;li>&lt;a href="https://blog.lo0.es/en/posts/vllm-scheduler-step-maitre-d-each-round/">The pass: the vLLM scheduler step&lt;/a> — the concrete algorithm implementing this batching: the &lt;code>{request: number of tokens}&lt;/code> dictionary assembled on each iteration, the &lt;code>max-num-batched-tokens&lt;/code> budget and preemption by RECOMPUTE.&lt;/li>
&lt;li>&lt;a href="https://blog.lo0.es/en/posts/kv-cache-working-memory-llm-inference/">KV cache: the working memory&lt;/a> — the artefact continuous batching manages; without understanding it you cannot see why the minimum unit is the decoder iteration.&lt;/li>
&lt;li>&lt;a href="https://blog.lo0.es/en/posts/pagedattention-vllm-block-manager/">PagedAttention deep dive&lt;/a> — the memory piece without which dynamic continuous batching would fragment the HBM; a deep dive into the block manager.&lt;/li>
&lt;li>&lt;a href="https://blog.lo0.es/en/posts/disaggregated-serving-prefill-decode-specialised-pods/">Disaggregated serving: prefill and decode in specialised pods&lt;/a> — the next layer: when continuous batching is already squeezed dry, separating prefill and decode gives another round of improvements (origin of the DistServe paper that contributes the concept of goodput).&lt;/li>
&lt;li>&lt;a href="https://blog.lo0.es/en/posts/speculative-decoding-fundamentals-maths-state-of-play/">Speculative decoding&lt;/a> — the scheduler&amp;rsquo;s first operational tension: each request in the batch can produce 1 to γ+1 tokens per step.&lt;/li>
&lt;li>&lt;a href="https://blog.lo0.es/en/posts/multi-lora-serving-shared-base-concurrent-adapters-sgmv-kernel/">Multi-LoRA serving&lt;/a> — the second tension: heterogeneous batching with SGMV lets each request use its own adapter, but the scheduler has to group by rank to keep throughput up.&lt;/li>
&lt;li>&lt;a href="https://blog.lo0.es/en/posts/moe-inference-fundamentals-expert-parallel-deepseek-economics/">MoE inference&lt;/a> — the third tension: each expert sees very few tokens per step at a typical batch size, forcing much larger batches than in dense models.&lt;/li>
&lt;li>&lt;a href="https://blog.lo0.es/en/posts/six-stage-llmops-pipeline-architecture-and-deep-dive/">The six-stage LLMOps pipeline&lt;/a> — the master map in which Deploy is stage 4.&lt;/li>
&lt;li>&lt;a href="https://blog.lo0.es/en/posts/capacity-planning-on-premise-llm-inference/">Capacity planning for on-premise LLM inference&lt;/a> — the DistServe goodput that appears here is exactly the metric that closes the crossing of VRAM budget × time budget in sizing.&lt;/li>
&lt;li>&lt;a href="https://blog.lo0.es/en/posts/gpu-observability-llm-inference-twelve-dcgm-vllm-metrics/">GPU observability for LLM inference&lt;/a> — the &lt;code>vllm:num_requests_running&lt;/code>, &lt;code>num_requests_waiting&lt;/code> and &lt;code>gpu_cache_usage_perc&lt;/code> metrics that come from the iterative scheduler are the operational cockpit of the engine in production.&lt;/li>
&lt;li>&lt;a href="https://blog.lo0.es/en/posts/optimising-prefill-vllm-knobs-ttft-will-not-forgive/">Optimising prefill in vLLM&lt;/a> — chunked prefill translates iterative scheduling into concrete parameters: &lt;code>--max-num-batched-tokens&lt;/code> is the budget the scheduler splits between prefill chunks and decode tokens per step.&lt;/li>
&lt;li>&lt;a href="https://blog.lo0.es/en/posts/optimising-decode-vllm-squeezing-every-token-small-hardware/">Optimising decode in vLLM&lt;/a> — &lt;code>--max-num-seqs&lt;/code> and &lt;code>--gpu-memory-utilization&lt;/code> are the two dials that determine how many decode slots the scheduler can keep before queueing.&lt;/li>
&lt;li>&lt;a href="https://blog.lo0.es/en/posts/llm-inference-autoscaling-kubernetes-hpa-keda/">LLM autoscaling on Kubernetes&lt;/a> — the scheduler queue (&lt;code>num_requests_waiting&lt;/code>) is the primary metric for HPA with KEDA on an on-premise cluster.&lt;/li>
&lt;li>&lt;a href="https://blog.lo0.es/en/posts/sms-cuda-streams-cuda-graphs-inference/">SMs, CUDA streams and CUDA graphs&lt;/a> — batching is precisely what amortises the memory bottleneck of decode and exposes the launch bottleneck; that is why batching and CUDA graphs reinforce each other.&lt;/li>
&lt;li>&lt;a href="https://blog.lo0.es/en/posts/llm-serving-engines-pareto-frontier-comparison/">Serving engine comparison (vLLM/SGLang/TRT-LLM/Dynamo)&lt;/a> — where the implementation differences in continuous batching between engines land: the real goodput of vLLM vs SGLang vs TRT-LLM measured with the same harness.&lt;/li>
&lt;/ul>
&lt;h2 id="references">References&lt;/h2>
&lt;ul>
&lt;li>Yu, G.-I., Jeong, J., Kim, G.-W., Kim, S., Chun, B.-G. &lt;em>Orca: A Distributed Serving System for Transformer-Based Generative Models&lt;/em>. OSDI 2022. &lt;a href="https://www.usenix.org/system/files/osdi22-yu.pdf">https://www.usenix.org/system/files/osdi22-yu.pdf&lt;/a>&lt;/li>
&lt;li>Kwon, W. et al. &lt;em>Efficient Memory Management for Large Language Model Serving with PagedAttention&lt;/em>. SOSP 2023. &lt;a href="https://arxiv.org/abs/2309.06180">https://arxiv.org/abs/2309.06180&lt;/a>&lt;/li>
&lt;li>Agrawal, A. et al. &lt;em>SARATHI: Efficient LLM Inference by Piggybacking Decodes with Chunked Prefills&lt;/em>. 2023. &lt;a href="https://arxiv.org/abs/2308.16369">https://arxiv.org/abs/2308.16369&lt;/a>&lt;/li>
&lt;li>Agrawal, A. et al. &lt;em>Taming Throughput-Latency Tradeoff in LLM Inference with Sarathi-Serve&lt;/em>. OSDI 2024. &lt;a href="https://arxiv.org/abs/2403.02310">https://arxiv.org/abs/2403.02310&lt;/a>&lt;/li>
&lt;li>Zhong, Y. et al. &lt;em>DistServe: Disaggregating Prefill and Decoding for Goodput-optimized Large Language Model Serving&lt;/em>. OSDI 2024. &lt;a href="https://arxiv.org/abs/2401.09670">https://arxiv.org/abs/2401.09670&lt;/a>&lt;/li>
&lt;li>Sheng, Y. et al. &lt;em>S-LoRA: Serving Thousands of Concurrent LoRA Adapters&lt;/em>. MLSys 2024. &lt;a href="https://arxiv.org/abs/2311.03285">https://arxiv.org/abs/2311.03285&lt;/a>&lt;/li>
&lt;li>&lt;em>Efficient LLM Scheduling by Learning to Rank&lt;/em>. NeurIPS 2024.&lt;/li>
&lt;li>&lt;em>Locality-aware Fair Scheduling&lt;/em>. 2025. &lt;a href="https://arxiv.org/abs/2501.14312">https://arxiv.org/abs/2501.14312&lt;/a>&lt;/li>
&lt;li>vLLM V1 alpha release (Jan 2025): &lt;a href="https://blog.vllm.ai/2025/01/27/v1-alpha-release.html">https://blog.vllm.ai/2025/01/27/v1-alpha-release.html&lt;/a>&lt;/li>
&lt;li>vLLM Anatomy (Sep 2025): &lt;a href="https://blog.vllm.ai/2025/09/05/anatomy-of-vllm.html">https://blog.vllm.ai/2025/09/05/anatomy-of-vllm.html&lt;/a>&lt;/li>
&lt;li>vLLM Large-Scale Serving (Dec 2025): &lt;a href="https://blog.vllm.ai/2025/12/17/large-scale-serving.html">https://blog.vllm.ai/2025/12/17/large-scale-serving.html&lt;/a>&lt;/li>
&lt;li>vLLM speculators v0.3.0 (Dec 2025): &lt;a href="https://blog.vllm.ai/2025/12/13/speculators-v030.html">https://blog.vllm.ai/2025/12/13/speculators-v030.html&lt;/a>&lt;/li>
&lt;li>Anyscale, &lt;em>Continuous Batching for LLM Inference&lt;/em> (2023): &lt;a href="https://www.anyscale.com/blog/continuous-batching-llm-inference">https://www.anyscale.com/blog/continuous-batching-llm-inference&lt;/a>&lt;/li>
&lt;li>vLLM metrics docs: &lt;a href="https://docs.vllm.ai/en/latest/design/metrics/">https://docs.vllm.ai/en/latest/design/metrics/&lt;/a>&lt;/li>
&lt;li>vLLM optimization: &lt;a href="https://docs.vllm.ai/en/stable/configuration/optimization/">https://docs.vllm.ai/en/stable/configuration/optimization/&lt;/a>&lt;/li>
&lt;li>SGLang: &lt;a href="https://github.com/sgl-project/sglang">https://github.com/sgl-project/sglang&lt;/a>&lt;/li>
&lt;li>TensorRT-LLM performance tuning: &lt;a href="https://nvidia.github.io/TensorRT-LLM/performance/performance-tuning-guide/tuning-max-batch-size-and-max-num-tokens.html">https://nvidia.github.io/TensorRT-LLM/performance/performance-tuning-guide/tuning-max-batch-size-and-max-num-tokens.html&lt;/a>&lt;/li>
&lt;li>Red Hat, &lt;em>5 steps to triage vLLM performance&lt;/em> (Mar 2026): &lt;a href="https://developers.redhat.com/articles/2026/03/09/5-steps-triage-vllm-performance">https://developers.redhat.com/articles/2026/03/09/5-steps-triage-vllm-performance&lt;/a>&lt;/li>
&lt;li>NVIDIA blog, &lt;em>Chunked Prefill with TensorRT-LLM&lt;/em>: &lt;a href="https://developer.nvidia.com/blog/streamlining-ai-inference-performance-and-deployment-with-nvidia-tensorrt-llm-chunked-prefill/">https://developer.nvidia.com/blog/streamlining-ai-inference-performance-and-deployment-with-nvidia-tensorrt-llm-chunked-prefill/&lt;/a>&lt;/li>
&lt;/ul></description></item><item><title>Multi-LoRA serving: the single translator with a thousand glossaries — shared base, thousands of concurrent adapters and the SGMV kernel</title><link>https://blog.lo0.es/en/posts/multi-lora-serving-shared-base-concurrent-adapters-sgmv-kernel/</link><pubDate>Sat, 30 May 2026 14:00:00 +0200</pubDate><guid>https://blog.lo0.es/en/posts/multi-lora-serving-shared-base-concurrent-adapters-sgmv-kernel/</guid><description>&lt;blockquote>
&lt;p>This post complements &lt;a href="https://blog.lo0.es/en/posts/continuous-fine-tuning-production-real-traffic-deployed-adapter/">Continuous fine-tuning in production&lt;/a>. Continuous fine-tuning is the producer of the adapters; multi-LoRA serving is the consumer that puts them to work. Without this layer, the whole feedback cycle breaks in the last mile. It also crosses over with &lt;a href="https://blog.lo0.es/en/posts/modern-alignment-dpo-kto-orpo-simpo/">Modern alignment (DPO/KTO/ORPO/SimPO)&lt;/a> (each alignment policy can live as a separate adapter) and &lt;a href="https://blog.lo0.es/en/posts/quantization-llm-inference-fp8-int4-gguf/">Quantisation&lt;/a> (a quantised base frees memory for many more adapters).&lt;/p>
&lt;/blockquote>
&lt;h2 id="you-are-here-deploy">You are here: DEPLOY&lt;/h2>
&lt;div class="diagram" style="max-width:780px;margin:1rem auto;">
&lt;svg viewBox="0 0 780 90" xmlns="http://www.w3.org/2000/svg" role="img" aria-label="you are here: Deploy">
&lt;style>.box{stroke:#444;stroke-width:1.4;rx:6}.active{fill:#7ad88f;stroke-width:3}.idle{fill:#f4f4f4}.lbl{font:600 12px sans-serif;fill:#222}.arr{stroke:#666;stroke-width:1.4;fill:none;marker-end:url(#mlm)}.cyc{stroke:#888;stroke-width:1.2;fill:none;stroke-dasharray:4 2;marker-end:url(#mlm)}&lt;/style>
&lt;defs>&lt;marker id="mlm" viewBox="0 0 10 10" refX="9" refY="5" markerWidth="6" markerHeight="6" orient="auto">&lt;path d="M0,0 L10,5 L0,10 z" fill="#666"/>&lt;/marker>&lt;/defs>
&lt;text x="390" y="20" text-anchor="middle" class="lbl">You are here: DEPLOY · shared base, N concurrent adapters on a single GPU&lt;/text>
&lt;rect x="30" y="35" width="110" height="35" class="box idle"/>&lt;text x="85" y="58" text-anchor="middle" class="lbl">1 · Data&lt;/text>
&lt;rect x="155" y="35" width="110" height="35" class="box idle"/>&lt;text x="210" y="58" text-anchor="middle" class="lbl">2 · Tune&lt;/text>
&lt;rect x="280" y="35" width="110" height="35" class="box idle"/>&lt;text x="335" y="58" text-anchor="middle" class="lbl">3 · Eval&lt;/text>
&lt;rect x="405" y="35" width="110" height="35" class="box active"/>&lt;text x="460" y="58" text-anchor="middle" class="lbl">4 · Deploy&lt;/text>
&lt;rect x="530" y="35" width="110" height="35" class="box idle"/>&lt;text x="585" y="58" text-anchor="middle" class="lbl">5 · Observe&lt;/text>
&lt;rect x="655" y="35" width="110" height="35" class="box idle"/>&lt;text x="710" y="58" text-anchor="middle" class="lbl">6 · Retrain&lt;/text>
&lt;path class="arr" d="M140,52 L155,52"/>&lt;path class="arr" d="M265,52 L280,52"/>&lt;path class="arr" d="M390,52 L405,52"/>&lt;path class="arr" d="M515,52 L530,52"/>&lt;path class="arr" d="M640,52 L655,52"/>
&lt;path class="cyc" d="M710,72 L710,82 L85,82 L85,72"/>
&lt;/svg>
&lt;/div>
&lt;h2 id="tldr">TL;DR&lt;/h2>
&lt;p>The dominant pattern in 2026 is not one model per customer but a single general-purpose base model plus N fine LoRA adapters per task, customer, language or domain. The reason is obvious: a rank-16 LoRA over Llama-3-70B takes about 400 MB; a full fine-tune takes about 140 GB. Dozens or hundreds of adapters per base is manageable; dozens or hundreds of bases is prohibitive. What is not obvious is how to serve them concurrently without reloading weights every time the adapter changes (killing batching) or replicating the base (killing memory). The answer crystallised in 2024 with two complementary papers: &lt;strong>S-LoRA&lt;/strong> (Sheng et al., Stanford + UC Berkeley, MLSys 2024) introduced &lt;em>unified paging&lt;/em>, where adapter weights live in the same memory pool as the KV cache, both pageable, and &lt;em>heterogeneous batching&lt;/em>, where a batch can hold requests with different adapters and different ranks without padding; &lt;strong>Punica&lt;/strong> (Chen et al., UW + Duke, MLSys 2024) introduced the CUDA kernel that has become the de facto standard: &lt;strong>SGMV&lt;/strong> (Segmented Gather Matrix-Vector multiplication), which computes &lt;code>Y += Σ_i X_i · A_i · B_i&lt;/code> in a single pass, grouping requests by adapter. SGMV sits today underneath vLLM, LoRAX (Predibase), SGLang and TGI. The measurable operational result: up to &lt;strong>2,000 concurrent adapters on a single GPU&lt;/strong> (S-LoRA paper), up to 4× throughput vs naive vLLM and up to 30× vs HuggingFace PEFT. The price: typical overhead of &lt;strong>10-30 %&lt;/strong> latency per layer with an active adapter in a heterogeneous batch, practically zero when every request in the batch uses the same adapter, and 20-40 % in the worst case. This post takes apart the mechanism, the maths (memory per adapter, overhead per rank), the comparison table of implementations, the pitfalls (cold start, mismatched rank, fragmentation) and the real economics on an H100 with a Llama-3-70B FP8 base + 200 adapters.&lt;/p>
&lt;h2 id="the-analogy-the-single-translator-with-a-thousand-glossaries">The analogy: the single translator with a thousand glossaries&lt;/h2>
&lt;p>Imagine a specialised translation agency with a single senior translator, brilliant, fluent in fifteen languages and across all the general technical domains. That translator is expensive to hire and expensive to train: it took years of formation and experience that is not easily replicated. But the agency receives texts from very different clients: a law firm using legal terminology specific to its jurisdiction, a manufacturer with an internal parts nomenclature, a hospital with its own clinical abbreviations. Every client has its own jargon.&lt;/p>
&lt;p>The agency does not hire one translator per client; that would be ridiculous, 90 % of the work is shared. What it does is &lt;strong>keep one glossary per client&lt;/strong>: a small notebook, easy to update, containing the specific terms and how they are translated for that client. When the translator receives a text, they open the glossary for the client in question and work with it alongside. As they translate each word, they first check whether it is in the glossary; if it is, they use the specific version; if not, they use their general knowledge.&lt;/p>
&lt;p>The glossaries live on a shared shelf, ordered by recent use: the most consulted ones within reach, the old ones in the archive. When a new client arrives, their glossary is brought from the archive to the shelf. When the desk fills up, the least used glossary goes back to the archive.&lt;/p>
&lt;p>And the most important part: the translator can have several glossaries open at once because they are working in parallel on five texts from five clients. It is not one glossary per document; it is one glossary per client, and client A&amp;rsquo;s documents use A&amp;rsquo;s glossary, client B&amp;rsquo;s use B&amp;rsquo;s, all on the same desk.&lt;/p>
&lt;p>The analogy holds on five mappings:&lt;/p>
&lt;ul>
&lt;li>&lt;strong>The single translator&lt;/strong> = the base model (Llama-3-70B, Qwen2.5-72B). Expensive to train, a single copy in VRAM.&lt;/li>
&lt;li>&lt;strong>Each glossary&lt;/strong> = a LoRA adapter. Small (~150-400 MB), specific, easy to update.&lt;/li>
&lt;li>&lt;strong>The shelf with the glossaries within reach&lt;/strong> = the pool of adapters cached in VRAM (typically 50-200 at a time with an FP8 base on an H100 80 GB).&lt;/li>
&lt;li>&lt;strong>The archive&lt;/strong> = adapter storage on MinIO/S3/HF Hub. Hundreds or thousands, fetched on demand.&lt;/li>
&lt;li>&lt;strong>Working in parallel with several glossaries open&lt;/strong> = a heterogeneous batch with SGMV. The kernel does the grouped lookup into the right glossary for each word in the batch.&lt;/li>
&lt;/ul>
&lt;h2 id="the-bare-mechanism-what-a-lora-does-and-why-it-can-be-served-multi-tenant">The bare mechanism: what a LoRA does and why it can be served multi-tenant&lt;/h2>
&lt;p>A LoRA adapter modifies a matrix &lt;code>W&lt;/code> of the base model by adding a low-rank product to it:&lt;/p>
$$W' = W + B A$$
&lt;p>where &lt;code>W ∈ R^{d_out × d_in}&lt;/code> is the original matrix (the base weights), &lt;code>A ∈ R^{r × d_in}&lt;/code> and &lt;code>B ∈ R^{d_out × r}&lt;/code> are the adapter&amp;rsquo;s trainable matrices, and &lt;code>r&lt;/code> is the &lt;strong>rank&lt;/strong> (typically 8, 16, 32 or 64, always much smaller than &lt;code>d_in&lt;/code> and &lt;code>d_out&lt;/code>).&lt;/p>
&lt;p>In a forward pass, instead of computing &lt;code>y = W' x&lt;/code>, it computes:&lt;/p>
$$y = W x + B(Ax)$$
&lt;p>That is: the base computation (&lt;code>Wx&lt;/code>) happens exactly as before; the adapter adds two cheap matmuls (&lt;code>Ax&lt;/code> and then &lt;code>B(·)&lt;/code>) that supply the correction. The matrix &lt;code>BA&lt;/code> is never explicitly materialised.&lt;/p>
&lt;p>&lt;strong>What this enables in serving&lt;/strong>: if you have the base loaded and N different adapters, &lt;code>Wx&lt;/code> is computed only once for all the tokens in the batch (the base is the same). What changes between tokens is only the delta &lt;code>B_i(A_i x)&lt;/code>. If the tokens in the batch use different adapters, different deltas have to be applied per token, and that is what the SGMV kernel does in a single pass.&lt;/p>
&lt;p>Without a specialised kernel this degenerates: you need to launch N separate matmuls (one per adapter), pay kernel launch overhead N times and lose the batching. With a specialised kernel (SGMV), every delta is computed in a single pass grouped by adapter.&lt;/p>
&lt;h2 id="sgmv-the-kernel-that-holds-it-all-up">SGMV: the kernel that holds it all up&lt;/h2>
&lt;p>&lt;strong>SGMV&lt;/strong> (Segmented Gather Matrix-Vector multiplication) is the CUDA kernel Punica introduced and that vLLM, LoRAX, SGLang and TGI have adopted as their multi-LoRA engine.&lt;/p>
&lt;p>Its job is to compute, given a batch of tokens with mixed adapters:&lt;/p>
$$y_t = W x_t + B_{a(t)} A_{a(t)} x_t \quad \forall t \in \text{batch}$$
&lt;p>where &lt;code>a(t)&lt;/code> is the adapter assigned to token &lt;code>t&lt;/code>. SGMV operates in two phases:&lt;/p>
&lt;ol>
&lt;li>&lt;strong>SGMV-shrink&lt;/strong>: projection &lt;code>d_in → r&lt;/code> with the corresponding &lt;code>A_{a(t)}&lt;/code> matrix.&lt;/li>
&lt;li>&lt;strong>SGMV-expand&lt;/strong>: projection &lt;code>r → d_out&lt;/code> with the corresponding &lt;code>B_{a(t)}&lt;/code> matrix.&lt;/li>
&lt;/ol>
&lt;p>Internally, SGMV groups the batch&amp;rsquo;s tokens by adapter (it &lt;code>segments&lt;/code> them), and for each segment it uses the optimal kernel for the size: for large segments (several requests with the same adapter) it goes through tensor cores; for small segments (one request per adapter) it uses the &lt;em>batched gather&lt;/em> path that minimises launch overhead.&lt;/p>
&lt;p>The result, in a single kernel pass, is the correct delta for every token in the batch, whatever its rank or its adapter. Punica reported up to 12× throughput vs vLLM/FasterTransformer/HF Transformers/DeepSpeed in heterogeneous multi-tenant scenarios; when every request uses the same adapter, SGMV is practically equivalent to the base without LoRA because it reduces to the &amp;ldquo;single large segment&amp;rdquo; case that is optimal for tensor cores.&lt;/p>
&lt;p>S-LoRA refined SGMV with two kernels specific to different serving phases: &lt;strong>MBGMM&lt;/strong> (Multi-size Batched Gather Matrix-Matrix) for prefill, &lt;strong>MBGMV&lt;/strong> for decode. Both support different ranks among requests in the same batch, which was a limitation in the original SGMV.&lt;/p>
&lt;div class="diagram" style="max-width:780px;margin:1.5rem auto;">
&lt;svg viewBox="0 0 780 380" xmlns="http://www.w3.org/2000/svg" role="img" aria-label="Multi-LoRA serving with SGMV kernel and hierarchical cache">
&lt;style>
.base{fill:#d4ecff;stroke:#1f5fa8;stroke-width:1.4;rx:8}
.gpu{fill:#fff4d6;stroke:#a48000;stroke-width:1.2;rx:4}
.cpu{fill:#cdebd0;stroke:#2a7a40;stroke-width:1.2;rx:4}
.s3{fill:#e6d0ff;stroke:#5a2db0;stroke-width:1.2;rx:4}
.req{fill:#f6e0c8;stroke:#a76b1f;stroke-width:1.2;rx:4}
.kern{fill:#f6caca;stroke:#a52a2a;stroke-width:1.4;rx:6}
.lbl{font:600 12px sans-serif;fill:#222}
.sub{font:400 10px sans-serif;fill:#555}
.arr{stroke:#666;stroke-width:1.4;fill:none;marker-end:url(#mlk)}
.evict{stroke:#999;stroke-width:1.2;fill:none;stroke-dasharray:4 2;marker-end:url(#mlk)}
&lt;/style>
&lt;defs>&lt;marker id="mlk" viewBox="0 0 10 10" refX="9" refY="5" markerWidth="6" markerHeight="6" orient="auto">&lt;path d="M0,0 L10,5 L0,10 z" fill="#444"/>&lt;/marker>&lt;/defs>
&lt;text x="20" y="22" class="lbl">1. Heterogeneous batch: four requests with three different adapters&lt;/text>
&lt;rect x="20" y="35" width="110" height="35" class="req"/>&lt;text x="75" y="55" text-anchor="middle" class="lbl">req_1 → A12&lt;/text>&lt;text x="75" y="68" text-anchor="middle" class="sub">tenant_1&lt;/text>
&lt;rect x="140" y="35" width="110" height="35" class="req"/>&lt;text x="195" y="55" text-anchor="middle" class="lbl">req_2 → A12&lt;/text>&lt;text x="195" y="68" text-anchor="middle" class="sub">tenant_1 (another chat)&lt;/text>
&lt;rect x="260" y="35" width="110" height="35" class="req"/>&lt;text x="315" y="55" text-anchor="middle" class="lbl">req_3 → A47&lt;/text>&lt;text x="315" y="68" text-anchor="middle" class="sub">tenant_2&lt;/text>
&lt;rect x="380" y="35" width="110" height="35" class="req"/>&lt;text x="435" y="55" text-anchor="middle" class="lbl">req_4 → A89&lt;/text>&lt;text x="435" y="68" text-anchor="middle" class="sub">tenant_3&lt;/text>
&lt;text x="20" y="105" class="lbl">2. SGMV kernel: groups by adapter, computes in a single pass&lt;/text>
&lt;rect x="20" y="115" width="470" height="60" class="kern"/>
&lt;text x="255" y="138" text-anchor="middle" class="lbl">SGMV: Y = Wx (base) + Σ_a B_a · A_a · x_{tokens(a)}&lt;/text>
&lt;text x="255" y="156" text-anchor="middle" class="sub">segment A12 (2 reqs, rank=16) | segment A47 (1 req, rank=8) | segment A89 (1 req, rank=32)&lt;/text>
&lt;text x="255" y="169" text-anchor="middle" class="sub">tensor cores for the large segment, batched gather for the small ones&lt;/text>
&lt;text x="20" y="200" class="lbl">3. GPU memory: shared base + unified adapter pool + KV cache&lt;/text>
&lt;rect x="20" y="210" width="470" height="50" class="base"/>
&lt;text x="255" y="230" text-anchor="middle" class="lbl">BASE: Llama-3-70B FP8 (~70 GB) — loaded once, shared by all&lt;/text>
&lt;text x="255" y="248" text-anchor="middle" class="sub">computes Wx for every token in the batch whatever the adapter&lt;/text>
&lt;rect x="20" y="270" width="220" height="55" class="gpu"/>
&lt;text x="130" y="290" text-anchor="middle" class="lbl">HBM POOL: ~10 GB free&lt;/text>
&lt;text x="130" y="305" text-anchor="middle" class="sub">~25 active r=16 adapters (hot)&lt;/text>
&lt;text x="130" y="318" text-anchor="middle" class="sub">A12, A47, A89, A03, A18, A23, ...&lt;/text>
&lt;rect x="270" y="270" width="220" height="55" class="cpu"/>
&lt;text x="380" y="290" text-anchor="middle" class="lbl">RAM CACHE: ~512 GB&lt;/text>
&lt;text x="380" y="305" text-anchor="middle" class="sub">~1,300 warm adapters&lt;/text>
&lt;text x="380" y="318" text-anchor="middle" class="sub">LRU eviction; async H2D on use&lt;/text>
&lt;text x="520" y="232" class="lbl">4. STORAGE&lt;/text>
&lt;rect x="520" y="240" width="240" height="85" class="s3"/>
&lt;text x="640" y="260" text-anchor="middle" class="lbl">MinIO / S3 / HF Hub&lt;/text>
&lt;text x="640" y="278" text-anchor="middle" class="sub">cold storage: thousands of adapters&lt;/text>
&lt;text x="640" y="293" text-anchor="middle" class="sub">A0001 ... A9999&lt;/text>
&lt;text x="640" y="308" text-anchor="middle" class="sub">cold start: ~0.5-5s per adapter&lt;/text>
&lt;path class="arr" d="M270,297 L240,297"/>
&lt;path class="evict" d="M240,310 L270,310"/>
&lt;path class="arr" d="M520,295 L490,295"/>
&lt;text x="20" y="355" class="sub">Solid arrow = on-demand path (cache miss). Dashed = LRU eviction.&lt;/text>
&lt;/svg>
&lt;/div>
&lt;h2 id="the-maths-that-matters">The maths that matters&lt;/h2>
&lt;p>Three numbers drive every operational decision with multi-LoRA.&lt;/p>
&lt;p>&lt;strong>Memory per adapter.&lt;/strong> For a &lt;code>d_in × d_out&lt;/code> matrix with rank &lt;code>r&lt;/code> and &lt;code>b&lt;/code> bytes per parameter (BF16/FP16 = 2):&lt;/p>
$$\text{bytes per matrix} = (d_{\text{in}} \cdot r + r \cdot d_{\text{out}}) \cdot b$$
&lt;p>Summing over all the target matrices in each layer and multiplying by the number of layers gives the adapter size. A concrete calculation for &lt;strong>Llama-3-70B&lt;/strong>, rank 16, BF16, &lt;strong>all&lt;/strong> the matrices (Q, K, V, O, gate, up, down):&lt;/p>
&lt;table>
&lt;thead>
&lt;tr>
&lt;th>Matrix&lt;/th>
&lt;th>Dimension&lt;/th>
&lt;th>Bytes&lt;/th>
&lt;/tr>
&lt;/thead>
&lt;tbody>
&lt;tr>
&lt;td>Q (8192→8192)&lt;/td>
&lt;td>8192·16 + 16·8192&lt;/td>
&lt;td>524,288&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>O (8192→8192)&lt;/td>
&lt;td>8192·16 + 16·8192&lt;/td>
&lt;td>524,288&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>K (8192→1024)&lt;/td>
&lt;td>8192·16 + 16·1024&lt;/td>
&lt;td>294,912&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>V (8192→1024)&lt;/td>
&lt;td>8192·16 + 16·1024&lt;/td>
&lt;td>294,912&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>gate (8192→28,672)&lt;/td>
&lt;td>8192·16 + 16·28,672&lt;/td>
&lt;td>1,179,648&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>up (8192→28,672)&lt;/td>
&lt;td>8192·16 + 16·28,672&lt;/td>
&lt;td>1,179,648&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>down (28,672→8192)&lt;/td>
&lt;td>28,672·16 + 16·8192&lt;/td>
&lt;td>1,179,648&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>&lt;strong>Sum per layer&lt;/strong>&lt;/td>
&lt;td>&lt;/td>
&lt;td>&lt;strong>5,177,344 ≈ 4.94 MB&lt;/strong>&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>&lt;strong>Adapter total (80 layers)&lt;/strong>&lt;/td>
&lt;td>&lt;/td>
&lt;td>&lt;strong>~395 MB&lt;/strong>&lt;/td>
&lt;/tr>
&lt;/tbody>
&lt;/table>
&lt;p>If it is limited to attention-only (Q, O, V, K): about 125 MB per adapter. The choice of which matrices get LoRA belongs to whoever trains it; in serving it is inherited and it determines the cost.&lt;/p>
&lt;p>&lt;strong>Memory per rank.&lt;/strong> Linear: rank 8 → ~200 MB; rank 16 → ~400 MB; rank 32 → ~800 MB; rank 64 → ~1.6 GB. The simple sizing rule is: &lt;code>max_lora_rank&lt;/code> should be the maximum rank you are going to serve, no more. Setting it higher wastes reserved memory in every slot.&lt;/p>
&lt;p>&lt;strong>How many adapters fit.&lt;/strong> For an H100 SXM 80 GB with a Llama-3-70B FP8 base (~70 GB), about 10 GB are left after a minimal KV cache → around 25 fully targeted r=16 adapters or around 80 attention-only. With an AWQ INT4 base (~35 GB), about 45 GB are left → hundreds of adapters. The rule: quantising the base does not just free memory, it multiplies the economics of the platform.&lt;/p>
&lt;p>&lt;strong>Latency overhead per adapter.&lt;/strong> Under real reported conditions:&lt;/p>
&lt;table>
&lt;thead>
&lt;tr>
&lt;th>Case&lt;/th>
&lt;th>Typical overhead&lt;/th>
&lt;/tr>
&lt;/thead>
&lt;tbody>
&lt;tr>
&lt;td>All requests in the batch on the same adapter&lt;/td>
&lt;td>~0 % (equivalent to a static merge)&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>Heterogeneous batch, similar ranks (e.g. all r=16)&lt;/td>
&lt;td>10-30 % per layer&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>Heterogeneous batch, mismatched ranks (r=8 with r=128)&lt;/td>
&lt;td>up to +84 % P95 TTFT for the lower rank&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>Naive PEFT LoRA (no SGMV)&lt;/td>
&lt;td>250-950 % extra&lt;/td>
&lt;/tr>
&lt;/tbody>
&lt;/table>
&lt;p>It scales linearly with rank: rank 8 ≈ baseline; rank 64 ≈ 3-4 × overhead.&lt;/p>
&lt;h2 id="the-real-implementations-in-may-2026">The real implementations in May 2026&lt;/h2>
&lt;table>
&lt;thead>
&lt;tr>
&lt;th>Implementation&lt;/th>
&lt;th>Base kernel&lt;/th>
&lt;th>Hot-swap&lt;/th>
&lt;th>Quantised base + LoRA&lt;/th>
&lt;th>Operational notes&lt;/th>
&lt;/tr>
&lt;/thead>
&lt;tbody>
&lt;tr>
&lt;td>&lt;strong>vLLM&lt;/strong>&lt;/td>
&lt;td>SGMV + ext&lt;/td>
&lt;td>Yes (LoRAResolver, S3/HF/FS plugins)&lt;/td>
&lt;td>AWQ/GPTQ yes, bnb 4-bit offline only&lt;/td>
&lt;td>The de facto default. &lt;code>--enable-lora --max-loras N --max-lora-rank R --max-cpu-loras M&lt;/code>&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>&lt;strong>LoRAX&lt;/strong> (Predibase)&lt;/td>
&lt;td>Optimised SGMV&lt;/td>
&lt;td>Yes (dynamic loading)&lt;/td>
&lt;td>Yes&lt;/td>
&lt;td>Designed specifically for multi-LoRA. Supports Medusa adapters per adapter (spec-dec per adapter).&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>&lt;strong>SGLang&lt;/strong>&lt;/td>
&lt;td>SGMV / csgmv&lt;/td>
&lt;td>Yes&lt;/td>
&lt;td>Yes&lt;/td>
&lt;td>&lt;code>--enable-lora-overlap-loading&lt;/code> cuts TTFT by up to 78 % on LoRA-heavy workloads&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>&lt;strong>TensorRT-LLM&lt;/strong>&lt;/td>
&lt;td>LoRA Executor (C++)&lt;/td>
&lt;td>Pre-compile at build time&lt;/td>
&lt;td>INT4 + LoRA common&lt;/td>
&lt;td>Peak throughput on H100/B200, less flexible than vLLM&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>&lt;strong>HF TGI&lt;/strong>&lt;/td>
&lt;td>Punica fork&lt;/td>
&lt;td>Yes (&lt;code>LORA_ADAPTERS=...&lt;/code>)&lt;/td>
&lt;td>Yes&lt;/td>
&lt;td>In &lt;em>maintenance mode&lt;/em> as of May 2026; HF recommends vLLM or SGLang&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>&lt;strong>NVIDIA NIM&lt;/strong>&lt;/td>
&lt;td>TRT-LLM under the hood&lt;/td>
&lt;td>Static or dynamic (&lt;code>NIM_PEFT_REFRESH_INTERVAL&lt;/code>)&lt;/td>
&lt;td>Yes&lt;/td>
&lt;td>Adapter store per model; polling for hot-add/remove&lt;/td>
&lt;/tr>
&lt;/tbody>
&lt;/table>
&lt;p>Three operational observations:&lt;/p>
&lt;ol>
&lt;li>&lt;strong>vLLM dominates&lt;/strong> open-source serving in 2026 thanks to the combination of a mature SGMV + LoRAResolver plugins + support for a quantised base. The critical parameter is &lt;code>--max-lora-rank&lt;/code>: many installations set it to the maximum &amp;ldquo;just in case&amp;rdquo; and waste memory silently.&lt;/li>
&lt;li>&lt;strong>LoRAX wins&lt;/strong> in production operations with thousands of rarely used adapters thanks to its dynamic loading that does not block concurrent requests. A public case: Convirza with 60+ concurrent adapters and sub-2s P95.&lt;/li>
&lt;li>&lt;strong>SGLang wins&lt;/strong> on latency when cold starts are frequent thanks to &lt;code>--enable-lora-overlap-loading&lt;/code> (async H2D during the compute of the previous request).&lt;/li>
&lt;/ol>
&lt;h2 id="combined-pattern-with-quantisation-and-disaggregated-serving">Combined pattern with quantisation and disaggregated serving&lt;/h2>
&lt;p>&lt;strong>With quantisation&lt;/strong> (&lt;a href="https://blog.lo0.es/en/posts/quantization-llm-inference-fp8-int4-gguf/">Quantisation&lt;/a>). The canonical stack in May 2026 is base in FP8 (Hopper/Blackwell) or INT4 AWQ + adapters in BF16/FP16. The adapters are not quantised: they are small, the memory saved is irrelevant, and quantisation noise would accumulate badly with the delta. Quantising the base frees massive amounts of memory for more adapters with no significant loss (&amp;lt;1 % in typical MMLU with AWQ INT4, somewhat more in math/code/reasoning). An adapter trained with a BF16 base works with an FP8/INT4 base at inference with marginal loss, and that is what makes QLoRA operationally trivial: train with a 4-bit base and deploy with a consistent 4-bit base.&lt;/p>
&lt;p>&lt;strong>With disaggregated serving&lt;/strong> (&lt;a href="https://blog.lo0.es/en/posts/disaggregated-serving-prefill-decode-specialised-pods/">Disaggregated serving&lt;/a>). Multi-LoRA + prefill/decode disaggregation adds a management layer: each pod needs access to the request&amp;rsquo;s active adapter. The 2026 strategy: replicate hot adapters across every pod (prefill and decode), evict the cold ones. Cold adapters not present in the target pod are transferred on demand, accepting the extra cost in TTFT. Recent work (InfiniLoRA, FASTLIBRA, LoRAServe) automates this balancing, but the simple rule of thumb works in most deployments.&lt;/p>
&lt;h2 id="operational-pitfalls">Operational pitfalls&lt;/h2>
&lt;p>&lt;strong>Cold start.&lt;/strong> The first request to a sleeping adapter involves a fetch (from S3/MinIO/HF Hub) → CPU load → H2D copy. For an adapter of around 400 MB: 0.5-5 s typically, depending on bandwidth. Under high concurrency it can be up to 35 % of E2E latency (Predictive-LoRA paper, arXiv:2512.20210). Established mitigations: SGLang &lt;code>--enable-lora-overlap-loading&lt;/code> (cuts TTFT by 35-78 %); vLLM pre-warming with a dummy request when an adapter is registered; predictive prefetching based on patterns (Predictive-LoRA cuts cold start by 68 %).&lt;/p>
&lt;p>&lt;strong>Heterogeneous worst case.&lt;/strong> If every request in the batch has a different adapter and a different rank, SGMV loses its advantage because each segment has size 1. Throughput can fall by up to 50 % vs the base without LoRA. Practical mitigation: group adapters by rank in the upstream routing, trying to get requests of the same rank into the same batch step.&lt;/p>
&lt;p>&lt;strong>Mismatched rank.&lt;/strong> Co-batching rank 8 with rank 128 penalises the smaller one: &lt;strong>+84 % P95 TTFT&lt;/strong> for the rank-8 requests (Serving Heterogeneous LoRA Adapters paper, arXiv:2511.22880). Practice: normalise the rank across the fleet whenever possible (train every adapter at the same rank, or at least within a narrow range).&lt;/p>
&lt;p>&lt;strong>Eviction.&lt;/strong> LRU is the default. If you have more adapters than fit in CPU RAM, the evicted ones get fetched again from cold storage. Monitoring &lt;code>cold_starts_per_minute&lt;/code> and &lt;code>cache_hit_ratio&lt;/code> per endpoint is basic hygiene.&lt;/p>
&lt;p>&lt;strong>Base versioning.&lt;/strong> Every adapter is tied to an exact base version (Llama-3-70B-Instruct ≠ Llama-3.1-70B-Instruct). The routing must validate the base+adapter pair before serving, or the output will be silent rubbish.&lt;/p>
&lt;p>&lt;strong>Memory fragmentation.&lt;/strong> Without paged management (the pre-S-LoRA / pre-vLLM case), evicting and inserting adapters fragments HBM until it becomes unusable. Unified Paging solves it: the LoRA weights and the KV cache live in the same pool of blocks, interchangeable.&lt;/p>
&lt;h2 id="implications-on-on-premise-hardware">Implications on on-premise hardware&lt;/h2>
&lt;p>&lt;strong>On an RTX 4090 (24 GB).&lt;/strong> The classic case: base &lt;strong>Llama-3-8B FP8&lt;/strong> (~8 GB) or &lt;strong>Llama-3-8B AWQ-INT4&lt;/strong> (~5 GB) + dozens of r=16 adapters (~80 MB each, around 3 MB per adapter for Llama-3-8B with r=16 attention-only). 50-100 active adapters fit easily on the 4090. It is the natural setup for multi-tenant demos, fine-tuning over an 8B base and platform prototypes.&lt;/p>
&lt;p>&lt;strong>On a generic 4×H100 SXM cluster (320 GB, NVLink, native FP8).&lt;/strong> This is where the serious setup comes in:&lt;/p>
&lt;ul>
&lt;li>&lt;strong>Llama-3-70B FP8&lt;/strong> (~70 GB) fits on 2 H100s; the other 2 GPUs are available for batch + adapters.&lt;/li>
&lt;li>&lt;strong>Llama-3-70B AWQ-INT4&lt;/strong> (~35 GB) fits on 1 H100; the rest of the cluster serves more concurrency or more adapters.&lt;/li>
&lt;li>&lt;strong>~200 fully targeted r=16 adapters&lt;/strong> fit with a comfortable budget on the cluster, enough for a SaaS platform with dozens of tenants and simultaneous A/B testing.&lt;/li>
&lt;li>&lt;strong>QLoRA training + consistent serving&lt;/strong>: the train→deploy adapter cycle takes hours, not days, because the adapter is around 400 MB instead of around 140 GB.&lt;/li>
&lt;/ul>
&lt;p>The rule of thumb on an H100 cluster in May 2026: an FP8 or INT4 base + 100-500 adapters per cluster is operationally trivial with vLLM or LoRAX; going past a thousand concurrent adapters starts to require serious tuning of eviction and prefetch.&lt;/p>
&lt;h2 id="typical-production-stack">Typical production stack&lt;/h2>
&lt;pre tabindex="0">&lt;code>[API Gateway]
↓ (JWT with tenant_id / API key)
[Router]
↓ injects adapter_id into the request
[vLLM / LoRAX with --enable-lora]
--max-loras 16
--max-lora-rank 32
--max-cpu-loras 200
+ LoRAResolver → s3://adapters/{tenant_id}/{version}/
+ Base: Llama-3-70B-FP8 loaded once on 4×H100 TP=4
↓
[GPU]
FP8 base (70 GB) + ~150 BF16 adapters hot in HBM + ~1,000 warm in RAM
[MinIO / S3]
Cold storage for thousands of adapters
[CI pipeline]
→ trains new QLoRA adapter → push MinIO → notify server → warm-up
[Observability]
Prom: active_adapters, cache_hit_ratio, cold_starts_per_minute,
per_adapter_throughput, P99_with_lora vs P99_base
&lt;/code>&lt;/pre>&lt;p>Routing pattern: &lt;code>Authorization: Bearer &amp;lt;key&amp;gt;&lt;/code> → middleware extracts &lt;code>tenant_id&lt;/code> → maps to &lt;code>adapter_id&lt;/code> (Postgres or Redis) → &lt;code>POST /v1/completions&lt;/code> with &lt;code>model: &amp;quot;&amp;lt;adapter_id&amp;gt;&amp;quot;&lt;/code>.&lt;/p>
&lt;h2 id="what-we-have-not-covered">What we have not covered&lt;/h2>
&lt;ul>
&lt;li>&lt;strong>DoRA (Weight-Decomposed LoRA)&lt;/strong>: decomposes the update into magnitude + direction, closing part of the quality gap with full fine-tuning. Supported by TensorRT-LLM and others, but the serving pattern is identical to LoRA.&lt;/li>
&lt;li>&lt;strong>MoE + LoRA&lt;/strong>: how adapter fine-tuning is done over an MoE, what happens to the routing. Not trivial, an active research area in 2026.&lt;/li>
&lt;li>&lt;strong>Activated LoRA&lt;/strong> (arXiv:2512.17910): a variant that reuses the KV cache between compatible adapters, cutting the cost of cold start with shared prefixes.&lt;/li>
&lt;li>&lt;strong>LoRA for speculative decoding&lt;/strong>: each adapter brings its own Medusa head, supported by LoRAX as &amp;ldquo;Turbo LoRA&amp;rdquo;.&lt;/li>
&lt;li>&lt;strong>Compress-then-Serve&lt;/strong> (arXiv:2407.00066): quantising the adapters themselves to serve even more concurrently. Still a marginal practice in production as of May 2026.&lt;/li>
&lt;/ul>
&lt;h2 id="see-also">See also&lt;/h2>
&lt;ul>
&lt;li>
&lt;p>&lt;a href="https://blog.lo0.es/en/posts/serving-several-models-single-gpu-co-residency-swapping-sleep-mode/">Serving several models on a single GPU: co-residency, model-swapping and sleep mode&lt;/a> — the alternative when the models do not share a base: co-residency, swap or sleep instead of adapters.&lt;/p>
&lt;/li>
&lt;li>
&lt;p>&lt;a href="https://blog.lo0.es/en/posts/continuous-fine-tuning-production-real-traffic-deployed-adapter/">Continuous fine-tuning in production&lt;/a> — the producer of the adapter fleet; multi-LoRA serving is the consumer that closes the cycle.&lt;/p>
&lt;/li>
&lt;li>
&lt;p>&lt;a href="https://blog.lo0.es/en/posts/retrain-closing-the-loop-feedback-dataset-adapter/">Retrain: closing the feedback → dataset → adapter loop&lt;/a> — how a new adapter is trained from production feedback; the resulting adapter is served with this stack.&lt;/p>
&lt;/li>
&lt;li>
&lt;p>&lt;a href="https://blog.lo0.es/en/posts/modern-alignment-dpo-kto-orpo-simpo/">Modern alignment: DPO, KTO, ORPO, SimPO&lt;/a> — each alignment policy can live as a separate adapter; multi-LoRA makes it possible to serve them in parallel and run A/B tests.&lt;/p>
&lt;/li>
&lt;li>
&lt;p>&lt;a href="https://blog.lo0.es/en/posts/quantization-llm-inference-fp8-int4-gguf/">Quantisation for LLM inference&lt;/a> — quantising the base frees memory for many more adapters; the canonical stack is an FP8/INT4 base + BF16 adapters.&lt;/p>
&lt;/li>
&lt;li>
&lt;p>&lt;a href="https://blog.lo0.es/en/posts/disaggregated-serving-prefill-decode-specialised-pods/">Disaggregated serving: prefill and decode&lt;/a> — once the pools are separated, adapters are managed per pod; the 2026 strategy is to replicate the hot ones everywhere and evict the cold ones.&lt;/p>
&lt;/li>
&lt;li>
&lt;p>&lt;a href="https://blog.lo0.es/en/posts/moe-inference-fundamentals-expert-parallel-deepseek-economics/">MoE inference: the call centre with 256 specialists&lt;/a> — LoRA over an MoE base is an active area; the MoE routing complicates applying the deltas correctly.&lt;/p>
&lt;/li>
&lt;li>
&lt;p>&lt;a href="https://blog.lo0.es/en/posts/continuous-batching-orca-vllm-chunked-prefill-goodput/">Continuous batching&lt;/a> — the critical operational tension of multi-LoRA: when every request in the batch uses a different adapter (and rank), throughput can fall by up to 50 %; the scheduler must group requests by rank and adapter to keep the economics.&lt;/p>
&lt;/li>
&lt;li>
&lt;p>&lt;a href="https://blog.lo0.es/en/posts/structured-output-bitmask-outlines-xgrammar-llguidance/">Structured output&lt;/a> — an adapter can be trained specifically for function calling or extraction; combined with XGrammar it gives the strong contract (an adapter suited to the task + a grammar that guarantees the schema).&lt;/p>
&lt;/li>
&lt;li>
&lt;p>&lt;a href="https://blog.lo0.es/en/posts/six-stage-llmops-pipeline-architecture-and-deep-dive/">The six-stage LLMOps pipeline&lt;/a> — the master map where Deploy is stage 4.&lt;/p>
&lt;/li>
&lt;li>
&lt;p>&lt;a href="https://blog.lo0.es/en/posts/qlora-multi-lora-limit-small-models/">QLoRA and multi-LoRA at the limit on small models&lt;/a> — the training side: fine-tuning over an NF4 quantised base on a single 4090, aggressive ranks (r=4-8) and the &amp;ldquo;one SLM base + one adapter per customer&amp;rdquo; case for sovereign deployment.&lt;/p>
&lt;/li>
&lt;/ul>
&lt;h2 id="references">References&lt;/h2>
&lt;ul>
&lt;li>Hu, E. et al. &lt;em>LoRA: Low-Rank Adaptation of Large Language Models&lt;/em>. ICLR 2022. &lt;a href="https://arxiv.org/abs/2106.09685">https://arxiv.org/abs/2106.09685&lt;/a>&lt;/li>
&lt;li>Sheng, Y. et al. &lt;em>S-LoRA: Serving Thousands of Concurrent LoRA Adapters&lt;/em>. MLSys 2024. &lt;a href="https://arxiv.org/abs/2311.03285">https://arxiv.org/abs/2311.03285&lt;/a>&lt;/li>
&lt;li>Chen, L. et al. &lt;em>Punica: Multi-Tenant LoRA Serving&lt;/em>. MLSys 2024. &lt;a href="https://arxiv.org/abs/2310.18547">https://arxiv.org/abs/2310.18547&lt;/a>&lt;/li>
&lt;li>Dettmers, T. et al. &lt;em>QLoRA: Efficient Finetuning of Quantized LLMs&lt;/em>. NeurIPS 2023. &lt;a href="https://arxiv.org/abs/2305.14314">https://arxiv.org/abs/2305.14314&lt;/a>&lt;/li>
&lt;li>Brüel-Gabrielsson, R. et al. &lt;em>Compress then Serve: Serving Thousands of LoRA Adapters with Little Overhead&lt;/em>. 2024. &lt;a href="https://arxiv.org/abs/2407.00066">https://arxiv.org/abs/2407.00066&lt;/a>&lt;/li>
&lt;li>&lt;em>Serving Heterogeneous LoRA Adapters in Distributed LLM Inference&lt;/em>. 2025. &lt;a href="https://arxiv.org/abs/2511.22880">https://arxiv.org/abs/2511.22880&lt;/a>&lt;/li>
&lt;li>&lt;em>InfiniLoRA: Disaggregated Multi-LoRA Serving&lt;/em>. 2026. &lt;a href="https://arxiv.org/abs/2604.07173">https://arxiv.org/abs/2604.07173&lt;/a>&lt;/li>
&lt;li>&lt;em>Predictive-LoRA: Proactive Fragmentation-Aware Serverless LoRA Serving&lt;/em>. &lt;a href="https://arxiv.org/abs/2512.20210">https://arxiv.org/abs/2512.20210&lt;/a>&lt;/li>
&lt;li>S-LoRA repo: &lt;a href="https://github.com/S-LoRA/S-LoRA">https://github.com/S-LoRA/S-LoRA&lt;/a>&lt;/li>
&lt;li>Punica repo: &lt;a href="https://github.com/punica-ai/punica">https://github.com/punica-ai/punica&lt;/a>&lt;/li>
&lt;li>LoRAX repo (Predibase): &lt;a href="https://github.com/predibase/lorax">https://github.com/predibase/lorax&lt;/a>&lt;/li>
&lt;li>vLLM LoRA docs: &lt;a href="https://docs.vllm.ai/en/stable/features/lora/">https://docs.vllm.ai/en/stable/features/lora/&lt;/a>&lt;/li>
&lt;li>SGLang LoRA docs: &lt;a href="https://docs.sglang.io/advanced_features/lora.html">https://docs.sglang.io/advanced_features/lora.html&lt;/a>&lt;/li>
&lt;li>TensorRT-LLM LoRA docs: &lt;a href="https://nvidia.github.io/TensorRT-LLM/advanced/lora.html">https://nvidia.github.io/TensorRT-LLM/advanced/lora.html&lt;/a>&lt;/li>
&lt;li>NVIDIA NIM PEFT: &lt;a href="https://docs.nvidia.com/nim/large-language-models/latest/peft.html">https://docs.nvidia.com/nim/large-language-models/latest/peft.html&lt;/a>&lt;/li>
&lt;li>HF TGI Multi-LoRA blog: &lt;a href="https://huggingface.co/blog/multi-lora-serving">https://huggingface.co/blog/multi-lora-serving&lt;/a>&lt;/li>
&lt;li>LMSYS S-LoRA recipe blog: &lt;a href="https://www.lmsys.org/blog/2023-11-15-slora/">https://www.lmsys.org/blog/2023-11-15-slora/&lt;/a>&lt;/li>
&lt;li>Predibase LoRAX blog: &lt;a href="https://predibase.com/blog/lorax-the-open-source-framework-for-serving-100s-of-fine-tuned-llms-in">https://predibase.com/blog/lorax-the-open-source-framework-for-serving-100s-of-fine-tuned-llms-in&lt;/a>&lt;/li>
&lt;/ul></description></item><item><title>Speculative decoding: the secretary who types ahead of the boss — fundamentals, maths and state of play in May 2026</title><link>https://blog.lo0.es/en/posts/speculative-decoding-fundamentals-maths-state-of-play/</link><pubDate>Fri, 29 May 2026 11:00:00 +0200</pubDate><guid>https://blog.lo0.es/en/posts/speculative-decoding-fundamentals-maths-state-of-play/</guid><description>&lt;blockquote>
&lt;p>This post complements &lt;a href="https://blog.lo0.es/en/posts/kv-cache-working-memory-llm-inference/">KV cache: the working memory&lt;/a> and &lt;a href="https://blog.lo0.es/en/posts/quantization-llm-inference-fp8-int4-gguf/">Quantisation for LLM inference&lt;/a>. Both are acceleration levers &lt;strong>inside a single forward pass&lt;/strong>. Speculative decoding is the complementary lever: instead of making each forward pass cheaper, it tries to &lt;strong>produce more tokens per forward pass&lt;/strong>. It is orthogonal to quantisation and to the KV cache, and it stacks multiplicatively with both.&lt;/p>
&lt;/blockquote>
&lt;h2 id="you-are-here-deploy">You are here: DEPLOY&lt;/h2>
&lt;div class="diagram" style="max-width:780px;margin:1rem auto;">
&lt;svg viewBox="0 0 780 90" xmlns="http://www.w3.org/2000/svg" role="img" aria-label="you are here: Deploy">
&lt;style>.box{stroke:#444;stroke-width:1.4;rx:6}.active{fill:#7ad88f;stroke-width:3}.idle{fill:#f4f4f4}.lbl{font:600 12px sans-serif;fill:#222}.arr{stroke:#666;stroke-width:1.4;fill:none;marker-end:url(#spm)}.cyc{stroke:#888;stroke-width:1.2;fill:none;stroke-dasharray:4 2;marker-end:url(#spm)}&lt;/style>
&lt;defs>&lt;marker id="spm" viewBox="0 0 10 10" refX="9" refY="5" markerWidth="6" markerHeight="6" orient="auto">&lt;path d="M0,0 L10,5 L0,10 z" fill="#666"/>&lt;/marker>&lt;/defs>
&lt;text x="390" y="20" text-anchor="middle" class="lbl">You are here: DEPLOY · more tokens per forward pass without touching quality&lt;/text>
&lt;rect x="30" y="35" width="110" height="35" class="box idle"/>&lt;text x="85" y="58" text-anchor="middle" class="lbl">1 · Data&lt;/text>
&lt;rect x="155" y="35" width="110" height="35" class="box idle"/>&lt;text x="210" y="58" text-anchor="middle" class="lbl">2 · Tune&lt;/text>
&lt;rect x="280" y="35" width="110" height="35" class="box idle"/>&lt;text x="335" y="58" text-anchor="middle" class="lbl">3 · Eval&lt;/text>
&lt;rect x="405" y="35" width="110" height="35" class="box active"/>&lt;text x="460" y="58" text-anchor="middle" class="lbl">4 · Deploy&lt;/text>
&lt;rect x="530" y="35" width="110" height="35" class="box idle"/>&lt;text x="585" y="58" text-anchor="middle" class="lbl">5 · Observe&lt;/text>
&lt;rect x="655" y="35" width="110" height="35" class="box idle"/>&lt;text x="710" y="58" text-anchor="middle" class="lbl">6 · Retrain&lt;/text>
&lt;path class="arr" d="M140,52 L155,52"/>&lt;path class="arr" d="M265,52 L280,52"/>&lt;path class="arr" d="M390,52 L405,52"/>&lt;path class="arr" d="M515,52 L530,52"/>&lt;path class="arr" d="M640,52 L655,52"/>
&lt;path class="cyc" d="M710,72 L710,82 L85,82 L85,72"/>
&lt;/svg>
&lt;/div>
&lt;h2 id="tldr">TL;DR&lt;/h2>
&lt;p>Autoregressive LLM inference has a structural problem: every new token needs a complete forward pass of the model, and decode forward passes are memory-bandwidth-bound, not compute-bound (the detail is in &lt;a href="https://blog.lo0.es/en/posts/kv-cache-working-memory-llm-inference/">KV cache&lt;/a>). The GPU spends most of its time waiting for the next weight to arrive from HBM. &lt;strong>Speculative decoding&lt;/strong> uses that dead time by doing two things in parallel: a small, cheap model (&lt;em>draft&lt;/em>) generates γ tokens autoregressively, fast but imprecise, and the large model (&lt;em>target&lt;/em>) verifies those γ tokens &lt;strong>in a single parallel forward pass&lt;/strong>, at almost the same cost as a single-token forward pass. An acceptance rule based on rejection sampling decides how many draft tokens are accepted, and the maths proves, it does not approximate, it proves, that the output distribution matches sampling directly from the target exactly. The technique has a ceiling: you never generate more than &lt;code>1/(1-α)&lt;/code> tokens per step, with α the acceptance rate. The five dominant families in May 2026 are &lt;strong>vanilla SD&lt;/strong> (Leviathan 2023), &lt;strong>Medusa&lt;/strong> (extra parallel heads), &lt;strong>EAGLE-1/2/3&lt;/strong> (a draft that operates at the level of hidden states), &lt;strong>MTP&lt;/strong> (multi-token prediction native to DeepSeek-V3) and &lt;strong>P-EAGLE&lt;/strong> (a draft that produces the γ tokens in a single forward pass, integrated in vLLM 0.16+). On low-concurrency workloads with short prompts and long outputs, the typical case of the on-premise conversational assistant, the real speedup sits between 2× and 4× on modern hardware.&lt;/p>
&lt;h2 id="the-analogy-the-secretary-who-types-ahead-and-the-boss-who-validates">The analogy: the secretary who types ahead and the boss who validates&lt;/h2>
&lt;p>Picture a crowded press conference with a real-time transcription setup. Two people are working:&lt;/p>
&lt;p>The &lt;strong>secretary&lt;/strong> sits at the keyboard. They know the subject, they have read the briefings and they know the boss&amp;rsquo;s verbal tics. As soon as the boss opens their mouth, the secretary starts typing what they think is coming. They are fast, three or four words ahead of the boss, but they sometimes get it wrong, especially when the boss takes an unexpected turn.&lt;/p>
&lt;p>The &lt;strong>boss&lt;/strong> periodically reads the secretary&amp;rsquo;s screen. They read a whole block at once, three or four words, and mentally compare what is written with what they would have said. If it matches their intent, they leave the text and carry on. If the secretary drifted at some point, they correct right there, and whatever the secretary typed after the divergence is discarded (it could be wrong because of the earlier error). If the whole block was fine, the boss takes the chance to add one more word while correcting.&lt;/p>
&lt;p>The result: the final text is identical to what the boss would have dictated alone, but it comes out faster because the secretary works ahead.&lt;/p>
&lt;p>The analogy holds on four details:&lt;/p>
&lt;ul>
&lt;li>&lt;strong>The secretary is the draft model&lt;/strong>. Small, cheap, fast, approximate.&lt;/li>
&lt;li>&lt;strong>The boss is the target model&lt;/strong>. Slow, expensive, but the only authority on output quality.&lt;/li>
&lt;li>&lt;strong>The block the boss reads at once is the target&amp;rsquo;s parallel forward pass&lt;/strong> over &lt;code>prompt + γ draft tokens&lt;/code>. Verifying γ tokens costs almost the same as generating one, because the bottleneck is loading the weights from HBM, not the operations.&lt;/li>
&lt;li>&lt;strong>The &amp;ldquo;correct right where it diverged&amp;rdquo; rule is the rejection sampling&lt;/strong> that preserves the target distribution.&lt;/li>
&lt;/ul>
&lt;p>From here we go into the mechanism and into why quality is preserved exactly, not approximately.&lt;/p>
&lt;h2 id="the-bare-mechanism">The bare mechanism&lt;/h2>
&lt;p>Call &lt;code>p&lt;/code> the target distribution (what it would produce if it were the only one speaking) and &lt;code>q&lt;/code> the draft&amp;rsquo;s. The speculative decoding iteration has three steps:&lt;/p>
&lt;p>&lt;strong>Step 1 — Draft.&lt;/strong> The draft generates γ tokens autoregressively: &lt;code>x_1, x_2, ..., x_γ&lt;/code>. Each one through its own forward pass. Since the draft is small, the γ steps cost little. Typically γ ∈ [4, 8].&lt;/p>
&lt;p>&lt;strong>Step 2 — Verify.&lt;/strong> The target runs a single forward pass over the complete sequence &lt;code>prompt + x_1 ... x_γ&lt;/code>. Because attention is causal, that forward pass simultaneously produces the distributions &lt;code>p(·|prompt, x_&amp;lt;i)&lt;/code> for each position &lt;em>i&lt;/em>. In other words, in a single step it obtains the verification of the γ tokens and, on top of that, an extra distribution &lt;code>p(·|prompt, x_1...x_γ)&lt;/code> for the next token.&lt;/p>
&lt;p>&lt;strong>Step 3 — Accept/reject token by token.&lt;/strong> For each &lt;code>x_i&lt;/code> from left to right it applies:&lt;/p>
&lt;ul>
&lt;li>If &lt;code>p(x_i) ≥ q(x_i)&lt;/code>: always accept.&lt;/li>
&lt;li>If &lt;code>p(x_i) &amp;lt; q(x_i)&lt;/code>: accept with probability &lt;code>p(x_i)/q(x_i)&lt;/code>.&lt;/li>
&lt;li>If it rejects: stop, sample a replacement token from the normalised residual distribution &lt;code>norm(max(0, p − q))&lt;/code>, and discard the rest.&lt;/li>
&lt;li>If it reaches the end having accepted the γ tokens: append a &lt;strong>bonus token&lt;/strong> sampled directly from &lt;code>p(·|prompt, x_1...x_γ)&lt;/code>, which is already in the logits of the target&amp;rsquo;s forward pass.&lt;/li>
&lt;/ul>
&lt;p>Result per iteration: between 1 and γ+1 new tokens. In the best case (all accepted + bonus) γ+1 tokens are generated at the cost of &lt;strong>a single target forward pass plus γ draft forward passes&lt;/strong>, against γ+1 target forward passes in the version without speculative.&lt;/p>
&lt;div class="diagram" style="max-width:780px;margin:1.5rem auto;">
&lt;svg viewBox="0 0 780 340" xmlns="http://www.w3.org/2000/svg" role="img" aria-label="Speculative decoding mechanism">
&lt;style>
.dbox{fill:#fff4d6;stroke:#a48000;stroke-width:1.4;rx:6}
.tbox{fill:#d4ecff;stroke:#1f5fa8;stroke-width:1.4;rx:6}
.acc{fill:#cdebd0;stroke:#2a7a40;stroke-width:1.4;rx:6}
.rej{fill:#f6caca;stroke:#a52a2a;stroke-width:1.4;rx:6}
.bon{fill:#e6d0ff;stroke:#5a2db0;stroke-width:1.4;rx:6}
.lbl{font:600 12px sans-serif;fill:#222}
.sub{font:400 11px sans-serif;fill:#555}
.arr{stroke:#666;stroke-width:1.4;fill:none;marker-end:url(#spec1)}
&lt;/style>
&lt;defs>&lt;marker id="spec1" viewBox="0 0 10 10" refX="9" refY="5" markerWidth="6" markerHeight="6" orient="auto">&lt;path d="M0,0 L10,5 L0,10 z" fill="#666"/>&lt;/marker>&lt;/defs>
&lt;text x="20" y="25" class="lbl">1. Draft model (q) generates γ=4 tokens autoregressively&lt;/text>
&lt;rect x="20" y="35" width="80" height="35" class="dbox"/>&lt;text x="60" y="58" text-anchor="middle" class="lbl">x₁&lt;/text>
&lt;rect x="120" y="35" width="80" height="35" class="dbox"/>&lt;text x="160" y="58" text-anchor="middle" class="lbl">x₂&lt;/text>
&lt;rect x="220" y="35" width="80" height="35" class="dbox"/>&lt;text x="260" y="58" text-anchor="middle" class="lbl">x₃&lt;/text>
&lt;rect x="320" y="35" width="80" height="35" class="dbox"/>&lt;text x="360" y="58" text-anchor="middle" class="lbl">x₄&lt;/text>
&lt;text x="420" y="58" class="sub">4 cheap forward passes&lt;/text>
&lt;text x="20" y="105" class="lbl">2. Target model (p) verifies the 4 tokens in ONE parallel forward pass&lt;/text>
&lt;rect x="20" y="115" width="380" height="40" class="tbox"/>&lt;text x="210" y="140" text-anchor="middle" class="lbl">single forward pass over [prompt, x₁, x₂, x₃, x₄]&lt;/text>
&lt;text x="420" y="140" class="sub">obtains p(·|prompt, x&amp;lt;i) for i=1..5&lt;/text>
&lt;text x="20" y="180" class="lbl">3. Left-to-right rejection sampling&lt;/text>
&lt;rect x="20" y="190" width="80" height="35" class="acc"/>&lt;text x="60" y="208" text-anchor="middle" class="lbl">x₁ ✓&lt;/text>&lt;text x="60" y="221" text-anchor="middle" class="sub">p≥q&lt;/text>
&lt;rect x="120" y="190" width="80" height="35" class="acc"/>&lt;text x="160" y="208" text-anchor="middle" class="lbl">x₂ ✓&lt;/text>&lt;text x="160" y="221" text-anchor="middle" class="sub">accepted&lt;/text>
&lt;rect x="220" y="190" width="80" height="35" class="acc"/>&lt;text x="260" y="208" text-anchor="middle" class="lbl">x₃ ✓&lt;/text>&lt;text x="260" y="221" text-anchor="middle" class="sub">accepted&lt;/text>
&lt;rect x="320" y="190" width="80" height="35" class="rej"/>&lt;text x="360" y="208" text-anchor="middle" class="lbl">x₄ ✗&lt;/text>&lt;text x="360" y="221" text-anchor="middle" class="sub">rejected&lt;/text>
&lt;text x="20" y="255" class="lbl">4. Result of this iteration&lt;/text>
&lt;rect x="20" y="265" width="80" height="35" class="acc"/>&lt;text x="60" y="288" text-anchor="middle" class="lbl">x₁&lt;/text>
&lt;rect x="120" y="265" width="80" height="35" class="acc"/>&lt;text x="160" y="288" text-anchor="middle" class="lbl">x₂&lt;/text>
&lt;rect x="220" y="265" width="80" height="35" class="acc"/>&lt;text x="260" y="288" text-anchor="middle" class="lbl">x₃&lt;/text>
&lt;rect x="320" y="265" width="80" height="35" class="rej"/>&lt;text x="360" y="288" text-anchor="middle" class="lbl">x'₄&lt;/text>
&lt;text x="420" y="280" class="sub">x'₄ = sample from norm(max(0, p−q)) at pos 4&lt;/text>
&lt;text x="420" y="295" class="sub">bonus dropped (only if all accepted)&lt;/text>
&lt;text x="20" y="325" class="sub">→ 4 new tokens in a single target iteration. Without speculative it would be 4 iterations.&lt;/text>
&lt;/svg>
&lt;/div>
&lt;h2 id="why-quality-does-not-degrade-the-proof">Why quality does not degrade (the proof)&lt;/h2>
&lt;p>This is the part of the technique that confuses people most the first time. It looks like magic: how can sampling from an arbitrary draft and then &amp;ldquo;validating&amp;rdquo; produce exactly the same distribution as sampling from the original target?&lt;/p>
&lt;p>The proof fits in two lines. For any token &lt;code>x&lt;/code>, the final probability of emitting it is the sum of two disjoint events: the draft proposes it and it is accepted, or the draft proposes something else, it is rejected, and sampling from the residual yields &lt;code>x&lt;/code>. Formally:&lt;/p>
$$P(\text{emit } x) = q(x) \cdot \min\!\left(1, \frac{p(x)}{q(x)}\right) + P(\text{reject}) \cdot \frac{\max(0, p(x)-q(x))}{\sum_y \max(0, p(y)-q(y))}$$
&lt;p>The first term is &lt;code>min(p(x), q(x))&lt;/code>. The second is &lt;code>max(0, p(x) − q(x))&lt;/code> (the total rejected mass &lt;code>Σ max(0, p−q)&lt;/code> cancels the denominator exactly). Adding them: &lt;code>min(p(x), q(x)) + max(0, p(x) − q(x)) = p(x)&lt;/code>. The output is statistically indistinguishable from sampling directly from the target, down to floating-point numerical differences.&lt;/p>
&lt;p>This matters operationally: it means speculative decoding is not perceptual compression, it is not a quality-speed trade-off, it does not require extra validation with evals. If the target would have passed a given eval, the system with speculative passes it too.&lt;/p>
&lt;h2 id="the-maths-that-matters-ceiling-and-speedup">The maths that matters: ceiling and speedup&lt;/h2>
&lt;p>Call α the &lt;strong>acceptance rate&lt;/strong>: the expected probability that an individual draft token is accepted. Assuming that acceptances of consecutive tokens are independent (a reasonable approximation in practice), the expected number of tokens generated per iteration is:&lt;/p>
$$E[\text{tokens per step}] = \sum_{k=0}^{\gamma} \alpha^k + \alpha^{\gamma+1} = \frac{1 - \alpha^{\gamma+1}}{1 - \alpha}$$
&lt;p>And the &lt;strong>theoretical speedup&lt;/strong> relative to generating one token per target iteration, with &lt;code>c = T_draft / T_target&lt;/code> the relative cost of the draft, is:&lt;/p>
$$\text{Speedup} = \frac{1 - \alpha^{\gamma+1}}{(1 - \alpha)(\gamma c + 1)}$$
&lt;p>There is a ceiling that many implementations try to beat and cannot: as &lt;code>γ → ∞&lt;/code>, the speedup converges to:&lt;/p>
$$\lim_{\gamma \to \infty} E[\text{tokens per step}] = \frac{1}{1 - \alpha}$$
&lt;p>It is an algorithmic ceiling, not a hardware one. With α = 0.7 you never generate more than 3.33 tokens per iteration no matter how much the hardware improves. With α = 0.8 → 5. With α = 0.9 → 10. That is why EAGLE-3, which targets α &amp;gt; 0.8 on many benchmarks, is not an incremental improvement over vanilla SD (α ≈ 0.5-0.6): it is a change of regime, because it raises the ceiling instead of merely getting closer to it.&lt;/p>
&lt;p>A concrete numerical example, Llama 3 70B as target with a draft giving α = 0.75, γ = 5, c = 0.1:&lt;/p>
&lt;ul>
&lt;li>Expected tokens per step: &lt;code>(1 − 0.75⁶) / (1 − 0.75) = (1 − 0.178) / 0.25 = 3.29&lt;/code>&lt;/li>
&lt;li>Speedup: &lt;code>3.29 / (5 × 0.1 + 1) = 3.29 / 1.5 = 2.19×&lt;/code>&lt;/li>
&lt;/ul>
&lt;p>If we raise α to 0.85 with the same configuration: tokens per step = &lt;code>(1 − 0.85⁶) / 0.15 = (1 − 0.377) / 0.15 = 4.16&lt;/code>, speedup = &lt;code>4.16 / 1.5 = 2.77×&lt;/code>. A change of α = 0.75 → 0.85 (10 absolute points) raises the speedup by 27 %. That is why the research of the last two years focuses on pushing α: that is where the prize is.&lt;/p>
&lt;h2 id="the-five-modern-families-may-2026">The five modern families (May 2026)&lt;/h2>
&lt;table>
&lt;thead>
&lt;tr>
&lt;th>Family&lt;/th>
&lt;th>Year&lt;/th>
&lt;th>Core idea&lt;/th>
&lt;th>Draft size&lt;/th>
&lt;th>Typical α / τ&lt;/th>
&lt;th>Speedup&lt;/th>
&lt;/tr>
&lt;/thead>
&lt;tbody>
&lt;tr>
&lt;td>&lt;strong>Vanilla SD&lt;/strong>&lt;/td>
&lt;td>2023&lt;/td>
&lt;td>Same-family target + draft pair, rejection sampling&lt;/td>
&lt;td>1/10 – 1/100 of target&lt;/td>
&lt;td>0.5 – 0.8&lt;/td>
&lt;td>2-3×&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>&lt;strong>Medusa&lt;/strong>&lt;/td>
&lt;td>2024&lt;/td>
&lt;td>N extra heads in parallel predict t+1, t+2, &amp;hellip;; tree attention verifies several candidates&lt;/td>
&lt;td>no draft, +1-2 % params&lt;/td>
&lt;td>top-5 &amp;gt; 0.8&lt;/td>
&lt;td>2.18-2.83×&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>&lt;strong>EAGLE-1/2/3&lt;/strong>&lt;/td>
&lt;td>2024-25&lt;/td>
&lt;td>Autoregressive draft at the level of &lt;strong>features (hidden states)&lt;/strong>, not tokens. Reuses the target&amp;rsquo;s embedding&lt;/td>
&lt;td>1 transformer block (~0.5-2 % params)&lt;/td>
&lt;td>EAGLE-3: α &amp;gt; 0.8, τ up to 7.5&lt;/td>
&lt;td>up to 6.5× peak&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>&lt;strong>MTP&lt;/strong> (DeepSeek-V3)&lt;/td>
&lt;td>2024&lt;/td>
&lt;td>Multi-token heads trained from scratch as part of the model; at inference they act as a &amp;ldquo;free&amp;rdquo; draft&lt;/td>
&lt;td>14B params in V3 671B&lt;/td>
&lt;td>α &amp;gt; 0.8 (MTP1)&lt;/td>
&lt;td>1.5-1.8×&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>&lt;strong>P-EAGLE&lt;/strong>&lt;/td>
&lt;td>2026&lt;/td>
&lt;td>EAGLE but producing the γ drafts in &lt;strong>a single forward pass&lt;/strong> (parallel, not autoregressive)&lt;/td>
&lt;td>same as EAGLE&lt;/td>
&lt;td>+30 % over EAGLE-3&lt;/td>
&lt;td>4-5× vs AR&lt;/td>
&lt;/tr>
&lt;/tbody>
&lt;/table>
&lt;p>Three operational observations:&lt;/p>
&lt;ol>
&lt;li>&lt;strong>EAGLE dominates in production&lt;/strong> because its overhead is minimal (one transformer block, ~1 % of the target in parameters) and its α is high. The draft does not need its own &amp;ldquo;complete&amp;rdquo; KV cache because it shares features with the target.&lt;/li>
&lt;li>&lt;strong>Medusa mattered historically&lt;/strong>, it showed that speculative was possible without a separate draft, but EAGLE beat it on every published benchmark through 2024-2025.&lt;/li>
&lt;li>&lt;strong>MTP is special&lt;/strong>: it is not something you add to an existing model. It is something the model trained natively. If you buy DeepSeek-V3, MTP comes free and gives around 1.8× without touching anything. If you buy Llama 3, there is no MTP to be had; use EAGLE-3.&lt;/li>
&lt;/ol>
&lt;p>There are also two related techniques that deserve a brief mention, both without a draft model: &lt;strong>Lookahead decoding&lt;/strong> (Fu et al. 2024), which formulates decoding as Jacobi iteration and extracts n-grams from the trajectory; and &lt;strong>REST&lt;/strong> (He et al. 2024), which keeps an n-gram datastore and proposes drafts by longest-prefix match against the last generated tokens. Both give 1.5-2× with no extra VRAM. Useful when you have no trained draft model and do not want to maintain one.&lt;/p>
&lt;h2 id="real-implementations-in-may-2026">Real implementations in May 2026&lt;/h2>
&lt;ul>
&lt;li>&lt;strong>vLLM v0.16+&lt;/strong>: unified support for EAGLE-1/2/3, P-EAGLE, Medusa, native MTP (DeepSeek-V3 and variants), n-gram/suffix decoding without a draft, an arbitrary draft model and MLP speculators. The canonical flag is &lt;code>--speculative-config '{&amp;quot;method&amp;quot;:&amp;quot;eagle3&amp;quot;, &amp;quot;model&amp;quot;:&amp;quot;...&amp;quot;, &amp;quot;num_speculative_tokens&amp;quot;: 5}'&lt;/code>.&lt;/li>
&lt;li>&lt;strong>SGLang&lt;/strong>: native EAGLE-3 support with &lt;code>--speculative-algorithm EAGLE3&lt;/code>. For DeepSeek-V3 it uses MTP through an EAGLE adapter. It has its own draft training framework (SpecForge).&lt;/li>
&lt;li>&lt;strong>TensorRT-LLM&lt;/strong>: Medusa, EAGLE (a simplified variant without a tree), ReDrafter, Lookahead. They report around 2.2× with EAGLE.&lt;/li>
&lt;li>&lt;strong>llama.cpp&lt;/strong>: basic speculative only with a draft model (&lt;code>--model-draft&lt;/code>). No native EAGLE/Medusa/MTP as far as I have verified. Typical speedup 1.5-2.5× single-user.&lt;/li>
&lt;/ul>
&lt;h2 id="when-speculative-does-not-help">When speculative does NOT help&lt;/h2>
&lt;p>The technique has three important blind spots:&lt;/p>
&lt;p>&lt;strong>Large batch.&lt;/strong> The GPU moves from memory-bound (decode at low concurrency) to compute-bound (decode at high concurrency). In the compute-bound regime the &amp;ldquo;almost free&amp;rdquo; target forward passes stop being free: γ tokens start costing γ times more instead of nearly 1. The typical crossover is at &lt;strong>batch 16-32 for dense models&lt;/strong>; for MoE with few active parameters per token the crossover happens later. At load peaks, speculative can worsen aggregate throughput per GPU.&lt;/p>
&lt;p>&lt;strong>Prefill / TTFT.&lt;/strong> Speculative decoding does not touch prefill. The phase that processes the whole prompt stays identical. TTFT does not improve and can get marginally worse because of the draft setup. If the SLA is TTFT-bound (assistants with short outputs, search, RAG with brief answers), this is not the right tool.&lt;/p>
&lt;p>&lt;strong>Short outputs.&lt;/strong> If the model generates 10-20 tokens and the answer is over, the fixed setup overhead is not amortised. Speculative shines with long outputs (300+ tokens): generative assistants, extended code completion, drafting.&lt;/p>
&lt;h2 id="operational-pitfalls">Operational pitfalls&lt;/h2>
&lt;p>&lt;strong>Extra VRAM for the draft.&lt;/strong> In vanilla SD, loading a complete draft plus its KV cache is expensive. Llama 3 8B in BF16 as the draft for a 70B is about 16 GB of weights plus KV cache. On an H100 80 GB with the target already nearly filling it, that can force you to shrink the target&amp;rsquo;s KV cache and lower maximum concurrency. EAGLE solves this: the draft is one transformer block (~1 GB for a 70B) and reuses the target&amp;rsquo;s features.&lt;/p>
&lt;p>&lt;strong>Draft quantisation.&lt;/strong> Quantising the draft to INT4 with GPTQ degrades α substantially (errors in the logits accumulate in the comparison against &lt;code>p&lt;/code>). AWQ holds up better but also lowers α. &lt;strong>Common practice in 2026: target in FP8 or INT4, draft in FP16/BF16&lt;/strong>. The draft is small enough that the VRAM saved by quantising it does not compensate for the drop in α and therefore in speedup. The detail of each format is in &lt;a href="https://blog.lo0.es/en/posts/quantization-llm-inference-fp8-int4-gguf/">Quantisation&lt;/a>.&lt;/p>
&lt;p>&lt;strong>Interaction with continuous batching.&lt;/strong> It does not break it, but it creates &lt;em>nested raggedness&lt;/em>: each request in the batch can accept a different number of tokens on each iteration. vLLM&amp;rsquo;s PagedAttention absorbs it, but the scheduler loses efficiency. At low QPS (conversational assistant, low simultaneous concurrency) the combination is excellent. At high QPS there is real tension, and work such as &lt;em>Goodput-optimized speculative decoding&lt;/em> (Liu et al., 2024) optimises γ dynamically according to the state of the batch.&lt;/p>
&lt;p>&lt;strong>Sampling temperature.&lt;/strong> α falls with high temperatures. At T = 1.0 with creative outputs (free-form writing), α can drop 10-15 points relative to T = 0 with the same model-draft pair. The speedup scales accordingly.&lt;/p>
&lt;h2 id="implications-on-on-premise-hardware">Implications on on-premise hardware&lt;/h2>
&lt;h3 id="on-an-rtx-4090-24-gb-ada-lovelace">On an RTX 4090 (24 GB, Ada Lovelace)&lt;/h3>
&lt;p>The classic use is &lt;strong>vanilla SD with two quantised models&lt;/strong>: for instance, Llama 3 70B AWQ-INT4 as target (~35 GB → needs TP=2 across two 4090s) and Llama 3 8B AWQ-INT4 as draft. In practice, the most realistic single-card case is &lt;strong>Llama 3 8B target + a 1B model as draft&lt;/strong> or &lt;strong>Qwen 3 14B target + Qwen 3 0.5B as draft&lt;/strong>: they fit comfortably and give 1.8-2.5× on conversational tasks. For EAGLE on consumer cards, the official drafts for popular families (Llama 3, Qwen 3) are published on Hugging Face and take up 0.5-2 GB extra.&lt;/p>
&lt;h3 id="on-a-generic-4h100-sxm-cluster-320-gb-nvlink-native-fp8">On a generic 4×H100 SXM cluster (320 GB, NVLink, native FP8)&lt;/h3>
&lt;p>Here EAGLE-3 shines:&lt;/p>
&lt;ul>
&lt;li>&lt;strong>Llama 3 70B FP8 + EAGLE-3 draft in FP16&lt;/strong>: the draft takes about 0.7 GB; the target about 70 GB with TP=2 (35 GB per GPU). The speedup observed in reproducible benchmarks is between 2.5× and 4× at batch 1-4, falling to almost break-even at batch 32.&lt;/li>
&lt;li>&lt;strong>DeepSeek-V3 671B FP8 + native MTP&lt;/strong>: the model ships with MTP trained in; there is nothing to add. The speedup is 1.5-1.8× with zero extra VRAM. It is the most efficient option operationally: zero additional pieces.&lt;/li>
&lt;li>&lt;strong>Combining with disaggregated serving&lt;/strong>: as &lt;a href="https://blog.lo0.es/en/posts/disaggregated-serving-prefill-decode-specialised-pods/">Disaggregated serving&lt;/a> explains, prefill and decode can live in separate pods. Speculative applies only in the decode pods, which fits the separation perfectly (prefill is compute-bound and would not benefit).&lt;/li>
&lt;/ul>
&lt;p>The rule of thumb on an H100 cluster in May 2026: &lt;strong>if the model is DeepSeek-V3 / V4 → native MTP, nothing else; if it is Llama 3 / Qwen 3 → EAGLE-3 with the official draft; if it is exotic → vanilla SD with a draft from the same family&lt;/strong>.&lt;/p>
&lt;h2 id="what-we-have-not-covered">What we have not covered&lt;/h2>
&lt;ul>
&lt;li>&lt;strong>Training custom EAGLE drafts&lt;/strong> with SpecForge: how to collect target trajectories and train the draft on-policy.&lt;/li>
&lt;li>&lt;strong>Speculative Prefill&lt;/strong> (arXiv:2502.02789): a variant for accelerating TTFT, a different mechanism from the decode one described here.&lt;/li>
&lt;li>&lt;strong>Tree attention in detail&lt;/strong>: how Medusa and EAGLE-2 verify several candidates at once with specific attention masks.&lt;/li>
&lt;li>&lt;strong>MoE + speculative&lt;/strong>: the combination has non-trivial interactions with the expert router. Low activated params keep the memory-bound regime in place even at high batch, which changes the rules.&lt;/li>
&lt;/ul>
&lt;h2 id="see-also">See also&lt;/h2>
&lt;ul>
&lt;li>&lt;a href="https://blog.lo0.es/en/posts/kv-cache-working-memory-llm-inference/">KV cache: the working memory that holds up LLM inference&lt;/a> — speculative would not exist without the memory-bound nature of decode, which is a direct consequence of the KV cache; that post gives the framework.&lt;/li>
&lt;li>&lt;a href="https://blog.lo0.es/en/posts/pagedattention-vllm-block-manager/">PagedAttention deep dive&lt;/a> — vLLM&amp;rsquo;s scheduler has to manage the nested raggedness of the γ tokens accepted per request; PagedAttention is what makes that possible without reserving fixed blocks per session.&lt;/li>
&lt;li>&lt;a href="https://blog.lo0.es/en/posts/quantization-llm-inference-fp8-int4-gguf/">Quantisation for LLM inference&lt;/a> — a lever orthogonal to and multiplicative with speculative; that post explains why the draft is usually left in BF16 even when the target is in FP8/INT4.&lt;/li>
&lt;li>&lt;a href="https://blog.lo0.es/en/posts/disaggregated-serving-prefill-decode-specialised-pods/">Disaggregated serving: prefill and decode in specialised pods&lt;/a> — speculative applies only in decode; disaggregation makes that specialisation easy without touching prefill.&lt;/li>
&lt;li>&lt;a href="https://blog.lo0.es/en/posts/moe-inference-fundamentals-expert-parallel-deepseek-economics/">MoE inference: the call centre with 256 specialists&lt;/a> — the persistent memory-bound regime of MoE means speculative gains more on MoE than on dense at medium batch. MTP in DeepSeek-V3 is speculative decoding native to the model (no external draft) with acceptance around 85-90 % on the second token.&lt;/li>
&lt;li>&lt;a href="https://blog.lo0.es/en/posts/continuous-batching-orca-vllm-chunked-prefill-goodput/">Continuous batching&lt;/a> — the scheduler where speculative lives. Speculative breaks the symmetry of the batch (each request accepts between 1 and γ+1 tokens per iteration); at high QPS it can reduce goodput if the draft consumes slots from the decode pool.&lt;/li>
&lt;li>&lt;a href="https://blog.lo0.es/en/posts/six-stage-llmops-pipeline-architecture-and-deep-dive/">The six-stage LLMOps pipeline&lt;/a> — the master map where Deploy is stage 4.&lt;/li>
&lt;li>&lt;a href="https://blog.lo0.es/en/posts/mixed-nvidia-intel-environments-llm-inference/">Mixed NVIDIA + Intel environments&lt;/a> — the &amp;ldquo;drafter near edge on an Intel NUC + target on a central H100&amp;rdquo; pattern as the canonical case of speculative decoding deployed heterogeneously.&lt;/li>
&lt;li>&lt;a href="https://blog.lo0.es/en/posts/optimising-decode-vllm-squeezing-every-token-small-hardware/">Optimising decode in vLLM&lt;/a> — the concrete vLLM parameters for turning on speculative decoding in production (&lt;code>--speculative-model&lt;/code>, &lt;code>--num-speculative-tokens&lt;/code>) with reference configs for RTX 4090 and L40.&lt;/li>
&lt;li>&lt;a href="https://blog.lo0.es/en/posts/knowledge-distillation-teaching-small-model-think-like-big-one/">Knowledge distillation&lt;/a> — the best drafters are not small versions of the base model: they are students distilled specifically to predict the verifier&amp;rsquo;s distribution; distillation explains why EAGLE beats a plain generic 0.5B.&lt;/li>
&lt;li>&lt;a href="https://blog.lo0.es/en/posts/llm-pruning-cutting-without-amputating/">Pruning LLM models&lt;/a> — an alternative to the distilled drafter: a pruned draft model (layer dropping from the base) as a cheap approximation of the verifier; it works worse than EAGLE but needs no extra training.&lt;/li>
&lt;li>&lt;a href="https://blog.lo0.es/en/posts/self-speculative-decoding-model-gets-ahead-of-itself/">Self-speculative decoding: the model that gets ahead of itself&lt;/a> — the variant without a separate draft: the model itself run in early-exit acts as the draft and is verified with the full forward pass, zero extra VRAM. It is the form of speculative that fits small models and on-device deployment.&lt;/li>
&lt;/ul>
&lt;h2 id="references">References&lt;/h2>
&lt;ul>
&lt;li>Leviathan, Y., Kalman, M., Matias, Y. &lt;em>Fast Inference from Transformers via Speculative Decoding&lt;/em>. ICML 2023. &lt;a href="https://arxiv.org/abs/2211.17192">https://arxiv.org/abs/2211.17192&lt;/a>&lt;/li>
&lt;li>Chen, C., Borgeaud, S., Irving, G., Lespiau, J.B., Sifre, L., Jumper, J. &lt;em>Accelerating Large Language Model Decoding with Speculative Sampling&lt;/em>. DeepMind 2023. &lt;a href="https://arxiv.org/abs/2302.01318">https://arxiv.org/abs/2302.01318&lt;/a>&lt;/li>
&lt;li>Cai, T., Li, Y., Geng, Z., Peng, H., Lee, J.D., Chen, D., Dao, T. &lt;em>Medusa: Simple LLM Inference Acceleration with Multiple Decoding Heads&lt;/em>. ICML 2024. &lt;a href="https://arxiv.org/abs/2401.10774">https://arxiv.org/abs/2401.10774&lt;/a>&lt;/li>
&lt;li>Li, Y., Wei, F., Zhang, C., Zhang, H. &lt;em>EAGLE: Speculative Sampling Requires Rethinking Feature Uncertainty&lt;/em>. ICML 2024. &lt;a href="https://arxiv.org/abs/2401.15077">https://arxiv.org/abs/2401.15077&lt;/a>&lt;/li>
&lt;li>Li, Y., Wei, F., Zhang, C., Zhang, H. &lt;em>EAGLE-2: Faster Inference of Language Models with Dynamic Draft Trees&lt;/em>. EMNLP 2024. &lt;a href="https://arxiv.org/abs/2406.16858">https://arxiv.org/abs/2406.16858&lt;/a>&lt;/li>
&lt;li>Li, Y., Wei, F., Zhang, C., Zhang, H. &lt;em>EAGLE-3: Scaling up Inference Acceleration of Large Language Models via Training-Time Test&lt;/em>. NeurIPS 2025. &lt;a href="https://arxiv.org/abs/2503.01840">https://arxiv.org/abs/2503.01840&lt;/a>&lt;/li>
&lt;li>DeepSeek-AI. &lt;em>DeepSeek-V3 Technical Report&lt;/em> — Multi-Token Prediction. &lt;a href="https://arxiv.org/abs/2412.19437">https://arxiv.org/abs/2412.19437&lt;/a>&lt;/li>
&lt;li>Fu, Y., Bailis, P., Stoica, I., Zhang, H. &lt;em>Break the Sequential Dependency of LLM Inference Using Lookahead Decoding&lt;/em>. ICML 2024. &lt;a href="https://arxiv.org/abs/2402.02057">https://arxiv.org/abs/2402.02057&lt;/a>&lt;/li>
&lt;li>He, Z., Zhong, Z., Cai, T., Lee, J.D., He, D. &lt;em>REST: Retrieval-Based Speculative Decoding&lt;/em>. NAACL 2024. &lt;a href="https://arxiv.org/abs/2311.08252">https://arxiv.org/abs/2311.08252&lt;/a>&lt;/li>
&lt;li>vLLM speculative decoding docs: &lt;a href="https://docs.vllm.ai/en/latest/features/speculative_decoding/">https://docs.vllm.ai/en/latest/features/speculative_decoding/&lt;/a>&lt;/li>
&lt;li>SGLang speculative decoding docs: &lt;a href="https://docs.sglang.ai/advanced_features/speculative_decoding.html">https://docs.sglang.ai/advanced_features/speculative_decoding.html&lt;/a>&lt;/li>
&lt;li>TensorRT-LLM speculative sampling: &lt;a href="https://nvidia.github.io/TensorRT-LLM/advanced/speculative-decoding.html">https://nvidia.github.io/TensorRT-LLM/advanced/speculative-decoding.html&lt;/a>&lt;/li>
&lt;li>vLLM blog &lt;em>Speculative Decoding in vLLM&lt;/em> (oct 2024): &lt;a href="https://blog.vllm.ai/2024/10/17/spec-decode.html">https://blog.vllm.ai/2024/10/17/spec-decode.html&lt;/a>&lt;/li>
&lt;li>vLLM blog &lt;em>P-EAGLE: Parallel Speculative Decoding in vLLM&lt;/em> (mar 2026): &lt;a href="https://vllm.ai/blog/2026-03-13-p-eagle">https://vllm.ai/blog/2026-03-13-p-eagle&lt;/a>&lt;/li>
&lt;li>Official EAGLE repo: &lt;a href="https://github.com/SafeAILab/EAGLE">https://github.com/SafeAILab/EAGLE&lt;/a>&lt;/li>
&lt;li>Official Medusa repo: &lt;a href="https://github.com/FasterDecoding/Medusa">https://github.com/FasterDecoding/Medusa&lt;/a>&lt;/li>
&lt;/ul></description></item></channel></rss>