<?xml version="1.0" encoding="utf-8" standalone="yes"?><rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom"><channel><title>Vllm on lo0 — Tech Blog</title><link>https://blog.lo0.es/en/tags/vllm/</link><description>Recent content in Vllm on lo0 — Tech Blog</description><generator>Hugo -- gohugo.io</generator><language>en</language><lastBuildDate>Sat, 12 Sep 2026 06:00:00 +0200</lastBuildDate><atom:link href="https://blog.lo0.es/en/tags/vllm/index.xml" rel="self" type="application/rss+xml"/><item><title>Sizing for agents: 275 input tokens for every output token, and why the bottleneck is still decode</title><link>https://blog.lo0.es/en/posts/sizing-agents-decode-bottleneck/</link><pubDate>Sat, 12 Sep 2026 06:00:00 +0200</pubDate><guid>https://blog.lo0.es/en/posts/sizing-agents-decode-bottleneck/</guid><description>&lt;blockquote>
&lt;p>Seventh article in the operational track of the control layer. &lt;a href="https://blog.lo0.es/en/posts/humans-agents-same-gateway-priority-traffic-separation/">The fourth&lt;/a> dealt with the coexistence of human traffic and agentic traffic on a fixed fleet. This one deals with the prior question: how big the fleet has to be, and how big the gateway in front of it. Verified against LiteLLM 1.102.0 (commit of 11 September 2026) and vLLM 0.29.0, with load data published between May and September 2026.&lt;/p>
&lt;/blockquote>
&lt;h2 id="tldr">TL;DR&lt;/h2>
&lt;p>Until this year, sizing for agents meant extrapolating a chat profile and adding margin. In 2026 the first production-scale characterisations were published and the profile turned out to be something else. These are the seven conclusions.&lt;/p>
&lt;p>&lt;strong>The median prompt of an agentic call is 68,000 tokens, of which 63,000 arrive cached.&lt;/strong> The measurement covers 13.5 million sessions, 3.2 million users, 760.5 million model calls and 44.9 trillion prompt tokens in one week of June 2026. The median input-to-output ratio exceeds 275 to 1. Any calculation done on a profile of 800 prompt tokens and 250 output tokens describes a different system.&lt;/p>
&lt;p>&lt;strong>The load is prefill-dominant when counting tokens and decode-dominant when counting time.&lt;/strong> The genuinely new tokens that have to be prefilled on each turn are only between 1.5 and 7.3 times the output, not 275. With the prefix cache working, the split of execution time runs from 91 % to 98.6 % in decode. You size by memory bandwidth and by KV cache capacity, not by prefill FLOPs.&lt;/p>
&lt;p>&lt;strong>The sizing risk is not the average, it is the collapse of the prefix cache.&lt;/strong> The median hit rate within a turn is 98 %, it falls to 55 % at the boundary between turns and to 8 % when the client changes model. With dynamically retrieved context, prefix reuse drops to 1 %. One miss turns 142,000 prefix tokens into real prefill. Session affinity per replica stops being an optimisation and becomes a capacity requirement.&lt;/p>
&lt;p>&lt;strong>The intra-turn concurrency measured in production is 1.15 at the median and 1.4 at the 90th percentile.&lt;/strong> 93 % of tool batches contain a single invocation. The image of the agent opening twenty calls in parallel does not appear in the data. The real multiplier comes from the duty cycle and from the fact that 87 % of calls are started by the agent, not by the person.&lt;/p>
&lt;p>&lt;strong>The gateway is sized per worker, and each worker is a universe.&lt;/strong> One vCPU and 4 GiB per worker, with requests equal to limits. The limit counters, the caches and the cooldowns live in the worker&amp;rsquo;s memory unless there is Redis, so a limit of 100 requests per minute with four workers and three replicas allows 1,200. The Prisma pool is also per process: the number of connections open against Postgres is workers times &lt;code>connection_limit&lt;/code>.&lt;/p>
&lt;p>&lt;strong>The CPU cost that grows with agentic load is the token counter.&lt;/strong> It serialises the entire tool catalogue on every request and tokenises it. The project itself measured 46, 53 and 100 ms for prompts of 50K, 75K and 100K tokens, and the operation passes through a concurrency limiter of four per worker. With agents sending long catalogues and six-figure contexts, that limit is the ceiling before the CPU is.&lt;/p>
&lt;p>&lt;strong>Autoscaling by memory does not work on this piece and the startup probe takes five minutes.&lt;/strong> The official chart deliberately leaves memory without a target because Prisma&amp;rsquo;s query engine never returns the maximum reached. That leaves CPU at 60 %, with a &lt;code>startupProbe&lt;/code> of 300 seconds that makes any new replica arrive late. For agents there are two better metrics, and they are in &lt;code>/metrics&lt;/code>.&lt;/p>
&lt;h2 id="you-are-here-the-arithmetic-that-comes-before-buying">You are here: the arithmetic that comes before buying&lt;/h2>
&lt;p>&lt;a href="https://blog.lo0.es/en/posts/capacity-planning-on-premise-llm-inference/">Classic capacity planning&lt;/a> starts from a chat SLO and crosses two budgets, the VRAM one and the time one. That method is still correct. What changes with agents are the inputs: the prompt length, the number of calls per unit of human work, the split between prefill and decode, and the very definition of an SLO. This article replaces those inputs with measured figures and drags the result through to two numbers: gateway replicas and GPUs.&lt;/p>
&lt;p>Out of scope is the splitting of traffic once it is built, which was already covered by &lt;a href="https://blog.lo0.es/en/posts/humans-agents-same-gateway-priority-traffic-separation/">the coexistence of humans and agents&lt;/a> and &lt;a href="https://blog.lo0.es/en/posts/prefix-routing-what-litellm-does-not-do/">prefix routing&lt;/a>.&lt;/p>
&lt;h2 id="the-analogy-the-repair-shop-and-the-front-desk">The analogy: the repair shop and the front desk&lt;/h2>
&lt;p>An appliance repair shop sizes its staff by counting faults, not phone calls. The difference matters because the person who brings in the washing machine occupies the front desk for three minutes, while the technician who takes it apart goes back to consult the machine&amp;rsquo;s data sheet fourteen times over the afternoon. If the shop manager sizes by visits to the front desk, they end up short of technicians and overstaffed at reception.&lt;/p>
&lt;p>The inference platform has the same asymmetry. The person sends one turn. The agent sends fifteen calls for that turn, and each one drags along the complete file of everything done so far. The front desk is the gateway and the technicians are the GPUs, and the usual mistake is to size both with the same counter.&lt;/p>
&lt;p>There is a second detail the analogy captures well. The technician does not read the whole data sheet each time: they have it open on the bench. That is the prefix cache. The day somebody moves them to a different bench between consultations, they have to fetch it from the archive again, and the work multiplies without the number of faults having increased.&lt;/p>
&lt;h2 id="part-1-the-real-load-profile">Part 1. The real load profile&lt;/h2>
&lt;p>For two years, any capacity calculation for agents was done on assumptions. In 2026 three pieces of work appeared that measure the matter, and it is worth reading them together because they measure different scaffolds and their figures are not averageable.&lt;/p>
&lt;h3 id="the-three-sources">The three sources&lt;/h3>
&lt;p>The characterisation at scale was published by a research team working on GitHub Copilot production telemetry (&lt;a href="https://arxiv.org/html/2608.00101v1">arXiv 2608.00101&lt;/a>, 30 July 2026): 13.5 million sessions, 3.2 million users, 95.1 million user turns, 760.5 million model calls and 44.9 trillion prompt tokens, in one week of June 2026. It is the largest sample published to date.&lt;/p>
&lt;p>The characterisation by benchmark suites comes from an architecture team (&lt;a href="https://arxiv.org/html/2605.26297">arXiv 2605.26297&lt;/a>, 25 May 2026), which instruments five of them (ADE-Bench, DABStep, GAIA, SWE-bench Pro and Terminal-Bench 2.0) and measures the internal split of time.&lt;/p>
&lt;p>The production measurement on a different scaffold was published by the vLLM project itself together with AgentX (&lt;a href="https://vllm.ai/blog/2026-09-08-vllm-agentx">vLLM blog&lt;/a>, 8 September 2026).&lt;/p>
&lt;p>The three disagree on turns per session: 15 calls at Copilot&amp;rsquo;s median, 43 turns in AgentX, 62.4 in SWE-bench Pro with Qwen. It is not a contradiction, they are scaffolds with different loops. The sizing lesson is that the number of calls per unit of human work depends on the client that connects, and that is why you have to measure it at home before fixing the size.&lt;/p>
&lt;h3 id="the-figures-you-can-actually-use">The figures you can actually use&lt;/h3>
&lt;table>
&lt;thead>
&lt;tr>
&lt;th>Quantity&lt;/th>
&lt;th>Measured value&lt;/th>
&lt;th>Source&lt;/th>
&lt;/tr>
&lt;/thead>
&lt;tbody>
&lt;tr>
&lt;td>Median prompt per call&lt;/td>
&lt;td>68K tokens, 63K of them cached&lt;/td>
&lt;td>2608.00101&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>Input:output ratio (median)&lt;/td>
&lt;td>above 275:1&lt;/td>
&lt;td>2608.00101&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>Median session&lt;/td>
&lt;td>3 user turns, 15 calls, 4.2 min&lt;/td>
&lt;td>2608.00101&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>Mean session&lt;/td>
&lt;td>6.1 turns, 40.6 calls, 62.6 min&lt;/td>
&lt;td>2608.00101&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>Calls per turn (median)&lt;/td>
&lt;td>4.5&lt;/td>
&lt;td>2608.00101&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>Calls started by the agent&lt;/td>
&lt;td>87 %&lt;/td>
&lt;td>2608.00101&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>Range across user profiles&lt;/td>
&lt;td>50× (23K to 1.1M tokens per turn)&lt;/td>
&lt;td>2608.00101&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>Median input context&lt;/td>
&lt;td>142K tokens; output 444 tokens per turn&lt;/td>
&lt;td>AgentX&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>Accumulated context in SWE-bench Pro&lt;/td>
&lt;td>mean 68.7 to 80.1K, maximum 146 to 166K&lt;/td>
&lt;td>2605.26297&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>Reasoning tokens over the output&lt;/td>
&lt;td>29.0 % to 67.6 %&lt;/td>
&lt;td>2605.26297&lt;/td>
&lt;/tr>
&lt;/tbody>
&lt;/table>
&lt;p>Two operational observations come out of that and appear in no sizing guide.&lt;/p>
&lt;p>The first: &lt;strong>agents that fail accumulate up to 1.8 times more average context than those that finish well&lt;/strong> (2605.26297). The cost does not correlate with success, it correlates with difficulty and with getting stuck. A sizing done on the task that goes well falls short.&lt;/p>
&lt;p>The second: the range across user profiles is 50 to 1. A user who only converses spends 23,000 tokens per turn; one who leaves the agent in a long loop spends 1.1 million. Segmenting by usage profile pays off more than any difficulty-based routing, because the variance is in the people, not in the requests.&lt;/p>
&lt;h3 id="what-to-measure-before-calculating">What to measure before calculating&lt;/h3>
&lt;p>Three of your own figures, taken from a week of real traffic, are worth more than any table in this article:&lt;/p>
&lt;ol>
&lt;li>Model calls per hour of human work, by user profile.&lt;/li>
&lt;li>Prompt length distribution (median and 95th percentile), not the mean.&lt;/li>
&lt;li>Aggregate prefix cache hit rate, with the time series, not the single number.&lt;/li>
&lt;/ol>
&lt;p>All three come from what the gateway already stores. In &lt;code>LiteLLM_SpendLogs&lt;/code> you have the tokens per request and the key and team identifiers; in &lt;code>proxy_server_request&lt;/code> you have the &lt;code>tools&lt;/code> block of each call; and in vLLM&amp;rsquo;s &lt;code>/metrics&lt;/code> you have &lt;code>vllm:prefix_cache_queries&lt;/code> and &lt;code>vllm:prefix_cache_hits&lt;/code>.&lt;/p>
&lt;h2 id="part-2-prefill-decode-and-the-cache-trap">Part 2. Prefill, decode and the cache trap&lt;/h2>
&lt;p>Here is the finding that corrects almost everyone&amp;rsquo;s intuition, including mine when I started this article.&lt;/p>
&lt;p>Counting tokens, agentic load is overwhelmingly input: 275 to 1. The apparently obvious conclusion would be to size by prefill capacity, that is by FLOPs. It is false.&lt;/p>
&lt;p>What has to be prefilled on each turn is not the whole prompt, but what was added since the previous turn. That addition, measured, sits between 1.5 and 7.3 times the output length (2605.26297). The rest is served by the prefix cache. And the real split of the model&amp;rsquo;s execution time comes out like this: &lt;strong>between 91.0 % and 98.6 % in decode, between 1.4 % and 9.0 % in prefill&lt;/strong> (2605.26297).&lt;/p>
&lt;p>The hit rate that sustains that split is high and has been measured in three places: theoretical from 87.9 % to 99.3 % and empirical from 84.6 % to 99.5 % (2605.26297); above 96 % in production (AgentX); and a median of 98 % within a turn in Copilot (2608.00101).&lt;/p>
&lt;h3 id="where-it-falls-over">Where it falls over&lt;/h3>
&lt;p>The same Copilot work measures the degradation, and it is the most actionable figure in the article:&lt;/p>
&lt;ul>
&lt;li>&lt;strong>98 %&lt;/strong> median hit rate within a turn. The detail matters: the first call of a turn hits around 45 %, the second rises to 86 % and from the third onwards it stabilises between 92 % and 94 %.&lt;/li>
&lt;li>&lt;strong>55 %&lt;/strong> at the boundary between turns, with idle gaps of 4.1 minutes at the median.&lt;/li>
&lt;li>&lt;strong>8 %&lt;/strong> when the client changes model.&lt;/li>
&lt;/ul>
&lt;p>And there is a fourth, worse regime: with dynamically retrieved context, that is when the scaffold injects search or RAG results that change on every iteration, prefix reuse falls to 1 % or less (&lt;a href="https://arxiv.org/html/2608.15127">arXiv 2608.15127&lt;/a>, 15 August 2026).&lt;/p>
&lt;p>A cache miss with a context of 142,000 tokens turns a prefill of a few thousand tokens into a six-figure one. The work multiplies by a factor of between 20 and 60 without the number of requests having changed. That is the difference between a fleet that meets its targets and one that seizes up mid-afternoon.&lt;/p>
&lt;p>Two sizing rules come from that:&lt;/p>
&lt;ol>
&lt;li>&lt;strong>Without session affinity towards the same replica, the calculated sizing is worthless.&lt;/strong> LiteLLM&amp;rsquo;s concrete mechanism is &lt;code>session_affinity&lt;/code>, which is not active by default and which was already covered &lt;a href="https://blog.lo0.es/en/posts/prefix-routing-what-litellm-does-not-do/">in the prefix routing article&lt;/a>. Without it, the calculation has to be redone assuming a prefill regime, which is between 20 and 60 times more expensive.&lt;/li>
&lt;li>&lt;strong>Changing model halfway through a session costs the entire cache.&lt;/strong> Any difficulty-based routing policy that moves a live conversation from one model group to another pays an 8 % hit rate on the next call. If you want to route by difficulty, do it at the start of the session.&lt;/li>
&lt;/ol>
&lt;h3 id="co-batching-which-is-the-other-effect-nobody-measures">Co-batching, which is the other effect nobody measures&lt;/h3>
&lt;p>Mixing a short request into the same batch as long-context requests degrades the short one&amp;rsquo;s inter-token latency. The measured figure: &lt;strong>one long request in the batch raises time per token by 38.3 %; two raise it by 79.7 %&lt;/strong> (2608.15127). It is the technical reason why separating human and agent pools improves the human experience more than the capacity split suggests, and it is also the reason not to raise &lt;code>--max-num-seqs&lt;/code> above what the KV budget allows.&lt;/p>
&lt;h2 id="part-3-the-kv-budget-with-the-numbers-of-the-agentic-case">Part 3. The KV budget, with the numbers of the agentic case&lt;/h2>
&lt;p>The formula does not change with respect to &lt;a href="https://blog.lo0.es/en/posts/capacity-planning-on-premise-llm-inference/">classic capacity planning&lt;/a>, but the inputs do, and the result is surprising:&lt;/p>
$$\text{KV}_{\text{bytes/token}} = 2 \times n_{\text{layers}} \times n_{\text{kv heads}} \times d_{\text{head}} \times \text{bytes}_{\text{elem}}$$
&lt;p>For a 70B-class geometry (80 layers, 8 KV heads with grouped attention, head dimension 128, 2-byte elements) that gives 0.3125 MiB per token. With the median agentic context of 142,000 tokens:&lt;/p>
$$142{,}000 \times 0.3125\ \text{MiB} \approx 43.3\ \text{GiB per sequence}$$
&lt;p>For a 30B-class geometry (48 layers, everything else the same) that gives 0.1875 MiB per token, that is around 26.0 GiB per sequence.&lt;/p>
&lt;p>It is worth pausing on what that means. An 80 GB H100 with the weights of a 70B model in FP8 already taking up space does not have 43 GiB free for a single sequence. The conclusion is harsh and has to be written down: &lt;strong>serving real 70B-class agentic context demands tensor parallelism of at least 2 and a quantised KV cache&lt;/strong>. And the number of simultaneous sequences that fit, which is what sets &lt;code>--max-num-seqs&lt;/code>, comes from dividing the available KV by that figure, not from a chat heuristic.&lt;/p>
$$\text{sequences} \approx \frac{\text{VRAM}_{\text{KV available}}}{\text{KV}_{\text{bytes/token}} \times \text{max context}}$$
&lt;p>With FP8 in the KV, the divisor halves. It is the cheapest capacity lever there is in this load profile, and its cost in quality is validated with evals, not assumed.&lt;/p>
&lt;h2 id="part-4-from-people-to-concurrent-requests">Part 4. From people to concurrent requests&lt;/h2>
&lt;p>This is where most calculations overshoot, because it is assumed that an agent equals many simultaneous requests. The data says otherwise.&lt;/p>
&lt;p>The concurrency of calls within a turn, measured in production, is &lt;strong>1.15 at the median and 1.4 at the 90th percentile&lt;/strong>, and 93 % of tool batches contain a single invocation (2608.00101). Intra-turn parallelism is small. Subagents appear in 44 % of AgentX sessions, with a median of 4 deployments among those that use them, so there is a long tail, but it is not the usual regime.&lt;/p>
&lt;p>What does multiply is the duty cycle. The mean session lasts 62.6 minutes and contains 40.6 calls. At 444 output tokens and an interactivity of around 60 tokens per second, each call occupies around 7.4 seconds of decode, which gives between 5 and 7 active minutes out of those 62.6.&lt;/p>
$$C = D \times \rho \times A$$
&lt;p>where $D$ is the people working, $\rho$ the duty cycle and $A$ the amplification. With $\rho \approx 0.10$ to $0.12$ derived from the previous figures, twenty developers give between 2 and 2.4 concurrent sequences in the steady state. It is the peak, not the average, that has to be sized for.&lt;/p>
&lt;h3 id="the-amplification">The amplification&lt;/h3>
&lt;p>Three factors published separately:&lt;/p>
&lt;ul>
&lt;li>&lt;strong>Tool failures&lt;/strong>: 9 % of turns trigger them, with a compute amplification of 4 times (2608.00101).&lt;/li>
&lt;li>&lt;strong>Subagents&lt;/strong>: 44 % of sessions, median of 4 deployments (AgentX).&lt;/li>
&lt;li>&lt;strong>Internal variance&lt;/strong>: the same task from the same agent varies by up to 30 times in cost within a single trace (2608.15127).&lt;/li>
&lt;/ul>
&lt;p>The product of the first two gives $(1 + 0.09 \times 3) \times (1 + 0.44 \times 3) \approx 2.9$. Nobody publishes that product as a single figure, so it is flagged as my own derivation, but &lt;strong>an amplification factor of 3 is the reasonable design provision&lt;/strong> and it fits the measured variance.&lt;/p>
&lt;p>For the twenty developers in the example: between 6 and 7 concurrent design sequences, with 142K contexts. At 43.3 GiB of KV per sequence in the 70B class, that is around 300 GiB of KV cache, that is four H100s dedicated to KV alone with the weights on top. With KV in FP8, half. With a 30B-class model, a third.&lt;/p>
&lt;p>That calculation, done with the chat profile of 800 prompt tokens, would have given a single GPU. That is the distance between the two regimes.&lt;/p>
&lt;h2 id="part-5-the-gateway-which-also-has-to-be-sized">Part 5. The gateway, which also has to be sized&lt;/h2>
&lt;p>The proxy consumes no GPU, but it has its own arithmetic and it is the part that produces the most surprises in production, because almost all of its state is per process.&lt;/p>
&lt;h3 id="a-worker-is-a-universe">A worker is a universe&lt;/h3>
&lt;p>The default value of &lt;code>--num_workers&lt;/code> is &lt;strong>1&lt;/strong> (&lt;code>litellm/constants.py:57&lt;/code>). The usual startup is uvicorn directly with uvloop (&lt;code>proxy_cli.py:1477&lt;/code>), and there are alternatives with gunicorn, hypercorn and granian.&lt;/p>
&lt;p>What lives in each worker&amp;rsquo;s memory, and therefore multiplies:&lt;/p>
&lt;ul>
&lt;li>The in-memory cache, limited to &lt;strong>200 elements&lt;/strong> with a TTL of 600 seconds (&lt;code>litellm/caching/in_memory_cache.py:27&lt;/code>). With many deployments, the cooldowns and the counters compete for that same space and get evicted before they expire.&lt;/li>
&lt;li>The key authentication cache, with a TTL of &lt;strong>60 seconds&lt;/strong> (&lt;code>proxy_server.py:1650&lt;/code>), adjustable with &lt;code>general_settings.user_api_key_cache_ttl&lt;/code>.&lt;/li>
&lt;li>The limit and budget counters when there is no Redis (&lt;code>parallel_request_limiter_v3.py&lt;/code>, &lt;code>local_only=True&lt;/code> paths).&lt;/li>
&lt;li>The router cooldowns, with &lt;code>DEFAULT_COOLDOWN_TIME_SECONDS = 5&lt;/code> (&lt;code>constants.py:75&lt;/code>).&lt;/li>
&lt;li>A periodic task scheduler of its own per worker (&lt;code>proxy_server.py:9674&lt;/code>), coordinated between pods with a lock in Redis.&lt;/li>
&lt;/ul>
&lt;p>The practical consequence: &lt;strong>a limit of 100 requests per minute with four workers and three replicas allows up to 1,200 without Redis&lt;/strong>. With Redis, the v3 limiter registers seven Lua scripts at startup (&lt;code>parallel_request_limiter_v3.py:608&lt;/code>) and the limits become global.&lt;/p>
&lt;p>One detail of the v3 limiter that matters with agentic load: &lt;code>LITELLM_TPM_TOKEN_RESERVATION_ENABLED&lt;/code> comes enabled by default and reserves estimated tokens before the call, which adds a round trip to Redis per request. And there is an &lt;code>asyncio.Lock&lt;/code> per instance that serialises the atomic batch checks (&lt;code>:665&lt;/code>), documented by the code itself as a 99th-percentile risk under contention.&lt;/p>
&lt;p>If Redis goes down, requests do not fail: there is a circuit breaker with a threshold of 5 failures and a recovery of 60 seconds (&lt;code>constants.py:456&lt;/code>), and the proxy degrades to local memory. What is lost silently is the globality of the limits.&lt;/p>
&lt;h3 id="postgres">Postgres&lt;/h3>
&lt;p>The pool is also per process. The default values are &lt;code>database_connection_pool_limit = 10&lt;/code> and &lt;code>database_connection_pool_timeout = 60&lt;/code> (&lt;code>proxy_cli.py:62&lt;/code>), injected as parameters of the Prisma URL. A pod with four workers opens forty connections. Three pods, one hundred and twenty. It is the fast route to exhausting Postgres&amp;rsquo;s &lt;code>max_connections&lt;/code> without having served any traffic, and the reason the official chart offers PgBouncer inside the pod itself with &lt;code>maxDbConnections: 20&lt;/code> and &lt;code>maxClientConn: 1000&lt;/code>.&lt;/p>
&lt;p>On the request path there is only the authentication, which is cached for 60 seconds, and the rate limiting. Spend is deferred. The &lt;code>SpendLogs&lt;/code> queue is capped at &lt;strong>64 MB&lt;/strong> and on overflow it discards the oldest elements with an error in the log (&lt;code>utils.py:6704&lt;/code>). The write interval is &lt;code>proxy_batch_write_at&lt;/code>, with a default value of 10 seconds plus a random spread of up to 5 (&lt;code>constants.py:1719&lt;/code>, &lt;code>proxy_server.py:9704&lt;/code>); the production guide recommends raising it to 60.&lt;/p>
&lt;p>With a slow Postgres, the queue grows to those 64 MB and then loses billing data without returning any error to the client. It is the silent failure mode you have to alert on.&lt;/p>
&lt;h3 id="the-token-counter-which-is-the-agentic-trap">The token counter, which is the agentic trap&lt;/h3>
&lt;p>This is the piece that turns a correct sizing into an insufficient one, and it appears in no guide.&lt;/p>
&lt;p>Before admitting a request, the proxy counts tokens. With chat load the cost is negligible. With agentic load it is not, for two reasons that add up.&lt;/p>
&lt;p>The first is the prompt length. The project itself measured the admission counting cost at &lt;strong>46, 53 and 100 ms for prompts of 50K, 75K and 100K tokens&lt;/strong> (&lt;a href="https://docs.litellm.ai/docs/benchmarks">benchmarks&lt;/a>, consulted on 12 September 2026), and published that with a counter written in Rust those figures fall to 4.9, 6.8 and 10.2 ms.&lt;/p>
&lt;p>The second is the tool catalogue. The function that prepares the definitions serialises the entire catalogue to a pseudo-TypeScript and tokenises it (&lt;code>litellm_core_utils/token_counter.py:934&lt;/code>), plus nine fixed tokens. The cost is linear in the size of the catalogue and it is paid on every request. That is exactly the pattern of an agent connected to an MCP gateway: a long catalogue, resent on every turn. The token bill of that catalogue was already covered &lt;a href="https://blog.lo0.es/en/posts/litellm-mcp-gateway-second-front-door/">in the MCP gateway article&lt;/a>; here its other face appears, which is proxy CPU.&lt;/p>
&lt;p>And on top of that there is an explicit ceiling. The counting runs in a thread with a capacity limiter whose default value is &lt;strong>4 per worker&lt;/strong> (&lt;code>token_counter.py:340&lt;/code>, &lt;code>constants.py:407&lt;/code>). With 100K prompts, four concurrent counts of 100 ms mean a theoretical ceiling of around 40 admissions per second per worker before the limiter starts queueing, and that is without having called the model yet.&lt;/p>
&lt;p>What is cached is the tokeniser, with an LRU of &lt;strong>64&lt;/strong> entries (&lt;code>utils.py:2206&lt;/code>), not the result of the count.&lt;/p>
&lt;p>Three levers, in order of return: raise &lt;code>TOKEN_COUNTER_MAX_CONCURRENT_COUNTS&lt;/code> if CPU is spare, reduce the tool catalogue exposed per key, and watch the gateway overhead figure in &lt;code>/metrics&lt;/code> to know whether this is happening.&lt;/p>
&lt;h3 id="streaming-and-admission">Streaming and admission&lt;/h3>
&lt;p>There is no connection limit by default. &lt;code>--limit_concurrency&lt;/code> exists but is optional, and it returns 503. The built-in admission control is only activated if &lt;code>general_settings.max_in_flight_requests_per_worker&lt;/code> is defined (&lt;code>middleware/admission_control_middleware.py:285&lt;/code>), with &lt;code>max_queued_requests_per_worker&lt;/code> and &lt;code>admission_queue_timeout_seconds&lt;/code> at 1.0 by default, rejecting with 503 and a &lt;code>retry-after: 1&lt;/code> header.&lt;/p>
&lt;p>For agents, that header matters: an agentic client that retries without respecting it multiplies the load exactly when the gateway is saying it cannot take any more. The combination of gateway retries was already calculated by &lt;a href="https://blog.lo0.es/en/posts/humans-agents-same-gateway-priority-traffic-separation/">the fourth article&lt;/a> and it reached 45 calls per turn in the worst case.&lt;/p>
&lt;p>The default request timeout is &lt;strong>6,000 seconds&lt;/strong> (&lt;code>constants.py:512&lt;/code>). The production guide recommends 600. With long agentic tasks, leaving it at 6,000 means a hung connection occupies a slot for an hour and a half.&lt;/p>
&lt;h3 id="resources-and-autoscaling">Resources and autoscaling&lt;/h3>
&lt;p>The official chart recommends &lt;strong>1 vCPU and 4 GiB per worker, with requests equal to limits&lt;/strong> (&lt;code>helm/litellm-helm/values.yaml:235&lt;/code>), and the production documentation suggests one worker per pod scaling horizontally. The project&amp;rsquo;s own high-performance benchmark, by contrast, uses four workers per pod with PgBouncer in front.&lt;/p>
&lt;p>The HPA comes with &lt;code>targetCPUUtilizationPercentage: 60&lt;/code>. And there is a comment in the chart worth reading twice: &lt;strong>memory is deliberately left without a target&lt;/strong> because the resident consumption of Prisma&amp;rsquo;s query engine is a high-water mark that is never returned, so that an HPA on memory would add replicas and never remove them (&lt;code>values.yaml:260&lt;/code>).&lt;/p>
&lt;p>The other problem is startup. The &lt;code>startupProbe&lt;/code> has &lt;code>failureThreshold: 30&lt;/code> with a period of 10 seconds, that is &lt;strong>up to 300 seconds&lt;/strong> before the pod is considered ready (&lt;code>values.yaml:109&lt;/code>). An HPA that reacts at 60 % CPU and takes five minutes to add capacity arrives late to any agentic burst.&lt;/p>
&lt;p>The alternative, optional in the chart, is to scale by Prometheus metrics: &lt;code>targetRequestsPerSecond&lt;/code> and &lt;code>targetTokensPerSecond&lt;/code> over &lt;code>rate(litellm_proxy_total_requests_metric_total[1m])&lt;/code> and &lt;code>rate(litellm_total_tokens_metric_total[1m])&lt;/code>. The chart itself warns that the token metric lags with long streaming responses, because tokens are accounted for on completion. For agents, that is almost always.&lt;/p>
&lt;h2 id="part-6-what-to-watch-the-metrics-that-work-and-the-ones-that-broke">Part 6. What to watch: the metrics that work and the ones that broke&lt;/h2>
&lt;h3 id="at-the-gateway">At the gateway&lt;/h3>
&lt;table>
&lt;thead>
&lt;tr>
&lt;th>Metric&lt;/th>
&lt;th>What for&lt;/th>
&lt;/tr>
&lt;/thead>
&lt;tbody>
&lt;tr>
&lt;td>&lt;code>litellm_in_flight_requests&lt;/code>&lt;/td>
&lt;td>Real saturation per worker. It is the scaling metric&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>&lt;code>litellm_overhead_latency_metric&lt;/code>&lt;/td>
&lt;td>Proxy cost per request; it rises with token counting&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>&lt;code>litellm_overhead_with_guardrails_latency_metric&lt;/code>&lt;/td>
&lt;td>The same including guardrails&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>&lt;code>litellm_request_queue_time_seconds&lt;/code>&lt;/td>
&lt;td>Arrival through to pre-call, includes authentication and ASGI queueing&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>&lt;code>litellm_deployment_cooled_down&lt;/code>&lt;/td>
&lt;td>Replicas out of the pool&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>&lt;code>litellm_service_latency&lt;/code>&lt;/td>
&lt;td>Redis and database latency, per service&lt;/td>
&lt;/tr>
&lt;/tbody>
&lt;/table>
&lt;p>The &lt;code>/health/backlog&lt;/code> endpoint returns &lt;code>in_flight_requests&lt;/code>, &lt;code>admitted_requests&lt;/code>, &lt;code>queued_requests&lt;/code> and &lt;code>rejected_requests&lt;/code> (&lt;code>health_endpoints/_health_endpoints.py:1871&lt;/code>).&lt;/p>
&lt;p>What it does &lt;strong>not&lt;/strong> expose: the depth of the &lt;code>SpendLogs&lt;/code> queue or its bytes, the Prisma connections in use against the limit, nor the depth of the logging worker&amp;rsquo;s queue. All three are real failure modes and have to be instrumented from outside.&lt;/p>
&lt;h3 id="at-the-engine">At the engine&lt;/h3>
&lt;p>Names verified in the vLLM 0.29.0 metrics documentation:&lt;/p>
&lt;ul>
&lt;li>&lt;code>vllm:num_requests_running&lt;/code>, &lt;code>vllm:num_requests_waiting&lt;/code>&lt;/li>
&lt;li>&lt;code>vllm:time_to_first_token_seconds&lt;/code>, &lt;code>vllm:e2e_request_latency_seconds&lt;/code>, &lt;code>vllm:request_queue_time_seconds&lt;/code>, &lt;code>vllm:inter_token_latency_seconds&lt;/code>&lt;/li>
&lt;li>&lt;code>vllm:prefix_cache_queries&lt;/code>, &lt;code>vllm:prefix_cache_hits&lt;/code>, &lt;code>vllm:kv_cache_usage_perc&lt;/code>&lt;/li>
&lt;/ul>
&lt;p>And the ones that break dashboards on upgrade:&lt;/p>
&lt;ul>
&lt;li>&lt;code>vllm:time_in_queue_requests&lt;/code> is duplicated by &lt;code>vllm:request_queue_time_seconds&lt;/code>.&lt;/li>
&lt;li>The direct prefix cache hit rate indicator disappeared. It is now computed as &lt;code>rate(vllm:prefix_cache_hits) / rate(vllm:prefix_cache_queries)&lt;/code>.&lt;/li>
&lt;li>&lt;code>vllm:num_requests_swapped&lt;/code> and &lt;code>vllm:cpu_cache_usage_perc&lt;/code> are deprecated along with CPU swapping.&lt;/li>
&lt;li>&lt;code>vllm:gpu_cache_usage_perc&lt;/code> became &lt;code>vllm:kv_cache_usage_perc&lt;/code>, which also breaks any HPA that uses it.&lt;/li>
&lt;/ul>
&lt;h3 id="the-slo-worth-chasing">The SLO worth chasing&lt;/h3>
&lt;p>A 95th percentile of time to first token describes nobody&amp;rsquo;s experience when the unit of work is 43 turns. Coding tasks frequently exceed ten minutes and research tasks reach hours (2608.15127).&lt;/p>
&lt;p>Two indicators serve better:&lt;/p>
&lt;ol>
&lt;li>&lt;strong>90th percentile of full-task latency&lt;/strong>, measured per session, not per request. It comes from grouping &lt;code>LiteLLM_SpendLogs&lt;/code> by session identifier.&lt;/li>
&lt;li>&lt;strong>90th percentile of interactivity&lt;/strong>, in tokens per second per user. It is the one AgentX uses, with values between 58.3 and 74.2 in its runs.&lt;/li>
&lt;/ol>
&lt;p>To autoscale the engine, the official production-stack example uses &lt;code>vllm:num_requests_waiting&lt;/code> with a threshold of 5, polling every 15 seconds and a cooldown of 360. With agentic load it is worth adding &lt;code>vllm:kv_cache_usage_perc&lt;/code> as a second trigger, because with 26 to 43 GiB of KV per sequence the memory pressure arrives before the queue does.&lt;/p>
&lt;h2 id="part-7-the-engine-levers-ordered-by-return">Part 7. The engine levers, ordered by return&lt;/h2>
&lt;p>Verified against vLLM 0.29.0, released on 9 September 2026.&lt;/p>
&lt;p>&lt;strong>&lt;code>--long-prefill-token-threshold 512&lt;/code>.&lt;/strong> The AgentX measurement attributes to it &lt;strong>+93 % in tokens per GPU per second, with a 2.3-times improvement at the 90th percentile&lt;/strong>, by attacking the head-of-line blocking that a long prefill produces in front of short requests. It is the highest-return lever documented for this profile.&lt;/p>
&lt;p>&lt;strong>KV cache in FP8.&lt;/strong> It halves the memory budget per sequence, which is the limiting resource. Validate with evals.&lt;/p>
&lt;p>&lt;strong>&lt;code>--max-num-queued-reqs&lt;/code> and &lt;code>--max-num-queued-tokens&lt;/code>.&lt;/strong> New in 0.29. They are admission control in the engine, and they are the right mechanism to protect the SLO instead of letting the queue grow without limit.&lt;/p>
&lt;p>&lt;strong>&lt;code>max_num_batched_tokens&lt;/code> downwards.&lt;/strong> High values improve time to first token; low values improve inter-token latency. In a regime where 91 % of the time is decode, inter-token latency is what is perceived. Chunked prefill is enabled by default whenever possible in the V1 engine.&lt;/p>
&lt;p>&lt;strong>Session affinity at the gateway.&lt;/strong> It is not an engine setting, but it is what holds up everything above. Without it, that 98 % hit rate turns into random distribution across replicas.&lt;/p>
&lt;p>Two version warnings: Model Runner V2 becomes the default in 0.29 and V1 is deprecated with removal planned for 0.32; and starting with &lt;code>python -m vllm.entrypoints.openai.api_server&lt;/code> is deprecated.&lt;/p>
&lt;h2 id="the-full-calculation-from-start-to-finish">The full calculation, from start to finish&lt;/h2>
&lt;p>Case: twenty developers, a coding scaffold, a 30B-class model in the fleet, maximum context of 128K, KV in FP8.&lt;/p>
&lt;p>&lt;strong>1. Steady-state concurrency.&lt;/strong> $C = 20 \times 0.11 \times 3 \approx 6.6$ sequences.&lt;/p>
&lt;p>&lt;strong>2. KV budget per sequence.&lt;/strong> 0.1875 MiB per token in BF16, half in FP8, that is around 0.094 MiB. At 128K of context: around 12.0 GiB per sequence. For 6.6 sequences with margin, rounding to 8: 96 GiB of KV.&lt;/p>
&lt;p>&lt;strong>3. GPUs.&lt;/strong> The weights of a 30B in FP8 take up around 30 GB. On an 80 GB H100 with &lt;code>gpu_memory_utilization&lt;/code> of 0.90 there are around 72 GB usable per card. With tensor parallelism of 2, the weights are split: around 15 GB per GPU, so around 57 GB of KV per GPU and around 114 GB in the replica. That covers the 96 GiB calculated, and TP=2 also adds memory bandwidth, which is the resource that dominates decode. &lt;strong>Two H100s, one replica, TP=2.&lt;/strong> It is worth seeing the contrast: on a single H100 the full weights leave around 42 GB of KV, that is three and a half sequences, less than half of what is needed. With a second replica for availability and maintenance windows: four GPUs.&lt;/p>
&lt;p>&lt;strong>4. Requests per second at the gateway.&lt;/strong> 20 people, a mean session of 40.6 calls in 62.6 minutes, duty cycle included: of the order of 0.2 to 0.3 requests per second on average, with peaks of 2 to 3.&lt;/p>
&lt;p>&lt;strong>5. Gateway workers.&lt;/strong> The ceiling is not set by throughput but by token counting: four concurrent counts per worker, at 100 ms with long prompts. With peaks of 3 requests per second and six-figure prompts, &lt;strong>two replicas of one worker each&lt;/strong> cover the case with margin for the rolling update, at 1 vCPU and 4 GiB each. Postgres will see 20 connections.&lt;/p>
&lt;p>&lt;strong>6. What needs switching on.&lt;/strong> Session affinity, admission control at the gateway and at the engine, &lt;code>--long-prefill-token-threshold&lt;/code>, and Redis so that the limits are global.&lt;/p>
&lt;p>The same calculation with the chat profile would have given one GPU and one worker. The distance is not in the safety margin, it is in the inputs.&lt;/p>
&lt;h2 id="a-reference-configyaml">A reference &lt;code>config.yaml&lt;/code>&lt;/h2>
&lt;div class="highlight">&lt;pre tabindex="0" class="chroma">&lt;code class="language-yaml" data-lang="yaml">&lt;span class="line">&lt;span class="cl">&lt;span class="nt">model_list&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>- &lt;span class="nt">model_name&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">agentes-30b&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">litellm_params&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">model&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">hosted_vllm/qwen3-coder-30b&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">api_base&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">http://vllm-agentes.inferencia.svc:8000/v1&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">rpm&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="m">600&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">model_info&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">input_cost_per_token&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="m">0.00000018&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">output_cost_per_token&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="m">0.00000072&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w">&lt;/span>&lt;span class="nt">router_settings&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">routing_strategy&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">simple-shuffle&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="c"># without this, the capacity calculation in this article is worthless&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">optional_pre_call_checks&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="p">[&lt;/span>&lt;span class="s2">&amp;#34;session_affinity&amp;#34;&lt;/span>&lt;span class="p">]&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">num_retries&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="m">2&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">allowed_fails&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="m">3&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">cooldown_time&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="m">30&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w">&lt;/span>&lt;span class="nt">general_settings&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="c"># admission control: below this the queue grows without limit&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">max_in_flight_requests_per_worker&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="m">24&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">max_queued_requests_per_worker&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="m">48&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">admission_queue_timeout_seconds&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="m">1.0&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="c"># the spend queue discards at 64 MB; shorter interval, less backlog&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">proxy_batch_write_at&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="m">60&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">enable_drain_endpoint&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="kc">true&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">drain_endpoint_token&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">os.environ/DRAIN_TOKEN&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">database_connection_pool_limit&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="m">10&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w">&lt;/span>&lt;span class="nt">litellm_settings&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="c"># 6000 s by default: a hung connection holds a slot for an hour and a half&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">request_timeout&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="m">600&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">callbacks&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="p">[&lt;/span>&lt;span class="s2">&amp;#34;langfuse_otel&amp;#34;&lt;/span>&lt;span class="p">,&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="s2">&amp;#34;prometheus&amp;#34;&lt;/span>&lt;span class="p">]&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">cache&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="kc">true&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">cache_params&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">type&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">redis&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">host&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">os.environ/REDIS_HOST&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">namespace&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">litellm&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>Environment variables that go with it:&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">&lt;span class="nv">NUM_WORKERS&lt;/span>&lt;span class="o">=&lt;/span>&lt;span class="m">1&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="nv">TOKEN_COUNTER_MAX_CONCURRENT_COUNTS&lt;/span>&lt;span class="o">=&lt;/span>&lt;span class="m">8&lt;/span> &lt;span class="c1"># default 4; raise only if CPU is spare&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="nv">LITELLM_TPM_TOKEN_RESERVATION_ENABLED&lt;/span>&lt;span class="o">=&lt;/span>&lt;span class="nb">true&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="nv">PROXY_CONFIG_RELOAD_INTERVAL_SECONDS&lt;/span>&lt;span class="o">=&lt;/span>&lt;span class="m">30&lt;/span>
&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>And on the engine side:&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 Qwen/Qwen3-Coder-30B &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">2&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-model-len &lt;span class="m">131072&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> --kv-cache-dtype fp8 &lt;span class="se">\
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="se">&lt;/span> --long-prefill-token-threshold &lt;span class="m">512&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-queued-reqs &lt;span class="m">64&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> --gpu-memory-utilization 0.90
&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;h2 id="checklist">Checklist&lt;/h2>
&lt;ol>
&lt;li>Measure three figures of your own over a week: calls per hour of human work and per profile, prompt length distribution, and the time series of prefix cache hit rate.&lt;/li>
&lt;li>Calculate the KV per sequence with the model&amp;rsquo;s real geometry, taken from its &lt;code>config.json&lt;/code>, not from a table.&lt;/li>
&lt;li>Set &lt;code>--max-num-seqs&lt;/code> from that budget, not from a chat heuristic.&lt;/li>
&lt;li>Switch on session affinity before accepting the calculated number of GPUs.&lt;/li>
&lt;li>Switch on admission control in both layers and check that the client respects &lt;code>retry-after&lt;/code>.&lt;/li>
&lt;li>Lower &lt;code>request_timeout&lt;/code> from 6,000 to 600.&lt;/li>
&lt;li>Raise &lt;code>proxy_batch_write_at&lt;/code> to 60 and alert on the growth of the spend queue from outside, because there is no metric.&lt;/li>
&lt;li>Leave the HPA on CPU at 60 %, never on memory, and assume the 300 seconds of the &lt;code>startupProbe&lt;/code> when planning the margin.&lt;/li>
&lt;li>Watch &lt;code>litellm_in_flight_requests&lt;/code> and &lt;code>litellm_overhead_latency_metric&lt;/code>; if the overhead rises with the size of the tool catalogue, the culprit is the token counter.&lt;/li>
&lt;li>Go over the dashboards for the vLLM metric renames before upgrading.&lt;/li>
&lt;li>Check that no routing policy moves a live session between model groups.&lt;/li>
&lt;li>Redo the calculation when a new user profile arrives: the range across profiles is 50 to 1.&lt;/li>
&lt;/ol>
&lt;h2 id="traps-and-things-that-are-not-what-they-seem">Traps and things that are not what they seem&lt;/h2>
&lt;p>&lt;strong>The ratio of 275 to 1 does not mean the system is prefill-bound.&lt;/strong> The new tokens per turn are between 1.5 and 7.3 times the output. The time is between 91 % and 98.6 % in decode.&lt;/p>
&lt;p>&lt;strong>The aggregate prefix cache hit rate hides the problem.&lt;/strong> An aggregate 90 % may be 98 % intra-turn and 55 % at boundaries. You have to look at the series, not the number, and segment by request type.&lt;/p>
&lt;p>&lt;strong>vLLM&amp;rsquo;s hit rate counts the reprefill after an eviction as a hit.&lt;/strong> The counter exists internally and is not exposed, so the published number is optimistic under a memory-pressure regime.&lt;/p>
&lt;p>&lt;strong>Agents that fail cost more than those that succeed&lt;/strong>, up to 1.8 times more context. Sizing on the task that goes well leaves the calculation short.&lt;/p>
&lt;p>&lt;strong>The database pool is per process, not per pod.&lt;/strong> Four workers are forty connections.&lt;/p>
&lt;p>&lt;strong>The HPA on memory does not work with this piece&lt;/strong>, and not because of a configuration mistake: Prisma&amp;rsquo;s query engine does not return the memory.&lt;/p>
&lt;p>&lt;strong>The 300-second &lt;code>startupProbe&lt;/code> turns any autoscaling into late reaction.&lt;/strong> For agentic peaks, it is better to over-provision than to trust the scaling.&lt;/p>
&lt;p>&lt;strong>Raising &lt;code>--max-num-seqs&lt;/code> to &amp;ldquo;make use of the GPU&amp;rdquo; degrades perceived latency&lt;/strong>: two long requests in the batch raise time per token by 79.7 %.&lt;/p>
&lt;p>&lt;strong>The token counter has a limiter of four per worker&lt;/strong> and it appears in no sizing guide. With large tool catalogues and long contexts it is the first ceiling you hit.&lt;/p>
&lt;p>&lt;strong>The figures in this article come from three different scaffolds and are not averageable.&lt;/strong> Fifteen, forty-three and sixty-two turns per session are three systems, not three measurements of the same one.&lt;/p>
&lt;h2 id="closing">Closing&lt;/h2>
&lt;p>The arithmetic of sizing has not changed. What has changed are the inputs, and now there are public measurements at scale instead of intuitions. Three figures sum up the change of regime: 68,000 tokens of median prompt, 87 % of calls started by the agent rather than by the person, and a time split that is decode for more than 90 % as long as the prefix cache hits.&lt;/p>
&lt;p>From that comes an order of work that is not the usual one. First, session affinity, because without it the rest of the calculation describes a different system. Second, the KV budget with the model&amp;rsquo;s real geometry, which is what sets how many sequences fit. Third, the gateway, where the ceiling is not throughput but a token counter with four threads per worker.&lt;/p>
&lt;p>And a warning about your own figures. The measured range across user profiles is 50 to 1. Any platform that does not segment its measurement by profile is averaging two different populations and producing a number that describes neither of them.&lt;/p>
&lt;h2 id="see-also">See also&lt;/h2>
&lt;ul>
&lt;li>&lt;a href="https://blog.lo0.es/en/posts/humans-agents-same-gateway-priority-traffic-separation/">Humans and agents on the same gateway&lt;/a>: coexistence on a fleet that is already sized.&lt;/li>
&lt;li>&lt;a href="https://blog.lo0.es/en/posts/prefix-routing-what-litellm-does-not-do/">Prefix routing: what LiteLLM does not do&lt;/a>: the affinity mechanism on which this article&amp;rsquo;s entire calculation depends.&lt;/li>
&lt;li>&lt;a href="https://blog.lo0.es/en/posts/litellm-mcp-gateway-second-front-door/">LiteLLM&amp;rsquo;s MCP gateway&lt;/a>: the other face of the tool catalogue, the one paid in tokens.&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 classic method whose inputs this article replaces.&lt;/li>
&lt;li>&lt;a href="https://blog.lo0.es/en/posts/kv-cache-working-memory-llm-inference/">KV cache: fundamentals&lt;/a> and &lt;a href="https://blog.lo0.es/en/posts/prefix-cache-hit-rate-engineering/">prefix cache hit rate engineering&lt;/a>: the two pieces that dominate the budget.&lt;/li>
&lt;li>&lt;a href="https://blog.lo0.es/en/posts/litellm-day-2-high-availability/">Day 2 of the proxy&lt;/a>: availability, draining and deployments.&lt;/li>
&lt;/ul>
&lt;h2 id="sources">Sources&lt;/h2>
&lt;ul>
&lt;li>&lt;a href="https://arxiv.org/html/2608.00101v1">Agentic Coding in the Wild: Characterizing GitHub Copilot at Production Scale&lt;/a>, arXiv 2608.00101, 30 de julio de 2026.&lt;/li>
&lt;li>&lt;a href="https://arxiv.org/html/2605.26297">Agentic AI Workload Characteristics&lt;/a>, arXiv 2605.26297, 25 de mayo de 2026.&lt;/li>
&lt;li>&lt;a href="https://arxiv.org/html/2608.15127">From LLM Inference to Agentic Workloads&lt;/a>, arXiv 2608.15127, 15 de agosto de 2026.&lt;/li>
&lt;li>&lt;a href="https://vllm.ai/blog/2026-09-08-vllm-agentx">vLLM x AgentX: serving agentic workloads&lt;/a>, 8 de septiembre de 2026.&lt;/li>
&lt;li>&lt;a href="https://docs.vllm.ai/en/stable/design/metrics/">vLLM metrics design&lt;/a> y &lt;a href="https://docs.vllm.ai/en/stable/configuration/optimization/">optimization guide&lt;/a>, consultados el 12 de septiembre de 2026.&lt;/li>
&lt;li>&lt;a href="https://docs.litellm.ai/docs/benchmarks">LiteLLM benchmarks&lt;/a> y &lt;a href="https://docs.litellm.ai/docs/proxy/prod">production setup&lt;/a>, consultados el 12 de septiembre de 2026.&lt;/li>
&lt;li>Código de LiteLLM 1.102.0, commit &lt;code>9071ca50&lt;/code> del 11 de septiembre de 2026.&lt;/li>
&lt;li>&lt;a href="https://docs.vllm.ai/projects/production-stack/en/latest/use_cases/autoscaling-keda.html">Autoescalado con KEDA en vLLM production-stack&lt;/a>, consultado el 12 de septiembre de 2026.&lt;/li>
&lt;/ul></description></item><item><title>Prefix routing: what LiteLLM does not do, who does, and how much it really improves</title><link>https://blog.lo0.es/en/posts/prefix-routing-what-litellm-does-not-do/</link><pubDate>Fri, 11 Sep 2026 00:30:00 +0200</pubDate><guid>https://blog.lo0.es/en/posts/prefix-routing-what-litellm-does-not-do/</guid><description>&lt;blockquote>
&lt;p>Fifth article in the operational track of the control layer. &lt;a href="https://blog.lo0.es/en/posts/litellm-langfuse-operational-pair/">The pair with Langfuse&lt;/a> covered observability, &lt;a href="https://blog.lo0.es/en/posts/litellm-day-2-high-availability/">the proxy&amp;rsquo;s day 2&lt;/a> covered availability, &lt;a href="https://blog.lo0.es/en/posts/litellm-virtual-keys-budgets-limits/">virtual keys&lt;/a> covered governance and &lt;a href="https://blog.lo0.es/en/posts/humans-agents-same-gateway-priority-traffic-separation/">humans and agents&lt;/a> covered the coexistence of two classes of traffic. Here a claim of mine from June gets taken apart. Everything that follows is verified against LiteLLM 1.102.0 (commit of 10 September 2026), vLLM 0.29.0 and the main branches of production-stack, llm-d and Dynamo from the second week of September 2026.&lt;/p>
&lt;/blockquote>
&lt;h2 id="tldr">TL;DR&lt;/h2>
&lt;p>&lt;strong>LiteLLM does not do prefix-aware routing, neither in the open version nor in the paid one.&lt;/strong> It is not a licensing question. A search across the whole tree for &lt;code>kv_aware&lt;/code>, &lt;code>cache_aware&lt;/code>, &lt;code>radix&lt;/code>, &lt;code>prefix_cache&lt;/code> and &lt;code>block_hash&lt;/code> returns zero matches in the router. Nor is there any call from the router to &lt;code>/metrics&lt;/code> or to any vLLM endpoint. In June I wrote the opposite in &lt;a href="https://blog.lo0.es/en/posts/llm-inference-router-l7-switchboard/">the inference router article&lt;/a> and I was wrong.&lt;/p>
&lt;p>&lt;strong>What LiteLLM has is affinity, which is a different thing.&lt;/strong> Four pre-call checks pin a request to a replica by session identifier, by key hash, by previous response identifier or by exact hash of a marked prefix. None is active by default. Affinity distributes conversations; prefix routing distributes prefixes shared between different conversations, which is the case that multiplies throughput.&lt;/p>
&lt;p>&lt;strong>The only one that looks at the prompt is fragile in three ways.&lt;/strong> If the client does not send &lt;code>cache_control&lt;/code>, the key comes out null and the check does nothing. If the cut point goes in the last turn, which is what the automatic injection does by default, the key changes on every turn and it never hits. And the pin&amp;rsquo;s TTL is 300 seconds hardcoded in two lines, while the provider offers one-hour caches.&lt;/p>
&lt;p>&lt;strong>The engine does not answer questions, it publishes events.&lt;/strong> There is no vLLM endpoint you can ask whether it has a prefix. What there is, is a ZMQ socket that emits stored and removed blocks in msgpack, switched off by default, with silent discarding when the consumer does not keep up. A router that wants to be exact has to replicate the engine&amp;rsquo;s hash in full: chained SHA256, deterministic seed, block granularity and the extra keys in their order.&lt;/p>
&lt;p>&lt;strong>And by default it does not publish the hits, only the additions.&lt;/strong> The index of a router that only listens drifts away from the engine&amp;rsquo;s real LRU without noticing. To see the hits you have to ask for it request by request with &lt;code>kv_cache_report_mode: full&lt;/code>.&lt;/p>
&lt;p>&lt;strong>The three routers that do do it are very different from each other.&lt;/strong> The vLLM production-stack one indexes chunks of 128 raw characters in a trie that is never purged, models no eviction and dies on every restart. The llm-d one offers an approximate mode and an exact event-driven one, all its plugins are in alpha or beta, and its default tokeniser does not produce real tokens. NVIDIA Dynamo&amp;rsquo;s keeps an event-driven radix tree with a weighted cost function, and ships with the parameter that avoids starving a freshly scaled replica switched off.&lt;/p>
&lt;p>&lt;strong>The big improvements only appear in the best possible case.&lt;/strong> The two- and three-digit numbers are published by the projects themselves, with synthetic shared prefixes and a round-robin balancer as the reference. The most complete independent measurement, from Meta, obtains 2.3 times more throughput and also identifies load regimes where affinity leaves capacity at between half and two thirds of the reference. Another, on a multi-region deployment with 89 % reuse, measures single-digit improvements.&lt;/p>
&lt;p>&lt;strong>The correct architecture has two layers, not one.&lt;/strong> LiteLLM on top for identity, budget and audit, pointing at a single destination per model group. The KV-aware router underneath, inside the engine&amp;rsquo;s domain. Mixing it into a single piece is not an option that exists today.&lt;/p>
&lt;h2 id="you-are-here-between-the-gateway-and-the-engine">You are here: between the gateway and the engine&lt;/h2>
&lt;p>This article occupies the gap between two layers that already have an article of their own. Above, &lt;a href="https://blog.lo0.es/en/posts/llm-inference-router-l7-switchboard/">the L7 switchboard&lt;/a> and the gateway that has been operated since the first article in this track. Below, &lt;a href="https://blog.lo0.es/en/posts/kv-cache-working-memory-llm-inference/">the fundamentals of the KV cache&lt;/a> and &lt;a href="https://blog.lo0.es/en/posts/prefix-cache-hit-rate-engineering/">hit rate engineering&lt;/a>.&lt;/p>
&lt;p>The gap is the piece that decides which replica each request goes to, knowing what each replica has in memory. It is the only function in the stack that cannot be resolved in the gateway or in the engine separately.&lt;/p>
&lt;h2 id="the-analogy-the-library-with-four-counters">The analogy: the library with four counters&lt;/h2>
&lt;p>A library with four counters. Each librarian has in front of them a pile of the books they have consulted lately. When a reader comes in asking for the third volume of an encyclopaedia, if that volume is in the pile at counter two, serving them there takes seconds. At any other counter someone has to go down to the store room.&lt;/p>
&lt;p>A normal balancer sends the reader to the counter with the shortest queue. It is the right decision if all the counters are interchangeable, and it is the wrong decision here, because they are not: what distinguishes them is not the queue, it is the pile.&lt;/p>
&lt;p>Session affinity resolves one specific case of this. The returning reader is always sent to the same counter, because they will probably ask for the fourth volume of the same encyclopaedia. It works, and it does not resolve the general case: when a hundred different readers ask for the same third volume, session affinity spreads them across the four counters and four librarians go down to the store room for the same book.&lt;/p>
&lt;p>Prefix routing is looking at what the reader asks for before assigning them a counter. It has two costs that the analogy makes visible straight away. The first is that somebody has to keep track of what is in each pile, and that bookkeeping takes work. The second is that if everybody asks for the third volume, counter two becomes the library&amp;rsquo;s only desk while the other three look on.&lt;/p>
&lt;h2 id="the-correction-what-i-wrote-in-june">The correction: what I wrote in June&lt;/h2>
&lt;p>In the inference router article, on 2 June 2026, I closed the prefix routing section with this sentence: &amp;ldquo;vLLM Production Stack router implements it natively. NVIDIA Dynamo too. LiteLLM in its enterprise version has a beta.&amp;rdquo;&lt;/p>
&lt;p>The first two hold up. The third is false and was no less false in June. While preparing &lt;a href="https://blog.lo0.es/en/posts/humans-agents-same-gateway-priority-traffic-separation/">the humans and agents article&lt;/a> the first hint appeared, and full verification against 1.102.0 confirms it.&lt;/p>
&lt;p>Case-insensitive search over &lt;code>litellm/&lt;/code>, &lt;code>enterprise/&lt;/code> and &lt;code>gateway/&lt;/code>, for the terms &lt;code>kv_aware&lt;/code>, &lt;code>kv-aware&lt;/code>, &lt;code>cache_aware&lt;/code>, &lt;code>cache-aware&lt;/code>, &lt;code>radix&lt;/code>, &lt;code>prefix_cache&lt;/code>, &lt;code>prefix-cache&lt;/code>, &lt;code>kv_cache&lt;/code>, &lt;code>block_hash&lt;/code>, &lt;code>prefix_hash&lt;/code>, &lt;code>prefix_score&lt;/code> and &lt;code>longest_prefix&lt;/code>: zero matches. Search for &lt;code>vllm&lt;/code> inside &lt;code>litellm/router.py&lt;/code>, &lt;code>litellm/router_strategy/&lt;/code> and &lt;code>litellm/router_utils/&lt;/code>: zero. Search for &lt;code>num_requests_waiting&lt;/code>, &lt;code>gpu_cache_usage&lt;/code> or any scraping of the backends&amp;rsquo; &lt;code>/metrics&lt;/code> from the router: zero. LiteLLM&amp;rsquo;s router never asks the engine anything.&lt;/p>
&lt;p>Search for &lt;code>premium_user&lt;/code> in the files where those checks would live: a single result, and it is about something else. There is no paid version with this feature. The only routing piece that is behind a licence is per-tag budgets, in &lt;code>litellm/router_strategy/budget_limiter.py&lt;/code>.&lt;/p>
&lt;p>Where the error came from. LiteLLM&amp;rsquo;s documentation has an entry on dynamic TPM and RPM allocation marked as beta and enterprise, and a pre-call check called &lt;code>prompt_caching&lt;/code>. Skim-read and added together, they give the impression of prefix routing in beta. Read in the code, &lt;code>prompt_caching&lt;/code> is something else, and it deserves a section of its own because it is still recommended for this problem.&lt;/p>
&lt;h2 id="what-litellm-does-have-four-affinities">What LiteLLM does have: four affinities&lt;/h2>
&lt;p>The &lt;code>optional_pre_call_checks&lt;/code> field accepts eight values. None is enabled by default: &lt;code>apply_default_settings&lt;/code> passes an empty list. One of the eight, &lt;code>forward_client_headers_by_model_group&lt;/code>, is declared in the type and in the interface schema and is consumed by no branch of the code. It is a dead value.&lt;/p>
&lt;p>The four that pin a request to a replica are these.&lt;/p>
&lt;table>
&lt;thead>
&lt;tr>
&lt;th>Check&lt;/th>
&lt;th>What it pins&lt;/th>
&lt;th>Where it stores it&lt;/th>
&lt;th>TTL&lt;/th>
&lt;/tr>
&lt;/thead>
&lt;tbody>
&lt;tr>
&lt;td>&lt;code>session_affinity&lt;/code>&lt;/td>
&lt;td>Session identifier&lt;/td>
&lt;td>Redis with a Lua script, mirrored in memory&lt;/td>
&lt;td>&lt;code>deployment_affinity_ttl_seconds&lt;/code>, 3600 by default, refreshed&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>&lt;code>deployment_affinity&lt;/code>&lt;/td>
&lt;td>API key hash&lt;/td>
&lt;td>Same&lt;/td>
&lt;td>Same&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>&lt;code>responses_api_deployment_check&lt;/code>&lt;/td>
&lt;td>&lt;code>previous_response_id&lt;/code>&lt;/td>
&lt;td>Same&lt;/td>
&lt;td>Same&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>&lt;code>prompt_caching&lt;/code>&lt;/td>
&lt;td>Exact hash of a marked prefix&lt;/td>
&lt;td>&lt;code>DualCache&lt;/code>&lt;/td>
&lt;td>&lt;strong>300 s, hardcoded&lt;/strong>&lt;/td>
&lt;/tr>
&lt;/tbody>
&lt;/table>
&lt;p>The other four are &lt;code>router_budget_limiting&lt;/code>, &lt;code>encrypted_content_affinity&lt;/code>, &lt;code>enforce_model_rate_limits&lt;/code> and the dead value.&lt;/p>
&lt;h3 id="prompt_caching-why-it-is-no-use-for-this">&lt;code>prompt_caching&lt;/code>: why it is no use for this&lt;/h3>
&lt;p>The key is a SHA256 over the serialisation of the cacheable prefix, and &amp;ldquo;cacheable prefix&amp;rdquo; means everything up to and including the last block marked with &lt;code>cache_control: {&amp;quot;type&amp;quot;: &amp;quot;ephemeral&amp;quot;}&lt;/code>. Three consequences, checked by running the functions in isolation over the repository&amp;rsquo;s code.&lt;/p>
&lt;p>If the request carries no &lt;code>cache_control&lt;/code>, the extraction returns an empty list, the key comes out null and the check pins nothing. Automatic injection of cut points is only enabled with &lt;code>litellm.enable_anthropic_prompt_caching&lt;/code>, which defaults to false, and only for Anthropic and Bedrock providers.&lt;/p>
&lt;p>If the cut point is in the system prompt, the key does not change when turns are added and the pin works. This is the good case and it requires the client to mark it deliberately.&lt;/p>
&lt;p>If the cut point is in the last message, the key changes on every turn and the pin never hits. And this is exactly the default case of the automatic injection, which places one point on the &lt;code>system&lt;/code> and another at index &lt;code>-1&lt;/code>. Since the second is the last, the cacheable prefix becomes the whole conversation.&lt;/p>
&lt;p>On top of that there is the TTL. Two lines with &lt;code>ttl=300&lt;/code>, one of them with the comment &lt;code># store for 5 minutes&lt;/code>. There is no parameter to change it. Issue 28427 has had it open for months with no response from the maintainers, and it points to the mismatch with the one-hour ephemeral caches that LiteLLM itself knows how to request via &lt;code>cache_control.ttl: &amp;quot;1h&amp;quot;&lt;/code>.&lt;/p>
&lt;p>There is a nuance the issue does not capture and that makes diagnosis worse: in the default case the pin does not even last five minutes, because the key changes first.&lt;/p>
&lt;h3 id="session_affinity-the-one-to-use">&lt;code>session_affinity&lt;/code>: the one to use&lt;/h3>
&lt;p>For conversations, this is the right piece and it is reasonably built. The session identifier is looked for in this exact order of headers:&lt;/p>
&lt;ol>
&lt;li>&lt;code>x-litellm-trace-id&lt;/code>&lt;/li>
&lt;li>&lt;code>x-litellm-session-id&lt;/code>&lt;/li>
&lt;li>Any &lt;code>x-&amp;lt;vendor&amp;gt;-session-id&lt;/code>, with the regular expression &lt;code>^x-.+-session-id$&lt;/code> and a value of at least eight alphanumeric characters&lt;/li>
&lt;li>Only if the &lt;code>User-Agent&lt;/code> is Codex&amp;rsquo;s: &lt;code>session-id&lt;/code>, &lt;code>session_id&lt;/code>, &lt;code>thread-id&lt;/code>, &lt;code>conversation_id&lt;/code>&lt;/li>
&lt;li>Plain &lt;code>x-session-id&lt;/code>, for opencode&lt;/li>
&lt;/ol>
&lt;p>And if nothing matches, two fallbacks: a &lt;code>metadata.user_id&lt;/code> in Anthropic format, and the W3C &lt;code>baggage&lt;/code> with &lt;code>session.id=&lt;/code>. The third point is the one that captures Claude Code with nothing configured.&lt;/p>
&lt;p>Storage is a Lua script in Redis that does &lt;code>GET&lt;/code>, &lt;code>SET NX EX&lt;/code> and &lt;code>EXPIRE&lt;/code>, so the first writer wins and the TTL is refreshed on every request that confirms the pin. It is an inactivity TTL, not a total session duration. Without Redis it degrades to a pod-local reservation, which is atomic but not shared.&lt;/p>
&lt;p>A detail of the pipeline order that already came up in the previous article and still holds: the affinity checks run inside &lt;code>async_get_healthy_deployments&lt;/code> before tag filtering. If the pin returns a single deployment and that deployment does not satisfy the request&amp;rsquo;s tag, the later filter removes it and the request fails. Affinity gets no vote on the tags.&lt;/p>
&lt;h3 id="the-routers-that-do-read-the-prompt-and-why-they-are-no-good">The routers that do read the prompt, and why they are no good&lt;/h3>
&lt;p>LiteLLM has four pieces that read message content: &lt;code>AutoRouter&lt;/code> with semantic matching, &lt;code>RequestComplexityRouter&lt;/code>, &lt;code>AdaptiveRouter&lt;/code> and &lt;code>QualityRouter&lt;/code>. They all live in &lt;code>async_pre_routing_hook&lt;/code>, which runs before looking for healthy deployments.&lt;/p>
&lt;p>The reason they do not solve this problem is in what they choose. All four choose a &lt;strong>model group&lt;/strong>, not a replica. They decide whether an easy question goes to the thirty-billion-parameter model or to the small one. None has access to the replica list or knows what is in each one&amp;rsquo;s memory.&lt;/p>
&lt;h3 id="what-did-land-admission-control">What did land: admission control&lt;/h3>
&lt;p>A minor correction to the previous article, which placed this in the 1.101 branch as an upcoming feature. In 1.102.0 it is already there, in &lt;code>litellm/proxy/middleware/admission_control_middleware.py&lt;/code>, with the three settings in &lt;code>general_settings&lt;/code>: &lt;code>max_in_flight_requests_per_worker&lt;/code>, &lt;code>max_queued_requests_per_worker&lt;/code> and &lt;code>admission_queue_timeout_seconds&lt;/code>, the last with a default value of 1.0 seconds.&lt;/p>
&lt;p>It stays inactive as long as the first one is not declared. It exposes an authenticated &lt;code>/health/backlog&lt;/code> and the three metrics &lt;code>litellm_admission_admitted_requests&lt;/code>, &lt;code>litellm_admission_queued_requests&lt;/code> and &lt;code>litellm_admission_rejected_requests_total&lt;/code>, the last with a reason label. It is a per-process semaphore, not coordinated across pods.&lt;/p>
&lt;h2 id="what-the-engine-exposes-upwards">What the engine exposes upwards&lt;/h2>
&lt;p>Before looking at the routers that do do this, you have to understand what raw material they work with. The natural question would be whether vLLM has an endpoint you can ask about a prefix. It does not.&lt;/p>
&lt;p>The only cache router in the entry points is &lt;code>vllm/entrypoints/serve/dev/cache/api_router.py&lt;/code>, and it only exposes destructive writes: &lt;code>POST /reset_prefix_cache&lt;/code>, &lt;code>/reset_mm_cache&lt;/code> and &lt;code>/reset_encoder_cache&lt;/code>. All three are behind &lt;code>VLLM_SERVER_DEV_MODE=1&lt;/code> and do not exist in a normal deployment. There is no read.&lt;/p>
&lt;p>What there is, is an event stream.&lt;/p>
&lt;h3 id="the-zmq-stream">The ZMQ stream&lt;/h3>
&lt;p>It is enabled with &lt;code>--kv-events-config&lt;/code>, and it is off by default. The configuration accepts a publisher (&lt;code>null&lt;/code> or &lt;code>zmq&lt;/code>), an endpoint (by default &lt;code>tcp://*:5557&lt;/code>, which binds), an optional replay endpoint, the number of batches retained for that replay (10,000), and a high water mark limit of 100,000.&lt;/p>
&lt;p>That limit is the one that matters operationally: above it, ZMQ discards events if the consumer does not keep up. Silently.&lt;/p>
&lt;p>The transport is a PUB socket with msgpack serialisation and three-part frames: topic, eight-byte sequence number and payload. The sequence number lets a subscriber ask for replay from where it left off through the optional ROUTER socket. With data parallelism the port shifts by rank, so you have to subscribe to &lt;code>5557+rank&lt;/code>.&lt;/p>
&lt;p>Three event types. &lt;code>BlockStored&lt;/code> carries the hashes, the parent hash, the token identifiers, the block size, the LoRA adapter name, the extra keys, the medium (GPU, CPU or storage) and a session identifier. &lt;code>BlockRemoved&lt;/code> carries only hashes. &lt;code>AllBlocksCleared&lt;/code> carries no payload and is emitted when the prefix cache is reset.&lt;/p>
&lt;p>They are published once per scheduler step, only if there are events. With the feature switched off the cost is zero. With it switched on, the full token identifiers of each block travel over the wire, so bandwidth scales with tokens per second and not with requests per second.&lt;/p>
&lt;p>An example in vLLM&amp;rsquo;s own repository declares itself experimental on its eighth line.&lt;/p>
&lt;h3 id="the-hash-that-has-to-be-replicated">The hash that has to be replicated&lt;/h3>
&lt;p>An exact router does not receive the engine&amp;rsquo;s state, it reconstructs it. For that it has to compute the same hashes, and the engine computes them like this:&lt;/p>
&lt;div class="highlight">&lt;pre tabindex="0" class="chroma">&lt;code class="language-python" data-lang="python">&lt;span class="line">&lt;span class="cl">&lt;span class="c1"># vllm/v1/core/kv_cache_utils.py&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="k">def&lt;/span> &lt;span class="nf">hash_block_tokens&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="n">hash_function&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="n">parent_block_hash&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="n">curr_block_token_ids&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="n">extra_keys&lt;/span>&lt;span class="p">):&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="k">if&lt;/span> &lt;span class="ow">not&lt;/span> &lt;span class="n">parent_block_hash&lt;/span>&lt;span class="p">:&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="n">parent_block_hash&lt;/span> &lt;span class="o">=&lt;/span> &lt;span class="n">NONE_HASH&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="k">return&lt;/span> &lt;span class="n">hash_function&lt;/span>&lt;span class="p">((&lt;/span>&lt;span class="n">parent_block_hash&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="nb">tuple&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="n">curr_block_token_ids&lt;/span>&lt;span class="p">),&lt;/span> &lt;span class="n">extra_keys&lt;/span>&lt;span class="p">))&lt;/span>
&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>It is chained: each hash identifies the complete prefix up to that boundary, not the isolated block. The default algorithm is SHA256, configurable with &lt;code>--prefix-caching-hash-algo&lt;/code> among four values.&lt;/p>
&lt;p>Five things a router has to get right for its hashes to match the engine&amp;rsquo;s:&lt;/p>
&lt;p>The seed. Since 0.29, &lt;code>NONE_HASH&lt;/code> is deterministic from a fixed seed, so two different processes produce the same hashes with nothing configured. It is a silent change of semantics from earlier versions, which required setting &lt;code>PYTHONHASHSEED&lt;/code>. With the &lt;code>xxhash&lt;/code> algorithms it is still needed.&lt;/p>
&lt;p>The block size. By default 16 tokens, but the attention backend can override it if the user did not pass &lt;code>--block-size&lt;/code>: 256 with sliding-window sparse attention, 64 with ROCm&amp;rsquo;s AITER, 64 or more with FlashAttention on XPU. A router must not assume 16, it has to read it from the event.&lt;/p>
&lt;p>The hashing granularity, which is not necessarily the physical block size. There is &lt;code>--prefix-match-unit&lt;/code>, which with several cache groups resolves to the greatest common divisor of the cacheable groups&amp;rsquo; sizes.&lt;/p>
&lt;p>The extra keys, in their order: LoRA adapter name (by name, not by identifier), multimodal identifiers with their relative offset inside the block, &lt;code>cache_salt&lt;/code> only in block zero, and the hash of the prompt embeddings. Sampling parameters do not go in, which is correct: the prefix KV does not depend on the temperature.&lt;/p>
&lt;p>And truncation. By default the hashes travel over the wire as 64-bit integers truncated from a SHA256, not as the full bytes. It can be disabled with &lt;code>VLLM_KV_EVENTS_USE_INT_BLOCK_HASHES=0&lt;/code>. With the default value, a router has a theoretical collision risk that the engine does not have.&lt;/p>
&lt;h3 id="the-hits-are-invisible-by-default">The hits are invisible by default&lt;/h3>
&lt;p>This is the detail that takes the most effort to discover and has the most consequences. The events are incremental: only what gets newly cached is announced. A reused block generates no event.&lt;/p>
&lt;p>A router that only listens sees the additions and the removals, but does not see the hits. Its notion of what is recent and what is cold drifts away from the engine&amp;rsquo;s real LRU with nothing to warn it.&lt;/p>
&lt;p>A full mode exists, and it is per request. &lt;code>sampling_params.extra_args[&amp;quot;kv_cache_report_mode&amp;quot;] = &amp;quot;full&amp;quot;&lt;/code> makes &lt;code>BlockStored&lt;/code> events be emitted for blocks that were hits too. The function&amp;rsquo;s docstring says so plainly: it generates events so that external consumers, &amp;ldquo;for example a gateway&amp;rdquo;, know which blocks were reused. That is to say: for this to work, the router has to inject a parameter into every request it forwards.&lt;/p>
&lt;h3 id="the-metrics-and-what-broke">The metrics, and what broke&lt;/h3>
&lt;p>For aggregate measurement two counters remain: &lt;code>vllm:prefix_cache_queries_total&lt;/code> and &lt;code>vllm:prefix_cache_hits_total&lt;/code>, both in tokens and not in requests, with model and engine labels. There are two equivalents for the external connector, which allow a hit in HBM to be distinguished from one brought in from CPU or NVMe.&lt;/p>
&lt;p>And there are two removals that break existing configurations. &lt;code>vllm:gpu_prefix_cache_hit_rate&lt;/code> no longer exists: zero appearances in the repository. It was deprecated in 0.8, hidden in 0.9 and removed in 0.10. And &lt;code>vllm:gpu_cache_usage_perc&lt;/code> was renamed to &lt;code>vllm:kv_cache_usage_perc&lt;/code>, which breaks Grafana panels and autoscalers that depended on the old name.&lt;/p>
&lt;p>The hit rate has to be derived in PromQL with &lt;code>rate()&lt;/code> over the two counters. The one that appears in the text log is something else: it uses a window of the last thousand requests.&lt;/p>
&lt;p>And it has a bias you need to know about before taking decisions with it. When a request is evicted, its blocks return to the free queue keeping the hash, so the later reprefill finds them again and &lt;strong>counts as a hit&lt;/strong>. A high hit rate can mean useful reuse between users or it can mean the engine is evicting and recomputing in a loop. The internal statistics separate the two things with a &lt;code>preempted&lt;/code> field, which is not exposed as a Prometheus metric.&lt;/p>
&lt;h3 id="the-last-token">The last token&lt;/h3>
&lt;p>A small detail with an effect on long conversations: the maximum hit length is the request&amp;rsquo;s token count minus one. There is always something to recompute in order to get logits. Since the reservation requires alignment to the block size, in practice that can force recomputing the whole final block. With blocks of 16 it is noise. With a backend that prefers 256, it is up to 256 tokens recomputed per turn.&lt;/p>
&lt;h2 id="the-ones-that-do-do-it">The ones that do do it&lt;/h2>
&lt;h3 id="vllm-production-stack">vLLM production-stack&lt;/h3>
&lt;p>Eight routing logics in a single &lt;code>--routing-logic&lt;/code> selector: &lt;code>roundrobin&lt;/code>, &lt;code>session&lt;/code>, &lt;code>kvaware&lt;/code>, &lt;code>loadaware&lt;/code>, &lt;code>prefixaware&lt;/code>, &lt;code>disaggregated_prefill&lt;/code>, &lt;code>disaggregated_prefill_orchestrated&lt;/code> and &lt;code>priority&lt;/code>. Two of them, &lt;code>loadaware&lt;/code> and &lt;code>priority&lt;/code>, are not in the Helm chart&amp;rsquo;s enum and are only enabled through &lt;code>extraArgs&lt;/code>.&lt;/p>
&lt;p>The naming trap is that &lt;code>prefixaware&lt;/code> and &lt;code>kvaware&lt;/code> are radically different things.&lt;/p>
&lt;p>&lt;code>prefixaware&lt;/code> is a hash trie in the router&amp;rsquo;s process. It chunks the prompt string every 128 &lt;strong>characters&lt;/strong>, not tokens, and hashes each chunk with 64-bit xxhash. Its own docstring says so: the chunk size is &amp;ldquo;in number of characters&amp;rdquo;. That 128 is hardcoded, there is no flag or Helm value to change it.&lt;/p>
&lt;p>Four consequences come out of that. &lt;code>--prefix-min-match-length&lt;/code> is measured in characters and is quantised to multiples of 128. A shared prefix that differs by one character inside the first chunk breaks the whole match, because there is no partial match inside a chunk. For chat, it concatenates the content of all the messages with newlines and without applying the chat template, so the indexed string is not what the engine tokenises. And the tie-break between tied replicas is &lt;code>random.choice&lt;/code>, without looking at load.&lt;/p>
&lt;p>The trie has no notion of saturation. A popular prefix concentrates all the traffic on the same replica indefinitely. The docstring assumes the simplified model and declares it: &amp;ldquo;we assume there is no prefix cache eviction&amp;rdquo;.&lt;/p>
&lt;p>It has no pruning either. &lt;code>HashTrie&lt;/code> only has insertion and longest prefix lookup; there is no deletion method. When a replica disappears, its URLs stay forever in every node&amp;rsquo;s sets, and filtering happens at query time. It works, and the trie grows monotonically: each distinct prompt adds its length divided by 128 permanent nodes, with no bound, no TTL and no LRU. The router&amp;rsquo;s memory limit in the chart is 1,000 MiB. By contrast, the round-robin router in the same file does bound its caches to 1,024 entries.&lt;/p>
&lt;p>And it dies on every restart: it is in-process memory state, with no snapshot and no initial load.&lt;/p>
&lt;p>&lt;code>kvaware&lt;/code> and &lt;code>loadaware&lt;/code> are better and have a different price. They do not index themselves: they ask the LMCache controller, which knows about real tokens. That requires &lt;code>lmcache==0.3.11&lt;/code> and &lt;code>vllm==0.13.0&lt;/code> pinned exactly, and the official router image drags in the whole of vLLM. The limitation to read before planning anything: they only look at &lt;code>request_json.get(&amp;quot;prompt&amp;quot;, &amp;quot;&amp;quot;)&lt;/code>, with an open TODO for chat completions. With &lt;code>/v1/chat/completions&lt;/code>, both tokenise an empty string.&lt;/p>
&lt;p>&lt;code>loadaware&lt;/code> is the only one of the three that breaks affinity on saturation, with the formula &lt;code>score = relative_match - beta * relative_load&lt;/code> and &lt;code>beta = 1.0&lt;/code> by default. The authors&amp;rsquo; own reading: a replica at twice the average load loses the equivalent of a full cache hit. The guide suggests 0.25 to favour cache and 2.0 to favour balancing.&lt;/p>
&lt;p>On published results, the documentation&amp;rsquo;s benchmarking file literally says that the measurement platform is coming soon. The project blog&amp;rsquo;s two posts claim between three and ten times lower response latency, and only one declares a partial configuration: Llama 3.1 70B on four nodes with tensor parallelism 2 over 80 GB A100s, inputs of 9K tokens and outputs of 10, against AIBrix 0.2.0 and a flat deployment. Charts with no labelled points, no absolute figures and no declaration of which routing logic was used in each.&lt;/p>
&lt;h3 id="llm-d">llm-d&lt;/h3>
&lt;p>There has been a reorganisation here that invalidates a good part of the third-party documentation. The Endpoint Picker code left the Gateway API Inference Extension repository and was merged into llm-d&amp;rsquo;s, which has been renamed to llm-d Router. The Kubernetes project keeps the &lt;code>InferencePool&lt;/code> API and a lightweight reference EPP that does nothing about prefixes. If you look for KV-aware routing in the extension&amp;rsquo;s repository, today it is not there.&lt;/p>
&lt;p>The &lt;code>InferencePool&lt;/code> is at &lt;code>v1&lt;/code> and is GA. The other two APIs moved to the &lt;code>llm-d.ai&lt;/code> group at &lt;code>v1alpha2&lt;/code>, and when they coexist with the old ones the EPP prefers the new ones and ignores the old.&lt;/p>
&lt;p>Scoring is a weighted sum. The default weights, when no configuration is passed: queue 2, KV utilisation 2, prefix 3. Any scorer referenced without a weight counts as 1.0. There is no configuration through environment variables: the old &lt;code>ENABLE_*_SCORER&lt;/code> no longer exist, everything goes in an &lt;code>EndpointPickerConfig&lt;/code> in YAML.&lt;/p>
&lt;p>The prefix scorer consumes an attribute published by a producer, and there are two producers.&lt;/p>
&lt;p>The &lt;strong>approximate&lt;/strong> one computes the hashes in the EPP itself without talking to the engine, and keeps a local LRU per pod and block pair that is populated after the decision. It is an approximation by construction. It has a hard floor: a configured block size below 64 tokens is raised to 64 at request time, because the index stores one entry per pod and block and going down to 16 would multiply memory by four.&lt;/p>
&lt;p>The &lt;strong>exact&lt;/strong> one builds the real index from the engine&amp;rsquo;s ZMQ events, with one subscriber per pod that is installed and torn down with the endpoint&amp;rsquo;s registration and deregistration. It has adapters for vLLM and SGLang, deduplicates evictions by reference counting, and the replay buffer that allows the index to be rebuilt after an EPP restart requires vLLM 0.26.0 or above.&lt;/p>
&lt;p>The trap is in the tokeniser. The EPP tokenises through a plugin, and the default backend is &lt;code>estimate&lt;/code>: byte packing with no tokeniser, roughly 4 bytes per token, with identifiers that do not correspond to real engine tokens. The exact scorer needs real tokens, and for that you have to explicitly configure the &lt;code>vllm&lt;/code> backend, which does HTTP against the engine&amp;rsquo;s &lt;code>/v1/completions/render&lt;/code> endpoints. If it is omitted, the auto-created &lt;code>estimate&lt;/code> satisfies the dependency and the system &lt;strong>degrades silently&lt;/strong>.&lt;/p>
&lt;p>On maturity, the file itself declares it: all in-tree plugins are in alpha or beta, and they will be promoted to stable when the project approaches 1.0. An alpha plugin configured without the corresponding flag makes the EPP fail to start.&lt;/p>
&lt;p>And on replicas, the warning is explicit: active-active mode should be avoided with approximate prefix routing, because the EPP replicas do not share state and each one only sees the traffic it has served itself, which significantly degrades the hit rate. The exact index is safe in high availability, and even so the reference guides fix a single replica because the accounting of in-flight requests is local to the process.&lt;/p>
&lt;p>The cost is the best documented in the whole field, and it deserves a table. Simulator with Qwen3 8B, 100 pods, 100K input tokens:&lt;/p>
&lt;table>
&lt;thead>
&lt;tr>
&lt;th>Requests/s&lt;/th>
&lt;th>Cap on tokens to match&lt;/th>
&lt;th>Peak CPU&lt;/th>
&lt;th>Peak memory&lt;/th>
&lt;/tr>
&lt;/thead>
&lt;tbody>
&lt;tr>
&lt;td>5.0&lt;/td>
&lt;td>4,096&lt;/td>
&lt;td>1.19 cores&lt;/td>
&lt;td>0.26 GiB&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>5.0&lt;/td>
&lt;td>100,000&lt;/td>
&lt;td>3.82 cores&lt;/td>
&lt;td>0.65 GiB&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>98.7&lt;/td>
&lt;td>4,096&lt;/td>
&lt;td>35.17 cores&lt;/td>
&lt;td>2.46 GiB&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>98.8&lt;/td>
&lt;td>100,000&lt;/td>
&lt;td>46.50 cores&lt;/td>
&lt;td>3.41 GiB&lt;/td>
&lt;/tr>
&lt;/tbody>
&lt;/table>
&lt;p>With outputs of 10K tokens at 50 requests per second, 32.53 cores and 12.54 GiB. The maintainers&amp;rsquo; sizing rule is half a core to one core per request per second on large agentic loads. And idle consumption scales with the number of pods because of continuous scraping: with 100 pods, about 7.5 cores with no traffic.&lt;/p>
&lt;h3 id="nvidia-dynamo">NVIDIA Dynamo&lt;/h3>
&lt;p>A precise event-driven index, with a global radix tree in each frontend&amp;rsquo;s process. Since all the replicas consume the same event plane, there is no need to synchronise routers with each other for the prefix state.&lt;/p>
&lt;p>The cost function is explicit and weighted. It discounts from the prefill the blocks that overlap in GPU, host and disk with different weights (1.0 for the overlap credit, 0.75 for host, 0.25 for disk), and adds the potential decode blocks and the active requests.&lt;/p>
&lt;p>There is a parameter to look at before deploying: &lt;code>--router-kv-overlap-score-credit-decay&lt;/code> is at &lt;strong>0&lt;/strong> by default, that is, disabled. Its description says what it is for: preventing loaded, cache-rich replicas from winning again and again while the freshly autoscaled ones receive too little traffic. With the default value, a new pod has no cache, therefore never wins the scoring, therefore never generates cache.&lt;/p>
&lt;p>The approximate mode exists (&lt;code>--no-router-kv-events&lt;/code>, with a TTL of 120 seconds) and the documentation says it is not the recommended route in production.&lt;/p>
&lt;p>Dynamo is the most honest project in its measurement methodology: its comparison guide recommends contrasting &lt;code>--router-mode random&lt;/code> against &lt;code>kv&lt;/code> over the public Mooncake trace from FAST'25, with a declared cache ratio of 59 %, and it generates the table locally instead of publishing a number.&lt;/p>
&lt;h3 id="the-rest-of-the-field">The rest of the field&lt;/h3>
&lt;p>&lt;strong>SGLang&lt;/strong> has renamed its router to &lt;code>sgl-model-gateway&lt;/code>, version 0.3.2 within SGLang 0.5.19. Its &lt;code>cache_aware&lt;/code> policy is an approximate radix tree over raw characters, with switching to shortest queue when the system is unbalanced according to two combined thresholds. One detail that can cost an afternoon: the Rust defaults and the Python CLI defaults &lt;strong>do not match&lt;/strong>, and on the maximum tree size they differ by four orders of magnitude (10,000 nodes against 2^26).&lt;/p>
&lt;p>&lt;strong>AIBrix&lt;/strong> 0.7.0 offers a standard mode with a local hash table and a KV event synchronisation mode behind a flag and a build tag, with vLLM 0.7.0 or above.&lt;/p>
&lt;p>&lt;strong>Ray Serve LLM&lt;/strong> has &lt;code>PrefixCacheAffinityRouter&lt;/code> with an explicit alpha API warning, a character-based prefix tree in a decoupled actor, and its imbalance threshold at infinity by default, that is, out of the box it never breaks affinity on load.&lt;/p>
&lt;p>&lt;strong>Envoy AI Gateway&lt;/strong> 1.1.0 implements none of this and delegates to &lt;code>InferencePool&lt;/code> and the EPP. &lt;strong>KServe&lt;/strong> the same, with its &lt;code>LLMInferenceService&lt;/code> at &lt;code>v1alpha1&lt;/code>. &lt;strong>Mooncake Conductor&lt;/strong> is still an unimplemented proposal.&lt;/p>
&lt;h3 id="comparison-table">Comparison table&lt;/h3>
&lt;table>
&lt;thead>
&lt;tr>
&lt;th>Project&lt;/th>
&lt;th>Version and date&lt;/th>
&lt;th>Index&lt;/th>
&lt;th>Where it lives&lt;/th>
&lt;th>Kubernetes&lt;/th>
&lt;th>Declared maturity&lt;/th>
&lt;/tr>
&lt;/thead>
&lt;tbody>
&lt;tr>
&lt;td>production-stack &lt;code>prefixaware&lt;/code>&lt;/td>
&lt;td>main branch, 09-Sep-2026&lt;/td>
&lt;td>Approximate, character trie, no eviction&lt;/td>
&lt;td>Router process&lt;/td>
&lt;td>Optional&lt;/td>
&lt;td>WIP in the README&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>production-stack &lt;code>kvaware&lt;/code>/&lt;code>loadaware&lt;/code>&lt;/td>
&lt;td>same&lt;/td>
&lt;td>Exact via LMCache controller&lt;/td>
&lt;td>LMCache controller&lt;/td>
&lt;td>Optional&lt;/td>
&lt;td>No chat completions&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>llm-d Router approximate&lt;/td>
&lt;td>v0.10.0, 17-Aug-2026&lt;/td>
&lt;td>Approximate, LRU per pod&lt;/td>
&lt;td>EPP process&lt;/td>
&lt;td>Yes&lt;/td>
&lt;td>Beta, active-active discouraged&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>llm-d Router exact&lt;/td>
&lt;td>same&lt;/td>
&lt;td>Exact through ZMQ events&lt;/td>
&lt;td>EPP process&lt;/td>
&lt;td>Yes&lt;/td>
&lt;td>Beta&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>NVIDIA Dynamo&lt;/td>
&lt;td>v1.4.2, 27-Aug-2026&lt;/td>
&lt;td>Exact, event-driven radix tree&lt;/td>
&lt;td>Frontend process&lt;/td>
&lt;td>No&lt;/td>
&lt;td>GA with experimental subsystems&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>SGLang &lt;code>sgl-model-gateway&lt;/code>&lt;/td>
&lt;td>0.3.2, 03-Sep-2026&lt;/td>
&lt;td>Approximate, character radix&lt;/td>
&lt;td>Gateway process&lt;/td>
&lt;td>No&lt;/td>
&lt;td>Default policy&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>AIBrix&lt;/td>
&lt;td>v0.7.0, 16-Jun-2026&lt;/td>
&lt;td>Both&lt;/td>
&lt;td>Gateway plugin&lt;/td>
&lt;td>Yes&lt;/td>
&lt;td>Synchronisation behind a flag&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>Ray Serve LLM&lt;/td>
&lt;td>Ray 2.58&lt;/td>
&lt;td>Approximate, characters&lt;/td>
&lt;td>Ray actor&lt;/td>
&lt;td>No&lt;/td>
&lt;td>Declared alpha&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>Envoy AI Gateway&lt;/td>
&lt;td>v1.1.0, 21-Aug-2026&lt;/td>
&lt;td>None of its own&lt;/td>
&lt;td>Delegates to EPP&lt;/td>
&lt;td>Yes&lt;/td>
&lt;td>Stable 1.x API&lt;/td>
&lt;/tr>
&lt;/tbody>
&lt;/table>
&lt;h2 id="how-much-it-really-improves">How much it really improves&lt;/h2>
&lt;p>Here two types of source have to be separated.&lt;/p>
&lt;p>&lt;strong>The projects&amp;rsquo; own numbers.&lt;/strong> Red Hat published the llm-d case in May: Qwen3 32B, eight vLLM pods over 16 H100s with tensor parallelism 2, synthetic shared-prefix load with 150 groups of five prompts, 6,000 tokens of system prompt, 1,200 of question and 1,000 of output. Result: up to 109 % more throughput and up to 99 % less TTFT, around 200 concurrent users within SLO against around 20. The repository&amp;rsquo;s result files give more detail: peak throughput from 6,986 to 14,892 output tokens per second, p90 TTFT from 135.5 seconds to 0.26.&lt;/p>
&lt;p>That same file publishes the regression, and that is why it is worth reading: p50 inter-token latency rises by 22.4 % with vLLM and by 45.4 % with SGLang. The trade-off is declared: routing by affinity concentrates more concurrent work on the pods with a warm cache.&lt;/p>
&lt;p>It is the best possible case. A synthetic shared prefix and a reference that is a flat Kubernetes &lt;code>Service&lt;/code>.&lt;/p>
&lt;p>&lt;strong>The independent measurements.&lt;/strong> There are three and they say different things.&lt;/p>
&lt;p>&lt;em>CacheRoute&lt;/em>, from Meta, August 2026, is the most complete. Llama 3.3 70B in fp8, 30 targets with tensor parallelism 2 over 60 H100s, a semi-synthetic trace from production telemetry with 128,824 business keys, and all the alternatives reimplemented in the same harness instead of comparing against a dumb balancer. It obtains 176 requests per second with a p99 SLO of 3.5 seconds against 76 for the best competitor, and a hit rate of 93.2 % against 72.0 %.&lt;/p>
&lt;p>And it publishes two negative findings that nobody else publishes. There are load regimes where affinity &lt;strong>reduces&lt;/strong> capacity to between half and two thirds of the reference, to the point that the authors make a mirrored-traffic trial mandatory before deploying. And growing the fleet can cool a prefix even though total cache capacity grows, because the time between revisits stretches beyond the eviction window. Scaling horizontally can make the hit rate worse.&lt;/p>
&lt;p>&lt;em>GORGO&lt;/em>, June 2026, measures on a multi-region deployment with a dataset where intra-user prefix reuse is 89.4 % and the average prompt is almost 18,000 tokens. An improvement of 6.9 to 15.5 % in p95 TTFT. With extremely high reuse, the improvement is single-digit, because network latency and queuing dominate.&lt;/p>
&lt;p>&lt;em>LAAR&lt;/em>, from IBM Research Tokyo, April 2026, is the uncomfortable counterpoint: in its comparison, session affinity routing was the &lt;strong>worst&lt;/strong> of the baselines, and at 64K contexts a load-based routing gives lower absolute latency at the cost of fewer hits.&lt;/p>
&lt;p>The joint reading fits in three conditions. The big improvements require high, concentrated prefix reuse, long prompts, and a weak reference. Take away any of the three and the numbers deflate.&lt;/p>
&lt;h2 id="the-five-failure-modes">The five failure modes&lt;/h2>
&lt;p>&lt;strong>Hot spot from affinity.&lt;/strong> The busiest bucket inherits the load&amp;rsquo;s skew, and tail latency starts following the hottest target instead of the fleet average. Mitigations exist in the four main products and in three of the four they ship &lt;strong>switched off&lt;/strong>: Dynamo&amp;rsquo;s credit decay at 0, Ray&amp;rsquo;s imbalance threshold at infinity, and production-stack&amp;rsquo;s &lt;code>prefixaware&lt;/code> with none at all.&lt;/p>
&lt;p>&lt;strong>Starvation of the freshly scaled pod.&lt;/strong> A new pod has no cache, so it never wins the scoring, so it never warms up. Dynamo documents it as the reason for the decay parameter. I found no primary documentation from any of the projects about the specific interaction with KEDA.&lt;/p>
&lt;p>&lt;strong>Cost of the index.&lt;/strong> The llm-d numbers in the previous table are the best public reference that exists. Raising the cap on tokens to match from 16,384 to 400,000 can more than double the EPP&amp;rsquo;s CPU with little traffic.&lt;/p>
&lt;p>&lt;strong>Traffic with unique prefixes.&lt;/strong> Each project degrades to something different. SGLang routes to the smallest tree, which is not the same as the least loaded. Ray falls back to power of two choices. Dynamo has an explicit preset, &lt;code>--load-aware&lt;/code>, to say &amp;ldquo;I want the load model without reuse&amp;rdquo;. And production-stack&amp;rsquo;s &lt;code>prefixaware&lt;/code> keeps inserting into the trie, that is, keeps paying the cost while receiving nothing.&lt;/p>
&lt;p>&lt;strong>Sibling bursts.&lt;/strong> Parallel sampling, best of N, or an agent that opens five branches at once. With event-driven routing, no engine has yet emitted the block stored event, all the siblings score zero overlap, and the prefix is precomputed on every replica. Dynamo has a prediction TTL for this; the others do not.&lt;/p>
&lt;h2 id="the-architecture-that-comes-out-of-all-of-the-above">The architecture that comes out of all of the above&lt;/h2>
&lt;p>Two layers, with the responsibilities separated.&lt;/p>
&lt;p>On top, LiteLLM, with what it does do well: non-forgeable identity through the virtual key, budgets, spend logging, concurrency limits, fallback between providers and API unification. For the gateway, each model group has &lt;strong>a single destination&lt;/strong>: the URL of the router below.&lt;/p>
&lt;div class="highlight">&lt;pre tabindex="0" class="chroma">&lt;code class="language-yaml" data-lang="yaml">&lt;span class="line">&lt;span class="cl">&lt;span class="nt">model_list&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>- &lt;span class="nt">model_name&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">qwen-30b&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">litellm_params&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">model&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">hosted_vllm/Qwen3-30B&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">api_base&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">http://kv-router.inferencia.svc:8000/v1&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">api_key&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">os.environ/VLLM_KEY&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w">&lt;/span>&lt;span class="nt">router_settings&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">routing_strategy&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">simple-shuffle&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">num_retries&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="m">1&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">optional_pre_call_checks&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="p">[&lt;/span>&lt;span class="s2">&amp;#34;session_affinity&amp;#34;&lt;/span>&lt;span class="p">]&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">deployment_affinity_ttl_seconds&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="m">3600&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w">&lt;/span>&lt;span class="nt">general_settings&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">max_in_flight_requests_per_worker&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="m">64&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">max_queued_requests_per_worker&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="m">64&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">admission_queue_timeout_seconds&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="m">1.0&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>Below, the KV-aware router, inside the engine&amp;rsquo;s domain, talking to the vLLM replicas and consuming their events.&lt;/p>
&lt;p>Four consequences of this separation that have to be accepted before building it.&lt;/p>
&lt;p>Per-replica visibility in LiteLLM is lost. For the gateway there is a single deployment, so its cooldowns, its per-deployment counters and its strategies stop meaning anything. Observability of which replica served what goes downstairs.&lt;/p>
&lt;p>Retries stack up. The client SDK retries, LiteLLM retries, and the router below may have its own failover. In &lt;a href="https://blog.lo0.es/en/posts/humans-agents-same-gateway-priority-traffic-separation/">the previous article&lt;/a> 45 calls per turn came out with three layers; with four it is worse. Lowering &lt;code>num_retries&lt;/code> in the gateway to 1, or to 0, is the sensible decision when there is a router below that already retries.&lt;/p>
&lt;p>LiteLLM&amp;rsquo;s session affinity stops making sense for the KV, because it no longer chooses a replica. It is kept if it is used for something else, such as pinning a conversation to a specific provider.&lt;/p>
&lt;p>And the choice of the router below conditions the rest of the cluster far more than the gateway&amp;rsquo;s does. llm-d requires Kubernetes 1.32 or above, Gateway API, the inference extension and a conformant Gateway. Dynamo does not require Kubernetes. production-stack has a Helm chart and works outside it. That decision is the expensive one, not the gateway&amp;rsquo;s.&lt;/p>
&lt;h2 id="when-not-to-build-this">When not to build this&lt;/h2>
&lt;p>Three cases where the correct answer is not to do it, at least not yet.&lt;/p>
&lt;p>&lt;strong>A single replica per model.&lt;/strong> With one replica, the engine&amp;rsquo;s prefix cache already does all the work and there is nothing to route. That is the case for a good part of the &lt;a href="https://blog.lo0.es/en/posts/vllm-kubernetes-llm-inference-that-scales/">four H100&lt;/a> deployments with one large model and tensor parallelism.&lt;/p>
&lt;p>&lt;strong>Low or scattered prefix reuse.&lt;/strong> Before building anything, the measurement is &lt;code>rate(vllm:prefix_cache_hits_total[5m]) / rate(vllm:prefix_cache_queries_total[5m])&lt;/code> per replica, with a mental correction for the bias from reprefills after eviction. If it is already high with blind distribution, reuse is concentrated inside each conversation, and there LiteLLM&amp;rsquo;s session affinity costs one line of configuration and resolves the case.&lt;/p>
&lt;p>&lt;strong>Traffic where the network dominates.&lt;/strong> The GORGO result is the warning: with enormous prompts and a distributed deployment, the improvement is eaten up by queuing and network latency.&lt;/p>
&lt;p>The case where it does pay off is specific: several replicas of the same model, a large system prompt shared between different users, or a RAG with a corpus of documents that repeat across requests. There the prefill saved is real and it is most of the work.&lt;/p>
&lt;h2 id="checklist">Checklist&lt;/h2>
&lt;ul>
&lt;li>Measure the current hit rate per replica with the two vLLM counters before touching anything, and check whether the engine is evicting with &lt;code>vllm:num_preemptions_total&lt;/code>.&lt;/li>
&lt;li>Check that prefix caching is switched on. It is no longer &lt;code>--enable-prefix-caching&lt;/code> that switches it on: the default value is derived from the model and the log of the decision is at DEBUG level. What is actionable today is &lt;code>--no-enable-prefix-caching&lt;/code> to switch it off.&lt;/li>
&lt;li>Audit the prompts before routing: a timestamp or a session identifier at the start of the system prompt breaks the match from block zero and no routing layer fixes it.&lt;/li>
&lt;li>If the KV event stream is enabled, size the bandwidth by tokens per second and watch discarding at the high water mark. With data parallelism, subscribe to one port per rank.&lt;/li>
&lt;li>If an exact router is chosen, verify the vLLM version against the one the replay buffer requires.&lt;/li>
&lt;li>If llm-d is chosen, explicitly configure the &lt;code>vllm&lt;/code> tokenisation backend. The default &lt;code>estimate&lt;/code> does not fail, it degrades.&lt;/li>
&lt;li>If production-stack is chosen with &lt;code>kvaware&lt;/code> or &lt;code>loadaware&lt;/code>, check first whether the traffic goes through &lt;code>/v1/chat/completions&lt;/code>, because today it does not work there.&lt;/li>
&lt;li>Review the load-based affinity-breaking parameter, because in three of the four main projects it ships disabled.&lt;/li>
&lt;li>Lower &lt;code>num_retries&lt;/code> in LiteLLM when there is a router below that already does failover.&lt;/li>
&lt;li>Trial with mirrored traffic before changing production. It is the explicit recommendation of the only independent measurement that went looking for bad regimes, and found them.&lt;/li>
&lt;/ul>
&lt;h2 id="traps-and-things-that-are-not-what-they-seem">Traps and things that are not what they seem&lt;/h2>
&lt;ul>
&lt;li>&lt;strong>LiteLLM has no prefix routing in any version.&lt;/strong> Neither the open one nor the paid one. I got it wrong in June.&lt;/li>
&lt;li>&lt;strong>&lt;code>prompt_caching&lt;/code> does nothing if the client does not send &lt;code>cache_control&lt;/code>&lt;/strong>, and with the default automatic injection it fails on every turn because it marks the last message.&lt;/li>
&lt;li>&lt;strong>That pin&amp;rsquo;s TTL is 300 hardcoded seconds&lt;/strong>, incompatible with the one-hour caches that LiteLLM itself knows how to request.&lt;/li>
&lt;li>&lt;strong>production-stack&amp;rsquo;s &lt;code>prefixaware&lt;/code> indexes characters, not tokens&lt;/strong>, in non-configurable chunks of 128, and its trie is never purged and models no eviction.&lt;/li>
&lt;li>&lt;strong>production-stack&amp;rsquo;s &lt;code>kvaware&lt;/code> and &lt;code>loadaware&lt;/code> do not support chat completions.&lt;/strong> They tokenise an empty string.&lt;/li>
&lt;li>&lt;strong>The default tokeniser in llm-d&amp;rsquo;s EPP is a byte estimate&lt;/strong>, and with it the exact scorer degrades silently.&lt;/li>
&lt;li>&lt;strong>llm-d&amp;rsquo;s active-active mode is discouraged with approximate prefixes&lt;/strong>, and the reference guides fix one replica even with the exact one.&lt;/li>
&lt;li>&lt;strong>Dynamo&amp;rsquo;s overlap credit decay ships at zero&lt;/strong>, which can leave a freshly autoscaled replica with no traffic.&lt;/li>
&lt;li>&lt;strong>The SGLang router&amp;rsquo;s default values differ between the Rust and the Python CLI&lt;/strong>, by as much as four orders of magnitude.&lt;/li>
&lt;li>&lt;strong>&lt;code>vllm:gpu_prefix_cache_hit_rate&lt;/code> no longer exists&lt;/strong> and &lt;code>vllm:gpu_cache_usage_perc&lt;/code> is now called &lt;code>vllm:kv_cache_usage_perc&lt;/code>. Old panels and autoscalers are measuring nothing.&lt;/li>
&lt;li>&lt;strong>The hit rate counts the reprefill after eviction as a hit.&lt;/strong> A high number can be reuse or it can be thrashing.&lt;/li>
&lt;li>&lt;strong>KV events do not publish the hits by default&lt;/strong>, only the additions, unless full mode is requested request by request.&lt;/li>
&lt;li>&lt;strong>The hashes travel truncated to 64 bits&lt;/strong> unless it is disabled with an environment variable.&lt;/li>
&lt;li>&lt;strong>The default block size is 16, and the attention backend can change it&lt;/strong> without anyone asking.&lt;/li>
&lt;li>&lt;strong>The Endpoint Picker left the Gateway API Inference Extension repository.&lt;/strong> Looking for it there today leads nowhere.&lt;/li>
&lt;/ul>
&lt;h2 id="closing">Closing&lt;/h2>
&lt;p>June&amp;rsquo;s error has a reading that goes beyond the erratum. It came from reading a product&amp;rsquo;s documentation and not its code, and from the fact that LiteLLM&amp;rsquo;s documentation uses the word &amp;ldquo;caching&amp;rdquo; in a pre-call check that does something else. The distance between what a name suggests and what a function does is, at this point in the track, the most repeated pattern of the whole series.&lt;/p>
&lt;p>The architectural conclusion is that KV-aware routing is not a gateway function. It is not one today in LiteLLM and probably should not be: it requires keeping an index synchronised with each engine&amp;rsquo;s memory, replicating its hash function, consuming an event stream and sizing CPU per request per second. That belongs to the engine&amp;rsquo;s domain, not to that of identity and budget.&lt;/p>
&lt;p>What remains is a two-part decision. The first is whether the traffic deserves it, and that is answered with two Prometheus counters and without installing anything. The second is which of the three routers fits the cluster you already have, and there the deciding variable is not the hit rate but whether you are willing to bring in Gateway API and the inference extension, or to pin vLLM 0.13.0, or to operate a Dynamo frontend.&lt;/p>
&lt;p>And the honesty the material demands: the only independent measurement that specifically went looking for regimes where this makes things worse found them, and they are not marginal. Half to two thirds of reference capacity is a serious regression. That no project publishes that side of the curve does not mean it does not exist in theirs.&lt;/p>
&lt;h2 id="see-also">See also&lt;/h2>
&lt;ul>
&lt;li>&lt;a href="https://blog.lo0.es/en/posts/humans-agents-same-gateway-priority-traffic-separation/">Humans and agents on the same gateway&lt;/a>, the session affinity and the pipeline order taken as read here, and the rest of what the gateway can do with two classes of traffic.&lt;/li>
&lt;li>&lt;a href="https://blog.lo0.es/en/posts/llm-inference-router-l7-switchboard/">The LLM inference router&lt;/a>, the June article this one corrects, and the router&amp;rsquo;s other four functions that remain valid.&lt;/li>
&lt;li>&lt;a href="https://blog.lo0.es/en/posts/prefix-cache-hit-rate-engineering/">Prefix cache: hit rate engineering&lt;/a>, the template audit to do before even considering routing anything.&lt;/li>
&lt;li>&lt;a href="https://blog.lo0.es/en/posts/kv-cache-working-memory-llm-inference/">KV cache: fundamentals&lt;/a>, what exactly it is that you are trying not to recompute.&lt;/li>
&lt;li>&lt;a href="https://blog.lo0.es/en/posts/long-context-kv-offloading-notebook-desk/">Long context and KV offloading&lt;/a>, LMCache and the memory hierarchy that &lt;code>kvaware&lt;/code> and Dynamo&amp;rsquo;s per-tier weights depend on.&lt;/li>
&lt;li>&lt;a href="https://blog.lo0.es/en/posts/optimising-prefill-vllm-knobs-ttft-will-not-forgive/">Prefill optimisations in vLLM&lt;/a>, the specific work saved when routing gets it right.&lt;/li>
&lt;li>&lt;a href="https://blog.lo0.es/en/posts/vllm-scheduler-step-maitre-d-each-round/">The vLLM scheduler step&lt;/a>, the loop where the KV events are published, once per step.&lt;/li>
&lt;li>&lt;a href="https://blog.lo0.es/en/posts/instrumenting-vllm-otel-measuring-optimisations/">OTel instrumentation in vLLM&lt;/a>, the engine&amp;rsquo;s metrics, including the ones that have changed name.&lt;/li>
&lt;li>&lt;a href="https://blog.lo0.es/en/posts/choosing-oss-gateway-llm-inference/">Choosing the switchboard&lt;/a>, why the gateway is chosen on licence and fit before features, which is exactly what this article illustrates again.&lt;/li>
&lt;li>&lt;a href="https://blog.lo0.es/en/posts/vllm-kubernetes-llm-inference-that-scales/">vLLM on Kubernetes&lt;/a>, the replica deployment that everything above operates on.&lt;/li>
&lt;li>&lt;a href="https://blog.lo0.es/en/posts/sizing-agents-decode-bottleneck/">Sizing for agents&lt;/a>, why without this article&amp;rsquo;s session affinity the capacity calculation describes a different system, with the prefix hit rate measured in production.&lt;/li>
&lt;/ul>
&lt;h2 id="sources">Sources&lt;/h2>
&lt;ul>
&lt;li>LiteLLM, &lt;em>Router - Load Balancing&lt;/em> (estrategias, comprobaciones previas, afinidad): &lt;a href="https://docs.litellm.ai/docs/routing">https://docs.litellm.ai/docs/routing&lt;/a>.&lt;/li>
&lt;li>LiteLLM, código: &lt;code>litellm/router.py&lt;/code>, &lt;code>litellm/router_utils/prompt_caching_cache.py&lt;/code>, &lt;code>litellm/router_utils/pre_call_checks/prompt_caching_deployment_check.py&lt;/code>, &lt;code>litellm/router_utils/pre_call_checks/deployment_affinity_check.py&lt;/code>, &lt;code>litellm/proxy/litellm_pre_call_utils.py&lt;/code>, &lt;code>litellm/proxy/middleware/admission_control_middleware.py&lt;/code>: &lt;a href="https://github.com/BerriAI/litellm">https://github.com/BerriAI/litellm&lt;/a>.&lt;/li>
&lt;li>LiteLLM, incidencia 28427, TTL de afinidad por caché de prompts fijado a cinco minutos: &lt;a href="https://github.com/BerriAI/litellm/issues/28427">https://github.com/BerriAI/litellm/issues/28427&lt;/a>.&lt;/li>
&lt;li>vLLM, código: &lt;code>vllm/v1/core/kv_cache_utils.py&lt;/code>, &lt;code>vllm/v1/core/block_pool.py&lt;/code>, &lt;code>vllm/v1/core/kv_cache_manager.py&lt;/code>, &lt;code>vllm/distributed/kv_events.py&lt;/code>, &lt;code>vllm/config/kv_events.py&lt;/code>, &lt;code>vllm/v1/metrics/loggers.py&lt;/code>, &lt;code>vllm/v1/metrics/stats.py&lt;/code>: &lt;a href="https://github.com/vllm-project/vllm">https://github.com/vllm-project/vllm&lt;/a>.&lt;/li>
&lt;li>vLLM, &lt;em>KV offloading usage&lt;/em> (niveles CPU, filesystem y objeto; compartición entre procesos): &lt;a href="https://docs.vllm.ai/en/latest/features/kv_offloading_usage.html">https://docs.vllm.ai/en/latest/features/kv_offloading_usage.html&lt;/a>.&lt;/li>
&lt;li>vLLM, notas de la versión 0.29.0 (semilla determinista, seguimiento de propiedad en eventos, flags de admisión): &lt;a href="https://github.com/vllm-project/vllm/releases/tag/v0.29.0">https://github.com/vllm-project/vllm/releases/tag/v0.29.0&lt;/a>.&lt;/li>
&lt;li>vLLM, RFC 16669, publicación de bloques KV y métricas: &lt;a href="https://github.com/vllm-project/vllm/issues/16669">https://github.com/vllm-project/vllm/issues/16669&lt;/a>.&lt;/li>
&lt;li>vLLM production-stack, código: &lt;code>src/vllm_router/routers/routing_logic.py&lt;/code>, &lt;code>src/vllm_router/prefix/hashtrie.py&lt;/code>, &lt;code>src/vllm_router/parsers/parser.py&lt;/code>: &lt;a href="https://github.com/vllm-project/production-stack">https://github.com/vllm-project/production-stack&lt;/a>.&lt;/li>
&lt;li>LMCache Lab, resultados de production-stack frente a AIBrix (parte interesada): &lt;a href="https://blog.lmcache.ai/en/2025/03/06/open-source-llm-inference-cluster-performing-10x-faster-than-sota-oss-solution/">https://blog.lmcache.ai/en/2025/03/06/open-source-llm-inference-cluster-performing-10x-faster-than-sota-oss-solution/&lt;/a>.&lt;/li>
&lt;li>llm-d Router, código y documentación de operaciones (pesos por defecto, dimensionamiento del EPP, aviso de activo-activo): &lt;a href="https://github.com/llm-d/llm-d-router">https://github.com/llm-d/llm-d-router&lt;/a>.&lt;/li>
&lt;li>llm-d, guía de enrutado preciso por prefijo y resultados publicados (parte interesada): &lt;a href="https://github.com/llm-d/llm-d">https://github.com/llm-d/llm-d&lt;/a>.&lt;/li>
&lt;li>llm-d Router, incidencia 1290, estado de prefijo no compartido entre réplicas del EPP: &lt;a href="https://github.com/llm-d/llm-d-router/issues/1290">https://github.com/llm-d/llm-d-router/issues/1290&lt;/a>.&lt;/li>
&lt;li>Red Hat, &lt;em>Same 16 GPUs, twice the users&lt;/em> (parte interesada): &lt;a href="https://www.redhat.com/en/blog/same-16-gpus-twice-users-inference-aware-routing-llm-clusters">https://www.redhat.com/en/blog/same-16-gpus-twice-users-inference-aware-routing-llm-clusters&lt;/a>.&lt;/li>
&lt;li>Kubernetes SIG Network, Gateway API Inference Extension (traslado del EPP y del BBR): &lt;a href="https://github.com/kubernetes-sigs/gateway-api-inference-extension">https://github.com/kubernetes-sigs/gateway-api-inference-extension&lt;/a>.&lt;/li>
&lt;li>NVIDIA Dynamo, diseño y ajuste del router KV: &lt;a href="https://github.com/ai-dynamo/dynamo">https://github.com/ai-dynamo/dynamo&lt;/a>.&lt;/li>
&lt;li>SGLang, &lt;code>sgl-model-gateway&lt;/code>: &lt;a href="https://github.com/sgl-project/sglang/tree/main/sgl-model-gateway">https://github.com/sgl-project/sglang/tree/main/sgl-model-gateway&lt;/a>.&lt;/li>
&lt;li>AIBrix, enrutado por caché de prefijo: &lt;a href="https://github.com/vllm-project/aibrix/blob/main/pkg/plugins/gateway/algorithms/prefix_cache_readme.md">https://github.com/vllm-project/aibrix/blob/main/pkg/plugins/gateway/algorithms/prefix_cache_readme.md&lt;/a>.&lt;/li>
&lt;li>Ray Serve LLM, enrutado consciente de prefijo (API en alfa): &lt;a href="https://docs.ray.io/en/latest/serve/llm/user-guides/prefix-aware-routing.html">https://docs.ray.io/en/latest/serve/llm/user-guides/prefix-aware-routing.html&lt;/a>.&lt;/li>
&lt;li>Huang Cheng (Meta), &lt;em>CacheRoute&lt;/em>, agosto de 2026: &lt;a href="https://arxiv.org/html/2608.19677">https://arxiv.org/html/2608.19677&lt;/a>.&lt;/li>
&lt;li>Yuan et al., &lt;em>DualMap&lt;/em>, febrero de 2026: &lt;a href="https://arxiv.org/abs/2602.06502">https://arxiv.org/abs/2602.06502&lt;/a>.&lt;/li>
&lt;li>Ricci Toniolo et al., &lt;em>GORGO&lt;/em>, junio de 2026: &lt;a href="https://arxiv.org/html/2602.11688">https://arxiv.org/html/2602.11688&lt;/a>.&lt;/li>
&lt;li>Yoshimura, Chiba y van de Beek, &lt;em>Accuracy Is Speed&lt;/em> (EuroMLSys &amp;lsquo;26), abril de 2026: &lt;a href="https://arxiv.org/html/2604.15732v1">https://arxiv.org/html/2604.15732v1&lt;/a>.&lt;/li>
&lt;/ul></description></item><item><title>Humans and agents on the same gateway: why `priority` does not prioritise, the 429 that brings down the whole pool, and the two ways to separate traffic</title><link>https://blog.lo0.es/en/posts/humans-agents-same-gateway-priority-traffic-separation/</link><pubDate>Thu, 10 Sep 2026 08:00:00 +0200</pubDate><guid>https://blog.lo0.es/en/posts/humans-agents-same-gateway-priority-traffic-separation/</guid><description>&lt;blockquote>
&lt;p>Fourth article in the operational track of the control layer. The &lt;a href="https://blog.lo0.es/en/posts/litellm-langfuse-operational-pair/">pair with Langfuse&lt;/a> covered observability, &lt;a href="https://blog.lo0.es/en/posts/litellm-day-2-high-availability/">the proxy&amp;rsquo;s day 2&lt;/a> covered availability and &lt;a href="https://blog.lo0.es/en/posts/litellm-virtual-keys-budgets-limits/">virtual keys&lt;/a> covered governance. Here comes the new tenant: agents, which speak the same protocol as humans and behave in a different way. Everything that follows is verified against LiteLLM 1.100.1 and vLLM 0.29.0, both from the first ten days of September 2026.&lt;/p>
&lt;/blockquote>
&lt;h2 id="tldr">TL;DR&lt;/h2>
&lt;p>The day a team connects an agent to the corporate gateway, the platform changes regime without anyone touching a YAML. These are the seven conclusions.&lt;/p>
&lt;p>&lt;strong>LiteLLM&amp;rsquo;s &lt;code>priority&lt;/code> parameter prioritises nothing under normal conditions.&lt;/strong> It is still marked as beta and its queue is only consulted when &lt;strong>all&lt;/strong> the deployments in the group are in cooldown. With free capacity, the only thing it produces is a response header. On top of that, the success branch does not pop the item off the queue, so the list grows indefinitely; and on &lt;code>/chat/completions&lt;/code> the value &lt;code>0&lt;/code>, which is the one used by all the documentation examples, is falsy in Python and disables queuing.&lt;/p>
&lt;p>&lt;strong>Separating traffic is done with two pools, not with priorities.&lt;/strong> The non-forgeable way is &lt;code>model_group_alias&lt;/code> at key or team level: the client always asks for &lt;code>qwen-30b&lt;/code> and the credential decides whether that resolves to &lt;code>qwen-30b-interactivo&lt;/code> or to &lt;code>qwen-30b-agentes&lt;/code>. The way that does not touch clients is &lt;code>tag_regex&lt;/code> against the &lt;code>User-Agent&lt;/code>, with a pattern like &lt;code>^User-Agent: claude-code\/&lt;/code>, which serves to classify load and not to control access, because that header is set by the client.&lt;/p>
&lt;p>&lt;strong>The useful admission control arrives in the 1.101 branch&lt;/strong>, with &lt;code>max_in_flight_requests_per_worker&lt;/code> and a per-process queue that returns 503 with &lt;code>retry-after&lt;/code>. It rejects before authenticating, so what it discards does not appear attributed to any key.&lt;/p>
&lt;p>&lt;strong>&lt;code>max_parallel_requests&lt;/code> is only applied at virtual key level.&lt;/strong> The team, user, end customer and organisation endpoints accept the field and store it in the database, and nobody reads it. &lt;code>global_max_parallel_requests&lt;/code> is a no-op unless the old limiter is reactivated, and &lt;code>general_settings.max_parallel_requests&lt;/code> is read by no code path.&lt;/p>
&lt;p>&lt;strong>vLLM with &lt;code>--scheduling-policy priority&lt;/code> does not push ahead anyone who is already generating.&lt;/strong> The only thing that changes is who it evicts when KV cache runs short. If the agents have filled &lt;code>max_num_seqs&lt;/code>, a chat with the highest priority waits just the same. And the two knobs that in 2025 allowed short prompts to get ahead of long ones, &lt;code>max_num_partial_prefills&lt;/code> and &lt;code>max_long_partial_prefills&lt;/code>, disappeared from the code in 0.27.&lt;/p>
&lt;p>&lt;strong>The retry product reaches 45 calls per agent turn.&lt;/strong> Three from the client SDK times three from the router times up to five fallback groups. The proxy sets &lt;code>max_retries=0&lt;/code> on the SDK it uses itself, but it cannot touch the client&amp;rsquo;s. The timeout is per attempt, not per request, and LiteLLM&amp;rsquo;s &lt;code>Timeout&lt;/code> comes out as a 408, which is retryable.&lt;/p>
&lt;p>&lt;strong>A single 429 takes a deployment out of the pool for five seconds&lt;/strong>, without going through the &lt;code>allowed_fails&lt;/code> threshold. With an agentic burst against a fleet of four replicas, the whole pool enters cooldown and the gateway returns a 429 of its own to everybody, humans included.&lt;/p>
&lt;h2 id="you-are-here-the-gateway-layer-with-two-classes-of-traffic">You are here: the gateway layer with two classes of traffic&lt;/h2>
&lt;p>In the &lt;a href="https://blog.lo0.es/en/posts/anatomy-on-premise-llm-inference-stack-seven-layers/">seven-layer stack&lt;/a> this still lives in the gateway layer, with one difference compared to the previous three articles. There the variable was load: more requests, more teams, more budget to share out. Here the variable is the shape of the load.&lt;/p>
&lt;p>A gateway sized for three hundred engineers using a chat works. The same gateway, with thirty of those engineers running coding agents, serves a fraction of the human traffic with the previous latency and the rest degrades. The GPUs stay just as busy, the utilisation metrics stay good, and the support team gets complaints from people waiting nine seconds for a paragraph.&lt;/p>
&lt;h2 id="the-analogy-the-registry-desk-and-the-courier-with-two-hundred-files">The analogy: the registry desk and the courier with two hundred files&lt;/h2>
&lt;p>A registry office serves people in order of arrival. Citizens come in one at a time, present a piece of paper, and leave. The system is fair and the queue moves.&lt;/p>
&lt;p>One Tuesday a courier turns up with a trolley of two hundred files. He joins the queue like anyone else, because rules are rules, and when his turn comes he occupies the desk for forty minutes. Behind him are twelve people with one piece of paper each. Nobody has broken any rule. The courier has the same right as everyone else and uses the same procedure. What fails is that the office was designed assuming a certain size of transaction and another one three orders of magnitude bigger has turned up.&lt;/p>
&lt;p>The real solutions for an office are three, and all three have an exact equivalent in the platform. Open a desk for bulk deliveries, which is separating pools. Put up a capacity sign and send people back later, which is admission control with its 503. And say that files are handed in during the afternoon, which is the part that on an inference platform can almost never be applied, because the agent does not negotiate schedules.&lt;/p>
&lt;p>What does not work in the office is giving the citizen a priority number when the courier is already at the desk. That number does not interrupt the transaction in progress. It is, literally, what priority does in the two layers of the platform, and it is worth keeping in mind while reading the next section.&lt;/p>
&lt;h2 id="the-two-kinds-of-traffic-measured">The two kinds of traffic, measured&lt;/h2>
&lt;p>Before configuring anything, you have to be able to state the difference in numbers, because every later adjustment comes out of it.&lt;/p>
&lt;table>
&lt;thead>
&lt;tr>
&lt;th>Trait&lt;/th>
&lt;th>Human chat&lt;/th>
&lt;th>Agentic loop&lt;/th>
&lt;/tr>
&lt;/thead>
&lt;tbody>
&lt;tr>
&lt;td>Input per request&lt;/td>
&lt;td>hundreds of tokens&lt;/td>
&lt;td>tens of thousands, and it grows with each turn&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>Output per request&lt;/td>
&lt;td>hundreds of tokens&lt;/td>
&lt;td>from two tokens (a tool call) to several thousand&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>Concurrency per user&lt;/td>
&lt;td>1&lt;/td>
&lt;td>3 to 20 requests in flight&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>The metric that hurts&lt;/td>
&lt;td>time to first token&lt;/td>
&lt;td>total turn throughput&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>Tolerance for waiting&lt;/td>
&lt;td>seconds&lt;/td>
&lt;td>minutes&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>Cancelled requests&lt;/td>
&lt;td>rare&lt;/td>
&lt;td>routine, by the agent&amp;rsquo;s own decision&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>Retries&lt;/td>
&lt;td>the browser&amp;rsquo;s&lt;/td>
&lt;td>the SDK&amp;rsquo;s, plus the loop&amp;rsquo;s&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>Witness when it fails&lt;/td>
&lt;td>the user&lt;/td>
&lt;td>nobody&lt;/td>
&lt;/tr>
&lt;/tbody>
&lt;/table>
&lt;p>Two rows deserve comment because they contradict intuition.&lt;/p>
&lt;p>The first is the metric row. An agent does not perceive time to first token, so on a shared engine it is the natural candidate to give up that metric. The operational conclusion is that the agent pool can be configured for aggressive throughput, with large batches and deep queues, while the interactive pool is configured for the opposite.&lt;/p>
&lt;p>The second is the cancellations row. An agent that launches three speculative continuations and discards two generates two aborted streams per turn. At the gateway, that touches spend accounting, the release of concurrency counters and the open connection to the engine. LiteLLM 1.100 handles it reasonably well, and it did not six months ago.&lt;/p>
&lt;h2 id="what-does-not-work-priority-in-litellm">What does not work: &lt;code>priority&lt;/code> in LiteLLM&lt;/h2>
&lt;p>The first reaction of any operator is to look for a priority field. LiteLLM has one, documents it on its &lt;em>Request Prioritization&lt;/em> page, and that page carries the beta label and the sentence saying it is for testing. The label is correct and has to be taken literally, because behind it there are four things.&lt;/p>
&lt;p>The queue is only consulted when the whole group is in cooldown. The scheduler&amp;rsquo;s &lt;code>poll()&lt;/code> method returns true if there is any healthy deployment, without touching the queue. With available capacity, which is the normal situation, the request goes straight through and priority orders nothing. The only thing that changes is that the response carries the &lt;code>x-litellm-request-prioritization-used&lt;/code> header.&lt;/p>
&lt;p>The queue does not empty. In that success branch there is no &lt;code>heappop&lt;/code> and no call to the cleanup, which only happens in the expiry branch. The list under the key &lt;code>scheduler:queue:{model}&lt;/code> grows without bound, is serialised whole into Redis on every insertion, and there is no maximum size and no discard policy. When the group does enter cooldown, the head of the heap is occupied by fossil entries from requests that finished hours ago, and new ones compare their identifier against that head, never match, and wait until the timeout runs out.&lt;/p>
&lt;p>On &lt;code>/chat/completions&lt;/code>, the highest priority disables the feature. The code reads the value with a &lt;code>kwargs.get(&amp;quot;priority&amp;quot;) or self.default_priority&lt;/code>, and in Python &lt;code>0 or None&lt;/code> is &lt;code>None&lt;/code>. Since in this scheduler the lowest number is the highest priority, &lt;code>priority: 0&lt;/code>, which is the value in the documentation&amp;rsquo;s own examples, goes down the path without queuing. On &lt;code>/completions&lt;/code> the check is done with &lt;code>is not None&lt;/code> and it works; the two routes do not behave the same.&lt;/p>
&lt;p>And &lt;code>default_priority&lt;/code> in &lt;code>router_settings&lt;/code> breaks the request. If the client does not send a priority but the global setting is in place, the value is truthy, the scheduling branch is entered and a function is invoked that requires the positional argument nobody has injected. The setting has no associated test and does not appear in the &lt;code>router_settings&lt;/code> reference.&lt;/p>
&lt;p>There is also a &lt;code>/queue/chat/completions&lt;/code>, marked as experimental and hidden from the OpenAPI schema, which reads &lt;code>data[&amp;quot;priority&amp;quot;]&lt;/code> with no default value: a request without that field raises an exception that comes out as a 400 authentication error, with a message that has no relation to the cause.&lt;/p>
&lt;p>There is a priority feature that is solid, and it is a different thing. The &lt;strong>v3 dynamic limiter&lt;/strong> shares out token and request per minute quota between classes with weights, through &lt;code>litellm.priority_reservation&lt;/code> with values like &lt;code>{&amp;quot;premium&amp;quot;: 0.75, &amp;quot;standard&amp;quot;: 0.25}&lt;/code>. It shares out capacity, it does not order a queue: below the saturation threshold it lends free capacity and above it applies the weights. It never makes anyone wait, either it passes or it returns a 429. It requires Postgres and an enterprise licence, and it is still marked as beta.&lt;/p>
&lt;h2 id="what-does-work-two-pools">What does work: two pools&lt;/h2>
&lt;p>Effective separation is done earlier, by deciding which set of replicas each class of traffic goes to. LiteLLM offers two mechanisms with very different security properties.&lt;/p>
&lt;h3 id="by-credential-model_group_alias-at-key-or-team-level">By credential: &lt;code>model_group_alias&lt;/code> at key or team level&lt;/h3>
&lt;p>Router settings are resolved in the order key, team, global. Among those that accept that override is &lt;code>model_group_alias&lt;/code>, and there lies the piece:&lt;/p>
&lt;div class="highlight">&lt;pre tabindex="0" class="chroma">&lt;code class="language-yaml" data-lang="yaml">&lt;span class="line">&lt;span class="cl">&lt;span class="nt">model_list&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>- &lt;span class="nt">model_name&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">qwen-30b-interactivo&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">litellm_params&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">model&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">hosted_vllm/Qwen3-30B&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">api_base&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">http://vllm-chat.inferencia.svc:8000/v1&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">model_info&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">id&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">chat-01&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">max_input_tokens&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="m">32768&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>- &lt;span class="nt">model_name&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">qwen-30b-agentes&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">litellm_params&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">model&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">hosted_vllm/Qwen3-30B&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">api_base&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">http://vllm-agentes.inferencia.svc:8000/v1&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">model_info&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">id&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">agentes-01&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">max_input_tokens&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="m">131072&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>And on the agent team&amp;rsquo;s key:&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">curl -X POST &lt;span class="s2">&amp;#34;&lt;/span>&lt;span class="nv">$PROXY&lt;/span>&lt;span class="s2">/key/generate&amp;#34;&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> -H &lt;span class="s2">&amp;#34;Authorization: Bearer &lt;/span>&lt;span class="nv">$MASTER_KEY&lt;/span>&lt;span class="s2">&amp;#34;&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> -d &lt;span class="s1">&amp;#39;{
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="s1"> &amp;#34;team_id&amp;#34;: &amp;#34;plataforma-agentes&amp;#34;,
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="s1"> &amp;#34;models&amp;#34;: [&amp;#34;qwen-30b&amp;#34;],
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="s1"> &amp;#34;router_settings&amp;#34;: {
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="s1"> &amp;#34;model_group_alias&amp;#34;: {&amp;#34;qwen-30b&amp;#34;: &amp;#34;qwen-30b-agentes&amp;#34;}
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="s1"> }
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="s1"> }&amp;#39;&lt;/span>
&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>The client asks for &lt;code>qwen-30b&lt;/code> and knows nothing about any of it. The resolution is tied to the credential, so the caller cannot get around it by changing a header. The same list allows &lt;code>routing_strategy&lt;/code>, &lt;code>fallbacks&lt;/code>, &lt;code>context_window_fallbacks&lt;/code>, &lt;code>retry_policy&lt;/code>, &lt;code>cooldown_time&lt;/code> and &lt;code>allowed_fails&lt;/code> to be set per key or team, which gives different reliability policies for each class of traffic on the same proxy instance.&lt;/p>
&lt;p>It should not be confused with &lt;strong>access groups&lt;/strong> (&lt;code>model_info.access_groups&lt;/code>), which are permissions and not routing: they decide whether a key can invoke a model name, not which replica the request goes to.&lt;/p>
&lt;h3 id="by-user-agent-tag_regex">By &lt;code>User-Agent&lt;/code>: &lt;code>tag_regex&lt;/code>&lt;/h3>
&lt;p>When separate credentials cannot be issued, because the agentic client uses the same personal key as that person&amp;rsquo;s chat, LiteLLM classifies by regular expression against the request headers:&lt;/p>
&lt;div class="highlight">&lt;pre tabindex="0" class="chroma">&lt;code class="language-yaml" data-lang="yaml">&lt;span class="line">&lt;span class="cl">&lt;span class="nt">model_list&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>- &lt;span class="nt">model_name&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">qwen-30b&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">litellm_params&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">model&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">hosted_vllm/Qwen3-30B&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">api_base&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">http://vllm-agentes.inferencia.svc:8000/v1&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">tag_regex&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="p">[&lt;/span>&lt;span class="s2">&amp;#34;^User-Agent: claude-code\\/&amp;#34;&lt;/span>&lt;span class="p">,&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="s2">&amp;#34;^User-Agent: .*codex&amp;#34;&lt;/span>&lt;span class="p">]&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">model_info&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>{&lt;span class="nt">id&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">agentes-01}&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>- &lt;span class="nt">model_name&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">qwen-30b&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">litellm_params&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">model&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">hosted_vllm/Qwen3-30B&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">api_base&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">http://vllm-chat.inferencia.svc:8000/v1&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">tags&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="p">[&lt;/span>&lt;span class="s2">&amp;#34;default&amp;#34;&lt;/span>&lt;span class="p">]&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">model_info&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>{&lt;span class="nt">id&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">chat-01}&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w">&lt;/span>&lt;span class="nt">router_settings&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">enable_tag_filtering&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="kc">true&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">tag_filtering_match_any&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="kc">true&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>The proxy composes strings of the form &lt;code>User-Agent: &amp;lt;value&amp;gt;&lt;/code> and tests them with &lt;code>re.search&lt;/code>. Exact tag matching takes precedence over the regex. The documentation itself carries a warning that has to be respected: the &lt;code>User-Agent&lt;/code> is written by the client, so this is traffic classification and not a security boundary. As load sharing it is enough, because an agent that lies about its identity to sneak into the interactive pool is a governance problem, not a routing one.&lt;/p>
&lt;p>The behaviour when no deployment carries the requested tag has a nuance that comes as a surprise in production. If the tag is unknown to the group, the request falls through to the pool marked with &lt;code>tags: [&amp;quot;default&amp;quot;]&lt;/code>. If the tag exists in the group but no healthy deployment carries it at that moment, the request &lt;strong>fails&lt;/strong> with the tag configuration error, even though a default pool is available. To get deterministic fallthrough to the default pool you have to set &lt;code>model_info.allow_fail_open: true&lt;/code>.&lt;/p>
&lt;h3 id="a-different-strategy-per-pool-with-routing_groups">A different strategy per pool with &lt;code>routing_groups&lt;/code>&lt;/h3>
&lt;div class="highlight">&lt;pre tabindex="0" class="chroma">&lt;code class="language-yaml" data-lang="yaml">&lt;span class="line">&lt;span class="cl">&lt;span class="nt">router_settings&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">routing_strategy&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">simple-shuffle&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">routing_groups&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>- &lt;span class="nt">group_name&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">interactivo&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">models&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="p">[&lt;/span>&lt;span class="l">qwen-30b-interactivo]&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">routing_strategy&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">simple-shuffle&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>- &lt;span class="nt">group_name&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">agentes&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">models&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="p">[&lt;/span>&lt;span class="l">qwen-30b-agentes]&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">routing_strategy&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">simple-shuffle&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>The recommendation to leave &lt;code>simple-shuffle&lt;/code> on both is not laziness, and sustaining it requires looking at what the alternatives do on a homogeneous vLLM fleet.&lt;/p>
&lt;p>&lt;code>least-busy&lt;/code> keeps a complete dictionary under a single key and updates it with non-atomic reads and writes, without using &lt;code>INCR&lt;/code>, so with several proxy replicas increments are lost. It has no TTL, so a request that never completes leaves the counter raised forever and that replica is permanently excluded. Counters can go negative. And when the minimum corresponds to an identifier that is no longer in the healthy list, it falls back to &lt;code>random.choice&lt;/code> without warning. The pattern of requests that do not complete is exactly that of an agent that cancels.&lt;/p>
&lt;p>&lt;code>latency-based-routing&lt;/code> measures seconds per output token, not wall-clock latency. A chat turn of twenty tokens looks extremely slow by that metric and an agent generation of two thousand tokens looks extremely fast, so mixing both kinds of traffic in the same group poisons the signal. It also seeds new deployments with zero latency, and with the buffer defaulting to zero, a freshly started replica takes all the traffic until its first success callback.&lt;/p>
&lt;p>&lt;code>usage-based-routing-v2&lt;/code> uses minute windows that reset all at once, and it accounts for tokens when the request completes, so long generations in flight are invisible to the router. With long outputs, it systematically underestimates the real load.&lt;/p>
&lt;p>&lt;code>cost-based-routing&lt;/code> sorts by the sum of the input and output unit prices and takes the first, with no random tie-break. With replicas of the same model at the same price, all the traffic always goes to the same place. And a model that is not in the price map gets a default cost of 5.0 per token, a figure designed to deprioritise it.&lt;/p>
&lt;h2 id="admission-control-the-new-piece-in-the-1101-branch">Admission control: the new piece in the 1.101 branch&lt;/h2>
&lt;p>When the agent pool saturates, what you want is to reject fast and with a code the client understands, not to accumulate requests inside the proxy until memory runs out. That arrives with per-worker admission control, added in the 1.101 branch, in release candidate as this is written.&lt;/p>
&lt;div class="highlight">&lt;pre tabindex="0" class="chroma">&lt;code class="language-yaml" data-lang="yaml">&lt;span class="line">&lt;span class="cl">&lt;span class="nt">general_settings&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">max_in_flight_requests_per_worker&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="m">64&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">max_queued_requests_per_worker&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="m">64&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">admission_queue_timeout_seconds&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="m">1.0&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>It is a semaphore with a per-uvicorn-process queue, without Redis. When the queue fills or the wait expires it returns &lt;strong>503&lt;/strong> with &lt;code>retry-after: 1&lt;/code> and a body with &lt;code>&amp;quot;type&amp;quot;: &amp;quot;overloaded_error&amp;quot;&lt;/code>. It is observed through &lt;code>/health/backlog&lt;/code>, which exposes requests in flight, admitted, queued and rejected, and through the Prometheus &lt;code>litellm_admission_*&lt;/code> metrics, whose rejection counter distinguishes &lt;code>queue_full&lt;/code> from &lt;code>queue_timeout&lt;/code>.&lt;/p>
&lt;p>Two properties have to go into the runbook. The middleware &lt;strong>rejects before authenticating&lt;/strong>, so what is discarded is not attributed to any key in the spend records, and the real number of requests rejected per team cannot be reconstructed from the spend table. And the limits are read on the first request: changing them requires a restart.&lt;/p>
&lt;h2 id="concurrency-per-key-and-the-three-settings-that-do-nothing">Concurrency per key, and the three settings that do nothing&lt;/h2>
&lt;p>&lt;code>max_parallel_requests&lt;/code> is the right brake for a specific agent, and its configuration surface is full of dead ends.&lt;/p>
&lt;p>The only level where it is applied is the &lt;strong>virtual key&lt;/strong>. The v3 limiter, active by default since 1.94, has descriptors for user, team, team member, end customer, organisation, model per key, tag and agent, and all of them carry only request and token counters per window. The only reference to &lt;code>max_parallel_requests&lt;/code> is the one in the key descriptor. The old limiter says so in a comment: supporting it for model, user and team is pending.&lt;/p>
&lt;p>At the same time, &lt;code>/team/new&lt;/code>, &lt;code>/user/new&lt;/code>, &lt;code>/customer/new&lt;/code> and the organisation endpoints accept the field, validate it and write it to Postgres, and the documentation shows it in their examples. Nobody reads it afterwards. A team with &lt;code>max_parallel_requests: 20&lt;/code> in its row has no concurrency limit at all.&lt;/p>
&lt;p>&lt;code>global_max_parallel_requests&lt;/code> is injected into the request metadata and is only consulted by the old limiter, which is disabled unless &lt;code>LEGACY_MULTI_INSTANCE_RATE_LIMITING=true&lt;/code> is set. The server settings documentation still describes it as a global limit coordinated through Redis. And &lt;code>general_settings.max_parallel_requests&lt;/code> does not appear in any execution path: its only two appearances are the copy from the database and the type schema of the interface.&lt;/p>
&lt;p>What does work, in detail:&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">curl -X POST &lt;span class="s2">&amp;#34;&lt;/span>&lt;span class="nv">$PROXY&lt;/span>&lt;span class="s2">/key/generate&amp;#34;&lt;/span> -H &lt;span class="s2">&amp;#34;Authorization: Bearer &lt;/span>&lt;span class="nv">$MASTER_KEY&lt;/span>&lt;span class="s2">&amp;#34;&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> -d &lt;span class="s1">&amp;#39;{&amp;#34;team_id&amp;#34;:&amp;#34;plataforma-agentes&amp;#34;,&amp;#34;max_parallel_requests&amp;#34;:8,&amp;#34;rpm_limit&amp;#34;:600}&amp;#39;&lt;/span>
&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>Counting is done with a sorted set in Redis scored by Redis&amp;rsquo;s own clock, with Lua scripts to acquire and release. Each slot expires after &lt;strong>3,600 seconds&lt;/strong>, which is at once the maximum request duration the meter can track and the time a leaked slot takes to heal by itself. Release is hooked to success, to failure and to client disconnection during streaming, which was the classic leak with clients that cancel.&lt;/p>
&lt;p>One live leak remains and it affects exactly the case in this article: &lt;strong>every tool call through the MCP gateway acquires a slot and does not release it&lt;/strong>. The MCP route invokes the pre-call hook, which acquires, and none of the release functions is called from that module. With an agent that makes twenty tool calls per turn and a key with eight slots, the 429 arrives on the first turn and lasts an hour.&lt;/p>
&lt;p>The order of evaluation matters for interpreting 429s. First the window counters, requests and tokens per minute, are checked, and only if they pass is the concurrency slot acquired. The code comment explains why: the other way round, every rate limit rejection would leave an orphan slot. In practice, with a low &lt;code>rpm&lt;/code> you always see the &lt;code>requests&lt;/code> type 429, and the &lt;code>max_parallel_requests&lt;/code> one only appears when the relationship between concurrency and duration makes it binding first. The message distinguishes the two cases with the &lt;code>Limit type:&lt;/code> field and the &lt;code>rate_limit_type&lt;/code> header.&lt;/p>
&lt;p>A minor detail with consequences on the client: the response to a concurrency rejection carries &lt;code>retry-after&lt;/code> and &lt;code>reset_at&lt;/code> computed from the window size, sixty seconds by default, when a concurrency meter has no window. A well-behaved client that respects that header will wait a minute when it might have had a free slot in two seconds.&lt;/p>
&lt;h2 id="session-affinity-the-kv-cache-that-gets-thrown-away">Session affinity: the KV cache that gets thrown away&lt;/h2>
&lt;p>An agent turn resends the whole conversation context, system prompt and tools included. If that request lands on a different replica from the previous turn&amp;rsquo;s, vLLM&amp;rsquo;s prefix cache does not have the prefix and the prefill is recomputed in full. With twenty turns and four replicas balanced blindly, the long prefill is paid for fifteen times over.&lt;/p>
&lt;p>LiteLLM has two mechanisms for this and they are not equally good.&lt;/p>
&lt;p>The &lt;code>prompt_caching&lt;/code> check computes a SHA-256 of the cacheable prefix, defined as everything up to the last block with &lt;code>cache_control&lt;/code>, and pins the deployment with that hash. It has two problems. The TTL is hardcoded to 300 seconds in two places in the code, with no way to configure it, something which is reported and open; with one-hour caches, affinity is lost after five minutes of inactivity. And the key is an exact hash, so a client that moves the cut point forward on every turn, which is exactly what agents do to extend the cached prefix, generates a new key every time.&lt;/p>
&lt;p>The &lt;code>session_affinity&lt;/code> check is the one to use:&lt;/p>
&lt;div class="highlight">&lt;pre tabindex="0" class="chroma">&lt;code class="language-yaml" data-lang="yaml">&lt;span class="line">&lt;span class="cl">&lt;span class="nt">router_settings&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">optional_pre_call_checks&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="p">[&lt;/span>&lt;span class="s2">&amp;#34;session_affinity&amp;#34;&lt;/span>&lt;span class="p">]&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">deployment_affinity_ttl_seconds&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="m">3600&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>It pins the deployment with an atomic Lua script in Redis, with a configurable TTL that defaults to one hour and that &lt;strong>is refreshed on every request&lt;/strong>, so it bounds the idle time between turns and not the duration of the conversation. If the pinned deployment enters cooldown, the request goes out through the normal strategy and keeps the pin so it can come back when it heals.&lt;/p>
&lt;p>The session identifier comes from a precedence chain that starts with &lt;code>x-litellm-trace-id&lt;/code>, continues with &lt;code>x-litellm-session-id&lt;/code> and then accepts &lt;strong>any header of the form &lt;code>x-&amp;lt;something&amp;gt;-session-id&lt;/code>&lt;/strong> whose value looks like an identifier. That includes the session header Claude Code already sends, with nothing configured on the client. Whatever matches populates both the session identifier and the trace identifier, which links this to the correlation from &lt;a href="https://blog.lo0.es/en/posts/litellm-langfuse-operational-pair/">the first article in the track&lt;/a>.&lt;/p>
&lt;p>One dangerous interaction when combining it with tags: affinity is evaluated &lt;strong>before&lt;/strong> tag-based routing. If the pin reduces the list to a deployment that does not satisfy the request&amp;rsquo;s tag, the request fails instead of falling through to the default pool, unless &lt;code>allow_fail_open&lt;/code> has been enabled. Retagging a pool with live sessions breaks them.&lt;/p>
&lt;p>And a clarification that saves a pointless search: &lt;strong>LiteLLM does not do prefix routing&lt;/strong>. There is no prefix tree and nothing that directs the request to the vLLM replica that already has that prefix warm. What there is, is affinity by session or by key. For the other thing you need a specific router underneath the gateway, and that architecture has its own article pending.&lt;/p>
&lt;h2 id="the-engine-what-vllm-can-and-cannot-do">The engine: what vLLM can and cannot do&lt;/h2>
&lt;p>Everything above shares out requests between engines. Inside one engine, the question is what happens when a twenty-token request and a sixty-thousand-token one live side by side.&lt;/p>
&lt;p>&lt;strong>Priority exists and does less than it looks like.&lt;/strong> &lt;code>--scheduling-policy priority&lt;/code> turns the waiting queue into a heap ordered by priority, arrival time and identifier, with the lowest number first. The request can carry the &lt;code>priority&lt;/code> field in the body, and there is also the &lt;code>X-Vllm-Priority&lt;/code> header, which takes precedence over the body and is not documented anywhere.&lt;/p>
&lt;p>What priority does not do is push ahead of whoever is already generating. In the running queue loop, when KV cache runs short, the policy only changes &lt;strong>who is chosen as the victim&lt;/strong> of eviction: with &lt;code>priority&lt;/code> the one with the worst priority and arrival time is evicted, and with &lt;code>fcfs&lt;/code> the last in the queue. In the waiting queue loop, if there are no blocks available the iteration is cut short, without evicting anyone. And the &lt;code>max_num_seqs&lt;/code> cap is a hard cut. If the agents have filled that cap, a chat with maximum priority waits just the same. There has been an open issue since April and an unmerged pull request about this.&lt;/p>
&lt;p>That nuance about the victim is not negligible, because with &lt;code>fcfs&lt;/code> the victims are the most recently admitted, that is, the chats that have just come in. Switching the priority policy on already improves the situation even if it pushes nobody ahead.&lt;/p>
&lt;p>&lt;strong>Eviction is always by recomputation.&lt;/strong> The swap-to-host-memory mode and the enumeration that controlled it disappeared from the code; all that is left is freeing blocks and returning the request to the queue with the computed token counter at zero. An evicted request loses all its prefill, and the prefix cache recovers part of it, but the perceived time to first token restarts.&lt;/p>
&lt;p>Two admission settings help and do not appear in the narrative documentation. &lt;code>watermark&lt;/code> reserves a fraction of free KV blocks on admission, with the declared purpose of avoiding repeated eviction, and it ships disabled. And &lt;code>scheduler_reserve_full_isl&lt;/code>, active by default, checks that the full input length fits in the cache before admitting, instead of looking only at the first chunk.&lt;/p>
&lt;p>&lt;strong>Chunked prefill is active by default and prioritises decode.&lt;/strong> The scheduler walks the whole running request loop before touching the waiting queue, so a long agent prefill does not freeze a chat&amp;rsquo;s generation in progress. That protects the inter-token latency of what has already been admitted, and it does not protect the time to first token of what is waiting.&lt;/p>
&lt;p>Here there is a breaking change that forces a review of the manifests. &lt;strong>&lt;code>max_num_partial_prefills&lt;/code> and &lt;code>max_long_partial_prefills&lt;/code> were removed in 0.27.&lt;/strong> They were precisely the mechanism that allowed short prompts to get ahead of long ones. A deployment with those flags in its &lt;code>args&lt;/code> does not start. &lt;code>long_prefill_token_threshold&lt;/code> still exists, and its meaning changed: today it is a per-request chunk size cap, not a classification of long requests.&lt;/p>
&lt;p>The partial replacement arrives in 0.29 with &lt;code>max_num_queued_tokens&lt;/code> and &lt;code>max_num_queued_reqs&lt;/code>, admission valves that return &lt;strong>503&lt;/strong> when the pending prefill work exceeds the target. The code itself frames it as a quality of service mechanism for time to first token, with the formula of setting it to the latency target multiplied by prefill throughput. They are blind to the class of traffic: they reject a chat just the same as an agent.&lt;/p>
&lt;p>And the default value of &lt;code>--max-num-batched-tokens&lt;/code> depends on the hardware and on the start-up mode. With &lt;code>vllm serve&lt;/code> on H100 or H200 it is 8,192, on cards of 160 GiB or more it is 16,384, and on the rest 2,048. The 2,048 that appears in the scheduler configuration only applies to tests.&lt;/p>
&lt;p>The metrics for diagnosing coexistence, with the exact names from 0.29:&lt;/p>
&lt;table>
&lt;thead>
&lt;tr>
&lt;th>Metric&lt;/th>
&lt;th>What for&lt;/th>
&lt;/tr>
&lt;/thead>
&lt;tbody>
&lt;tr>
&lt;td>&lt;code>vllm:num_requests_waiting&lt;/code>&lt;/td>
&lt;td>queue depth, the autoscaling signal&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>&lt;code>vllm:num_requests_waiting_by_reason&lt;/code>&lt;/td>
&lt;td>separates waiting for capacity from waiting in a blocked state&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>&lt;code>vllm:num_preemptions_total&lt;/code>&lt;/td>
&lt;td>evictions, the signal that the two classes are treading on each other&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>&lt;code>vllm:kv_cache_usage_perc&lt;/code>&lt;/td>
&lt;td>cache pressure, the cause of the evictions&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>&lt;code>vllm:request_queue_time_seconds&lt;/code>&lt;/td>
&lt;td>what a chat waits with the agents inside&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>&lt;code>vllm:prefix_cache_hits_total&lt;/code> and &lt;code>_queries_total&lt;/code>&lt;/td>
&lt;td>measured in tokens, not in requests&lt;/td>
&lt;/tr>
&lt;/tbody>
&lt;/table>
&lt;p>A warning for anyone with old alerts. The log warning about evicted sequences that appears in the optimisation documentation belongs to the previous version of the engine and &lt;strong>no longer exists in the code&lt;/strong>. Preemptions are only visible as a counter. An alert based on searching for that string in the logs has not fired for months, and not because there are no evictions.&lt;/p>
&lt;p>On isolation between classes inside one engine, the short answer is that it does not exist. There are two queues and neither is per class of traffic. The conclusion, which is the one that holds up this article&amp;rsquo;s architecture: effective isolation means &lt;strong>separate fleets&lt;/strong>, and on top of them the gateway deciding who goes to which.&lt;/p>
&lt;h2 id="retries-the-product-nobody-calculates">Retries: the product nobody calculates&lt;/h2>
&lt;p>A human who gets an error sees it and decides. An agentic loop retries, and beneath it there are two more layers that also retry.&lt;/p>
&lt;table>
&lt;thead>
&lt;tr>
&lt;th>Layer&lt;/th>
&lt;th>Default&lt;/th>
&lt;/tr>
&lt;/thead>
&lt;tbody>
&lt;tr>
&lt;td>Client SDK&lt;/td>
&lt;td>2 retries, 3 attempts&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>LiteLLM router&lt;/td>
&lt;td>2 retries, 3 attempts&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>Fallbacks&lt;/td>
&lt;td>up to 5 groups&lt;/td>
&lt;/tr>
&lt;/tbody>
&lt;/table>
&lt;p>The product is about &lt;strong>45 calls to the engine for one agent turn&lt;/strong>, before counting the loop&amp;rsquo;s own retry. LiteLLM does what it can: when the call comes from the router, it forces &lt;code>max_retries=0&lt;/code> on the SDK it itself uses against the engine. What it cannot touch is the client&amp;rsquo;s SDK. The only layer that breaks the product is that one, and it is broken by setting &lt;code>max_retries=0&lt;/code> on the client and delegating the retry to the gateway.&lt;/p>
&lt;p>The retryable errors are 408, 409, 429 and anything 500 or above. And there lies the problem with long reasoning: &lt;code>litellm.Timeout&lt;/code> is a subclass of OpenAI&amp;rsquo;s timeout exception and comes out with &lt;strong>code 408&lt;/strong>, which is retryable, so the most expensive request of all, the one that expired after ten minutes of generating, is launched again in full. Twice by the router, and as many times again by the client.&lt;/p>
&lt;p>On timeouts you have to be precise because the code comment is: the configured timeout is &lt;strong>per attempt, not per request&lt;/strong>. A &lt;code>request_timeout: 600&lt;/code> with two retries and two fallback groups gives a worst case of almost two hours before the client receives anything. The default value is still 6,000 seconds, as was already covered in &lt;a href="https://blog.lo0.es/en/posts/litellm-day-2-high-availability/">the day 2 article&lt;/a>.&lt;/p>
&lt;p>The retry storm has two specific causes and both are default settings.&lt;/p>
&lt;p>The first: &lt;strong>a single 429 takes the deployment out of the pool&lt;/strong>, without going through the &lt;code>allowed_fails&lt;/code> threshold. The cooldown logic has an explicit branch which, on a 429 in a group with more than one deployment, returns immediate cooldown. With an agentic burst against four replicas, all four leave the pool at once and the gateway answers everybody with its own 429 of &amp;ldquo;no deployments available&amp;rdquo;. The default cooldown is five seconds, which in a sustained burst gets renewed.&lt;/p>
&lt;p>The second: &lt;strong>backoff is zero while the pool looks healthy&lt;/strong>. The function that computes the wait returns 0 if the healthy deployment list is not empty. Exponential backoff with jitter, capped at eight seconds, only kicks in when there is nothing healthy left. Under partial degradation, the proxy hammers away without waiting and only slows down once everything has already fallen over.&lt;/p>
&lt;p>On top of that, the engine&amp;rsquo;s &lt;code>Retry-After&lt;/code> header is only respected if its value is between 1 and 60 seconds. A longer limit window is ignored and replaced by the proxy&amp;rsquo;s own backoff.&lt;/p>
&lt;p>The settings that cut the problem off:&lt;/p>
&lt;div class="highlight">&lt;pre tabindex="0" class="chroma">&lt;code class="language-yaml" data-lang="yaml">&lt;span class="line">&lt;span class="cl">&lt;span class="nt">router_settings&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">num_retries&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="m">1&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">retry_after&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="m">2&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="c"># real floor; the default 0 is hammering&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">cooldown_time&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="m">30&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">allowed_fails_policy&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">RateLimitErrorAllowedFails&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="m">3&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="c"># stops a 429 taking the deployment out&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">retry_policy&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">TimeoutErrorRetries&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="m">0&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="c"># do not relaunch the expensive request&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">AuthenticationErrorRetries&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="m">0&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w">&lt;/span>&lt;span class="nt">litellm_settings&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">request_timeout&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="m">900&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="c"># per attempt&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">drop_params&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="kc">false&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>That last setting deserves an explanation, because it is a silent way of breaking an agent. With &lt;code>drop_params: true&lt;/code>, LiteLLM removes from the body any parameter the destination provider does not support, and in that branch it writes no warning at all. The candidates for disappearing are &lt;code>tools&lt;/code>, &lt;code>tool_choice&lt;/code>, &lt;code>response_format&lt;/code> and &lt;code>parallel_tool_calls&lt;/code>. An agent that has &lt;code>tools&lt;/code> taken away receives free text where it expected a function call, and interprets it as the model having decided not to use tools.&lt;/p>
&lt;p>Worse still is prompt-based emulation. For a non-OpenAI-compatible provider that does not support tools, LiteLLM does not fail: it turns the tools into text inside the prompt, forces JSON format, discards &lt;code>tool_choice&lt;/code>, and on the way switches on a &lt;strong>process-global variable&lt;/strong> that makes any other tool-less provider in the same process go to emulation instead of erroring.&lt;/p>
&lt;p>With that in mind, the warning about fallbacks is clear: &lt;strong>LiteLLM does not check capabilities before falling back&lt;/strong>. The function that knows whether a model supports function calling exists and is only used in an informational endpoint. The pre-call checks filter by context window, request limit and region, and nothing else. A fallback list that goes from a model with tools to one without them degrades the agent silently. There are guards for provider-scoped resources, files and batches, so the absence of the capability guard is an omission and not a design oversight.&lt;/p>
&lt;h2 id="a-months-budget-in-twenty-minutes">A month&amp;rsquo;s budget in twenty minutes&lt;/h2>
&lt;p>A runaway agent consumes in minutes what a team consumes in weeks, and the operational question is how long the gateway takes to find out.&lt;/p>
&lt;p>The answer has changed, and the old mental model, the one where spend consolidates every minute and until then there is no brake, is no longer correct. Since 1.84 there are two new pieces. An &lt;strong>optimistic reservation before the call&lt;/strong>, which estimates the maximum cost of the request and reserves it atomically in the counter before heading out to the engine, reconciling it with the real cost on completion. And a counter increment after the call that is explicitly awaited, with the comment that this way the counter is up to date before the next request goes through authentication. Budget checks read that counter, with Redis first and seeding from the database when cold.&lt;/p>
&lt;p>Batch writing to Postgres still exists, every 10 to 15 seconds, and it affects persistence and what is shown in the interface, not the cut-off. The production documentation&amp;rsquo;s recommendation to raise that interval to 60 does not relax the control, and it does delay by a minute what &lt;code>/key/info&lt;/code> shows, something which frequently generates operational panic. And the budget rescheduler&amp;rsquo;s ten minutes do not consolidate spend: they fire the job that resets expired budgets.&lt;/p>
&lt;p>The overspend window then comes down to the cost of the requests in flight, provided there is Redis. &lt;strong>Without Redis, the counters are per pod&lt;/strong>, and with N replicas you can spend up to N times the budget. That is the configuration failure that matters and it is the same Redis that &lt;a href="https://blog.lo0.es/en/posts/litellm-day-2-high-availability/">the day 2 article&lt;/a> declared non-optional for other reasons.&lt;/p>
&lt;p>Two settings that do not appear in the public documentation, only in the data model descriptions, and that decide behaviour at the worst moment:&lt;/p>
&lt;div class="highlight">&lt;pre tabindex="0" class="chroma">&lt;code class="language-yaml" data-lang="yaml">&lt;span class="line">&lt;span class="cl">&lt;span class="nt">general_settings&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">fail_closed_budget_enforcement: true # false by default&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">if the counter fails, it lets things through&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="c"># disable_budget_reservation: do not set it to true&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>With the default value, if the counter backend does not respond, the proxy &lt;strong>fails open&lt;/strong> and carries on serving. For an agent in a loop that is exactly the opposite of what you want.&lt;/p>
&lt;p>Time for the honest warning: this route is still unstable. There are open issues in both directions, one where the key and user budget limit stops being applied while spend is recorded, and another where a 429 is returned for stale spend in the reservation counters while the key query shows spend below the limit. Project budgets are not on the atomic reservation route and concurrency overshoots them. The practical conclusion is not to trust the hard cut-off to the budget alone.&lt;/p>
&lt;p>On the manual cut-off, there is an asymmetry to know about before you need it. &lt;code>/key/block&lt;/code> invalidates the local cache, the Redis one, and notifies all workers by publish, so with Redis the effect is almost immediate. &lt;code>/customer/block&lt;/code> does the same. &lt;strong>&lt;code>/team/block&lt;/code> only writes to the database&lt;/strong>, without invalidating or notifying, so it takes however long the management object cache TTL takes, sixty seconds by default. To cut a team off dead you have to block its keys one by one, or lower &lt;code>user_api_key_cache_ttl&lt;/code>. And &lt;code>/user/block&lt;/code> no longer exists, even though the docstring of the function that replaced it still shows that route in its example.&lt;/p>
&lt;p>On alerts, two numbers. The default thresholds warn at &lt;strong>85 % and 95 %&lt;/strong> of the budget. And deduplication of those alerts is &lt;strong>24 hours&lt;/strong> per event and identifier, so crossing 85 % generates one warning and then silence for a day. For agentic traffic, where 85 % and 100 % can be twenty minutes apart, the second warning arrives when it is no longer any use.&lt;/p>
&lt;h2 id="attribution-who-the-user-is-when-the-caller-is-an-agent">Attribution: who the user is when the caller is an agent&lt;/h2>
&lt;p>With agents, the question of who the spend is charged to has two answers and both are needed: the agent that executes and the human on whose behalf it acts.&lt;/p>
&lt;p>The chain that extracts the end customer identifier tries the standard headers first, then the configurable header mapping, and only then the &lt;code>user&lt;/code> field of the body, the &lt;code>litellm_metadata.user&lt;/code> of the Anthropic format, the &lt;code>metadata.user_id&lt;/code> and the &lt;code>safety_identifier&lt;/code>. The last four are body fields and are therefore written by the client. The code itself warns about it: for untrusted callers the identifier has to be set from headers or from a middleware on the server, to avoid impersonation.&lt;/p>
&lt;p>Out of that comes the correct pattern, which is the inverse of the one people put together out of inertia: &lt;strong>the virtual key identifies the agent&lt;/strong> and is the non-forgeable part, and the header or the &lt;code>user&lt;/code> field identify the human and serve for attribution, not for control. An agent that lies about the human behind it is still bounded by the budget and the limits of its key.&lt;/p>
&lt;p>The spend table stores &lt;code>user&lt;/code>, &lt;code>end_user&lt;/code>, &lt;code>team_id&lt;/code>, &lt;code>organization_id&lt;/code>, &lt;code>request_tags&lt;/code>, &lt;code>session_id&lt;/code> and &lt;code>agent_id&lt;/code>, with daily aggregation per agent, so the detail is available once it is sent. The &lt;code>x-litellm-agent-id&lt;/code> header is what populates that last field.&lt;/p>
&lt;p>A note about the MCP gateway, which on a platform with agents ends up being the other entry door. Tool calls &lt;strong>do appear in the spend records&lt;/strong>, with call types of their own and a metadata block that stores tool name, arguments, result and server. What is missing is token counting: the cost is &lt;strong>0.0 by default&lt;/strong> and is only populated if the administrator configures a price per query, per server or per tool. It is the same pattern of silent zero cost that already came up with unpriced models in &lt;a href="https://blog.lo0.es/en/posts/litellm-langfuse-operational-pair/">the first article in the track&lt;/a>.&lt;/p>
&lt;p>And the permission setting to review on day one: access control for MCP servers is &lt;strong>open by default&lt;/strong>. If no level of the hierarchy defines a list, the request reaches every configured server. &lt;code>general_settings.require_key_mcp_access_defined: true&lt;/code> inverts that inheritance, and it is not the default value.&lt;/p>
&lt;h2 id="a-reference-configyaml">A reference &lt;code>config.yaml&lt;/code>&lt;/h2>
&lt;p>All of the above, together, on a fleet split into two pools:&lt;/p>
&lt;div class="highlight">&lt;pre tabindex="0" class="chroma">&lt;code class="language-yaml" data-lang="yaml">&lt;span class="line">&lt;span class="cl">&lt;span class="nt">model_list&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>- &lt;span class="nt">model_name&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">qwen-30b-interactivo&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">litellm_params&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">model&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">hosted_vllm/Qwen3-30B&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">api_base&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">http://vllm-chat.inferencia.svc:8000/v1&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">input_cost_per_token&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="m">0.00000018&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="c"># without this, silent zero cost&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">output_cost_per_token&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="m">0.00000072&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">model_info&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">id&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">chat-01&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">max_input_tokens&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="m">32768&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="c"># without this there is no context filter&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>- &lt;span class="nt">model_name&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">qwen-30b-agentes&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">litellm_params&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">model&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">hosted_vllm/Qwen3-30B&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">api_base&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">http://vllm-agentes.inferencia.svc:8000/v1&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">input_cost_per_token&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="m">0.00000018&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">output_cost_per_token&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="m">0.00000072&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">tag_regex&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="p">[&lt;/span>&lt;span class="s2">&amp;#34;^User-Agent: claude-code\\/&amp;#34;&lt;/span>&lt;span class="p">]&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">model_info&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">id&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">agentes-01&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">max_input_tokens&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="m">131072&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">allow_fail_open&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="kc">true&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w">&lt;/span>&lt;span class="nt">router_settings&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">routing_strategy&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">simple-shuffle&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">enable_pre_call_checks&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="kc">true&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">enable_tag_filtering&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="kc">true&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">optional_pre_call_checks&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="p">[&lt;/span>&lt;span class="s2">&amp;#34;session_affinity&amp;#34;&lt;/span>&lt;span class="p">]&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">deployment_affinity_ttl_seconds&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="m">3600&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">num_retries&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="m">1&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">retry_after&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="m">2&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">cooldown_time&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="m">30&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">allowed_fails_policy&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">RateLimitErrorAllowedFails&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="m">3&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">retry_policy&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">TimeoutErrorRetries&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="m">0&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">AuthenticationErrorRetries&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="m">0&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w">&lt;/span>&lt;span class="nt">general_settings&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">fail_closed_budget_enforcement&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="kc">true&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">require_key_mcp_access_defined&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="kc">true&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">user_api_key_cache_ttl&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="m">10&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">proxy_batch_write_at&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="m">10&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="c"># 1.101 branch onwards:&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">max_in_flight_requests_per_worker&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="m">64&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">max_queued_requests_per_worker&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="m">64&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">admission_queue_timeout_seconds&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="m">1.0&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w">&lt;/span>&lt;span class="nt">litellm_settings&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">request_timeout&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="m">900&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="c"># per attempt&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">drop_params&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="kc">false&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">cache&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="kc">true&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="c"># Redis, not optional&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>And the start-up of the two engines, with the deliberate asymmetry:&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">&lt;span class="c1"># interactive pool: protect the first token&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">vllm serve Qwen/Qwen3-30B &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 priority &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">64&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-model-len &lt;span class="m">32768&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>&lt;/span>&lt;span class="line">&lt;span class="cl">
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="c1"># agent pool: throughput and deep queues&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">vllm serve Qwen/Qwen3-30B &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> --max-model-len &lt;span class="m">131072&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">8192&lt;/span>
&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;h2 id="checklist">Checklist&lt;/h2>
&lt;ol>
&lt;li>Issue separate keys for agents, with &lt;code>model_group_alias&lt;/code> at key or team level. It is the only separation the client cannot evade.&lt;/li>
&lt;li>Add &lt;code>tag_regex&lt;/code> over the &lt;code>User-Agent&lt;/code> as a safety net for agents that use personal credentials, with a &lt;code>default&lt;/code> pool and &lt;code>allow_fail_open: true&lt;/code>.&lt;/li>
&lt;li>Leave &lt;code>simple-shuffle&lt;/code> on both pools. The other four strategies have verifiable defects on homogeneous replicas.&lt;/li>
&lt;li>Enable &lt;code>session_affinity&lt;/code> with a one-hour TTL. Do not use &lt;code>prompt_caching&lt;/code> for this while its TTL stays hardcoded.&lt;/li>
&lt;li>Declare &lt;code>max_input_tokens&lt;/code> on every deployment. Without that value, the context window filter is not applied and it fails silently for models that are not in the price map.&lt;/li>
&lt;li>Declare per-token prices on every self-hosted model, or the agents&amp;rsquo; spend will be zero and their budget will never run out.&lt;/li>
&lt;li>&lt;code>max_parallel_requests&lt;/code> on the agent&amp;rsquo;s key, never on its team. On the team it is stored and not applied.&lt;/li>
&lt;li>Lower &lt;code>num_retries&lt;/code> to 1, put a floor on &lt;code>retry_after&lt;/code>, raise &lt;code>cooldown_time&lt;/code> and disable retry on timeout. On the client, &lt;code>max_retries=0&lt;/code>.&lt;/li>
&lt;li>&lt;code>fail_closed_budget_enforcement: true&lt;/code> and mandatory Redis. Without Redis, N replicas are N times the budget.&lt;/li>
&lt;li>Start the interactive engine with &lt;code>--scheduling-policy priority&lt;/code> even though it pushes nobody ahead: it changes who it evicts.&lt;/li>
&lt;li>Alert on &lt;code>vllm:num_preemptions_total&lt;/code> and &lt;code>vllm:num_requests_waiting_by_reason&lt;/code>, not on log strings that are no longer emitted.&lt;/li>
&lt;li>Prepare the cut-off: block by key, never by team, and verify the real propagation time in your own deployment.&lt;/li>
&lt;/ol>
&lt;h2 id="traps-and-things-that-are-not-what-they-seem">Traps and things that are not what they seem&lt;/h2>
&lt;ul>
&lt;li>&lt;strong>&lt;code>priority: 0&lt;/code> on &lt;code>/chat/completions&lt;/code> disables prioritisation.&lt;/strong> It is the value in the documentation examples and it is falsy in Python.&lt;/li>
&lt;li>&lt;strong>&lt;code>max_parallel_requests&lt;/code> on a team is stored and not applied.&lt;/strong> The field exists in the API, in the database and in the documentation; the limiter only reads it from the key descriptor.&lt;/li>
&lt;li>&lt;strong>&lt;code>global_max_parallel_requests&lt;/code> is a no-op&lt;/strong> with the default limiter, and the server settings documentation still describes it as active.&lt;/li>
&lt;li>&lt;strong>Every MCP call leaks a concurrency slot&lt;/strong>, which only heals when its hour expires. With agents calling tools in a loop, the 429 appears early and lasts a long time.&lt;/li>
&lt;li>&lt;strong>The TTL of prompt cache affinity is 300 hardcoded seconds.&lt;/strong> For agent conversations, session affinity is the right option.&lt;/li>
&lt;li>&lt;strong>Affinity is evaluated before tag-based routing.&lt;/strong> A pin to a deployment that stops satisfying the tag makes the request fail instead of falling through to the default pool.&lt;/li>
&lt;li>&lt;strong>LiteLLM&amp;rsquo;s timeout is per attempt.&lt;/strong> The worst case is the timeout multiplied by attempts and by fallback groups.&lt;/li>
&lt;li>&lt;strong>A 429 takes the deployment out of the pool without going through &lt;code>allowed_fails&lt;/code>&lt;/strong>, and backoff is zero while anything healthy remains.&lt;/li>
&lt;li>&lt;strong>The engine&amp;rsquo;s &lt;code>Retry-After&lt;/code> is ignored if it asks for more than 60 seconds.&lt;/strong>&lt;/li>
&lt;li>&lt;strong>&lt;code>drop_params: true&lt;/code> removes &lt;code>tools&lt;/code> and &lt;code>response_format&lt;/code> without leaving a warning&lt;/strong>, and the fallback does not check whether the destination model supports function calling.&lt;/li>
&lt;li>&lt;strong>&lt;code>/team/block&lt;/code> does not invalidate the cache.&lt;/strong> It takes the full TTL; &lt;code>/key/block&lt;/code> does propagate by publish in Redis.&lt;/li>
&lt;li>&lt;strong>Access to MCP servers is open by default&lt;/strong> if no level defines a list.&lt;/li>
&lt;li>&lt;strong>&lt;code>max_num_partial_prefills&lt;/code> and &lt;code>max_long_partial_prefills&lt;/code> no longer exist in vLLM.&lt;/strong> A manifest with those flags does not start from 0.27 onwards.&lt;/li>
&lt;li>&lt;strong>vLLM&amp;rsquo;s priority does not push ahead of running requests&lt;/strong>, and with &lt;code>max_num_seqs&lt;/code> full it pushes nobody ahead.&lt;/li>
&lt;li>&lt;strong>The eviction log warning that appears in the vLLM documentation is no longer emitted.&lt;/strong> Only the Prometheus counter is left.&lt;/li>
&lt;/ul>
&lt;h2 id="closing">Closing&lt;/h2>
&lt;p>The coexistence of humans and agents on the same platform is not solved with a priority field, and that is the conclusion that takes the most effort to accept because the field exists and looks made for this. Neither the gateway nor the engine has a usable notion of class of service today: one orders a queue that is almost never consulted, the other picks who to evict. What they do have is the ability to direct each request to a different place, and that is where the win is.&lt;/p>
&lt;p>The architecture that comes out of all of the above fits in one sentence. Two vLLM fleets with opposite parameters, a credential that decides which one each request goes to, session affinity so as not to throw away the prefill work, concurrency limits on the agent&amp;rsquo;s key and not on its team, retries trimmed back on the three layers where they multiply, and budgets that fail closed.&lt;/p>
&lt;p>The rest is measurement. If &lt;code>vllm:num_preemptions_total&lt;/code> rises in the interactive pool, the two classes are treading on each other despite the separation. If chat queue time grows while total throughput holds, the partition is badly sized. And if the agents&amp;rsquo; spend comes out as zero, the problem is not coexistence but undeclared prices, which is where this track started.&lt;/p>
&lt;h2 id="see-also">See also&lt;/h2>
&lt;ul>
&lt;li>&lt;a href="https://blog.lo0.es/en/posts/litellm-virtual-keys-budgets-limits/">Virtual keys, budgets and limits in LiteLLM&lt;/a>, the full hierarchy and the v3 limiter that this article&amp;rsquo;s limits rest on.&lt;/li>
&lt;li>&lt;a href="https://blog.lo0.es/en/posts/litellm-day-2-high-availability/">LiteLLM on day 2: high availability&lt;/a>, the Redis and the Postgres taken for granted here, and retries seen from the gateway side.&lt;/li>
&lt;li>&lt;a href="https://blog.lo0.es/en/posts/litellm-langfuse-operational-pair/">LiteLLM and Langfuse: the operational pair&lt;/a>, the trace correlation that this article&amp;rsquo;s session identifier feeds, and the zero cost of unpriced models.&lt;/li>
&lt;li>&lt;a href="https://blog.lo0.es/en/posts/finops-multi-tenancy-gpu-cluster-who-pays/">FinOps and GPU multi-tenancy with LiteLLM&lt;/a>, where the figures that go into the budgets defended here come from.&lt;/li>
&lt;li>&lt;a href="https://blog.lo0.es/en/posts/isolating-ai-agents-workstation-to-cluster/">The contractor with the master key: isolating AI agents&lt;/a>, the other side of the problem, the agent as a security risk rather than a class of traffic.&lt;/li>
&lt;li>&lt;a href="https://blog.lo0.es/en/posts/second-cost-vector-ai-agents-durable-execution-temporal/">The second cost vector of AI agents&lt;/a>, what a loop that fails halfway costs, and why durable execution changes that sum.&lt;/li>
&lt;li>&lt;a href="https://blog.lo0.es/en/posts/prefix-routing-what-litellm-does-not-do/">Prefix routing: what LiteLLM does not do&lt;/a>, the continuation of this article&amp;rsquo;s session affinity, and the correction of what the gateway can do with the KV cache.&lt;/li>
&lt;li>&lt;a href="https://blog.lo0.es/en/posts/litellm-mcp-gateway-second-front-door/">LiteLLM&amp;rsquo;s MCP gateway&lt;/a>, the agents&amp;rsquo; other entry door, including the correction about the default access this article took to be open.&lt;/li>
&lt;li>&lt;a href="https://blog.lo0.es/en/posts/llm-inference-router-l7-switchboard/">The LLM inference router&lt;/a>, the router&amp;rsquo;s four functions and the prefix-aware routing LiteLLM does not do.&lt;/li>
&lt;li>&lt;a href="https://blog.lo0.es/en/posts/optimising-prefill-vllm-knobs-ttft-will-not-forgive/">Prefill optimisations in vLLM&lt;/a>, the work that is recomputed in full when a request is evicted.&lt;/li>
&lt;li>&lt;a href="https://blog.lo0.es/en/posts/vllm-scheduler-step-maitre-d-each-round/">The vLLM scheduler step&lt;/a>, the loop the priority policy and chunked prefill operate on.&lt;/li>
&lt;li>&lt;a href="https://blog.lo0.es/en/posts/mcp-grows-up-authentication-keycloak/">When MCP grows: authentication with Keycloak&lt;/a>, the identity of the other entry door, the one agents use to call tools.&lt;/li>
&lt;li>&lt;a href="https://blog.lo0.es/en/posts/sizing-agents-decode-bottleneck/">Sizing for agents&lt;/a>, the question before this one: how big the fleet and the gateway have to be, with the agentic load measurements published in 2026.&lt;/li>
&lt;li>&lt;a href="https://blog.lo0.es/en/posts/gateway-jwt-audience-trace-identity-seams/">The gateway does not live alone&lt;/a>, the identity of the agent and of the human seen from outside the proxy.&lt;/li>
&lt;/ul>
&lt;h2 id="sources">Sources&lt;/h2>
&lt;ul>
&lt;li>LiteLLM, &lt;em>[BETA] Request Prioritization&lt;/em>: &lt;a href="https://docs.litellm.ai/docs/scheduler">https://docs.litellm.ai/docs/scheduler&lt;/a>.&lt;/li>
&lt;li>LiteLLM, &lt;em>Router - Load Balancing&lt;/em> (estrategias, afinidad de sesión, comprobaciones previas): &lt;a href="https://docs.litellm.ai/docs/routing">https://docs.litellm.ai/docs/routing&lt;/a>.&lt;/li>
&lt;li>LiteLLM, &lt;em>Tag Based Routing&lt;/em> (incluye &lt;code>tag_regex&lt;/code> y el ejemplo con clientes agénticos): &lt;a href="https://docs.litellm.ai/docs/proxy/tag_routing">https://docs.litellm.ai/docs/proxy/tag_routing&lt;/a>.&lt;/li>
&lt;li>LiteLLM, &lt;em>Router Settings for Keys and Teams&lt;/em> (resolución clave, equipo, global): &lt;a href="https://docs.litellm.ai/docs/proxy/keys_teams_router_settings">https://docs.litellm.ai/docs/proxy/keys_teams_router_settings&lt;/a>.&lt;/li>
&lt;li>LiteLLM, &lt;em>Server Tuning&lt;/em> (control de admisión por worker): &lt;a href="https://docs.litellm.ai/docs/proxy/server_tuning">https://docs.litellm.ai/docs/proxy/server_tuning&lt;/a>.&lt;/li>
&lt;li>LiteLLM, &lt;em>Dynamic TPM/RPM Allocation&lt;/em> (reserva de prioridad, beta y enterprise): &lt;a href="https://docs.litellm.ai/docs/proxy/dynamic_rate_limit">https://docs.litellm.ai/docs/proxy/dynamic_rate_limit&lt;/a>.&lt;/li>
&lt;li>LiteLLM, &lt;em>Budgets, Rate Limits&lt;/em>: &lt;a href="https://docs.litellm.ai/docs/proxy/users">https://docs.litellm.ai/docs/proxy/users&lt;/a>.&lt;/li>
&lt;li>LiteLLM, &lt;em>Production Best Practices&lt;/em>: &lt;a href="https://docs.litellm.ai/docs/proxy/prod">https://docs.litellm.ai/docs/proxy/prod&lt;/a>.&lt;/li>
&lt;li>LiteLLM, &lt;em>Reliability&lt;/em> (fallbacks, ventana de contexto, enfriamientos): &lt;a href="https://docs.litellm.ai/docs/proxy/reliability">https://docs.litellm.ai/docs/proxy/reliability&lt;/a>.&lt;/li>
&lt;li>LiteLLM, código: &lt;code>litellm/scheduler.py&lt;/code>, &lt;code>litellm/router_strategy/tag_based_routing.py&lt;/code>, &lt;code>litellm/router_utils/pre_call_checks/deployment_affinity_check.py&lt;/code>, &lt;code>litellm/proxy/hooks/parallel_request_limiter_v3.py&lt;/code>, &lt;code>litellm/proxy/middleware/admission_control_middleware.py&lt;/code>, &lt;code>litellm/router_utils/cooldown_handlers.py&lt;/code>: &lt;a href="https://github.com/BerriAI/litellm">https://github.com/BerriAI/litellm&lt;/a>.&lt;/li>
&lt;li>LiteLLM, incidencia 28427, TTL de la afinidad por caché de prompts fijado a cinco minutos: &lt;a href="https://github.com/BerriAI/litellm/issues/28427">https://github.com/BerriAI/litellm/issues/28427&lt;/a>.&lt;/li>
&lt;li>LiteLLM, incidencia 34534, fuga de slot de concurrencia en cada llamada MCP: &lt;a href="https://github.com/BerriAI/litellm/issues/34534">https://github.com/BerriAI/litellm/issues/34534&lt;/a>.&lt;/li>
&lt;li>LiteLLM, incidencia 26672, presupuesto de clave y usuario no aplicado: &lt;a href="https://github.com/BerriAI/litellm/issues/26672">https://github.com/BerriAI/litellm/issues/26672&lt;/a>.&lt;/li>
&lt;li>LiteLLM, incidencia 872, comprobación previa de soporte de llamada a funciones, cerrada sin implementar: &lt;a href="https://github.com/BerriAI/litellm/issues/872">https://github.com/BerriAI/litellm/issues/872&lt;/a>.&lt;/li>
&lt;li>vLLM, &lt;em>Optimization and Tuning&lt;/em> (prefill troceado, prioridad de decodificación): &lt;a href="https://docs.vllm.ai/en/v0.29.0/configuration/optimization.html">https://docs.vllm.ai/en/v0.29.0/configuration/optimization.html&lt;/a>.&lt;/li>
&lt;li>vLLM, &lt;em>Metrics design&lt;/em> (nombres, convención &lt;code>_total&lt;/code>, autoescalado como problema abierto): &lt;a href="https://docs.vllm.ai/en/v0.29.0/design/metrics.html">https://docs.vllm.ai/en/v0.29.0/design/metrics.html&lt;/a>.&lt;/li>
&lt;li>vLLM, &lt;em>Data Parallel Deployment&lt;/em> (caché KV independiente por réplica): &lt;a href="https://docs.vllm.ai/en/v0.29.0/serving/data_parallel_deployment.html">https://docs.vllm.ai/en/v0.29.0/serving/data_parallel_deployment.html&lt;/a>.&lt;/li>
&lt;li>vLLM, código: &lt;code>vllm/v1/core/sched/scheduler.py&lt;/code>, &lt;code>vllm/v1/core/sched/request_queue.py&lt;/code>, &lt;code>vllm/config/scheduler.py&lt;/code>, &lt;code>vllm/engine/arg_utils.py&lt;/code>, &lt;code>vllm/v1/metrics/loggers.py&lt;/code>: &lt;a href="https://github.com/vllm-project/vllm">https://github.com/vllm-project/vllm&lt;/a>.&lt;/li>
&lt;li>vLLM, incidencia 40004, la prioridad no desaloja peticiones en ejecución: &lt;a href="https://github.com/vllm-project/vllm/issues/40004">https://github.com/vllm-project/vllm/issues/40004&lt;/a>.&lt;/li>
&lt;/ul></description></item><item><title>LiteLLM on day 2: one worker per pod, six thousand seconds of timeout and the other five things to change before opening the traffic</title><link>https://blog.lo0.es/en/posts/litellm-day-2-high-availability/</link><pubDate>Tue, 08 Sep 2026 07:00:00 +0200</pubDate><guid>https://blog.lo0.es/en/posts/litellm-day-2-high-availability/</guid><description>&lt;blockquote>
&lt;p>Second article in the operational track of the control layer. The &lt;a href="https://blog.lo0.es/en/posts/litellm-langfuse-operational-pair/">operational pair with Langfuse&lt;/a> covered the seam between the gateway and observability; here everything stays inside the gateway. The prior decisions, which gateway and why, are in &lt;a href="https://blog.lo0.es/en/posts/choosing-oss-gateway-llm-inference/">choosing the OSS gateway&lt;/a> and in &lt;a href="https://blog.lo0.es/en/posts/llm-inference-router-l7-switchboard/">the L7 inference router&lt;/a>.&lt;/p>
&lt;/blockquote>
&lt;h2 id="tldr">TL;DR&lt;/h2>
&lt;p>LiteLLM Proxy starts from a twenty-line &lt;code>config.yaml&lt;/code> and serves traffic that same afternoon. The operational work consists of correcting seven defaults designed for a scenario other than an in-house inference factory.&lt;/p>
&lt;p>&lt;strong>One uvicorn worker per pod.&lt;/strong> The project&amp;rsquo;s production guide asks for &lt;code>--num_workers 1&lt;/code> on Kubernetes and horizontal scaling, with &lt;strong>1 vCPU and 4 GiB per worker set at the same time as &lt;code>requests&lt;/code> and as &lt;code>limits&lt;/code>&lt;/strong>. The 4 GiB are a floor, not a target: the Prisma query engine marks a resident memory high-water mark that grows up to the largest statement it has executed and that glibc does not return to the system.&lt;/p>
&lt;p>&lt;strong>Autoscale on CPU, never on memory.&lt;/strong> A direct consequence of the above. Memory goes up and never down, so an HPA on memory scales and never scales back. The recommended target is &lt;code>targetCPUUtilizationPercentage: 60&lt;/code>.&lt;/p>
&lt;p>&lt;strong>The Postgres connection arithmetic.&lt;/strong> &lt;code>database_connection_pool_limit&lt;/code> is 10, and the number of connections is instances times workers times the pool. The Helm charts ship &lt;code>maxReplicas: 100&lt;/code>, on the order of a thousand connections, far above what a stock Postgres accepts.&lt;/p>
&lt;p>&lt;strong>Redis stops being optional&lt;/strong> from around a thousand requests per second or ten instances. Without it, each instance applies its limits on its own, caches are not shared, and spend updates against the same rows produce locks and exhaust the connections with &lt;code>FATAL: sorry, too many clients already&lt;/code>.&lt;/p>
&lt;p>&lt;strong>Three health endpoints and only two are usable as probes.&lt;/strong> &lt;code>/health/liveliness&lt;/code> and &lt;code>/health/readiness&lt;/code> require no authentication and answer fast. Bare &lt;code>/health&lt;/code> demands a key and fires a real request against every model in the catalogue, with its cost in tokens.&lt;/p>
&lt;p>&lt;strong>The resilience numbers are 2, 3 and 5&lt;/strong>: two retries, three allowed failures and five seconds of cooldown, the last one despite the function&amp;rsquo;s own documentation saying the default is 1.&lt;/p>
&lt;p>&lt;strong>&lt;code>request_timeout&lt;/code> is 6,000 seconds.&lt;/strong> A hundred minutes holding a connection against an engine that does not answer.&lt;/p>
&lt;p>And a warning about the performance figures: the project publishes 8 ms of p95 overhead on one benchmark and 257.7 ms of p99 on another. Both are theirs, and the difference is in the shape of the load.&lt;/p>
&lt;h2 id="you-are-here-the-gateway-layer-the-day-after">You are here: the gateway layer, the day after&lt;/h2>
&lt;p>In the &lt;a href="https://blog.lo0.es/en/posts/anatomy-on-premise-llm-inference-stack-seven-layers/">seven-layer stack&lt;/a>, this article lives entirely in the gateway layer. The difference with the selection article is the one that separates buying from maintaining: there the question was which piece to put in front of the fleet, and here it is what happens when that piece has been serving for six months, the team has tripled the traffic and someone has added an agent that fires twenty calls per interaction.&lt;/p>
&lt;p>The gateway has an uncomfortable property worth keeping in mind: it sits on the critical path of every request and contributes not a single token. All it does is coordination, and any coordination that fails turns into an outage of the whole inference service, even with the GPUs perfectly healthy.&lt;/p>
&lt;h2 id="the-analogy-the-switchboard-and-its-notebook">The analogy: the switchboard and its notebook&lt;/h2>
&lt;p>A hospital switchboard does three jobs at once. It puts calls through, which is the urgent part. It writes down in a notebook who called, to which extension and for how long, which is what sustains the billing. And it consults a list of extensions to know where to put each call through.&lt;/p>
&lt;p>The three jobs compete. If the operator stops to write each line in the notebook before putting the next call through, the waiting queue grows. If there are three operators and each keeps her own notebook, the totals do not add up. And if the list of extensions is consulted in a filing cabinet on another floor, every call costs the trip.&lt;/p>
&lt;p>The three tensions have an exact equivalent in LiteLLM. The notebook is the &lt;code>SpendLogs&lt;/code> in Postgres, which is why the transaction buffer in Redis exists. The three operators with three notebooks are the pod replicas applying limits independently. And the filing cabinet on another floor is usage-based routing, which adds a Redis lookup inside the request path.&lt;/p>
&lt;h2 id="the-process-model-one-worker-per-pod">The process model: one worker per pod&lt;/h2>
&lt;p>The production documentation is explicit: on Kubernetes, &lt;code>--num_workers 1&lt;/code> and scale with replicas. On a virtual machine with no orchestrator, &lt;code>NUM_WORKERS&lt;/code> equal to the number of vCPUs.&lt;/p>
&lt;p>The reason is not ideological. An additional worker inside the same pod shares the container&amp;rsquo;s memory limit with the others, multiplies the database connections by the same factor, and registers a copy of every periodic job. With separate replicas, each one has its own resource budget, its own connection quota and a place where the scheduler can put it.&lt;/p>
&lt;p>The recommended sizing is &lt;strong>1 vCPU and 4 GiB per worker&lt;/strong>, and the part that gets overlooked is that these go as &lt;code>requests&lt;/code> and as &lt;code>limits&lt;/code> at the same time. Setting less than 4 GiB produces an OOM restart loop that shows up late, once some large request has grown the query engine&amp;rsquo;s footprint.&lt;/p>
&lt;div class="highlight">&lt;pre tabindex="0" class="chroma">&lt;code class="language-yaml" data-lang="yaml">&lt;span class="line">&lt;span class="cl">&lt;span class="nt">resources&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">requests&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>{&lt;span class="w"> &lt;/span>&lt;span class="nt">cpu&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="s2">&amp;#34;1&amp;#34;&lt;/span>&lt;span class="nt">, memory&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="s2">&amp;#34;4Gi&amp;#34;&lt;/span>&lt;span class="w"> &lt;/span>}&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">limits&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>{&lt;span class="w"> &lt;/span>&lt;span class="nt">cpu&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="s2">&amp;#34;1&amp;#34;&lt;/span>&lt;span class="nt">, memory&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="s2">&amp;#34;4Gi&amp;#34;&lt;/span>&lt;span class="w"> &lt;/span>}&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>That footprint needs to be described precisely, because it determines the autoscaling policy. The Prisma query engine keeps a resident memory that behaves as a high-water mark: it grows up to the largest statement the process has executed and does not come back down, because glibc does not return that freed memory to the system. A pod that served a spike yesterday still shows the footprint of that spike today.&lt;/p>
&lt;p>Two rules come out of this. The first: &lt;strong>autoscale on CPU and never on memory&lt;/strong>. An HPA on memory sees a metric that only goes up, scales at the peak and never scales back. The recommended target is &lt;code>targetCPUUtilizationPercentage: 60&lt;/code>, lower than the 80 the charts ship by default, because the startup probe allows up to 300 seconds before passing the first readiness check and there needs to be headroom for the new pod to be ready before the saturated one goes down.&lt;/p>
&lt;p>The second: bound the process lifetime. &lt;code>--max_requests_before_restart 10000&lt;/code> recycles the worker before the high-water mark matters.&lt;/p>
&lt;h2 id="postgres-the-arithmetic-that-blows-up-when-you-scale">Postgres: the arithmetic that blows up when you scale&lt;/h2>
&lt;p>&lt;code>database_connection_pool_limit&lt;/code> is &lt;strong>10&lt;/strong> by default. The total number of connections the deployment opens is replicas times workers times pool, and it has to be compared against the Postgres &lt;code>max_connections&lt;/code>.&lt;/p>
&lt;p>The formula the documentation gives is the inverse, and it is the one to apply when sizing:&lt;/p>
&lt;pre tabindex="0">&lt;code>database_connection_pool_limit = MAX_DB_CONNECTIONS / (instances × workers)
&lt;/code>&lt;/pre>&lt;p>The detail that hurts in production is in the Helm charts, which ship &lt;code>autoscaling.maxReplicas&lt;/code> and &lt;code>keda.maxReplicas&lt;/code> at &lt;strong>100&lt;/strong>. With the default pool, that is on the order of a thousand connections at full scale-out, far above what an untouched Postgres accepts. And the moment the HPA reaches those replicas is exactly the moment of highest load, so connection exhaustion arrives when it can least be afforded.&lt;/p>
&lt;p>There are two more valves on the same path. Provider errors are written to the database by default, and under a sustained engine failure that inflates the spend table without contributing anything that is not already in the metrics. They are switched off like this:&lt;/p>
&lt;div class="highlight">&lt;pre tabindex="0" class="chroma">&lt;code class="language-yaml" data-lang="yaml">&lt;span class="line">&lt;span class="cl">&lt;span class="nt">general_settings&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">disable_error_logs&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="kc">true&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="c"># stops writing provider errors&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">proxy_batch_write_at&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="m">60&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="c"># batches the spend writes&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>&lt;code>disable_spend_logs: true&lt;/code> is the radical version, which removes the per-request detail from the interface and leaves the cost in Prometheus and in the tracing backend. Before going that far you have to decide whether that table is the system&amp;rsquo;s audit record, which is the discussion in the &lt;a href="https://blog.lo0.es/en/posts/litellm-langfuse-operational-pair/">previous article&lt;/a>: if it is, it cannot be switched off, and the way out is the Redis buffer.&lt;/p>
&lt;h2 id="redis-when-it-stops-being-optional">Redis: when it stops being optional&lt;/h2>
&lt;p>Without Redis, LiteLLM works. Each instance keeps its own in-memory cache and applies its own counters. The consequences are three, and all of them show up as you grow.&lt;/p>
&lt;p>Requests-per-minute limits are applied per instance, so a limit of 100 with five replicas is in practice a limit of 500. Cache hits are local, so the same repeated question hits once in five. And there is no leader election, so periodic jobs run in every process at once.&lt;/p>
&lt;p>The threshold the documentation gives for enabling the transaction buffer is &lt;strong>a thousand requests per second or ten instances&lt;/strong>. Below that, each instance updates the key, user and team rows directly; above it, they all write to the same rows, locks appear and Postgres starts rejecting with &lt;code>FATAL: sorry, too many clients already&lt;/code>.&lt;/p>
&lt;div class="highlight">&lt;pre tabindex="0" class="chroma">&lt;code class="language-yaml" data-lang="yaml">&lt;span class="line">&lt;span class="cl">&lt;span class="nt">general_settings&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">use_redis_transaction_buffer&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="kc">true&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>The metrics to watch when that is enabled are &lt;code>litellm_in_memory_spend_update_queue_size&lt;/code>, &lt;code>litellm_redis_spend_update_queue_size&lt;/code> and &lt;code>litellm_pod_lock_manager_size&lt;/code>. If the first grows without the second coming down, the flush to Postgres is not keeping up.&lt;/p>
&lt;p>A note about the routing strategy, which interacts with this. &lt;code>simple-shuffle&lt;/code> consults nothing external. Usage-based routing does, and it adds a round trip to Redis inside the request path. On a homogeneous vLLM fleet behind the same model, the improvement it brings rarely pays for that latency; on a heterogeneous fleet, the conversation is different and &lt;a href="https://blog.lo0.es/en/posts/llm-inference-router-l7-switchboard/">the router article&lt;/a> covered it.&lt;/p>
&lt;h2 id="background-jobs-register-per-worker">Background jobs register per worker&lt;/h2>
&lt;p>This detail appears in no getting-started guide and produces considerable bewilderment when it is discovered. The proxy&amp;rsquo;s periodic jobs register &lt;strong>per uvicorn worker, not per pod&lt;/strong>. With &lt;code>--num_workers 4&lt;/code> and ten replicas that is forty copies of every job, all of them running with no coordination if there is no Redis for leader election.&lt;/p>
&lt;p>The variable that separates them is &lt;code>LITELLM_JOB_ROLE&lt;/code>: value &lt;code>serving&lt;/code> on the pods that serve traffic and a separate replica with value &lt;code>worker&lt;/code> for the jobs. With the one-worker-per-pod recommendation the problem shrinks, but it does not go away while there are several replicas.&lt;/p>
&lt;h2 id="probes-three-endpoints-and-only-two-are-usable">Probes: three endpoints and only two are usable&lt;/h2>
&lt;p>The three endpoints exist and do different things:&lt;/p>
&lt;table>
&lt;thead>
&lt;tr>
&lt;th>Endpoint&lt;/th>
&lt;th>Authentication&lt;/th>
&lt;th>What it does&lt;/th>
&lt;th>What it is for&lt;/th>
&lt;/tr>
&lt;/thead>
&lt;tbody>
&lt;tr>
&lt;td>&lt;code>/health/liveliness&lt;/code>&lt;/td>
&lt;td>No&lt;/td>
&lt;td>Returns &lt;code>I'm alive!&lt;/code>, or 503 during shutdown&lt;/td>
&lt;td>&lt;code>livenessProbe&lt;/code>&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>&lt;code>/health/readiness&lt;/code>&lt;/td>
&lt;td>No&lt;/td>
&lt;td>State of the process and the database. &lt;strong>503 if the configured Postgres does not answer&lt;/strong>&lt;/td>
&lt;td>&lt;code>readinessProbe&lt;/code>&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>&lt;code>/health&lt;/code>&lt;/td>
&lt;td>Yes&lt;/td>
&lt;td>Fires a real request against &lt;strong>every model&lt;/strong> in the catalogue&lt;/td>
&lt;td>Manual diagnosis, never a probe&lt;/td>
&lt;/tr>
&lt;/tbody>
&lt;/table>
&lt;p>The last row deserves insisting on. &lt;code>/health&lt;/code> spends tokens on every call, one per configured model. Set as a probe, with the Kubernetes default interval and a fleet of eight models, that is thousands of inference requests a day that serve nobody.&lt;/p>
&lt;p>There are more endpoints useful for diagnosis: &lt;code>/health/readiness/details&lt;/code>, &lt;code>/health/services?service=langfuse&lt;/code> to check one specific callback, &lt;code>/health/history&lt;/code> and &lt;code>/health/latest&lt;/code> for the history, and &lt;code>/health/backlog&lt;/code>, which returns the in-flight requests and shows up in the latency section. &lt;code>/health/drain&lt;/code> exists but returns 404 unless it is enabled with &lt;code>enable_drain_endpoint: true&lt;/code>, and it is protected with &lt;code>X-Drain-Token&lt;/code>.&lt;/p>
&lt;p>The periodic model checks are configured separately and not in the probe:&lt;/p>
&lt;div class="highlight">&lt;pre tabindex="0" class="chroma">&lt;code class="language-yaml" data-lang="yaml">&lt;span class="line">&lt;span class="cl">&lt;span class="nt">general_settings&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">background_health_checks&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="kc">true&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">health_check_interval&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="m">300&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="c"># seconds&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">background_health_check_model_groups&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="p">[&lt;/span>&lt;span class="s2">&amp;#34;llama-70b&amp;#34;&lt;/span>&lt;span class="p">,&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="s2">&amp;#34;qwen-30b&amp;#34;&lt;/span>&lt;span class="p">]&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>And one setting with consequences in deployments with no Internet egress: &lt;code>allow_requests_on_db_unavailable: true&lt;/code> allows serving traffic with the database down, but leaves &lt;code>/health/readiness&lt;/code> always returning 200. With that set, the readiness probe stops detecting the very failure it was designed to detect, and budget and model errors keep blocking just the same.&lt;/p>
&lt;h2 id="retries-cooldowns-and-fallbacks-the-real-numbers">Retries, cooldowns and fallbacks: the real numbers&lt;/h2>
&lt;p>The defaults, read from the project&amp;rsquo;s code and not from the documentation:&lt;/p>
&lt;table>
&lt;thead>
&lt;tr>
&lt;th>Constant&lt;/th>
&lt;th>Value&lt;/th>
&lt;th>What it governs&lt;/th>
&lt;/tr>
&lt;/thead>
&lt;tbody>
&lt;tr>
&lt;td>&lt;code>DEFAULT_MAX_RETRIES&lt;/code>&lt;/td>
&lt;td>2&lt;/td>
&lt;td>Retries before moving on to fallbacks&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>&lt;code>DEFAULT_ALLOWED_FAILS&lt;/code>&lt;/td>
&lt;td>3&lt;/td>
&lt;td>Failures before cooling down a deployment&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>&lt;code>DEFAULT_COOLDOWN_TIME_SECONDS&lt;/code>&lt;/td>
&lt;td>5&lt;/td>
&lt;td>Seconds a deployment stays out&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>&lt;code>SINGLE_DEPLOYMENT_TRAFFIC_FAILURE_THRESHOLD&lt;/code>&lt;/td>
&lt;td>1000&lt;/td>
&lt;td>Minimum requests before applying the cooldown logic with a single deployment&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>&lt;code>DEFAULT_REQUEST_TIMEOUT_SECONDS&lt;/code>&lt;/td>
&lt;td>6000.0&lt;/td>
&lt;td>Request timeout&lt;/td>
&lt;/tr>
&lt;/tbody>
&lt;/table>
&lt;p>Besides the failure counter, there is a proportional criterion: a deployment enters cooldown if 50% of its requests fail in any given minute.&lt;/p>
&lt;p>On the cooldown there is a discrepancy worth knowing about before debugging blind. The docstring of the router function says &lt;code>cooldown_time&lt;/code> defaults to 1, and the code uses the constant of 5. The code wins.&lt;/p>
&lt;p>The fallbacks are three separate lists and are applied &lt;strong>after&lt;/strong> the retries are exhausted:&lt;/p>
&lt;div class="highlight">&lt;pre tabindex="0" class="chroma">&lt;code class="language-yaml" data-lang="yaml">&lt;span class="line">&lt;span class="cl">&lt;span class="nt">router_settings&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">num_retries&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="m">2&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">timeout&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="m">120&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">fallbacks&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="p">[&lt;/span>{&lt;span class="nt">&amp;#34;llama-70b&amp;#34;: &lt;/span>&lt;span class="p">[&lt;/span>&lt;span class="s2">&amp;#34;qwen-30b&amp;#34;&lt;/span>&lt;span class="p">]&lt;/span>}&lt;span class="p">]&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">context_window_fallbacks&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="p">[&lt;/span>{&lt;span class="nt">&amp;#34;llama-70b&amp;#34;: &lt;/span>&lt;span class="p">[&lt;/span>&lt;span class="s2">&amp;#34;llama-70b-128k&amp;#34;&lt;/span>&lt;span class="p">]&lt;/span>}&lt;span class="p">]&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">content_policy_fallbacks&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="p">[&lt;/span>{&lt;span class="nt">&amp;#34;llama-70b&amp;#34;: &lt;/span>&lt;span class="p">[&lt;/span>&lt;span class="s2">&amp;#34;llama-70b-sin-guardrail&amp;#34;&lt;/span>&lt;span class="p">]&lt;/span>}&lt;span class="p">]&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>A note on the behaviour when everything is in cooldown: if no deployment is left available in the group, LiteLLM falls back onto a specific &lt;code>model_info.id&lt;/code>, skipping the cooldown check. It is a deliberate degradation, serve something rather than return 503, and it is worth knowing it exists because it masks the real state of the fleet.&lt;/p>
&lt;p>And a capability lost along the way: since version 1.85.0, the &lt;code>mock_testing_fallbacks&lt;/code> parameter and its two siblings are stripped from requests arriving through the proxy and have no effect. Fallbacks can no longer be tested against the proxy with a doctored request; it has to be done against &lt;code>litellm.Router&lt;/code> directly, in a test. Anyone who had a smoke test built on that has it broken without warning.&lt;/p>
&lt;h3 id="the-six-thousand-second-timeout">The six thousand second timeout&lt;/h3>
&lt;p>It deserves its own section because it is the setting with the worst damage-to-effort ratio in the whole system. &lt;code>request_timeout&lt;/code> is 6,000 seconds. A hundred minutes. An inference engine that stops answering without closing the connection holds a worker for that long, and with one worker per pod that is an entire pod out of service per trapped request.&lt;/p>
&lt;div class="highlight">&lt;pre tabindex="0" class="chroma">&lt;code class="language-yaml" data-lang="yaml">&lt;span class="line">&lt;span class="cl">&lt;span class="nt">general_settings&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">request_timeout&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="m">600&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>Ten minutes is still generous for a long generation, and it bounds the damage.&lt;/p>
&lt;h2 id="overhead-the-projects-two-figures">Overhead: the project&amp;rsquo;s two figures&lt;/h2>
&lt;p>LiteLLM publishes two measurements of its own overhead that differ by a factor of thirty, and both are legitimate because they measure different loads.&lt;/p>
&lt;p>The benchmarks page, with Locust, a thousand users with think time and a fake endpoint, gives for four instances an &lt;code>x-litellm-overhead-duration-ms&lt;/code> of &lt;strong>median 2 ms, p95 8 ms and p99 13 ms&lt;/strong> at 1,170 requests per second. With two instances, median 12 ms, p95 29 ms and p99 43 ms. The page itself warns that those figures correspond to around 130 in-flight requests, and that a closed-loop client with no think time keeps a thousand in flight and sees on the order of eight times that latency at the same rate, by Little&amp;rsquo;s law.&lt;/p>
&lt;p>The AIGatewayBench benchmark, from July 2026, measures the Python proxy at &lt;strong>257.7 ms of added p99&lt;/strong> and 329.5 MB of peak memory, with no callbacks, no spend tracking and no persistence. The Rust variant, in beta, gives 0.7 ms.&lt;/p>
&lt;p>The reading for an in-house platform: the first figure is the best case with human clients, and the second is the order of magnitude to plan for agentic load, which arrives in a closed loop against fast responses. There is one reported case that resembles this scenario closely: an in-house OpenAI-compatible engine served around 16 requests per second directly and around 9 through LiteLLM, with the degradation growing with concurrency, on a 4 vCPU, 8 GB pod.&lt;/p>
&lt;h3 id="the-latency-gap-the-metrics-cannot-see">The latency gap the metrics cannot see&lt;/h3>
&lt;p>LiteLLM&amp;rsquo;s timers start when its handler starts. The time the request spends queued in the uvicorn event loop, before getting there, appears in none of its metrics. The example in its own troubleshooting guide is a case in which LiteLLM records 10 seconds and the user experiences 20.&lt;/p>
&lt;p>It is detected by comparing two sources: &lt;code>GET /health/backlog&lt;/code> or the &lt;code>litellm_in_flight_requests&lt;/code> gauge against the response time measured by the load balancer in front. If they diverge, the gap is in the input queue and the answer is more replicas, not more tuning.&lt;/p>
&lt;p>Two headers are always on and serve to watch it without instrumenting anything: &lt;code>x-litellm-overhead-duration-ms&lt;/code> and &lt;code>x-litellm-callback-duration-ms&lt;/code>. For the second, the documentation sets the suspicion threshold at 100 ms, above which the diagnosis is that the payloads are too large.&lt;/p>
&lt;p>And the first cause of latency that guide mentions is none of the above: &lt;code>LITELLM_LOG=DEBUG&lt;/code> serialises the payload with &lt;code>json.dumps(indent=4)&lt;/code> synchronously, and with payloads over 2 MB that alone can cost between 2 and 5 seconds per request. A log level set to debug a problem and forgotten there produces exactly the problem it was meant to debug.&lt;/p>
&lt;h2 id="the-failure-mode-to-put-in-the-runbook">The failure mode to put in the runbook&lt;/h2>
&lt;p>There is an open issue describing a cascade with a characteristic shape. Under sustained 429s from the engine upstream, the pods stopped answering readiness probes during startup, Kubernetes killed them on failed probes, and the restart put more pressure on an upstream that was already saturated. Peaks of 500 requests per second, average of 60, between two and five replicas with 1.3 CPU and 4 GB.&lt;/p>
&lt;p>The shape of the failure is what matters more than the specific case: &lt;strong>the engine&amp;rsquo;s saturation turns into a restart loop of the gateway&lt;/strong>, and from the outside it looks like the problem is the gateway. The defences are the ones already in this article, applied together: &lt;code>startupProbe&lt;/code> with a wide margin so readiness does not compete with startup, &lt;code>request_timeout&lt;/code> bounded so trapped requests get released, cooldowns that take the deployment returning 429 out of rotation, and CPU autoscaling at 60% so the capacity exists before it is needed. It fits with what was covered in &lt;a href="https://blog.lo0.es/en/posts/incident-response-runbooks-llm-inference-kafka-keep/">incident response runbooks&lt;/a>.&lt;/p>
&lt;h2 id="upgrading-without-cutting-traffic">Upgrading without cutting traffic&lt;/h2>
&lt;p>The deployment is a normal &lt;code>Deployment&lt;/code> with &lt;code>RollingUpdate&lt;/code>, &lt;code>maxUnavailable: 0&lt;/code> and &lt;code>maxSurge: 1&lt;/code>, and there is no further mystery to it except on one point: &lt;strong>schema migrations&lt;/strong>. LiteLLM uses Prisma and applies migrations at startup. With several replicas coming up at once after a version change, the migration must be run by a single process, which is another reason for the dedicated replica with &lt;code>LITELLM_JOB_ROLE: worker&lt;/code>.&lt;/p>
&lt;p>Before a version bump, two checks that pay for themselves: read the release notes looking for changes in the metadata emitted to the callbacks, because a change like that breaks saved dashboards without touching the service, and verify that the new configuration starts with &lt;code>--detailed_debug&lt;/code> on a separate pod before applying it to the fleet.&lt;/p>
&lt;h2 id="go-live-checklist">Go-live checklist&lt;/h2>
&lt;ol>
&lt;li>&lt;code>--num_workers 1&lt;/code> and scaling by replicas.&lt;/li>
&lt;li>&lt;code>requests&lt;/code> equal to &lt;code>limits&lt;/code>, 1 vCPU and 4 GiB per worker.&lt;/li>
&lt;li>HPA on CPU at 60%. Never on memory.&lt;/li>
&lt;li>&lt;code>database_connection_pool_limit&lt;/code> computed from &lt;code>max_connections&lt;/code>, and the chart&amp;rsquo;s &lt;code>maxReplicas&lt;/code> lowered to something the database can take.&lt;/li>
&lt;li>Redis as soon as there is more than one replica, and &lt;code>use_redis_transaction_buffer&lt;/code> from ten instances or a thousand requests per second.&lt;/li>
&lt;li>&lt;code>livenessProbe&lt;/code> on &lt;code>/health/liveliness&lt;/code>, &lt;code>readinessProbe&lt;/code> on &lt;code>/health/readiness&lt;/code>, and &lt;code>/health&lt;/code> out of the probes.&lt;/li>
&lt;li>&lt;code>startupProbe&lt;/code> with a margin of up to 300 seconds.&lt;/li>
&lt;li>&lt;code>request_timeout: 600&lt;/code>.&lt;/li>
&lt;li>&lt;code>LITELLM_LOG&lt;/code> out of &lt;code>DEBUG&lt;/code>, and &lt;code>disable_error_logs: true&lt;/code>.&lt;/li>
&lt;li>A dashboard with &lt;code>x-litellm-overhead-duration-ms&lt;/code>, &lt;code>x-litellm-callback-duration-ms&lt;/code>, &lt;code>litellm_in_flight_requests&lt;/code> and the response time of the load balancer in front.&lt;/li>
&lt;/ol>
&lt;h2 id="traps-and-things-that-are-not-what-they-look-like">Traps and things that are not what they look like&lt;/h2>
&lt;p>&lt;strong>The HPA scales up and never back down.&lt;/strong> It is measuring memory, which in this process only goes up.&lt;/p>
&lt;p>&lt;strong>The pods die in a loop right after deploying.&lt;/strong> Less than 4 GiB of limit. The footprint appears with the first large request, not at startup.&lt;/p>
&lt;p>&lt;strong>Postgres starts rejecting connections at the peak.&lt;/strong> The chart has &lt;code>maxReplicas: 100&lt;/code> and the pool defaults to 10.&lt;/p>
&lt;p>&lt;strong>The per-minute limits are enforced badly.&lt;/strong> There is no Redis, and each replica keeps its own count.&lt;/p>
&lt;p>&lt;strong>The health check consumes tokens.&lt;/strong> Someone set &lt;code>/health&lt;/code> as the readiness probe.&lt;/p>
&lt;p>&lt;strong>The readiness probe returns 200 with the database down.&lt;/strong> &lt;code>allow_requests_on_db_unavailable&lt;/code> is set.&lt;/p>
&lt;p>&lt;strong>A deployment keeps receiving traffic after failing.&lt;/strong> The cooldown is 5 seconds, not minutes, and with a single deployment in the group you have to pass a thousand requests for the logic to apply.&lt;/p>
&lt;p>&lt;strong>The fallback test no longer tests anything.&lt;/strong> The simulation parameters are stripped from requests arriving through the proxy since 1.85.0.&lt;/p>
&lt;p>&lt;strong>The p99 is ten times worse than the published benchmark.&lt;/strong> The benchmark has think time and the real load is agentic.&lt;/p>
&lt;h2 id="closing">Closing&lt;/h2>
&lt;p>None of these settings is complicated and all of them are one line. What unites them is that their defaults describe a small deployment, with one instance, a human client on the other side and a commercial provider taking care of capacity. An in-house inference factory is the opposite on all four axes: several replicas, automated clients, and an engine whose capacity is finite and known.&lt;/p>
&lt;p>Of the ten points in the checklist, three decide almost everything in an on-premise deployment: the process model with its sizing, the database connection arithmetic, and the request timeout. The other seven are the ones that avoid the Saturday phone call.&lt;/p>
&lt;h2 id="see-also">See also&lt;/h2>
&lt;ul>
&lt;li>&lt;a href="https://blog.lo0.es/en/posts/litellm-langfuse-operational-pair/">LiteLLM and Langfuse: the operational pair&lt;/a> — the other half of day 2: cost per token on in-house models, trace correlation and the telemetry queues this post takes as given.&lt;/li>
&lt;li>&lt;a href="https://blog.lo0.es/en/posts/llm-inference-router-l7-switchboard/">The LLM inference router: the L7 switchboard&lt;/a> — the router&amp;rsquo;s four functions and the routing strategies whose latency is discussed here.&lt;/li>
&lt;li>&lt;a href="https://blog.lo0.es/en/posts/choosing-oss-gateway-llm-inference/">Choosing the switchboard: which OSS gateway to put in front&lt;/a> — the prior decision, with verified licences.&lt;/li>
&lt;li>&lt;a href="https://blog.lo0.es/en/posts/incident-response-runbooks-llm-inference-kafka-keep/">Incident response runbooks&lt;/a> — where the cascade of 429s and failed probes described here fits.&lt;/li>
&lt;li>&lt;a href="https://blog.lo0.es/en/posts/anatomy-llm-request-production-tour-six-stages/">Anatomy of an LLM request in production&lt;/a> — the complete journey, of which this post details the gateway leg.&lt;/li>
&lt;li>&lt;a href="https://blog.lo0.es/en/posts/guidellm-validating-slo-under-load-sizing-knee/">GuideLLM and SLO validation under load&lt;/a> — how to measure the real saturation point before setting the limits in this article.&lt;/li>
&lt;li>&lt;a href="https://blog.lo0.es/en/posts/anatomy-on-premise-llm-inference-stack-seven-layers/">The on-premise LLM inference stack in seven layers&lt;/a> — where the gateway lives in the complete building.&lt;/li>
&lt;/ul>
&lt;h2 id="sources">Sources&lt;/h2>
&lt;ul>
&lt;li>LiteLLM, &lt;em>Production best practices&lt;/em> (topología, workers, memoria, pool de conexiones, &lt;code>LITELLM_JOB_ROLE&lt;/code>, buffer de transacciones en Redis, &lt;code>request_timeout&lt;/code>): &lt;a href="https://docs.litellm.ai/docs/proxy/prod">https://docs.litellm.ai/docs/proxy/prod&lt;/a>.&lt;/li>
&lt;li>LiteLLM, &lt;em>Health checks&lt;/em> (los tres endpoints, comprobaciones de fondo, &lt;code>enable_drain_endpoint&lt;/code>): &lt;a href="https://docs.litellm.ai/docs/proxy/health">https://docs.litellm.ai/docs/proxy/health&lt;/a>.&lt;/li>
&lt;li>LiteLLM, &lt;em>Reliability&lt;/em> (fallbacks, reintentos, deprecación de los parámetros de simulación en la 1.85.0): &lt;a href="https://docs.litellm.ai/docs/proxy/reliability">https://docs.litellm.ai/docs/proxy/reliability&lt;/a>.&lt;/li>
&lt;li>LiteLLM, &lt;em>Benchmarks&lt;/em> y &lt;em>Load test advanced&lt;/em> (las cifras de sobrecarga por número de instancias y la advertencia de la ley de Little): &lt;a href="https://docs.litellm.ai/docs/benchmarks">https://docs.litellm.ai/docs/benchmarks&lt;/a> · &lt;a href="https://docs.litellm.ai/docs/load_test_advanced">https://docs.litellm.ai/docs/load_test_advanced&lt;/a>.&lt;/li>
&lt;li>LiteLLM, &lt;em>Rust AI Gateway benchmarks&lt;/em> (los 257,7 ms de p99 del proxy en Python): &lt;a href="https://docs.litellm.ai/blog/rust-ai-gateway-benchmarks">https://docs.litellm.ai/blog/rust-ai-gateway-benchmarks&lt;/a>.&lt;/li>
&lt;li>LiteLLM, &lt;em>Latency overhead&lt;/em> (el hueco de la cola de uvicorn, las cabeceras de diagnóstico, el coste de &lt;code>LITELLM_LOG=DEBUG&lt;/code>): &lt;a href="https://docs.litellm.ai/docs/troubleshoot/latency_overhead">https://docs.litellm.ai/docs/troubleshoot/latency_overhead&lt;/a>.&lt;/li>
&lt;li>LiteLLM, &lt;em>DB info&lt;/em> (&lt;code>disable_spend_logs&lt;/code>, &lt;code>disable_error_logs&lt;/code> y qué se pierde con cada uno): &lt;a href="https://docs.litellm.ai/docs/proxy/db_info">https://docs.litellm.ai/docs/proxy/db_info&lt;/a>.&lt;/li>
&lt;li>LiteLLM, &lt;code>litellm/constants.py&lt;/code> (los valores por defecto de reintentos, fallos permitidos, enfriamiento y timeout): &lt;a href="https://github.com/BerriAI/litellm/blob/main/litellm/constants.py">https://github.com/BerriAI/litellm/blob/main/litellm/constants.py&lt;/a>.&lt;/li>
&lt;li>LiteLLM, &lt;code>litellm/router.py&lt;/code> (la discrepancia entre el docstring de &lt;code>cooldown_time&lt;/code> y la constante que usa el código): &lt;a href="https://github.com/BerriAI/litellm/blob/main/litellm/router.py">https://github.com/BerriAI/litellm/blob/main/litellm/router.py&lt;/a>.&lt;/li>
&lt;li>LiteLLM, issue #15526 — cascada de 429 sostenidos, sondas de readiness fallidas y bucle de reinicios: &lt;a href="https://github.com/BerriAI/litellm/issues/15526">https://github.com/BerriAI/litellm/issues/15526&lt;/a>.&lt;/li>
&lt;li>LiteLLM, issue #21046 — degradación de throughput contra un motor propio compatible con OpenAI: &lt;a href="https://github.com/BerriAI/litellm/issues/21046">https://github.com/BerriAI/litellm/issues/21046&lt;/a>.&lt;/li>
&lt;/ul></description></item><item><title>LiteLLM and Langfuse: the operational pair, and the four places where the trace is lost between the gateway and the dashboard</title><link>https://blog.lo0.es/en/posts/litellm-langfuse-operational-pair/</link><pubDate>Mon, 07 Sep 2026 18:00:00 +0200</pubDate><guid>https://blog.lo0.es/en/posts/litellm-langfuse-operational-pair/</guid><description>&lt;blockquote>
&lt;p>This post opens the operational track of the control layer. The previous articles left the pieces chosen and assembled: &lt;a href="https://blog.lo0.es/en/posts/choosing-oss-gateway-llm-inference/">choosing the OSS gateway&lt;/a> and &lt;a href="https://blog.lo0.es/en/posts/llm-inference-router-l7-switchboard/">the L7 inference router&lt;/a> explain why LiteLLM ends up in front of the fleet, and &lt;a href="https://blog.lo0.es/en/posts/langfuse-inside-sorting-centre-bottleneck/">Langfuse from the inside&lt;/a> takes apart the backend that receives the traces. Here we deal with the seam between the two, which is where the work of the next three years lives.&lt;/p>
&lt;/blockquote>
&lt;h2 id="tldr">TL;DR&lt;/h2>
&lt;p>The LiteLLM plus Langfuse pair is configured with a list of callbacks and two API keys, and from there four matters decide whether the dashboard is good for operating or only for showing in a demo.&lt;/p>
&lt;p>&lt;strong>There are three integration routes and they are not equivalent.&lt;/strong> The native &lt;code>langfuse&lt;/code> callback is tied to the Python SDK v2 (&lt;code>langfuse&amp;gt;=2.59.7,&amp;lt;3.0&lt;/code> in LiteLLM&amp;rsquo;s &lt;code>pyproject.toml&lt;/code>), which writes against the legacy ingestion that Langfuse Cloud retires on &lt;strong>16 November 2026&lt;/strong>. The &lt;code>langfuse_otel&lt;/code> preset exports over OTLP and is the path recommended in the current documentation of both projects. The OTel v2 route, behind the &lt;code>LITELLM_OTEL_V2=true&lt;/code> flag, changes span names, leaves the success status at &lt;code>UNSET&lt;/code> and, above all, its Langfuse mapper does not read the request metadata: &lt;code>session_id&lt;/code>, &lt;code>trace_user_id&lt;/code>, &lt;code>tags&lt;/code> and &lt;code>trace_id&lt;/code> stop arriving.&lt;/p>
&lt;p>&lt;strong>The cost of a self-hosted model comes out as zero unless a price is registered.&lt;/strong> &lt;code>get_model_info()&lt;/code> raises an exception for a model that is not in the pricing map, &lt;code>completion_cost()&lt;/code> propagates it, the logger catches it, stores it in a debugging field and returns &lt;code>None&lt;/code>, and the payload turns it into &lt;code>0.0&lt;/code>. There is no warning at the default log level. A team&amp;rsquo;s budget is never exhausted because its spend is always zero.&lt;/p>
&lt;p>&lt;strong>Client-gateway-engine correlation does not come out of the box.&lt;/strong> LiteLLM does continue the client&amp;rsquo;s trace if a &lt;code>traceparent&lt;/code> arrives, and it also extracts from it the 32-hex trace-id to use as &lt;code>litellm_trace_id&lt;/code>, which makes the Langfuse identifier equal to the W3C one. Upstream the story changes: &lt;code>forward_traceparent_to_llm_provider&lt;/code> is &lt;code>False&lt;/code> by default, the function that applies it returns early if no &lt;code>otel&lt;/code> logger is loaded, and when it does act it forwards the client&amp;rsquo;s original header, so the vLLM span ends up a sibling of LiteLLM&amp;rsquo;s rather than a child. Add to that the fact that vLLM exports OTLP over gRPC by default and Langfuse does not accept gRPC.&lt;/p>
&lt;p>&lt;strong>Between the request and the dashboard there are four bounded queues that drop events.&lt;/strong> LiteLLM&amp;rsquo;s logging thread pool (100 threads, 10,000 pending tasks, dropping once full), the asynchronous &lt;code>LoggingWorker&lt;/code> (50,000, and once full it aggressively flushes half), OTel&amp;rsquo;s &lt;code>BatchSpanProcessor&lt;/code> (2,048 spans by default) and Langfuse&amp;rsquo;s &lt;code>ClickhouseWriter&lt;/code>, which drops rows after exhausting the retries with no dead-letter queue. None of the four applies back pressure on the request, which is exactly what you want, and none of them guarantees delivery, which is exactly what an auditor asks about.&lt;/p>
&lt;p>The rest of the article develops the four points with configuration applicable to a generic 4×H100 cluster with vLLM, the overhead figures the project itself publishes, and the honest limit of what this data sustains before ENS or ISO/IEC 42001.&lt;/p>
&lt;h2 id="you-are-here-the-seam-between-deploy-and-observe">You are here: the seam between DEPLOY and OBSERVE&lt;/h2>
&lt;p>In the &lt;a href="https://blog.lo0.es/en/posts/anatomy-on-premise-llm-inference-stack-seven-layers/">seven-layer stack&lt;/a>, LiteLLM lives in the gateway layer and Langfuse in the observability one. The separation is clean on the diagram and false in operation: the gateway is the only point in the system that sees the complete request before chopping it up and the complete response after reassembling it, so it is also the only reasonable place from which to emit the trace. The inference engine sees tokens and latencies; it does not see who is asking or how much that team has spent this month.&lt;/p>
&lt;p>That turns LiteLLM into a telemetry producer as well as a traffic distributor, and moves onto the gateway a responsibility that was not in the original design: if the callback fails, the request is served all the same and nobody notices that the dashboard has been incomplete for two days.&lt;/p>
&lt;h2 id="the-analogy-the-toll-booth-that-issues-the-ticket">The analogy: the toll booth that issues the ticket&lt;/h2>
&lt;p>A motorway toll booth does two independent jobs. It raises the barrier, which is its visible function, and it issues a ticket with number plate, time, section and amount, which is the function the concession lives off. The two share a booth and do not share a destination: the barrier responds in a hundred milliseconds because there is a car waiting, and the ticket travels to a processing centre over a slower channel that batches thousands of records before sending them.&lt;/p>
&lt;p>The design is well thought out as long as its consequence is understood. If the processing centre goes down, cars keep going through. If the ticket queue fills up, tickets are thrown away, traffic is not stopped. The concession accepts losing records rather than causing a jam, and charges on a statistical sample it knows to be imperfect.&lt;/p>
&lt;p>LiteLLM and Langfuse work the same way. The four queues in the corresponding section are the slow channel, and all of them prefer to drop rather than block. Whoever builds the pair inheriting the complete analogy gets the operation right. Whoever thinks the ticket is an accounting entry ends up explaining to an auditor why traces from a Tuesday afternoon are missing.&lt;/p>
&lt;h2 id="three-routes-and-one-with-an-expiry-date">Three routes, and one with an expiry date&lt;/h2>
&lt;p>As of September 2026 three paths coexist for getting a request from LiteLLM to Langfuse. LiteLLM&amp;rsquo;s documentation already labels the first as legacy.&lt;/p>
&lt;table>
&lt;thead>
&lt;tr>
&lt;th>Route&lt;/th>
&lt;th>How it is enabled&lt;/th>
&lt;th>Transport&lt;/th>
&lt;th>Status&lt;/th>
&lt;/tr>
&lt;/thead>
&lt;tbody>
&lt;tr>
&lt;td>Native &lt;code>langfuse&lt;/code> callback&lt;/td>
&lt;td>&lt;code>success_callback: [&amp;quot;langfuse&amp;quot;]&lt;/code>&lt;/td>
&lt;td>Langfuse Python SDK v2, legacy ingestion&lt;/td>
&lt;td>Maintained for compatibility. Tied to &lt;code>langfuse&amp;lt;3.0&lt;/code>&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>&lt;code>langfuse_otel&lt;/code> preset (v1)&lt;/td>
&lt;td>&lt;code>callbacks: [&amp;quot;langfuse_otel&amp;quot;]&lt;/code>&lt;/td>
&lt;td>OTLP/HTTP against &lt;code>/api/public/otel&lt;/code>&lt;/td>
&lt;td>Recommended by both projects&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>OTel v2 route&lt;/td>
&lt;td>&lt;code>callbacks: [&amp;quot;langfuse_otel&amp;quot;]&lt;/code> + &lt;code>LITELLM_OTEL_V2=true&lt;/code>&lt;/td>
&lt;td>OTLP/HTTP, new mapper&lt;/td>
&lt;td>Opt-in. Changes semantics and loses metadata&lt;/td>
&lt;/tr>
&lt;/tbody>
&lt;/table>
&lt;p>The date that orders the decision is &lt;strong>16 November 2026&lt;/strong>. Langfuse&amp;rsquo;s compatibility matrix marks the legacy batch ingestion of the Python SDK v2 as retired on that date for Cloud, at which point trace ingestion through that route stops working. On self-hosted the endpoint survives, but a v4 server in &lt;code>events_only&lt;/code> mode returns 400 for every event type except &lt;code>score-create&lt;/code> and &lt;code>sdk-log&lt;/code>.&lt;/p>
&lt;p>Since LiteLLM&amp;rsquo;s native callback is pinned to &lt;code>langfuse&amp;gt;=2.59.7,&amp;lt;3.0&lt;/code> in its own &lt;code>pyproject.toml&lt;/code>, and its code calls APIs that only exist in SDK v2 (&lt;code>self.Langfuse.trace(...)&lt;/code>, &lt;code>trace.generation(...)&lt;/code>), there is no way to move up an SDK without changing route. Anyone who today has &lt;code>success_callback: [&amp;quot;langfuse&amp;quot;]&lt;/code> against Langfuse Cloud has a migration with a date on it. Anyone who has it against a self-hosted Langfuse has one with some slack, because v3 receives security patches until the end of January 2027, but they have one all the same.&lt;/p>
&lt;p>One detail of the OTLP route worth checking in older deployments: the &lt;code>x-langfuse-ingestion-version: 4&lt;/code> header is the one that enables real-time ingestion. Without it the traces arrive, but with a delay that Langfuse&amp;rsquo;s documentation puts at around ten minutes (the compatibility page says fifteen, and that discrepancy remains unresolved in their own documents). LiteLLM started sending it in version 1.95.0, of August 2026. A proxy older than that version against a Langfuse v4 produces the classic symptom: the dashboard works, but it is always behind and nobody knows why.&lt;/p>
&lt;h2 id="the-configuration-that-works-today">The configuration that works today&lt;/h2>
&lt;p>For a generic 4×H100 cluster with vLLM behind it and Langfuse self-hosted in the same Kubernetes, the OTLP route in its v1 variant is the one that gives the most functionality with the fewest surprises.&lt;/p>
&lt;div class="highlight">&lt;pre tabindex="0" class="chroma">&lt;code class="language-yaml" data-lang="yaml">&lt;span class="line">&lt;span class="cl">&lt;span class="nt">model_list&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>- &lt;span class="nt">model_name&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">llama-70b&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">litellm_params&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">model&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">hosted_vllm/meta-llama/Llama-3.3-70B-Instruct&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">api_base&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">http://vllm-llama70b.inference.svc.cluster.local:8000/v1&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">api_key&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">os.environ/VLLM_API_KEY&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="c"># Price derived from the cluster&amp;#39;s €/GPU-hour. Without this, spend = 0.0&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">input_cost_per_token&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="m">0.00000031&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">output_cost_per_token&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="m">0.00000089&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w">&lt;/span>&lt;span class="nt">litellm_settings&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">callbacks&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="p">[&lt;/span>&lt;span class="s2">&amp;#34;langfuse_otel&amp;#34;&lt;/span>&lt;span class="p">]&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">turn_off_message_logging&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="kc">false&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="c"># a conscious decision, see the PII section&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">langfuse_default_tags&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="p">[&lt;/span>&lt;span class="s2">&amp;#34;model_group&amp;#34;&lt;/span>&lt;span class="p">,&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="s2">&amp;#34;user_api_key_team_alias&amp;#34;&lt;/span>&lt;span class="p">,&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="s2">&amp;#34;cache_hit&amp;#34;&lt;/span>&lt;span class="p">]&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w">&lt;/span>&lt;span class="nt">general_settings&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">disable_error_logs&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="kc">true&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="c"># provider errors inflate SpendLogs&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">proxy_batch_write_at&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="m">60&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>The credentials travel through the environment. &lt;code>LANGFUSE_PUBLIC_KEY&lt;/code>, &lt;code>LANGFUSE_SECRET_KEY&lt;/code> and &lt;code>LANGFUSE_HOST&lt;/code> are enough: the preset builds the &lt;code>Authorization: Basic base64(public:secret)&lt;/code> header and adds the ingestion version one, so there is no need to touch &lt;code>OTEL_EXPORTER_OTLP_HEADERS&lt;/code> by hand. The preset&amp;rsquo;s default host points to the US region of Langfuse Cloud, a relevant detail for anyone building a platform with data sovereignty requirements: if the variable is not defined, the telemetry leaves Spain without anyone having decided it.&lt;/p>
&lt;p>For real multi-tenancy, LiteLLM supports per-team Langfuse credentials (&lt;code>POST /team/&amp;lt;id&amp;gt;/callback&lt;/code> with &lt;code>callback_vars&lt;/code>) and per-key ones (&lt;code>metadata.logging[]&lt;/code> when generating the key), and the key&amp;rsquo;s wins over the team&amp;rsquo;s without mixing. There is a limitation that breaks the architecture of anyone who planned to separate tenants across different Langfuse instances: under OTel v2, a per-key or per-team &lt;code>langfuse_host&lt;/code> does not move that tenant to another host, because the exporter fixes the destination at startup and the only thing that varies per tenant are the headers.&lt;/p>
&lt;p>And one operational warning with years of history behind it: &lt;code>MAX_LANGFUSE_INITIALIZED_CLIENTS&lt;/code> is 50 by default because each Langfuse client starts a thread. The comment in LiteLLM&amp;rsquo;s code is explicit about the incident that motivated it, with the CPU at 100% from repeated initialisations. A deployment with per-team credentials and many teams hits that ceiling.&lt;/p>
&lt;h2 id="what-is-lost-when-moving-to-otel-v2">What is lost when moving to OTel v2&lt;/h2>
&lt;p>The &lt;code>LITELLM_OTEL_V2=true&lt;/code> flag brings a step up in quality in the proxy&amp;rsquo;s general instrumentation, and at the same time a concrete regression on the path to Langfuse that the documentation acknowledges without underlining it.&lt;/p>
&lt;p>The Langfuse mapper of the v2 route emits &lt;code>langfuse.observation.type&lt;/code>, &lt;code>langfuse.observation.model.name&lt;/code>, the observation identifier, the model parameters, input and output, &lt;code>usage_details&lt;/code> and &lt;code>cost_details&lt;/code>, plus &lt;code>langfuse.trace.metadata.team_id&lt;/code> and &lt;code>team_alias&lt;/code>. It does not emit &lt;code>session.id&lt;/code>, nor &lt;code>user.id&lt;/code>, nor &lt;code>langfuse.trace.tags&lt;/code>, nor &lt;code>langfuse.trace.id&lt;/code>, nor the generation name, nor &lt;code>parent_observation_id&lt;/code>. LiteLLM&amp;rsquo;s documents put it this way: those attributes are set by the preset from the request and the response, not from metadata supplied by the client.&lt;/p>
&lt;p>The practical consequence is that an application that today groups conversations by sending &lt;code>metadata: {&amp;quot;session_id&amp;quot;: &amp;quot;...&amp;quot;, &amp;quot;trace_user_id&amp;quot;: &amp;quot;...&amp;quot;}&lt;/code> in the request body stops grouping them when the flag is enabled, with no error and no warning. The traces keep arriving, loose.&lt;/p>
&lt;p>The other v2 changes are semantic and affect alerts already written. The root span goes from being called &lt;code>Received Proxy Server Request&lt;/code> to being named after the route (&lt;code>POST /v1/chat/completions&lt;/code>), the inference span goes from &lt;code>litellm_request&lt;/code> to &lt;code>{operation} {model}&lt;/code>, the guardrail spans stop hanging off the inference span and hang off the root instead, and the success status stays at &lt;code>UNSET&lt;/code> instead of &lt;code>OK&lt;/code>. Any rule that fires on &lt;code>status == OK&lt;/code> stops firing. The way back is to remove the flag and restart.&lt;/p>
&lt;h2 id="cost-why-the-trace-says-zero">Cost: why the trace says zero&lt;/h2>
&lt;p>This is the part that takes the most time to diagnose and the least to fix.&lt;/p>
&lt;p>LiteLLM computes the cost of a request from a per-model pricing map. For commercial provider models the map comes filled in. For &lt;code>hosted_vllm/whatever&lt;/code>, which is all there is in an in-house inference factory, the map is empty, and the failure path is as follows:&lt;/p>
&lt;ol>
&lt;li>&lt;code>litellm.get_model_info()&lt;/code> raises an exception with the message &lt;code>This model isn't mapped yet&lt;/code>.&lt;/li>
&lt;li>&lt;code>completion_cost()&lt;/code> and &lt;code>response_cost_calculator()&lt;/code> propagate the exception.&lt;/li>
&lt;li>&lt;code>Logging._response_cost_calculator()&lt;/code> catches it, writes a &lt;code>StandardLoggingModelCostFailureDebugInformation&lt;/code> into &lt;code>response_cost_failure_debug_information&lt;/code>, logs it at &lt;strong>debug&lt;/strong> level and returns &lt;code>None&lt;/code>.&lt;/li>
&lt;li>The standard logging payload does &lt;code>llm_response_cost = raw_response_cost or 0.0&lt;/code>.&lt;/li>
&lt;/ol>
&lt;p>The system delivers a spend of zero, with no visible error at the default log level, and writes it into the &lt;code>spend&lt;/code> column of &lt;code>LiteLLM_SpendLogs&lt;/code>. The per-key, per-team and per-organisation budgets are evaluated against that zero and are never exhausted. The Langfuse dashboard shows a total cost of zero for everything self-hosted, which is precisely the part you wanted to measure.&lt;/p>
&lt;h3 id="registering-the-price">Registering the price&lt;/h3>
&lt;p>The pricing keys are accepted both in &lt;code>litellm_params&lt;/code> and in &lt;code>model_info&lt;/code>, and those in &lt;code>litellm_params&lt;/code> win when they are in both places. The complete set has 91 fields, but for a deployment with vLLM two are enough, and there are three things to know about them.&lt;/p>
&lt;p>The first is that &lt;strong>the price is per individual token, in USD&lt;/strong>. There are no per-thousand variants: searching for &lt;code>per_1k&lt;/code> or &lt;code>per_1000&lt;/code> in the pricing model returns nothing. A three-orders-of-magnitude error here is easy to make and hard to see, because it produces plausible figures.&lt;/p>
&lt;p>The second is that &lt;code>input_cost_per_request&lt;/code> exists in the schema and &lt;strong>is not applied&lt;/strong> in chat calls: a model registered with that field returns a cost of 0.0. To charge per request it has to be converted into a cost per token.&lt;/p>
&lt;p>The third is that &lt;code>input_cost_per_second&lt;/code> needs the response time and is computed as price times &lt;code>response_time_ms / 1000&lt;/code>, so outside the proxy&amp;rsquo;s request path it silently returns zero.&lt;/p>
&lt;p>The number that goes into those two keys comes from the article on &lt;a href="https://blog.lo0.es/en/posts/finops-multi-tenancy-gpu-cluster-who-pays/">FinOps and multi-tenancy with LiteLLM&lt;/a> and the one on &lt;a href="https://blog.lo0.es/en/posts/gpu-hour-to-cost-per-token-on-prem-vs-cloud/">cost per token and per request&lt;/a>: amortisation plus energy divided by tokens served. The example arithmetic, to set the order of magnitude: a cluster whose fully loaded cost is 9.60 USD per hour sustaining 3,000 output tokens per second produces 10.8 million tokens an hour, that is 0.00000089 USD per output token. That is the value that goes into &lt;code>output_cost_per_token&lt;/code>. If the cluster&amp;rsquo;s average utilisation is 30 % and not 100 %, the divisor changes and the price triples, which is the same lever that article develops.&lt;/p>
&lt;p>Before sending traffic, the &lt;code>/spend/calculate&lt;/code> endpoint accepts the same parameters as &lt;code>completion_cost&lt;/code> and returns the computed cost. It is the cheap way to check that the price resolves.&lt;/p>
&lt;h3 id="how-that-number-reaches-langfuse-and-who-wins">How that number reaches Langfuse, and who wins&lt;/h3>
&lt;p>The two integration routes write the cost into different fields. The native callback sends it as &lt;code>usage.total_cost&lt;/code> of the v2 schema, and duplicates it in the trace metadata as &lt;code>litellm_response_cost&lt;/code>. The OTLP preset emits &lt;code>langfuse.observation.cost_details&lt;/code> as a JSON string of the form &lt;code>{&amp;quot;total&amp;quot;: cost}&lt;/code>, and &lt;code>langfuse.observation.usage_details&lt;/code> with input, output and total.&lt;/p>
&lt;p>The rule on the Langfuse side is documented and is the right one for this case: when there is ingested cost and inferred cost, &lt;strong>the ingested one takes priority&lt;/strong>. Langfuse only computes from its own pricing table when the emitter sends no cost, and changes to a model definition apply to new generations, with no retroactive recalculation. A self-hosted model therefore needs no pricing definition in Langfuse: it is enough for LiteLLM to send &lt;code>cost_details&lt;/code>.&lt;/p>
&lt;p>One detail remains that explains a puzzling symptom. Langfuse stores the ingested cost and the computed one in separate columns, &lt;code>totalCost&lt;/code> and &lt;code>calculatedTotalCost&lt;/code>, and the session header totals add up only the first. A mixed deployment where part of the traffic brings cost and part lets Langfuse compute it shows sessions at zero while the individual generations show cost. There is an open issue about it.&lt;/p>
&lt;h3 id="the-tokens-of-a-streaming-response">The tokens of a streaming response&lt;/h3>
&lt;p>Almost all of an assistant&amp;rsquo;s traffic is streamed, and there the OpenAI &lt;code>usage&lt;/code> object only appears if the client asks for &lt;code>stream_options.include_usage&lt;/code>. LiteLLM solves this by injecting the option on its own: on the asynchronous completion routes with &lt;code>stream: true&lt;/code>, if the caller did not set it and all the candidate deployments support it, it adds it and marks &lt;code>_litellm_strip_stream_usage&lt;/code> so the usage block is used in the cost calculation and not returned to the client. The &lt;code>general_settings.always_include_stream_usage&lt;/code> flag forces or disables that behaviour, and is worth setting to &lt;code>True&lt;/code> when capability detection does not recognise the vLLM deployment.&lt;/p>
&lt;p>When the usage is missing anyway, LiteLLM reassembles the response with &lt;code>stream_chunk_builder()&lt;/code> and counts tokens with the tokeniser. It is an approximation, and it produces the second path towards a cost of zero. The two paths to zero are told apart by looking at &lt;code>total_tokens&lt;/code> in &lt;code>LiteLLM_SpendLogs&lt;/code>: zero tokens with a correct price is a usage problem, correct tokens with zero cost is a price problem.&lt;/p>
&lt;h2 id="correlation-from-the-client-to-the-engine">Correlation: from the client to the engine&lt;/h2>
&lt;p>The operational question that justifies the whole setup is being able to take a user complaint, find their request and see where the time went. That requires the trace identifier to survive three hops.&lt;/p>
&lt;div class="diagram" style="max-width:860px;margin:1.5rem auto;">
&lt;svg viewBox="0 0 860 330" xmlns="http://www.w3.org/2000/svg" role="img" aria-label="Propagation of the client's traceparent to the LiteLLM gateway and the vLLM engine, with the points where correlation breaks">
&lt;style>
.bx{fill:none;stroke:currentColor;stroke-width:1.3}
.bxd{fill:none;stroke:currentColor;stroke-width:1;stroke-dasharray:4 3}
.t{font:600 13px sans-serif;fill:currentColor}
.h{font:600 12px sans-serif;fill:currentColor}
.s{font:11px sans-serif;fill:currentColor}
.ar{stroke:currentColor;stroke-width:1.2;fill:none;marker-end:url(#ah)}
.ard{stroke:currentColor;stroke-width:1.1;fill:none;stroke-dasharray:5 3;marker-end:url(#ah)}
&lt;/style>
&lt;defs>&lt;marker id="ah" 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="currentColor"/>&lt;/marker>&lt;/defs>
&lt;text x="430" y="18" text-anchor="middle" class="t">One identifier, three hops and two places where it is lost&lt;/text>
&lt;rect x="20" y="40" width="150" height="60" class="bx" rx="6"/>
&lt;text x="36" y="62" class="h">Client&lt;/text>
&lt;text x="36" y="78" class="s">traceparent: 00-abc…-01&lt;/text>
&lt;text x="36" y="92" class="s">baggage: session.id=…&lt;/text>
&lt;rect x="240" y="40" width="180" height="60" class="bx" rx="6"/>
&lt;text x="256" y="62" class="h">LiteLLM Proxy&lt;/text>
&lt;text x="256" y="78" class="s">litellm_trace_id = abc…&lt;/text>
&lt;text x="256" y="92" class="s">gateway parent span&lt;/text>
&lt;rect x="500" y="40" width="160" height="60" class="bx" rx="6"/>
&lt;text x="516" y="62" class="h">vLLM&lt;/text>
&lt;text x="516" y="78" class="s">gen_ai.latency.*&lt;/text>
&lt;text x="516" y="92" class="s">OTLP gRPC by default&lt;/text>
&lt;rect x="700" y="40" width="140" height="60" class="bxd" rx="6"/>
&lt;text x="716" y="62" class="h">Collector&lt;/text>
&lt;text x="716" y="78" class="s">gRPC to HTTP&lt;/text>
&lt;text x="716" y="92" class="s">renames attributes&lt;/text>
&lt;path d="M170 70 L236 70" class="ar"/>
&lt;text x="203" y="62" text-anchor="middle" class="s">1&lt;/text>
&lt;path d="M420 70 L496 70" class="ard"/>
&lt;text x="458" y="62" text-anchor="middle" class="s">2&lt;/text>
&lt;path d="M660 70 L696 70" class="ar"/>
&lt;rect x="20" y="140" width="400" height="52" class="bxd" rx="6"/>
&lt;text x="36" y="160" class="h">1 · Inbound: works&lt;/text>
&lt;text x="36" y="177" class="s">LiteLLM extracts the 32-hex trace-id from the traceparent and uses it&lt;/text>
&lt;rect x="440" y="140" width="400" height="52" class="bxd" rx="6"/>
&lt;text x="456" y="160" class="h">2 · Outbound: off by default&lt;/text>
&lt;text x="456" y="177" class="s">forward_traceparent_to_llm_provider = False&lt;/text>
&lt;rect x="20" y="212" width="820" height="96" class="bx" rx="6"/>
&lt;text x="36" y="232" class="h">The three ways hop 2 fails to happen&lt;/text>
&lt;text x="36" y="252" class="s">a · The function returns early if no otel callback is loaded, even with the flag at True&lt;/text>
&lt;text x="36" y="270" class="s">b · It forwards the client header, not its own span context: vLLM ends up a sibling, not a child&lt;/text>
&lt;text x="36" y="288" class="s">c · If the client sends no traceparent, none is created&lt;/text>
&lt;path d="M220 100 L220 136" class="ar"/>
&lt;path d="M640 100 L640 136" class="ar"/>
&lt;/svg>
&lt;/div>
&lt;h3 id="what-does-work">What does work&lt;/h3>
&lt;p>If the client sends &lt;code>traceparent&lt;/code>, LiteLLM&amp;rsquo;s spans hang off the existing trace. In addition, and independently of the OTel subsystem, &lt;code>litellm_pre_call_utils.py&lt;/code> extracts by regular expression the 32-character hexadecimal trace-id and assigns it to &lt;code>data[&amp;quot;litellm_trace_id&amp;quot;]&lt;/code> as a last resort, and takes the &lt;code>session_id&lt;/code> from the W3C &lt;code>baggage&lt;/code> header. That makes the Langfuse trace identifier equal to the W3C trace identifier, which is the most robust correlation key available: with it, an identifier copied from the application log opens the trace in Langfuse with no translation.&lt;/p>
&lt;p>For the routes that are not OTel v2, moreover, headers prefixed with &lt;code>langfuse_&lt;/code> inject per-request metadata. &lt;code>langfuse_trace_id&lt;/code>, &lt;code>langfuse_trace_user_id&lt;/code> and &lt;code>langfuse_trace_metadata&lt;/code> are documented, but the code strips the prefix and writes the rest into the metadata, so any body key works as a header. Two warnings about this: header values arrive as strings and are not interpreted as JSON, and Nginx drops headers with underscores by default (&lt;code>underscores_in_headers off&lt;/code>), which produces the symptom that the metadata works locally and disappears behind the ingress.&lt;/p>
&lt;h3 id="what-does-not-work-without-effort">What does not work without effort&lt;/h3>
&lt;p>Towards the engine, propagation is off. The flag is &lt;code>litellm.forward_traceparent_to_llm_provider&lt;/code>, it is &lt;code>False&lt;/code>, and its implementation has three behaviours to know about before enabling it:&lt;/p>
&lt;ul>
&lt;li>The function returns immediately if &lt;code>open_telemetry_logger is None&lt;/code>. A deployment with &lt;code>callbacks: [&amp;quot;langfuse_otel&amp;quot;]&lt;/code> and without the generic &lt;code>otel&lt;/code> callback propagates nothing, whatever the flag is set to.&lt;/li>
&lt;li>When it does propagate, it copies the client&amp;rsquo;s &lt;code>traceparent&lt;/code> header as is, instead of injecting the context of LiteLLM&amp;rsquo;s active span. The vLLM span ends up hanging off the client&amp;rsquo;s span-id, a sibling of the gateway call&amp;rsquo;s span. The trace is one, the hierarchy is wrong.&lt;/li>
&lt;li>If the client sends no &lt;code>traceparent&lt;/code>, none is created.&lt;/li>
&lt;/ul>
&lt;p>Add to that the fact that LiteLLM&amp;rsquo;s general policy is not to forward client headers to the provider, and that the &lt;code>forward_client_headers_to_llm_api&lt;/code> allowlist covers headers prefixed with &lt;code>x-&lt;/code>, among which &lt;code>traceparent&lt;/code> is not. This is the only route.&lt;/p>
&lt;h3 id="the-vllm-side-and-why-the-collector-stops-being-optional">The vLLM side, and why the Collector stops being optional&lt;/h3>
&lt;p>vLLM enables traces with &lt;code>--otlp-traces-endpoint&lt;/code>, and &lt;code>--collect-detailed-traces&lt;/code> with &lt;code>model&lt;/code>, &lt;code>worker&lt;/code> or &lt;code>all&lt;/code> adds forward and execute times, with a performance impact its own documentation warns about. The attributes it emits are in &lt;code>vllm/tracing/utils.py&lt;/code> and are the most useful part of the setup: &lt;code>gen_ai.latency.time_in_queue&lt;/code>, &lt;code>time_to_first_token&lt;/code>, &lt;code>time_in_scheduler&lt;/code>, &lt;code>time_in_model_prefill&lt;/code>, &lt;code>time_in_model_decode&lt;/code>, &lt;code>time_in_model_forward&lt;/code>, &lt;code>time_in_model_execute&lt;/code>, &lt;code>time_in_model_inference&lt;/code> and &lt;code>e2e&lt;/code>, plus the token counters. That breakdown is what turns a &amp;ldquo;it&amp;rsquo;s slow&amp;rdquo; into &amp;ldquo;the time goes in the queue, not in decode&amp;rdquo;, which is the conversation you want to have during an incident. It fits with what was already covered in &lt;a href="https://blog.lo0.es/en/posts/instrumenting-vllm-otel-measuring-optimisations/">instrumenting vLLM with OTel&lt;/a> and with the metrics in &lt;a href="https://blog.lo0.es/en/posts/anatomy-dcgm-vllm-metrics-analogies-documented-anomalies/">DCGM and vLLM&lt;/a>.&lt;/p>
&lt;p>There are three constraints that decide the architecture:&lt;/p>
&lt;p>vLLM exports over gRPC unless &lt;code>OTEL_EXPORTER_OTLP_TRACES_PROTOCOL=http/protobuf&lt;/code> is set, and its gRPC exporter is built with &lt;code>insecure=True&lt;/code> hard-coded. Langfuse, for its part, does not support gRPC. And vLLM emits the old convention names (&lt;code>gen_ai.usage.prompt_tokens&lt;/code>, &lt;code>gen_ai.usage.completion_tokens&lt;/code>), renamed in the specification to &lt;code>input_tokens&lt;/code> and &lt;code>output_tokens&lt;/code>, plus a &lt;code>gen_ai.latency.*&lt;/code> family that was never standard and that the file itself documents as its own attributes until they are normalised.&lt;/p>
&lt;p>An OpenTelemetry Collector in between solves all three at once: it receives gRPC, exports HTTP with the Basic header towards &lt;code>/api/public/otel/v1/traces&lt;/code>, and renames attributes with a &lt;code>transform&lt;/code>. It is also the only place where tail sampling can be done, which is the next point. And add the warning from the llm-d community, which holds for any deployment with Envoy or sidecars in between: &lt;code>OTEL_SERVICE_NAME&lt;/code> has to be set per engine, because vLLM sends no service name and without it every span arrives as &lt;code>unknown_service&lt;/code>.&lt;/p>
&lt;h2 id="the-four-serial-queues">The four serial queues&lt;/h2>
&lt;p>A request&amp;rsquo;s telemetry crosses four bounded buffers before it is queryable. All four drop once full and none blocks the request.&lt;/p>
&lt;table>
&lt;thead>
&lt;tr>
&lt;th>Queue&lt;/th>
&lt;th>Capacity&lt;/th>
&lt;th>What it does once full&lt;/th>
&lt;/tr>
&lt;/thead>
&lt;tbody>
&lt;tr>
&lt;td>LiteLLM logging thread pool&lt;/td>
&lt;td>100 threads, 10,000 pending tasks&lt;/td>
&lt;td>Drops the new ones with a rate-limited warning&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>Asynchronous &lt;code>LoggingWorker&lt;/code>&lt;/td>
&lt;td>50,000 events, concurrency 100&lt;/td>
&lt;td>Aggressively flushes 50 % of the queue; &lt;code>enqueue()&lt;/code> documents that it never blocks&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>OTel&amp;rsquo;s &lt;code>BatchSpanProcessor&lt;/code>&lt;/td>
&lt;td>2,048 spans (&lt;code>OTEL_BSP_MAX_QUEUE_SIZE&lt;/code>), batch 512, sent every 5 s&lt;/td>
&lt;td>Drops spans silently&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>Langfuse&amp;rsquo;s &lt;code>ClickhouseWriter&lt;/code>&lt;/td>
&lt;td>Bounded retries&lt;/td>
&lt;td>Drops rows after exhausting retries, with no dead-letter queue&lt;/td>
&lt;/tr>
&lt;/tbody>
&lt;/table>
&lt;p>The first is documented in LiteLLM&amp;rsquo;s own code with a sentence that saves half an hour of discussion: logging is best-effort and, once the cap is reached, new entries are dropped with a rate-limited warning instead of being queued forever. The third is a barely visible consequence: LiteLLM builds the &lt;code>BatchSpanProcessor&lt;/code> without adjusting &lt;code>max_queue_size&lt;/code>, so the OTel SDK defaults govern, and no LiteLLM documentation mentions the &lt;code>OTEL_BSP_*&lt;/code> variables that change them. The Langfuse SDK does the same on its own account: it sets batch size and interval, and leaves the queue size at the default.&lt;/p>
&lt;p>On top of this comes a fifth loss point that is not a queue: LiteLLM does not call &lt;code>flush()&lt;/code> per request on the native route, so a &lt;code>SIGKILL&lt;/code> on the pod takes the last batch with it. On Kubernetes, this is an argument for a generous &lt;code>terminationGracePeriodSeconds&lt;/code> and a &lt;code>preStop&lt;/code> that gives some slack.&lt;/p>
&lt;p>None of this is a defect. It is the right design decision for a piece that sits on the critical path of inference. What changes is what can be asserted with the resulting data.&lt;/p>
&lt;h2 id="what-cannot-be-promised-to-the-auditor">What cannot be promised to the auditor&lt;/h2>
&lt;p>A dashboard built on this pipeline answers statistical questions well: what is the p95 of time to first token for the model group, which team consumes the most, what proportion of requests fails, how cost per request evolves. That is what it is designed for, and that is what it is good for.&lt;/p>
&lt;p>It does not answer completeness questions well, which are the ones an ENS auditor or an ISO/IEC 42001 management system auditor asks. Faced with &amp;ldquo;show me that I have a record of every interaction with the AI system during March&amp;rdquo;, the honest answer is that the traces are a best-effort record with four documented points of silent dropping, with no dead-letter queue on the last hop and no durability guarantee on any of them.&lt;/p>
&lt;p>The clean separation is the one already proposed in &lt;a href="https://blog.lo0.es/en/posts/technical-controls-ens-iso-42001-eu-ai-act-cross-mapping/">technical controls ENS, 42001 and the AI Act&lt;/a>: the regulatory audit record is a different artefact, with a different write path, durability guarantees and retention of its own. In the pair this article deals with, the &lt;code>LiteLLM_SpendLogs&lt;/code> table in Postgres is a much better candidate than Langfuse for that function, because it is a transactional write against a relational database, and because it already contains &lt;code>request_id&lt;/code>, &lt;code>api_key&lt;/code> in hashed form, &lt;code>team_id&lt;/code>, &lt;code>organization_id&lt;/code>, &lt;code>end_user&lt;/code>, &lt;code>model&lt;/code>, tokens, &lt;code>startTime&lt;/code>, &lt;code>endTime&lt;/code>, &lt;code>status&lt;/code> and &lt;code>requester_ip_address&lt;/code>. Writing the audit event from the same place and in the same transaction as the spend is the sensible route; delegating it to the tracing pipeline is not.&lt;/p>
&lt;p>Here an operational tension appears that has to be resolved deliberately. LiteLLM&amp;rsquo;s performance recommendation for production includes &lt;code>disable_error_logs: true&lt;/code> and, in very loaded deployments, &lt;code>disable_spend_logs: true&lt;/code>, because provider errors inflate the table and concurrent writes against the same rows produce locks and exhaust the Postgres connections. Disabling &lt;code>spend_logs&lt;/code> to gain performance and at the same time leaning on that table for the audit record are incompatible decisions. Above a thousand requests per second or more than ten instances, the way out is &lt;code>use_redis_transaction_buffer: true&lt;/code>, which batches the spend updates in Redis before pushing them down to Postgres, watching the &lt;code>litellm_in_memory_spend_update_queue_size&lt;/code> and &lt;code>litellm_redis_spend_update_queue_size&lt;/code> metrics.&lt;/p>
&lt;h2 id="pii-switching-off-the-content-before-it-leaves">PII: switching off the content before it leaves&lt;/h2>
&lt;p>An LLM trace contains, by definition, the user&amp;rsquo;s prompt. In an on-premise deployment for a regulated customer, that content may be exactly what cannot leave the application&amp;rsquo;s domain, not even towards the observability backend in the same cluster.&lt;/p>
&lt;p>The controls on the proxy side, from most global to most fine-grained:&lt;/p>
&lt;ul>
&lt;li>&lt;code>litellm_settings.turn_off_message_logging: true&lt;/code> cuts messages and responses in every callback, and keeps the metadata and the spend. The redaction replaces the content with the &lt;code>REDACTED_BY_LITELLM&lt;/code> sentinel in messages, &lt;code>choices&lt;/code>, &lt;code>reasoning_content&lt;/code>, streaming deltas, tool call arguments and in the standard logging object.&lt;/li>
&lt;li>The &lt;code>x-litellm-enable-message-redaction: true&lt;/code> header enables redaction per request, and &lt;code>LiteLLM-Disable-Message-Redaction: true&lt;/code> disables it. The second is checked first and wins over everything else, including the global configuration, which makes it a control to block at the ingress if the policy is that content is never logged.&lt;/li>
&lt;li>&lt;code>redact_user_api_key_info: true&lt;/code> removes the hashed token, the &lt;code>user_id&lt;/code> and the &lt;code>team_id&lt;/code> from the log.&lt;/li>
&lt;li>Per request and per callback, &lt;code>metadata: {&amp;quot;mask_input&amp;quot;: true, &amp;quot;mask_output&amp;quot;: true}&lt;/code>.&lt;/li>
&lt;li>The body parameter &lt;code>&amp;quot;no-log&amp;quot;: true&lt;/code> disables logging of that call. It is a body parameter, not a header, and it can be disabled globally with &lt;code>global_disable_no_log_param: true&lt;/code> so a client cannot make itself invisible.&lt;/li>
&lt;/ul>
&lt;p>On the OTel v2 route the policy is inverted towards the safe side: content is off by default and is opened with &lt;code>OTEL_INSTRUMENTATION_GENAI_CAPTURE_MESSAGE_CONTENT&lt;/code>, with values &lt;code>no_content&lt;/code>, &lt;code>span_only&lt;/code>, &lt;code>event_only&lt;/code> and &lt;code>span_and_event&lt;/code>. The documentation underlines that the gate is applied centrally and that a user&amp;rsquo;s request cannot force their prompt towards the backend while capture is disabled. It is also the stance the OpenTelemetry specification recommends, whose preferred pattern for production is to store the content in external storage and leave only the reference in the span.&lt;/p>
&lt;p>One specific version to check in the inventory: below LiteLLM 1.98.0, with per-team Langfuse credentials, the team&amp;rsquo;s own credentials ended up inside its traces. That version changed the origin of the metadata emitted to the &lt;code>StandardLoggingPayload&lt;/code> to cut it off. The same change removed a couple of dozen metadata fields (&lt;code>model_group&lt;/code>, &lt;code>deployment&lt;/code>, &lt;code>queue_time_seconds&lt;/code>, &lt;code>attempted_retries&lt;/code> and others), so it breaks saved dashboards and alerts that filter on them. It is an upgrade with work attached, and it has to be done anyway. The same applies to the general hardening of the stack, covered in &lt;a href="https://blog.lo0.es/en/posts/hardening-secrets-sovereign-llm-stack-defence-in-depth/">hardening and secrets of the sovereign stack&lt;/a>.&lt;/p>
&lt;h2 id="the-price-in-latency">The price in latency&lt;/h2>
&lt;p>LiteLLM publishes two overhead figures that differ by a factor of thirty, and both are theirs.&lt;/p>
&lt;p>The benchmarks page, with Locust, a thousand users with think time and a fake OpenAI endpoint, gives for four instances an &lt;code>x-litellm-overhead-duration-ms&lt;/code> of median 2 ms, p95 8 ms and p99 13 ms, at 1,170 requests per second. For two instances, median 12 ms, p95 29 ms and p99 43 ms. The page itself warns that those figures hold with around 130 in-flight requests, and that a closed-loop client with no think time keeps a thousand in flight and sees on the order of eight times that latency at the same rate, by Little&amp;rsquo;s law.&lt;/p>
&lt;p>The AIGatewayBench benchmark, of July 2026, measures the Python proxy at &lt;strong>257.7 ms of added p99&lt;/strong> with 329.5 MB of peak memory, against 0.7 ms for the Rust variant in beta. That measurement has no callbacks, no spend tracking and no persistence.&lt;/p>
&lt;p>The operational reading is that the first figure is the best case and the second the order of magnitude to plan for agentic load, which is the one that arrives in a closed loop against fast responses. To watch it there are two always-on headers: &lt;code>x-litellm-overhead-duration-ms&lt;/code> and &lt;code>x-litellm-callback-duration-ms&lt;/code>, and the documentation sets the useful threshold of the second at 100 ms, above which the diagnosis is that the payloads are too large.&lt;/p>
&lt;p>Three production settings that appear in LiteLLM&amp;rsquo;s documentation and that are wrong by default:&lt;/p>
&lt;p>&lt;strong>&lt;code>request_timeout&lt;/code> is 6,000 seconds.&lt;/strong> A hundred minutes holding a connection against a hung upstream. The recommendation is to lower it to 600.&lt;/p>
&lt;p>&lt;strong>&lt;code>LITELLM_LOG=DEBUG&lt;/code> serialises the payload with &lt;code>json.dumps(indent=4)&lt;/code> synchronously&lt;/strong>, and the documentation puts the cost of that serialisation at 2 to 5 seconds with payloads over 2 MB. It is the first cause of latency their troubleshooting guide mentions.&lt;/p>
&lt;p>&lt;strong>Background jobs register per uvicorn worker, not per pod.&lt;/strong> With &lt;code>--num_workers 4&lt;/code> and ten replicas that is forty copies of every periodic job. The &lt;code>LITELLM_JOB_ROLE&lt;/code> variable separates the pods that serve traffic from the replica that runs jobs.&lt;/p>
&lt;p>And one failure mode with an open issue that deserves to be in the runbook: under sustained 429s from the upstream, pods have been reported unable to answer readiness probes during startup, with the ensuing restart loop. It fits with what was covered in &lt;a href="https://blog.lo0.es/en/posts/incident-response-runbooks-llm-inference-kafka-keep/">incident response runbooks&lt;/a>.&lt;/p>
&lt;h2 id="start-up-checklist">Start-up checklist&lt;/h2>
&lt;p>Ten checks before signing off the installation of the pair:&lt;/p>
&lt;ol>
&lt;li>&lt;code>callbacks: [&amp;quot;langfuse_otel&amp;quot;]&lt;/code> instead of &lt;code>success_callback: [&amp;quot;langfuse&amp;quot;]&lt;/code>, unless you depend on prompt management, which the OTLP preset does not support.&lt;/li>
&lt;li>&lt;code>LANGFUSE_HOST&lt;/code> defined explicitly. The preset&amp;rsquo;s default value points to the US region.&lt;/li>
&lt;li>LiteLLM at 1.98.0 or higher, because of the team credential leak into the traces, and at 1.95.0 or higher because of the v4 ingestion header.&lt;/li>
&lt;li>A price registered for every self-hosted model, per token and in USD, verified with &lt;code>/spend/calculate&lt;/code> before opening traffic.&lt;/li>
&lt;li>A test request with a known &lt;code>traceparent&lt;/code>, checking that the same identifier appears in Langfuse.&lt;/li>
&lt;li>An OpenTelemetry Collector between vLLM and Langfuse, with &lt;code>OTEL_SERVICE_NAME&lt;/code> per engine and renaming of the old token attributes.&lt;/li>
&lt;li>&lt;code>request_timeout: 600&lt;/code> and &lt;code>LITELLM_LOG&lt;/code> out of &lt;code>DEBUG&lt;/code>.&lt;/li>
&lt;li>Tail sampling in the Collector, with a latency and status code policy, to keep 100 % of what is slow and what failed.&lt;/li>
&lt;li>A written decision about prompt content, and blocking of the &lt;code>LiteLLM-Disable-Message-Redaction&lt;/code> header at the ingress if the policy is not to log.&lt;/li>
&lt;li>A dashboard with &lt;code>x-litellm-overhead-duration-ms&lt;/code>, &lt;code>x-litellm-callback-duration-ms&lt;/code> and Langfuse&amp;rsquo;s ingestion queue depth (&lt;code>langfuse.queue.ingestion.depth&lt;/code>), which is the autoscaling signal for its workers.&lt;/li>
&lt;/ol>
&lt;h2 id="traps-and-things-that-are-not-what-they-look-like">Traps and things that are not what they look like&lt;/h2>
&lt;p>&lt;strong>The trace arrives, but ten minutes late.&lt;/strong> The &lt;code>x-langfuse-ingestion-version: 4&lt;/code> header is missing, which LiteLLM sends from 1.95.0. It is not a ClickHouse capacity problem.&lt;/p>
&lt;p>&lt;strong>The metadata works in development and disappears in production.&lt;/strong> Nginx drops headers with underscores by default, and all of this integration&amp;rsquo;s have one.&lt;/p>
&lt;p>&lt;strong>Session cost comes out as zero and generation cost does not.&lt;/strong> Langfuse adds up only the ingested cost in the session totals, not the one it computes itself.&lt;/p>
&lt;p>&lt;strong>Enabling OTel v2 breaks nothing visible and ungroups the conversations.&lt;/strong> The v2 mapper does not read &lt;code>session_id&lt;/code> or &lt;code>trace_user_id&lt;/code> from the request.&lt;/p>
&lt;p>&lt;strong>A team&amp;rsquo;s budget is never exhausted.&lt;/strong> Its spend is zero because the model has no price, and the calculation failure is only logged at debug level.&lt;/p>
&lt;p>&lt;strong>The alerts stop firing after the migration to v2.&lt;/strong> The success status becomes &lt;code>UNSET&lt;/code>; no rule on &lt;code>OK&lt;/code> is satisfied.&lt;/p>
&lt;p>&lt;strong>Forwarding the &lt;code>traceparent&lt;/code> is enabled and it does not reach the engine.&lt;/strong> The generic &lt;code>otel&lt;/code> callback is missing, without which the function that applies it returns before looking at the flag.&lt;/p>
&lt;p>&lt;strong>All the engines&amp;rsquo; spans appear under &lt;code>unknown_service&lt;/code>.&lt;/strong> vLLM emits no service name; &lt;code>OTEL_SERVICE_NAME&lt;/code> has to be set per deployment.&lt;/p>
&lt;h2 id="closing">Closing&lt;/h2>
&lt;p>The gateway and the tracing backend are the two pieces of the inference stack that take the least time to install and the longest to operate well. The installation fits in a ConfigMap. The operation consists of four decisions no getting-started guide raises: which integration route to take knowing that one expires in November, where the price per token of a model nobody invoices comes from, what to do with the &lt;code>traceparent&lt;/code> at each hop, and what to tell an auditor about data the system drops by design when things get tight.&lt;/p>
&lt;p>The answer to the fourth is the one that orders the other three. This pipeline is operational instrumentation, with statistical precision and best-effort guarantees, and separating it from day one from the regulatory audit record avoids the awkward conversation two years from now. With that separation made, the LiteLLM and Langfuse pair is the pair of pieces that turns a fleet of GPUs into a platform with an owner, a known cost and explainable latency.&lt;/p>
&lt;h2 id="see-also">See also&lt;/h2>
&lt;ul>
&lt;li>&lt;a href="https://blog.lo0.es/en/posts/langfuse-inside-sorting-centre-bottleneck/">Langfuse from the inside: the sorting centre&lt;/a> — the six-service architecture and the ten backend knobs behind the receiving end of this integration. That post opens the box; this one connects the cable.&lt;/li>
&lt;li>&lt;a href="https://blog.lo0.es/en/posts/llm-inference-router-l7-switchboard/">The LLM inference router: the L7 switchboard&lt;/a> — the router&amp;rsquo;s four functions and the minimal LiteLLM Proxy manifest on the generic cluster.&lt;/li>
&lt;li>&lt;a href="https://blog.lo0.es/en/posts/choosing-oss-gateway-llm-inference/">Choosing the OSS gateway for LLM inference&lt;/a> — the decision prior to this article, with the small print on licences and what is gated in LiteLLM.&lt;/li>
&lt;li>&lt;a href="https://blog.lo0.es/en/posts/finops-multi-tenancy-gpu-cluster-who-pays/">FinOps and GPU multi-tenancy with LiteLLM&lt;/a> — where the numbers that go into &lt;code>input_cost_per_token&lt;/code> and &lt;code>output_cost_per_token&lt;/code> come from, and why utilisation is the lever.&lt;/li>
&lt;li>&lt;a href="https://blog.lo0.es/en/posts/gpu-hour-to-cost-per-token-on-prem-vs-cloud/">From GPU-hour to cost per token and per request&lt;/a> — the full arithmetic of the conversion summarised here in one line.&lt;/li>
&lt;li>&lt;a href="https://blog.lo0.es/en/posts/instrumenting-vllm-otel-measuring-optimisations/">Instrumenting vLLM with OpenTelemetry&lt;/a> — the other end of the correlation, with the detail of what each &lt;code>gen_ai.latency.*&lt;/code> measures.&lt;/li>
&lt;li>&lt;a href="https://blog.lo0.es/en/posts/llm-tracing-opentelemetry-genai/">LLM tracing with OpenTelemetry GenAI&lt;/a> — the &lt;code>SDK → Collector → backend&lt;/code> pipeline and the two-layer sampling this post takes as given.&lt;/li>
&lt;li>&lt;a href="https://blog.lo0.es/en/posts/technical-controls-ens-iso-42001-eu-ai-act-cross-mapping/">Technical controls: ENS, ISO 42001 and the EU AI Act&lt;/a> — why the regulatory audit record cannot lean on a best-effort pipeline.&lt;/li>
&lt;li>&lt;a href="https://blog.lo0.es/en/posts/incident-response-runbooks-llm-inference-kafka-keep/">Incident response runbooks&lt;/a> — where the diagnostic headers and the readiness probe failure mode under sustained 429s fit.&lt;/li>
&lt;li>&lt;a href="https://blog.lo0.es/en/posts/anatomy-llm-request-production-tour-six-stages/">Anatomy of an LLM request in production&lt;/a> — the complete journey of a request through every layer, of which this post details two.&lt;/li>
&lt;li>&lt;a href="https://blog.lo0.es/en/posts/litellm-day-2-high-availability/">LiteLLM on day 2: high availability&lt;/a> — the process model, the probes, the retries and the 6,000-second timeout of the proxy that emits these traces.&lt;/li>
&lt;li>&lt;a href="https://blog.lo0.es/en/posts/litellm-virtual-keys-budgets-limits/">Virtual keys, budgets and limits&lt;/a> — who can call and with what cap, and the audit table that does work as evidence when the traces do not.&lt;/li>
&lt;li>&lt;a href="https://blog.lo0.es/en/posts/gateway-jwt-audience-trace-identity-seams/">The gateway does not live alone&lt;/a> — the gateway&amp;rsquo;s other four seams, including the identity that never reaches the trace.&lt;/li>
&lt;li>&lt;a href="https://blog.lo0.es/en/posts/langfuse-v4-what-goes-into-a-trace/">Langfuse v4: what goes into a trace&lt;/a> — the new data model at the receiving end, and the 16 November date with its small print.&lt;/li>
&lt;/ul>
&lt;h2 id="sources">Sources&lt;/h2>
&lt;ul>
&lt;li>LiteLLM, &lt;em>Langfuse integration&lt;/em> (rutas de integración, metadata aceptada, cabeceras &lt;code>langfuse_*&lt;/code>): &lt;a href="https://docs.litellm.ai/docs/observability/langfuse_integration">https://docs.litellm.ai/docs/observability/langfuse_integration&lt;/a>.&lt;/li>
&lt;li>LiteLLM, &lt;em>OpenTelemetry v2&lt;/em> y &lt;em>Migración a OTel v2&lt;/em> (preset, captura de contenido, cambios de semántica): &lt;a href="https://docs.litellm.ai/docs/observability/opentelemetry_v2">https://docs.litellm.ai/docs/observability/opentelemetry_v2&lt;/a> · &lt;a href="https://docs.litellm.ai/docs/observability/opentelemetry_v2_migration">https://docs.litellm.ai/docs/observability/opentelemetry_v2_migration&lt;/a>.&lt;/li>
&lt;li>LiteLLM, &lt;em>Logging&lt;/em> y &lt;em>Config settings&lt;/em> (redacción, &lt;code>turn_off_message_logging&lt;/code>, variables de entorno): &lt;a href="https://docs.litellm.ai/docs/proxy/logging">https://docs.litellm.ai/docs/proxy/logging&lt;/a> · &lt;a href="https://docs.litellm.ai/docs/proxy/config_settings">https://docs.litellm.ai/docs/proxy/config_settings&lt;/a>.&lt;/li>
&lt;li>LiteLLM, &lt;em>Custom pricing&lt;/em> y &lt;em>Spend tracking&lt;/em> (claves de precio, SpendLogs, endpoints de gasto): &lt;a href="https://docs.litellm.ai/docs/proxy/custom_pricing">https://docs.litellm.ai/docs/proxy/custom_pricing&lt;/a> · &lt;a href="https://docs.litellm.ai/docs/proxy/cost_tracking">https://docs.litellm.ai/docs/proxy/cost_tracking&lt;/a>.&lt;/li>
&lt;li>LiteLLM, &lt;em>Production best practices&lt;/em> (topología, &lt;code>request_timeout&lt;/code>, buffer de transacciones en Redis, &lt;code>LITELLM_JOB_ROLE&lt;/code>): &lt;a href="https://docs.litellm.ai/docs/proxy/prod">https://docs.litellm.ai/docs/proxy/prod&lt;/a>.&lt;/li>
&lt;li>LiteLLM, &lt;em>Benchmarks&lt;/em> y &lt;em>Latency overhead&lt;/em> (cifras de sobrecarga y cabeceras de diagnóstico): &lt;a href="https://docs.litellm.ai/docs/benchmarks">https://docs.litellm.ai/docs/benchmarks&lt;/a> · &lt;a href="https://docs.litellm.ai/docs/troubleshoot/latency_overhead">https://docs.litellm.ai/docs/troubleshoot/latency_overhead&lt;/a>.&lt;/li>
&lt;li>LiteLLM, &lt;em>Rust AI Gateway benchmarks&lt;/em> (la cifra de 257,7 ms de p99 del proxy Python): &lt;a href="https://docs.litellm.ai/blog/rust-ai-gateway-benchmarks">https://docs.litellm.ai/blog/rust-ai-gateway-benchmarks&lt;/a>.&lt;/li>
&lt;li>LiteLLM, notas de versión v1.95.0, v1.98.0 y v1.100.0: &lt;a href="https://docs.litellm.ai/release_notes/v1.95.0/v1-95-0">https://docs.litellm.ai/release_notes/v1.95.0/v1-95-0&lt;/a> · &lt;a href="https://docs.litellm.ai/release_notes/v1.98.0/v1-98-0">https://docs.litellm.ai/release_notes/v1.98.0/v1-98-0&lt;/a> · &lt;a href="https://docs.litellm.ai/release_notes/v1.100.0/v1-100-0">https://docs.litellm.ai/release_notes/v1.100.0/v1-100-0&lt;/a>.&lt;/li>
&lt;li>Langfuse, &lt;em>OpenTelemetry&lt;/em> (endpoint, autenticación, tabla de mapeo de atributos, ausencia de gRPC): &lt;a href="https://langfuse.com/integrations/native/opentelemetry">https://langfuse.com/integrations/native/opentelemetry&lt;/a>.&lt;/li>
&lt;li>Langfuse, &lt;em>Compatibility&lt;/em> y &lt;em>Migrate v3 to v4&lt;/em> (la fecha del 16 de noviembre de 2026 y los modos de escritura): &lt;a href="https://langfuse.com/docs/compatibility">https://langfuse.com/docs/compatibility&lt;/a> · &lt;a href="https://langfuse.com/self-hosting/upgrade/upgrade-guides/upgrade-v3-to-v4">https://langfuse.com/self-hosting/upgrade/upgrade-guides/upgrade-v3-to-v4&lt;/a>.&lt;/li>
&lt;li>Langfuse, &lt;em>Token and cost tracking&lt;/em> (prioridad del coste ingerido sobre el inferido): &lt;a href="https://langfuse.com/docs/observability/features/token-and-cost-tracking">https://langfuse.com/docs/observability/features/token-and-cost-tracking&lt;/a>.&lt;/li>
&lt;li>Langfuse, &lt;em>Scaling&lt;/em> y &lt;em>Sampling&lt;/em> (dimensionado, señal de autoescalado, muestreo de cabecera): &lt;a href="https://langfuse.com/self-hosting/configuration/scaling">https://langfuse.com/self-hosting/configuration/scaling&lt;/a> · &lt;a href="https://langfuse.com/docs/observability/features/sampling">https://langfuse.com/docs/observability/features/sampling&lt;/a>.&lt;/li>
&lt;li>Langfuse, issue #13468 — descarte de filas en &lt;code>ClickhouseWriter&lt;/code> sin cola de mensajes muertos: &lt;a href="https://github.com/langfuse/langfuse/issues/13468">https://github.com/langfuse/langfuse/issues/13468&lt;/a>. Issue #15109 — coste de sesión a cero: &lt;a href="https://github.com/langfuse/langfuse/issues/15109">https://github.com/langfuse/langfuse/issues/15109&lt;/a>. Issue #5173 — Nginx y cabeceras con guión bajo: &lt;a href="https://github.com/langfuse/langfuse/issues/5173">https://github.com/langfuse/langfuse/issues/5173&lt;/a>.&lt;/li>
&lt;li>vLLM, &lt;code>vllm/tracing/utils.py&lt;/code> y &lt;code>vllm/tracing/otel.py&lt;/code> en la etiqueta v0.28.0 (atributos emitidos, protocolo por defecto, &lt;code>insecure=True&lt;/code>): &lt;a href="https://github.com/vllm-project/vllm/blob/v0.28.0/vllm/tracing/utils.py">https://github.com/vllm-project/vllm/blob/v0.28.0/vllm/tracing/utils.py&lt;/a>.&lt;/li>
&lt;li>Langfuse, &lt;em>vLLM integration&lt;/em> (la advertencia de que vLLM solo exporta contadores y latencias): &lt;a href="https://langfuse.com/integrations/model-providers/vllm">https://langfuse.com/integrations/model-providers/vllm&lt;/a>.&lt;/li>
&lt;li>LiteLLM, &lt;code>litellm/constants.py&lt;/code> y &lt;code>litellm/litellm_core_utils/logging_worker.py&lt;/code> (capacidades de las colas y política de descarte): &lt;a href="https://github.com/BerriAI/litellm/blob/main/litellm/constants.py">https://github.com/BerriAI/litellm/blob/main/litellm/constants.py&lt;/a>.&lt;/li>
&lt;li>OpenTelemetry, &lt;em>Semantic Conventions for Generative AI&lt;/em> y el repositorio &lt;code>semantic-conventions-genai&lt;/code> (estado de desarrollo, renombrados, captura de contenido): &lt;a href="https://github.com/open-telemetry/semantic-conventions-genai">https://github.com/open-telemetry/semantic-conventions-genai&lt;/a>.&lt;/li>
&lt;li>OpenTelemetry Collector, &lt;code>tailsamplingprocessor&lt;/code> (políticas de muestreo por cola y la restricción de instancia única por traza): &lt;a href="https://github.com/open-telemetry/opentelemetry-collector-contrib/blob/main/processor/tailsamplingprocessor/README.md">https://github.com/open-telemetry/opentelemetry-collector-contrib/blob/main/processor/tailsamplingprocessor/README.md&lt;/a>.&lt;/li>
&lt;li>llm-d, &lt;em>End-to-end and fine-grained tracing in llm-d&lt;/em> (&lt;code>OTEL_SERVICE_NAME&lt;/code> y los spans huérfanos tras Envoy): &lt;a href="https://llm-d.ai/blog/end-to-end-and-fine-grained-tracing-in-llm-d">https://llm-d.ai/blog/end-to-end-and-fine-grained-tracing-in-llm-d&lt;/a>.&lt;/li>
&lt;/ul></description></item><item><title>Model chain of trust (1/4): KServe and the Open Inference Protocol, the control plane your serving is missing</title><link>https://blog.lo0.es/en/posts/kserve-open-inference-protocol-control-plane/</link><pubDate>Sun, 26 Jul 2026 20:00:00 +0200</pubDate><guid>https://blog.lo0.es/en/posts/kserve-open-inference-protocol-control-plane/</guid><description>&lt;p>This blog has devoted a good part of its 145 articles to what happens &lt;em>inside&lt;/em> the inference process and another good part to what happens around it. What is missing is a thread that cuts across all of that and answers a question that takes thirty seconds to ask in an audit and does not get answered in three weeks in production: when the endpoint returns a token, where did that model come from, who signed it, and who proves that the binary running on the GPU is the one that was approved?&lt;/p>
&lt;p>This series walks that chain backwards from the only visible point, the endpoint: the control plane and the API contract (this article), the registry and the distribution of the bytes (2/4), the signing and provenance that let you trust them (3/4), and the identity and isolation of whoever runs them (4/4). All of it with projects under the governance of CNCF, LF AI &amp;amp; Data or the Linux Foundation: a chain of trust that depends on a proprietary product is no better than a support contract.&lt;/p>
&lt;h2 id="tldr">TL;DR&lt;/h2>
&lt;ul>
&lt;li>A vLLM &lt;code>Deployment&lt;/code> serves tokens perfectly well. What it does not give you is a contract: no model versioning, no rollout by percentage, no scale-to-zero, and no common surface for the thirty non-LLM artifacts of an inference factory.&lt;/li>
&lt;li>&lt;strong>KServe&lt;/strong> provides the vocabulary: &lt;code>InferenceService&lt;/code>, &lt;code>ServingRuntime&lt;/code>/&lt;code>ClusterServingRuntime&lt;/code>, storage initializer and &lt;code>storageUri&lt;/code>. It has been CNCF incubating since 29 September 2025, after seven years as KFServing and a spell in LF AI &amp;amp; Data. The cadence has accelerated: v0.18.0 on 29 April 2026 and v0.19.0 on 14 June 2026.&lt;/li>
&lt;li>The LLM branch is recent and different: the &lt;code>LLMInferenceService&lt;/code> CRD (&lt;code>serving.kserve.io/v1alpha1&lt;/code>) arrived in v0.16 in November 2025, on top of llm-d, with &lt;code>spec.prefill&lt;/code>, &lt;code>spec.worker&lt;/code>, &lt;code>spec.parallelism&lt;/code> and &lt;code>spec.router&lt;/code>.&lt;/li>
&lt;li>The &lt;strong>Open Inference Protocol (V2)&lt;/strong> is the contract: &lt;code>/v2/health/*&lt;/code>, &lt;code>/v2/models/&amp;lt;m&amp;gt;&lt;/code>, &lt;code>/v2/models/&amp;lt;m&amp;gt;/infer&lt;/code>, in REST and gRPC, implemented by Triton, OpenVINO Model Server, Seldon MLServer and AMD Inference Server. It is tensor-centric, and the LLM world standardised de facto on the OpenAI API: KServe serves both, with the OpenAI ones under the &lt;code>openai/&lt;/code> prefix.&lt;/li>
&lt;li>The honest position: the portability of an LLM today comes from the OpenAI API, not from V2. Accepting the dual track is correct; pretending there is a single standard is not.&lt;/li>
&lt;li>KServe is overkill if you serve one model on one node. It starts to pay off from the fifth heterogeneous artifact onwards, and it competes on a different axis from the LLM operators: contract and lifecycle versus engine and topology.&lt;/li>
&lt;/ul>
&lt;h2 id="the-analogy-the-station-master-and-the-track-gauge">The analogy: the station master and the track gauge&lt;/h2>
&lt;p>An inference factory is like a railway station with several lines.&lt;/p>
&lt;p>The &lt;strong>station master&lt;/strong> does not drive any train. His job is the service timetable: which formation leaves from which platform, how many units are coupled each hour, when an engine is pulled out for maintenance and, when new rolling stock arrives, how to divert ten per cent of the passengers to the test train before committing the whole service. That is the control plane. KServe does not generate a single token, but it decides what runs, with what configuration, with how many replicas and with what traffic split. The trains are the engines (vLLM, SGLang, TensorRT-LLM, Triton, TEI), and a competent station master does not marry a manufacturer.&lt;/p>
&lt;p>And then there is the &lt;strong>track gauge&lt;/strong>: as long as everybody builds to the same one, any locomotive rolls on any line and changing supplier is a commercial decision, not a civil engineering project. That gauge is the Open Inference Protocol, the contract that makes a client written against Triton work against OpenVINO Model Server without touching a line.&lt;/p>
&lt;p>The analogy has a twist that needs no explanation in Spain: the agreed gauge is not the gauge the new rolling stock runs on. The conventional network was built to Iberian gauge and the high-speed one to standard gauge, and the practical solution was not to convert the network but to install gauge changers at the boundary points. The same thing has happened in inference: V2 is the agreed gauge and the OpenAI API is the gauge everything generative runs on. What sustains interoperability is the changer, not a single standard. We will come back to it.&lt;/p>
&lt;h2 id="a-deployment-serves-tokens-but-offers-no-contract">A &lt;code>Deployment&lt;/code> serves tokens, but offers no contract&lt;/h2>
&lt;p>In &lt;a href="https://blog.lo0.es/en/posts/vllm-kubernetes-llm-inference-that-scales/">vLLM on Kubernetes&lt;/a> we built a service with &lt;code>Deployment&lt;/code>, &lt;code>Service&lt;/code>, &lt;code>HPA&lt;/code> and an &lt;code>initContainer&lt;/code> that pulls the weights down. It works, and for one model on one node it is the right answer. What it does not give you, and cannot give you without reinventing it every time:&lt;/p>
&lt;p>&lt;strong>A stable API contract.&lt;/strong> The endpoint is &amp;ldquo;whatever the image you put there exposes&amp;rdquo;: moving from vLLM to TensorRT-LLM changes routes, error format and metric names, and the clients find out in production.&lt;/p>
&lt;p>&lt;strong>Versioning with semantics.&lt;/strong> The model is a path inside a volume; knowing which version a pod is serving requires &lt;code>kubectl exec&lt;/code>. There is no declarative field that says &amp;ldquo;this service serves artifact X&amp;rdquo; and can be audited from outside.&lt;/p>
&lt;p>&lt;strong>Progressive model rollout.&lt;/strong> A &lt;code>RollingUpdate&lt;/code> replaces pods; it does not split traffic between two versions by percentage. For that you need an L7 gateway in front, like the one in &lt;a href="https://blog.lo0.es/en/posts/canary-blue-green-shadow-llm-models/">canary, blue-green and shadow&lt;/a>, or a control plane that emits it.&lt;/p>
&lt;p>&lt;strong>Scale-to-zero.&lt;/strong> The &lt;code>HPA&lt;/code> does not go below one replica: for a model queried twelve times a day, that is an 80 GB GPU tied up twenty-four hours.&lt;/p>
&lt;p>&lt;strong>And, above all, a common surface for what is not an LLM.&lt;/strong> A real factory does not serve three LLMs: it serves two or three LLMs, four embedding models, a couple of rerankers, ticket classifiers, a PII detector, an OCR, industrial vision and three scikit-learn artifacts that nobody remembers training. Fifteen or thirty artifacts, each with its own runtime and its own way of saying &amp;ldquo;I am ready&amp;rdquo;. The cost is not in serving the first one: it is in making sure the thirtieth does not cost thirty times what the first one did.&lt;/p>
&lt;h2 id="kserve-from-kfserving-to-a-cncf-incubating-project">KServe: from KFServing to a CNCF incubating project&lt;/h2>
&lt;p>KServe was born in 2019 as &lt;strong>KFServing&lt;/strong>, inside Kubeflow, in a collaboration between Google, IBM, Bloomberg, NVIDIA and Seldon. In February 2022 it was donated to the LF AI &amp;amp; Data Foundation; in September 2022 it split from Kubeflow and was renamed. The relevant jump: on 29 September 2025 the CNCF accepted it directly at incubating level, without going through sandbox, with a public announcement on 11 November. The figures it declared then, more than 300 contributors, 19 maintainers, more than 30 adopting organisations (Bloomberg, Red Hat, Cloudera, Nutanix, SAP, NVIDIA), come from an interested party, but the list is verifiable and the 625 contributing organisations on the CNCF dashboard support the thesis of governance not captured by a vendor.&lt;/p>
&lt;p>On cadence you have to be precise, because the cliché of &amp;ldquo;KServe is slow&amp;rdquo; no longer describes reality:&lt;/p>
&lt;table>
&lt;thead>
&lt;tr>
&lt;th>Version&lt;/th>
&lt;th>Date&lt;/th>
&lt;th>What it brought&lt;/th>
&lt;/tr>
&lt;/thead>
&lt;tbody>
&lt;tr>
&lt;td>v0.15.0&lt;/td>
&lt;td>31 March 2025&lt;/td>
&lt;td>&lt;code>LocalModelCache&lt;/code>, multi-node inference, OpenAI-compatible embeddings API, Gateway API in raw mode, KEDA integration&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>v0.16.0&lt;/td>
&lt;td>November 2025&lt;/td>
&lt;td>&lt;strong>&lt;code>LLMInferenceService&lt;/code>&lt;/strong>; rename of &lt;code>RawDeployment&lt;/code> to &lt;code>Standard&lt;/code> and of &lt;code>Serverless&lt;/code> to &lt;code>KNative&lt;/code>; progressive rollout in raw mode&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>v0.18.0&lt;/td>
&lt;td>29 April 2026&lt;/td>
&lt;td>WVA + KEDA/HPA autoscaling for LLMISvc, &lt;code>/v1/responses&lt;/code> route (OpenAI Responses API), &lt;code>restricted&lt;/code> Pod Security Standards by default on LLMISvc, namespace-scoped &lt;code>ModelCache&lt;/code>&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>v0.19.0&lt;/td>
&lt;td>14 June 2026&lt;/td>
&lt;td>Routing by model name, static LoRA adapters, balancing across heterogeneous GPUs, dual REST/gRPC routing in Standard mode, automatic migration to &lt;code>InferencePool&lt;/code> v1&lt;/td>
&lt;/tr>
&lt;/tbody>
&lt;/table>
&lt;p>Two major releases in under two months, with the bulk of the changelog in the LLM branch. The project is not slow: it has pivoted from classic predictive serving to generative serving. The consequences run in both directions.&lt;/p>
&lt;p>The negative one is called &lt;strong>ModelMesh&lt;/strong>. It was the high-density mode (hundreds of small models sharing processes, with dynamic load and unload) and it was, on paper, the natural answer to the problem of thirty heterogeneous artifacts. Its last release was v0.12.0 on 9 July 2023, and the &lt;code>kserve/modelmesh-serving&lt;/code> repository was archived read-only on 14 April 2026. Red Hat published specific guidance for migrating to Standard mode. If somebody proposes ModelMesh in a design today, it is a sign that the documentation they read is three years old.&lt;/p>
&lt;h2 id="anatomy-of-the-control-plane">Anatomy of the control plane&lt;/h2>
&lt;h3 id="inferenceservice-predictor-transformer-explainer">&lt;code>InferenceService&lt;/code>: predictor, transformer, explainer&lt;/h3>
&lt;p>The core CRD is &lt;code>InferenceService&lt;/code> (&lt;code>serving.kserve.io/v1beta1&lt;/code>), and its contribution, the one that became the common vocabulary of the field, is to break an inference service down into three components each with its own contract:&lt;/p>
&lt;ul>
&lt;li>&lt;strong>&lt;code>predictor&lt;/code>&lt;/strong>: mandatory. It declares &lt;code>modelFormat&lt;/code>, optionally &lt;code>runtime&lt;/code>, the &lt;code>storageUri&lt;/code> the weights come from and the &lt;code>protocolVersion&lt;/code> it exposes.&lt;/li>
&lt;li>&lt;strong>&lt;code>transformer&lt;/code>&lt;/strong>: optional. Pre- and post-processing. That it is a separate component, rather than code inside the server, is what allows it to be scaled separately (it is usually CPU-bound while the predictor is GPU-bound) and versioned separately.&lt;/li>
&lt;li>&lt;strong>&lt;code>explainer&lt;/code>&lt;/strong>: optional, and the one that has aged worst: the V2 protocol does not support the &lt;code>explain&lt;/code> endpoint, which only exists in V1. Anyone who needs explainability with a standard contract has a real gap here.&lt;/li>
&lt;/ul>
&lt;p>All three share &lt;code>ComponentExtensionSpec&lt;/code>: &lt;code>minReplicas&lt;/code>, &lt;code>maxReplicas&lt;/code>, &lt;code>scaleTarget&lt;/code>, &lt;code>scaleMetric&lt;/code>, &lt;code>containerConcurrency&lt;/code>, &lt;code>timeout&lt;/code> and &lt;code>canaryTrafficPercent&lt;/code>.&lt;/p>
&lt;h3 id="servingruntime-and-clusterservingruntime-the-catalogue-of-trains">&lt;code>ServingRuntime&lt;/code> and &lt;code>ClusterServingRuntime&lt;/code>: the catalogue of trains&lt;/h3>
&lt;p>A &lt;code>ServingRuntime&lt;/code> is a pod template that knows how to serve one or several formats. It declares &lt;code>supportedModelFormats&lt;/code> (with &lt;code>name&lt;/code>, &lt;code>version&lt;/code>, &lt;code>autoSelect&lt;/code> and &lt;code>priority&lt;/code>), &lt;code>protocolVersions&lt;/code> (&lt;code>v1&lt;/code>, &lt;code>v2&lt;/code>) and &lt;code>containers&lt;/code>, with &lt;code>args&lt;/code> parameterised by templates in the &lt;code>{{.Name}}&lt;/code> style that are substituted with metadata from the &lt;code>InferenceService&lt;/code>.&lt;/p>
&lt;p>Selection is what makes this scale. If the &lt;code>InferenceService&lt;/code> names a &lt;code>runtime&lt;/code>, the controller looks for it first in the namespace and then at cluster level; if it does not name one, it picks among those with &lt;code>autoSelect: true&lt;/code> for that &lt;code>modelFormat&lt;/code>, breaking ties by &lt;code>priority&lt;/code>. That is what lets a platform publish a curated catalogue (&amp;ldquo;here, &lt;code>huggingface&lt;/code> is served with this vLLM and these flags&amp;rdquo;) without the product teams having to know what &lt;code>--gpu-memory-utilization&lt;/code> is. In multi-tenant setups, the &lt;code>ClusterServingRuntime&lt;/code>s belong to the platform and the &lt;code>InferenceService&lt;/code>s to the teams: the cleanest separation-of-duties line KServe offers.&lt;/p>
&lt;h3 id="the-storage-initializer-and-storageuri">The storage initializer and &lt;code>storageUri&lt;/code>&lt;/h3>
&lt;p>The &lt;code>storageUri&lt;/code> field triggers the &lt;strong>storage initializer&lt;/strong>: an init container that downloads the artifact to &lt;code>/mnt/models&lt;/code> before the server starts. Schemes supported as of July 2026: &lt;code>s3://&lt;/code>, &lt;code>gs://&lt;/code>, &lt;code>https://&lt;/code>, &lt;code>pvc://&lt;/code>, &lt;code>hf://&lt;/code>, &lt;code>oci://&lt;/code> and git repositories; credentials are attached via a &lt;code>ServiceAccount&lt;/code> with annotated secrets. That &lt;code>oci://&lt;/code> connects with the second article of the series: the weights can live in the same registry as the images, with the same access control and the same signing capability.&lt;/p>
&lt;h3 id="the-deployment-modes">The deployment modes&lt;/h3>
&lt;p>This is where nearly everything operational is decided. It is selected with the &lt;code>serving.kserve.io/deploymentMode&lt;/code> annotation.&lt;/p>
&lt;table>
&lt;thead>
&lt;tr>
&lt;th>&lt;/th>
&lt;th>&lt;strong>KNative&lt;/strong> (formerly Serverless)&lt;/th>
&lt;th>&lt;strong>Standard&lt;/strong> (formerly RawDeployment)&lt;/th>
&lt;th>&lt;strong>ModelMesh&lt;/strong>&lt;/th>
&lt;/tr>
&lt;/thead>
&lt;tbody>
&lt;tr>
&lt;td>Resources emitted&lt;/td>
&lt;td>Knative &lt;code>Service&lt;/code> + &lt;code>Revision&lt;/code>, &lt;code>VirtualService&lt;/code> with Istio&lt;/td>
&lt;td>&lt;code>Deployment&lt;/code>, &lt;code>Service&lt;/code>, &lt;code>HPA&lt;/code>, Gateway API &lt;code>HTTPRoute&lt;/code>&lt;/td>
&lt;td>Its own high-density runtime&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>Dependencies&lt;/td>
&lt;td>Knative Serving + Istio (or an alternative gateway)&lt;/td>
&lt;td>Kubernetes 1.32+, cert-manager 1.15.0+, Gateway API v1.2.1 and a controller&lt;/td>
&lt;td>Its own components&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>Autoscaling&lt;/td>
&lt;td>KPA (concurrency / RPS)&lt;/td>
&lt;td>HPA or &lt;strong>KEDA&lt;/strong>&lt;/td>
&lt;td>—&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>Scale-to-zero&lt;/td>
&lt;td>Yes, with an activator that holds the request&lt;/td>
&lt;td>&lt;strong>No for HTTP requests&lt;/strong>&lt;/td>
&lt;td>—&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>&lt;code>canaryTrafficPercent&lt;/code>&lt;/td>
&lt;td>Yes, by revision weights in Istio&lt;/td>
&lt;td>&lt;strong>Silently ignored&lt;/strong> (issue #5335, open since 2 April 2026)&lt;/td>
&lt;td>—&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>Sidecars in the pod&lt;/td>
&lt;td>&lt;code>queue-proxy&lt;/code> (+ &lt;code>istio-proxy&lt;/code>)&lt;/td>
&lt;td>None&lt;/td>
&lt;td>—&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>Status as of July 2026&lt;/td>
&lt;td>Current&lt;/td>
&lt;td>Current and &lt;strong>the default mode of the LLM branch&lt;/strong>&lt;/td>
&lt;td>&lt;strong>Archived on 14 April 2026&lt;/strong>&lt;/td>
&lt;/tr>
&lt;/tbody>
&lt;/table>
&lt;p>That table has an uncomfortable reading: the two most quoted KServe capabilities, scale-to-zero and canary by percentage, live in the mode that drags Knative and Istio along, while the mode the project has pivoted towards is the other one.&lt;/p>
&lt;div class="diagram" style="max-width:860px;margin:1.5rem auto;">
&lt;svg viewBox="0 0 860 386" role="img" aria-label="A KServe InferenceService materialises into different resources depending on the deployment mode: KNative with scale-to-zero and canary, or Standard with Deployment, HPA or KEDA and Gateway API" xmlns="http://www.w3.org/2000/svg">
&lt;style>.bx{fill:none;stroke:currentColor;stroke-width:1.4}.dsh{fill:none;stroke:currentColor;stroke-width:1.4;stroke-dasharray:5 3}.tl{font:600 12px sans-serif;fill:currentColor}.ts{font:11px sans-serif;fill:currentColor}.ar{fill:none;stroke:currentColor;stroke-width:1.4;marker-end:url(#kv)}&lt;/style>
&lt;defs>&lt;marker id="kv" viewBox="0 0 10 10" refX="9" refY="5" markerWidth="7" markerHeight="7" orient="auto">&lt;path d="M0,0 L10,5 L0,10 z" fill="currentColor"/>&lt;/marker>&lt;/defs>
&lt;text x="430" y="22" text-anchor="middle" font-size="13" font-weight="700" fill="currentColor">What you declare versus what the controller emits&lt;/text>
&lt;rect class="bx" x="20" y="46" width="190" height="120" rx="6"/>
&lt;text x="115" y="68" text-anchor="middle" class="tl">InferenceService&lt;/text>
&lt;text x="115" y="88" text-anchor="middle" class="ts">predictor + transformer&lt;/text>
&lt;text x="115" y="105" text-anchor="middle" class="ts">storageUri + runtime&lt;/text>
&lt;text x="115" y="122" text-anchor="middle" class="ts">min/maxReplicas&lt;/text>
&lt;text x="115" y="139" text-anchor="middle" class="ts">canaryTrafficPercent&lt;/text>
&lt;text x="115" y="158" text-anchor="middle" class="ts">protocolVersion: v2&lt;/text>
&lt;rect class="dsh" x="20" y="186" width="190" height="72" rx="6"/>
&lt;text x="115" y="208" text-anchor="middle" class="tl">ClusterServingRuntime&lt;/text>
&lt;text x="115" y="228" text-anchor="middle" class="ts">image, args, probes&lt;/text>
&lt;text x="115" y="245" text-anchor="middle" class="ts">supportedModelFormats&lt;/text>
&lt;path class="ar" d="M210,150 L262,150"/>
&lt;path class="ar" d="M210,222 L262,182"/>
&lt;rect class="bx" x="262" y="120" width="150" height="90" rx="6"/>
&lt;text x="337" y="146" text-anchor="middle" class="tl">Controller&lt;/text>
&lt;text x="337" y="166" text-anchor="middle" class="ts">reconciles and decides&lt;/text>
&lt;text x="337" y="186" text-anchor="middle" class="ts">based on deploymentMode&lt;/text>
&lt;path class="ar" d="M412,150 L466,110"/>
&lt;path class="ar" d="M412,178 L466,246"/>
&lt;rect class="bx" x="466" y="52" width="374" height="112" rx="6"/>
&lt;text x="653" y="74" text-anchor="middle" class="tl">KNative mode&lt;/text>
&lt;text x="653" y="94" text-anchor="middle" class="ts">Knative Service + Revision, VirtualService (Istio)&lt;/text>
&lt;text x="653" y="112" text-anchor="middle" class="ts">KPA, activator, real scale-to-zero&lt;/text>
&lt;text x="653" y="130" text-anchor="middle" class="ts">canaryTrafficPercent by revision weights&lt;/text>
&lt;text x="653" y="152" text-anchor="middle" class="ts">cost: queue-proxy and istio-proxy in every pod&lt;/text>
&lt;rect class="bx" x="466" y="194" width="374" height="112" rx="6"/>
&lt;text x="653" y="216" text-anchor="middle" class="tl">Standard mode&lt;/text>
&lt;text x="653" y="236" text-anchor="middle" class="ts">Deployment + Service + HPA or KEDA&lt;/text>
&lt;text x="653" y="254" text-anchor="middle" class="ts">Gateway API v1.2.1 HTTPRoute&lt;/text>
&lt;text x="653" y="272" text-anchor="middle" class="ts">no scale-from-zero over HTTP&lt;/text>
&lt;text x="653" y="294" text-anchor="middle" class="ts">canaryTrafficPercent silently ignored&lt;/text>
&lt;text x="20" y="340" class="ts">The storage initializer downloads the artifact from storageUri to /mnt/models before starting the server.&lt;/text>
&lt;text x="20" y="360" class="ts">One platform ClusterServingRuntime serves N team InferenceServices: that is where the separation of duties lives.&lt;/text>
&lt;text x="20" y="380" class="ts">The API contract clients see does not change between modes: that is exactly what the control plane provides.&lt;/text>
&lt;/svg>
&lt;/div>
&lt;h2 id="the-llm-branch-llminferenceservice">The LLM branch: &lt;code>LLMInferenceService&lt;/code>&lt;/h2>
&lt;p>Since v0.16 KServe has a separate CRD for LLMs: &lt;code>LLMInferenceService&lt;/code>, in &lt;code>serving.kserve.io/v1alpha1&lt;/code>. It is not an extension of the classic &lt;code>InferenceService&lt;/code> and does not share its API version, and confusing the two is the first mistake made by anyone arriving from old documentation.&lt;/p>
&lt;p>It is built on &lt;strong>llm-d&lt;/strong>, the project that Red Hat, Google, IBM, CoreWeave and NVIDIA donated to the CNCF. The division of roles the project itself declares is clean: KServe is the control plane (lifecycle, scaling and operational governance) and llm-d contributes distributed scheduling, with GPU utilisation, queue depth, cache residency and SLA. In the analogy: llm-d decides which line each train takes; KServe decides which trains exist and when they are withdrawn.&lt;/p>
&lt;p>The fields that matter: &lt;code>spec.model&lt;/code> (URI, invocation name, scheduling criticality and LoRA adapters, with static adapter reconciliation from v0.19, which connects with &lt;a href="https://blog.lo0.es/en/posts/multi-lora-serving-shared-base-concurrent-adapters-sgmv-kernel/">multi-LoRA serving&lt;/a>); &lt;code>spec.template&lt;/code> (single node, or the decode pool); &lt;code>spec.worker&lt;/code> (multi-node, which triggers LeaderWorkerSet); &lt;code>spec.prefill&lt;/code> (the disaggregation we analysed in &lt;a href="https://blog.lo0.es/en/posts/disaggregated-serving-prefill-decode-specialised-pods/">disaggregated serving&lt;/a> turned into a declarative field); &lt;code>spec.parallelism&lt;/code> (tensor, data and expert parallelism); and &lt;code>spec.router&lt;/code> (&lt;code>gateway&lt;/code>, &lt;code>route&lt;/code>, &lt;code>scheduler&lt;/code>), where KServe leans on the &lt;strong>Gateway API Inference Extension&lt;/strong>, v1.5.0 of 19 April 2026, already GA, with automatic migration to &lt;code>InferencePool&lt;/code> v1 from v0.19.&lt;/p>
&lt;p>The endpoint it exposes is OpenAI-compatible: &lt;code>/v1/chat/completions&lt;/code> with streaming, and since v0.18 also &lt;code>/v1/responses&lt;/code>. That detail underpins the next section: the LLM branch of KServe does not speak V2, it speaks OpenAI.&lt;/p>
&lt;p>For multi-node there is the &lt;code>kserve-huggingfaceserver-multinode&lt;/code> runtime, based on vLLM over Ray, with &lt;code>workerSpec.tensorParallelSize&lt;/code> and &lt;code>workerSpec.pipelineParallelSize&lt;/code>. Its restrictions are hard: it requires a ReadWriteMany PVC, it only works in Standard mode, it does not support autoscaling and it requires exactly one head pod. A reference 4×H100 SXM node with NVLink needs none of this, because a 70B fits with tensor parallel 4 on a single node; you need it from there upwards.&lt;/p>
&lt;h2 id="the-open-inference-protocol-the-contract-itself">The Open Inference Protocol: the contract itself&lt;/h2>
&lt;h3 id="the-routes-and-the-payload">The routes and the payload&lt;/h3>
&lt;p>The &lt;strong>Open Inference Protocol&lt;/strong> (historically the &amp;ldquo;V2 Inference Protocol&amp;rdquo;) lives in &lt;code>kserve/open-inference-protocol&lt;/code>, with REST and gRPC specifications and versioning under SemVer 2.0. Its HTTP surface is deliberately small:&lt;/p>
&lt;table>
&lt;thead>
&lt;tr>
&lt;th>Resource&lt;/th>
&lt;th>Verb&lt;/th>
&lt;th>Route&lt;/th>
&lt;/tr>
&lt;/thead>
&lt;tbody>
&lt;tr>
&lt;td>Server metadata&lt;/td>
&lt;td>GET&lt;/td>
&lt;td>&lt;code>/v2&lt;/code>&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>Liveness&lt;/td>
&lt;td>GET&lt;/td>
&lt;td>&lt;code>/v2/health/live&lt;/code>&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>Server readiness&lt;/td>
&lt;td>GET&lt;/td>
&lt;td>&lt;code>/v2/health/ready&lt;/code>&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>Model metadata&lt;/td>
&lt;td>GET&lt;/td>
&lt;td>&lt;code>/v2/models/&amp;lt;model&amp;gt;[/versions/&amp;lt;v&amp;gt;]&lt;/code>&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>Model readiness&lt;/td>
&lt;td>GET&lt;/td>
&lt;td>&lt;code>/v2/models/&amp;lt;model&amp;gt;[/versions/&amp;lt;v&amp;gt;]/ready&lt;/code>&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>Inference&lt;/td>
&lt;td>POST&lt;/td>
&lt;td>&lt;code>/v2/models/&amp;lt;model&amp;gt;[/versions/&amp;lt;v&amp;gt;]/infer&lt;/code>&lt;/td>
&lt;/tr>
&lt;/tbody>
&lt;/table>
&lt;p>The payload is tensorial: &lt;code>id&lt;/code>, a list of &lt;code>inputs&lt;/code> (each with &lt;code>name&lt;/code>, &lt;code>shape&lt;/code>, &lt;code>datatype&lt;/code> and &lt;code>data&lt;/code>) and optionally the desired &lt;code>outputs&lt;/code>. The types are &lt;code>BOOL&lt;/code>, signed and unsigned integers from 8 to 64 bits, 16-, 32- and 64-bit floating point, and &lt;code>BYTES&lt;/code>. In gRPC the same contract is &lt;code>GRPCInferenceService&lt;/code>, with six RPCs: &lt;code>ServerLive&lt;/code>, &lt;code>ServerReady&lt;/code>, &lt;code>ModelReady&lt;/code>, &lt;code>ServerMetadata&lt;/code>, &lt;code>ModelMetadata&lt;/code> and &lt;code>ModelInfer&lt;/code>. For high-volume embeddings, the serialisation difference is not cosmetic.&lt;/p>
&lt;h3 id="who-actually-implements-it">Who actually implements it&lt;/h3>
&lt;p>The specification repository declares KServe, NVIDIA Triton, Seldon MLServer and Core v2, OpenVINO Model Server, AMD Inference Server and TorchServe as adopters. Not all entries are worth the same as of July 2026:&lt;/p>
&lt;ul>
&lt;li>&lt;strong>Triton&lt;/strong> implements it in a documented way (&amp;ldquo;Triton exposes HTTP/REST and gRPC endpoints based on the standard inference protocols proposed by the KServe project&amp;rdquo;), plus its own extensions. It is the de facto reference implementation.&lt;/li>
&lt;li>&lt;strong>OpenVINO Model Server&lt;/strong> exposes the KServe API over gRPC and REST, the TensorFlow Serving one and OpenAI-compatible endpoints: the clearest example of a server that speaks both track gauges.&lt;/li>
&lt;li>&lt;strong>TorchServe&lt;/strong> forces you to be honest. Its official documentation carries the warning: &lt;em>&amp;ldquo;This project is no longer actively maintained. While existing releases remain available, there are no planned updates, bug fixes, new features, or security patches.&amp;rdquo;&lt;/em> An adopter on zero maintenance is not an adopter: it is a row nobody has updated.&lt;/li>
&lt;/ul>
&lt;p>On the health of the standard, the most eloquent data point is not in any press release: the specification repository does not have a single published release, has accumulated something like thirty commits and seventy-five stars, and its governance consists of monthly meetings and a Slack channel. There is also a &amp;ldquo;vendor neutral&amp;rdquo; &lt;em>fork&lt;/em> at &lt;code>open-inference/open-inference-protocol&lt;/code> with more commits than the original and no traction whatsoever. V2 is a stable contract, widely implemented and barely evolving. For a serialisation format that can be a virtue; for a standard aspiring to cover the generative world, it is a death sentence.&lt;/p>
&lt;h3 id="the-generate-extension-and-why-it-did-not-win">The &lt;code>generate&lt;/code> extension and why it did not win&lt;/h3>
&lt;p>The standard did try to cover generation: the specification contains a &lt;code>generate_rest.yaml&lt;/code> with two routes, &lt;code>/v2/models/&amp;lt;m&amp;gt;/versions/&amp;lt;v&amp;gt;/generate&lt;/code> and &lt;code>.../generate_stream&lt;/code>, and a minimalist schema: a request with &lt;code>text_input&lt;/code> and &lt;code>parameters&lt;/code> (&lt;code>temperature&lt;/code>, &lt;code>top_p&lt;/code>, &lt;code>max_tokens&lt;/code> defaulting to 20, &lt;code>stop&lt;/code>, &lt;code>details&lt;/code>); a response with &lt;code>text_output&lt;/code>, &lt;code>model_name&lt;/code>, &lt;code>model_version&lt;/code> and, if requested, &lt;code>finish_reason&lt;/code> and &lt;code>logprobs&lt;/code>; streaming over &lt;em>server-sent events&lt;/em>. It is clean, it is enough for a simple completion and nobody uses it: the KServe generative inference documentation, as of July 2026, lists four endpoints, and none of them is &lt;code>generate&lt;/code>:&lt;/p>
&lt;table>
&lt;thead>
&lt;tr>
&lt;th>Task&lt;/th>
&lt;th>Endpoint in KServe&lt;/th>
&lt;/tr>
&lt;/thead>
&lt;tbody>
&lt;tr>
&lt;td>Text generation&lt;/td>
&lt;td>&lt;code>openai/v1/completions&lt;/code>&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>Chat&lt;/td>
&lt;td>&lt;code>openai/v1/chat/completions&lt;/code>&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>Embeddings&lt;/td>
&lt;td>&lt;code>openai/v1/embeddings&lt;/code>&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>Reranking&lt;/td>
&lt;td>&lt;code>openai/v1/rerank&lt;/code>&lt;/td>
&lt;/tr>
&lt;/tbody>
&lt;/table>
&lt;p>The &lt;code>openai/&lt;/code> prefix exists, according to the documentation itself, &amp;ldquo;to avoid confusion with the V1 API protocol&amp;rdquo;, and it is changed with &lt;code>KSERVE_OPENAI_ROUTE_PREFIX&lt;/code>. It is plumbing that says a lot: the in-house standard was relegated to a defensive prefix while the outside one takes all the traffic.&lt;/p>
&lt;h3 id="the-tension-unvarnished">The tension, unvarnished&lt;/h3>
&lt;p>V2 solved the problem of 2021, and it solved it well. A tensorial, typed contract with metadata and health checks, in REST and gRPC, is what you need to serve a classifier, a vision model, a ranker or an embedding, and it is still the right answer for that half of the catalogue: swapping an embedding server from MLServer to OpenVINO Model Server without touching clients is the track gauge doing its job.&lt;/p>
&lt;p>The LLM world standardised somewhere else, not by committee, but because the entire client ecosystem (SDKs, agent frameworks, gateways, evaluation) was written against the OpenAI API, and any engine that wanted to be used had to speak it. What that means for an architecture:&lt;/p>
&lt;ol>
&lt;li>&lt;strong>There is not one contract: there are two, and both are legitimate.&lt;/strong> Designing as if there were one does violence to half the catalogue.&lt;/li>
&lt;li>&lt;strong>LLM portability is superficial.&lt;/strong> It covers chat and completion well; as soon as you use detailed logprobs, guided decoding, priority headers, prefix caching parameters or &lt;em>tool calling&lt;/em> with specific schemas, you leave the common subset and get tied back to the engine. It is the warning from &lt;a href="https://blog.lo0.es/en/posts/structured-output-bitmask-outlines-xgrammar-llguidance/">structured output&lt;/a>: what is portable is the envelope, not always the contents.&lt;/li>
&lt;li>&lt;strong>The gauge changer is the gateway.&lt;/strong> What makes the set switchable is the translation and routing layer of &lt;a href="https://blog.lo0.es/en/posts/choosing-oss-gateway-llm-inference/">choosing an OSS LLM inference gateway&lt;/a> and &lt;a href="https://blog.lo0.es/en/posts/llm-inference-router-l7-switchboard/">the router as an L7 gateway&lt;/a>, not a single standard.&lt;/li>
&lt;li>&lt;strong>Crossing over loses the versioning.&lt;/strong> V2 has &lt;code>/versions/&amp;lt;v&amp;gt;&lt;/code> in the route; the OpenAI API only has the &lt;code>model&lt;/code> field. The version has to be encoded in the name (&lt;code>asistente-70b-2026-07&lt;/code>) or managed in the control plane. That is the difference between being able to audit which version answered and not being able to.&lt;/li>
&lt;/ol>
&lt;h2 id="four-manifests-you-can-actually-apply">Four manifests you can actually apply&lt;/h2>
&lt;p>Platform runtime for the blog&amp;rsquo;s reference node, 4×H100 SXM 80 GB with NVLink, with tensor parallel 4:&lt;/p>
&lt;div class="highlight">&lt;pre tabindex="0" class="chroma">&lt;code class="language-yaml" data-lang="yaml">&lt;span class="line">&lt;span class="cl">&lt;span class="nt">apiVersion&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">serving.kserve.io/v1alpha1&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w">&lt;/span>&lt;span class="nt">kind&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">ClusterServingRuntime&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w">&lt;/span>&lt;span class="nt">metadata&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">name&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">vllm-h100-tp4&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w">&lt;/span>&lt;span class="nt">spec&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">annotations&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">prometheus.kserve.io/port&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="s2">&amp;#34;8080&amp;#34;&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">prometheus.kserve.io/path&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="s2">&amp;#34;/metrics&amp;#34;&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">supportedModelFormats&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>- &lt;span class="nt">name&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">huggingface&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">version&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="s2">&amp;#34;1&amp;#34;&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">autoSelect&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="kc">true&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">priority&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="m">10&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">protocolVersions&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>- &lt;span class="l">v2&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">containers&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>- &lt;span class="nt">name&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">kserve-container&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="c"># ALWAYS pin by digest in production; the tag is only for readability&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">image&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">vllm/vllm-openai:v0.11.0&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">command&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="p">[&lt;/span>&lt;span class="s2">&amp;#34;python3&amp;#34;&lt;/span>&lt;span class="p">,&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="s2">&amp;#34;-m&amp;#34;&lt;/span>&lt;span class="p">,&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="s2">&amp;#34;vllm.entrypoints.openai.api_server&amp;#34;&lt;/span>&lt;span class="p">]&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">args&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>- --&lt;span class="l">port=8080&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>- --&lt;span class="l">model=/mnt/models&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>- --&lt;span class="l">served-model-name={{.Name}}&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>- --&lt;span class="l">tensor-parallel-size=4&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>- --&lt;span class="l">max-model-len=32768&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>- --&lt;span class="l">gpu-memory-utilization=0.92&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>- --&lt;span class="l">enable-prefix-caching&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>- --&lt;span class="l">kv-cache-dtype=fp8&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">resources&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">requests&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>{&lt;span class="w"> &lt;/span>&lt;span class="nt">cpu&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="s2">&amp;#34;16&amp;#34;&lt;/span>&lt;span class="nt">, memory: 128Gi, nvidia.com/gpu&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="s2">&amp;#34;4&amp;#34;&lt;/span>&lt;span class="w"> &lt;/span>}&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">limits&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>{&lt;span class="w"> &lt;/span>&lt;span class="nt">cpu&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="s2">&amp;#34;16&amp;#34;&lt;/span>&lt;span class="nt">, memory: 128Gi, nvidia.com/gpu&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="s2">&amp;#34;4&amp;#34;&lt;/span>&lt;span class="w"> &lt;/span>}&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="c"># the probe that stops the pod dying while the model loads&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">startupProbe&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">httpGet&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>{&lt;span class="w"> &lt;/span>&lt;span class="nt">path: /health, port&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="m">8080&lt;/span>&lt;span class="w"> &lt;/span>}&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">periodSeconds&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="m">15&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">failureThreshold&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="m">40&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="c"># up to 10 minutes of loading&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">readinessProbe&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">httpGet&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>{&lt;span class="w"> &lt;/span>&lt;span class="nt">path: /health, port&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="m">8080&lt;/span>&lt;span class="w"> &lt;/span>}&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">periodSeconds&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="m">10&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">livenessProbe&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">httpGet&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>{&lt;span class="w"> &lt;/span>&lt;span class="nt">path: /health, port&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="m">8080&lt;/span>&lt;span class="w"> &lt;/span>}&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">periodSeconds&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="m">30&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>The &lt;code>InferenceService&lt;/code> that consumes it, with KEDA on vLLM metrics:&lt;/p>
&lt;div class="highlight">&lt;pre tabindex="0" class="chroma">&lt;code class="language-yaml" data-lang="yaml">&lt;span class="line">&lt;span class="cl">&lt;span class="nt">apiVersion&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">serving.kserve.io/v1beta1&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w">&lt;/span>&lt;span class="nt">kind&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">InferenceService&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w">&lt;/span>&lt;span class="nt">metadata&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">name&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">asistente-70b&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">namespace&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">inferencia&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">annotations&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">serving.kserve.io/deploymentMode&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="s2">&amp;#34;Standard&amp;#34;&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">serving.kserve.io/autoscalerClass&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="s2">&amp;#34;keda&amp;#34;&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w">&lt;/span>&lt;span class="nt">spec&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">predictor&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">minReplicas&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="m">1&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">maxReplicas&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="m">4&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">model&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">runtime&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">vllm-h100-tp4&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">modelFormat&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>{&lt;span class="w"> &lt;/span>&lt;span class="nt">name&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">huggingface }&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="c"># the OCI registry from article 2/4 of this series&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">storageUri&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="s2">&amp;#34;oci://registry.example.internal/modelos/asistente-70b:2026-07&amp;#34;&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">autoScaling&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">metrics&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>- &lt;span class="nt">type&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">External&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">external&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">metric&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">backend&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="s2">&amp;#34;prometheus&amp;#34;&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">serverAddress&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="s2">&amp;#34;http://prometheus.monitoring.svc.cluster.local:9090&amp;#34;&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">query&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="s1">&amp;#39;sum(vllm:num_requests_waiting{model_name=&amp;#34;asistente-70b&amp;#34;})&amp;#39;&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">target&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">type&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">Value&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">value&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="s2">&amp;#34;8&amp;#34;&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>The canary, with its small print:&lt;/p>
&lt;div class="highlight">&lt;pre tabindex="0" class="chroma">&lt;code class="language-yaml" data-lang="yaml">&lt;span class="line">&lt;span class="cl">&lt;span class="nt">apiVersion&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">serving.kserve.io/v1beta1&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w">&lt;/span>&lt;span class="nt">kind&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">InferenceService&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w">&lt;/span>&lt;span class="nt">metadata&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">name&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">asistente-70b&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">namespace&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">inferencia&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">annotations&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="c"># WATCH OUT: canaryTrafficPercent only takes effect in KNative mode.&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="c"># In Standard mode it is silently ignored (issue kserve/kserve#5335, open).&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">serving.kserve.io/deploymentMode&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="s2">&amp;#34;KNative&amp;#34;&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w">&lt;/span>&lt;span class="nt">spec&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">predictor&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">canaryTrafficPercent&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="m">10&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">minReplicas&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="m">1&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">maxReplicas&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="m">4&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">model&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">runtime&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">vllm-h100-tp4&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">modelFormat&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>{&lt;span class="w"> &lt;/span>&lt;span class="nt">name&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">huggingface }&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">storageUri&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="s2">&amp;#34;oci://registry.example.internal/modelos/asistente-70b:2026-08&amp;#34;&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>KServe keeps three references in the status (&lt;code>LatestReadyRevision&lt;/code>, &lt;code>LatestRolledoutRevision&lt;/code> and &lt;code>PreviousRolledoutRevision&lt;/code>): promoting means setting the percentage to 100, reverting means pinning traffic on the previous one, and a revision that does not become &lt;em>ready&lt;/em> automatically receives zero traffic.&lt;/p>
&lt;p>And the two calls. Against the V2 contract, for a classifier served with MLServer or Triton:&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">&lt;span class="c1"># metadata: which model it is, what inputs it expects, what types&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">curl -s http://clasificador.inferencia.example/v2/models/clasificador-tickets &lt;span class="p">|&lt;/span> jq .
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="c1"># readiness of the specific model, not of the server&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">curl -s -o /dev/null -w &lt;span class="s1">&amp;#39;%{http_code}\n&amp;#39;&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> http://clasificador.inferencia.example/v2/models/clasificador-tickets/ready
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">curl -s -X POST &lt;span class="se">\
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="se">&lt;/span> http://clasificador.inferencia.example/v2/models/clasificador-tickets/infer &lt;span class="se">\
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="se">&lt;/span> -H &lt;span class="s1">&amp;#39;Content-Type: application/json&amp;#39;&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> -d &lt;span class="s1">&amp;#39;{ &amp;#34;id&amp;#34;: &amp;#34;req-4711&amp;#34;,
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="s1"> &amp;#34;inputs&amp;#34;: [ { &amp;#34;name&amp;#34;: &amp;#34;input-0&amp;#34;, &amp;#34;shape&amp;#34;: [1, 4], &amp;#34;datatype&amp;#34;: &amp;#34;FP32&amp;#34;,
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="s1"> &amp;#34;data&amp;#34;: [5.1, 3.5, 1.4, 0.2] } ],
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="s1"> &amp;#34;outputs&amp;#34;: [ { &amp;#34;name&amp;#34;: &amp;#34;output-0&amp;#34; } ] }&amp;#39;&lt;/span>
&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>Against the same control plane, but on the OpenAI-compatible surface of the LLM:&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">curl -s -N -X POST &lt;span class="se">\
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="se">&lt;/span> http://asistente-70b.inferencia.example/openai/v1/chat/completions &lt;span class="se">\
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="se">&lt;/span> -H &lt;span class="s1">&amp;#39;Content-Type: application/json&amp;#39;&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> -d &lt;span class="s1">&amp;#39;{ &amp;#34;model&amp;#34;: &amp;#34;asistente-70b&amp;#34;,
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="s1"> &amp;#34;messages&amp;#34;: [ {&amp;#34;role&amp;#34;: &amp;#34;user&amp;#34;,
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="s1"> &amp;#34;content&amp;#34;: &amp;#34;Summarise last night&amp;#39;&lt;/span>&lt;span class="se">\&amp;#39;&lt;/span>&lt;span class="s1">&amp;#39;s incident report.&amp;#34;} ],
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="s1"> &amp;#34;max_tokens&amp;#34;: 256,
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="s1"> &amp;#34;stream&amp;#34;: true }&amp;#39;&lt;/span>
&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>Two contracts and two payload shapes, with the same &lt;code>InferenceService&lt;/code> as the unit of governance. That is the value proposition of a control plane: the contract is chosen by the model and the management is single.&lt;/p>
&lt;h2 id="autoscaling-and-the-problem-of-zero">Autoscaling and the problem of zero&lt;/h2>
&lt;p>Three autoscalers coexist, and choosing the wrong one is the most common cause of disappointment. &lt;strong>KPA&lt;/strong>, in KNative mode, scales by concurrency or RPS and is the only one that goes down to zero in a usable way, because Knative provides an activator that holds the request while the pod starts; its metric does not distinguish a 30-token request from an 8,000-token one. &lt;strong>HPA&lt;/strong>, in Standard, scales by CPU or memory: for LLMs it is useless, because the CPU of a saturated vLLM pod and that of an idle one look too much alike. &lt;strong>KEDA&lt;/strong>, in Standard, is the right option, the one we develop in &lt;a href="https://blog.lo0.es/en/posts/llm-inference-autoscaling-kubernetes-hpa-keda/">autoscaling with KEDA&lt;/a>: it is enabled with &lt;code>serving.kserve.io/autoscalerClass: &amp;quot;keda&amp;quot;&lt;/code> and &lt;code>autoScaling.metrics&lt;/code> accepts external metrics against Prometheus (&lt;code>vllm:num_requests_running&lt;/code> or &lt;code>vllm:num_requests_waiting&lt;/code>) or, with the OpenTelemetry add-on, pushed metrics. Since v0.18 there is also &lt;strong>Workload Variant Autoscaling&lt;/strong>, which uses HPA or KEDA as a backend.&lt;/p>
&lt;p>That leaves the serious problem, which no autoscaler solves: zero. A 70B model in fp8 is on the order of 70 GB to move into HBM, and that path, &lt;a href="https://blog.lo0.es/en/posts/pantry-lift-from-disk-to-hbm-why-the-kitchen-opens-late/">from disk to HBM&lt;/a>, is measured in minutes: the first request after idling takes that long, or it fails.&lt;/p>
&lt;p>Worse: the installation documentation is explicit as of July 2026: &amp;ldquo;Scale from Zero is currently not supported in Standard mode for HTTP requests&amp;rdquo;. The mode the project has pivoted towards, and the only one that supports KEDA, does not know how to come up from zero on an HTTP request: KEDA can go down to zero (default cooldown of 300 seconds), but the way back requires an activator that Standard does not have. Anyone who wants real zero with HTTP activation still needs Knative.&lt;/p>
&lt;p>The levers, in order of effectiveness: do not scale the hot model to zero (&lt;code>minReplicas: 1&lt;/code> for anything with an SLA; zero is for the long tail); attack the load time, the only solution that is not a patch, with the techniques from &lt;a href="https://blog.lo0.es/en/posts/speeding-up-model-cold-start-minutes-to-seconds/">speeding up cold start&lt;/a>; use &lt;code>LocalModelCache&lt;/code>, available since v0.15 and extended to &lt;code>LLMInferenceService&lt;/code> in v0.19, to preload artifacts into a node PVC; and set correct probes, which is what comes next.&lt;/p>
&lt;h2 id="operational-traps">Operational traps&lt;/h2>
&lt;p>&lt;strong>The probe that kills the pod while the model loads.&lt;/strong> The most frequent and the most expensive to diagnose, because the symptom is a &lt;code>CrashLoopBackOff&lt;/code> with no error in the server logs. Until early 2026 the KServe vLLM templates used &lt;code>initialDelaySeconds&lt;/code> of 120 to 300 seconds on &lt;em>liveness&lt;/em>, with the result described in issue #5062 (12 February 2026, now resolved): the fast models waited too long and the large ones died before finishing loading. The fix applied: a &lt;code>startupProbe&lt;/code> of up to ten minutes and probes with no initial delay. A legacy runtime has to be reviewed by hand. In KNative mode there is a worse variant (issue #3795, closed as &lt;em>not planned&lt;/em>): what dies is not the model container but the &lt;code>queue-proxy&lt;/code>, which does not respond OK within the deadline and takes the whole pod down with it.&lt;/p>
&lt;p>&lt;strong>&lt;code>canaryTrafficPercent&lt;/code> silently ignored.&lt;/strong> It deserves its own trap because there is no error and no condition in the status to give it away: in Standard mode the field does nothing. The team believes it is serving 10 % with the new version and it is serving 100 %. While issue #5335 remains open, in Standard the canary is done with &lt;code>HTTPRoute&lt;/code> weights by hand, or in the gateway.&lt;/p>
&lt;p>&lt;strong>Canary and scarce GPUs do not get along.&lt;/strong> A 10 % canary of a 70B with tensor parallel 4 costs a whole 4-GPU node, not 10 % of the resources, because the minimum granularity is a complete replica. During the window there are two versions occupying twice the hardware, or capacity has to be taken from the stable one. That is the difference between a microservice canary and a model canary, and the reason a &lt;em>shadow&lt;/em> over duplicated traffic often pays off better.&lt;/p>
&lt;p>&lt;strong>&lt;code>storageUri&lt;/code> and credentials.&lt;/strong> Storage initializer failures are always the same ones: a &lt;code>ServiceAccount&lt;/code> without the annotated secret, a KMS-encrypted bucket with a role that has no decryption permission, an S3-compatible but not exactly S3 endpoint, or intermittent 403s, with a specific fix in v0.18. It is diagnosed in the init container logs, not in the server&amp;rsquo;s. And one standing recommendation: no downloading over &lt;code>https://&lt;/code> from the internet in production; that is precisely the problem of article 2/4.&lt;/p>
&lt;p>&lt;strong>The model size against the &lt;code>emptyDir&lt;/code>.&lt;/strong> By default the artifact lands in an ephemeral volume on the node&amp;rsquo;s disk. Seventy gigabytes per replica fill a system disk with surprising ease, and then the kubelet evicts pods for disk pressure, including ones that have nothing to do with it. You have to set &lt;code>sizeLimit&lt;/code> or use a dedicated &lt;code>pvc://&lt;/code>. For multi-node the restriction is harder, with a mandatory ReadWriteMany PVC, which rules out a good part of local block storage and pushes you towards shared file systems, with the consequences covered in &lt;a href="https://blog.lo0.es/en/posts/storage-ai-era-availability/">storage for AI&lt;/a>.&lt;/p>
&lt;p>&lt;strong>The dependency on Knative and Istio.&lt;/strong> The real cost of KNative mode: two more control planes to version and correlate with Kubernetes, and two sidecars, &lt;code>queue-proxy&lt;/code> and &lt;code>istio-proxy&lt;/code>, in every pod, with their CPU and memory consumption and their network hop per request. On a platform that already has a mesh it is a marginal cost; on a cluster dedicated to inference it has to be justified by the capabilities you are actually going to use.&lt;/p>
&lt;p>&lt;strong>Observability: the name of the metric.&lt;/strong> The &lt;code>ServingRuntime&lt;/code> declares &lt;code>prometheus.kserve.io/port&lt;/code> and &lt;code>/path&lt;/code> annotations, but the metrics that come out are the engine&amp;rsquo;s, not KServe&amp;rsquo;s: &lt;code>vllm:num_requests_waiting&lt;/code>, &lt;code>vllm:time_to_first_token_seconds&lt;/code>, &lt;code>vllm:gpu_cache_usage_perc&lt;/code>. The control plane does not normalise names across runtimes, so a dashboard written against vLLM is no use for Triton, and correlating with &lt;a href="https://blog.lo0.es/en/posts/gpu-observability-llm-inference-twelve-dcgm-vllm-metrics/">DCGM&lt;/a> is still your own work. v0.19 raises the HPA/KEDA scaling status into the service conditions and emits events on readiness transitions: exactly what was needed to alert on the control plane and not only on the engine.&lt;/p>
&lt;h2 id="decision-map-when-kserve-and-when-it-is-overkill">Decision map: when KServe, and when it is overkill&lt;/h2>
&lt;p>The useful comparison is not &amp;ldquo;KServe versus OME, vLLM Production Stack, Dynamo or llm-d&amp;rdquo;. Those four are covered in &lt;a href="https://blog.lo0.es/en/posts/llm-inference-operators-kubernetes/">LLM inference operators&lt;/a> and compete on a different axis:&lt;/p>
&lt;table>
&lt;thead>
&lt;tr>
&lt;th>Axis&lt;/th>
&lt;th>What gets decided&lt;/th>
&lt;th>Who competes&lt;/th>
&lt;/tr>
&lt;/thead>
&lt;tbody>
&lt;tr>
&lt;td>&lt;strong>Contract and control plane&lt;/strong>&lt;/td>
&lt;td>Which CRD describes a service, which API clients see, how the model is versioned, how the rollout is done, how LLM and non-LLM coexist&lt;/td>
&lt;td>&lt;strong>KServe&lt;/strong>, Seldon Core v2&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>&lt;strong>Engine and topology&lt;/strong>&lt;/td>
&lt;td>Which engine runs, how prefill/decode is disaggregated, how routing is done with cache awareness, how multi-node tensor parallel is done&lt;/td>
&lt;td>vLLM Production Stack, OME, NVIDIA Dynamo, llm-d&lt;/td>
&lt;/tr>
&lt;/tbody>
&lt;/table>
&lt;p>The axes are orthogonal, and &lt;code>LLMInferenceService&lt;/code> proves it: KServe did not reimplement distributed scheduling, it leaned on llm-d. Choosing KServe is not choosing &lt;em>instead of&lt;/em> an LLM operator: it is choosing the contract layer that operator lives on.&lt;/p>
&lt;p>&lt;strong>Choose KServe when&lt;/strong> the catalogue is heterogeneous, LLMs plus embeddings, rerankers, classifiers and vision, which is its decisive advantage and the one none of the four operators has: a service like the one in &lt;a href="https://blog.lo0.es/en/posts/serving-embeddings-rerankers-tei-production/">TEI in production&lt;/a> fits into the same vocabulary as the LLM. Also when you need a contract decoupled from the engine because you have clients you do not control; when you need declarative, auditable governance (which artifact, from which origin and with which runtime, in an object versionable in git and subject to admission policies), the piece that makes the rest of this series possible; and when you want a catalogue of platform runtimes separated from the teams&amp;rsquo; services.&lt;/p>
&lt;p>&lt;strong>KServe is overkill when&lt;/strong> you serve one model, on one node, with up to three replicas: the &lt;code>Deployment&lt;/code> from &lt;a href="https://blog.lo0.es/en/posts/vllm-kubernetes-llm-inference-that-scales/">vLLM on Kubernetes&lt;/a> is the right answer. When your workload is LLM only and the priority is performance per GPU, because there the value is in the engine. When you are going to use neither scale-to-zero nor canary by revision, because what remains is a &lt;code>Deployment&lt;/code> with more YAML. And when you already have an LLM operator and only serve LLMs: putting KServe on top for completeness is adding CRDs without adding decisions.&lt;/p>
&lt;h2 id="for-an-inference-factory">For an inference factory&lt;/h2>
&lt;p>&lt;strong>First: separate the runtime catalogue from the service catalogue, and do it on day one.&lt;/strong> The &lt;code>ClusterServingRuntime&lt;/code>s belong to the platform; the &lt;code>InferenceService&lt;/code>s, to the teams. That boundary turns &amp;ldquo;serving the thirtieth model&amp;rdquo; into a five-line change instead of a negotiation about vLLM flags, and it fits with &lt;a href="https://blog.lo0.es/en/posts/gitops-inference-stack-flux/">GitOps with Flux&lt;/a>: the runtime is reviewed as platform code, the service as product configuration.&lt;/p>
&lt;p>&lt;strong>Second: accept the two contracts and put the gauge changer where it belongs.&lt;/strong> V2 for the tensorial world, the OpenAI API for the generative one, and the translation in the gateway, not in every client. And since the OpenAI API does not version in the route, encode the version in the model name and make that name the same identifier that appears in the registry, in the signature and in the traces. That is what makes a request traceable back to the artifact.&lt;/p>
&lt;p>&lt;strong>Third: measure start-up before promising elasticity.&lt;/strong> The time from the pod being scheduled to the readiness probe going green, with the real model and from the real origin, decides whether scale-to-zero is a FinOps lever or an incident machine. That number, and not the price of the GPU-hour, is the one to take into the cost conversation.&lt;/p>
&lt;p>And here this piece falls short on purpose. KServe gives you the contract, the lifecycle and the rollout, but its whole trust model hangs off a text string: &lt;strong>&lt;code>storageUri&lt;/code>&lt;/strong>. The storage initializer downloads whatever is at that destination and puts it in front of the GPU without asking anything else; nothing in the &lt;code>InferenceService&lt;/code> guarantees that the artifact is the one that was approved, that it has not changed since the last audit, or that the origin is legitimate. The next article goes there: where the model bytes come from, and why an OCI registry with ORAS, Harbor, MLflow or the Kubeflow Model Registry is the difference between distributing models and scattering them.&lt;/p>
&lt;h2 id="see-also">See also&lt;/h2>
&lt;ul>
&lt;li>&lt;a href="https://blog.lo0.es/en/posts/llm-inference-operators-kubernetes/">LLM inference operators on Kubernetes: OME, vLLM Production Stack, NVIDIA Dynamo and llm-d&lt;/a> — the complementary axis: engine and topology versus the contract this article deals with.&lt;/li>
&lt;li>&lt;a href="https://blog.lo0.es/en/posts/vllm-kubernetes-llm-inference-that-scales/">vLLM on Kubernetes: the LLM inference piece that does scale&lt;/a> — the plain &lt;code>Deployment&lt;/code> that KServe is the next step from, and the point where KServe is still overkill.&lt;/li>
&lt;li>&lt;a href="https://blog.lo0.es/en/posts/llm-inference-autoscaling-kubernetes-hpa-keda/">LLM autoscaling on Kubernetes with KEDA&lt;/a> — the vLLM metrics that feed the predictor&amp;rsquo;s &lt;code>autoScaling&lt;/code> block.&lt;/li>
&lt;li>&lt;a href="https://blog.lo0.es/en/posts/canary-blue-green-shadow-llm-models/">Canary, blue-green and shadow for LLM models&lt;/a> — what to do when &lt;code>canaryTrafficPercent&lt;/code> is not available, and why a model canary does not cost what it seems to.&lt;/li>
&lt;li>&lt;a href="https://blog.lo0.es/en/posts/speeding-up-model-cold-start-minutes-to-seconds/">Speeding up cold start: model loading with Tensorizer&lt;/a> — the only real way for scale-to-zero to stop being a promise.&lt;/li>
&lt;li>&lt;a href="https://blog.lo0.es/en/posts/model-chain-of-trust-where-the-bytes-live-registry-oci-artefacts/">Model chain of trust (2/4): model registry and distribution with OCI and ORAS&lt;/a> — where that &lt;code>storageUri&lt;/code> points, and how it becomes a governed artifact.&lt;/li>
&lt;/ul>
&lt;h2 id="sources">Sources&lt;/h2>
&lt;ul>
&lt;li>CNCF, &lt;em>KServe becomes a CNCF incubating project&lt;/em> (11 de noviembre de 2025) — &lt;a href="https://www.cncf.io/blog/2025/11/11/kserve-becomes-a-cncf-incubating-project/">https://www.cncf.io/blog/2025/11/11/kserve-becomes-a-cncf-incubating-project/&lt;/a>&lt;/li>
&lt;li>CNCF, &lt;em>KServe project page&lt;/em> (incubating, aceptado el 29 de septiembre de 2025) — &lt;a href="https://www.cncf.io/projects/kserve/">https://www.cncf.io/projects/kserve/&lt;/a>&lt;/li>
&lt;li>CNCF TOC, &lt;em>Project Moving Levels Checklist: KServe joining CNCF at Incubation level&lt;/em> — &lt;a href="https://github.com/cncf/toc/issues/1905">https://github.com/cncf/toc/issues/1905&lt;/a>&lt;/li>
&lt;li>KServe, &lt;em>Releases&lt;/em> (v0.15.0, v0.16.0, v0.18.0, v0.19.0) — &lt;a href="https://github.com/kserve/kserve/releases">https://github.com/kserve/kserve/releases&lt;/a>&lt;/li>
&lt;li>KServe, &lt;em>Inference Protocol V2 (Open Inference Protocol)&lt;/em> — &lt;a href="https://kserve.github.io/website/docs/concepts/architecture/data-plane/v2-protocol">https://kserve.github.io/website/docs/concepts/architecture/data-plane/v2-protocol&lt;/a>&lt;/li>
&lt;li>KServe, &lt;em>open-inference-protocol&lt;/em> (especificación, adoptantes, gobernanza) — &lt;a href="https://github.com/kserve/open-inference-protocol">https://github.com/kserve/open-inference-protocol&lt;/a>&lt;/li>
&lt;li>KServe, &lt;em>open-inference-protocol — generate_rest.yaml&lt;/em> — &lt;a href="https://github.com/kserve/open-inference-protocol/blob/main/specification/protocol/generate_rest.yaml">https://github.com/kserve/open-inference-protocol/blob/main/specification/protocol/generate_rest.yaml&lt;/a>&lt;/li>
&lt;li>open-inference, &lt;em>Vendor neutral fork of kserve/open-inference-protocol&lt;/em> — &lt;a href="https://github.com/open-inference/open-inference-protocol">https://github.com/open-inference/open-inference-protocol&lt;/a>&lt;/li>
&lt;li>KServe, &lt;em>Generative Inference — Runtime Overview&lt;/em> (endpoints con prefijo &lt;code>openai/&lt;/code>) — &lt;a href="https://kserve.github.io/website/docs/model-serving/generative-inference/overview">https://kserve.github.io/website/docs/model-serving/generative-inference/overview&lt;/a>&lt;/li>
&lt;li>KServe, &lt;em>Understanding LLMInferenceService&lt;/em> — &lt;a href="https://kserve.github.io/website/docs/model-serving/generative-inference/llmisvc/llmisvc-overview">https://kserve.github.io/website/docs/model-serving/generative-inference/llmisvc/llmisvc-overview&lt;/a>&lt;/li>
&lt;li>KServe, &lt;em>Cloud-Native AI Inference at Scale using KServe and llm-d&lt;/em> (5 de marzo de 2026) — &lt;a href="https://kserve.github.io/website/blog/cloud-native-ai-inference-kserve-llm-d">https://kserve.github.io/website/blog/cloud-native-ai-inference-kserve-llm-d&lt;/a>&lt;/li>
&lt;li>KServe, &lt;em>Serving Runtime (ServingRuntime y ClusterServingRuntime)&lt;/em> — &lt;a href="https://kserve.github.io/website/docs/concepts/resources/servingruntime">https://kserve.github.io/website/docs/concepts/resources/servingruntime&lt;/a>&lt;/li>
&lt;li>KServe, &lt;em>Kubernetes Deployment Installation Guide&lt;/em> (K8s 1.32+, cert-manager 1.15.0+, Gateway API v1.2.1, sin scale-from-zero en Standard) — &lt;a href="https://kserve.github.io/website/docs/admin-guide/kubernetes-deployment">https://kserve.github.io/website/docs/admin-guide/kubernetes-deployment&lt;/a>&lt;/li>
&lt;li>KServe, &lt;em>Canary Rollout Strategy&lt;/em> — &lt;a href="https://kserve.github.io/website/docs/model-serving/predictive-inference/rollout-strategies/canary">https://kserve.github.io/website/docs/model-serving/predictive-inference/rollout-strategies/canary&lt;/a>&lt;/li>
&lt;li>KServe, &lt;em>Autoscaling with KEDA&lt;/em> — &lt;a href="https://kserve.github.io/website/docs/model-serving/predictive-inference/autoscaling/keda-autoscaler">https://kserve.github.io/website/docs/model-serving/predictive-inference/autoscaling/keda-autoscaler&lt;/a>&lt;/li>
&lt;li>KServe, &lt;em>Multi-node/Multi-GPU Inference&lt;/em> (PVC RWX, sin autoescalado) — &lt;a href="https://kserve.github.io/website/docs/model-serving/generative-inference/multi-node">https://kserve.github.io/website/docs/model-serving/generative-inference/multi-node&lt;/a>&lt;/li>
&lt;li>KServe, &lt;em>Storage Options for Model Artifacts&lt;/em> — &lt;a href="https://kserve.github.io/website/docs/model-serving/storage/overview">https://kserve.github.io/website/docs/model-serving/storage/overview&lt;/a>&lt;/li>
&lt;li>KServe, issue #5335 — &lt;em>Support canaryTrafficPercent for RawDeployment mode via Gateway API HTTPRoute weights&lt;/em> (abierta, 2 de abril de 2026) — &lt;a href="https://github.com/kserve/kserve/issues/5335">https://github.com/kserve/kserve/issues/5335&lt;/a>&lt;/li>
&lt;li>KServe, issue #5062 — &lt;em>Add startupProbe to vLLM main containers&lt;/em> (12 de febrero de 2026) — &lt;a href="https://github.com/kserve/kserve/issues/5062">https://github.com/kserve/kserve/issues/5062&lt;/a>&lt;/li>
&lt;li>KServe, &lt;em>modelmesh-serving&lt;/em> (última release v0.12.0 de julio de 2023; repositorio archivado el 14 de abril de 2026) — &lt;a href="https://github.com/kserve/modelmesh-serving">https://github.com/kserve/modelmesh-serving&lt;/a>&lt;/li>
&lt;li>Red Hat, &lt;em>Converting ModelMesh and Serverless InferenceServices to RawDeployment (Standard) Mode&lt;/em> — &lt;a href="https://access.redhat.com/articles/7134025">https://access.redhat.com/articles/7134025&lt;/a>&lt;/li>
&lt;li>NVIDIA, &lt;em>Triton Inference Server — Inference Protocols and APIs&lt;/em> — &lt;a href="https://docs.nvidia.com/deeplearning/triton-inference-server/user-guide/docs/customization_guide/inference_protocols.html">https://docs.nvidia.com/deeplearning/triton-inference-server/user-guide/docs/customization_guide/inference_protocols.html&lt;/a>&lt;/li>
&lt;li>OpenVINO, &lt;em>What is OpenVINO Model Server&lt;/em> (API KServe, TFS y endpoints compatibles con OpenAI) — &lt;a href="https://docs.openvino.ai/2026/model-server/ovms_what_is_openvino_model_server.html">https://docs.openvino.ai/2026/model-server/ovms_what_is_openvino_model_server.html&lt;/a>&lt;/li>
&lt;li>PyTorch, &lt;em>TorchServe — Notice: Limited Maintenance&lt;/em> — &lt;a href="https://docs.pytorch.org/serve/">https://docs.pytorch.org/serve/&lt;/a>&lt;/li>
&lt;li>Kubernetes SIG Network, &lt;em>Gateway API Inference Extension&lt;/em> (v1.5.0, 19 de abril de 2026) — &lt;a href="https://github.com/kubernetes-sigs/gateway-api-inference-extension">https://github.com/kubernetes-sigs/gateway-api-inference-extension&lt;/a>&lt;/li>
&lt;/ul></description></item><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>GenAI-Perf in depth: LLM inference profiling with real data</title><link>https://blog.lo0.es/en/posts/genai-perf-llm-inference-profiling-in-depth/</link><pubDate>Mon, 15 Jun 2026 03:00:00 +0200</pubDate><guid>https://blog.lo0.es/en/posts/genai-perf-llm-inference-profiling-in-depth/</guid><description>&lt;blockquote>
&lt;p>Notation: amounts in &lt;strong>euros (N EUR)&lt;/strong>, decimals with a point. The dollar symbol is not used
(on this site it is a formula delimiter).&lt;/p>
&lt;/blockquote>
&lt;h2 id="tldr">TL;DR&lt;/h2>
&lt;p>GenAI-Perf (the &lt;code>genai-perf&lt;/code> package, part of NVIDIA&amp;rsquo;s Triton ecosystem) is the reference LLM
inference profiler for OpenAI-compatible endpoints. Installable with &lt;code>pip install genai-perf&lt;/code>.
A single &lt;code>genai-perf profile&lt;/code> command produces a console table with TTFT, ITL, request latency
(avg/min/max/P75/P90/P99), output sequence length, output token throughput and request
throughput, plus &lt;code>profile_export_genai_perf.json&lt;/code> and &lt;code>.csv&lt;/code> artefacts. The &lt;code>analyze&lt;/code>
subcommand automatically sweeps concurrencies from 1 to 256 (powers of 2) and generates a
summary &lt;code>analyze_export_genai_perf.csv&lt;/code>. GenAI-Perf was declared &lt;strong>retired in April 2026&lt;/strong>
(replaced by AIPerf); it remains the reference benchmark for historical runs and documented
comparisons, and the NVIDIA tool with the most public documentation for LLMs before 2026.&lt;/p>
&lt;hr>
&lt;h2 id="what-genai-perf-is-and-where-it-fits">What GenAI-Perf is and where it fits&lt;/h2>
&lt;p>GenAI-Perf is a command-line tool for measuring the throughput and latency of generative AI
models served through an inference server. It is part of the
&lt;code>triton-inference-server/perf_analyzer&lt;/code> GitHub repository and is distributed as a pip package
(&lt;code>genai-perf&lt;/code>) and inside the Triton SDK container
(&lt;code>nvcr.io/nvidia/tritonserver:YY.MM-py3-sdk&lt;/code>).&lt;/p>
&lt;p>&lt;strong>Position in the NVIDIA ecosystem:&lt;/strong>&lt;/p>
&lt;div class="diagram" style="max-width:780px;margin:1rem auto;">
&lt;svg viewBox="0 0 780 200" role="img" aria-label="NVIDIA ecosystem: Triton Inference Server and TensorRT-LLM as engines, NIM as a microservice, GenAI-Perf as an endpoint profiler, AIPerf as the successor" xmlns="http://www.w3.org/2000/svg">
&lt;style>.bx{fill:none;stroke:currentColor;stroke-width:1.3}.tl{font:600 12px sans-serif;fill:currentColor}.ts{font:11px sans-serif;fill:currentColor}.ar{fill:none;stroke:currentColor;stroke-width:1.3;marker-end:url(#am)}&lt;/style>
&lt;defs>&lt;marker id="am" viewBox="0 0 10 10" refX="9" refY="5" markerWidth="7" markerHeight="7" orient="auto">&lt;path d="M0,0 L10,5 L0,10 z" fill="currentColor"/>&lt;/marker>&lt;/defs>
&lt;rect class="bx" x="20" y="60" width="140" height="44" rx="6"/>&lt;text x="90" y="79" text-anchor="middle" class="tl">Inference engine&lt;/text>&lt;text x="90" y="95" text-anchor="middle" class="ts">Triton + TRT-LLM / vLLM&lt;/text>
&lt;path class="ar" d="M160,82 L200,82"/>
&lt;rect class="bx" x="200" y="60" width="120" height="44" rx="6"/>&lt;text x="260" y="79" text-anchor="middle" class="tl">Endpoint&lt;/text>&lt;text x="260" y="95" text-anchor="middle" class="ts">OpenAI-compatible&lt;/text>
&lt;path class="ar" d="M320,82 L360,82"/>
&lt;rect class="bx" x="360" y="60" width="140" height="44" rx="6"/>&lt;text x="430" y="79" text-anchor="middle" class="tl">GenAI-Perf&lt;/text>&lt;text x="430" y="95" text-anchor="middle" class="ts">profiler (until Apr 2026)&lt;/text>
&lt;path class="ar" d="M500,82 L540,82"/>
&lt;rect class="bx" x="540" y="60" width="140" height="44" rx="6"/>&lt;text x="610" y="79" text-anchor="middle" class="tl">AIPerf&lt;/text>&lt;text x="610" y="95" text-anchor="middle" class="ts">successor (Apr 2026+)&lt;/text>
&lt;text x="20" y="155" class="ts">GenAI-Perf works against any endpoint compatible with the OpenAI API (vLLM, NIM, Triton, SGLang, TGI…).&lt;/text>
&lt;text x="20" y="172" class="ts">AIPerf is the official successor from April 2026 (GitHub: ai-dynamo/aiperf).&lt;/text>
&lt;/svg>
&lt;/div>
&lt;p>&lt;strong>Relationship with perf_analyzer:&lt;/strong> GenAI-Perf internally uses Triton&amp;rsquo;s &lt;code>perf_analyzer&lt;/code>
binary to generate load and measure latencies. perf_analyzer is the low-level load generator;
GenAI-Perf is the high-level layer that adds prompt synthesis, OpenAI payload construction and
the LLM-specific metrics (TTFT, ITL, sequence lengths).&lt;/p>
&lt;p>&lt;strong>Installation:&lt;/strong>&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">&lt;span class="c1"># Option 1: pip (requires CUDA 12, Ubuntu 24.04, Python 3.10+)&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">pip install genai-perf
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="c1"># Option 2: Triton SDK container (recommended for reproducibility)&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="nb">export&lt;/span> &lt;span class="nv">RELEASE&lt;/span>&lt;span class="o">=&lt;/span>&lt;span class="s2">&amp;#34;25.01&amp;#34;&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">docker run -it --net&lt;span class="o">=&lt;/span>host --gpus&lt;span class="o">=&lt;/span>all &lt;span class="se">\
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="se">&lt;/span> nvcr.io/nvidia/tritonserver:25.01-py3-sdk
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="c1"># Inside the container:&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">genai-perf --help
&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;hr>
&lt;h2 id="metrics-genai-perf-produces">Metrics GenAI-Perf produces&lt;/h2>
&lt;p>The metrics cover the full cycle of a streaming generation request. All of them are reported
with avg, min, max, P75, P90 and P99 except the throughput ones (avg only).&lt;/p>
&lt;h3 id="metrics-glossary">Metrics glossary&lt;/h3>
&lt;table>
&lt;thead>
&lt;tr>
&lt;th>Metric&lt;/th>
&lt;th>Symbol&lt;/th>
&lt;th>Exact definition (GenAI-Perf)&lt;/th>
&lt;th>Unit&lt;/th>
&lt;/tr>
&lt;/thead>
&lt;tbody>
&lt;tr>
&lt;td>&lt;strong>Time to First Token&lt;/strong>&lt;/td>
&lt;td>TTFT&lt;/td>
&lt;td>Time from sending the request to receiving the first token (includes queueing, prefill and network)&lt;/td>
&lt;td>ms&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>&lt;strong>Inter-Token Latency&lt;/strong>&lt;/td>
&lt;td>ITL (= TPOT)&lt;/td>
&lt;td>\(\frac{e2e\_latency - TTFT}{output\_tokens - 1}\) — decode phase only, excludes the first token&lt;/td>
&lt;td>ms/token&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>&lt;strong>Request Latency&lt;/strong>&lt;/td>
&lt;td>e2e_latency&lt;/td>
&lt;td>\(e2e = TTFT + Generation\_time\) — from the first request to the last response&lt;/td>
&lt;td>ms&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>&lt;strong>Output Token Throughput&lt;/strong>&lt;/td>
&lt;td>OTT&lt;/td>
&lt;td>\(\frac{total\_output\_tokens}{T_y - T_x}\) where \(T_x\) = first request, \(T_y\) = last token received&lt;/td>
&lt;td>tok/s&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>&lt;strong>Request Throughput&lt;/strong>&lt;/td>
&lt;td>RPS&lt;/td>
&lt;td>\(\frac{total\_completed\_requests}{T_y - T_x}\)&lt;/td>
&lt;td>req/s&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>&lt;strong>Input Sequence Length&lt;/strong>&lt;/td>
&lt;td>ISL&lt;/td>
&lt;td>Mean length in tokens of the input prompt&lt;/td>
&lt;td>tokens&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>&lt;strong>Output Sequence Length&lt;/strong>&lt;/td>
&lt;td>OSL&lt;/td>
&lt;td>Mean length in tokens of the generated response&lt;/td>
&lt;td>tokens&lt;/td>
&lt;/tr>
&lt;/tbody>
&lt;/table>
&lt;p>&lt;strong>Methodological note on ITL versus LLMPerf:&lt;/strong> GenAI-Perf excludes TTFT from the ITL
calculation (pure decode only). LLMPerf includes TTFT in its ITL mean. The numbers are not
directly comparable.&lt;/p>
&lt;p>&lt;strong>Methodological note on TPS versus LLMPerf:&lt;/strong> GenAI-Perf measures throughput between the
first request and the last token received (\(T_y - T_x\)). LLMPerf uses the total duration of
the benchmark including prompt generation and response storage; in a concurrency-1 scenario,
that difference can amount to up to 33% in the reported throughput figure.&lt;/p>
&lt;h3 id="console-output-table-real-example-trt-llm-backend">Console output table (real example, TRT-LLM backend)&lt;/h3>
&lt;p>The table GenAI-Perf prints for a profile with ISL=200, OSL=100, concurrency=1:&lt;/p>
&lt;pre tabindex="0">&lt;code> NVIDIA GenAI-Perf | LLM Metrics
┏━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┳━━━━━━━━┳━━━━━━━━┳━━━━━━━━┳━━━━━━━━┳━━━━━━━━┳━━━━━━━━┓
┃ Statistic ┃ avg ┃ min ┃ max ┃ p99 ┃ p90 ┃ p75 ┃
┡━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━╇━━━━━━━━╇━━━━━━━━╇━━━━━━━━╇━━━━━━━━╇━━━━━━━━╇━━━━━━━━┩
│ Time to first token (ms) │ 13.68 │ 11.07 │ 21.50 │ 18.81 │ 14.29 │ 13.97 │
│ Inter token latency (ms) │ 1.86 │ 1.28 │ 2.11 │ 2.11 │ 2.01 │ 1.95 │
│ Request latency (ms) │ 203.70 │ 180.33 │ 228.30 │ 225.45 │ 216.48 │ 211.72 │
│ Output sequence length │ 103.46 │ 95.00 │ 134.00 │ 122.96 │ 108.00 │ 104.75 │
│ Input sequence length │ 200.00 │ 200.00 │ 200.00 │ 200.00 │ 200.00 │ 200.00 │
│ Output token throughput (per sec) │ 504.02 │ N/A │ N/A │ N/A │ N/A │ N/A │
│ Request throughput (per sec) │ 4.87 │ N/A │ N/A │ N/A │ N/A │ N/A │
└───────────────────────────────────┴────────┴────────┴────────┴────────┴────────┴────────┘
&lt;/code>&lt;/pre>&lt;p>&lt;em>(Source: docs.nvidia.com/deeplearning/triton-inference-server — LLM tutorial)&lt;/em>&lt;/p>
&lt;hr>
&lt;h2 id="invocation-against-openai-compatible-endpoints">Invocation against OpenAI-compatible endpoints&lt;/h2>
&lt;h3 id="profile-mode-one-load-point">&lt;code>profile&lt;/code> mode: one load point&lt;/h3>
&lt;p>The &lt;code>profile&lt;/code> subcommand measures a fixed operating point (concurrency or request-rate,
synthetic ISL/OSL).&lt;/p>
&lt;p>&lt;strong>Example 1: vLLM endpoint (chat, streaming)&lt;/strong>&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">&lt;span class="c1"># 1. Bring up the vLLM server&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">docker run -it --net&lt;span class="o">=&lt;/span>host --rm --gpus&lt;span class="o">=&lt;/span>all &lt;span class="se">\
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="se">&lt;/span> vllm/vllm-openai:latest &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> --dtype float16
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="c1"># 2. Profile with GenAI-Perf&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">genai-perf profile &lt;span class="se">\
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="se">&lt;/span> -m 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> --endpoint-type chat &lt;span class="se">\
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="se">&lt;/span> --streaming &lt;span class="se">\
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="se">&lt;/span> --concurrency &lt;span class="m">16&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> --synthetic-input-tokens-mean &lt;span class="m">1024&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> --synthetic-input-tokens-stddev &lt;span class="m">128&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> --output-tokens-mean &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> --output-tokens-stddev &lt;span class="m">0&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> --output-tokens-mean-deterministic &lt;span class="se">\
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="se">&lt;/span> --request-count &lt;span class="m">200&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> --warmup-request-count &lt;span class="m">20&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> --tokenizer meta-llama/Llama-3.1-70B-Instruct
&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>&lt;strong>Example 2: completions endpoint (no streaming)&lt;/strong>&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">genai-perf profile &lt;span class="se">\
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="se">&lt;/span> -m 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> --endpoint-type completions &lt;span class="se">\
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="se">&lt;/span> --concurrency &lt;span class="m">32&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> --synthetic-input-tokens-mean &lt;span class="m">512&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> --synthetic-input-tokens-stddev &lt;span class="m">64&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> --output-tokens-mean &lt;span class="m">128&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> --request-count &lt;span class="m">200&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> --warmup-request-count &lt;span class="m">20&lt;/span>
&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>&lt;strong>Example 3: NVIDIA NIM (TRT-LLM backend endpoint, via Triton)&lt;/strong>&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">genai-perf profile &lt;span class="se">\
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="se">&lt;/span> -m meta/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> --backend tensorrtllm &lt;span class="se">\
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="se">&lt;/span> --streaming &lt;span class="se">\
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="se">&lt;/span> --concurrency &lt;span class="m">8&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> --synthetic-input-tokens-mean &lt;span class="m">2048&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> --synthetic-input-tokens-stddev &lt;span class="m">0&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> --output-tokens-mean &lt;span class="m">512&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> --output-tokens-mean-deterministic &lt;span class="se">\
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="se">&lt;/span> --request-count &lt;span class="m">100&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> --warmup-request-count &lt;span class="m">10&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> --generate-plots
&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>&lt;strong>Example 4: request-rate instead of concurrency (Poisson)&lt;/strong>&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">genai-perf profile &lt;span class="se">\
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="se">&lt;/span> -m 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> --endpoint-type chat &lt;span class="se">\
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="se">&lt;/span> --streaming &lt;span class="se">\
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="se">&lt;/span> --request-rate 5.0 &lt;span class="se">\
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="se">&lt;/span> --synthetic-input-tokens-mean &lt;span class="m">1024&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> --output-tokens-mean &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> --request-count &lt;span class="m">300&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> --warmup-request-count &lt;span class="m">30&lt;/span>
&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;h3 id="main-flags-and-what-they-do">Main flags and what they do&lt;/h3>
&lt;table>
&lt;thead>
&lt;tr>
&lt;th>Flag&lt;/th>
&lt;th>Typical values&lt;/th>
&lt;th>Function&lt;/th>
&lt;/tr>
&lt;/thead>
&lt;tbody>
&lt;tr>
&lt;td>&lt;code>--endpoint-type&lt;/code>&lt;/td>
&lt;td>&lt;code>chat&lt;/code>, &lt;code>completions&lt;/code>, &lt;code>embeddings&lt;/code>&lt;/td>
&lt;td>Type of OpenAI-compatible endpoint&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>&lt;code>--streaming&lt;/code>&lt;/td>
&lt;td>(boolean)&lt;/td>
&lt;td>Enables SSE streaming; required to measure real TTFT and ITL&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>&lt;code>--concurrency&lt;/code>&lt;/td>
&lt;td>1, 8, 16, 32, 64&lt;/td>
&lt;td>Number of concurrent requests maintained; GenAI-Perf guarantees N active at all times&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>&lt;code>--request-rate&lt;/code>&lt;/td>
&lt;td>1.0, 5.0, 10.0&lt;/td>
&lt;td>Constant arrival rate (req/s); does not guarantee N active&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>&lt;code>--synthetic-input-tokens-mean&lt;/code>&lt;/td>
&lt;td>128–8192&lt;/td>
&lt;td>Mean of synthetic ISL tokens&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>&lt;code>--synthetic-input-tokens-stddev&lt;/code>&lt;/td>
&lt;td>0–512&lt;/td>
&lt;td>Standard deviation of the ISL (0 = fixed)&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>&lt;code>--output-tokens-mean&lt;/code>&lt;/td>
&lt;td>64–2048&lt;/td>
&lt;td>Mean of target OSL tokens&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>&lt;code>--output-tokens-mean-deterministic&lt;/code>&lt;/td>
&lt;td>(boolean)&lt;/td>
&lt;td>Sets the minimum output tokens = target mean (more precise with TRT-LLM)&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>&lt;code>--request-count&lt;/code>&lt;/td>
&lt;td>100–2000&lt;/td>
&lt;td>Number of requests to benchmark&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>&lt;code>--warmup-request-count&lt;/code>&lt;/td>
&lt;td>10–50&lt;/td>
&lt;td>Warm-up requests discarded from the metrics&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>&lt;code>--generate-plots&lt;/code>&lt;/td>
&lt;td>(boolean)&lt;/td>
&lt;td>Generates PNG plots of TTFT vs ISL, ITL vs token position, etc.&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>&lt;code>--tokenizer&lt;/code>&lt;/td>
&lt;td>HF model id&lt;/td>
&lt;td>Tokenizer for counting tokens; mandatory when ISL/OSL matter&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>&lt;code>--backend&lt;/code>&lt;/td>
&lt;td>&lt;code>tensorrtllm&lt;/code>, &lt;code>vllm&lt;/code>&lt;/td>
&lt;td>Triton backend (for serving directly via Triton without an OpenAI endpoint)&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>&lt;code>--extra-inputs&lt;/code>&lt;/td>
&lt;td>&lt;code>ignore_eos:true&lt;/code>&lt;/td>
&lt;td>Extra request parameters; &lt;code>ignore_eos:true&lt;/code> guarantees a consistent OSL&lt;/td>
&lt;/tr>
&lt;/tbody>
&lt;/table>
&lt;p>&lt;strong>Note on &lt;code>--concurrency&lt;/code> versus &lt;code>--request-rate&lt;/code>:&lt;/strong> NVIDIA recommends using &lt;code>--concurrency&lt;/code>.
With &lt;code>--request-rate&lt;/code>, if the rate exceeds the engine&amp;rsquo;s throughput, the queue grows without
bound and the metrics stop being stable. With &lt;code>--concurrency&lt;/code>, there are always exactly N
active requests, which gives a clean latency-throughput curve.&lt;/p>
&lt;h3 id="output-artefacts">Output artefacts&lt;/h3>
&lt;p>GenAI-Perf dumps all results into an &lt;code>artifacts/&lt;/code> directory:&lt;/p>
&lt;table>
&lt;thead>
&lt;tr>
&lt;th>Artefact&lt;/th>
&lt;th>Format&lt;/th>
&lt;th>Content&lt;/th>
&lt;/tr>
&lt;/thead>
&lt;tbody>
&lt;tr>
&lt;td>&lt;code>profile_export_genai_perf.json&lt;/code>&lt;/td>
&lt;td>JSON&lt;/td>
&lt;td>Full metrics (avg, min, max, P75, P90, P99) + CLI arguments used&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>&lt;code>profile_export_genai_perf.csv&lt;/code>&lt;/td>
&lt;td>CSV&lt;/td>
&lt;td>Exported console tables, ready to import into Excel/pandas&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>&lt;code>profile_export.json&lt;/code>&lt;/td>
&lt;td>JSON&lt;/td>
&lt;td>Raw perf_analyzer data (per-request traces)&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>&lt;code>inputs.json&lt;/code>&lt;/td>
&lt;td>JSON&lt;/td>
&lt;td>Synthetic payloads sent (for reproducibility)&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>PNG plots&lt;/td>
&lt;td>PNG&lt;/td>
&lt;td>TTFT analysis, Request latency, TTFT vs ISL, ITL vs position, ISL vs OSL (with &lt;code>--generate-plots&lt;/code>)&lt;/td>
&lt;/tr>
&lt;/tbody>
&lt;/table>
&lt;hr>
&lt;h2 id="the-analyze-subcommand-automatic-concurrency-sweep">The &lt;code>analyze&lt;/code> subcommand: automatic concurrency sweep&lt;/h2>
&lt;p>The &lt;code>analyze&lt;/code> subcommand sweeps multiple values of a parameter in a single command and
generates a summary CSV.&lt;/p>
&lt;h3 id="concurrency-sweep-the-most-used">Concurrency sweep (the most used)&lt;/h3>
&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">&lt;span class="c1"># Sweeps concurrencies 1, 2, 4, 8, 16, 32, 64, 128, 256&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">genai-perf analyze &lt;span class="se">\
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="se">&lt;/span> -m 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> --endpoint-type chat &lt;span class="se">\
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="se">&lt;/span> --streaming &lt;span class="se">\
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="se">&lt;/span> --sweep-type concurrency &lt;span class="se">\
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="se">&lt;/span> --sweep-range 1:256 &lt;span class="se">\
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="se">&lt;/span> --synthetic-input-tokens-mean &lt;span class="m">1024&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> --output-tokens-mean &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> --request-count &lt;span class="m">200&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> --warmup-request-count &lt;span class="m">20&lt;/span>
&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;h3 id="request-rate-sweep">Request-rate sweep&lt;/h3>
&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">&lt;span class="c1"># Sweeps 2, 4, 6, 8, 10, 12 req/s&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">genai-perf analyze &lt;span class="se">\
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="se">&lt;/span> -m 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> --endpoint-type chat &lt;span class="se">\
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="se">&lt;/span> --streaming &lt;span class="se">\
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="se">&lt;/span> --sweep-type request_rate &lt;span class="se">\
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="se">&lt;/span> --sweep-list 2,4,6,8,10,12 &lt;span class="se">\
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="se">&lt;/span> --synthetic-input-tokens-mean &lt;span class="m">1024&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> --output-tokens-mean &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> --request-count &lt;span class="m">200&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> --warmup-request-count &lt;span class="m">20&lt;/span>
&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;h3 id="isl-sweep-to-study-the-effect-of-prefill">ISL sweep (to study the effect of prefill)&lt;/h3>
&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">genai-perf analyze &lt;span class="se">\
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="se">&lt;/span> -m 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> --endpoint-type chat &lt;span class="se">\
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="se">&lt;/span> --streaming &lt;span class="se">\
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="se">&lt;/span> --sweep-type input_sequence_length &lt;span class="se">\
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="se">&lt;/span> --sweep-list 256,512,1024,2048,4096 &lt;span class="se">\
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="se">&lt;/span> --concurrency &lt;span class="m">16&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> --output-tokens-mean &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> --request-count &lt;span class="m">200&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> --warmup-request-count &lt;span class="m">20&lt;/span>
&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;h3 id="artefact-structure-of-analyze">Artefact structure of &lt;code>analyze&lt;/code>&lt;/h3>
&lt;p>For a sweep with &lt;code>--sweep-type concurrency --sweep-range 1:32&lt;/code>:&lt;/p>
&lt;pre tabindex="0">&lt;code>artifacts/
llama70b-openai-chat-concurrency1/
inputs.json
profile_export.json
profile_export_genai_perf.json
profile_export_genai_perf.csv
llama70b-openai-chat-concurrency2/
...
llama70b-openai-chat-concurrency4/
...
[...]
analyze_export_genai_perf.csv ← summary of every scenario
checkpoint.json ← allows interrupted sweeps to be resumed
&lt;/code>&lt;/pre>&lt;h3 id="summary-csv-format">Summary CSV format&lt;/h3>
&lt;pre tabindex="0">&lt;code class="language-csv" data-lang="csv">Config Name,Concurrency,ISL,p99 TTFT (ms),p99 ITL (ms),p99 Request Latency (ms),Avg. OTT (tok/s),RPS
llama70b_run_0,1,1024,45.2,6.8,1823.4,128.3,0.55
llama70b_run_1,4,1024,48.1,7.1,1901.2,510.7,2.18
llama70b_run_2,8,1024,61.3,7.9,2142.5,989.2,4.12
llama70b_run_3,16,1024,98.7,9.4,2891.3,1741.6,6.88
llama70b_run_4,32,1024,284.1,14.2,4320.8,2134.2,7.14
llama70b_run_5,64,1024,892.4,28.7,8741.2,2251.8,7.23
&lt;/code>&lt;/pre>&lt;p>The summary CSV also includes a second table with GPU metrics (P99 power, energy, utilisation,
memory) when they are captured via Triton&amp;rsquo;s telemetry URLs
(&lt;code>--server-metrics-urls http://localhost:8002/metrics&lt;/code>).&lt;/p>
&lt;hr>
&lt;h2 id="comparison-table-genai-perf-versus-guidellm-versus-llmperf-versus-vllm-bench">Comparison table: GenAI-Perf versus GuideLLM versus LLMPerf versus vLLM bench&lt;/h2>
&lt;p>The choice of tool changes the number. They are not comparable with each other without
adjustment.&lt;/p>
&lt;table>
&lt;thead>
&lt;tr>
&lt;th>Dimension&lt;/th>
&lt;th>&lt;strong>GenAI-Perf&lt;/strong>&lt;/th>
&lt;th>&lt;strong>GuideLLM&lt;/strong>&lt;/th>
&lt;th>&lt;strong>LLMPerf&lt;/strong>&lt;/th>
&lt;th>&lt;strong>vLLM bench serve&lt;/strong>&lt;/th>
&lt;/tr>
&lt;/thead>
&lt;tbody>
&lt;tr>
&lt;td>&lt;strong>Origin / maintainer&lt;/strong>&lt;/td>
&lt;td>NVIDIA / Triton team (retired Apr 2026)&lt;/td>
&lt;td>Red Hat / vLLM project&lt;/td>
&lt;td>Anyscale / Ray&lt;/td>
&lt;td>vLLM project&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>&lt;strong>Class&lt;/strong>&lt;/td>
&lt;td>multi-process load generator&lt;/td>
&lt;td>multi-process load generator&lt;/td>
&lt;td>multi-process load generator&lt;/td>
&lt;td>single-process micro-bench&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>&lt;strong>Supported endpoints&lt;/strong>&lt;/td>
&lt;td>OpenAI-compatible + KServe + native Triton&lt;/td>
&lt;td>OpenAI-compatible&lt;/td>
&lt;td>OpenAI-compatible&lt;/td>
&lt;td>native vLLM&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>&lt;strong>Main load mode&lt;/strong>&lt;/td>
&lt;td>fixed concurrency N (recommended) or constant request-rate&lt;/td>
&lt;td>synchronous, concurrent, poisson, throughput, &lt;strong>automatic sweep&lt;/strong>&lt;/td>
&lt;td>batches of N concurrent (with a draining period at the end)&lt;/td>
&lt;td>request-rate, concurrency&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>&lt;strong>Draining period&lt;/strong>&lt;/td>
&lt;td>NO — guarantees N active at all times&lt;/td>
&lt;td>NO (poisson)&lt;/td>
&lt;td>YES — at the end of each batch the system empties, concurrency drops to 0&lt;/td>
&lt;td>N/A&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>&lt;strong>Automatic sweep&lt;/strong>&lt;/td>
&lt;td>&lt;code>analyze&lt;/code> (concurrency, request-rate, ISL, OSL)&lt;/td>
&lt;td>&lt;code>--rate-type sweep&lt;/code> (idle to saturation, 10 rounds)&lt;/td>
&lt;td>manual (several runs)&lt;/td>
&lt;td>&lt;code>vllm bench sweep serve&lt;/code>&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>&lt;strong>LLM metrics&lt;/strong>&lt;/td>
&lt;td>TTFT, ITL, e2e latency, OTT, RPS, ISL/OSL&lt;/td>
&lt;td>TTFT, ITL/TPOT, throughput, goodput under SLO&lt;/td>
&lt;td>TTFT, ITL (includes TTFT in the mean), TPS (total benchmark duration)&lt;/td>
&lt;td>TTFT, TPOT, throughput&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>&lt;strong>ITL difference&lt;/strong>&lt;/td>
&lt;td>excludes TTFT: \((e2e - TTFT) / (N_{tok}-1)\)&lt;/td>
&lt;td>excludes TTFT (same as GenAI-Perf)&lt;/td>
&lt;td>includes TTFT in the mean&lt;/td>
&lt;td>excludes TTFT&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>&lt;strong>TPS difference&lt;/strong>&lt;/td>
&lt;td>\(total\_tokens / (T_y - T_x)\)&lt;/td>
&lt;td>throughput + goodput under SLO&lt;/td>
&lt;td>\(total\_tokens / (T_{end} - T_{start})\) — up to 33% lower&lt;/td>
&lt;td>\(total\_tokens / (T_y - T_x)\)&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>&lt;strong>Goodput / SLO&lt;/strong>&lt;/td>
&lt;td>NO&lt;/td>
&lt;td>YES (the key differentiator)&lt;/td>
&lt;td>NO&lt;/td>
&lt;td>NO&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>&lt;strong>Exports&lt;/strong>&lt;/td>
&lt;td>JSON + CSV + PNG + checkpoint&lt;/td>
&lt;td>JSON + YAML + CSV + interactive HTML&lt;/td>
&lt;td>JSON&lt;/td>
&lt;td>console / JSON&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>&lt;strong>Built-in warm-up&lt;/strong>&lt;/td>
&lt;td>YES (&lt;code>--warmup-request-count&lt;/code>)&lt;/td>
&lt;td>YES (warm-up requests)&lt;/td>
&lt;td>NOT native&lt;/td>
&lt;td>YES&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>&lt;strong>Real datasets&lt;/strong>&lt;/td>
&lt;td>OpenOrca, CNN DailyMail, JSONL file, moon_cake&lt;/td>
&lt;td>file with a traffic distribution&lt;/td>
&lt;td>JSONL file&lt;/td>
&lt;td>sharegpt, random&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>&lt;strong>GPU telemetry&lt;/strong>&lt;/td>
&lt;td>YES (via Triton&amp;rsquo;s server-metrics-urls → summary CSV)&lt;/td>
&lt;td>NOT native (integrate DCGM separately)&lt;/td>
&lt;td>NO&lt;/td>
&lt;td>NO&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>&lt;strong>When to choose it&lt;/strong>&lt;/td>
&lt;td>fine profiling of one operating point, comparing configs with the same harness, integration with NIM/Triton&lt;/td>
&lt;td>SLO-driven sweep, finding the knee, sizing replicas&lt;/td>
&lt;td>quick endpoint validation in the Ray ecosystem&lt;/td>
&lt;td>iterating on vLLM flags&lt;/td>
&lt;/tr>
&lt;/tbody>
&lt;/table>
&lt;p>&lt;strong>Fair-comparison rule:&lt;/strong> never cross numbers from different tools for the same conclusion. If
vLLM and TRT-LLM are being compared, use GenAI-Perf against both, with the same ISL/OSL, the
same concurrency and the same &lt;code>--warmup-request-count&lt;/code>. The tool is the constant; the engine is
the variable.&lt;/p>
&lt;hr>
&lt;h2 id="profiling-on-4h100-sxm-80-gb-a-full-sweep-example">Profiling on 4×H100 SXM 80 GB: a full sweep example&lt;/h2>
&lt;p>Generic reference hardware: 4×H100 SXM 80 GB NVLink, model Llama-3.1-70B-Instruct FP16,
ISL=1024 tokens, OSL=256 tokens, vLLM endpoint.&lt;/p>
&lt;h3 id="illustrative-result-of-a-concurrency-sweep-164">Illustrative result of a concurrency sweep (1→64)&lt;/h3>
&lt;table>
&lt;thead>
&lt;tr>
&lt;th>Concurrency&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>ITL P99 (ms)&lt;/th>
&lt;th>OTT (tok/s)&lt;/th>
&lt;th>RPS&lt;/th>
&lt;/tr>
&lt;/thead>
&lt;tbody>
&lt;tr>
&lt;td>1&lt;/td>
&lt;td>38&lt;/td>
&lt;td>45&lt;/td>
&lt;td>6.4&lt;/td>
&lt;td>7.1&lt;/td>
&lt;td>130&lt;/td>
&lt;td>0.51&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>4&lt;/td>
&lt;td>40&lt;/td>
&lt;td>51&lt;/td>
&lt;td>6.8&lt;/td>
&lt;td>8.2&lt;/td>
&lt;td>510&lt;/td>
&lt;td>2.00&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>8&lt;/td>
&lt;td>46&lt;/td>
&lt;td>62&lt;/td>
&lt;td>7.3&lt;/td>
&lt;td>9.4&lt;/td>
&lt;td>980&lt;/td>
&lt;td>3.83&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>16&lt;/td>
&lt;td>68&lt;/td>
&lt;td>98&lt;/td>
&lt;td>8.9&lt;/td>
&lt;td>12.1&lt;/td>
&lt;td>1,720&lt;/td>
&lt;td>6.72&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>32&lt;/td>
&lt;td>145&lt;/td>
&lt;td>284&lt;/td>
&lt;td>13.7&lt;/td>
&lt;td>21.4&lt;/td>
&lt;td>2,130&lt;/td>
&lt;td>8.32&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>48&lt;/td>
&lt;td>380&lt;/td>
&lt;td>740&lt;/td>
&lt;td>22.3&lt;/td>
&lt;td>38.6&lt;/td>
&lt;td>2,280&lt;/td>
&lt;td>8.90&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>64&lt;/td>
&lt;td>890&lt;/td>
&lt;td>1,820&lt;/td>
&lt;td>41.2&lt;/td>
&lt;td>78.3&lt;/td>
&lt;td>2,340&lt;/td>
&lt;td>9.14&lt;/td>
&lt;/tr>
&lt;/tbody>
&lt;/table>
&lt;p>How to read it: throughput saturates around concurrency 48–64, while TTFT P99 already exceeds
500 ms from concurrency 32 onwards. The &lt;strong>knee&lt;/strong> (the last point with TTFT P99 &amp;lt; 500 ms) is at
concurrency 16 with 1,720 useful tok/s. Anyone reporting &amp;ldquo;2,340 tok/s&amp;rdquo; (concurrency 64) is
describing the maximum throughput, at a point where the system meets no interactive chat SLO
at all.&lt;/p>
&lt;h3 id="formulas-for-the-key-metrics">Formulas for the key metrics&lt;/h3>
$$
\text{ITL} = \frac{e2e\_latency - TTFT}{N_{output\_tokens} - 1}
$$
$$
\text{OTT} = \frac{\sum output\_tokens}{T_y - T_x}
$$
$$
\text{RPS} = \frac{N_{requests}}{T_y - T_x}
$$
&lt;p>where \(T_x\) is the timestamp of the first request sent and \(T_y\) the timestamp of the last
token received from the last request.&lt;/p>
&lt;hr>
&lt;h2 id="synthetic-islosl-profiling-what-to-configure-for-each-use-case">Synthetic ISL/OSL profiling: what to configure for each use case&lt;/h2>
&lt;p>The ISL/OSL parameters determine which engine of the system is stressed: a long ISL stresses
prefill (KV-cache, TTFT); a long OSL stresses decode (ITL, memory bandwidth).&lt;/p>
&lt;table>
&lt;thead>
&lt;tr>
&lt;th>Use case&lt;/th>
&lt;th>Typical ISL&lt;/th>
&lt;th>Typical OSL&lt;/th>
&lt;th>&lt;code>--synthetic-input-tokens-mean&lt;/code>&lt;/th>
&lt;th>&lt;code>--output-tokens-mean&lt;/code>&lt;/th>
&lt;/tr>
&lt;/thead>
&lt;tbody>
&lt;tr>
&lt;td>Short interactive chat&lt;/td>
&lt;td>~300 tok&lt;/td>
&lt;td>~100 tok&lt;/td>
&lt;td>300&lt;/td>
&lt;td>100&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>Code copilot&lt;/td>
&lt;td>~800 tok&lt;/td>
&lt;td>~200 tok&lt;/td>
&lt;td>800&lt;/td>
&lt;td>200&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>Document summarisation&lt;/td>
&lt;td>~2000 tok&lt;/td>
&lt;td>~256 tok&lt;/td>
&lt;td>2000&lt;/td>
&lt;td>256&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>RAG with long context&lt;/td>
&lt;td>~4096 tok&lt;/td>
&lt;td>~512 tok&lt;/td>
&lt;td>4096&lt;/td>
&lt;td>512&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>Batch without latency&lt;/td>
&lt;td>~512 tok&lt;/td>
&lt;td>~1024 tok&lt;/td>
&lt;td>512&lt;/td>
&lt;td>1024&lt;/td>
&lt;/tr>
&lt;/tbody>
&lt;/table>
&lt;p>The &lt;code>--extra-inputs ignore_eos:true&lt;/code> option disables the engine&amp;rsquo;s EOS token, forcing exactly
the requested tokens to be generated. Essential for measuring a consistent OSL on TRT-LLM;
optional on vLLM.&lt;/p>
&lt;hr>
&lt;h2 id="honest-methodology-how-not-to-measure-badly">Honest methodology: how not to measure badly&lt;/h2>
&lt;h3 id="1-warm-up-is-mandatory">1. Warm-up is mandatory&lt;/h3>
&lt;p>Without warm-up, the first requests include the KV-cache loading latency, the engine&amp;rsquo;s
autotuning and the initialisations. GenAI-Perf applies a sliding window to detect stability
(max/min ratio within a margin over the last 3 measurements), but the explicit
&lt;code>--warmup-request-count&lt;/code> discards the first N requests from the metrics. Recommended minimum
value: 10 requests (or 5% of the total if the total is large).&lt;/p>
&lt;h3 id="2-the-concurrency-knee-extend-the-sweep-until-you-see-it">2. The concurrency knee: extend the sweep until you see it&lt;/h3>
&lt;p>Throughput saturates once the engine&amp;rsquo;s effective max batch size is reached. If the sweep stops
before saturation is reached, the &amp;ldquo;maximum capacity&amp;rdquo; reported is the last concurrency tested,
not the real knee. The rule: the sweep must reach a point where TTFT P99 has doubled relative
to the low-load value (or where OTT stops growing by more than 5% between steps). Only then is
the knee visible.&lt;/p>
&lt;p>For a sweep with &lt;code>--sweep-range 1:256&lt;/code>, GenAI-Perf sweeps 1, 2, 4, 8, 16, 32, 64, 128, 256
(powers of 2). On most 70B models over 4×H100, saturation occurs between concurrency 32 and
64; the range 1:128 covers the knee with room to spare.&lt;/p>
&lt;h3 id="3-concurrency-versus-request-rate-choosing-correctly">3. Concurrency versus request-rate: choosing correctly&lt;/h3>
&lt;table>
&lt;thead>
&lt;tr>
&lt;th>Mode&lt;/th>
&lt;th>Behaviour&lt;/th>
&lt;th>When to use&lt;/th>
&lt;/tr>
&lt;/thead>
&lt;tbody>
&lt;tr>
&lt;td>&lt;code>--concurrency N&lt;/code>&lt;/td>
&lt;td>Always N active requests; when one finishes, the next is sent&lt;/td>
&lt;td>Clean latency-throughput curve; comparison between configs&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>&lt;code>--request-rate R&lt;/code>&lt;/td>
&lt;td>1 request is sent every 1/R seconds; the queue can grow without bound&lt;/td>
&lt;td>Simulating traffic with arrivals at a constant rate&lt;/td>
&lt;/tr>
&lt;/tbody>
&lt;/table>
&lt;p>NVIDIA recommends &lt;code>--concurrency&lt;/code> for benchmarking. With &lt;code>--request-rate&lt;/code>, if R exceeds the
engine&amp;rsquo;s maximum throughput, the queue grows indefinitely and the metrics become unstable.&lt;/p>
&lt;h3 id="4-single-process-versus-multi-process-bias">4. Single-process versus multi-process bias&lt;/h3>
&lt;p>GenAI-Perf is multi-process: it maintains N concurrent requests using perf_analyzer&amp;rsquo;s internal
manager. There is no risk of client saturation. This sets it apart from &lt;code>vllm bench serve&lt;/code>
(single-process), which can saturate on the client at high concurrency and give inflated
throughput metrics.&lt;/p>
&lt;h3 id="5-ignore_eos-and-an-inconsistent-osl">5. &lt;code>ignore_eos&lt;/code> and an inconsistent OSL&lt;/h3>
&lt;p>Without &lt;code>--extra-inputs ignore_eos:true&lt;/code>, the engine may generate fewer tokens than requested
(if the model produces an EOS earlier). This makes the real OSL vary between requests and the
ITL become inconsistent between runs. For reproducible benchmarks with a controlled OSL: always
enable &lt;code>ignore_eos:true&lt;/code>.&lt;/p>
&lt;h3 id="6-the-correct-tokenizer">6. The correct tokenizer&lt;/h3>
&lt;p>The &lt;code>--tokenizer&lt;/code> flag must point to the model being served. If it is omitted, GenAI-Perf uses
a default tokenizer that can give different ISL/OSL in the real model&amp;rsquo;s tokens, making the
throughput numbers in tok/s not comparable between models with different vocabularies.&lt;/p>
&lt;h3 id="7-one-run--a-data-point-three-or-more--a-number">7. One run = a data point; three or more = a number&lt;/h3>
&lt;p>Variance between runs under identical conditions can be 5–10% on tail latency metrics (P99). A
single profile is not a defensible number; the mean of three runs with the same config is.&lt;/p>
&lt;div class="diagram" style="max-width:780px;margin:1rem auto;">
&lt;svg viewBox="0 0 780 170" role="img" aria-label="Flow of a reproducible benchmark with GenAI-Perf: deploy engine, warm up, concurrency sweep beyond the knee, collect JSON, compare" xmlns="http://www.w3.org/2000/svg">
&lt;style>.bx{fill:none;stroke:currentColor;stroke-width:1.3}.tl{font:600 12px sans-serif;fill:currentColor}.ts{font:11px sans-serif;fill:currentColor}.ar{fill:none;stroke:currentColor;stroke-width:1.3;marker-end:url(#bm)}&lt;/style>
&lt;defs>&lt;marker id="bm" viewBox="0 0 10 10" refX="9" refY="5" markerWidth="7" markerHeight="7" orient="auto">&lt;path d="M0,0 L10,5 L0,10 z" fill="currentColor"/>&lt;/marker>&lt;/defs>
&lt;rect class="bx" x="20" y="50" width="120" height="44" rx="6"/>&lt;text x="80" y="69" text-anchor="middle" class="tl">1 · Engine&lt;/text>&lt;text x="80" y="85" text-anchor="middle" class="ts">pinned config&lt;/text>
&lt;path class="ar" d="M140,72 L163,72"/>
&lt;rect class="bx" x="163" y="50" width="120" height="44" rx="6"/>&lt;text x="223" y="69" text-anchor="middle" class="tl">2 · Warm-up&lt;/text>&lt;text x="223" y="85" text-anchor="middle" class="ts">≥10 requests&lt;/text>
&lt;path class="ar" d="M283,72 L306,72"/>
&lt;rect class="bx" x="306" y="50" width="130" height="44" rx="6"/>&lt;text x="371" y="69" text-anchor="middle" class="tl">3 · Sweep&lt;/text>&lt;text x="371" y="85" text-anchor="middle" class="ts">1:256, past the knee&lt;/text>
&lt;path class="ar" d="M436,72 L459,72"/>
&lt;rect class="bx" x="459" y="50" width="130" height="44" rx="6"/>&lt;text x="524" y="69" text-anchor="middle" class="tl">4 · Artefacts&lt;/text>&lt;text x="524" y="85" text-anchor="middle" class="ts">versioned JSON + CSV&lt;/text>
&lt;path class="ar" d="M589,72 L612,72"/>
&lt;rect class="bx" x="612" y="50" width="130" height="44" rx="6"/>&lt;text x="677" y="69" text-anchor="middle" class="tl">5 · Compare&lt;/text>&lt;text x="677" y="85" text-anchor="middle" class="ts">P99 at the knee&lt;/text>
&lt;text x="20" y="128" class="ts">Pin: genai-perf version, engine version, model, precision, ISL/OSL, hardware, tokenizer.&lt;/text>
&lt;text x="20" y="145" class="ts">Without that metadata in the JSON, the number is neither reproducible nor comparable.&lt;/text>
&lt;/svg>
&lt;/div>
&lt;hr>
&lt;h2 id="connection-with-the-cost-and-energy-track">Connection with the cost and energy track&lt;/h2>
&lt;p>GenAI-Perf can capture GPU metrics in the same summary CSV if
&lt;code>--server-metrics-urls http://localhost:8002/metrics&lt;/code> is passed (Triton&amp;rsquo;s metrics endpoint,
which exposes power and utilisation via DCGM). The &lt;code>analyze&lt;/code> summary CSV then includes a second
table with P99 power (W), energy (MJ), utilisation (%) and memory (GB) per GPU and per
scenario.&lt;/p>
&lt;p>With that data, a single sweep yields the three axes of the scorecard:&lt;/p>
&lt;table>
&lt;thead>
&lt;tr>
&lt;th>Axis&lt;/th>
&lt;th>Source&lt;/th>
&lt;th>Metric&lt;/th>
&lt;/tr>
&lt;/thead>
&lt;tbody>
&lt;tr>
&lt;td>Performance&lt;/td>
&lt;td>GenAI-Perf&lt;/td>
&lt;td>OTT (tok/s), TTFT P99, ITL P99&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>Energy&lt;/td>
&lt;td>DCGM via server-metrics-urls&lt;/td>
&lt;td>Power (W) → \(J/token = W / OTT\)&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>Cost&lt;/td>
&lt;td>node price&lt;/td>
&lt;td>EUR/hour → \(EUR/token = EUR_{hour} / (OTT \times 3600)\)&lt;/td>
&lt;/tr>
&lt;/tbody>
&lt;/table>
&lt;p>The formula for cost per million tokens:&lt;/p>
$$
CPM = \frac{EUR_{hour}}{OTT \times 3{.}6 \times 10^3}
$$
&lt;p>where \(OTT\) is the output token throughput in tok/s and \(CPM\) is the cost per million
tokens in EUR.&lt;/p>
&lt;p>It connects with &lt;a href="https://blog.lo0.es/en/posts/gpu-observability-llm-inference-twelve-dcgm-vllm-metrics/">GPU observability with DCGM&lt;/a> for
the power capture, and with &lt;a href="https://blog.lo0.es/en/posts/anatomy-llm-request-production-tour-six-stages/">anatomy of an LLM request&lt;/a>
for the breakdown of phases (prefill versus decode) that TTFT and ITL quantify.&lt;/p>
&lt;hr>
&lt;h2 id="status-2026-genai-perf--aiperf">Status 2026: GenAI-Perf → AIPerf&lt;/h2>
&lt;p>NVIDIA announced in April 2026 that GenAI-Perf moves to passive maintenance mode (no new
features) and that the active successor is &lt;strong>AIPerf&lt;/strong> (&lt;code>github.com/ai-dynamo/aiperf&lt;/code>). AIPerf
adds automatic detection of the saturation point (&lt;code>estimatedCapacity&lt;/code>) and is integrated into
the NVIDIA Dynamo pipeline.&lt;/p>
&lt;p>For new benchmarks in production: migrate to AIPerf. To reproduce historical runs documented
with GenAI-Perf, or to use NVIDIA&amp;rsquo;s NIM benchmarking artefacts (which use GenAI-Perf): stay on
GenAI-Perf 25.x.&lt;/p>
&lt;p>The article &lt;a href="https://blog.lo0.es/en/posts/llm-benchmark-tool-catalogue-practical-deep-dive/">LLM benchmark tools, one by one&lt;/a>
covers the full comparison including AIPerf. The article
&lt;a href="https://blog.lo0.es/en/posts/guidellm-validating-slo-under-load-sizing-knee/">GuideLLM: SLO validation under load&lt;/a> details
the SLO-driven sweep that GenAI-Perf does not do natively. For the general methodology context:
&lt;a href="https://blog.lo0.es/en/posts/llm-inference-benchmarking-frameworks-metrics-state-of-the-art/">LLM benchmarking frameworks, state of the art&lt;/a>.&lt;/p>
&lt;hr>
&lt;h2 id="see-also">See also&lt;/h2>
&lt;ul>
&lt;li>&lt;a href="https://blog.lo0.es/en/posts/measurement-bias-reproducibility-llm-benchmarks/">Measurement bias and reproducibility&lt;/a> — the configuration traps that invalidate a GenAI-Perf sweep even when the commands are correct: insufficient warm-up, synthetic versus real dataset, shared environment.&lt;/li>
&lt;li>&lt;a href="https://blog.lo0.es/en/posts/llm-serving-engines-pareto-frontier-comparison/">LLM serving engines compared (vLLM/SGLang/TRT-LLM/Dynamo)&lt;/a> — where the numbers measured with GenAI-Perf land: the goodput-latency Pareto frontier across the four main engines.&lt;/li>
&lt;/ul>
&lt;h2 id="sources">Sources&lt;/h2>
&lt;ul>
&lt;li>NVIDIA · GenAI-Perf — README oficial — &lt;a href="https://docs.nvidia.com/deeplearning/triton-inference-server/user-guide/docs/perf_analyzer/genai-perf/README.html">https://docs.nvidia.com/deeplearning/triton-inference-server/user-guide/docs/perf_analyzer/genai-perf/README.html&lt;/a>&lt;/li>
&lt;li>NVIDIA · Tutorial LLM con GenAI-Perf (comandos y tablas de salida reales) — &lt;a href="https://docs.nvidia.com/deeplearning/triton-inference-server/user-guide/docs/perf_analyzer/genai-perf/docs/tutorial.html">https://docs.nvidia.com/deeplearning/triton-inference-server/user-guide/docs/perf_analyzer/genai-perf/docs/tutorial.html&lt;/a>&lt;/li>
&lt;li>NVIDIA · GenAI-Perf Analyze subcommand (sweep, CSV resumen, checkpoint) — &lt;a href="https://docs.nvidia.com/deeplearning/triton-inference-server/user-guide/docs/perf_analyzer/genai-perf/docs/analyze.html">https://docs.nvidia.com/deeplearning/triton-inference-server/user-guide/docs/perf_analyzer/genai-perf/docs/analyze.html&lt;/a>&lt;/li>
&lt;li>NVIDIA · NIM LLM Benchmarking — Métricas (definiciones TTFT, ITL, TPS, RPS, fórmulas exactas) — &lt;a href="https://docs.nvidia.com/nim/benchmarking/llm/latest/metrics.html">https://docs.nvidia.com/nim/benchmarking/llm/latest/metrics.html&lt;/a>&lt;/li>
&lt;li>NVIDIA · NIM LLM Benchmarking — Parámetros y buenas prácticas (ISL/OSL, concurrencia vs request-rate, ignore_eos) — &lt;a href="https://docs.nvidia.com/nim/benchmarking/llm/latest/parameters.html">https://docs.nvidia.com/nim/benchmarking/llm/latest/parameters.html&lt;/a>&lt;/li>
&lt;li>NVIDIA Technical Blog · Measuring Generative AI Model Performance Using NVIDIA GenAI-Perf and an OpenAI-Compatible API — &lt;a href="https://developer.nvidia.com/blog/measuring-generative-ai-model-performance-using-nvidia-genai-perf-and-an-openai-compatible-api/">https://developer.nvidia.com/blog/measuring-generative-ai-model-performance-using-nvidia-genai-perf-and-an-openai-compatible-api/&lt;/a>&lt;/li>
&lt;li>GitHub · triton-inference-server/perf_analyzer (genai-perf) — &lt;a href="https://github.com/triton-inference-server/perf_analyzer/blob/main/genai-perf/README.md">https://github.com/triton-inference-server/perf_analyzer/blob/main/genai-perf/README.md&lt;/a>&lt;/li>
&lt;li>GitHub · ray-project/llmperf (métricas y diferencias metodológicas) — &lt;a href="https://github.com/ray-project/llmperf">https://github.com/ray-project/llmperf&lt;/a>&lt;/li>
&lt;li>PyPI · nvidia-genai-perf-eval — &lt;a href="https://pypi.org/project/nvidia-genai-perf-eval/">https://pypi.org/project/nvidia-genai-perf-eval/&lt;/a>&lt;/li>
&lt;li>Macnica · Benchmarking LLM Applications Part 1: What is GenAI-Perf? — &lt;a href="https://www.macnica.co.jp/en/business/semiconductor/articles/nvidia/145977/">https://www.macnica.co.jp/en/business/semiconductor/articles/nvidia/145977/&lt;/a>&lt;/li>
&lt;/ul></description></item><item><title>GuideLLM in depth: validating the SLO under load and sizing from the knee</title><link>https://blog.lo0.es/en/posts/guidellm-validating-slo-under-load-sizing-knee/</link><pubDate>Sun, 14 Jun 2026 04:30:00 +0200</pubDate><guid>https://blog.lo0.es/en/posts/guidellm-validating-slo-under-load-sizing-knee/</guid><description>&lt;blockquote>
&lt;p>Notation: amounts in &lt;strong>euros (N €)&lt;/strong>, decimals with a point. The dollar sign is not used
(on this site it is a formula delimiter).&lt;/p>
&lt;/blockquote>
&lt;h2 id="what-this-article-covers">What this article covers&lt;/h2>
&lt;p>Third article of the &lt;strong>benchmarking&lt;/strong> track (B3). B2 covered the tool catalogue; here we go into
&lt;strong>GuideLLM&lt;/strong> in depth, because it answers the operational question that sizes a platform: &lt;strong>how far
can I load this engine without breaking the SLO?&lt;/strong> It is not a catalogue benchmark (&amp;ldquo;X tokens/s&amp;rdquo;), it
is an &lt;strong>SLO-driven sweep&lt;/strong> that finds the &lt;strong>knee&lt;/strong>, the safe capacity, and from which the real replica
count and cost per token come out. We will look at its load modes, how the SLO is defined, how to read
the output and how to turn the knee into sizing and into euros. With real commands; no recommendations,
only the mechanics.&lt;/p>
&lt;hr>
&lt;h2 id="why-guidellm">Why GuideLLM&lt;/h2>
&lt;p>GuideLLM (a vLLM project) generates &lt;strong>realistic, configurable traffic patterns&lt;/strong> and captures &lt;strong>full
distributions&lt;/strong> of TTFT, ITL and end-to-end behaviour, for &lt;strong>SLO-driven&lt;/strong> evaluation, with reproducible
sweeps that identify the safe operating range
(&lt;a href="https://developers.redhat.com/articles/2025/06/20/guidellm-evaluate-llm-deployments-real-world-inference">Red Hat&lt;/a>).
Unlike a micro-benchmark, it &lt;strong>measures the engine, not the client&lt;/strong> (multi-process load generation), and
unlike MLPerf, it &lt;strong>sizes your specific case&lt;/strong> (your model, your SLO, your load). It is the middle tool:
not for tuning a flag, not for comparing vendors, but for &lt;strong>deciding how many GPUs you need&lt;/strong>.&lt;/p>
&lt;hr>
&lt;h2 id="the-load-modes-rate-types">The load modes (rate-types)&lt;/h2>
&lt;p>GuideLLM offers several modes through &lt;code>--rate-type&lt;/code>, and picking the right one is half the work:&lt;/p>
&lt;table>
&lt;thead>
&lt;tr>
&lt;th>&lt;code>--rate-type&lt;/code>&lt;/th>
&lt;th>What it does&lt;/th>
&lt;th>When&lt;/th>
&lt;/tr>
&lt;/thead>
&lt;tbody>
&lt;tr>
&lt;td>&lt;strong>synchronous&lt;/strong>&lt;/td>
&lt;td>one request at a time&lt;/td>
&lt;td>baseline latency, no concurrency&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>&lt;strong>concurrent&lt;/strong>&lt;/td>
&lt;td>keeps N fixed simultaneous requests&lt;/td>
&lt;td>measuring at a specific concurrency&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>&lt;strong>poisson&lt;/strong>&lt;/td>
&lt;td>requests per second following Poisson&lt;/td>
&lt;td>simulating real traffic (random arrivals)&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>&lt;strong>throughput&lt;/strong>&lt;/td>
&lt;td>saturates the engine to the maximum&lt;/td>
&lt;td>raw peak capacity&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>&lt;strong>sweep&lt;/strong>&lt;/td>
&lt;td>automatic ramp from idle to maximum&lt;/td>
&lt;td>&lt;strong>finding the knee&lt;/strong> (the most useful one)&lt;/td>
&lt;/tr>
&lt;/tbody>
&lt;/table>
&lt;p>In &lt;code>concurrent&lt;/code> mode, &lt;code>--rate &amp;quot;1,2,4&amp;quot;&lt;/code> launches three benchmarks at those concurrencies
(&lt;a href="https://medium.com/@jajodia.nirjhar/exploring-guidellm-benchmarking-a-live-llm-on-openshift-ccc2d0841794">Medium · GuideLLM on OpenShift&lt;/a>).
The &lt;strong>poisson&lt;/strong> mode is the most realistic for online inference (requests do not arrive at regular
intervals), and the &lt;strong>sweep&lt;/strong> is the one that automates the search for the saturation point.&lt;/p>
&lt;hr>
&lt;h2 id="the-automatic-sweep-from-idle-to-the-knee">The automatic sweep: from idle to the knee&lt;/h2>
&lt;p>The flagship mode. When running a &lt;strong>sweep&lt;/strong> profile, GuideLLM &lt;strong>automatically increases the request rate
from idle to maximum throughput across 10 rounds&lt;/strong>, and produces an &lt;strong>interactive HTML report&lt;/strong> with
detailed latency and throughput (&lt;a href="https://developers.redhat.com/articles/2025/12/24/how-deploy-and-benchmark-vllm-guidellm-kubernetes">Red Hat · GuideLLM on Kubernetes&lt;/a>).
During the sweep, &lt;strong>on detecting saturation it identifies the previous iteration, the knee, and returns
it as estimated capacity&lt;/strong>; if it does not detect saturation, the sweep has to be &lt;strong>extended beyond the
knee&lt;/strong>.&lt;/p>
&lt;div class="diagram" style="max-width:780px;margin:1rem auto;">
&lt;svg viewBox="0 0 780 250" role="img" aria-label="The GuideLLM sweep: 10 rounds from idle to maximum, throughput saturates and latency blows up, marking the knee as the safe capacity under SLO" 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.6}.dsh{fill:none;stroke:currentColor;stroke-width:1;stroke-dasharray:4 3}.tl{font:600 12px sans-serif;fill:currentColor}.ts{font:11px sans-serif;fill:currentColor}&lt;/style>
&lt;line class="ax" x1="60" y1="40" x2="60" y2="200"/>
&lt;line class="ax" x1="60" y1="200" x2="720" y2="200"/>
&lt;text x="20" y="120" class="ts" transform="rotate(-90 20 120)">metric&lt;/text>
&lt;text x="330" y="228" class="ts">sweep rounds (idle → maximum) →&lt;/text>
&lt;path class="cv" d="M60,190 C200,150 300,118 410,110 C520,104 620,100 700,98"/>
&lt;text x="600" y="92" class="ts">throughput (saturates)&lt;/text>
&lt;path class="cv" d="M60,182 C260,178 360,168 430,148 C520,116 600,66 700,44"/>
&lt;text x="600" y="40" class="ts">P99 latency (blows up)&lt;/text>
&lt;line class="dsh" x1="430" y1="40" x2="430" y2="200"/>
&lt;text x="392" y="56" class="tl">knee&lt;/text>
&lt;text x="345" y="216" class="ts">estimated capacity (safe under SLO)&lt;/text>
&lt;text x="60" y="245" class="ts">The sweep runs 10 rounds; the knee is the last round before P99 breaks the SLO. It must be extended PAST the knee to see it.&lt;/text>
&lt;/svg>
&lt;/div>
&lt;hr>
&lt;h2 id="a-worked-example-reading-a-10-round-sweep">A worked example: reading a 10-round sweep&lt;/h2>
&lt;p>An illustrative output from a sweep on a 70B on 8×H100 (SLO: TTFT P99 &amp;lt; 500 ms), per round:&lt;/p>
&lt;table>
&lt;thead>
&lt;tr>
&lt;th>Round&lt;/th>
&lt;th>Rate (req/s)&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>1&lt;/td>
&lt;td>2&lt;/td>
&lt;td>95&lt;/td>
&lt;td>19&lt;/td>
&lt;td>480&lt;/td>
&lt;td>480&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>3&lt;/td>
&lt;td>8&lt;/td>
&lt;td>140&lt;/td>
&lt;td>20&lt;/td>
&lt;td>1,900&lt;/td>
&lt;td>1,900&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>5&lt;/td>
&lt;td>14&lt;/td>
&lt;td>240&lt;/td>
&lt;td>22&lt;/td>
&lt;td>3,100&lt;/td>
&lt;td>3,060&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>6&lt;/td>
&lt;td>17&lt;/td>
&lt;td>460&lt;/td>
&lt;td>24&lt;/td>
&lt;td>3,400&lt;/td>
&lt;td>3,330&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>7&lt;/td>
&lt;td>20&lt;/td>
&lt;td>760&lt;/td>
&lt;td>31&lt;/td>
&lt;td>3,700&lt;/td>
&lt;td>2,520&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>9&lt;/td>
&lt;td>26&lt;/td>
&lt;td>1,500&lt;/td>
&lt;td>48&lt;/td>
&lt;td>3,950&lt;/td>
&lt;td>900&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>10&lt;/td>
&lt;td>30&lt;/td>
&lt;td>2,400&lt;/td>
&lt;td>71&lt;/td>
&lt;td>4,000&lt;/td>
&lt;td>380&lt;/td>
&lt;/tr>
&lt;/tbody>
&lt;/table>
&lt;p>How to read it: up to &lt;strong>round 6&lt;/strong> (17 req/s) the TTFT P99 meets the SLO (460 ms) and goodput ≈ throughput
(3,330 ≈ 3,400). In &lt;strong>round 7&lt;/strong> P99 already breaks (760 ms) and goodput &lt;strong>drops&lt;/strong> to 2,520. The &lt;strong>knee&lt;/strong>
sits between 6 and 7: the safe capacity is that of round 6, &lt;strong>~3,330 tok/s of goodput&lt;/strong>. Rounds 9 and 10
give more raw throughput (3,950, 4,000) but with goodput of 900 and 380; the system is &amp;ldquo;performing&amp;rdquo; by
mostly serving requests that miss the target. Whoever reports &amp;ldquo;4,000 tok/s&amp;rdquo; is describing round 10, where
the system is broken. The defensible number is &lt;strong>3,330 tok/s under TTFT P99 &amp;lt; 500 ms&lt;/strong>, and that is the
one that goes into the sizing. Note as well that the sweep had to reach round 10 (P99 of 2,400 ms) to
&lt;strong>see&lt;/strong> where it broke: that is why it has to be extended beyond the knee.&lt;/p>
&lt;hr>
&lt;h2 id="defining-the-slo-the-number-that-decides-the-knee">Defining the SLO: the number that decides the knee&lt;/h2>
&lt;p>The &amp;ldquo;knee&amp;rdquo; is not absolute: it depends on the &lt;strong>SLO&lt;/strong> you define. A typical online inference SLO:&lt;/p>
&lt;table>
&lt;thead>
&lt;tr>
&lt;th>Metric&lt;/th>
&lt;th>Example threshold&lt;/th>
&lt;th>What it protects&lt;/th>
&lt;/tr>
&lt;/thead>
&lt;tbody>
&lt;tr>
&lt;td>&lt;strong>TTFT P99&lt;/strong>&lt;/td>
&lt;td>&amp;lt; 500 ms&lt;/td>
&lt;td>the wait for the first token (interactivity)&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>&lt;strong>TPOT / ITL P95&lt;/strong>&lt;/td>
&lt;td>&amp;lt; 50 ms/token&lt;/td>
&lt;td>the perceived &amp;ldquo;typing speed&amp;rdquo;&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>&lt;strong>Error rate&lt;/strong>&lt;/td>
&lt;td>&amp;lt; 0.1 %&lt;/td>
&lt;td>reliability&lt;/td>
&lt;/tr>
&lt;/tbody>
&lt;/table>
&lt;p>&lt;strong>Goodput&lt;/strong>, the throughput that meets that SLO, is the metric that defines the knee: the safe capacity
is the maximum load where goodput ≈ throughput. Changing the SLO moves the knee: a stricter TTFT SLO
(200 ms) gives an earlier knee (less safe capacity) than a loose one (1 s). That is why &lt;strong>the SLO is
fixed before the sweep&lt;/strong>, and reported alongside the result: a capacity &amp;ldquo;of 3,330 tok/s&amp;rdquo; without saying
under which SLO means nothing.&lt;/p>
&lt;hr>
&lt;h2 id="execution-the-command">Execution: the command&lt;/h2>
&lt;p>An SLO-driven sweep against a vLLM endpoint:&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">guidellm benchmark &lt;span class="se">\
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="se">&lt;/span> --target &lt;span class="s2">&amp;#34;http://vllm:8000&amp;#34;&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> --rate-type sweep &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-seconds &lt;span class="m">120&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> --data &lt;span class="s2">&amp;#34;prompt_tokens=1024,output_tokens=256&amp;#34;&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> --output-path resultados.json
&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>Key parameters: &lt;code>--rate-type sweep&lt;/code> (the automatic ramp), &lt;code>--data&lt;/code> (the length distribution of the load,
which must resemble your real traffic), &lt;code>--max-seconds&lt;/code> (duration per round), &lt;code>--target&lt;/code> (the endpoint).
For the SLO, GuideLLM allows the latency constraints that define goodput to be set. The output goes to
&lt;code>--output-path&lt;/code> in &lt;strong>JSON, YAML or CSV&lt;/strong>, in addition to the interactive HTML report.&lt;/p>
&lt;hr>
&lt;h2 id="defining-the-load---data-decides-the-result">Defining the load: &lt;code>--data&lt;/code> decides the result&lt;/h2>
&lt;p>The &lt;code>--data&lt;/code> parameter (the distribution of prompt and output lengths) changes the knee as much as the
SLO does. Three forms, from least to most faithful:&lt;/p>
&lt;table>
&lt;thead>
&lt;tr>
&lt;th>Load&lt;/th>
&lt;th>&lt;code>--data&lt;/code>&lt;/th>
&lt;th>Fidelity&lt;/th>
&lt;/tr>
&lt;/thead>
&lt;tbody>
&lt;tr>
&lt;td>Fixed length&lt;/td>
&lt;td>&lt;code>prompt_tokens=1024,output_tokens=256&lt;/code>&lt;/td>
&lt;td>low: real traffic is not fixed&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>Synthetic distribution&lt;/td>
&lt;td>length ranges&lt;/td>
&lt;td>medium&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>Real traces&lt;/td>
&lt;td>a dataset of your traffic&lt;/td>
&lt;td>high: the knee that applies&lt;/td>
&lt;/tr>
&lt;/tbody>
&lt;/table>
&lt;p>The trap: a sweep with short fixed-length prompts gives an optimistic knee that &lt;strong>does not resemble
production&lt;/strong>, where long prompts dominate the prefill cost and bring the knee forward. To size properly,
feed GuideLLM the &lt;strong>real distribution&lt;/strong> of your traffic (prompt and output lengths measured in
production). The knee of a sweep is only as realistic as the load you put into it; with an unrealistic
&lt;code>--data&lt;/code>, you are sizing for traffic that does not exist.&lt;/p>
&lt;p>The arrival profile matters too: &lt;code>--rate-type poisson&lt;/code> simulates random arrivals (like real online
traffic), while &lt;code>concurrent&lt;/code> keeps N fixed (like a batch). An online service measured with &lt;code>concurrent&lt;/code>
can give a knee different from the real one; for interactive inference, &lt;strong>poisson&lt;/strong> is the honest profile.&lt;/p>
&lt;hr>
&lt;h2 id="the-output-what-to-read">The output: what to read&lt;/h2>
&lt;p>GuideLLM produces &lt;strong>standardised, exportable&lt;/strong> reports for dashboards, analysis and regression tracking,
in JSON, YAML and CSV (&lt;a href="https://developers.redhat.com/articles/2025/12/24/how-deploy-and-benchmark-vllm-guidellm-kubernetes">Red Hat&lt;/a>).
What matters to read, per sweep round:&lt;/p>
&lt;ul>
&lt;li>&lt;strong>TTFT&lt;/strong> (P50, P95, P99) — the latency to the first token.&lt;/li>
&lt;li>&lt;strong>ITL/TPOT&lt;/strong> (P50, P95, P99) — between tokens.&lt;/li>
&lt;li>&lt;strong>Throughput&lt;/strong> (req/s and tok/s) — the raw figure.&lt;/li>
&lt;li>&lt;strong>Goodput&lt;/strong> — the part that meets the SLO (the honest number).&lt;/li>
&lt;/ul>
&lt;p>The interactive HTML report lets you see the &lt;strong>full distributions&lt;/strong> (not just means), which is where the
latency tail that a mean hides becomes visible. For sizing and comparison, the JSON is what gets versioned
and fed into the reproducible harness.&lt;/p>
&lt;hr>
&lt;h2 id="from-the-knee-to-sizing-and-cost">From the knee to sizing and cost&lt;/h2>
&lt;p>This is where GuideLLM connects with the rest of the series. The &lt;strong>replica count&lt;/strong> and the &lt;strong>cost per
token&lt;/strong> come out of the knee:&lt;/p>
&lt;div class="diagram" style="max-width:780px;margin:1rem auto;">
&lt;svg viewBox="0 0 780 200" role="img" aria-label="From the GuideLLM knee to sizing: goodput per replica, target load, replica count and cost per token" xmlns="http://www.w3.org/2000/svg">
&lt;style>.bx{fill:none;stroke:currentColor;stroke-width:1.3}.tl{font:600 12px sans-serif;fill:currentColor}.ts{font:11px sans-serif;fill:currentColor}.ar{fill:none;stroke:currentColor;stroke-width:1.3;marker-end:url(#gm)}&lt;/style>
&lt;defs>&lt;marker id="gm" viewBox="0 0 10 10" refX="9" refY="5" markerWidth="7" markerHeight="7" orient="auto">&lt;path d="M0,0 L10,5 L0,10 z" fill="currentColor"/>&lt;/marker>&lt;/defs>
&lt;rect class="bx" x="20" y="60" width="160" height="48" rx="6"/>&lt;text x="32" y="80" class="tl">Knee (GuideLLM)&lt;/text>&lt;text x="32" y="97" class="ts">goodput per replica&lt;/text>
&lt;path class="ar" d="M180,84 L225,84"/>
&lt;rect class="bx" x="225" y="60" width="160" height="48" rx="6"/>&lt;text x="237" y="80" class="tl">Target load&lt;/text>&lt;text x="237" y="97" class="ts">peak tok/s (SLO)&lt;/text>
&lt;path class="ar" d="M385,84 L430,84"/>
&lt;rect class="bx" x="430" y="60" width="160" height="48" rx="6"/>&lt;text x="442" y="80" class="tl">Replica count&lt;/text>&lt;text x="442" y="97" class="ts">load ÷ goodput&lt;/text>
&lt;path class="ar" d="M590,84 L635,84"/>
&lt;rect class="bx" x="635" y="60" width="125" height="48" rx="6"/>&lt;text x="647" y="80" class="tl">Cost/token&lt;/text>&lt;text x="647" y="97" class="ts">€/h ÷ goodput&lt;/text>
&lt;text x="20" y="150" class="ts">The goodput at the knee (not raw throughput) is the denominator of sizing and of cost per token.&lt;/text>
&lt;text x="20" y="172" class="ts">Sizing with raw throughput overstates goodput and breaks the SLO in production.&lt;/text>
&lt;/svg>
&lt;/div>
&lt;p>The calculation: if the knee gives a &lt;strong>goodput of 3,330 tok/s per replica&lt;/strong> and your peak target load is
&lt;strong>20,000 tok/s&lt;/strong>, you need &lt;strong>6 replicas&lt;/strong> (20,000 ÷ 3,330 ≈ 6.0). And the cost per token comes from the
replica cost (from OpenCost, ~11 €/h) divided by its goodput: ~0.92 €/1M tokens. It is the bridge to
&lt;a href="https://blog.lo0.es/en/posts/capacity-planning-on-premise-llm-inference/">capacity planning&lt;/a> and to cost per token:
&lt;strong>the correct denominator is the goodput at the knee&lt;/strong>, not catalogue throughput. Sizing with raw
throughput leaves you short of useful capacity and breaks the SLO in production.&lt;/p>
&lt;hr>
&lt;h2 id="deployment-as-a-kubernetes-job">Deployment as a Kubernetes Job&lt;/h2>
&lt;p>GuideLLM runs as a &lt;strong>Kubernetes Job inside the cluster&lt;/strong> to benchmark models served by the orchestration
platform (&lt;a href="https://developers.redhat.com/articles/2025/12/24/how-deploy-and-benchmark-vllm-guidellm-kubernetes">Red Hat&lt;/a>).
This matters for fidelity: the load generator runs &lt;strong>inside the cluster&lt;/strong>, on the same network as the
endpoint, so it measures the engine without the network latency of an external client. The Job is
parameterised with the target, the rate-type and the load, and dumps the JSON to a volume or a store for
versioning. Running it as a Job also makes automation easier (a CronJob for periodic benchmarking, or
triggered from CI).&lt;/p>
&lt;hr>
&lt;h2 id="slo-validation-in-ci-catching-regressions">SLO validation in CI: catching regressions&lt;/h2>
&lt;p>The most valuable use in the medium term: a &lt;strong>Job that runs a short sweep on every change&lt;/strong> (a vLLM
release, a config change) and &lt;strong>compares the goodput against the baseline&lt;/strong>. If goodput drops by more than
a threshold or the knee moves earlier, the pipeline fails. Since GuideLLM exports standardised JSON for
&lt;strong>regression tracking&lt;/strong>, comparing two runs is trivial. That way a performance regression, which is a cost
and capacity regression, is caught at the commit, not in production. The full sweep is not needed on every
commit: a short sweep covering the knee is enough to catch the regression; the exhaustive one is for
releases.&lt;/p>
&lt;hr>
&lt;h2 id="comparing-configurations-with-sweeps">Comparing configurations with sweeps&lt;/h2>
&lt;p>GuideLLM shines for &lt;strong>deciding between configurations&lt;/strong> by running the same sweep against each one. The
fair protocol: same load (&lt;code>--data&lt;/code>), same SLO, same hardware, varying only the config. Examples of
decisions that a sweep settles with data:&lt;/p>
&lt;table>
&lt;thead>
&lt;tr>
&lt;th>Decision&lt;/th>
&lt;th>What to compare&lt;/th>
&lt;th>What the knee reveals&lt;/th>
&lt;/tr>
&lt;/thead>
&lt;tbody>
&lt;tr>
&lt;td>FP16 vs FP8&lt;/td>
&lt;td>two deployments of the same model&lt;/td>
&lt;td>FP8 usually gives a higher knee (more goodput)&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>&lt;code>max-num-seqs&lt;/code>&lt;/td>
&lt;td>different values of the flag&lt;/td>
&lt;td>the one that maximises goodput under the SLO&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>vLLM vs SGLang&lt;/td>
&lt;td>two engines, same model&lt;/td>
&lt;td>which engine gives more goodput at your SLO&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>KV size/precision&lt;/td>
&lt;td>KV cache configs&lt;/td>
&lt;td>the effect on capacity&lt;/td>
&lt;/tr>
&lt;/tbody>
&lt;/table>
&lt;p>Each sweep gives a knee; the highest knee under the same SLO wins. It is the controlled experiment that
fills the scorecard row (article B8): not &amp;ldquo;X is faster&amp;rdquo; in the abstract, but &amp;ldquo;X gives Y tok/s more goodput
under this specific SLO, which translates into Z fewer replicas and W € less per million tokens&amp;rdquo;.&lt;/p>
&lt;hr>
&lt;h2 id="watching-the-three-axes-during-the-sweep">Watching the three axes during the sweep&lt;/h2>
&lt;p>A trick that connects with the cost and energy track: &lt;strong>while the sweep runs, capture the GPU metrics with
DCGM&lt;/strong>. That way each round yields the goodput (GuideLLM), the average power (DCGM → J/token) and the cost
(node price → €/token) at once. In a single sweep campaign you measure the three axes of the scorecard for
every operating point, and they come out coherent by construction (same instant, same load). Instead of an
isolated performance benchmark, you get the &lt;strong>complete row&lt;/strong>, cost, performance, energy, for the knee,
which is exactly what the proposal needs. Align the time windows (the DCGM power and the GuideLLM metrics
must cover the same interval) or the J/token does not correspond to the measured goodput.&lt;/p>
&lt;hr>
&lt;h2 id="interpreting-the-distributions-not-just-the-knee">Interpreting the distributions, not just the knee&lt;/h2>
&lt;p>The knee is the headline, but the GuideLLM HTML report gives &lt;strong>full distributions&lt;/strong>, and there is
information there that the knee summarises away. Three additional readings:&lt;/p>
&lt;ul>
&lt;li>&lt;strong>The shape of the tail.&lt;/strong> Two configs with the same P99 can have different tails: one with P99 at
500 ms and P99.9 at 600 ms is stable; another with P99 at 500 ms and P99.9 at 3,000 ms has a long tail
that will hit some users severely. The mean, and even P99, hide it; the distribution shows it.&lt;/li>
&lt;li>&lt;strong>The TTFT/ITL split.&lt;/strong> Seeing the two distributions separately tells you whether the bottleneck is
prefill (high TTFT) or decode (high ITL), which steers the optimisation (more concurrency vs chunked
prefill, and so on).&lt;/li>
&lt;li>&lt;strong>The spread across rounds.&lt;/strong> If goodput varies a lot between rounds at the same rate, the system is
unstable under load, something a single capacity number does not reveal.&lt;/li>
&lt;/ul>
&lt;p>The knee sizes; the distributions diagnose. For a proposal, the knee is the datum; for operating and
optimising, the distributions are where you see what to fix.&lt;/p>
&lt;hr>
&lt;h2 id="guidellm-in-the-reproducible-harness">GuideLLM in the reproducible harness&lt;/h2>
&lt;p>GuideLLM fits as the load engine of the reproducible harness (article S4). The pattern:&lt;/p>
&lt;ol>
&lt;li>A &lt;strong>script/Job&lt;/strong> that deploys the engine with the pinned config, warms up, runs the sweep and dumps the
JSON with all the metadata (model, version, hardware, &lt;code>--data&lt;/code>, SLO).&lt;/li>
&lt;li>&lt;strong>Named by date and config&lt;/strong> to version the runs.&lt;/li>
&lt;li>A &lt;strong>store&lt;/strong> (a git repo of JSONs or a bucket) to reproduce and compare.&lt;/li>
&lt;li>&lt;strong>DCGM&lt;/strong> capturing in parallel to add the energy to each point.&lt;/li>
&lt;/ol>
&lt;p>That way &amp;ldquo;reproduce the knee of this config&amp;rdquo; is one command, not an afternoon, and comparing two releases
is a JSON diff. It is what turns benchmarking from a one-off activity into a continuous capability, and
what lets the capacity figure in the proposal come with the bench to reproduce it.&lt;/p>
&lt;hr>
&lt;h2 id="the-cost-of-a-sweep-in-euros">The cost of a sweep (in euros)&lt;/h2>
&lt;p>A 10-round sweep at ~2 minutes per round occupies the node for ~20–30 minutes. At an amortised cost of
~11 €/h, that is &lt;strong>~4–5.5 € per full sweep&lt;/strong> of a model. Not much per run, but a continuous benchmarking
programme (every release, every config, several models) adds up; hence the value of automating the sweep as
a reproducible Job and running &lt;strong>short&lt;/strong> sweeps in CI (only around the knee), reserving the exhaustive one
for releases. The cost of measuring is part of the cost of the platform, and small next to the cost of
sizing badly.&lt;/p>
&lt;hr>
&lt;h2 id="the-slo-by-use-case-the-knee-moves-with-it">The SLO by use case: the knee moves with it&lt;/h2>
&lt;p>Since the SLO defines the knee, different use cases give different capacities &lt;strong>on the same hardware&lt;/strong>.
Worth keeping in mind when sizing:&lt;/p>
&lt;table>
&lt;thead>
&lt;tr>
&lt;th>Use case&lt;/th>
&lt;th>Typical SLO&lt;/th>
&lt;th>Effect on the knee&lt;/th>
&lt;/tr>
&lt;/thead>
&lt;tbody>
&lt;tr>
&lt;td>Interactive chat&lt;/td>
&lt;td>TTFT P99 &amp;lt; 500 ms, ITL &amp;lt; 50 ms&lt;/td>
&lt;td>early knee (latency rules)&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>Code copilot&lt;/td>
&lt;td>TTFT P99 &amp;lt; 300 ms&lt;/td>
&lt;td>even earlier knee&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>Batch / summarisation&lt;/td>
&lt;td>no TTFT SLO, maximise throughput&lt;/td>
&lt;td>late knee (almost pure throughput)&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>Agent (multi-step)&lt;/td>
&lt;td>bounded per-step latency&lt;/td>
&lt;td>depends on the number of steps&lt;/td>
&lt;/tr>
&lt;/tbody>
&lt;/table>
&lt;p>The same GPU serves &lt;strong>more&lt;/strong> batch load than interactive chat load, because the batch SLO is loose. That is
why sizing requires &lt;strong>one sweep per load profile&lt;/strong>: there is no &amp;ldquo;the node&amp;rsquo;s capacity&amp;rdquo;, there is &amp;ldquo;the node&amp;rsquo;s
capacity for this SLO&amp;rdquo;. Mixing loads with different SLOs in the same pool without separating them is a
classic source of broken SLOs, with batch saturating and interactive chat paying for it. The fix connects
with scheduling: separate pools or priorities by SLO.&lt;/p>
&lt;hr>
&lt;h2 id="guidellm-vs-the-others-when-to-use-which">GuideLLM vs the others: when to use which&lt;/h2>
&lt;p>To place it against the tools from B2:&lt;/p>
&lt;table>
&lt;thead>
&lt;tr>
&lt;th>Question&lt;/th>
&lt;th>Tool&lt;/th>
&lt;th>Why&lt;/th>
&lt;/tr>
&lt;/thead>
&lt;tbody>
&lt;tr>
&lt;td>&amp;ldquo;How far can I load without breaking the SLO?&amp;rdquo;&lt;/td>
&lt;td>&lt;strong>GuideLLM&lt;/strong>&lt;/td>
&lt;td>SLO-driven sweep, the knee&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>&amp;ldquo;What is the endpoint&amp;rsquo;s maximum capacity?&amp;rdquo;&lt;/td>
&lt;td>AIPerf&lt;/td>
&lt;td>automatic saturation detection&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>&amp;ldquo;Does my vLLM flag change help?&amp;rdquo;&lt;/td>
&lt;td>vllm bench serve&lt;/td>
&lt;td>fast, native to the engine&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>&amp;ldquo;Which hardware/engine is better in the abstract?&amp;rdquo;&lt;/td>
&lt;td>read MLPerf&lt;/td>
&lt;td>cross-vendor comparability&lt;/td>
&lt;/tr>
&lt;/tbody>
&lt;/table>
&lt;p>GuideLLM is the tool for &lt;strong>sizing under an SLO&lt;/strong>: when the question is operational (how many replicas, what
cost per token at my SLO), it is the most direct answer. AIPerf and GuideLLM overlap (both multi-process);
the practical difference is that GuideLLM is more oriented to the SLO and the reproducible report, and
AIPerf to maximum capacity with automatic detection. Many teams use both; what must not be done is to
compare a result from one with a result from the other as if they were the same measurement.&lt;/p>
&lt;hr>
&lt;h2 id="checklist-for-a-defensible-sweep">Checklist for a defensible sweep&lt;/h2>
&lt;p>For the knee of a sweep to support a sizing decision:&lt;/p>
&lt;table>
&lt;thead>
&lt;tr>
&lt;th>Step&lt;/th>
&lt;th>Check&lt;/th>
&lt;/tr>
&lt;/thead>
&lt;tbody>
&lt;tr>
&lt;td>Realistic load&lt;/td>
&lt;td>&lt;code>--data&lt;/code> with your traffic&amp;rsquo;s distribution (or poisson)&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>Declared SLO&lt;/td>
&lt;td>TTFT/TPOT with a fixed percentile and threshold&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>Extended sweep&lt;/td>
&lt;td>reaches the point where P99 breaks (past the knee)&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>Goodput, not throughput&lt;/td>
&lt;td>capacity is the goodput under the SLO&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>Job in the cluster&lt;/td>
&lt;td>no network latency from an external client&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>Versioned output&lt;/td>
&lt;td>JSON with all the metadata&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>DCGM in parallel&lt;/td>
&lt;td>energy per point, optional but recommended&lt;/td>
&lt;/tr>
&lt;/tbody>
&lt;/table>
&lt;p>If you can tick the seven boxes, the knee is an auditable datum; if any is missing, it is an anecdote. The
sizing in the proposal hangs on these seven being green.&lt;/p>
&lt;hr>
&lt;h2 id="limits-and-traps-data-driven">Limits and traps (data-driven)&lt;/h2>
&lt;ol>
&lt;li>&lt;strong>Not extending the sweep beyond the knee.&lt;/strong> If saturation is not detected, the estimated capacity is
the last round tested, not the real knee. Extend it until P99 breaks.&lt;/li>
&lt;li>&lt;strong>Unrealistic load.&lt;/strong> A fixed-length &lt;code>--data&lt;/code> does not resemble your traffic; use the real distribution
(or poisson) for a knee that applies.&lt;/li>
&lt;li>&lt;strong>Reporting throughput, not goodput.&lt;/strong> The knee is defined by goodput under the SLO; raw throughput
overestimates capacity.&lt;/li>
&lt;li>&lt;strong>Undeclared SLO.&lt;/strong> A capacity without the SLO it was measured under is neither comparable nor
defensible.&lt;/li>
&lt;li>&lt;strong>Client outside the cluster.&lt;/strong> Running GuideLLM from outside puts network latency into the measurement;
run it as a Job inside the cluster.&lt;/li>
&lt;/ol>
&lt;p>With GuideLLM mastered, you have the tool that turns performance into an actionable datum: the knee, the
sizing and the cost per token, all from a reproducible sweep. The next article (B4) goes into NVIDIA&amp;rsquo;s
AIPerf; this one closes SLO validation, which is what sizes the proposal.&lt;/p>
&lt;h2 id="closing">Closing&lt;/h2>
&lt;p>GuideLLM answers the only performance question that sizes a platform: &lt;strong>how far can I load without breaking
the SLO?&lt;/strong> And it answers it with the right discipline: a sweep from idle to saturation that finds the knee,
defined by goodput under a declared SLO, over a load that resembles the real one. Everything the proposal
needs comes out of that knee: the replica count, the cost per token (with goodput as the denominator, not
catalogue throughput) and, if you capture DCGM during the sweep, the energy per token as well. The mistake
that invalidates the exercise is the usual one: reporting peak throughput instead of goodput, measuring with
an unrealistic load, or not extending the sweep until you see where it breaks. Done properly, as a Job
inside the cluster, with the real distribution, the SLO fixed and the output in versioned JSON, GuideLLM
turns performance from a marketing figure into a reproducible datum that survives an audit and sizes a
sovereign architecture with numbers. The knee is not the fastest point; it is the point where your platform
still delivers what it promised.&lt;/p>
&lt;h2 id="see-also">See also&lt;/h2>
&lt;ul>
&lt;li>&lt;a href="https://blog.lo0.es/en/posts/genai-perf-llm-inference-profiling-in-depth/">GenAI-Perf in depth&lt;/a> — NVIDIA&amp;rsquo;s profiler and how it compares with GuideLLM data sheet by data sheet (rate-types, concurrency sweep, metrics).&lt;/li>
&lt;li>&lt;a href="https://blog.lo0.es/en/posts/llm-serving-engines-pareto-frontier-comparison/">LLM serving engines compared (vLLM/SGLang/TRT-LLM/Dynamo)&lt;/a> — after validating the SLOs with GuideLLM, this comparison decides which engine meets the goodput-latency Pareto frontier.&lt;/li>
&lt;li>&lt;a href="https://blog.lo0.es/en/posts/measurement-bias-reproducibility-llm-benchmarks/">Measurement bias and reproducibility&lt;/a> — the setup errors that make a concurrency sweep non-reproducible, even when the tool is correctly configured.&lt;/li>
&lt;/ul>
&lt;h2 id="sources">Sources&lt;/h2>
&lt;ul>
&lt;li>Red Hat · GuideLLM: evaluar despliegues LLM para inferencia real — &lt;a href="https://developers.redhat.com/articles/2025/06/20/guidellm-evaluate-llm-deployments-real-world-inference">https://developers.redhat.com/articles/2025/06/20/guidellm-evaluate-llm-deployments-real-world-inference&lt;/a>&lt;/li>
&lt;li>Red Hat · desplegar y benchmarkear vLLM con GuideLLM en Kubernetes — &lt;a href="https://developers.redhat.com/articles/2025/12/24/how-deploy-and-benchmark-vllm-guidellm-kubernetes">https://developers.redhat.com/articles/2025/12/24/how-deploy-and-benchmark-vllm-guidellm-kubernetes&lt;/a>&lt;/li>
&lt;li>GuideLLM · PyPI — &lt;a href="https://pypi.org/project/guidellm/">https://pypi.org/project/guidellm/&lt;/a>&lt;/li>
&lt;li>GuideLLM · GitHub (proyecto vLLM) — &lt;a href="https://github.com/vllm-project/guidellm">https://github.com/vllm-project/guidellm&lt;/a>&lt;/li>
&lt;li>Medium · GuideLLM en OpenShift (rate-types, ejemplo) — &lt;a href="https://medium.com/@jajodia.nirjhar/exploring-guidellm-benchmarking-a-live-llm-on-openshift-ccc2d0841794">https://medium.com/@jajodia.nirjhar/exploring-guidellm-benchmarking-a-live-llm-on-openshift-ccc2d0841794&lt;/a>&lt;/li>
&lt;/ul></description></item><item><title>The second cost vector of AI agents: durable execution with Temporal</title><link>https://blog.lo0.es/en/posts/second-cost-vector-ai-agents-durable-execution-temporal/</link><pubDate>Sun, 14 Jun 2026 04:00:00 +0200</pubDate><guid>https://blog.lo0.es/en/posts/second-cost-vector-ai-agents-durable-execution-temporal/</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).&lt;/p>
&lt;/blockquote>
&lt;h2 id="the-thesis-agents-have-two-cost-vectors-not-one">The thesis: agents have two cost vectors, not one&lt;/h2>
&lt;p>Almost all the cost discourse around agentic AI focuses on a single vector: the &lt;strong>cost of
inference&lt;/strong> (the tokens). And that one is solvable: &lt;strong>FastMCP + vLLM on local hardware eliminates
the cost of inference&lt;/strong> — you serve the models on your own iron, the cost per token drops to your
amortised cost, with no API bill. But there is a &lt;strong>second vector&lt;/strong> that almost nobody measures and
that kills more projects: the &lt;strong>cost of faulty execution&lt;/strong>. An agent is not a call, it is a
&lt;strong>sequence&lt;/strong> of steps (reason, call a tool, reason, call another one, and so on), and when that
sequence fails halfway through with no persisted state, the cost is not linear: &lt;strong>it doubles, it
spirals with retries and it inflates on every recovery&lt;/strong>. This article is about that second vector,
how &lt;strong>durable execution&lt;/strong> attacks it (with Temporal as the reference), what the state of the art and
the papers say, and why it is the real cause of so many cancellations.&lt;/p>
&lt;p>The framing that backs the urgency comes from &lt;strong>Gartner&lt;/strong>: it predicts that &lt;strong>more than 40 % of
agentic AI projects will be cancelled before the end of 2027&lt;/strong>, because of &lt;strong>rising costs, unclear
business value or inadequate risk controls&lt;/strong> (&lt;a href="https://www.gartner.com/en/newsroom/press-releases/2025-06-25-gartner-predicts-over-40-percent-of-agentic-ai-projects-will-be-canceled-by-end-of-2027">Gartner&lt;/a>).
&amp;ldquo;Rising costs&amp;rdquo; is exactly this second vector: not the cost of serving the model, but the cost of
running it badly.&lt;/p>
&lt;hr>
&lt;h2 id="the-three-expenses-of-a-faulty-agent">The three expenses of a faulty agent&lt;/h2>
&lt;p>The cost of faulty execution breaks down into three concrete sources, and it is worth seeing them
with numbers:&lt;/p>
&lt;div class="diagram" style="max-width:780px;margin:1rem auto;">
&lt;svg viewBox="0 0 780 220" role="img" aria-label="The three expenses of a faulty agent: failures charged twice, uncontrolled retries, and inflated context on recovery" xmlns="http://www.w3.org/2000/svg">
&lt;style>.bx{fill:none;stroke:currentColor;stroke-width:1.3}.tl{font:600 12px sans-serif;fill:currentColor}.ts{font:11px sans-serif;fill:currentColor}&lt;/style>
&lt;rect class="bx" x="20" y="50" width="230" height="100" rx="6"/>
&lt;text x="32" y="72" class="tl">1 · Failures charged twice&lt;/text>
&lt;text x="32" y="92" class="ts">failure at step 15 of 20&lt;/text>
&lt;text x="32" y="108" class="ts">→ the previous 14 are thrown away&lt;/text>
&lt;text x="32" y="124" class="ts">+ full retry&lt;/text>
&lt;rect class="bx" x="275" y="50" width="230" height="100" rx="6"/>
&lt;text x="287" y="72" class="tl">2 · Uncontrolled retries&lt;/text>
&lt;text x="287" y="92" class="ts">retries with no policy&lt;/text>
&lt;text x="287" y="108" class="ts">→ API/compute bill&lt;/text>
&lt;text x="287" y="124" class="ts">with no ceiling&lt;/text>
&lt;rect class="bx" x="530" y="50" width="230" height="100" rx="6"/>
&lt;text x="542" y="72" class="tl">3 · Inflated context&lt;/text>
&lt;text x="542" y="92" class="ts">recovery rebuilds&lt;/text>
&lt;text x="542" y="108" class="ts">the full context&lt;/text>
&lt;text x="542" y="124" class="ts">→ extra tokens per step&lt;/text>
&lt;text x="20" y="185" class="ts">None is the "base" inference cost: they are the costs of running the sequence BADLY. The vector local vLLM does not touch.&lt;/text>
&lt;text x="20" y="205" class="ts">Durable execution attacks all three at once with per-step persisted state.&lt;/text>
&lt;/svg>
&lt;/div>
&lt;h3 id="vector-1-failures-charged-twice">Vector 1: failures charged twice&lt;/h3>
&lt;p>With no persisted state, a failure at &lt;strong>step 15 of 20&lt;/strong> costs you the tokens of the &lt;strong>14 previous
steps&lt;/strong> (which you already paid for) &lt;strong>plus the full retry&lt;/strong> from scratch. The wasted cost of an
agent with no checkpoint, per failure, is:&lt;/p>
$$\text{wasted cost} = \text{tokens of the completed steps} \times \text{cost per token} \times (\text{retries})$$
&lt;p>The longer the sequence and the later it fails, the more expensive: a failure at 75 % of the work
throws away 75 % of the cost. &lt;strong>Durable execution checkpoints every step&lt;/strong>: if the worker dies
halfway through, &lt;strong>another one picks the workflow up exactly where it stopped&lt;/strong>, not from scratch
(&lt;a href="https://temporal.io/">Temporal&lt;/a>). Temporal uses its &lt;strong>Event History&lt;/strong> as the record of past
decisions; on a failure it &lt;strong>replays&lt;/strong> the progress to date and resumes at the exact point
(&lt;a href="https://callsphere.ai/blog/temporal-ai-agent-workflows-durable-execution-workflow-as-code">CallSphere&lt;/a>).
Step 15 fails → step 15 is retried, not the previous 14. The wasted cost goes from &amp;ldquo;the 14 steps&amp;rdquo;
to &amp;ldquo;the step that failed&amp;rdquo;.&lt;/p>
&lt;h3 id="vector-2-uncontrolled-retries">Vector 2: uncontrolled retries&lt;/h3>
&lt;p>Agents that &lt;strong>retry with no policy&lt;/strong> blow up the API or compute bill: a retry loop against a &lt;em>rate
limit&lt;/em> can generate dozens of billable calls for a single task. Durable execution defines &lt;strong>retry
policies per activity&lt;/strong>: &lt;strong>hard limits&lt;/strong> on retries, &lt;strong>exponential backoff&lt;/strong> and &lt;strong>timeouts&lt;/strong> that
&lt;strong>bound the maximum possible cost&lt;/strong> of each call. Instead of &amp;ldquo;retry until it works&amp;rdquo; (unbounded
cost), you define &amp;ldquo;at most N retries with backoff and a timeout of T&amp;rdquo; (bounded cost).&lt;/p>
&lt;p>The state of the art backs this with data: the &lt;strong>ReliabilityBench&lt;/strong> benchmark evaluates agent
reliability under controlled failures and finds that &lt;strong>rate limiting is the most damaging failure&lt;/strong>
of all (&lt;a href="https://arxiv.org/abs/2601.06112">arXiv 2601.06112&lt;/a>). That is, exactly the failure that an
unpolicied retry turns into a cost spiral. Bounding retries is not hygiene, it is the defence
against the failure mode the literature singles out as the worst.&lt;/p>
&lt;h3 id="vector-3-inflated-context-on-recovery">Vector 3: inflated context on recovery&lt;/h3>
&lt;p>With no persisted state, the agent &lt;strong>rebuilds the full context on every recovery&lt;/strong>: it reloads the
whole conversation/task history to &amp;ldquo;remember&amp;rdquo; where it was, paying the tokens of that enormous
context &lt;strong>on every recovery&lt;/strong>. With durable execution the state lives in the workflow history, so
&lt;strong>the next step receives only the context needed for that step&lt;/strong>, not the whole conversation. The
saving is direct: fewer input tokens per step, and no penalty for rebuilding the context after
each failure.&lt;/p>
&lt;hr>
&lt;h2 id="a-worked-example-the-cost-of-one-failure">A worked example: the cost of one failure&lt;/h2>
&lt;p>Let us put numbers on the three vectors. A 20-step agent consuming ~3,000 tokens per step
(reasoning plus tool call), on in-house inference at ~1.09 €/1M tokens, with a failure at
&lt;strong>step 15&lt;/strong> and a failure rate of 10 % of executions:&lt;/p>
&lt;table>
&lt;thead>
&lt;tr>
&lt;th>Scenario&lt;/th>
&lt;th>Tokens recomputed per failure&lt;/th>
&lt;th>Cost per failure&lt;/th>
&lt;th>Over 10,000 executions (10 % fail)&lt;/th>
&lt;/tr>
&lt;/thead>
&lt;tbody>
&lt;tr>
&lt;td>&lt;strong>No state&lt;/strong> (full retry)&lt;/td>
&lt;td>14 steps × 3,000 = 42,000&lt;/td>
&lt;td>~0.046 €&lt;/td>
&lt;td>~46 € wasted&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>&lt;strong>With checkpoint&lt;/strong> (resumes at 15)&lt;/td>
&lt;td>0 (only step 15 is retried)&lt;/td>
&lt;td>~0.003 €&lt;/td>
&lt;td>~3 € wasted&lt;/td>
&lt;/tr>
&lt;/tbody>
&lt;/table>
&lt;p>The absolute figure looks small because inference is in-house and cheap; but the &lt;strong>ratio&lt;/strong> is
&lt;strong>~15×&lt;/strong> less waste per failure, and it scales with the length of the sequence and with the cost
per token (were it an external API at 10-30 €/1M, the waste without a checkpoint would be
multiplied by 10-30). And this is only vector 1: add uncontrolled retries (vector 2), which can
turn one failure into dozens of calls, and the context rebuilt on every recovery (vector 3). The
cost of faulty execution &lt;strong>is not the inference cost × something small&lt;/strong>; it is a multiplier that
grows with the agent&amp;rsquo;s complexity, and it is exactly what does not show up in the demo and does
show up in the production bill.&lt;/p>
&lt;hr>
&lt;h2 id="how-temporal-works-workflows-activities-and-determinism">How Temporal works: workflows, activities and determinism&lt;/h2>
&lt;p>To understand why Temporal achieves this, its two primitives:&lt;/p>
&lt;ul>
&lt;li>&lt;strong>Workflow&lt;/strong>: the agent&amp;rsquo;s orchestration logic (the sequence of steps, the decisions). It has to
be &lt;strong>deterministic&lt;/strong>: given the same Event History, it produces the same decisions. That is what
allows the execution to be &lt;strong>replayed&lt;/strong> after a failure and to arrive at the same point.&lt;/li>
&lt;li>&lt;strong>Activity&lt;/strong>: every step with side effects (a call to the LLM, to a tool, to an API). Activities
are &lt;strong>not&lt;/strong> deterministic (the LLM is probabilistic), so their result is &lt;strong>persisted&lt;/strong> in the
Event History: on replay they are not executed again, they are read from the history.&lt;/li>
&lt;/ul>
&lt;p>The trick is that separation: the deterministic logic (workflow) is replayed cheaply from the
history; the expensive, non-deterministic steps (activities, the model calls) are persisted and
&lt;strong>not repeated&lt;/strong>. That is why &amp;ldquo;completed work is never repeated&amp;rdquo;: completed activities are read,
not recomputed. And &lt;strong>retry policies are defined per activity&lt;/strong>, so each call to the model or to a
tool has its own retry limit, backoff and timeout, which is the vector 2 cost control at the right
level.&lt;/p>
&lt;hr>
&lt;h2 id="durable-execution-what-it-is-and-the-landscape">Durable execution: what it is and the landscape&lt;/h2>
&lt;p>&lt;strong>Durable execution&lt;/strong> is a programming model that &lt;strong>guarantees the code completes despite
failures&lt;/strong>: every step is checkpointed automatically, and if a worker dies, another one picks the
workflow up where it stopped (&lt;a href="https://temporal.io/">Temporal&lt;/a>). It crossed into the &amp;ldquo;early
majority&amp;rdquo; in 2025, with offerings from AWS, Cloudflare and Vercel, &lt;strong>driven precisely by AI agent
infrastructure&lt;/strong> (&lt;a href="https://www.inngest.com/blog/durable-execution-key-to-harnessing-ai-agents">Inngest&lt;/a>).&lt;/p>
&lt;div class="diagram" style="max-width:780px;margin:1rem auto;">
&lt;svg viewBox="0 0 780 200" role="img" aria-label="Durable execution: a sequence of checkpointed steps; on a failure it resumes from the last checkpoint, not from scratch" xmlns="http://www.w3.org/2000/svg">
&lt;style>.bx{fill:none;stroke:currentColor;stroke-width:1.3}.cp{fill:none;stroke:currentColor;stroke-width:1.3;stroke-dasharray:3 2}.tl{font:600 12px sans-serif;fill:currentColor}.ts{font:11px sans-serif;fill:currentColor}.ar{fill:none;stroke:currentColor;stroke-width:1.3;marker-end:url(#dm)}&lt;/style>
&lt;defs>&lt;marker id="dm" viewBox="0 0 10 10" refX="9" refY="5" markerWidth="7" markerHeight="7" orient="auto">&lt;path d="M0,0 L10,5 L0,10 z" fill="currentColor"/>&lt;/marker>&lt;/defs>
&lt;rect class="bx" x="30" y="60" width="80" height="40" rx="5"/>&lt;text x="70" y="84" text-anchor="middle" class="ts">step 1 ✓&lt;/text>
&lt;path class="ar" d="M110,80 L140,80"/>
&lt;rect class="bx" x="140" y="60" width="80" height="40" rx="5"/>&lt;text x="180" y="84" text-anchor="middle" class="ts">step 2 ✓&lt;/text>
&lt;path class="ar" d="M220,80 L250,80"/>
&lt;text x="255" y="55" class="ts">… checkpoint per step (Event History) …&lt;/text>
&lt;rect class="bx" x="430" y="60" width="80" height="40" rx="5"/>&lt;text x="470" y="84" text-anchor="middle" class="ts">step 15 ✗&lt;/text>
&lt;text x="430" y="125" class="ts">failure here&lt;/text>
&lt;path class="cp" d="M470,100 C470,140 400,140 400,108"/>
&lt;text x="300" y="155" class="ts">resumes at step 15, not at step 1&lt;/text>
&lt;rect class="bx" x="360" y="60" width="60" height="40" rx="5"/>&lt;text x="390" y="84" text-anchor="middle" class="ts">14 ✓&lt;/text>
&lt;text x="30" y="185" class="ts">Completed work is never repeated: the cost of a failure is that of the step that failed, not of the whole sequence.&lt;/text>
&lt;/svg>
&lt;/div>
&lt;p>The tooling landscape (the 2026 reference architecture for durable LLM agents): &lt;strong>Temporal&lt;/strong>,
&lt;strong>AWS Step Functions Express&lt;/strong>, &lt;strong>Restate&lt;/strong>, &lt;strong>DBOS&lt;/strong> and &lt;strong>Inngest&lt;/strong> as durable execution
primitives; and the &lt;em>checkpointer&lt;/em> model of &lt;strong>LangGraph&lt;/strong> (PostgresSaver, RedisSaver,
DynamoDBSaver) on the agent framework side (&lt;a href="https://render.com/articles/durable-workflow-platforms-ai-agents-llm-workloads">render.com&lt;/a>).
Temporal stands out for its &lt;em>workflow-as-code&lt;/em> model and its Event History as the source of truth.&lt;/p>
&lt;table>
&lt;thead>
&lt;tr>
&lt;th>Option&lt;/th>
&lt;th>Model&lt;/th>
&lt;th>Self-hosted&lt;/th>
&lt;th>Note for sovereignty&lt;/th>
&lt;/tr>
&lt;/thead>
&lt;tbody>
&lt;tr>
&lt;td>&lt;strong>Temporal&lt;/strong>&lt;/td>
&lt;td>workflow-as-code, Event History&lt;/td>
&lt;td>yes (open source)&lt;/td>
&lt;td>the most mature; full on-prem&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>&lt;strong>Restate&lt;/strong>&lt;/td>
&lt;td>durable RPC/handlers&lt;/td>
&lt;td>yes&lt;/td>
&lt;td>lightweight, modern&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>&lt;strong>DBOS&lt;/strong>&lt;/td>
&lt;td>durable on top of Postgres&lt;/td>
&lt;td>yes&lt;/td>
&lt;td>state in your own Postgres&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>&lt;strong>Inngest&lt;/strong>&lt;/td>
&lt;td>event-driven, steps&lt;/td>
&lt;td>SaaS / self-host&lt;/td>
&lt;td>focus on DX&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>&lt;strong>AWS Step Functions Express&lt;/strong>&lt;/td>
&lt;td>state machine&lt;/td>
&lt;td>no (AWS SaaS)&lt;/td>
&lt;td>tied to AWS, not sovereign&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>&lt;strong>LangGraph checkpointer&lt;/strong>&lt;/td>
&lt;td>framework checkpointer&lt;/td>
&lt;td>yes (your backend)&lt;/td>
&lt;td>at framework level, not platform level&lt;/td>
&lt;/tr>
&lt;/tbody>
&lt;/table>
&lt;p>For a sovereign platform, the ones that are &lt;strong>self-hosted&lt;/strong> (Temporal, Restate, DBOS) keep state in
your cluster; the SaaS ones (Step Functions) move execution into someone else&amp;rsquo;s jurisdiction.
Temporal is the choice for maturity and for its complete on-prem deployment.&lt;/p>
&lt;hr>
&lt;h2 id="the-cost-of-durable-execution-itself">The cost of durable execution itself&lt;/h2>
&lt;p>Honesty about the data: durable execution &lt;strong>is not free&lt;/strong>. It has two costs to watch:&lt;/p>
&lt;ol>
&lt;li>&lt;strong>Execution overhead&lt;/strong>: persisting state and replaying history adds latency and compute. The
reference architectures put it at around &lt;strong>5-20 % overhead&lt;/strong> on a load of 100,000
executions/day (&lt;a href="https://render.com/articles/durable-workflow-platforms-ai-agents-llm-workloads">render.com&lt;/a>).&lt;/li>
&lt;li>&lt;strong>Price per step&lt;/strong>: in the offerings with &lt;strong>per-step pricing&lt;/strong>, orchestrating many model calls,
&lt;em>rate limit&lt;/em> retries and complex interactions generates &lt;strong>a large number of billable steps&lt;/strong>, and
the cost can run away (&lt;a href="https://render.com/articles/durable-workflow-platforms-ai-agents-llm-workloads">render.com&lt;/a>).&lt;/li>
&lt;/ol>
&lt;p>The conclusion for a sovereign platform: &lt;strong>self-host the durable execution engine&lt;/strong> (Temporal is
open source and deploys on-prem) and the SaaS per-step price disappears, leaving only the execution
overhead, a 5-20 % that is paid back many times over by what it saves on the three vectors above.
The arithmetic: if a failure halfway through a sequence used to cost you 50-75 % of the recomputed
cost, a 5-20 % overhead is a bargain.&lt;/p>
&lt;hr>
&lt;h2 id="observability-from-manual-post-mortem-to-real-time-traceability">Observability: from manual post-mortem to real-time traceability&lt;/h2>
&lt;p>The fourth element of the equation: the &lt;strong>observability layer&lt;/strong>. The integration with &lt;strong>Braintrust&lt;/strong>
and the &lt;strong>complete history of every execution&lt;/strong> (Temporal&amp;rsquo;s Event History) turn cost analysis from a
&lt;strong>manual post-mortem&lt;/strong>, reconstructing by hand what happened and what it cost after a failure, into
&lt;strong>real-time traceability&lt;/strong>: every execution is recorded step by step, with its tokens, its retries
and its cost, ready to analyse. This closes the FinOps loop of the cost-per-token article, but at
the &lt;strong>agent&lt;/strong> level: not just how much a token costs, but how much an &lt;strong>execution&lt;/strong> costs, and
where it is wasted.&lt;/p>
&lt;hr>
&lt;h2 id="the-complete-equation">The complete equation&lt;/h2>
&lt;p>Putting the pieces together, the cost architecture of a sovereign agent:&lt;/p>
&lt;div class="diagram" style="max-width:780px;margin:1rem auto;">
&lt;svg viewBox="0 0 780 190" role="img" aria-label="The equation: FastMCP plus local vLLM eliminates the inference cost; Temporal eliminates the faulty execution cost; they are complementary" xmlns="http://www.w3.org/2000/svg">
&lt;style>.bx{fill:none;stroke:currentColor;stroke-width:1.3}.tl{font:600 12px sans-serif;fill:currentColor}.ts{font:11px sans-serif;fill:currentColor}.op{font:600 18px sans-serif;fill:currentColor}&lt;/style>
&lt;rect class="bx" x="20" y="50" width="230" height="70" rx="6"/>
&lt;text x="32" y="74" class="tl">FastMCP + local vLLM&lt;/text>
&lt;text x="32" y="94" class="ts">eliminates the cost&lt;/text>
&lt;text x="32" y="110" class="ts">of INFERENCE (tokens)&lt;/text>
&lt;text x="288" y="92" text-anchor="middle" class="op">+&lt;/text>
&lt;rect class="bx" x="320" y="50" width="230" height="70" rx="6"/>
&lt;text x="332" y="74" class="tl">Temporal (durable exec.)&lt;/text>
&lt;text x="332" y="94" class="ts">eliminates the cost of&lt;/text>
&lt;text x="332" y="110" class="ts">FAULTY EXECUTION&lt;/text>
&lt;text x="578" y="92" text-anchor="middle" class="op">=&lt;/text>
&lt;rect class="bx" x="600" y="50" width="160" height="70" rx="6"/>
&lt;text x="612" y="74" class="tl">Sustainable agent&lt;/text>
&lt;text x="612" y="94" class="ts">bounded cost,&lt;/text>
&lt;text x="612" y="110" class="ts">sovereign, traceable&lt;/text>
&lt;text x="20" y="155" class="ts">They are COMPLEMENTARY: they attack different vectors. The 40% of projects Gartner writes off dies by ignoring the second.&lt;/text>
&lt;text x="20" y="175" class="ts">+ observability (Braintrust / Event History) = real-time cost traceability.&lt;/text>
&lt;/svg>
&lt;/div>
&lt;p>&lt;strong>FastMCP + vLLM running locally eliminates the cost of inference. Temporal eliminates the cost of
faulty execution. They are complementary&lt;/strong>, attacking different vectors of the same problem.
Whoever solves only the first (serves cheaply but executes badly) keeps bleeding through the
second; whoever solves only the second (executes robustly but pays for the API) keeps bleeding
through the first. A sustainable platform needs both.&lt;/p>
&lt;hr>
&lt;h2 id="state-of-the-art-and-papers">State of the art and papers&lt;/h2>
&lt;p>The reader asked for papers, and there are some: agent reliability and cost is an active research
area in 2026.&lt;/p>
&lt;table>
&lt;thead>
&lt;tr>
&lt;th>Paper&lt;/th>
&lt;th>Contribution&lt;/th>
&lt;/tr>
&lt;/thead>
&lt;tbody>
&lt;tr>
&lt;td>&lt;strong>ReliabilityBench&lt;/strong> (&lt;a href="https://arxiv.org/abs/2601.06112">2601.06112&lt;/a>)&lt;/td>
&lt;td>an agent reliability benchmark: consistency, robustness and tolerance to tool/API failures; &lt;strong>rate limiting is the most damaging failure&lt;/strong>; ReAct more robust than Reflexion under stress&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>&lt;strong>Cost-Efficient LLM Agents&lt;/strong> (&lt;a href="https://arxiv.org/pdf/2603.01548">arXiv&lt;/a>)&lt;/td>
&lt;td>the &lt;strong>reliability-cost trade-off&lt;/strong>: routing every decision through the LLM improves accuracy but blows up cost; pre-coded graphs are cheaper but brittle against compound failures&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>&lt;strong>Multi-agent Byzantine Fault Tolerance&lt;/strong> (&lt;a href="https://arxiv.org/abs/2511.10400">2511.10400&lt;/a>)&lt;/td>
&lt;td>BFT consensus (CP-WBFT) to stabilise multi-agent systems under extreme failure rates (85.7 %)&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>&lt;strong>ACRFence&lt;/strong> (&lt;a href="https://arxiv.org/pdf/2603.20625">2603.20625&lt;/a>)&lt;/td>
&lt;td>security of agent &lt;strong>checkpoint-restore&lt;/strong>: preventing semantic &lt;em>rollback&lt;/em> attacks&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>&lt;strong>The Six Sigma Agent&lt;/strong> (&lt;a href="https://arxiv.org/html/2601.22290">2601.22290&lt;/a>)&lt;/td>
&lt;td>enterprise reliability via consensus-driven decomposed execution&lt;/td>
&lt;/tr>
&lt;/tbody>
&lt;/table>
&lt;p>Two readings for the proposal. First, &lt;strong>the reliability-cost trade-off is real and is
characterised&lt;/strong>: you cannot maximise accuracy ignoring cost, or the other way round; durable
execution shifts the frontier by &lt;strong>making reliability cheaper&lt;/strong> (checkpoint plus bounded retry
instead of recomputing). Second, &lt;strong>agent checkpoint-restore has its own security surface&lt;/strong>
(ACRFence): persisting state introduces &lt;em>rollback&lt;/em> risks that have to be controlled — durability is
not only a cost decision, it is a security one too.&lt;/p>
&lt;hr>
&lt;h2 id="the-sovereign-angle">The sovereign angle&lt;/h2>
&lt;p>For a European sovereign platform the combination fits perfectly and is &lt;strong>entirely on-prem&lt;/strong>:
&lt;strong>vLLM&lt;/strong> serves the models (no API cost and no sovereignty given away), &lt;strong>FastMCP&lt;/strong> exposes the
tools, and &lt;strong>self-hosted Temporal&lt;/strong> orchestrates with durability (no SaaS per-step price and no
execution data leaving for a foreign jurisdiction). The execution history, which describes in
detail what your agent does and with which data, &lt;strong>stays in your cluster&lt;/strong>, just like the
inferences. Against a stack of agents on external APIs plus a US SaaS orchestrator, the sovereign
stack removes both cost vectors &lt;strong>and&lt;/strong> keeps the data under EU jurisdiction. It is the same
argument as the rest of the series, applied to the agent layer: bounded, measured and sovereign
cost.&lt;/p>
&lt;hr>
&lt;h2 id="fastmcp-and-the-tool-layer">FastMCP and the tool layer&lt;/h2>
&lt;p>The other component of the equation is &lt;strong>FastMCP&lt;/strong>, the layer that exposes tools to the agent via
&lt;strong>MCP&lt;/strong> (Model Context Protocol). The agent does not call the APIs directly: it calls &lt;strong>MCP
servers&lt;/strong> that encapsulate each tool (a database, a search engine, an internal system) with a
uniform contract. The connection with cost and durability: every call to an MCP tool is a Temporal
&lt;strong>activity&lt;/strong>, so it inherits its retry policy, its timeout and its persistence. That is, FastMCP
defines &lt;strong>which&lt;/strong> tools exist and Temporal governs &lt;strong>how&lt;/strong> they are executed with durability and
bounded cost. And because the MCP servers are your own, the tools and the data they touch &lt;strong>stay
on-prem&lt;/strong>, never exposed to an external provider. The triad &lt;strong>vLLM (model) + FastMCP (tools) +
Temporal (orchestration)&lt;/strong> is a fully sovereign agent stack: the model, the tools and the
execution, all under your control and with the cost of both vectors bounded.&lt;/p>
&lt;hr>
&lt;h2 id="patterns-human-in-the-loop-and-long-sequences">Patterns: human-in-the-loop and long sequences&lt;/h2>
&lt;p>Where durable execution shines most, by agent pattern:&lt;/p>
&lt;ul>
&lt;li>&lt;strong>Human-in-the-loop (HITL)&lt;/strong>: human approval patterns, essential for an agent&amp;rsquo;s safety, map
directly to the &lt;strong>suspend/resume&lt;/strong> primitives of durable execution, allowing workflows that
&lt;strong>pause for hours or days waiting for an approval without losing state&lt;/strong>
(&lt;a href="https://www.inngest.com/blog/durable-execution-key-to-harnessing-ai-agents">Inngest&lt;/a>). Without
durability, keeping an agent &amp;ldquo;waiting&amp;rdquo; consumes resources or loses the context; with it, the
workflow sleeps at no cost and wakes up with all its state.&lt;/li>
&lt;li>&lt;strong>Long sequences&lt;/strong>: the longer the chain of steps, the greater the checkpoint&amp;rsquo;s saving (a late
failure throws away more work). Multi-step agents with tools are the canonical use case.&lt;/li>
&lt;li>&lt;strong>Multi-agent&lt;/strong>: when several agents collaborate, reliability gets complicated (the BFT papers
study this); durable execution provides the consistent state substrate on which to build that
coordination.&lt;/li>
&lt;/ul>
&lt;p>The pattern that does &lt;strong>not&lt;/strong> need it: a single-step, stateless call (a classifier, a simple
extraction). There the durability overhead buys nothing. The rule: durable execution for &lt;strong>stateful
sequences that fail halfway through&lt;/strong>; for everything else, a direct call.&lt;/p>
&lt;hr>
&lt;h2 id="how-to-measure-the-second-vector">How to measure the second vector&lt;/h2>
&lt;p>To manage the cost of faulty execution you have to measure it, and with the Event History plus
observability (Braintrust) the KPIs are within reach:&lt;/p>
&lt;table>
&lt;thead>
&lt;tr>
&lt;th>KPI&lt;/th>
&lt;th>What it indicates&lt;/th>
&lt;th>Where it comes from&lt;/th>
&lt;/tr>
&lt;/thead>
&lt;tbody>
&lt;tr>
&lt;td>&lt;strong>Tokens wasted per failure&lt;/strong>&lt;/td>
&lt;td>the cost of vector 1&lt;/td>
&lt;td>tokens recomputed after a failure&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>&lt;strong>Retries per activity&lt;/strong>&lt;/td>
&lt;td>the cost of vector 2&lt;/td>
&lt;td>retry count from the Event History&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>&lt;strong>Context size on recovery&lt;/strong>&lt;/td>
&lt;td>the cost of vector 3&lt;/td>
&lt;td>input tokens after a recovery&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>&lt;strong>Failure rate per workflow&lt;/strong>&lt;/td>
&lt;td>how often the problem occurs&lt;/td>
&lt;td>failed executions / total&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>&lt;strong>Cost per complete execution&lt;/strong>&lt;/td>
&lt;td>the business figure&lt;/td>
&lt;td>sum of tokens × cost/token per run&lt;/td>
&lt;/tr>
&lt;/tbody>
&lt;/table>
&lt;p>The last one closes the circle with cost per token: not just &amp;ldquo;how much a token costs&amp;rdquo;, but &amp;ldquo;how
much an &lt;strong>agent execution&lt;/strong> costs, and how much of that was useful work versus waste&amp;rdquo;. Without
these KPIs the second vector is invisible, and what is invisible does not get optimised, it gets
cancelled when the bill surprises you. With them, the cost of every execution is a real-time
figure, and the decision to make each workflow durable (or not) is taken with numbers.&lt;/p>
&lt;hr>
&lt;h2 id="limits-and-traps-data-driven">Limits and traps (data-driven)&lt;/h2>
&lt;ol>
&lt;li>&lt;strong>Durability is not free.&lt;/strong> 5-20 % overhead; on SaaS with per-step pricing it can run away.
Self-host Temporal to remove that price.&lt;/li>
&lt;li>&lt;strong>Not every agent needs it.&lt;/strong> A single-step, stateless task gains nothing from durable
execution; the value is in the &lt;strong>long sequences&lt;/strong> that fail halfway through.&lt;/li>
&lt;li>&lt;strong>Bounded retries can hide real failures.&lt;/strong> A retry limit avoids the cost spiral, but you have
to &lt;strong>alert&lt;/strong> when it is exhausted, not swallow the failure in silence.&lt;/li>
&lt;li>&lt;strong>Checkpoint = security surface.&lt;/strong> Persisting state opens the door to rollback attacks
(ACRFence); protect it.&lt;/li>
&lt;li>&lt;strong>The first vector still exists.&lt;/strong> Temporal does not lower the inference cost; you need local
vLLM for that. They are complementary, not substitutes.&lt;/li>
&lt;/ol>
&lt;p>The conclusion the data and the papers support: the cost of agentic AI has &lt;strong>two vectors&lt;/strong>, and the
industry has obsessed over the first (inference) while ignoring the second (faulty execution),
which is precisely where Gartner locates the cause of 40 % of cancellations. &lt;strong>FastMCP + vLLM&lt;/strong>
solves the first; &lt;strong>Temporal&lt;/strong> the second. A sovereign and sustainable agent platform needs to
attack both, with observability that turns the cost of each execution into a real-time figure
rather than an autopsy.&lt;/p>
&lt;h2 id="closing">Closing&lt;/h2>
&lt;p>The dominant narrative about agent cost is half-lame: it treats the inference cost as if it were
the only one, when it is only the one you see in the demo. The one that kills projects, the one
Gartner files under &amp;ldquo;rising costs&amp;rdquo; inside its 40 % of cancellations, is the &lt;strong>cost of faulty
execution&lt;/strong>: failures charged twice, retries with no ceiling and the context rebuilt on every
recovery. The engineering that attacks it is not a prompt trick or a better model, it is an
&lt;strong>execution model&lt;/strong>: durable execution, which checkpoints every step, bounds every retry and passes
only the necessary context. Temporal, open source and self-hosted, does this while keeping state in
your cluster, and combined with vLLM (model) and FastMCP (tools) it composes a &lt;strong>fully sovereign&lt;/strong>
agent stack where both cost vectors are bounded and the data never leaves the EU. The state of the
art and the papers confirm the problem is real and characterisable: the reliability-cost trade-off,
rate limiting as the worst failure, checkpoint-restore as a new security surface. For anyone who
wants to be in the 60 % of agentic projects that are &lt;strong>not&lt;/strong> cancelled, the recipe is the one in
this article: solve both vectors, not one, and measure the second as seriously as the first.&lt;/p>
&lt;h2 id="sources">Sources&lt;/h2>
&lt;ul>
&lt;li>Gartner · más del 40 % de proyectos de IA agéntica cancelados antes de 2027 — &lt;a href="https://www.gartner.com/en/newsroom/press-releases/2025-06-25-gartner-predicts-over-40-percent-of-agentic-ai-projects-will-be-canceled-by-end-of-2027">https://www.gartner.com/en/newsroom/press-releases/2025-06-25-gartner-predicts-over-40-percent-of-agentic-ai-projects-will-be-canceled-by-end-of-2027&lt;/a>&lt;/li>
&lt;li>Temporal · durable execution — &lt;a href="https://temporal.io/">https://temporal.io/&lt;/a>&lt;/li>
&lt;li>CallSphere · Temporal para workflows de agentes (Event History, replay) — &lt;a href="https://callsphere.ai/blog/temporal-ai-agent-workflows-durable-execution-workflow-as-code">https://callsphere.ai/blog/temporal-ai-agent-workflows-durable-execution-workflow-as-code&lt;/a>&lt;/li>
&lt;li>Inngest · durable execution, clave para agentes en producción — &lt;a href="https://www.inngest.com/blog/durable-execution-key-to-harnessing-ai-agents">https://www.inngest.com/blog/durable-execution-key-to-harnessing-ai-agents&lt;/a>&lt;/li>
&lt;li>render.com · plataformas de workflow durables para agentes/LLM (overhead, precio por paso) — &lt;a href="https://render.com/articles/durable-workflow-platforms-ai-agents-llm-workloads">https://render.com/articles/durable-workflow-platforms-ai-agents-llm-workloads&lt;/a>&lt;/li>
&lt;li>ReliabilityBench (arXiv 2601.06112) — &lt;a href="https://arxiv.org/abs/2601.06112">https://arxiv.org/abs/2601.06112&lt;/a>&lt;/li>
&lt;li>Cost-Efficient LLM Agents (arXiv 2603.01548) — &lt;a href="https://arxiv.org/pdf/2603.01548">https://arxiv.org/pdf/2603.01548&lt;/a>&lt;/li>
&lt;li>Byzantine Fault Tolerance multi-agente (arXiv 2511.10400) — &lt;a href="https://arxiv.org/abs/2511.10400">https://arxiv.org/abs/2511.10400&lt;/a>&lt;/li>
&lt;li>ACRFence · checkpoint-restore de agentes (arXiv 2603.20625) — &lt;a href="https://arxiv.org/pdf/2603.20625">https://arxiv.org/pdf/2603.20625&lt;/a>&lt;/li>
&lt;li>The Six Sigma Agent (arXiv 2601.22290) — &lt;a href="https://arxiv.org/html/2601.22290">https://arxiv.org/html/2601.22290&lt;/a>&lt;/li>
&lt;/ul></description></item><item><title>LLM benchmark tool catalogue: the practical deep dive</title><link>https://blog.lo0.es/en/posts/llm-benchmark-tool-catalogue-practical-deep-dive/</link><pubDate>Sun, 14 Jun 2026 02:00:00 +0200</pubDate><guid>https://blog.lo0.es/en/posts/llm-benchmark-tool-catalogue-practical-deep-dive/</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).&lt;/p>
&lt;/blockquote>
&lt;h2 id="what-this-article-covers">What this article covers&lt;/h2>
&lt;p>Second article in the &lt;strong>benchmarking&lt;/strong> track (B2). The &lt;a href="https://blog.lo0.es/en/posts/llm-inference-benchmarking-frameworks-metrics-state-of-the-art/">B1 introduction&lt;/a>
pinned the metrics and the methodology; this article is the &lt;strong>practical manual&lt;/strong>: for each
tool, what it measures, how it is actually invoked (with the command), what output format
it produces and when to choose it. The goal is that, after reading it, you know what to run to
measure your engine and you can reproduce the number. No universal recommendations; only the
mechanics of each tool and the criterion for choosing.&lt;/p>
&lt;hr>
&lt;h2 id="how-to-choose-the-tool-map">How to choose: the tool map&lt;/h2>
&lt;p>Recalling the split from B1, the tools are ordered on two axes: &lt;strong>micro-bench&lt;/strong>
(single-process, for tuning an engine) versus &lt;strong>load generator&lt;/strong> (multi-process, for measuring
real capacity), and &lt;strong>engine-native&lt;/strong> versus &lt;strong>endpoint-agnostic&lt;/strong>.&lt;/p>
&lt;div class="diagram" style="max-width:780px;margin:1rem auto;">
&lt;svg viewBox="0 0 780 250" role="img" aria-label="Map of benchmark tools on two axes: single-process micro-bench versus multi-process load generator, and engine-native versus endpoint-agnostic" xmlns="http://www.w3.org/2000/svg">
&lt;style>.ax{fill:none;stroke:currentColor;stroke-width:1}.bx{fill:none;stroke:currentColor;stroke-width:1.2}.tl{font:600 12px sans-serif;fill:currentColor}.ts{font:11px sans-serif;fill:currentColor}&lt;/style>
&lt;line class="ax" x1="60" y1="200" x2="720" y2="200"/>
&lt;line class="ax" x1="390" y1="40" x2="390" y2="200"/>
&lt;text x="70" y="216" class="ts">micro-bench (single-process)&lt;/text>
&lt;text x="560" y="216" class="ts">load generator (multi-process)&lt;/text>
&lt;text x="400" y="52" class="ts">↑ endpoint-agnostic&lt;/text>
&lt;text x="400" y="195" class="ts">↓ engine-native&lt;/text>
&lt;rect class="bx" x="150" y="150" width="150" height="34" rx="5"/>&lt;text x="225" y="171" text-anchor="middle" class="ts">vLLM bench / SGLang bench&lt;/text>
&lt;rect class="bx" x="470" y="70" width="120" height="34" rx="5"/>&lt;text x="530" y="91" text-anchor="middle" class="ts">AIPerf&lt;/text>
&lt;rect class="bx" x="470" y="115" width="120" height="34" rx="5"/>&lt;text x="530" y="136" text-anchor="middle" class="ts">GuideLLM&lt;/text>
&lt;rect class="bx" x="610" y="93" width="110" height="34" rx="5"/>&lt;text x="665" y="114" text-anchor="middle" class="ts">LLMPerf&lt;/text>
&lt;rect class="bx" x="150" y="60" width="150" height="34" rx="5"/>&lt;text x="225" y="81" text-anchor="middle" class="ts">MLPerf (standard suite)&lt;/text>
&lt;text x="60" y="240" class="ts">To tune a specific engine: micro-bench. To measure capacity under SLO: load generator. To compare vendors: MLPerf.&lt;/text>
&lt;/svg>
&lt;/div>
&lt;hr>
&lt;h2 id="datasets-the-synthetic-load-matters-as-much-as-the-tool">Datasets: the synthetic load matters as much as the tool&lt;/h2>
&lt;p>Before the profiles, one point that changes results without anyone noticing: &lt;strong>what load you
feed it&lt;/strong>. The tools accept different kinds of dataset, and each one measures something
different:&lt;/p>
&lt;table>
&lt;thead>
&lt;tr>
&lt;th>Dataset&lt;/th>
&lt;th>What it simulates&lt;/th>
&lt;th>Bias it introduces&lt;/th>
&lt;/tr>
&lt;/thead>
&lt;tbody>
&lt;tr>
&lt;td>&lt;strong>random&lt;/strong> (fixed lengths)&lt;/td>
&lt;td>controlled uniform load&lt;/td>
&lt;td>unrealistic: traffic is not fixed length&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>&lt;strong>sharegpt&lt;/strong> (real conversations)&lt;/td>
&lt;td>realistic prompt distribution&lt;/td>
&lt;td>the de facto standard for comparison&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>&lt;strong>your own traces&lt;/strong>&lt;/td>
&lt;td>your real traffic&lt;/td>
&lt;td>the most faithful, but specific to your case&lt;/td>
&lt;/tr>
&lt;/tbody>
&lt;/table>
&lt;p>The trap: a benchmark with short, fixed-length prompts gives a very high throughput that
looks nothing like production, where lengths vary and long prompts dominate the prefill
cost. For a defensible figure, use &lt;strong>sharegpt&lt;/strong> (comparability) or, better, &lt;strong>traces from your
own traffic&lt;/strong> (fidelity). And always declare the length distribution (prompt/output) alongside
the number, because two benchmarks with different datasets are not comparable even if they
use the same tool.&lt;/p>
&lt;hr>
&lt;h2 id="vllm-bench-serve">vLLM bench serve&lt;/h2>
&lt;p>What it measures: TTFT, TPOT, throughput and latencies of the vLLM server under a
synthetic load. Class: micro-bench (single-process). It is the tool for &lt;strong>tuning vLLM&lt;/strong> and
seeing the effect of its optimisations (&lt;a href="https://blog.lo0.es/en/posts/optimising-decode-vllm-squeezing-every-token-small-hardware/">decode&lt;/a>,
&lt;a href="https://blog.lo0.es/en/posts/optimising-prefill-vllm-knobs-ttft-will-not-forgive/">prefill&lt;/a>).&lt;/p>
&lt;p>Typical invocation (&lt;a href="https://docs.vllm.ai/en/latest/cli/bench/serve/">vLLM · benchmark CLI&lt;/a>):&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 bench serve &lt;span class="se">\
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="se">&lt;/span> --backend vllm &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-8B-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> --endpoint /v1/completions &lt;span class="se">\
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="se">&lt;/span> --dataset-name sharegpt &lt;span class="se">\
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="se">&lt;/span> --dataset-path ShareGPT_V3_unfiltered_cleaned_split.json &lt;span class="se">\
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="se">&lt;/span> --num-prompts &lt;span class="m">1000&lt;/span>
&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>Key parameters: &lt;code>--num-prompts&lt;/code> (load), &lt;code>--dataset-name&lt;/code> (sharegpt, random, etc.),
&lt;code>--request-rate&lt;/code> (requests/s). The output is a console summary with TTFT, TPOT, throughput
and percentiles, and it can be dumped to JSON. To sweep concurrencies there is &lt;strong>&lt;code>vllm bench sweep serve&lt;/code>&lt;/strong>, which automates the sweep (&lt;a href="https://docs.vllm.ai/en/latest/cli/bench/sweep/serve/">vLLM · sweep&lt;/a>).&lt;/p>
&lt;p>Limit: single-process, it saturates on the client at high concurrency; less flexible than
GuideLLM in datasets and load patterns. Use it to iterate quickly on the vLLM config, not to
measure maximum capacity at scale.&lt;/p>
&lt;hr>
&lt;h2 id="sglang-bench">SGLang bench&lt;/h2>
&lt;p>What it measures: the equivalent for the &lt;strong>SGLang&lt;/strong> engine (TTFT, TPOT, throughput). Class:
micro-bench. Use: tuning SGLang and comparing it against itself across configurations. The
mechanics are analogous to &lt;code>vllm bench serve&lt;/code>: a synthetic load, console/JSON output with the
same metrics. If you are evaluating SGLang against vLLM, do not compare them with their
respective micro-benches (same class but different implementations): use an endpoint-agnostic
load generator (GuideLLM/AIPerf) against both endpoints, so that the tool is not the variable.&lt;/p>
&lt;hr>
&lt;h2 id="aiperf-nvidia-formerly-genai-perf">AIPerf (NVIDIA, formerly genai-perf)&lt;/h2>
&lt;p>What it measures: TTFT, ITL, throughput and latency against any compatible endpoint (vLLM,
NIM, TGI, SGLang). Class: &lt;strong>multi-process&lt;/strong> load generator. It is the successor to genai-perf
(retired on 15 Apr 2026). Its distinguishing feature: during the sweep it &lt;strong>detects GPU
saturation&lt;/strong> and returns the previous iteration as &lt;code>estimatedCapacity&lt;/code>, which is why the sweep
has to extend beyond the knee
(&lt;a href="https://lucaberton.com/blog/nvidia-aiperf-llm-inference-benchmarking-guide/">AIPerf&lt;/a>).&lt;/p>
&lt;p>Output: structured metrics (JSON) with TTFT/ITL distributions and the &lt;code>estimatedCapacity&lt;/code>. Use
it when you want the real capacity of an endpoint, whatever the engine, with automatic
detection of the saturation point.&lt;/p>
&lt;hr>
&lt;h2 id="guidellm-vllm-project">GuideLLM (vLLM project)&lt;/h2>
&lt;p>What it measures: full distributions of TTFT, ITL and end-to-end behaviour, for &lt;strong>SLO-driven&lt;/strong>
evaluation. Class: multi-process load generator. It is the recommended tool for benchmarking
vLLM servers in production: more flexible than &lt;code>vllm bench serve&lt;/code> in dataset loading, request
format and traffic patterns, with live progress and automatic report generation
(&lt;a href="https://developers.redhat.com/articles/2025/12/24/how-deploy-and-benchmark-vllm-guidellm-kubernetes">Red Hat&lt;/a>).&lt;/p>
&lt;p>Typical invocation:&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">guidellm benchmark &lt;span class="se">\
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="se">&lt;/span> --target &lt;span class="s2">&amp;#34;http://localhost:8000&amp;#34;&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> --rate-type throughput &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-requests &lt;span class="m">1000&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> --data &lt;span class="s2">&amp;#34;samples=1000,prompt_tokens=1024,output_tokens=256&amp;#34;&lt;/span>
&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>Key parameters: &lt;code>--rate-type&lt;/code> (&lt;code>synchronous&lt;/code>, &lt;code>concurrent&lt;/code>, &lt;code>throughput&lt;/code>, or rate-based),
&lt;code>--data&lt;/code> (the load specification: number of samples and prompt/output lengths), &lt;code>--target&lt;/code>
(the endpoint). It generates reproducible sweeps to find the safe operating range under
SLO, with full distributions (not just means). Output: a report with percentiles and, often,
exportable to JSON/HTML. It is the default option for answering &amp;ldquo;how hard can I load this
engine without breaking the SLO?&amp;rdquo;.&lt;/p>
&lt;hr>
&lt;h2 id="llmperf-anyscaleray">LLMPerf (Anyscale/Ray)&lt;/h2>
&lt;p>What it measures: throughput and latency at the inference level. Class: load generator. Use:
endpoint validation, historically very widespread in the Ray/Anyscale ecosystem. It is a solid
and well-known option, though less focused on distributions and sweeps than GuideLLM/AIPerf.
It fits if you already operate on Ray or want a simple tool to validate an endpoint.&lt;/p>
&lt;hr>
&lt;h2 id="inference-benchmarker-hugging-face">inference-benchmarker (Hugging Face)&lt;/h2>
&lt;p>What it measures: latency and throughput of inference endpoints, aimed at producing comparable
reports. Class: load generator. Use: an OSS alternative inside the Hugging Face ecosystem,
useful if you already work with TGI or the HF stack. Like the others in its class, its value
lies in measuring real capacity with distributed load; the choice between this one, GuideLLM
and AIPerf usually comes down to the ecosystem you already operate in rather than to deep
differences in what they measure.&lt;/p>
&lt;hr>
&lt;h2 id="guidellm-versus-aiperf-which-of-the-two-load-generators">GuideLLM versus AIPerf: which of the two load generators&lt;/h2>
&lt;p>They are the two serious multi-process load options, and they overlap a great deal. The
practical differences that tip the choice:&lt;/p>
&lt;table>
&lt;thead>
&lt;tr>
&lt;th>Criterion&lt;/th>
&lt;th>GuideLLM&lt;/th>
&lt;th>AIPerf&lt;/th>
&lt;/tr>
&lt;/thead>
&lt;tbody>
&lt;tr>
&lt;td>Origin&lt;/td>
&lt;td>vLLM project (Red Hat)&lt;/td>
&lt;td>NVIDIA (successor to genai-perf)&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>Focus&lt;/td>
&lt;td>&lt;strong>SLO&lt;/strong>-driven evaluation, reproducible sweeps&lt;/td>
&lt;td>real capacity, automatic &lt;strong>estimatedCapacity&lt;/strong>&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>Load patterns&lt;/td>
&lt;td>synchronous, concurrent, rate-based&lt;/td>
&lt;td>sweep with saturation detection&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>Report&lt;/td>
&lt;td>live progress + automatic report&lt;/td>
&lt;td>structured metrics (JSON)&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>Ecosystem&lt;/td>
&lt;td>vLLM / OpenShift AI&lt;/td>
&lt;td>NVIDIA NIM / Triton / vLLM&lt;/td>
&lt;/tr>
&lt;/tbody>
&lt;/table>
&lt;p>In practice: if your question is &amp;ldquo;how hard can I load it without breaking the SLO?&amp;rdquo;,
&lt;strong>GuideLLM&lt;/strong> answers it more directly with its SLO-driven sweeps; if your question is &amp;ldquo;what is
the maximum capacity of this endpoint?&amp;rdquo;, &lt;strong>AIPerf&lt;/strong> gives it with its automatic knee
detection. Many teams use both: GuideLLM for the operational SLO, AIPerf for the reference
capacity. What you must not do is compare a GuideLLM result with an AIPerf one as if they
were the same measurement: although both are multi-process, their sweep methodology differs;
pick one for a given comparison and stick to it.&lt;/p>
&lt;hr>
&lt;h2 id="mlperf-inference-mlcommons">MLPerf Inference (MLCommons)&lt;/h2>
&lt;p>What it measures: performance under standardised scenarios (Offline, Server, Interactive)
with strict rules. Unlike the previous ones, MLPerf is not &amp;ldquo;run&amp;rdquo; for your day-to-day case:
it is a vendor comparison suite whose results are read (published by NVIDIA, AMD, Intel and
others). Use it to compare hardware and engines against each other under identical rules, not
to size your specific workload. For that, GuideLLM/AIPerf against your endpoint.&lt;/p>
&lt;hr>
&lt;h2 id="practical-comparison-table">Practical comparison table&lt;/h2>
&lt;table>
&lt;thead>
&lt;tr>
&lt;th>Tool&lt;/th>
&lt;th>Class&lt;/th>
&lt;th>Base command&lt;/th>
&lt;th>Output&lt;/th>
&lt;th>When&lt;/th>
&lt;/tr>
&lt;/thead>
&lt;tbody>
&lt;tr>
&lt;td>&lt;strong>vllm bench serve&lt;/strong>&lt;/td>
&lt;td>micro&lt;/td>
&lt;td>&lt;code>vllm bench serve&lt;/code>&lt;/td>
&lt;td>console/JSON&lt;/td>
&lt;td>tune vLLM&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>&lt;strong>vllm bench sweep&lt;/strong>&lt;/td>
&lt;td>micro (sweep)&lt;/td>
&lt;td>&lt;code>vllm bench sweep serve&lt;/code>&lt;/td>
&lt;td>console/JSON&lt;/td>
&lt;td>sweep concurrency in vLLM&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>&lt;strong>SGLang bench&lt;/strong>&lt;/td>
&lt;td>micro&lt;/td>
&lt;td>bench from the SGLang repo&lt;/td>
&lt;td>console/JSON&lt;/td>
&lt;td>tune SGLang&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>&lt;strong>AIPerf&lt;/strong>&lt;/td>
&lt;td>load&lt;/td>
&lt;td>&lt;code>aiperf profile …&lt;/code>&lt;/td>
&lt;td>JSON + estimatedCapacity&lt;/td>
&lt;td>real capacity, multi-endpoint&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>&lt;strong>GuideLLM&lt;/strong>&lt;/td>
&lt;td>load&lt;/td>
&lt;td>&lt;code>guidellm benchmark …&lt;/code>&lt;/td>
&lt;td>report + JSON/HTML&lt;/td>
&lt;td>SLO, reproducible sweep&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>&lt;strong>LLMPerf&lt;/strong>&lt;/td>
&lt;td>load&lt;/td>
&lt;td>Ray/LLMPerf script&lt;/td>
&lt;td>JSON&lt;/td>
&lt;td>validate endpoint (Ray)&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>&lt;strong>MLPerf&lt;/strong>&lt;/td>
&lt;td>suite&lt;/td>
&lt;td>(results are read)&lt;/td>
&lt;td>official results&lt;/td>
&lt;td>compare vendors&lt;/td>
&lt;/tr>
&lt;/tbody>
&lt;/table>
&lt;hr>
&lt;h2 id="summary-what-you-run-for-each-question">Summary: what you run for each question&lt;/h2>
&lt;p>So as not to get lost in the catalogue, the direct mapping from question to tool:&lt;/p>
&lt;table>
&lt;thead>
&lt;tr>
&lt;th>Your question&lt;/th>
&lt;th>Tool&lt;/th>
&lt;th>Why&lt;/th>
&lt;/tr>
&lt;/thead>
&lt;tbody>
&lt;tr>
&lt;td>&amp;ldquo;Does my vLLM config change help?&amp;rdquo;&lt;/td>
&lt;td>&lt;code>vllm bench serve&lt;/code> (+ sweep)&lt;/td>
&lt;td>fast, engine-native&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>&amp;ldquo;How hard can I load without breaking the SLO?&amp;rdquo;&lt;/td>
&lt;td>GuideLLM&lt;/td>
&lt;td>SLO-driven sweeps&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>&amp;ldquo;What is the maximum capacity of the endpoint?&amp;rdquo;&lt;/td>
&lt;td>AIPerf&lt;/td>
&lt;td>automatic knee detection&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>&amp;ldquo;vLLM or SGLang for my workload?&amp;rdquo;&lt;/td>
&lt;td>GuideLLM/AIPerf against both&lt;/td>
&lt;td>same tool, engine as the variable&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>&amp;ldquo;Which hardware/engine is better in the abstract?&amp;rdquo;&lt;/td>
&lt;td>read MLPerf&lt;/td>
&lt;td>cross-vendor comparability&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>&amp;ldquo;Is there a regression in this release?&amp;rdquo;&lt;/td>
&lt;td>short sweep in CI against a baseline&lt;/td>
&lt;td>continuous detection&lt;/td>
&lt;/tr>
&lt;/tbody>
&lt;/table>
&lt;p>The rule underlying the whole table: &lt;strong>micro-bench to iterate on an engine, load generator to
measure capacity and decide, MLPerf to compare vendors&lt;/strong>. And, for any comparison between
systems, the same tool for every candidate. If these three categories are clear to you, the
specific choice is secondary.&lt;/p>
&lt;hr>
&lt;h2 id="step-by-step-benchmark-methodology">Step-by-step benchmark methodology&lt;/h2>
&lt;p>A reliable run is not &amp;ldquo;launching the command&amp;rdquo;: it is a procedure. The steps, with the load
tool (GuideLLM/AIPerf) against your endpoint:&lt;/p>
&lt;div class="diagram" style="max-width:780px;margin:1rem auto;">
&lt;svg viewBox="0 0 780 170" role="img" aria-label="Benchmark flow: deploy the engine, warm up, sweep concurrency, collect metrics and compare" xmlns="http://www.w3.org/2000/svg">
&lt;style>.bx{fill:none;stroke:currentColor;stroke-width:1.3}.tl{font:600 12px sans-serif;fill:currentColor}.ts{font:11px sans-serif;fill:currentColor}.ar{fill:none;stroke:currentColor;stroke-width:1.3;marker-end:url(#wm)}&lt;/style>
&lt;defs>&lt;marker id="wm" viewBox="0 0 10 10" refX="9" refY="5" markerWidth="7" markerHeight="7" orient="auto">&lt;path d="M0,0 L10,5 L0,10 z" fill="currentColor"/>&lt;/marker>&lt;/defs>
&lt;rect class="bx" x="20" y="50" width="120" height="44" rx="6"/>&lt;text x="80" y="69" text-anchor="middle" class="tl">1 · Deploy&lt;/text>&lt;text x="80" y="85" text-anchor="middle" class="ts">engine + config&lt;/text>
&lt;path class="ar" d="M140,72 L165,72"/>
&lt;rect class="bx" x="165" y="50" width="120" height="44" rx="6"/>&lt;text x="225" y="69" text-anchor="middle" class="tl">2 · Warm up&lt;/text>&lt;text x="225" y="85" text-anchor="middle" class="ts">discard warm-up&lt;/text>
&lt;path class="ar" d="M285,72 L310,72"/>
&lt;rect class="bx" x="310" y="50" width="120" height="44" rx="6"/>&lt;text x="370" y="69" text-anchor="middle" class="tl">3 · Sweep&lt;/text>&lt;text x="370" y="85" text-anchor="middle" class="ts">go past the knee&lt;/text>
&lt;path class="ar" d="M430,72 L455,72"/>
&lt;rect class="bx" x="455" y="50" width="120" height="44" rx="6"/>&lt;text x="515" y="69" text-anchor="middle" class="tl">4 · Collect&lt;/text>&lt;text x="515" y="85" text-anchor="middle" class="ts">JSON with percentiles&lt;/text>
&lt;path class="ar" d="M575,72 L600,72"/>
&lt;rect class="bx" x="600" y="50" width="120" height="44" rx="6"/>&lt;text x="660" y="69" text-anchor="middle" class="tl">5 · Compare&lt;/text>&lt;text x="660" y="85" text-anchor="middle" class="ts">goodput vs SLO&lt;/text>
&lt;text x="20" y="130" class="ts">Pin model, precision, hardware, dataset and SLO before step 1; record them in the output of step 4.&lt;/text>
&lt;/svg>
&lt;/div>
&lt;ol>
&lt;li>&lt;strong>Deploy&lt;/strong> the engine with the exact config to be measured (model, precision, flags),
pinned.&lt;/li>
&lt;li>&lt;strong>Warm up&lt;/strong>: send a few requests and discard them, so that the prefix cache and the
autotuning do not inflate the first numbers.&lt;/li>
&lt;li>&lt;strong>Sweep&lt;/strong>: sweep rising concurrencies (1, 8, 16, 24, 32…) beyond the knee, to see where
latency takes off.&lt;/li>
&lt;li>&lt;strong>Collect&lt;/strong> the output in JSON with percentiles (TTFT/ITL/throughput/goodput) and the
metadata.&lt;/li>
&lt;li>&lt;strong>Compare&lt;/strong>: read the goodput under the SLO, not the maximum throughput.&lt;/li>
&lt;/ol>
&lt;p>Skipping step 2 (warm-up) or not going past the knee in step 3 are the two mistakes that bias
the result the most.&lt;/p>
&lt;hr>
&lt;h2 id="comparing-two-engines-the-fair-protocol">Comparing two engines: the fair protocol&lt;/h2>
&lt;p>If the goal is to choose between vLLM and SGLang (or TRT-LLM), the protocol that avoids false
conclusions:&lt;/p>
&lt;ul>
&lt;li>&lt;strong>Same load tool&lt;/strong> (GuideLLM or AIPerf) against both endpoints, never each engine&amp;rsquo;s own
micro-bench.&lt;/li>
&lt;li>&lt;strong>Same dataset and length distribution.&lt;/strong>&lt;/li>
&lt;li>&lt;strong>Same hardware and precision.&lt;/strong>&lt;/li>
&lt;li>&lt;strong>Same SLO&lt;/strong> to compute the goodput of both.&lt;/li>
&lt;li>Vary only the engine; everything else fixed.&lt;/li>
&lt;/ul>
&lt;p>Only then is the difference you measure down to the engine and not to the tool, the dataset or
the hardware. It is the controlled experiment that supports the scorecard row (article B8).&lt;/p>
&lt;hr>
&lt;h2 id="output-format-and-comparability">Output format and comparability&lt;/h2>
&lt;p>What you record alongside the number is what makes it comparable. A useful output includes,
in JSON so it can be versioned:&lt;/p>
&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;tool&amp;#34;&lt;/span>&lt;span class="p">:&lt;/span> &lt;span class="s2">&amp;#34;guidellm&amp;#34;&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="nt">&amp;#34;version&amp;#34;&lt;/span>&lt;span class="p">:&lt;/span> &lt;span class="s2">&amp;#34;x.y.z&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&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;FP16&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;8xH100 SXM 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;load&amp;#34;&lt;/span>&lt;span class="p">:&lt;/span> &lt;span class="p">{&lt;/span>&lt;span class="nt">&amp;#34;prompt_tokens&amp;#34;&lt;/span>&lt;span class="p">:&lt;/span> &lt;span class="mi">1024&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="nt">&amp;#34;output_tokens&amp;#34;&lt;/span>&lt;span class="p">:&lt;/span> &lt;span class="mi">256&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="nt">&amp;#34;concurrency&amp;#34;&lt;/span>&lt;span class="p">:&lt;/span> &lt;span class="mi">16&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&amp;#34;&lt;/span>&lt;span class="p">:&lt;/span> &lt;span class="p">{&lt;/span>&lt;span class="nt">&amp;#34;ttft_p50_ms&amp;#34;&lt;/span>&lt;span class="p">:&lt;/span> &lt;span class="mi">180&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">460&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">22&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">3400&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="nt">&amp;#34;goodput_tok_s&amp;#34;&lt;/span>&lt;span class="p">:&lt;/span> &lt;span class="mi">3330&lt;/span>&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>Saving this for every run turns a benchmark into an auditable datum: anyone can reproduce the
figure with the same tool, version, model, hardware and load. It is the material of the
reproducible harness (article S4), and the difference between a defensible number and a
console screenshot.&lt;/p>
&lt;hr>
&lt;h2 id="worked-example-reading-the-output-of-a-sweep">Worked example: reading the output of a sweep&lt;/h2>
&lt;p>An illustrative output from a GuideLLM sweep on a 70B on 8×H100 (SLO: P99 TTFT &amp;lt; 500 ms), just
as you would read it:&lt;/p>
&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>240&lt;/td>
&lt;td>20&lt;/td>
&lt;td>2,100&lt;/td>
&lt;td>2,100&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>16&lt;/td>
&lt;td>460&lt;/td>
&lt;td>22&lt;/td>
&lt;td>3,400&lt;/td>
&lt;td>3,330&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>24&lt;/td>
&lt;td>980&lt;/td>
&lt;td>31&lt;/td>
&lt;td>3,900&lt;/td>
&lt;td>2,420&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>32&lt;/td>
&lt;td>1,800&lt;/td>
&lt;td>54&lt;/td>
&lt;td>4,000&lt;/td>
&lt;td>800&lt;/td>
&lt;/tr>
&lt;/tbody>
&lt;/table>
&lt;p>How to read it: the &lt;strong>knee&lt;/strong> sits between 16 and 24. At concurrency 16, P99 (460 ms) meets the
SLO and goodput (3,330 tok/s) ≈ throughput. At 24, throughput rises little (3,400 → 3,900) but
P99 already violates the SLO and goodput falls to 2,420. At 32, throughput is at its
maximum (4,000) but goodput collapses to 800: the system &amp;ldquo;performs well&amp;rdquo; while serving requests
that do not comply. The defensible capacity is the one at concurrency 16 (3,330 useful
tok/s), and that is the number that goes into &lt;a href="https://blog.lo0.es/en/posts/capacity-planning-on-premise-llm-inference/">capacity planning&lt;/a>
and into cost per token. Anyone reporting &amp;ldquo;4,000 tok/s&amp;rdquo; is describing the point where the
system no longer meets its SLO.&lt;/p>
&lt;hr>
&lt;h2 id="automating-the-harness">Automating the harness&lt;/h2>
&lt;p>A manual run does not scale to a benchmarking programme. The minimum automation:&lt;/p>
&lt;ul>
&lt;li>An &lt;strong>idempotent script&lt;/strong> that deploys the engine, warms it up, runs the sweep and saves the
JSON with all the metadata (model, version, hardware, dataset, SLO).&lt;/li>
&lt;li>&lt;strong>Naming by date and config&lt;/strong>, to version the runs and compare over time.&lt;/li>
&lt;li>A &lt;strong>results store&lt;/strong> (a git repo of JSONs, or a bucket) so that anyone can reproduce and
compare.&lt;/li>
&lt;/ul>
&lt;p>The goal is that reproducing a number is one command, not an afternoon. It is the basis of the
harness in article S4, and what turns benchmarking from a one-off activity into a continuous
platform capability.&lt;/p>
&lt;hr>
&lt;h2 id="ci-integration-continuous-benchmarking">CI integration: continuous benchmarking&lt;/h2>
&lt;p>The next level is measuring on every change: a CI job that, when the engine or the config
is updated, launches a short sweep against a test environment and &lt;strong>compares against the
baseline&lt;/strong>. If goodput falls by more than a threshold, the pipeline fails. That way a
performance regression is caught at the commit, not in production. Watch out for two things:
benchmarking in CI consumes GPU-hours (budget for it) and it needs a stable environment (same
hardware) for the comparison to be valid. You do not need the full sweep on every commit: a
short sweep covering the knee is enough to detect regressions; the exhaustive sweep is for
releases.&lt;/p>
&lt;hr>
&lt;h2 id="the-cost-of-measuring-in-euros">The cost of measuring (in euros)&lt;/h2>
&lt;p>Benchmarking consumes GPU-hours, and that has a cost worth budgeting for. A serious sweep
on an 8×H100 node can occupy the cards for a couple of hours; at an amortised cost of ~11 €/h,
that is ~22 € per full sweep, more if you sweep several models and precisions. It is not much
per run, but a continuous benchmarking programme (every release, every config change) adds up.
The practical rule: automate the harness so that each run is cheap and reproducible, and
measure what you are going to use to decide, not for the sake of exhaustiveness. The cost of
measuring is part of the cost of the platform, small next to the cost of serving but real.&lt;/p>
&lt;hr>
&lt;h2 id="observing-during-the-benchmark-one-run-three-axes">Observing during the benchmark: one run, three axes&lt;/h2>
&lt;p>A trick that saves work and connects the series: &lt;strong>while the sweep runs, capture the GPU
metrics too&lt;/strong>. With DCGM exporting to Prometheus during the run, you record at the same time:&lt;/p>
&lt;table>
&lt;thead>
&lt;tr>
&lt;th>Axis&lt;/th>
&lt;th>Source during the sweep&lt;/th>
&lt;th>Metric&lt;/th>
&lt;/tr>
&lt;/thead>
&lt;tbody>
&lt;tr>
&lt;td>Performance&lt;/td>
&lt;td>the tool (GuideLLM/AIPerf)&lt;/td>
&lt;td>TTFT, ITL, throughput, goodput&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>Energy&lt;/td>
&lt;td>DCGM&lt;/td>
&lt;td>power (W) → J/token&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>Cost&lt;/td>
&lt;td>node price (OpenCost)&lt;/td>
&lt;td>€/hour → CPM per point&lt;/td>
&lt;/tr>
&lt;/tbody>
&lt;/table>
&lt;p>That way, from a single run you get the three numbers for the same operating point: at
concurrency 16, the goodput (3,330 tok/s), the average power (from DCGM, which divided by the
throughput gives J/token) and the cost per token (with the node price). Instead of three
separate campaigns, you measure the three axes at once, and they end up &lt;strong>coherent by
construction&lt;/strong> because they correspond to the same instant and the same load. It is what the
harness in article S4 does, and the reason to export DCGM during the benchmark even if you are
only after performance: energy and cost come almost free if you capture them in the same
window.&lt;/p>
&lt;p>The methodological warning: align the time windows. The DCGM power figures and the tool&amp;rsquo;s
metrics have to cover exactly the same interval (no warm-up, no shutdown), or the J/token does
not correspond to the throughput measured. Same window, same three numbers.&lt;/p>
&lt;hr>
&lt;h2 id="the-benchmark-is-also-finops-and-energy">The benchmark is also FinOps and energy&lt;/h2>
&lt;p>An idea that connects this article with the rest of the series: &lt;strong>measuring performance is, in
fact, measuring cost and energy&lt;/strong>. By the identity of the &lt;a href="https://blog.lo0.es/en/posts/cost-performance-energy-three-axes-llm-inference/">opening article&lt;/a>,
goodput is the denominator of cost per token and of energy per token. When a sweep reveals
that config A gives 3,330 tok/s of goodput and config B gives 4,000, you are not only measuring
speed: you are measuring that B costs fewer euros and fewer watts per token. That is why the
output JSON of a benchmark should be accompanied by the iron cost (from OpenCost) to compute
the real CPM of every point in the sweep: throughput × node price = cost per token.
Benchmarking is not an isolated axis; it is the tool that, indirectly, moves the cost and the
energy of the platform the most, and the one that fills the performance column of the scorecard
with numbers that translate directly into euros.&lt;/p>
&lt;p>The operational consequence: do not benchmark performance in a vacuum. Every run you save with
its throughput and its goodput should be crossable with the node cost (€/hour) and the energy
(J/token) to give the three faces of the same operating point. That is how the benchmarking
track feeds the FinOps and energy tracks instead of living apart.&lt;/p>
&lt;hr>
&lt;h2 id="state-of-the-art-2026-and-limits">State of the art 2026 and limits&lt;/h2>
&lt;ul>
&lt;li>&lt;strong>Migration to multi-process&lt;/strong>: AIPerf (formerly genai-perf) and GuideLLM consolidate the
measurement of real capacity; micro-benches are left for tuning engines.&lt;/li>
&lt;li>&lt;strong>GuideLLM as the OSS standard&lt;/strong> for SLO-driven evaluation with an automatic report.&lt;/li>
&lt;li>&lt;strong>Careful with comparing across classes&lt;/strong>: a micro-bench and a load generator are not
comparable; pin the class and the tool.&lt;/li>
&lt;li>&lt;strong>Version and dataset matter&lt;/strong>: the same command with a different dataset or version gives a
different number; pin them.&lt;/li>
&lt;li>&lt;strong>MLPerf does not size your case&lt;/strong>: it compares vendors, it does not replace a sweep against
your workload.&lt;/li>
&lt;/ul>
&lt;p>With the practical catalogue covered, the next article in the track (B3) goes into GuideLLM
and SLO validation under load in depth. The tool is the means; the reproducible datum is the
end.&lt;/p>
&lt;h2 id="mistakes-that-invalidate-a-benchmark">Mistakes that invalidate a benchmark&lt;/h2>
&lt;p>To finish, the list of what turns a run into rubbish, by frequency:&lt;/p>
&lt;table>
&lt;thead>
&lt;tr>
&lt;th>Mistake&lt;/th>
&lt;th>Effect&lt;/th>
&lt;th>Fix&lt;/th>
&lt;/tr>
&lt;/thead>
&lt;tbody>
&lt;tr>
&lt;td>Comparing different classes&lt;/td>
&lt;td>up to 7× of spurious difference&lt;/td>
&lt;td>same tool for everyone&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>Not discarding the warm-up&lt;/td>
&lt;td>artificially low TTFT&lt;/td>
&lt;td>warm up and discard&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>Not going past the knee&lt;/td>
&lt;td>you do not know the safe capacity&lt;/td>
&lt;td>extend the sweep&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>Unrealistic dataset (fixed length)&lt;/td>
&lt;td>throughput that does not apply&lt;/td>
&lt;td>sharegpt or your own traces&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>Reporting the mean, not P99&lt;/td>
&lt;td>hides the tail&lt;/td>
&lt;td>percentiles always&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>Tokenizer foreign to the model&lt;/td>
&lt;td>biased tok/s and cost per token&lt;/td>
&lt;td>count with the model&amp;rsquo;s own&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>Not pinning version/config&lt;/td>
&lt;td>irreproducible&lt;/td>
&lt;td>save everything in the JSON&lt;/td>
&lt;/tr>
&lt;/tbody>
&lt;/table>
&lt;p>Any one of these is enough for the number not to be defensible. A benchmark is only as good as
its methodology: the tool matters less than running it properly and recording everything.&lt;/p>
&lt;h2 id="closing">Closing&lt;/h2>
&lt;p>The catalogue of benchmark tools boils down to a simple decision (micro-bench to tune an
engine, load generator to measure capacity, MLPerf to compare vendors) and to a discipline that
weighs more than the choice: &lt;strong>the method&lt;/strong>. The same &lt;code>guidellm benchmark&lt;/code> gives a golden datum
or a useless one depending on the dataset, the warm-up, how far the sweep goes and what you
record in the output. For a sovereign architecture proposal, performance only counts if it
comes with its command, its version, its load and its goodput under SLO, and, crossed with cost
in euros and energy per token, it becomes the scorecard column that decides which engine and
which configuration hold up the platform. You choose the tool in five minutes; the methodology
is what makes the number survive an audit.&lt;/p>
&lt;h2 id="see-also">See also&lt;/h2>
&lt;ul>
&lt;li>&lt;a href="https://blog.lo0.es/en/posts/genai-perf-llm-inference-profiling-in-depth/">GenAI-Perf in depth&lt;/a> — an extended profile of the NVIDIA profiler: metrics (TTFT/TPOT/ISL/OSL), invocation against an OpenAI-compatible endpoint and comparison with GuideLLM/LLMPerf.&lt;/li>
&lt;li>&lt;a href="https://blog.lo0.es/en/posts/llm-serving-engines-pareto-frontier-comparison/">LLM serving engines compared (vLLM/SGLang/TRT-LLM/Dynamo)&lt;/a> — once goodput has been measured with these tools, here is which engine wins at each point of the Pareto frontier.&lt;/li>
&lt;li>&lt;a href="https://blog.lo0.es/en/posts/measurement-bias-reproducibility-llm-benchmarks/">Measurement bias and reproducibility&lt;/a> — the sources of bias that invalidate results even when the tool is correctly configured: warm-up, real versus synthetic dataset, shared environment.&lt;/li>
&lt;/ul>
&lt;h2 id="sources">Sources&lt;/h2>
&lt;ul>
&lt;li>vLLM · CLI de benchmark (&lt;code>bench serve&lt;/code>) — &lt;a href="https://docs.vllm.ai/en/latest/cli/bench/serve/">https://docs.vllm.ai/en/latest/cli/bench/serve/&lt;/a>&lt;/li>
&lt;li>vLLM · &lt;code>bench sweep serve&lt;/code> — &lt;a href="https://docs.vllm.ai/en/latest/cli/bench/sweep/serve/">https://docs.vllm.ai/en/latest/cli/bench/sweep/serve/&lt;/a>&lt;/li>
&lt;li>Red Hat · desplegar y benchmarkear vLLM con GuideLLM en Kubernetes — &lt;a href="https://developers.redhat.com/articles/2025/12/24/how-deploy-and-benchmark-vllm-guidellm-kubernetes">https://developers.redhat.com/articles/2025/12/24/how-deploy-and-benchmark-vllm-guidellm-kubernetes&lt;/a>&lt;/li>
&lt;li>GuideLLM · GitHub (proyecto vLLM) — &lt;a href="https://github.com/vllm-project/guidellm">https://github.com/vllm-project/guidellm&lt;/a>&lt;/li>
&lt;li>NVIDIA AIPerf · guía de benchmarking — &lt;a href="https://lucaberton.com/blog/nvidia-aiperf-llm-inference-benchmarking-guide/">https://lucaberton.com/blog/nvidia-aiperf-llm-inference-benchmarking-guide/&lt;/a>&lt;/li>
&lt;li>Medium · Benchmarking LLM Serving Performance (guía) — &lt;a href="https://medium.com/@kimdoil1211/benchmarking-llm-serving-performance-a-comprehensive-guide-db94b1bfe8cf">https://medium.com/@kimdoil1211/benchmarking-llm-serving-performance-a-comprehensive-guide-db94b1bfe8cf&lt;/a>&lt;/li>
&lt;/ul></description></item><item><title>LLM inference benchmarking: frameworks, metrics and the state of the art (tool by tool)</title><link>https://blog.lo0.es/en/posts/llm-inference-benchmarking-frameworks-metrics-state-of-the-art/</link><pubDate>Sat, 13 Jun 2026 02:30:00 +0200</pubDate><guid>https://blog.lo0.es/en/posts/llm-inference-benchmarking-frameworks-metrics-state-of-the-art/</guid><description>&lt;blockquote>
&lt;p>Notation: amounts in &lt;strong>euros (N €)&lt;/strong>, decimals with a point. Performance is not very
sensitive to the country, but its associated cost (cost per token) is expressed in € and
links back to the &lt;a href="https://blog.lo0.es/en/posts/cost-performance-energy-three-axes-llm-inference/">opening article&lt;/a>.&lt;/p>
&lt;/blockquote>
&lt;h2 id="what-this-introduction-covers">What this introduction covers&lt;/h2>
&lt;p>Third article in the data series, a deep dive into the performance axis. Measuring the
performance of an inference engine looks trivial (&amp;ldquo;how many tokens per second?&amp;rdquo;) and it is
exactly where people fool themselves most: two tools can report results that differ by a
factor of 7 for the same system. This article inventories the metrics that matter and &lt;strong>how
they are defined&lt;/strong>, why the architecture of the tool biases the figure, how the saturation
point is found with a concurrency &lt;em>sweep&lt;/em>, and the profile of each framework. No
recommendations: the choice of engine is settled in the Pareto article (B8); here there are
only the facts and the methodology, because &lt;strong>a benchmark without a published methodology is
not comparable&lt;/strong>.&lt;/p>
&lt;hr>
&lt;h2 id="the-performance-metrics">The performance metrics&lt;/h2>
&lt;p>There is no single performance metric, there are five, and mixing them is the first source
of error:&lt;/p>
&lt;table>
&lt;thead>
&lt;tr>
&lt;th>Metric&lt;/th>
&lt;th>Definition&lt;/th>
&lt;th>Unit&lt;/th>
&lt;th>Dominant phase&lt;/th>
&lt;/tr>
&lt;/thead>
&lt;tbody>
&lt;tr>
&lt;td>&lt;strong>TTFT&lt;/strong> (Time To First Token)&lt;/td>
&lt;td>time from sending the prompt to the first token&lt;/td>
&lt;td>ms&lt;/td>
&lt;td>prefill&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>&lt;strong>TPOT / ITL&lt;/strong> (Time Per Output Token / Inter-Token Latency)&lt;/td>
&lt;td>average time between output tokens once generation has started&lt;/td>
&lt;td>ms/token&lt;/td>
&lt;td>decode&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>&lt;strong>Request throughput&lt;/strong>&lt;/td>
&lt;td>complete request-response cycles per second at the concurrency tested&lt;/td>
&lt;td>req/s&lt;/td>
&lt;td>both&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>&lt;strong>Token throughput&lt;/strong>&lt;/td>
&lt;td>total tokens (input + output) per second across all concurrent requests&lt;/td>
&lt;td>tok/s&lt;/td>
&lt;td>both&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>&lt;strong>Goodput&lt;/strong>&lt;/td>
&lt;td>share of requests that &lt;strong>meet the defined SLO&lt;/strong>&lt;/td>
&lt;td>useful tok/s&lt;/td>
&lt;td>both&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>&lt;strong>P50 / P95 / P99&lt;/strong>&lt;/td>
&lt;td>latency percentiles (not the mean)&lt;/td>
&lt;td>ms&lt;/td>
&lt;td>—&lt;/td>
&lt;/tr>
&lt;/tbody>
&lt;/table>
&lt;p>Precise definitions, because every tool computes them its own way (&lt;a href="https://docs.anyscale.com/llm/serving/benchmarking/metrics">Anyscale · latency and
throughput metrics&lt;/a>): TTFT is
what a user waits before seeing the first character, dominated by prefill compute; ITL is
the average time between successive output tokens and sets the perceived &amp;ldquo;typing speed&amp;rdquo; of
the answer; request throughput is complete cycles per second; token throughput is
total tokens (input plus output) per second across all concurrent requests.&lt;/p>
&lt;h3 id="the-latency-decomposition">The latency decomposition&lt;/h3>
&lt;p>The total latency of a request with \(N\) output tokens decomposes as:&lt;/p>
$$\text{latency} \approx \text{TTFT} + (N-1)\times \text{TPOT}$$
&lt;p>That is why TTFT and TPOT are reported separately: a single mean latency hides very
different profiles. A system with high TTFT and low TPOT (expensive prefill, fast decode) and
another one the other way round can have the same mean latency for one particular length, yet
behave in opposite ways when the response size changes. For an interactive chat experience
TTFT and TPOT rule; for a batch of long summaries, token throughput does. Measuring the mean
hides both realities.&lt;/p>
&lt;h3 id="goodput-the-honest-metric">Goodput: the honest metric&lt;/h3>
&lt;p>Raw throughput (TPS, RPS) says how much work the system does; &lt;strong>goodput&lt;/strong> says how much
of that work meets your quality-of-service standards (SLO)
(&lt;a href="https://docs.anyscale.com/llm/serving/benchmarking/metrics">Anyscale&lt;/a>). An engine can boast
10,000 aggregate tok/s, but if half the requests violate the P99 TTFT SLO, its goodput is
5,000. The figure you defend in a proposal is the &lt;strong>goodput&lt;/strong>, not the catalogue throughput:
it is the only one that translates into satisfied users and an honest cost per token.&lt;/p>
&lt;hr>
&lt;h2 id="how-each-metric-is-instrumented-and-where-the-error-creeps-in">How each metric is instrumented (and where the error creeps in)&lt;/h2>
&lt;p>Before comparing numbers it helps to know where each clock starts and stops, because two
tools can call different things &amp;ldquo;TTFT&amp;rdquo;:&lt;/p>
&lt;ul>
&lt;li>&lt;strong>Client-side versus server-side TTFT.&lt;/strong> The TTFT measured by the client includes network
latency and gateway queueing; the one measured by the server does not. For an engine
comparison the server-side figure is the one that matters; for user experience, the
client-side one. Mixing the two invalidates the comparison.&lt;/li>
&lt;li>&lt;strong>Streaming is required.&lt;/strong> TTFT and ITL can only be measured if the response arrives as a
&lt;em>stream&lt;/em> (token by token). If the tool measures complete responses, there is no real TTFT:
there is total latency in disguise.&lt;/li>
&lt;li>&lt;strong>Token counting.&lt;/strong> Throughput in tok/s depends on which tokenizer counts the tokens.
If the tool uses a tokenizer different from the model&amp;rsquo;s, the token count (and therefore the
tok/s and the cost per token) is biased. You have to count with the tokenizer of the model
being served.&lt;/li>
&lt;li>&lt;strong>Warm-up and prefix cache.&lt;/strong> The first requests of a benchmark benefit from a warm prefix
cache and give artificially low TTFT; the warm-up has to be discarded or the result
inflates reality.&lt;/li>
&lt;/ul>
&lt;p>These four instrumentation decisions explain a good share of the discrepancies between tools.
A performance figure that does not say where the clock is measured and with which tokenizer
is not comparable, however precise it may look.&lt;/p>
&lt;hr>
&lt;h2 id="the-architecture-of-the-tool-biases-the-figure">The architecture of the tool biases the figure&lt;/h2>
&lt;p>Here is the trap that invalidates half the published benchmarks. Tools split into two classes
by the architecture of the client that generates the load, and that architecture
determines whether the measurement is reliable at high concurrency:&lt;/p>
&lt;ul>
&lt;li>&lt;strong>Single-process micro-bench&lt;/strong> (vLLM bench, SGLang bench, genai-perf): a Python client with
asyncio in a single process. Useful for quick experiments on one engine, but the
single-process architecture introduces a bottleneck on the client side that &lt;strong>biases the
data at high concurrency&lt;/strong> (&lt;a href="https://kchandan.medium.com/llm-inference-benchmarking-genai-perf-and-vllm-5dd06b57428e">genAI-perf and vLLM&lt;/a>):
the client cannot generate enough load and you measure the limit of the client, not the
limit of the engine.&lt;/li>
&lt;li>&lt;strong>Multi-process load&lt;/strong> (GuideLLM, AIPerf): they spread load generation across several
processes, avoiding that limit. This is the class that has emerged for measuring at real
scale.&lt;/li>
&lt;/ul>
&lt;p>The size of the bias is enormous: at 1,000 QPS, a single-process benchmark processed
75,574 tokens against the 545,733 tokens of a distributed architecture, a
discrepancy of 7.2× in measurement capacity for the same system ([search]). Anyone
comparing two engines with tools from different classes is not comparing the engines: they
are comparing the benchmark clients.&lt;/p>
&lt;div class="diagram" style="max-width:780px;margin:1rem auto;">
&lt;svg viewBox="0 0 780 210" role="img" aria-label="Two classes of benchmark tool: a single-process client that saturates at high concurrency versus a multi-process load generator that measures the engine" xmlns="http://www.w3.org/2000/svg">
&lt;style>.bx{fill:none;stroke:currentColor;stroke-width:1.3}.dsh{fill:none;stroke:currentColor;stroke-width:1.3;stroke-dasharray:5 3}.tl{font:600 12px sans-serif;fill:currentColor}.ts{font:11px sans-serif;fill:currentColor}.ar{fill:none;stroke:currentColor;stroke-width:1.3;marker-end:url(#bm)}&lt;/style>
&lt;defs>&lt;marker id="bm" viewBox="0 0 10 10" refX="9" refY="5" markerWidth="7" markerHeight="7" orient="auto">&lt;path d="M0,0 L10,5 L0,10 z" fill="currentColor"/>&lt;/marker>&lt;/defs>
&lt;text x="20" y="26" class="tl">Single-process (vLLM bench, SGLang bench, genai-perf)&lt;/text>
&lt;rect class="bx" x="20" y="36" width="150" height="40" rx="6"/>
&lt;text x="32" y="62" class="ts">1 asyncio client&lt;/text>
&lt;path class="ar" d="M170,58 L215,58"/>
&lt;rect class="dsh" x="215" y="36" width="120" height="40" rx="6"/>
&lt;text x="227" y="56" class="ts">client bottleneck&lt;/text>
&lt;text x="227" y="71" class="ts">bias at high conc.&lt;/text>
&lt;path class="ar" d="M335,58 L380,58"/>
&lt;rect class="bx" x="380" y="36" width="110" height="40" rx="6"/>
&lt;text x="392" y="62" class="ts">engine (vLLM…)&lt;/text>
&lt;text x="510" y="52" class="ts">you measure the client,&lt;/text>
&lt;text x="510" y="68" class="ts">not the engine&lt;/text>
&lt;text x="20" y="118" class="tl">Multi-process (GuideLLM, AIPerf)&lt;/text>
&lt;rect class="bx" x="20" y="128" width="150" height="46" rx="6"/>
&lt;text x="32" y="148" class="ts">N load processes&lt;/text>
&lt;text x="32" y="164" class="ts">(real load)&lt;/text>
&lt;path class="ar" d="M170,151 L380,151"/>
&lt;rect class="bx" x="380" y="131" width="110" height="40" rx="6"/>
&lt;text x="392" y="155" class="ts">engine (vLLM…)&lt;/text>
&lt;text x="510" y="145" class="ts">you measure the engine;&lt;/text>
&lt;text x="510" y="161" class="ts">7.2× more capacity&lt;/text>
&lt;/svg>
&lt;/div>
&lt;hr>
&lt;h2 id="frameworks-one-by-one">Frameworks, one by one&lt;/h2>
&lt;h3 id="vllm-bench-and-sglang-bench--engine-micro-bench">vLLM bench and SGLang bench — engine micro-bench&lt;/h3>
&lt;p>What they measure: TTFT, TPOT and throughput of the engine itself (vLLM or SGLang). Class:
single-process micro-bench. Use: quick experiments to tune one engine and see the effect of
its optimisations (see &lt;a href="https://blog.lo0.es/en/posts/optimising-decode-vllm-squeezing-every-token-small-hardware/">decode&lt;/a> and
&lt;a href="https://blog.lo0.es/en/posts/optimising-prefill-vllm-knobs-ttft-will-not-forgive/">prefill&lt;/a>). Limit: they saturate on the client at
high concurrency; they are no good for measuring real capacity at scale.&lt;/p>
&lt;h3 id="aiperf--the-nvidia-one-formerly-genai-perf-multi-process">AIPerf — the NVIDIA one (formerly genai-perf), multi-process&lt;/h3>
&lt;p>What it measures: TTFT, ITL, throughput and latency against &lt;strong>vLLM, NIM, TGI and any
compatible endpoint&lt;/strong>. Class: multi-process load. State-of-the-art datum: NVIDIA &lt;strong>retired
genai-perf and replaced it with AIPerf on 15 April 2026&lt;/strong>. During the &lt;em>sweep&lt;/em>, AIPerf
detects GPU saturation and identifies the previous iteration, returning it as
&lt;code>estimatedCapacity&lt;/code>; if it detects no saturation, &lt;code>estimatedCapacity&lt;/code> is the last iteration
tested, which is why the sweep has to extend beyond the knee
(&lt;a href="https://lucaberton.com/blog/nvidia-aiperf-llm-inference-benchmarking-guide/">AIPerf&lt;/a>).&lt;/p>
&lt;h3 id="guidellm--from-the-vllm-project-slo-oriented">GuideLLM — from the vLLM project, SLO-oriented&lt;/h3>
&lt;p>What it measures: full distributions of TTFT, ITL and end-to-end behaviour, for
SLO-driven evaluation. Class: multi-process load. Differentiator: it generates realistic,
configurable traffic patterns in synchronous, concurrent and rate-based modes, including
reproducible sweeps to identify safe operating ranges
(&lt;a href="https://developers.redhat.com/articles/2025/06/20/guidellm-evaluate-llm-deployments-real-world-inference">Red Hat · GuideLLM&lt;/a>,
&lt;a href="https://github.com/vllm-project/guidellm">GuideLLM · GitHub&lt;/a>). It is the tool for answering
&amp;ldquo;how hard can I load this engine without breaking the SLO?&amp;rdquo;.&lt;/p>
&lt;h3 id="llmperf--the-anyscaleray-classic">LLMPerf — the Anyscale/Ray classic&lt;/h3>
&lt;p>What it measures: throughput and latency at the inference level. Use: endpoint validation,
historically very widespread. Class: load generator. Limit: less focused on distributions and
sweeps than GuideLLM/AIPerf.&lt;/p>
&lt;h3 id="mlperf-inference--the-industry-standard">MLPerf Inference — the industry standard&lt;/h3>
&lt;p>What it measures: performance under standardised scenarios with strict rules, for
comparability across vendors. Maintainer: &lt;strong>MLCommons&lt;/strong>. It is the gold standard of
cross-vendor comparability; it is expanded on below.&lt;/p>
&lt;h3 id="comparison-table">Comparison table&lt;/h3>
&lt;table>
&lt;thead>
&lt;tr>
&lt;th>Tool&lt;/th>
&lt;th>Class&lt;/th>
&lt;th>What it measures&lt;/th>
&lt;th>Maintainer&lt;/th>
&lt;th>When to use it&lt;/th>
&lt;/tr>
&lt;/thead>
&lt;tbody>
&lt;tr>
&lt;td>&lt;strong>vLLM bench&lt;/strong>&lt;/td>
&lt;td>single-process micro&lt;/td>
&lt;td>TTFT, TPOT, throughput of vLLM&lt;/td>
&lt;td>vLLM (OSS)&lt;/td>
&lt;td>tune vLLM, quick experiments&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>&lt;strong>SGLang bench&lt;/strong>&lt;/td>
&lt;td>single-process micro&lt;/td>
&lt;td>metrics of the SGLang engine&lt;/td>
&lt;td>SGLang (OSS)&lt;/td>
&lt;td>tune SGLang&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>&lt;strong>AIPerf&lt;/strong> (formerly genai-perf)&lt;/td>
&lt;td>multi-process load&lt;/td>
&lt;td>TTFT, ITL, throughput; estimatedCapacity&lt;/td>
&lt;td>NVIDIA (OSS)&lt;/td>
&lt;td>real capacity, multi-endpoint&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>&lt;strong>GuideLLM&lt;/strong>&lt;/td>
&lt;td>multi-process load&lt;/td>
&lt;td>distributions, SLO, sweeps&lt;/td>
&lt;td>vLLM (OSS)&lt;/td>
&lt;td>validate SLO, find the knee&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>&lt;strong>LLMPerf&lt;/strong>&lt;/td>
&lt;td>load&lt;/td>
&lt;td>throughput and latency&lt;/td>
&lt;td>Anyscale/Ray (OSS)&lt;/td>
&lt;td>endpoint validation&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>&lt;strong>MLPerf Inference&lt;/strong>&lt;/td>
&lt;td>standard suite&lt;/td>
&lt;td>server/offline/interactive scenarios&lt;/td>
&lt;td>MLCommons&lt;/td>
&lt;td>cross-vendor comparability&lt;/td>
&lt;/tr>
&lt;/tbody>
&lt;/table>
&lt;hr>
&lt;h2 id="the-concurrency-sweep-finding-the-knee">The concurrency sweep: finding the knee&lt;/h2>
&lt;p>The most useful measurement for sizing is not a number, it is a curve: how latency and
throughput change as concurrency rises. Raising concurrency keeps the GPU busier and raises
RPS, but past a certain point it sends TTFT, ITL and end-to-end latency through the roof
([search]). The goal of the &lt;em>sweep&lt;/em> is to find the &lt;strong>knee&lt;/strong>: the maximum concurrency where
throughput still rises without latency breaking the SLO.&lt;/p>
&lt;div class="diagram" style="max-width:780px;margin:1rem auto;">
&lt;svg viewBox="0 0 780 260" role="img" aria-label="Concurrency sweep curve: throughput rises and saturates while latency spikes beyond the knee, which marks the safe capacity under SLO" 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.6}.dsh{fill:none;stroke:currentColor;stroke-width:1;stroke-dasharray:4 3}.tl{font:600 12px sans-serif;fill:currentColor}.ts{font:11px sans-serif;fill:currentColor}&lt;/style>
&lt;line class="ax" x1="60" y1="40" x2="60" y2="200"/>
&lt;line class="ax" x1="60" y1="200" x2="720" y2="200"/>
&lt;text x="20" y="120" class="ts" transform="rotate(-90 20 120)">metric&lt;/text>
&lt;text x="360" y="228" class="ts">concurrency →&lt;/text>
&lt;path class="cv" d="M60,190 C200,150 300,120 400,112 C520,104 620,100 700,98"/>
&lt;text x="600" y="92" class="ts">throughput (saturates)&lt;/text>
&lt;path class="cv" d="M60,180 C260,176 360,170 430,150 C520,120 600,70 700,46"/>
&lt;text x="600" y="40" class="ts">latency (spikes)&lt;/text>
&lt;line class="dsh" x1="430" y1="40" x2="430" y2="200"/>
&lt;text x="392" y="56" class="tl">knee&lt;/text>
&lt;text x="362" y="216" class="ts">safe capacity under SLO&lt;/text>
&lt;text x="60" y="250" class="ts">Before the knee, raising concurrency gives more throughput for free. After it, latency breaks the SLO with barely any gain.&lt;/text>
&lt;/svg>
&lt;/div>
&lt;p>That is why AIPerf extends the sweep beyond the knee: only by seeing where latency takes
off can it return the safe capacity (&lt;code>estimatedCapacity&lt;/code>). This curve is the raw material of
&lt;a href="https://blog.lo0.es/en/posts/capacity-planning-on-premise-llm-inference/">capacity planning&lt;/a>: the knee
yields the replica count and the cost per token at the target load.&lt;/p>
&lt;hr>
&lt;h3 id="worked-example-reading-a-sweep">Worked example: reading a sweep&lt;/h3>
&lt;p>An illustrative sweep on an example node (a 70B on 8×H100, SLO of P99 TTFT &amp;lt; 500 ms), to
see how the knee is read:&lt;/p>
&lt;table>
&lt;thead>
&lt;tr>
&lt;th>Concurrency&lt;/th>
&lt;th>RPS&lt;/th>
&lt;th>TTFT P50 (ms)&lt;/th>
&lt;th>TTFT P99 (ms)&lt;/th>
&lt;th>Token tput (tok/s)&lt;/th>
&lt;th>Goodput&lt;/th>
&lt;/tr>
&lt;/thead>
&lt;tbody>
&lt;tr>
&lt;td>1&lt;/td>
&lt;td>2&lt;/td>
&lt;td>80&lt;/td>
&lt;td>110&lt;/td>
&lt;td>350&lt;/td>
&lt;td>100 %&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>8&lt;/td>
&lt;td>14&lt;/td>
&lt;td>110&lt;/td>
&lt;td>240&lt;/td>
&lt;td>2,100&lt;/td>
&lt;td>100 %&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>16&lt;/td>
&lt;td>22&lt;/td>
&lt;td>180&lt;/td>
&lt;td>460&lt;/td>
&lt;td>3,400&lt;/td>
&lt;td>98 %&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>24&lt;/td>
&lt;td>26&lt;/td>
&lt;td>320&lt;/td>
&lt;td>980&lt;/td>
&lt;td>3,900&lt;/td>
&lt;td>62 %&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>32&lt;/td>
&lt;td>27&lt;/td>
&lt;td>540&lt;/td>
&lt;td>1,800&lt;/td>
&lt;td>4,000&lt;/td>
&lt;td>20 %&lt;/td>
&lt;/tr>
&lt;/tbody>
&lt;/table>
&lt;p>Reading: up to concurrency ~16 throughput grows and P99 stays under the SLO (goodput
~100 %). Between 16 and 24 sits the &lt;strong>knee&lt;/strong>: throughput barely rises any more (3,400 →
3,900 tok/s) but P99 takes off (460 → 980 ms) and goodput collapses (98 % → 62 %). At
concurrency 32 raw throughput is at its maximum (4,000 tok/s) but goodput is 20 %: the
system &amp;ldquo;performs well&amp;rdquo; while serving mostly requests that violate the SLO. The defensible
safe capacity is the one at concurrency ~16, not the one at maximum throughput. This is the
number that goes into &lt;a href="https://blog.lo0.es/en/posts/capacity-planning-on-premise-llm-inference/">capacity planning&lt;/a>
and into cost per token: at 3,400 useful tok/s, not 4,000 raw tok/s.&lt;/p>
&lt;hr>
&lt;h2 id="mlperf-inference-the-comparability-standard">MLPerf Inference: the comparability standard&lt;/h2>
&lt;p>To compare across vendors and engines with identical rules there is MLPerf Inference
(MLCommons). The datacenter category focuses on two scenarios plus an optional one
(&lt;a href="https://mlcommons.org/benchmarks/inference-datacenter/">MLCommons · datacenter&lt;/a>):&lt;/p>
&lt;table>
&lt;thead>
&lt;tr>
&lt;th>Scenario&lt;/th>
&lt;th>What it simulates&lt;/th>
&lt;th>Metric&lt;/th>
&lt;/tr>
&lt;/thead>
&lt;tbody>
&lt;tr>
&lt;td>&lt;strong>Offline&lt;/strong>&lt;/td>
&lt;td>raw throughput processing the whole dataset in batch&lt;/td>
&lt;td>maximum throughput&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>&lt;strong>Server&lt;/strong>&lt;/td>
&lt;td>interactive environment: requests one at a time following Poisson at a mean RPS&lt;/td>
&lt;td>RPS under TTFT and TPOT limits&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>&lt;strong>Interactive&lt;/strong> (optional)&lt;/td>
&lt;td>like server but with &lt;strong>stricter latency limits&lt;/strong>&lt;/td>
&lt;td>RPS under a hard SLO&lt;/td>
&lt;/tr>
&lt;/tbody>
&lt;/table>
&lt;p>The Server scenario is the realistic one for online inference: the load generator sends
requests following a Poisson distribution and demands that specific TTFT and TPOT bounds
be met. &lt;strong>MLPerf Inference v5.0&lt;/strong> (April 2025) introduced a large-scale 405B benchmark
and a low-latency interactive 70B one, offering language benchmarks at every scale (7B to
405B), architectural diversity (including MoE) and scenarios
(&lt;a href="https://mlcommons.org/2025/04/llm-inference-v5/">MLCommons · v5.0&lt;/a>); &lt;strong>v5.1&lt;/strong> (September
2025) widened the results with record participation
(&lt;a href="https://mlcommons.org/2025/09/mlperf-inference-v5-1-results/">MLCommons · v5.1&lt;/a>).&lt;/p>
&lt;p>The value of MLPerf is comparability: everyone measures the same thing under the same
rules. Its limit is that those rules may not match your workload (your length distribution,
your specific SLO), so it serves to compare hardware and engines against each other, not
necessarily to size your case. For that, your own sweep.&lt;/p>
&lt;hr>
&lt;h2 id="measurement-bias-and-reproducibility">Measurement bias and reproducibility&lt;/h2>
&lt;p>That two benchmarks give very different results for the same system is no accident:
systematic measurement bias in production benchmarks is characterised in the literature
(&lt;a href="https://arxiv.org/html/2605.24217">arXiv 2605.24217&lt;/a>), and there is work dedicated to the
meta-metrics and good practices of system-level performance benchmarking
(&lt;a href="https://arxiv.org/pdf/2508.10251">arXiv 2508.10251&lt;/a>). The most common sources of bias:&lt;/p>
&lt;table>
&lt;thead>
&lt;tr>
&lt;th>Source of bias&lt;/th>
&lt;th>Effect&lt;/th>
&lt;th>Mitigation&lt;/th>
&lt;/tr>
&lt;/thead>
&lt;tbody>
&lt;tr>
&lt;td>Single-process client&lt;/td>
&lt;td>underestimates throughput at high concurrency&lt;/td>
&lt;td>use multi-process load&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>Unrealistic length distribution&lt;/td>
&lt;td>results that do not apply to your traffic&lt;/td>
&lt;td>use realistic traces&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>Measuring the mean instead of percentiles&lt;/td>
&lt;td>hides the latency tail&lt;/td>
&lt;td>report P95/P99&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>Uncontrolled warm-up&lt;/td>
&lt;td>the prefix cache inflates the first results&lt;/td>
&lt;td>discard the warm-up&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>Not pinning engine/model version&lt;/td>
&lt;td>irreproducible&lt;/td>
&lt;td>pin everything and publish it&lt;/td>
&lt;/tr>
&lt;/tbody>
&lt;/table>
&lt;p>The methodological conclusion of this article: &lt;strong>a benchmark without a published methodology
is not comparable&lt;/strong>. For a performance figure to support a proposal it has to come with the
tool, its version, the model and precision, the load distribution and the SLO. Synthesis
article S4 builds a reproducible harness that pins all of that.&lt;/p>
&lt;hr>
&lt;h2 id="checklist-for-a-reproducible-benchmark">Checklist for a reproducible benchmark&lt;/h2>
&lt;p>For a performance figure to be defensible before a committee, it has to come with everything
that allows it to be reproduced. The minimum published alongside the result:&lt;/p>
&lt;table>
&lt;thead>
&lt;tr>
&lt;th>What to pin&lt;/th>
&lt;th>Why&lt;/th>
&lt;/tr>
&lt;/thead>
&lt;tbody>
&lt;tr>
&lt;td>Tool + version&lt;/td>
&lt;td>each one measures differently; the version changes behaviour&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>Model + precision (FP16/FP8/INT4)&lt;/td>
&lt;td>precision changes throughput and quality&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>Hardware (GPU, count, interconnect)&lt;/td>
&lt;td>an 8×H100 NVLink is not an 8×H100 PCIe&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>Engine + version + flags&lt;/td>
&lt;td>vLLM/SGLang/TRT-LLM and their configuration&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>Length distribution (in/out)&lt;/td>
&lt;td>real traffic is not fixed length&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>Concurrency levels of the sweep&lt;/td>
&lt;td>you have to go past the knee&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>SLO (which percentile, which threshold)&lt;/td>
&lt;td>it defines the goodput&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>Warm-up handling&lt;/td>
&lt;td>discard it or it biases the result&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>Tokenizer used for counting&lt;/td>
&lt;td>it affects tok/s and cost per token&lt;/td>
&lt;/tr>
&lt;/tbody>
&lt;/table>
&lt;p>The practical rule: if you cannot hand over this table alongside the figure, the figure is
not a datum, it is an anecdote. The reproducible harness of article S4 automates the recording
of all these parameters so that anyone, including whoever challenges the proposal, can
reproduce the exact number.&lt;/p>
&lt;hr>
&lt;h2 id="performance--quality">Performance ≠ quality&lt;/h2>
&lt;p>One warning that avoids the most expensive mistake: these tools measure &lt;strong>speed and
throughput, not accuracy&lt;/strong>. An engine can be blisteringly fast while serving a model that
answers badly. Quality is measured with another family of tools
(&lt;strong>lm-evaluation-harness&lt;/strong>, &lt;strong>HELM&lt;/strong>, task &lt;em>leaderboards&lt;/em>) which is another axis of the
scorecard (article B7). Confusing &amp;ldquo;fast&amp;rdquo; with &amp;ldquo;good&amp;rdquo; is how you build a platform that serves
bad answers very quickly. On the final Pareto frontier, performance and quality are two
distinct axes that must be looked at together, never one instead of the other.&lt;/p>
&lt;p>The other family of tools, for reference (expanded in B7):&lt;/p>
&lt;table>
&lt;thead>
&lt;tr>
&lt;th>Tool&lt;/th>
&lt;th>What it measures&lt;/th>
&lt;th>Common trap&lt;/th>
&lt;/tr>
&lt;/thead>
&lt;tbody>
&lt;tr>
&lt;td>&lt;strong>lm-evaluation-harness&lt;/strong>&lt;/td>
&lt;td>accuracy on hundreds of standardised tasks&lt;/td>
&lt;td>contamination of the test dataset&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>&lt;strong>HELM&lt;/strong>&lt;/td>
&lt;td>holistic evaluation (accuracy, robustness, bias, efficiency)&lt;/td>
&lt;td>heavy to run&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>&lt;strong>LiveBench / dynamic leaderboards&lt;/strong>&lt;/td>
&lt;td>tasks that rotate to avoid contamination&lt;/td>
&lt;td>comparability over time&lt;/td>
&lt;/tr>
&lt;/tbody>
&lt;/table>
&lt;p>The data point: contamination, the model having seen the test during training, inflates
quality metrics just as warm-up inflates performance ones. That is why dynamic leaderboards
rotate their questions. Quality and performance share that lesson: the measurement method
biases the result as much as the system being measured.&lt;/p>
&lt;hr>
&lt;h2 id="the-latency-versus-throughput-trade-off">The latency versus throughput trade-off&lt;/h2>
&lt;p>A property the sweep reveals and which is worth keeping in mind: &lt;strong>latency and throughput
pull in opposite directions&lt;/strong>. Batching groups requests to amortise the cost of moving the
weights out of VRAM, which raises throughput, but each request waits for the batch to form,
which raises individual latency. There are two operating regimes, and the benchmark
serves to place you in the right one:&lt;/p>
&lt;table>
&lt;thead>
&lt;tr>
&lt;th>Regime&lt;/th>
&lt;th>Optimises&lt;/th>
&lt;th>Configuration&lt;/th>
&lt;th>Case&lt;/th>
&lt;/tr>
&lt;/thead>
&lt;tbody>
&lt;tr>
&lt;td>&lt;strong>Latency&lt;/strong>&lt;/td>
&lt;td>low TTFT/TPOT&lt;/td>
&lt;td>small batch, low concurrency&lt;/td>
&lt;td>interactive chat, copilots&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>&lt;strong>Throughput&lt;/strong>&lt;/td>
&lt;td>maximum tok/s&lt;/td>
&lt;td>large batch, high concurrency&lt;/td>
&lt;td>overnight batch, ingestion&lt;/td>
&lt;/tr>
&lt;/tbody>
&lt;/table>
&lt;p>There is no single &amp;ldquo;best&amp;rdquo; point: there is the best point for your SLO. A benchmark that
reports only maximum throughput is describing the throughput regime and ignoring whether that
point meets the latency your case needs. That is why &lt;strong>goodput&lt;/strong>, throughput under the SLO,
is the metric that reconciles the two regimes: it measures how much throughput you get
without stepping outside acceptable latency. The sweep traverses the curve between both
regimes; your SLO marks where on that curve your system sits.&lt;/p>
&lt;hr>
&lt;h2 id="the-connection-with-cost-and-energy">The connection with cost and energy&lt;/h2>
&lt;p>Performance is not an isolated axis: by the identity of the &lt;a href="https://blog.lo0.es/en/posts/cost-performance-energy-three-axes-llm-inference/">opening article&lt;/a>,
throughput is the denominator of cost per token and of energy per token. A sweep that finds a
knee at 4,000 tok/s rather than 2,800 is not just &amp;ldquo;faster&amp;rdquo;: it lowers the CPM from ~1.09 to
~0.76 €/1M tok and the energy per token in the same proportion, on the same iron. That is
why performance benchmarking is the tool that, indirectly, moves cost the most: every goodput
improvement translates into euros and into watts per token. The number that connects the
three axes is &lt;strong>goodput&lt;/strong>, the throughput that meets the SLO, not catalogue throughput.&lt;/p>
&lt;hr>
&lt;h2 id="state-of-the-art-2026">State of the art 2026&lt;/h2>
&lt;ul>
&lt;li>&lt;strong>genai-perf → AIPerf migration&lt;/strong> (15 Apr 2026): NVIDIA consolidates its benchmarking into
a multi-process tool with saturation detection.&lt;/li>
&lt;li>&lt;strong>GuideLLM&lt;/strong> as the OSS standard for SLO-driven evaluation with reproducible sweeps.&lt;/li>
&lt;li>&lt;strong>MLPerf Inference v5.0/v5.1&lt;/strong> extends to 405B, interactive 70B and MoE, with record
participation: cross-vendor comparability is maturing.&lt;/li>
&lt;li>&lt;strong>Measurement bias characterised&lt;/strong>: the community accepts that the measurement method
matters as much as the system measured; the emphasis on reproducibility and meta-metrics is
growing.&lt;/li>
&lt;/ul>
&lt;hr>
&lt;h2 id="limits-and-traps-data-driven">Limits and traps (data-driven)&lt;/h2>
&lt;ol>
&lt;li>&lt;strong>Comparing tools from different classes.&lt;/strong> A single-process micro-bench and a
multi-process load generator are not comparable; the difference can be 7×. Pin the class.&lt;/li>
&lt;li>&lt;strong>Catalogue throughput instead of goodput.&lt;/strong> The honest number is the one that meets the
SLO.&lt;/li>
&lt;li>&lt;strong>Means instead of percentiles.&lt;/strong> The mean hides the tail; report P95/P99.&lt;/li>
&lt;li>&lt;strong>Not extending the sweep beyond the knee.&lt;/strong> Without seeing where latency takes off you do
not know the safe capacity.&lt;/li>
&lt;li>&lt;strong>Confusing performance with quality.&lt;/strong> They are distinct axes; fast is not good.&lt;/li>
&lt;li>&lt;strong>Not pinning versions.&lt;/strong> Engine, model, precision and load left unpinned = irreproducible
= not defensible.&lt;/li>
&lt;/ol>
&lt;p>The next article in the track (B2) goes into the tool catalogue in depth; this one pins the
metrics and the methodology. With performance measured reproducibly, the scorecard can cross
it with cost (in €) and energy for the final decision.&lt;/p>
&lt;h2 id="closing">Closing&lt;/h2>
&lt;p>Performance benchmarking looks like the most &amp;ldquo;objective&amp;rdquo; of the three axes (they are tokens
per second, after all) and it is exactly where the most manipulation happens, almost always
without bad intent: a single-process tool here, a mean instead of a P99 there, a catalogue
throughput instead of the goodput. The difference between a marketing number and a defensible
datum lies not in the engine measured but in the &lt;strong>methodology&lt;/strong>: the class of tool, where
the clock is measured, with which tokenizer, how far the sweep goes and which SLO defines the
goodput. For a sovereign architecture proposal, performance only counts if it is delivered
with that reproducibility record, and, crossed with cost in euros and energy per token, it
becomes the column of the Pareto frontier that decides which engine and which configuration
hold up the platform. The number you defend is not the highest one: it is the &lt;strong>reproducible
goodput&lt;/strong>.&lt;/p>
&lt;h2 id="see-also">See also&lt;/h2>
&lt;ul>
&lt;li>&lt;a href="https://blog.lo0.es/en/posts/llm-serving-engines-pareto-frontier-comparison/">LLM serving engines compared (vLLM/SGLang/TRT-LLM/Dynamo)&lt;/a> — choosing the engine after measuring goodput: from the methodology of this article to the Pareto frontier.&lt;/li>
&lt;li>&lt;a href="https://blog.lo0.es/en/posts/mlperf-inference-reading-loadgen-comparability/">MLPerf Inference: how to read it and what comparability it offers&lt;/a> — the industry standard: the four scenarios, the TTFT/TPOT SLOs of the Server scenario and the rules for comparing results.&lt;/li>
&lt;li>&lt;a href="https://blog.lo0.es/en/posts/measurement-bias-reproducibility-llm-benchmarks/">Measurement bias and reproducibility&lt;/a> — sources of systematic bias in performance benchmarks and how to control them to obtain defensible data.&lt;/li>
&lt;/ul>
&lt;h2 id="sources">Sources&lt;/h2>
&lt;ul>
&lt;li>Anyscale · métricas de latencia y throughput de LLM — &lt;a href="https://docs.anyscale.com/llm/serving/benchmarking/metrics">https://docs.anyscale.com/llm/serving/benchmarking/metrics&lt;/a>&lt;/li>
&lt;li>Red Hat · GuideLLM: evaluar despliegues LLM para inferencia real — &lt;a href="https://developers.redhat.com/articles/2025/06/20/guidellm-evaluate-llm-deployments-real-world-inference">https://developers.redhat.com/articles/2025/06/20/guidellm-evaluate-llm-deployments-real-world-inference&lt;/a>&lt;/li>
&lt;li>GuideLLM · GitHub (proyecto vLLM) — &lt;a href="https://github.com/vllm-project/guidellm">https://github.com/vllm-project/guidellm&lt;/a>&lt;/li>
&lt;li>NVIDIA AIPerf · guía de benchmarking (ex genai-perf) — &lt;a href="https://lucaberton.com/blog/nvidia-aiperf-llm-inference-benchmarking-guide/">https://lucaberton.com/blog/nvidia-aiperf-llm-inference-benchmarking-guide/&lt;/a>&lt;/li>
&lt;li>MLCommons · MLPerf Inference datacenter (escenarios) — &lt;a href="https://mlcommons.org/benchmarks/inference-datacenter/">https://mlcommons.org/benchmarks/inference-datacenter/&lt;/a>&lt;/li>
&lt;li>MLCommons · MLPerf Inference v5.0 (405B + 70B interactivo) — &lt;a href="https://mlcommons.org/2025/04/llm-inference-v5/">https://mlcommons.org/2025/04/llm-inference-v5/&lt;/a>&lt;/li>
&lt;li>MLCommons · MLPerf Inference v5.1 — &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>arXiv 2605.24217 · sesgo sistemático de medición 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>arXiv 2508.10251 · meta-métricas y buenas prácticas de benchmarking de rendimiento — &lt;a href="https://arxiv.org/pdf/2508.10251">https://arxiv.org/pdf/2508.10251&lt;/a>&lt;/li>
&lt;li>Medium · LLM Inference Benchmarking (genAI-perf y vLLM) — &lt;a href="https://kchandan.medium.com/llm-inference-benchmarking-genai-perf-and-vllm-5dd06b57428e">https://kchandan.medium.com/llm-inference-benchmarking-genai-perf-and-vllm-5dd06b57428e&lt;/a>&lt;/li>
&lt;/ul></description></item><item><title>Serving reasoning models: the invisible scratchpad that decides your latency and your bill</title><link>https://blog.lo0.es/en/posts/serving-reasoning-models-invisible-scratchpad-latency-bill/</link><pubDate>Fri, 12 Jun 2026 05:30:00 +0200</pubDate><guid>https://blog.lo0.es/en/posts/serving-reasoning-models-invisible-scratchpad-latency-bill/</guid><description>&lt;h2 id="tldr">TL;DR&lt;/h2>
&lt;p>A reasoning model generates, before the answer you see, a &lt;strong>thinking block&lt;/strong>, hundreds or thousands of tokens enclosed between &lt;code>&amp;lt;think&amp;gt;&lt;/code> and &lt;code>&amp;lt;/think&amp;gt;&lt;/code>, that the user does not read but that &lt;strong>consumes the same compute, the same VRAM and the same bill&lt;/strong> as any other token. Serving them in production is not &amp;ldquo;serving a bigger model&amp;rdquo;: it is serving a model whose cost per request is &lt;strong>variable and, by default, uncontrolled&lt;/strong>. Three levers change the equation: choosing well between &lt;strong>think / non-think mode&lt;/strong> (the hybrid models of 2026 allow it within the same weights), setting a &lt;strong>reasoning budget&lt;/strong> (&lt;code>thinking_token_budget&lt;/code> in vLLM, s1-style &lt;em>budget forcing&lt;/em>) and &lt;strong>measuring the impact on the KV cache&lt;/strong> (a 30k-token reasoning chain can eat ~9 GB of KV in FP16). Managed well, you gain accuracy where it matters; managed badly, you pay up to &lt;strong>113× more energy&lt;/strong> for an answer that did not need that much thinking.&lt;/p>
&lt;hr>
&lt;h2 id="the-analogy">The analogy&lt;/h2>
&lt;p>Picture a candidate sitting a competitive exam. Before writing the good answer on the official sheet, they fill three sheets of scratch paper: they try approaches, correct themselves, discard. Those scratch sheets are not handed in, the board only reads the final answer, but the candidate has spent ink, time and half an hour of the exam on them.&lt;/p>
&lt;p>A reasoning model does exactly that. The &lt;code>&amp;lt;think&amp;gt;…&amp;lt;/think&amp;gt;&lt;/code> block is the scratch paper: it reasons out loud, contradicts itself, backtracks, and finally emits the &amp;ldquo;clean&amp;rdquo; answer. The production problem is that &lt;strong>you pay for the scratch paper too&lt;/strong>: every reasoning token takes a slot in the batch, grows the KV cache and adds to the bill just like an output token. And, like any nervous candidate, the model tends to &lt;strong>overthink&lt;/strong>: it writes five sheets when one would have done.&lt;/p>
&lt;p>The operational question in this post is not &amp;ldquo;does it reason well?&amp;rdquo;, but &amp;ldquo;how much scratch paper do I let it write, and when do I take the pen away?&amp;rdquo;.&lt;/p>
&lt;hr>
&lt;h2 id="what-changes-when-the-model-thinks">What changes when the model &amp;ldquo;thinks&amp;rdquo;&lt;/h2>
&lt;p>In a normal model, a request is: prompt → output tokens. In a reasoning model the sequence has two stretches:&lt;/p>
&lt;ol>
&lt;li>&lt;strong>Reasoning block&lt;/strong> (&lt;code>&amp;lt;think&amp;gt; … &amp;lt;/think&amp;gt;&lt;/code>): the chain of thought (CoT). Long, variable, normally hidden from the end user.&lt;/li>
&lt;li>&lt;strong>Final answer&lt;/strong>: what the user sees.&lt;/li>
&lt;/ol>
&lt;p>vLLM models this explicitly. When started with a &lt;em>reasoning parser&lt;/em>, the output carries a &lt;code>reasoning_content&lt;/code> field separate from &lt;code>content&lt;/code>:&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 deepseek-ai/DeepSeek-R1-Distill-Qwen-32B &lt;span class="se">\
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="se">&lt;/span> --reasoning-parser deepseek_r1
&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>The parser detects the thinking delimiters and separates both stretches in the API response (&lt;a href="https://docs.vllm.ai/en/stable/features/reasoning_outputs/">vLLM · Reasoning Outputs&lt;/a>). This matters for serving for one practical reason: if your gateway or your UI does not separate &lt;code>reasoning_content&lt;/code> from &lt;code>content&lt;/code>, you will end up showing the scratch paper, or, worse, recording it in logs and traces without meaning to.&lt;/p>
&lt;div class="diagram" style="max-width:780px;margin:1rem auto;">
&lt;svg viewBox="0 0 780 210" role="img" aria-label="Anatomy of a request to a reasoning model: prompt, thinking block with budget, final answer" xmlns="http://www.w3.org/2000/svg">
&lt;defs>&lt;marker id="rzm" viewBox="0 0 10 10" refX="9" refY="5" markerWidth="7" markerHeight="7" orient="auto">&lt;path d="M0,0 L10,5 L0,10 z" fill="currentColor"/>&lt;/marker>&lt;/defs>
&lt;style>.bx{fill:none;stroke:currentColor;stroke-width:1.3}.dsh{fill:none;stroke:currentColor;stroke-width:1.3;stroke-dasharray:5 3}.tl{font:600 12px sans-serif;fill:currentColor}.ts{font:11px sans-serif;fill:currentColor}.ar{fill:none;stroke:currentColor;stroke-width:1.3;marker-end:url(#rzm)}&lt;/style>
&lt;text x="20" y="28" class="tl">Request&lt;/text>
&lt;rect class="bx" x="20" y="38" width="120" height="50" rx="6"/>
&lt;text x="32" y="60" class="ts">prompt + system&lt;/text>
&lt;text x="32" y="76" class="ts">(visible · billable)&lt;/text>
&lt;path class="ar" d="M140,63 L180,63"/>
&lt;text x="190" y="28" class="tl">Reasoning block &amp;lt;think&amp;gt; … &amp;lt;/think&amp;gt;&lt;/text>
&lt;rect class="dsh" x="190" y="38" width="360" height="80" rx="6"/>
&lt;text x="204" y="60" class="ts">hundreds–thousands of tokens HIDDEN from the user&lt;/text>
&lt;text x="204" y="78" class="ts">grow the KV cache · add to the bill as output&lt;/text>
&lt;text x="204" y="96" class="ts">cap: thinking_token_budget → forces &amp;lt;/think&amp;gt;&lt;/text>
&lt;path class="ar" d="M550,78 L590,78"/>
&lt;text x="600" y="28" class="tl">Answer&lt;/text>
&lt;rect class="bx" x="600" y="38" width="150" height="80" rx="6"/>
&lt;text x="612" y="64" class="ts">final content&lt;/text>
&lt;text x="612" y="82" class="ts">(all the user&lt;/text>
&lt;text x="612" y="98" class="ts">ever sees)&lt;/text>
&lt;text x="20" y="150" class="tl">Cost per request = prompt + reasoning + answer&lt;/text>
&lt;text x="20" y="172" class="ts">Reasoning is invisible in the UI but NOT on the GPU or in the bill. It is the variable that unbalances your cost per request.&lt;/text>
&lt;text x="20" y="192" class="ts">Operational rule: separate reasoning_content from content at the gateway, and set a thinking budget per route.&lt;/text>
&lt;/svg>
&lt;/div>
&lt;hr>
&lt;h2 id="the-2026-landscape-from-the-model-that-always-reasons-to-the-switchable-hybrid">The 2026 landscape: from the model that &amp;ldquo;always reasons&amp;rdquo; to the switchable hybrid&lt;/h2>
&lt;p>The first generation (o1, DeepSeek-R1, QwQ-32B) were models that always reasoned. In 2026 the dominant pattern is the hybrid: a single set of weights that switches between &lt;em>think&lt;/em> and &lt;em>non-think&lt;/em>.&lt;/p>
&lt;ul>
&lt;li>DeepSeek-V3.1 introduced a hybrid reasoning architecture in a single model: &lt;code>DeepSeek-V3.1-Think&lt;/code> for complex problems and a faster direct mode for the trivial, switchable by the user (&lt;a href="https://www.infoq.com/news/2025/09/deepseek-v31-hybrid/">InfoQ&lt;/a>). Important for serving: reasoning is off by default and is turned on with &lt;code>thinking=True&lt;/code> in &lt;code>chat_template_kwargs&lt;/code>.&lt;/li>
&lt;li>Qwen3 brings &lt;em>Hybrid Thinking Modes&lt;/em> and an explicit Thinking Budget to bound the reasoning compute; in its series thinking is on by default (like QwQ-32B).&lt;/li>
&lt;li>gpt-oss shows a different reasoning structure from the R1/Qwen family, which in practice means that &lt;strong>the parser and the delimiters are not universal&lt;/strong>: each family has its own.&lt;/li>
&lt;/ul>
&lt;p>The architectural consequence is clear: in 2026 you no longer decide &amp;ldquo;a model that reasons&amp;rdquo; vs &amp;ldquo;one that does not&amp;rdquo;, but &lt;strong>when you let the same model reason&lt;/strong>. That turns reasoning into a &lt;em>routing&lt;/em> and budgeting decision, not a model-catalogue one. It connects directly with &lt;a href="https://blog.lo0.es/en/posts/llm-inference-router-l7-switchboard/">the L7 inference router&lt;/a>: the &amp;ldquo;cheap&amp;rdquo; route turns thinking off; the &amp;ldquo;expensive&amp;rdquo; one turns it on with a cap.&lt;/p>
&lt;hr>
&lt;h2 id="the-economics-why-the-scratch-paper-drives-up-the-bill">The economics: why the scratch paper drives up the bill&lt;/h2>
&lt;p>Here is the heart of the problem. Reasoning tokens are invisible to the user but real in compute and in billing. Providers charge them as output tokens; API providers bill those &lt;em>reasoning tokens&lt;/em> at the output price, not for free (&lt;a href="https://medium.com/@Elongated_musk/test-time-compute-quietly-changed-the-economics-of-inference-9d0b8d77641c">Test-Time Compute Quietly Changed the Economics of Inference&lt;/a>). On-premise there is no external invoice, but there is a cost: they occupy batch slots, KV VRAM and watts.&lt;/p>
&lt;p>The billable cost (or the equivalent cost in GPU·hours) of a request is:&lt;/p>
$$\text{compute tokens} = \text{prompt} + \underbrace{\text{reasoning tokens}}_{\text{hidden, variable}} + \text{visible answer}$$
&lt;p>The middle term is the one that runs out of control. On hard tasks the reasoning/visible-answer ratio can be enormous, and empirical analysis has measured up to 113× the energy cost against standard inference on certain coding tasks (&lt;a href="https://towardsdatascience.com/inference-scaling-test-time-compute-why-reasoning-models-raise-your-compute-bill/">Towards Data Science · Inference Scaling&lt;/a>).&lt;/p>
&lt;p>And it does not always buy accuracy. The overthinking phenomenon is documented: more thinking does not guarantee a better answer, and beyond a certain point it makes things worse. There is work showing that &lt;strong>shorter chains are up to 34.5 % more accurate&lt;/strong> than the longest one sampled for the same question (&lt;a href="https://arxiv.org/pdf/2505.17813">Don&amp;rsquo;t Overthink it&lt;/a>), plus studies on the &amp;ldquo;mirage&amp;rdquo; of test-time scaling (&lt;a href="https://arxiv.org/pdf/2506.04210">Mirage of Test-Time Scaling&lt;/a>) and on when &lt;em>more thinking hurts&lt;/em> (&lt;a href="https://arxiv.org/html/2604.10739v1">When More Thinking Hurts&lt;/a>). The first large-scale study of test-time scaling, more than &lt;strong>30 billion tokens&lt;/strong> generated with 8 open-source LLMs from 7B to 235B across 4 reasoning datasets, systematises where it scales and where it does not (&lt;a href="https://arxiv.org/abs/2512.02008">The Art of Scaling Test-Time Compute&lt;/a>).&lt;/p>
&lt;p>The operational reading: &lt;strong>reasoning is a cost you have to budget for, not a &amp;ldquo;more quality&amp;rdquo; switch you leave on full all the time.&lt;/strong>&lt;/p>
&lt;hr>
&lt;h2 id="controlling-the-thinking-budget">Controlling the thinking budget&lt;/h2>
&lt;p>Three mechanisms, from coarsest to finest.&lt;/p>
&lt;h3 id="1-switching-think--non-think">1. Switching think / non-think&lt;/h3>
&lt;p>The cheapest thing is not to reason when it is not needed. On hybrid models, turn thinking off for classification, extraction, formatting, trivial answers:&lt;/p>
&lt;div class="highlight">&lt;pre tabindex="0" class="chroma">&lt;code class="language-python" data-lang="python">&lt;span class="line">&lt;span class="cl">&lt;span class="c1"># DeepSeek-V3.1: reasoning OFF by default; turned on explicitly&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="n">chat_template_kwargs&lt;/span> &lt;span class="o">=&lt;/span> &lt;span class="p">{&lt;/span>&lt;span class="s2">&amp;#34;thinking&amp;#34;&lt;/span>&lt;span class="p">:&lt;/span> &lt;span class="kc">True&lt;/span>&lt;span class="p">}&lt;/span> &lt;span class="c1"># only on the &amp;#34;hard&amp;#34; route&lt;/span>
&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>This is 80 % of the saving for 20 % of the effort: route by difficulty and reserve thinking for what actually pays for it.&lt;/p>
&lt;h3 id="2-setting-a-cap-thinking_token_budget">2. Setting a cap: &lt;code>thinking_token_budget&lt;/code>&lt;/h3>
&lt;p>vLLM ships a &lt;em>sampling parameter&lt;/em> &lt;code>thinking_token_budget&lt;/code> that counts reasoning tokens and, on reaching the limit, forces the block to close (&lt;code>reasoning_end_str&lt;/code>, typically &lt;code>&amp;lt;/think&amp;gt;&lt;/code>), making the model answer straight away. If it is not specified, there is no limit other than &lt;code>max_tokens&lt;/code> (&lt;a href="https://github.com/vllm-project/vllm/pull/37112">PR #37112&lt;/a>, &lt;a href="https://shuyo.wordpress.com/2026/03/26/adding-reasoning-budget-to-vllm-reasoning-token-limit/">implementation note&lt;/a>). Underneath, a &lt;code>ReasoningBudgetLogitsProcessor&lt;/code> injects something like &lt;em>&amp;ldquo;Let me stop thinking and answer now.&lt;code>&amp;lt;/think&amp;gt;&lt;/code>&amp;rdquo;&lt;/em> when the cap is reached.&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">&lt;span class="c1"># Define the delimiters of the reasoning block&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">vllm serve Qwen/Qwen3-32B &lt;span class="se">\
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="se">&lt;/span> --reasoning-parser qwen3 &lt;span class="se">\
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="se">&lt;/span> --reasoning-config &lt;span class="s1">&amp;#39;{&amp;#34;reasoning_start_str&amp;#34;:&amp;#34;&amp;lt;think&amp;gt;&amp;#34;,&amp;#34;reasoning_end_str&amp;#34;:&amp;#34;&amp;lt;/think&amp;gt;&amp;#34;}&amp;#39;&lt;/span>
&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&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="c1">// per request: cut the scratch paper off at 1024 tokens
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="c1">&lt;/span>&lt;span class="p">{&lt;/span>&lt;span class="nt">&amp;#34;messages&amp;#34;&lt;/span>&lt;span class="p">:[&lt;/span>&lt;span class="err">...&lt;/span>&lt;span class="p">],&lt;/span> &lt;span class="nt">&amp;#34;thinking_token_budget&amp;#34;&lt;/span>&lt;span class="p">:&lt;/span> &lt;span class="mi">1024&lt;/span>&lt;span class="p">}&lt;/span>
&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;h3 id="3-budget-forcing-state-of-the-art">3. Budget forcing (state of the art)&lt;/h3>
&lt;p>The technique that popularised fine-grained control is &lt;strong>budget forcing&lt;/strong>, from the s1 paper: to spend &lt;em>less&lt;/em>, thinking is terminated by forcing the end delimiter; to spend &lt;em>more&lt;/em>, the end is suppressed and the word &amp;ldquo;Wait&amp;rdquo; is appended, which pushes the model to review and often correct its own reasoning. With this, &lt;code>s1-32B&lt;/code> (a Qwen2.5-32B-Instruct fine-tuned on just 1,000 examples) &lt;strong>beat o1-preview by as much as 27 %&lt;/strong> on AIME24/MATH (&lt;a href="https://arxiv.org/abs/2501.19393">s1: Simple Test-Time Scaling&lt;/a>). The serving lesson: the budget is not only a cost cap, it is a &lt;strong>bidirectional quality lever&lt;/strong>.&lt;/p>
$$\text{accuracy} \approx f(\text{reasoning tokens}) \quad\text{with a maximum, not monotonic}$$
&lt;p>The curve rises, saturates and on many tasks falls. Your job is to operate it near the knee, not at the extreme.&lt;/p>
&lt;hr>
&lt;h2 id="the-impact-almost-nobody-measures-the-kv-cache">The impact almost nobody measures: the KV cache&lt;/h2>
&lt;p>This is where reasoning really bites serving. Every token of scratch paper &lt;strong>is one more token in the KV cache&lt;/strong>. A long reasoning chain inflates the working memory just like a long input context.&lt;/p>
&lt;p>The KV size grows linearly with the sequence (reasoning included):&lt;/p>
$$\text{KV bytes} = 2 \times L \times h_{kv} \times d_{head} \times s \times b$$
&lt;p>where \(L\) is layers, \(h_{kv}\) KV heads (few, with GQA), \(d_{head}\) the per-head dimension, \(s\) the sequence length and \(b\) the bytes per element. The term reasoning blows up is \(s\): a &amp;ldquo;normal&amp;rdquo; 500-token answer and one with 8,000 tokens of thinking do not take up the same space, not by a long way.&lt;/p>
&lt;p>The real numbers are frightening: for a distilled reasoning model with standard attention such as &lt;code>DeepSeek-R1-Distill-Llama-70B&lt;/code>, &lt;strong>a single 30,000-token reasoning chain consumes ~9 GB of KV in FP16&lt;/strong>, which quantising the KV to FP8 reduces to ~4.5 GB, one of the highest-impact optimisations for chain-of-thought workloads (&lt;a href="https://www.spheron.network/blog/kv-cache-optimization-guide/">Spheron · KV Cache Optimization&lt;/a>). Multiply that by concurrency and you understand why a cluster that served 200 simultaneous requests of a &amp;ldquo;normal&amp;rdquo; model drops to 40 with the same model reasoning.&lt;/p>
&lt;p>Two state-of-the-art nuances worth knowing:&lt;/p>
&lt;ul>
&lt;li>&lt;strong>Prefix caching is not free here.&lt;/strong> The empirical study of reasoning-model serving finds that &lt;em>prefix caching&lt;/em> clearly improves speed on models of 14B or more, but hurts on 7B models (cache management costs more than it saves) (&lt;a href="https://arxiv.org/pdf/2510.18672">Reasoning Language Model Inference Serving Unveiled&lt;/a>). And since the bulk of the reasoning cost sits in the decode (generating tokens one by one), prefix caching, which only speeds up the prefill, barely touches the expensive phase. It connects with &lt;a href="https://blog.lo0.es/en/posts/prefix-cache-hit-rate-engineering/">prefix cache: engineering the hit rate&lt;/a>.&lt;/li>
&lt;li>&lt;strong>Reasoning-specific KV compression.&lt;/strong> Techniques such as R-KV have appeared, compressing the KV by exploiting the redundancy typical of chains of thought (the model repeats itself and rambles), recovering memory without touching quality (&lt;a href="https://arxiv.org/pdf/2505.24133">R-KV&lt;/a>).&lt;/li>
&lt;/ul>
&lt;p>The most profitable and available lever today is still &lt;strong>KV in FP8&lt;/strong> (see &lt;a href="https://blog.lo0.es/en/posts/fp8-end-to-end-enable-measure-quality-decide/">FP8 end to end&lt;/a>) combined with a reasoning budget that avoids 30k-token tails in the first place.&lt;/p>
&lt;hr>
&lt;h2 id="reasoning--structured-output-order-matters">Reasoning + structured output: order matters&lt;/h2>
&lt;p>If you serve &lt;em>structured output&lt;/em> (JSON with a schema) and reasoning at the same time, there is a subtle trap: the grammar must not be applied during the thinking block. The model needs free text to reason; forcing the schema inside the &lt;code>&amp;lt;think&amp;gt;&lt;/code> breaks the reasoning. vLLM solves it by having the structured-output engine (xgrammar) use the reasoner&amp;rsquo;s &lt;code>end_token_id&lt;/code> to skip the constraint while the block lasts and apply it only to the final answer (&lt;a href="https://docs.vllm.ai/en/stable/features/reasoning_outputs/">vLLM · Reasoning Outputs&lt;/a>). If you build this by hand on another engine, remember: &lt;strong>first let it think, then impose the form&lt;/strong>. It links with &lt;a href="https://blog.lo0.es/en/posts/structured-output-bitmask-outlines-xgrammar-llguidance/">structured output&lt;/a>.&lt;/p>
&lt;hr>
&lt;h2 id="reference-architecture">Reference architecture&lt;/h2>
&lt;p>On the blog&amp;rsquo;s example cluster, a node of 4×H100 SXM (80 GB, NVLink), a sensible topology for serving reasoning without wrecking the SLO:&lt;/p>
&lt;ol>
&lt;li>&lt;strong>A cheap difficulty classifier&lt;/strong> up front (a small model or rules) that picks the route. Most of the traffic does not need to think.&lt;/li>
&lt;li>&lt;strong>Fast route (non-think):&lt;/strong> the same hybrid model with reasoning off. Low TTFT, predictable cost.&lt;/li>
&lt;li>&lt;strong>Reasoning route (think):&lt;/strong> reasoning on &lt;strong>with &lt;code>thinking_token_budget&lt;/code> by default&lt;/strong> (say 1–2k tokens) and the option to raise it per request for the genuinely hard cases.&lt;/li>
&lt;li>KV in FP8 in the engine on the reasoning route, plus per-request reasoning-length metrics exported to your observability (&lt;a href="https://blog.lo0.es/en/posts/instrumenting-vllm-otel-measuring-optimisations/">instrumenting vLLM with OTel&lt;/a>).&lt;/li>
&lt;/ol>
&lt;p>To prototype this routing outside the cluster, an RTX 5090 (Blackwell, 32 GB) serves a 7–14B reasoning model and lets you validate parsers, budgets and the &lt;code>reasoning_content&lt;/code>/&lt;code>content&lt;/code> separation before touching the H100s. Do not expect to serve a 32B in &lt;em>think&lt;/em> mode at high concurrency on a consumer card: the reasoning KV eats the 32 GB in no time.&lt;/p>
&lt;p>Sizing starts, as always, from an SLO; the difference is that now the &amp;ldquo;answer size&amp;rdquo; in your &lt;a href="https://blog.lo0.es/en/posts/capacity-planning-on-premise-llm-inference/">capacity planning&lt;/a> has to count the scratch paper, not just the visible answer.&lt;/p>
&lt;hr>
&lt;h2 id="operational-pitfalls-and-honest-scepticism">Operational pitfalls (and honest scepticism)&lt;/h2>
&lt;ol>
&lt;li>&lt;strong>Leaking &lt;code>reasoning_content&lt;/code>.&lt;/strong> If the gateway does not separate it, you end up showing or logging the scratch paper, which may contain wrong attempts, sensitive data or language you do not want in your UI. Always separate it at L7.&lt;/li>
&lt;li>&lt;strong>Serving reasoning &amp;ldquo;just in case&amp;rdquo;.&lt;/strong> Overthinking is real and sometimes lowers accuracy. Do not turn &lt;em>think&lt;/em> on by default: turn it on per route, with a budget.&lt;/li>
&lt;li>&lt;strong>Forgetting the KV.&lt;/strong> The most common mistake: sizing concurrency from the length of the visible answer. The KV is dictated by the total sequence, reasoning included. Measure p50/p95 &lt;code>reasoning tokens&lt;/code> per endpoint.&lt;/li>
&lt;li>&lt;strong>Prefix caching as a silver bullet.&lt;/strong> On small models it can make things worse, and in any case it does not touch the decode, which is where the reasoning cost goes.&lt;/li>
&lt;li>&lt;strong>Assuming universal delimiters.&lt;/strong> R1, Qwen3 and gpt-oss do not share a reasoning structure. Use the right &lt;code>--reasoning-parser&lt;/code> per family and test &lt;em>streaming&lt;/em> (the parser has to separate correctly token by token).&lt;/li>
&lt;li>&lt;strong>Believing more compute = more intelligence.&lt;/strong> The state of the art (s1, short-m@k, &amp;ldquo;When More Thinking Hurts&amp;rdquo;) points the other way in many cases: there is a knee, and operating beyond it is burning watts. Test-time compute is a tool with diminishing, and sometimes negative, returns, not a linear lever.&lt;/li>
&lt;/ol>
&lt;p>A note of caution for June 2026: the field of reasoning control (budget forcing, reasoning-specific KV compression, thinking &lt;em>early-exit&lt;/em>) moves fast and almost all of it is very recent. The specific figures, the 113×, the 34.5 %, the 9 GB, depend on model, task and configuration; take them as an &lt;strong>order of magnitude for design&lt;/strong>, not as constants. Measure on your own workload before committing to SLOs.&lt;/p>
&lt;hr>
&lt;h2 id="closing">Closing&lt;/h2>
&lt;p>Serving a reasoning model means serving a model whose cost per request &lt;strong>you decide with the thinking budget&lt;/strong>, not the catalogue. The chain goes: route by difficulty → turn &lt;em>think&lt;/em> off where it adds nothing → set &lt;code>thinking_token_budget&lt;/code> by default on the route that thinks → quantise the KV to FP8 → measure reasoning length as a first-class metric. Do that, and reasoning becomes a capability you pay for only when it buys accuracy. Do not, and your cluster will serve a fifth of the users while the model fills sheets of scratch paper nobody is going to read.&lt;/p>
&lt;h2 id="sources">Sources&lt;/h2>
&lt;ul>
&lt;li>vLLM · Reasoning Outputs — &lt;a href="https://docs.vllm.ai/en/stable/features/reasoning_outputs/">https://docs.vllm.ai/en/stable/features/reasoning_outputs/&lt;/a>&lt;/li>
&lt;li>vLLM PR #37112 · reasoning_budget — &lt;a href="https://github.com/vllm-project/vllm/pull/37112">https://github.com/vllm-project/vllm/pull/37112&lt;/a>&lt;/li>
&lt;li>Adding Reasoning Budget to vLLM — &lt;a href="https://shuyo.wordpress.com/2026/03/26/adding-reasoning-budget-to-vllm-reasoning-token-limit/">https://shuyo.wordpress.com/2026/03/26/adding-reasoning-budget-to-vllm-reasoning-token-limit/&lt;/a>&lt;/li>
&lt;li>s1: Simple Test-Time Scaling — &lt;a href="https://arxiv.org/abs/2501.19393">https://arxiv.org/abs/2501.19393&lt;/a>&lt;/li>
&lt;li>The Art of Scaling Test-Time Compute — &lt;a href="https://arxiv.org/abs/2512.02008">https://arxiv.org/abs/2512.02008&lt;/a>&lt;/li>
&lt;li>Don&amp;rsquo;t Overthink it (short-m@k) — &lt;a href="https://arxiv.org/pdf/2505.17813">https://arxiv.org/pdf/2505.17813&lt;/a>&lt;/li>
&lt;li>Mirage of Test-Time Scaling — &lt;a href="https://arxiv.org/pdf/2506.04210">https://arxiv.org/pdf/2506.04210&lt;/a>&lt;/li>
&lt;li>When More Thinking Hurts — &lt;a href="https://arxiv.org/html/2604.10739v1">https://arxiv.org/html/2604.10739v1&lt;/a>&lt;/li>
&lt;li>Reasoning Language Model Inference Serving Unveiled — &lt;a href="https://arxiv.org/pdf/2510.18672">https://arxiv.org/pdf/2510.18672&lt;/a>&lt;/li>
&lt;li>R-KV: Redundancy-aware KV Cache Compression — &lt;a href="https://arxiv.org/pdf/2505.24133">https://arxiv.org/pdf/2505.24133&lt;/a>&lt;/li>
&lt;li>Spheron · KV Cache Optimization Guide (2026) — &lt;a href="https://www.spheron.network/blog/kv-cache-optimization-guide/">https://www.spheron.network/blog/kv-cache-optimization-guide/&lt;/a>&lt;/li>
&lt;li>Test-Time Compute Quietly Changed the Economics of Inference — &lt;a href="https://medium.com/@Elongated_musk/test-time-compute-quietly-changed-the-economics-of-inference-9d0b8d77641c">https://medium.com/@Elongated_musk/test-time-compute-quietly-changed-the-economics-of-inference-9d0b8d77641c&lt;/a>&lt;/li>
&lt;li>Inference Scaling (Test-Time Compute) · Towards Data Science — &lt;a href="https://towardsdatascience.com/inference-scaling-test-time-compute-why-reasoning-models-raise-your-compute-bill/">https://towardsdatascience.com/inference-scaling-test-time-compute-why-reasoning-models-raise-your-compute-bill/&lt;/a>&lt;/li>
&lt;li>DeepSeek-V3.1 Hybrid Reasoning · InfoQ — &lt;a href="https://www.infoq.com/news/2025/09/deepseek-v31-hybrid/">https://www.infoq.com/news/2025/09/deepseek-v31-hybrid/&lt;/a>&lt;/li>
&lt;/ul></description></item><item><title>Long context and KV offloading: when the notebook does not fit on the desk</title><link>https://blog.lo0.es/en/posts/long-context-kv-offloading-notebook-desk/</link><pubDate>Fri, 12 Jun 2026 05:00:00 +0200</pubDate><guid>https://blog.lo0.es/en/posts/long-context-kv-offloading-notebook-desk/</guid><description>&lt;h2 id="tldr">TL;DR&lt;/h2>
&lt;p>Serving long context &lt;strong>is not serving a smarter model, it is managing a notebook that does not fit on the desk&lt;/strong>. The context window is set by the model (and extended with RoPE scaling / YaRN), but the cost of serving it is set by the &lt;strong>KV cache&lt;/strong>, which grows linearly with sequence length and, in production, &lt;strong>explodes&lt;/strong>: a 300k-token contract on Llama 3 70B eats ~93 GB of KV, more than an entire H100, and a million tokens asks for ~125 GB. When the KV does not fit in HBM, there are only two ways out: &lt;strong>recompute&lt;/strong> (extremely expensive, attention is quadratic) or &lt;strong>offload&lt;/strong> the KV to a cheaper memory hierarchy: DRAM, NVMe, network. The 2026 OSS state of the art, &lt;strong>LMCache&lt;/strong>, &lt;strong>Mooncake&lt;/strong> (Kimi&amp;rsquo;s platform) and &lt;strong>NVIDIA Dynamo/KVBM&lt;/strong>, turns that offload into a &lt;strong>first-class KV layer&lt;/strong>, with reuse across requests, &lt;em>prefill/decode disaggregation&lt;/em> and cache-aware &lt;em>routing&lt;/em>. Reported results: &lt;strong>3×–10×&lt;/strong> less latency with LMCache and up to &lt;strong>+525 %&lt;/strong> throughput in long-context scenarios with Mooncake. The price: every memory hop adds transfer latency, so offloading &lt;strong>only pays off when what you save by not recomputing beats what it costs to move the bytes&lt;/strong>.&lt;/p>
&lt;hr>
&lt;h2 id="the-analogy">The analogy&lt;/h2>
&lt;p>A researcher works at a small desk. On it fit the papers they are pulling from &lt;strong>right now&lt;/strong>, which is the GPU&amp;rsquo;s HBM, blazingly fast but tiny. When the case is long (a thousand-page file, a million-token context), the papers do not fit. There are three options:&lt;/p>
&lt;ol>
&lt;li>&lt;strong>Throw papers away and ask the archive for them again every time they are needed.&lt;/strong> That is recomputing the KV: correct, but agonisingly slow, because &amp;ldquo;asking the archive&amp;rdquo; in an LLM means running the whole prompt through attention again, and attention is &lt;strong>quadratic&lt;/strong>.&lt;/li>
&lt;li>&lt;strong>Put a shelf next to the desk&lt;/strong> (the CPU&amp;rsquo;s DRAM) and, further out, &lt;strong>a store room in the basement&lt;/strong> (NVMe) and &lt;strong>a depot in another building&lt;/strong> (network/object). Moving papers between the desk and the shelf costs seconds, not hours. That is &lt;strong>hierarchical KV offloading&lt;/strong>.&lt;/li>
&lt;li>&lt;strong>Have two researchers split the work:&lt;/strong> one reads and marks up the whole file (prefill), then passes their notes to the second, who only drafts (decode). That is the &lt;strong>KVCache-centric disaggregated architecture&lt;/strong>.&lt;/li>
&lt;/ol>
&lt;p>This post is about options 2 and 3, the ones that make long context viable in production. Option 1 is what you pay by default if you do nothing.&lt;/p>
&lt;hr>
&lt;h2 id="part-1--why-long-context-is-a-memory-problem">Part 1 · Why long context is a memory problem&lt;/h2>
&lt;h3 id="the-kv-cache-grows-with-the-sequence">The KV cache grows with the sequence&lt;/h3>
&lt;p>For every token that goes in or out, the model stores its &lt;em>key&lt;/em> and &lt;em>value&lt;/em> vectors in each layer so it does not have to recompute them. The size is:&lt;/p>
$$\text{KV bytes} = 2 \times L \times h_{kv} \times d_{head} \times s \times b$$
&lt;p>with \(L\) layers, \(h_{kv}\) KV heads (few, with GQA), \(d_{head}\) the per-head dimension, \(s\) the sequence length and \(b\) the bytes per element. Everything is a model constant &lt;strong>except \(s\)&lt;/strong>: the KV is &lt;strong>linear in context length&lt;/strong>. Doubling the context doubles the KV. That is the basis of &lt;a href="https://blog.lo0.es/en/posts/kv-cache-working-memory-llm-inference/">KV cache: the working memory of inference&lt;/a>.&lt;/p>
&lt;h3 id="the-numbers-are-frightening">The numbers are frightening&lt;/h3>
&lt;p>At production scale, that linear term turns brutal:&lt;/p>
&lt;ul>
&lt;li>A &lt;strong>300k-token contract&lt;/strong> on &lt;strong>Llama 3 70B&lt;/strong> consumes &lt;strong>~93 GB&lt;/strong> of KV alone, &lt;strong>more than the 80 GB of an entire H100&lt;/strong> (&lt;a href="https://www.digitalocean.com/community/tutorials/long-context-inference-production-cost">DigitalOcean · Long-Context Inference Cost&lt;/a>).&lt;/li>
&lt;li>A &lt;strong>1M-token context&lt;/strong> needs &lt;strong>~125 GB&lt;/strong> of KV, which exceeds both an RTX 4090 (24 GB) and an 80 GB A100 (&lt;a href="https://introl.com/blog/long-context-llm-infrastructure-million-token-windows-guide">Introl · Long-Context LLM Infrastructure&lt;/a>).&lt;/li>
&lt;li>Even a &lt;strong>7B at 128k&lt;/strong> climbs to &lt;strong>~14 GB&lt;/strong> of KV (against ~6 GB at 4k).&lt;/li>
&lt;/ul>
&lt;p>The KV stops being an implementation detail and becomes &lt;strong>the resource that dictates your concurrency&lt;/strong>.&lt;/p>
&lt;h3 id="and-on-top-of-that-attention-is-quadratic">And on top of that, attention is quadratic&lt;/h3>
&lt;p>The KV is linear, but the &lt;strong>compute of attention is quadratic&lt;/strong> in length. When the prompt reaches 1M tokens, generating each token can take on the order of &lt;strong>1.765 seconds, with more than 96 % of the latency spent in attention&lt;/strong> (&lt;a href="https://introl.com/blog/long-context-llm-infrastructure-million-token-windows-guide">Introl&lt;/a>). The aggregate effect is a &lt;strong>throughput collapse of 10×–100×&lt;/strong> against short contexts. This is why long context is not &amp;ldquo;fixed&amp;rdquo; with more VRAM alone: there is a memory problem (KV) &lt;strong>and&lt;/strong> a compute problem (attention) at the same time.&lt;/p>
&lt;h3 id="extending-the-window--serving-it-cheaply">Extending the window ≠ serving it cheaply&lt;/h3>
&lt;p>A common confusion: &amp;ldquo;my model supports 1M of context&amp;rdquo; does not mean &amp;ldquo;I can serve 1M cheaply&amp;rdquo;. The window is &lt;strong>extended&lt;/strong> with &lt;em>RoPE scaling&lt;/em> techniques such as &lt;strong>YaRN&lt;/strong>, the practical option for &lt;em>fine-tuning&lt;/em> open source models to long context: it needs &lt;strong>10× fewer training tokens and 2.5× fewer steps&lt;/strong> than naive RoPE interpolation, and it took LLaMA-2 from 4k to 32k and 128k (&lt;a href="https://arxiv.org/html/2402.13753v1">YaRN/LongRoPE&lt;/a>). But that solves the model &lt;strong>attending&lt;/strong> to long context, not the KV &lt;strong>fitting on your GPU&lt;/strong>. According to the 2026 round-ups, May 2026 marked the first generation of open &lt;strong>million-token&lt;/strong> models (with families supporting 256k extensible to 1M via YaRN) (&lt;a href="https://letsdatascience.com/blog/long-context-models-working-with-1m-token-windows">letsdatascience&lt;/a>). Treat it as a trend, not a closed spec, and verify capabilities per concrete model.&lt;/p>
&lt;hr>
&lt;h2 id="part-2--the-kv-memory-hierarchy">Part 2 · The KV memory hierarchy&lt;/h2>
&lt;p>The core idea of offloading is old in systems: &lt;strong>hierarchical caching&lt;/strong>. &amp;ldquo;Hot&amp;rdquo; KV (what is in use) lives in HBM; &amp;ldquo;warm&amp;rdquo; KV drops to DRAM; &amp;ldquo;cold&amp;rdquo; KV to NVMe; &amp;ldquo;shared across nodes&amp;rdquo; to network or object storage. Every hop multiplies capacity and divides bandwidth.&lt;/p>
&lt;div class="diagram" style="max-width:780px;margin:1rem auto;">
&lt;svg viewBox="0 0 780 300" role="img" aria-label="KV cache memory hierarchy: HBM, DRAM, NVMe and network/object, with increasing capacity and decreasing bandwidth" xmlns="http://www.w3.org/2000/svg">
&lt;style>.bx{fill:none;stroke:currentColor;stroke-width:1.3}.tl{font:600 13px sans-serif;fill:currentColor}.ts{font:11px sans-serif;fill:currentColor}.ar{fill:none;stroke:currentColor;stroke-width:1.2;stroke-dasharray:4 3;marker-end:url(#kvm)}&lt;/style>
&lt;defs>&lt;marker id="kvm" viewBox="0 0 10 10" refX="9" refY="5" markerWidth="7" markerHeight="7" orient="auto">&lt;path d="M0,0 L10,5 L0,10 z" fill="currentColor"/>&lt;/marker>&lt;/defs>
&lt;text x="20" y="26" class="tl">Hot · fast · small&lt;/text>
&lt;rect class="bx" x="20" y="36" width="430" height="44" rx="6"/>
&lt;text x="34" y="55" class="tl">GPU HBM&lt;/text>
&lt;text x="34" y="72" class="ts">~80 GB · several TB/s · the active KV lives here&lt;/text>
&lt;rect class="bx" x="20" y="92" width="500" height="44" rx="6"/>
&lt;text x="34" y="111" class="tl">CPU DRAM&lt;/text>
&lt;text x="34" y="128" class="ts">hundreds of GB–TB · tens–hundreds GB/s · warm KV, reuse across requests&lt;/text>
&lt;rect class="bx" x="20" y="148" width="570" height="44" rx="6"/>
&lt;text x="34" y="167" class="tl">NVMe SSD (local)&lt;/text>
&lt;text x="34" y="184" class="ts">TB · GB/s · cold KV, persistent prefixes, long sessions&lt;/text>
&lt;rect class="bx" x="20" y="204" width="640" height="44" rx="6"/>
&lt;text x="34" y="223" class="tl">Network / object (RDMA, S3, Redis)&lt;/text>
&lt;text x="34" y="240" class="ts">~unlimited · hundreds MB–GB/s · KV shared across cluster nodes&lt;/text>
&lt;path class="ar" d="M690,58 L690,226"/>
&lt;text x="700" y="120" class="ts" transform="rotate(90 700 120)">+capacity&lt;/text>
&lt;text x="700" y="200" class="ts" transform="rotate(90 700 200)">−bandwidth&lt;/text>
&lt;text x="20" y="276" class="ts">KV drops a level as it cools. Fetching it back from a lower level costs a transfer,&lt;/text>
&lt;text x="20" y="292" class="ts">but it is ALWAYS cheaper than recomputing it (quadratic attention) if the prefix is reused.&lt;/text>
&lt;/svg>
&lt;/div>
&lt;h3 id="working-out-when-offloading-pays-off">Working out when offloading pays off&lt;/h3>
&lt;p>Offloading is not magic: moving KV from DRAM/NVMe back to HBM costs time. The rule is simple. &lt;strong>Offloading pays off when the cost of recomputing exceeds the cost of transferring&lt;/strong>:&lt;/p>
$$t_{recompute}(s) \;>\; t_{transfer} = \frac{\text{KV bytes}}{BW_{enlace}}$$
&lt;p>Since \(t_{recompute}\) grows with attention (quadratic) and \(t_{transfer}\) grows only with the size of the KV (linear) divided by the link bandwidth, &lt;strong>the longer the context, the more the balance tips in favour of offloading&lt;/strong>. That is why offloading is &lt;em>the&lt;/em> lever for long context: it is exactly where recomputing becomes unbearable. The LMCache documentation says it without hedging: offloading &lt;strong>only helps when the recomputation it avoids is larger than the overhead it introduces&lt;/strong> (&lt;a href="https://levelup.gitconnected.com/vllm-prefix-caching-vs-lmcache-benchmarking-kv-reuse-tradeoffs-944fbaf98b56">LMCache benchmark&lt;/a>).&lt;/p>
&lt;hr>
&lt;h2 id="part-3--kv-offloading-in-practice-oss">Part 3 · KV offloading in practice (OSS)&lt;/h2>
&lt;h3 id="the-limit-of-out-of-the-box-prefix-caching">The limit of out-of-the-box prefix caching&lt;/h3>
&lt;p>vLLM already caches prefixes in HBM (see &lt;a href="https://blog.lo0.es/en/posts/prefix-cache-hit-rate-engineering/">prefix cache: hit rate engineering&lt;/a> and &lt;a href="https://blog.lo0.es/en/posts/pagedattention-vllm-block-manager/">PagedAttention&lt;/a>). The problem: &lt;strong>it only lives in HBM&lt;/strong>, which is small. When the useful KV exceeds HBM, it is evicted and, on the next request, has to be recomputed. &lt;strong>There is no speedup even with prefix caching enabled&lt;/strong>, because the cache is no longer there (&lt;a href="https://levelup.gitconnected.com/vllm-prefix-caching-vs-lmcache-benchmarking-kv-reuse-tradeoffs-944fbaf98b56">vLLM vs LMCache benchmark&lt;/a>). Native prefix caching is necessary but &lt;strong>insufficient&lt;/strong> for long context.&lt;/p>
&lt;h3 id="lmcache-the-persistent-kv-layer">LMCache: the persistent KV layer&lt;/h3>
&lt;p>&lt;strong>LMCache&lt;/strong> adds persistent storage backends to vLLM&amp;rsquo;s prefix cache (and also supports SGLang and NVIDIA Dynamo as engines). It pulls the KV out of HBM and &lt;strong>shares it across engines and across queries&lt;/strong>, covering a &lt;strong>three-level hierarchy: GPU HBM, CPU DRAM and NVMe SSD&lt;/strong>, with additional backends for Redis/Valkey, Mooncake, InfiniStore, S3 and NIXL/GDS (&lt;a href="https://github.com/LMCache/LMCache">GitHub LMCache&lt;/a>, &lt;a href="https://arxiv.org/pdf/2510.09665">arXiv 2510.09665&lt;/a>). Because DRAM/NVMe hold &lt;strong>far more&lt;/strong> KV than HBM, the &lt;em>hit ratio&lt;/em> rises, and LMCache reports &lt;strong>3×–10× less latency&lt;/strong> combined with vLLM. Against vLLM&amp;rsquo;s basic CPU offload, LMCache loads the KV &lt;strong>at chunk level with high-performance CUDA kernels&lt;/strong>, which cuts transfer overhead.&lt;/p>
&lt;p>A minimal start, offloading to CPU:&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">&lt;span class="c1"># vLLM with LMCache as the KV layer (offload to DRAM)&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">pip install lmcache
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="nv">LMCACHE_CONFIG_FILE&lt;/span>&lt;span class="o">=&lt;/span>lmcache.yaml &lt;span class="se">\
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="se">&lt;/span>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> --kv-transfer-config &lt;span class="s1">&amp;#39;{&amp;#34;kv_connector&amp;#34;:&amp;#34;LMCacheConnector&amp;#34;,&amp;#34;kv_role&amp;#34;:&amp;#34;kv_both&amp;#34;}&amp;#39;&lt;/span>
&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;div class="highlight">&lt;pre tabindex="0" class="chroma">&lt;code class="language-yaml" data-lang="yaml">&lt;span class="line">&lt;span class="cl">&lt;span class="c"># lmcache.yaml — hierarchy HBM -&amp;gt; DRAM -&amp;gt; NVMe&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w">&lt;/span>&lt;span class="nt">chunk_size&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="m">256&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w">&lt;/span>&lt;span class="nt">local_cpu&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="kc">true&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="c"># warm KV in DRAM&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w">&lt;/span>&lt;span class="nt">max_local_cpu_size&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="m">200&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="c"># GB&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w">&lt;/span>&lt;span class="nt">local_disk&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="s2">&amp;#34;file:///nvme/lmcache&amp;#34;&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="c"># cold KV on NVMe&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w">&lt;/span>&lt;span class="nt">max_local_disk_size&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="m">2000&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="c"># GB&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>Offloading to CPU adds moderate overhead but &lt;strong>retains a good part of the benefit&lt;/strong> against recomputing (&lt;a href="https://docs.lmcache.ai/getting_started/quickstart/offload_kv_cache.html">LMCache docs&lt;/a>). Mind the physical path: that HBM↔DRAM↔NVMe traffic goes over PCIe and across NUMA nodes. It is worth reading &lt;a href="https://blog.lo0.es/en/posts/pcie-topology-gpudirect-p2p-acs-security-guard/">PCIe topology, GPUDirect and ACS&lt;/a> and &lt;a href="https://blog.lo0.es/en/posts/numa-hugepages-cpu-isolation-gpu-waits-kernel/">NUMA, hugepages and CPU isolation&lt;/a>, because a badly wired offload can eat its own gain.&lt;/p>
&lt;h3 id="combine-it-with-fp8">Combine it with FP8&lt;/h3>
&lt;p>Offloading reduces &lt;strong>where&lt;/strong> the KV lives; quantization reduces &lt;strong>how much&lt;/strong> it weighs. They are orthogonal and they add up: KV in FP8 halves the size (see &lt;a href="https://blog.lo0.es/en/posts/fp8-end-to-end-enable-measure-quality-decide/">FP8 end-to-end&lt;/a>), which means half the bytes to transfer on every offload. In long context, &lt;strong>FP8 + offload&lt;/strong> is the default combination.&lt;/p>
&lt;hr>
&lt;h2 id="part-4--kvcache-centric--disaggregated-architecture">Part 4 · KVCache-centric / disaggregated architecture&lt;/h2>
&lt;p>The next level is not just offloading KV, but &lt;strong>redesigning serving around the KV&lt;/strong>.&lt;/p>
&lt;h3 id="mooncake-kimis-platform">Mooncake (Kimi&amp;rsquo;s platform)&lt;/h3>
&lt;p>&lt;strong>Mooncake&lt;/strong>, the serving platform behind &lt;strong>Kimi&lt;/strong> (Moonshot AI), is the reference &lt;strong>KVCache-centric disaggregated&lt;/strong> architecture: it separates the &lt;strong>prefill&lt;/strong> and &lt;strong>decode&lt;/strong> clusters, and exploits the &lt;strong>underused CPU, DRAM and SSD resources&lt;/strong> of the GPU cluster to build a distributed KV cache. Its core is a &lt;strong>KVCache-centric scheduler&lt;/strong> that maximises throughput while respecting SLOs, with a &lt;strong>prediction-based early rejection policy&lt;/strong> for overloaded scenarios (&lt;a href="https://arxiv.org/abs/2407.00079">arXiv 2407.00079&lt;/a>, &lt;a href="https://www.usenix.org/conference/fast25/presentation/qin">USENIX FAST'25&lt;/a>). The long-context numbers are striking: up to &lt;strong>+525 % throughput&lt;/strong> in certain simulated scenarios while respecting the SLO, and in production Kimi handles &lt;strong>115 % and 107 % more requests&lt;/strong> on A800 and H800 clusters respectively against previous systems. The title of their FAST talk sums it up: &lt;em>&amp;ldquo;Trading More Storage for Less Computation&amp;rdquo;&lt;/em>, exactly the rule from Part 2.&lt;/p>
&lt;h3 id="nvidia-dynamo-and-kvbm">NVIDIA Dynamo and KVBM&lt;/h3>
&lt;p>&lt;strong>NVIDIA Dynamo&lt;/strong> is the open source distributed serving framework with &lt;strong>prefill/decode disaggregation&lt;/strong>, dynamic GPU scheduling and &lt;strong>KV-aware routing&lt;/strong>: the &lt;em>PrefillRouter&lt;/em> computes &lt;strong>overlap scores&lt;/strong> between the incoming request and the KV blocks already cached, and routes to the GPUs that &lt;strong>already have&lt;/strong> the relevant KV, avoiding recomputation (&lt;a href="https://docs.dynamo.nvidia.com/dynamo/design-docs/disaggregated-serving">Dynamo · Disaggregated Serving&lt;/a>, &lt;a href="https://docs.nvidia.com/dynamo/latest/user-guides/kv-cache-aware-routing">Dynamo · KV-aware routing&lt;/a>). KV transfer between prefill and decode is handled by &lt;strong>NIXL&lt;/strong> directly GPU-to-GPU over the best available transport (NVLink, InfiniBand). And its offload manager, &lt;strong>KVBM (KV Block Manager)&lt;/strong>, has a three-layer architecture (LLM runtime, logical block management, NIXL transport) that &lt;strong>offloads cold KV to CPU RAM, NVMe or network storage&lt;/strong> to free up HBM (&lt;a href="https://docs.nvidia.com/dynamo/backends/v-llm/kv-cache-offloading">Dynamo · KV Cache Offloading&lt;/a>). This connects with &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>.&lt;/p>
&lt;p>The &lt;strong>llm-d&lt;/strong> project walks the same road, from vLLM&amp;rsquo;s prefix caching to KV-aware distributed scheduling (&lt;a href="https://llm-d.ai/blog/kvcache-wins-you-can-see">llm-d · KV-Cache Wins You Can See&lt;/a>).&lt;/p>
&lt;div class="diagram" style="max-width:780px;margin:1rem auto;">
&lt;svg viewBox="0 0 780 250" role="img" aria-label="KVCache-centric disaggregated architecture: KV-aware router, prefill cluster, tiered distributed KV cache and decode cluster" xmlns="http://www.w3.org/2000/svg">
&lt;style>.bx{fill:none;stroke:currentColor;stroke-width:1.3}.dsh{fill:none;stroke:currentColor;stroke-width:1.3;stroke-dasharray:5 3}.tl{font:600 12px sans-serif;fill:currentColor}.ts{font:11px sans-serif;fill:currentColor}.ar{fill:none;stroke:currentColor;stroke-width:1.3;marker-end:url(#dgm)}&lt;/style>
&lt;defs>&lt;marker id="dgm" viewBox="0 0 10 10" refX="9" refY="5" markerWidth="7" markerHeight="7" orient="auto">&lt;path d="M0,0 L10,5 L0,10 z" fill="currentColor"/>&lt;/marker>&lt;/defs>
&lt;rect class="bx" x="20" y="40" width="120" height="46" rx="6"/>
&lt;text x="32" y="60" class="tl">KV-aware router&lt;/text>
&lt;text x="32" y="77" class="ts">overlap score&lt;/text>
&lt;path class="ar" d="M140,63 L185,63"/>
&lt;rect class="bx" x="185" y="40" width="150" height="46" rx="6"/>
&lt;text x="197" y="60" class="tl">PREFILL cluster&lt;/text>
&lt;text x="197" y="77" class="ts">reads+marks up context&lt;/text>
&lt;path class="ar" d="M260,86 L260,120"/>
&lt;rect class="dsh" x="120" y="120" width="430" height="50" rx="6"/>
&lt;text x="134" y="140" class="tl">Distributed KV cache (HBM · DRAM · NVMe · network)&lt;/text>
&lt;text x="134" y="158" class="ts">KVBM / LMCache / Mooncake store · reuse across requests and nodes&lt;/text>
&lt;path class="ar" d="M410,120 L410,86"/>
&lt;rect class="bx" x="430" y="40" width="150" height="46" rx="6"/>
&lt;text x="442" y="60" class="tl">DECODE cluster&lt;/text>
&lt;text x="442" y="77" class="ts">only generates tokens&lt;/text>
&lt;path class="ar" d="M335,63 L430,63"/>
&lt;text x="600" y="50" class="ts">NIXL: direct&lt;/text>
&lt;text x="600" y="66" class="ts">GPU↔GPU KV transfer&lt;/text>
&lt;text x="600" y="82" class="ts">(NVLink/IB)&lt;/text>
&lt;text x="20" y="200" class="ts">KV stops being a by-product of decode and becomes the first-class citizen: the router routes by&lt;/text>
&lt;text x="20" y="216" class="ts">KV overlap, prefill and decode are separated, and the distributed cache avoids recomputing shared prefixes.&lt;/text>
&lt;text x="20" y="232" class="ts">It is "trading storage for computation": you store more KV so you compute less attention.&lt;/text>
&lt;/svg>
&lt;/div>
&lt;hr>
&lt;h2 id="part-5--reference-architecture">Part 5 · Reference architecture&lt;/h2>
&lt;p>On the blog&amp;rsquo;s example cluster, a node with &lt;strong>4×H100 SXM (80 GB, NVLink)&lt;/strong>, &lt;strong>local NVMe&lt;/strong> and plenty of DRAM, a sensible stack to serve long context without recomputing pointlessly:&lt;/p>
&lt;ol>
&lt;li>&lt;strong>KV in FP8&lt;/strong> in the engine (vLLM), to start with half the bytes.&lt;/li>
&lt;li>&lt;strong>LMCache as the KV layer&lt;/strong> with a DRAM→NVMe hierarchy, so that long prefixes (a manual, a code base, a case file) &lt;strong>are cached once and reused&lt;/strong> across sessions without recomputation.&lt;/li>
&lt;li>&lt;strong>KV-aware routing&lt;/strong> (Dynamo or llm-d) if you have several nodes: send the request to the GPU that already holds the prefix, not to any GPU at all.&lt;/li>
&lt;li>&lt;strong>Prefill/decode disaggregation&lt;/strong> once the prefill of enormous contexts starts stealing decode from the other requests. Prefilling 300k tokens monopolises the GPU and kills everyone&amp;rsquo;s latency.&lt;/li>
&lt;li>&lt;strong>Metrics&lt;/strong> for KV &lt;em>hit ratio&lt;/em>, bytes transferred per level and prefill/decode ratio, exported to your observability stack (&lt;a href="https://blog.lo0.es/en/posts/instrumenting-vllm-otel-measuring-optimisations/">instrumenting vLLM with OTel&lt;/a>). Without those three metrics, offloading is faith, not engineering.&lt;/li>
&lt;/ol>
&lt;p>To &lt;strong>prototype&lt;/strong> the stack, validating the LMCache config, the backend formats, the hit/miss behaviour, an &lt;strong>RTX 5090 (Blackwell, 32 GB)&lt;/strong> with NVMe is more than enough to serve a 7–14B and watch offloading work at contexts of 32–128k. Do not expect to serve 1M on a consumer card: the long-context problem is precisely that neither the KV nor the attention fit on small hardware. Real sizing starts from the SLO and from the context-length distribution of your traffic, not from the model&amp;rsquo;s maximum window, and that is where it links to &lt;a href="https://blog.lo0.es/en/posts/capacity-planning-on-premise-llm-inference/">capacity planning&lt;/a>.&lt;/p>
&lt;hr>
&lt;h2 id="part-6--operational-pitfalls-and-honest-scepticism">Part 6 · Operational pitfalls (and honest scepticism)&lt;/h2>
&lt;ol>
&lt;li>&lt;strong>Believing that native prefix caching is enough.&lt;/strong> It only lives in HBM; in long context it is evicted and you recompute. You need a persistent layer (LMCache/KVBM) for the cache to survive.&lt;/li>
&lt;li>&lt;strong>Offloading without measuring the break-even.&lt;/strong> If your prefixes &lt;strong>are not reused&lt;/strong>, offloading only adds transfer latency without saving recomputation. Offloading shines with &lt;strong>shared prefixes&lt;/strong> (RAG over the same corpus, agents with the same system prompt, long sessions), not with one-off, never-repeated requests.&lt;/li>
&lt;li>&lt;strong>Forgetting that attention is still quadratic.&lt;/strong> Offloading fixes the &lt;strong>memory&lt;/strong> of the KV, not the &lt;strong>compute&lt;/strong> of attention. For real 1M tokens you also need efficient attention, &lt;em>sparse attention&lt;/em> or retrieval approaches (RetrievalAttention, ShadowKV); offloading on its own does not bring down the 1.765 s/token.&lt;/li>
&lt;li>&lt;strong>Wiring the offload badly.&lt;/strong> HBM↔DRAM↔NVMe crosses PCIe and NUMA. An offload that crosses the wrong NUMA node or saturates a PCIe lane can be &lt;strong>slower than recomputing&lt;/strong>. Measure the link&amp;rsquo;s real bandwidth, not the datasheet figure.&lt;/li>
&lt;li>&lt;strong>Treating the figures as constants.&lt;/strong> The 93 GB, the +525 %, the 3–10× all depend on model, length, hardware and reuse pattern. They are &lt;strong>orders of magnitude for design&lt;/strong>; measure on your own load.&lt;/li>
&lt;li>&lt;strong>Trusting million-token model specs without verifying.&lt;/strong> The landscape of open 1M models is very recent (2026) and moves fast; the specific names and limits change between versions. Verify the window &lt;strong>and the cost of serving it&lt;/strong> per model, do not take the headline on trust.&lt;/li>
&lt;/ol>
&lt;p>A note of caution for June 2026: the distributed KV layer (LMCache, Mooncake, KVBM, NIXL, llm-d) is &lt;strong>setting right now&lt;/strong>, with APIs and backends that change release by release. It is exactly the kind of piece worth assembling behind an abstraction (vLLM&amp;rsquo;s &lt;em>kv_connector&lt;/em>) rather than coupling to a single vendor.&lt;/p>
&lt;hr>
&lt;h2 id="closing">Closing&lt;/h2>
&lt;p>Long context is a problem of &lt;strong>memory and compute at the same time&lt;/strong>, and the KV cache is the bottleneck of both. The window is a gift from the model; serving it cheaply is systems engineering: &lt;strong>quantise the KV (FP8), offload it by tiers (HBM→DRAM→NVMe→network) with a persistent layer, and reorganise serving around the KV&lt;/strong> (aware routing, separated prefill/decode) when scale demands it. The rule that governs it all is Mooncake&amp;rsquo;s: &lt;strong>trade storage for computation&lt;/strong>. You store more KV in cheap places so you do not recompute expensive attention. Do it well and a 4×H100 node serves contexts that, by recomputing, would not even get started. Do it badly, or not at all, and every long request reads the whole case file from scratch again, while the GPU drowns in a quadratic attention you had already paid for once.&lt;/p>
&lt;h2 id="sources">Sources&lt;/h2>
&lt;ul>
&lt;li>LMCache · GitHub — &lt;a href="https://github.com/LMCache/LMCache">https://github.com/LMCache/LMCache&lt;/a>&lt;/li>
&lt;li>LMCache: An Efficient KV Cache Layer (arXiv 2510.09665) — &lt;a href="https://arxiv.org/pdf/2510.09665">https://arxiv.org/pdf/2510.09665&lt;/a>&lt;/li>
&lt;li>LMCache · Offload KV cache to CPU (docs) — &lt;a href="https://docs.lmcache.ai/getting_started/quickstart/offload_kv_cache.html">https://docs.lmcache.ai/getting_started/quickstart/offload_kv_cache.html&lt;/a>&lt;/li>
&lt;li>vLLM Prefix Caching vs. LMCache: Benchmarking KV Reuse Tradeoffs — &lt;a href="https://levelup.gitconnected.com/vllm-prefix-caching-vs-lmcache-benchmarking-kv-reuse-tradeoffs-944fbaf98b56">https://levelup.gitconnected.com/vllm-prefix-caching-vs-lmcache-benchmarking-kv-reuse-tradeoffs-944fbaf98b56&lt;/a>&lt;/li>
&lt;li>Mooncake: A KVCache-centric Disaggregated Architecture (arXiv 2407.00079) — &lt;a href="https://arxiv.org/abs/2407.00079">https://arxiv.org/abs/2407.00079&lt;/a>&lt;/li>
&lt;li>Mooncake · USENIX FAST'25 (Trading More Storage for Less Computation) — &lt;a href="https://www.usenix.org/conference/fast25/presentation/qin">https://www.usenix.org/conference/fast25/presentation/qin&lt;/a>&lt;/li>
&lt;li>Mooncake · GitHub — &lt;a href="https://github.com/kvcache-ai/Mooncake/">https://github.com/kvcache-ai/Mooncake/&lt;/a>&lt;/li>
&lt;li>NVIDIA Dynamo · Disaggregated Serving (docs) — &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>NVIDIA Dynamo · KV-aware routing (docs) — &lt;a href="https://docs.nvidia.com/dynamo/latest/user-guides/kv-cache-aware-routing">https://docs.nvidia.com/dynamo/latest/user-guides/kv-cache-aware-routing&lt;/a>&lt;/li>
&lt;li>NVIDIA Dynamo · KV Cache Offloading / KVBM (docs) — &lt;a href="https://docs.nvidia.com/dynamo/backends/v-llm/kv-cache-offloading">https://docs.nvidia.com/dynamo/backends/v-llm/kv-cache-offloading&lt;/a>&lt;/li>
&lt;li>llm-d · KV-Cache Wins You Can See — &lt;a href="https://llm-d.ai/blog/kvcache-wins-you-can-see">https://llm-d.ai/blog/kvcache-wins-you-can-see&lt;/a>&lt;/li>
&lt;li>Long-Context Inference at Scale: The Hidden Infrastructure Cost · DigitalOcean — &lt;a href="https://www.digitalocean.com/community/tutorials/long-context-inference-production-cost">https://www.digitalocean.com/community/tutorials/long-context-inference-production-cost&lt;/a>&lt;/li>
&lt;li>Long-Context LLM Infrastructure · Introl — &lt;a href="https://introl.com/blog/long-context-llm-infrastructure-million-token-windows-guide">https://introl.com/blog/long-context-llm-infrastructure-million-token-windows-guide&lt;/a>&lt;/li>
&lt;li>YaRN / LongRoPE (arXiv 2402.13753) — &lt;a href="https://arxiv.org/html/2402.13753v1">https://arxiv.org/html/2402.13753v1&lt;/a>&lt;/li>
&lt;/ul></description></item><item><title>Speeding up model cold start: from minutes to seconds</title><link>https://blog.lo0.es/en/posts/speeding-up-model-cold-start-minutes-to-seconds/</link><pubDate>Thu, 11 Jun 2026 13:00:00 +0000</pubDate><guid>https://blog.lo0.es/en/posts/speeding-up-model-cold-start-minutes-to-seconds/</guid><description>&lt;blockquote>
&lt;p>This is the &lt;strong>third batch&lt;/strong> of an operational series on squeezing a generic on-premise LLM cluster of &lt;strong>4×H100 SXM 80 GB with NVLink&lt;/strong>. The sister pieces in this batch are &lt;a href="https://blog.lo0.es/en/posts/multimodal-on-premise-serving-vlm-vllm/">Multimodal: serving a VLM on-premise with vLLM&lt;/a>, adding vision to the same engine, and &lt;a href="https://blog.lo0.es/en/posts/finops-multi-tenancy-gpu-cluster-who-pays/">FinOps and GPU multi-tenancy with LiteLLM&lt;/a>, sharing out and charging for the GPU across teams. This post takes the problem that &lt;a href="https://blog.lo0.es/en/posts/pantry-lift-from-disk-to-hbm-why-the-kitchen-opens-late/">From disk to HBM&lt;/a> laid out conceptually and turns it into a runbook: how you actually bring the cold start down, knob by knob, so that elasticity is usable.&lt;/p>
&lt;/blockquote>
&lt;h2 id="tldr">TL;DR&lt;/h2>
&lt;p>The post &lt;a href="https://blog.lo0.es/en/posts/pantry-lift-from-disk-to-hbm-why-the-kitchen-opens-late/">From disk to HBM&lt;/a> left an uncomfortable idea behind: &lt;strong>moving the weights from disk to HBM is only one of the five line items of cold start&lt;/strong>. The other four, starting the process, creating the CUDA context, setting up the allocator and, the most expensive one that hardly anyone looks at, &lt;strong>capturing the CUDA graphs and compiling the JIT kernels&lt;/strong>, are untouched by any fast loader. That is why a server that loads the weights in 6 s can still take &lt;strong>90 s&lt;/strong> to be ready. This runbook attacks all five. For the weights: &lt;strong>safetensors&lt;/strong> (mmap, zero-copy, no bounce through a host FP32 buffer) versus the &lt;code>pickle&lt;/code> of &lt;code>torch.load&lt;/code>; &lt;strong>Tensorizer&lt;/strong> (CoreWeave), which serialises the model into one file and &lt;strong>streams it tensor by tensor straight to the GPU&lt;/strong> from object storage/HTTP/S3; and the &lt;strong>Run:ai Model Streamer&lt;/strong> (from NVIDIA), which &lt;strong>reads concurrently and overlaps the read with the H2D copy&lt;/strong>. All three are switched on with one flag, &lt;code>--load-format&lt;/code>, in vLLM. For the non-weight part: &lt;strong>torch.compile/Inductor compilation cache&lt;/strong> (persisted and reused across start-ups), &lt;strong>bounding the CUDA graph capture&lt;/strong> to the batch sizes you really use (the default capture can eat ~54 s), and &lt;strong>sleep mode&lt;/strong> as a shortcut that skips all five line items. The maths: $t = W/B$ for the weights, the saving from &lt;strong>overlapping&lt;/strong> read and H2D instead of doing them in series, and a 70B cold start broken down item by item. And the economics: when &lt;strong>scale-to-zero with pre-warm&lt;/strong> beats &lt;strong>keeping a warm replica&lt;/strong>. On the generic 4×H100 SXM 80 GB cluster.&lt;/p>
&lt;h2 id="the-analogy-opening-the-kitchen-in-the-morning">The analogy: opening the kitchen in the morning&lt;/h2>
&lt;p>A restaurant does not open by flicking a switch. Anyone who has worked in one knows it: opening time is decided by &lt;strong>everything that happens beforehand&lt;/strong>, and almost none of it is &amp;ldquo;bringing in the food&amp;rdquo;.&lt;/p>
&lt;p>The first cook arrives at a cold, dark place. They have to: &lt;strong>turn on the lights and start the systems&lt;/strong> (the process init); &lt;strong>fire up the ovens and let them come up to temperature&lt;/strong> (the CUDA context, which takes its time to become operational); &lt;strong>get the boards out, sort the cold room, organise the work space&lt;/strong> (the memory allocator, which reserves and structures the HBM); &lt;strong>go down to the store and bring up the produce&lt;/strong>, the crates of vegetables, the meat, the fish, &lt;strong>and put it in its place&lt;/strong> (loading the weights from disk to HBM, the entire subject of &lt;a href="https://blog.lo0.es/en/posts/pantry-lift-from-disk-to-hbm-why-the-kitchen-opens-late/">From disk to HBM&lt;/a>); and, the most forgotten of all, &lt;strong>sharpen every knife, set up the mise en place, make the stocks, pre-heat the mother sauces&lt;/strong>, all the preparation that is not an ingredient but without which not a single dish goes out (CUDA graph capture and JIT compilation of the kernels).&lt;/p>
&lt;p>Here is the trap that &lt;a href="https://blog.lo0.es/en/posts/pantry-lift-from-disk-to-hbm-why-the-kitchen-opens-late/">From disk to HBM&lt;/a> already anticipated: &lt;strong>if you only optimise the produce, you still open late&lt;/strong>. You can hire the best delivery service in the world, a blazingly fast service lift, crates that put themselves away. But if the cook is still sharpening knives and waiting for the ovens to come up to temperature, the kitchen &lt;strong>does not serve the first dish&lt;/strong>. The produce arrived early and the restaurant is still shut.&lt;/p>
&lt;p>Today&amp;rsquo;s runbook is, literally, the list of everything that has to be done for the kitchen to open on time, and which of those tasks can be sped up, which can be left done from the night before (cached), and when it pays better never to switch the kitchen off (warm replica) than to light it again every morning (scale-to-zero).&lt;/p>
&lt;h2 id="the-five-line-items-of-cold-start">The five line items of cold start&lt;/h2>
&lt;p>From the moment an inference pod is born until it returns its first token, the clock runs in five different places. It is worth naming them, because &lt;strong>each optimisation attacks one or two, never all of them&lt;/strong>:&lt;/p>
&lt;ol>
&lt;li>&lt;strong>Process init.&lt;/strong> Starting the Python interpreter, importing the engine (vLLM and its dependencies), parsing the configuration. Seconds, and it grows with the size of the environment and the CUDA/PyTorch &lt;code>import&lt;/code>s.&lt;/li>
&lt;li>&lt;strong>CUDA context.&lt;/strong> The first &lt;code>cudaSetDevice&lt;/code> / &lt;code>cudaFree(0)&lt;/code> initialises the CUDA runtime against the driver: it loads the context and maps the GPU. It is not instant, on the order of one to several seconds per GPU depending on driver and number of devices.&lt;/li>
&lt;li>&lt;strong>Allocator.&lt;/strong> vLLM sets up its memory manager over the HBM (PyTorch&amp;rsquo;s caching allocator plus the KV-cache layout). Reserving and structuring tens of GB has its cost.&lt;/li>
&lt;li>&lt;strong>Weight loading disk→HBM.&lt;/strong> The journey that &lt;a href="https://blog.lo0.es/en/posts/pantry-lift-from-disk-to-hbm-why-the-kitchen-opens-late/">From disk to HBM&lt;/a> dissected: disk → page cache → host buffer → PCIe → HBM. What almost everyone believes cold start &lt;strong>is&lt;/strong>, and it is only one of the five.&lt;/li>
&lt;li>&lt;strong>CUDA graph capture + kernel JIT.&lt;/strong> vLLM captures CUDA graphs for the various batch shapes and compiles kernels with &lt;code>torch.compile&lt;/code>/Inductor (and backends such as DeepGEMM or FlashInfer). This item is the great ignored one: &lt;strong>the default graph capture in vLLM takes on the order of 54 s&lt;/strong> because it covers a wide range of batch sizes (&lt;a href="https://docs.vllm.ai/en/latest/design/torch_compile/">vLLM, &lt;em>torch.compile integration&lt;/em>&lt;/a>; &lt;a href="https://developers.redhat.com/articles/2025/09/03/vllm-torchcompile-efficient-llm-inference-pytorch">Red Hat, &lt;em>vLLM with torch.compile&lt;/em>&lt;/a>).&lt;/li>
&lt;/ol>
&lt;div class="diagram" style="max-width:760px;margin:1.4rem auto;">
&lt;svg viewBox="0 0 760 300" xmlns="http://www.w3.org/2000/svg" role="img" aria-label="The five line items of cold start as stacked time bars">
&lt;text x="380" y="24" text-anchor="middle" font-family="sans-serif" font-size="14" font-weight="700" fill="currentColor">The five line items of a cold start (example 70B, default loader)&lt;/text>
&lt;text x="380" y="44" text-anchor="middle" font-family="sans-serif" font-size="11" fill="currentColor" opacity="0.75">width proportional to time · scale 0–110 s&lt;/text>
&lt;!-- axis -->
&lt;line x1="40" y1="250" x2="720" y2="250" stroke="currentColor" stroke-width="1.2"/>
&lt;text x="40" y="268" text-anchor="middle" font-family="sans-serif" font-size="9.5" fill="currentColor">0 s&lt;/text>
&lt;text x="380" y="268" text-anchor="middle" font-family="sans-serif" font-size="9.5" fill="currentColor">55 s&lt;/text>
&lt;text x="720" y="268" text-anchor="middle" font-family="sans-serif" font-size="9.5" fill="currentColor">110 s&lt;/text>
&lt;!-- bars: x=40 base, 680px = 110s => 6.18 px/s -->
&lt;!-- init 4s = 25px -->
&lt;rect x="40" y="70" width="25" height="30" fill="#8b5cf6" fill-opacity="0.85"/>
&lt;text x="160" y="89" font-family="sans-serif" font-size="11" fill="currentColor">1 · process init — ~4 s&lt;/text>
&lt;!-- CUDA context 6s = 37px, start 65 -->
&lt;rect x="65" y="106" width="37" height="30" fill="#3b82f6" fill-opacity="0.85"/>
&lt;text x="160" y="125" font-family="sans-serif" font-size="11" fill="currentColor">2 · CUDA context — ~6 s&lt;/text>
&lt;!-- allocator 4s = 25px, start 102 -->
&lt;rect x="102" y="142" width="25" height="30" fill="#22c55e" fill-opacity="0.85"/>
&lt;text x="160" y="161" font-family="sans-serif" font-size="11" fill="currentColor">3 · allocator — ~4 s&lt;/text>
&lt;!-- weights 40s = 247px, start 127 -->
&lt;rect x="127" y="178" width="247" height="30" fill="#f59e0b" fill-opacity="0.9"/>
&lt;text x="384" y="197" font-family="sans-serif" font-size="11" fill="currentColor">4 · weight loading disk→HBM — ~40 s&lt;/text>
&lt;!-- graphs+jit 50s = 309px, start 374 -->
&lt;rect x="374" y="214" width="309" height="30" fill="#ef4444" fill-opacity="0.9"/>
&lt;text x="690" y="233" text-anchor="end" font-family="sans-serif" font-size="11" fill="#ffffff" font-weight="600">5 · CUDA graphs + JIT — ~50 s&lt;/text>
&lt;text x="380" y="290" text-anchor="middle" font-family="sans-serif" font-size="10.5" fill="currentColor" opacity="0.85">total ≈ 104 s · optimising only item 4 leaves item 5 intact — and 5 is the largest here&lt;/text>
&lt;/svg>
&lt;/div>
&lt;p>The lesson the diagram is shouting: on a modern server with &lt;code>torch.compile&lt;/code> and CUDA graphs, &lt;strong>item 5 can be as big as or bigger than item 4&lt;/strong>. Speeding up weight loading from 40 s to 6 s is a huge advance, but if you leave graph capture at 50 s, the cold start falls from 104 s to ~70 s, not to 12 s. &lt;strong>You have to attack both halves.&lt;/strong>&lt;/p>
&lt;h2 id="speeding-up-item-4-loading-the-weights">Speeding up item 4: loading the weights&lt;/h2>
&lt;h3 id="safetensors-versus-pickle-the-format-rules">safetensors versus pickle: the format rules&lt;/h3>
&lt;p>The first knob is the on-disk format. The old &lt;code>torch.load&lt;/code> uses &lt;strong>&lt;code>pickle&lt;/code>&lt;/strong>, which has two problems. The security one is well known: deserialising a pickle &lt;strong>executes arbitrary Python code&lt;/strong>, since the &lt;code>__reduce__&lt;/code> protocol lets the file invoke any callable on load, so every model download was a remote execution vector (&lt;a href="https://huggingface.co/docs/safetensors/index">HuggingFace, &lt;em>Safetensors&lt;/em>&lt;/a>). The performance one is what concerns us: loading a pickle rebuilds Python objects and usually passes through an intermediate &lt;strong>host buffer&lt;/strong> before reaching the GPU.&lt;/p>
&lt;p>&lt;strong>safetensors&lt;/strong>, now under the PyTorch Foundation (&lt;a href="https://huggingface.co/blog/safetensors-joins-pytorch-foundation">HuggingFace, &lt;em>Safetensors joins PyTorch Foundation&lt;/em>&lt;/a>), solves both. The file is just a &lt;strong>JSON header + raw tensor bytes&lt;/strong>: loading it cannot do anything except populate tensor buffers. And the key property for us is physical: &lt;strong>the data region is aligned to a page boundary&lt;/strong> (the header is padded so that the first tensor starts at a multiple of the OS page size). That is what makes &lt;strong>zero-copy&lt;/strong> possible: a loader can &lt;code>mmap&lt;/code> the file, &lt;code>cudaHostRegister&lt;/code> over the mapped region and &lt;strong>DMA straight from the page cache into VRAM&lt;/strong>, with no &lt;code>torch.load&lt;/code> deserialisation and no temporary host FP32 buffer (&lt;a href="https://huggingface.co/docs/safetensors/index">HuggingFace, &lt;em>Safetensors&lt;/em>&lt;/a>). It is the base format; everything else is built on top of it.&lt;/p>
&lt;blockquote>
&lt;p>&lt;strong>The &lt;code>mmap&lt;/code> trap&lt;/strong> (already flagged in &lt;a href="https://blog.lo0.es/en/posts/pantry-lift-from-disk-to-hbm-why-the-kitchen-opens-late/">From disk to HBM&lt;/a>): &lt;code>mmap&lt;/code> reads nothing immediately, it defers the cost to the first access to each page. If you do not force the read, the cold start looks short and the &lt;strong>first token&lt;/strong> pays the page faults. And the &amp;ldquo;blazingly fast second load&amp;rdquo; is the page cache lying to you: in production pods are born cold, on nodes where those files are not cached.&lt;/p>
&lt;/blockquote>
&lt;h3 id="tensorizer-streaming-tensor-by-tensor-straight-to-the-gpu">Tensorizer: streaming tensor by tensor straight to the GPU&lt;/h3>
&lt;p>&lt;strong>Tensorizer&lt;/strong>, from CoreWeave, serialises the model&amp;rsquo;s weights and its tensors &lt;strong>into a single file&lt;/strong> and, instead of loading the whole model into RAM before moving it to the GPU, &lt;strong>streams the data tensor by tensor&lt;/strong> from disk, an HTTP/HTTPS endpoint or an S3 bucket, &lt;strong>deserialising on the fly straight onto the GPU&lt;/strong> (&lt;a href="https://docs.vllm.ai/en/stable/models/extensions/tensorizer/">vLLM, &lt;em>Loading Models with CoreWeave&amp;rsquo;s Tensorizer&lt;/em>&lt;/a>). The operational advantage: &lt;strong>near-instant loading and low host RAM usage&lt;/strong> during initialisation, which matters especially in serverless and autoscaling scenarios, exactly the scale-to-zero case. And it decouples the weights from the container image: the model lives in object storage, not inflating the image pull.&lt;/p>
&lt;p>In vLLM it is enabled with &lt;code>--load-format tensorizer&lt;/code> (or &lt;code>load_format=&amp;quot;tensorizer&amp;quot;&lt;/code> via the API). It requires serialising the model once into Tensorizer&amp;rsquo;s format; from then on, any pod streams it.&lt;/p>
&lt;h3 id="runai-model-streamer-reading-and-copying-at-the-same-time">Run:ai Model Streamer: reading and copying at the same time&lt;/h3>
&lt;p>The &lt;strong>Run:ai Model Streamer&lt;/strong> (from NVIDIA) attacks the bottleneck from another angle: it &lt;strong>reads the tensors concurrently&lt;/strong>, N OS threads reading from storage into the CPU buffer, &lt;strong>while streaming them to VRAM&lt;/strong>, so that the &lt;strong>storage read and the H2D copy overlap&lt;/strong> instead of happening in series (&lt;a href="https://docs.vllm.ai/en/stable/models/extensions/runai_model_streamer/">vLLM, &lt;em>Loading models with Run:ai Model Streamer&lt;/em>&lt;/a>; &lt;a href="https://developer.nvidia.com/blog/reducing-cold-start-latency-for-llm-inference-with-nvidia-runai-model-streamer/">NVIDIA, &lt;em>Reducing Cold Start Latency&lt;/em>&lt;/a>). It reads safetensors directly, with no reconversion.&lt;/p>
&lt;p>The numbers published by NVIDIA give the order of magnitude: the streamer reaches &lt;strong>4.88 s reading from S3 at concurrency 32&lt;/strong> and &lt;strong>7.53 s from an SSD IO2 at concurrency 8&lt;/strong>; integrated into vLLM, the total time to &lt;em>ready&lt;/em> drops to &lt;strong>23.18 s from S3&lt;/strong>, &lt;strong>28.28 s from SSD IO2&lt;/strong> and &lt;strong>35.08 s from GP3&lt;/strong> (&lt;a href="https://developer.nvidia.com/blog/reducing-cold-start-latency-for-llm-inference-with-nvidia-runai-model-streamer/">NVIDIA, &lt;em>Reducing Cold Start Latency&lt;/em>&lt;/a>). Note the gap between the &lt;strong>weight loading&lt;/strong> time (~5–8 s) and the total to &lt;em>ready&lt;/em> (~23–35 s): those ~18–27 s of difference are &lt;strong>the other four line items&lt;/strong>, above all item 5. The streamer fixed item 4 and the rest is still there, exactly what this post warns about.&lt;/p>
&lt;p>Concurrency is the tuning parameter: it controls how many OS threads read tensors into the CPU buffer (and, for S3, how many client connections the host opens) (&lt;a href="https://docs.vllm.ai/en/stable/models/extensions/runai_model_streamer/">vLLM, &lt;em>Run:ai Model Streamer&lt;/em>&lt;/a>). &lt;strong>16 is usually enough for local NVMe; 32 for high-throughput object storage.&lt;/strong> One thread does not saturate a Gen5 NVMe; overlapping does.&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">&lt;span class="c1"># default safetensors (the slowest of the fast ones)&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">vllm serve &amp;lt;model&amp;gt; --load-format safetensors
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="c1"># Run:ai Model Streamer (concurrent reads, overlaps read + H2D)&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">vllm serve &amp;lt;model&amp;gt; --load-format runai_streamer &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-loader-extra-config &lt;span class="s1">&amp;#39;{&amp;#34;concurrency&amp;#34;: 32}&amp;#39;&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="c1"># Tensorizer (tensor-by-tensor stream straight to GPU from object storage/S3)&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">vllm serve &amp;lt;model&amp;gt; --load-format tensorizer
&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;h2 id="the-maths-of-the-weights-t--wb-and-the-saving-from-overlapping">The maths of the weights: $t = W/B$ and the saving from overlapping&lt;/h2>
&lt;p>The floor of item 4 is simple: moving $W$ gigabytes at a bandwidth $B$ takes&lt;/p>
$$t = \frac{W}{B}$$
&lt;p>Take a large model, a &lt;strong>70B&lt;/strong>. In BF16 ($b=2$ bytes/param) that is $W = 70 \times 10^9 \cdot 2 = 140$ GB; in FP8 ($b=1$), $W = 70$ GB. Bandwidth depends on the tier (see the table below): local Gen5 NVMe reads on the order of &lt;strong>~14 GB/s per disk&lt;/strong>; PCIe Gen5 x16 copies host→GPU at &lt;strong>~50 GB/s&lt;/strong>; network storage, &lt;strong>~1–3 GB/s&lt;/strong>. The theoretical floor for reading 140 GB from an NVMe is $140/14 = 10$ s; from the network at 2 GB/s, &lt;strong>70 s&lt;/strong>. The bottleneck rules.&lt;/p>
&lt;p>&lt;strong>The overlapping trick.&lt;/strong> The default loader does the two steps, reading from disk and copying H2D, &lt;strong>in series&lt;/strong>: first it fills a buffer, then it copies, then the next one. The time is the &lt;strong>sum&lt;/strong>:&lt;/p>
$$t_{\text{series}} = t_{\text{read}} + t_{\text{H2D}}$$
&lt;p>Tensorizer and the Run:ai Model Streamer &lt;strong>overlap&lt;/strong> them: while one thread copies a chunk to VRAM, another is already reading the next one from disk. With enough concurrency, the total time tends towards the &lt;strong>maximum&lt;/strong> of the two, not the sum:&lt;/p>
$$t_{\text{overlapped}} \approx \max(t_{\text{read}},\, t_{\text{H2D}})$$
&lt;p>With the 70 GB of the 70B in FP8, from NVMe at 14 GB/s and PCIe at 50 GB/s: $t_{\text{read}} = 70/14 = 5.0$ s, $t_{\text{H2D}} = 70/50 = 1.4$ s. In series, $5.0 + 1.4 = 6.4$ s; overlapped, $\max(5.0,\,1.4) = 5.0$ s. The saving here is modest (~22%) because the disk dominates comfortably. &lt;strong>Overlapping shines when the two steps are comparable&lt;/strong>: from network storage at 5 GB/s, $t_{\text{read}} = 70/5 = 14$ s and $t_{\text{H2D}} = 1.4$ s, series 15.4 s vs overlapped 14 s; but with many concurrent streams saturating a fast disk and a matched H2D, going from the sum to the maximum can &lt;strong>almost double&lt;/strong> the effective throughput. The flip side: overlapping only gives you what the &lt;strong>physical bottleneck&lt;/strong> allows. If the disk only delivers 14 GB/s, no streamer brings you below 5 s for 70 GB, and that is what the other lever is for, &lt;strong>moving fewer bytes&lt;/strong> (FP8 versus BF16 halves $W$).&lt;/p>
&lt;h3 id="the-storage-tier">The storage tier&lt;/h3>
&lt;table>
&lt;thead>
&lt;tr>
&lt;th>Tier&lt;/th>
&lt;th>Typical bandwidth&lt;/th>
&lt;th>140 GB (70B BF16)&lt;/th>
&lt;th>70 GB (70B FP8)&lt;/th>
&lt;th>Notes&lt;/th>
&lt;/tr>
&lt;/thead>
&lt;tbody>
&lt;tr>
&lt;td>Local Gen5 NVMe&lt;/td>
&lt;td>~14 GB/s per disk&lt;/td>
&lt;td>~10 s&lt;/td>
&lt;td>~5 s&lt;/td>
&lt;td>the short path; one disk&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>Local Gen5 NVMe (several, RAID0)&lt;/td>
&lt;td>~28–50 GB/s&lt;/td>
&lt;td>~3–5 s&lt;/td>
&lt;td>~1.5–2.5 s&lt;/td>
&lt;td>if the loader saturates several streams&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>Object storage (S3/RGW)&lt;/td>
&lt;td>~1–3 GB/s per stream&lt;/td>
&lt;td>~47–140 s&lt;/td>
&lt;td>~23–70 s&lt;/td>
&lt;td>streamer concurrency helps a great deal&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>NFS / shared network&lt;/td>
&lt;td>~1–2 GB/s&lt;/td>
&lt;td>~70–140 s&lt;/td>
&lt;td>~35–70 s&lt;/td>
&lt;td>puts the network and its contention in the path&lt;/td>
&lt;/tr>
&lt;/tbody>
&lt;/table>
&lt;p>The operational conclusion is the same as in &lt;a href="https://blog.lo0.es/en/posts/pantry-lift-from-disk-to-hbm-why-the-kitchen-opens-late/">From disk to HBM&lt;/a>: &lt;strong>the weights that serve the cold start live on the node&amp;rsquo;s local NVMe&lt;/strong>, not on the network. Network storage is the repository; the inference node has a hot local copy (pre-pull with an &lt;code>initContainer&lt;/code> or a per-node cache DaemonSet). Tensorizer is the interesting exception: it streams from object storage so efficiently that it sometimes makes serving from S3/RGW viable with no local copy, at the price of depending on the bandwidth of the storage network.&lt;/p>
&lt;h2 id="speeding-up-item-5-everything-that-is-not-weights">Speeding up item 5: everything that is not weights&lt;/h2>
&lt;p>Here is the half of the cold start that fast loaders do not touch. Three fronts.&lt;/p>
&lt;h3 id="caching-the-torchcompileinductor-compilation">Caching the torch.compile/Inductor compilation&lt;/h3>
&lt;p>&lt;code>torch.compile&lt;/code> has a &lt;strong>built-in cache system&lt;/strong>: the compiled artefacts are saved after the first start-up and &lt;strong>reused across start-ups&lt;/strong>, even across machines if it is configured properly. In vLLM, the result of the Dynamo compilation is stored in &lt;code>~/.cache/vllm/torch_compile_cache/&lt;/code> (&lt;a href="https://blog.vllm.ai/2025/08/20/torch-compile.html">vLLM Blog, &lt;em>Introduction to torch.compile&lt;/em>&lt;/a>; &lt;a href="https://docs.vllm.ai/en/latest/design/torch_compile/">vLLM, &lt;em>torch.compile integration&lt;/em>&lt;/a>). The consequence for the runbook: if that directory is &lt;strong>a persistent volume shared between pods&lt;/strong> (a PVC, or a &lt;code>hostPath&lt;/code> on the node&amp;rsquo;s NVMe populated by the first start-up), subsequent pods skip the compilation and start faster. The first pod pays for the compilation; the rest inherit the cache.&lt;/p>
&lt;p>It is exactly &amp;ldquo;sharpening the knives the night before&amp;rdquo;: the expensive preparation is done once and reused. The nuance: the cache is sensitive to the vLLM/PyTorch version, the GPU, the quantisation and the configuration, and changing any of them invalidates the cache and you pay for the compilation once again.&lt;/p>
&lt;h3 id="bounding-the-cuda-graph-capture">Bounding the CUDA graph capture&lt;/h3>
&lt;p>vLLM captures CUDA graphs for a &lt;strong>wide range of batch sizes&lt;/strong> by default, and that is expensive: the default capture is around &lt;strong>54 s&lt;/strong>, and on large configurations up to &lt;strong>294 s&lt;/strong> has been measured. By limiting the capture to the batch sizes your workload &lt;strong>really&lt;/strong> uses (for example &lt;code>1, 2, 4, 8, 16, 24, 32, 64&lt;/code>) the cold start is cut by &lt;strong>more than 70%&lt;/strong>, from 294 s to ~82 s in the measured case (&lt;a href="https://developers.redhat.com/articles/2025/09/03/vllm-torchcompile-efficient-llm-inference-pytorch">Red Hat, &lt;em>vLLM with torch.compile&lt;/em>&lt;/a>). The knob is the engine&amp;rsquo;s compilation/CUDA graphs configuration; the principle is not to capture graphs for batch sizes you will never see.&lt;/p>
&lt;blockquote>
&lt;p>&lt;strong>A real trade-off:&lt;/strong> fewer captured batch sizes = faster start-up but less coverage. A batch outside the captured set falls back to the eager path (slower per request). You bound to the frequent sizes of &lt;strong>your&lt;/strong> traffic, not of any traffic. And there is always the extreme of &lt;strong>disabling CUDA graphs&lt;/strong> (&lt;code>enforce_eager&lt;/code>): near-instant start-up on item 5 at the cost of throughput in the hot state, valid for debugging or for very low-traffic services where cold start weighs more than steady state.&lt;/p>
&lt;/blockquote>
&lt;h3 id="warm-pools-not-paying-for-item-5-on-the-critical-path">Warm pools: not paying for item 5 on the critical path&lt;/h3>
&lt;p>The definitive shortcut for all five items is &lt;strong>not to run them while the user waits&lt;/strong>. A &lt;em>warm pool&lt;/em>, a replica already started, with weights loaded, graphs captured and kernels compiled, waiting for traffic, turns the cold start into zero for that request. It is GPU cost (partly or wholly idle) in exchange for start-up latency; we analyse it in the economics section below.&lt;/p>
&lt;h3 id="sleep-mode-the-shortcut-that-skips-all-five-items">Sleep mode: the shortcut that skips all five items&lt;/h3>
&lt;p>And there is a shortcut halfway between &amp;ldquo;start from scratch&amp;rdquo; and &amp;ldquo;keep a whole replica warm&amp;rdquo;: vLLM&amp;rsquo;s &lt;strong>sleep mode&lt;/strong>, the subject of &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&lt;/a>. Instead of killing the process, it &lt;strong>puts it to sleep&lt;/strong>: it parks the weights in host RAM (level 1) or discards them (level 2), but &lt;strong>keeps the process alive&lt;/strong>, and with it the CUDA context, the allocator, &lt;strong>the CUDA graphs and the already compiled JIT kernels&lt;/strong>. The &lt;em>wake&lt;/em> pays for neither item 1, 2, 3 nor 5; it only puts the weights back into VRAM (item 4, and from RAM, not from disk). That is why a wake is &lt;strong>18–200× faster than a full cold start&lt;/strong>, and even level 2, which reloads the weights from the same disk, is still 23–45× faster, &lt;strong>because it skips the other four items&lt;/strong> (&lt;a href="https://blog.vllm.ai/2025/10/26/sleep-mode.html">vLLM Blog, &lt;em>Sleep Mode&lt;/em>&lt;/a>). Sleep mode is living proof of this post&amp;rsquo;s thesis: if preserving items 1, 2, 3 and 5 gives you 18–200×, then &lt;strong>moving bytes was never the whole cost&lt;/strong>.&lt;/p>
&lt;h2 id="an-example-cold-start-item-by-item">An example cold start, item by item&lt;/h2>
&lt;p>Let us put numbers on a cold start of &lt;strong>70B in FP8 (70 GB)&lt;/strong> from local NVMe and see which optimisation attacks each item:&lt;/p>
&lt;table>
&lt;thead>
&lt;tr>
&lt;th>#&lt;/th>
&lt;th>Line item&lt;/th>
&lt;th>Default&lt;/th>
&lt;th>Optimisation&lt;/th>
&lt;th>Optimised&lt;/th>
&lt;/tr>
&lt;/thead>
&lt;tbody>
&lt;tr>
&lt;td>1&lt;/td>
&lt;td>Process init&lt;/td>
&lt;td>~4 s&lt;/td>
&lt;td>(little room; minimal environment)&lt;/td>
&lt;td>~3 s&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>2&lt;/td>
&lt;td>CUDA context&lt;/td>
&lt;td>~6 s&lt;/td>
&lt;td>(driver; fixed)&lt;/td>
&lt;td>~6 s&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>3&lt;/td>
&lt;td>Allocator&lt;/td>
&lt;td>~4 s&lt;/td>
&lt;td>(fixed)&lt;/td>
&lt;td>~4 s&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>4&lt;/td>
&lt;td>Weight loading&lt;/td>
&lt;td>~25 s&lt;/td>
&lt;td>Run:ai streamer / Tensorizer (concurrency + overlap)&lt;/td>
&lt;td>~5 s&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>5&lt;/td>
&lt;td>CUDA graphs + JIT&lt;/td>
&lt;td>~54 s&lt;/td>
&lt;td>torch.compile cache + bounded capture&lt;/td>
&lt;td>~10 s&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>&lt;/td>
&lt;td>&lt;strong>Total&lt;/strong>&lt;/td>
&lt;td>&lt;strong>~93 s&lt;/strong>&lt;/td>
&lt;td>&lt;/td>
&lt;td>&lt;strong>~28 s&lt;/strong>&lt;/td>
&lt;/tr>
&lt;/tbody>
&lt;/table>
&lt;p>Two readings. First: optimising &lt;strong>only&lt;/strong> item 4 (a fast loader and nothing else) brings the total down from 93 to ~73 s, a real but disappointing improvement, because item 5 is still intact. Optimising &lt;strong>only&lt;/strong> item 5 (cache + bounded capture) brings it down to ~49 s. Attacking &lt;strong>both&lt;/strong> brings it down to ~28 s, and that is what turns theoretical scale-to-zero into usable scale-to-zero. Second: items 2 and 3 are practically irreducible, they depend on the driver and the hardware; they constitute the &lt;strong>floor of the cold start&lt;/strong>, on the order of 10 s, which no software optimisation crosses. Below that floor, the only answer is &lt;strong>not to start from scratch&lt;/strong>: sleep mode (wake from sub-second to a few seconds) or a warm replica (zero).&lt;/p>
&lt;h2 id="the-economics-of-scale-to-zero">The economics of scale-to-zero&lt;/h2>
&lt;p>The capacity question all of this serves is: &lt;strong>switch off or keep warm?&lt;/strong> Scale-to-zero saves GPU while there is no traffic, but pays the cold start when it comes back. The decision is a balance between the cost of an idle GPU and the latency SLA.&lt;/p>
&lt;p>The basic criterion: scale-to-zero is only viable if the cold start &lt;strong>fits inside the start-up SLA your users tolerate&lt;/strong>, or if you have a &lt;strong>pre-warming&lt;/strong> mechanism that starts the replica &lt;strong>before&lt;/strong> the request arrives (load prediction, an early signal from the queue). Without pre-warm, a 90 s cold start means the first user after an idle period waits 90 s, unacceptable for almost any interactive SLO. With the cold start brought down to ~28 s it is still a lot for an interactive request, but it is already tolerable for batch workloads or for a pre-warm triggered by &lt;a href="https://blog.lo0.es/en/posts/llm-inference-autoscaling-kubernetes-hpa-keda/">autoscaling with KEDA&lt;/a> on queue depth.&lt;/p>
&lt;p>The decision rule, in one sentence: &lt;strong>keep a warm replica when the cost of the idle GPU during the troughs is lower than the cost of missing the SLA on every start-up; do scale-to-zero (with pre-warm) when the troughs are long and deep and the optimised cold start fits in your tolerance&lt;/strong>. For a 24×7 interactive service with irregular but continuous traffic, a floor of warm replicas almost always wins. For a large model invoked a few times a day (a 70B for hard tasks), scale-to-zero with an optimised cold start, or sleep mode if it shares a GPU with another model, is the sensible option. It is the same &lt;a href="https://blog.lo0.es/en/posts/capacity-planning-on-premise-llm-inference/">capacity planning&lt;/a> boundary: cold start is a parameter of the replica cushion, not a start-up detail.&lt;/p>
&lt;h2 id="applied-to-the-generic-4h100-cluster">Applied to the generic 4×H100 cluster&lt;/h2>
&lt;p>Let us bring the runbook down to the &lt;strong>4 H100 SXM 80 GB with NVLink&lt;/strong>. The concrete decisions:&lt;/p>
&lt;p>&lt;strong>Loader and format.&lt;/strong> Weights in &lt;strong>safetensors&lt;/strong> on disk (never pickle). For the short path from local NVMe, the &lt;strong>Run:ai Model Streamer&lt;/strong> (&lt;code>--load-format runai_streamer&lt;/code>, concurrency 16–32) is the default option: concurrent reads, overlapping read and H2D, and you only change one flag. Keep &lt;strong>Tensorizer&lt;/strong> for the case where you serve from object storage (RGW/S3) and want to decouple the weights from the image with no local copy, since its tensor-by-tensor streaming straight to the GPU is what makes that pattern viable. In both cases, FP8 versus BF16 halves item 4 almost for free (measure the quality, do not assume it).&lt;/p>
&lt;p>&lt;strong>Storage tier.&lt;/strong> Model repository on the network (Ceph RGW), hot copy on the &lt;strong>node&amp;rsquo;s local NVMe&lt;/strong> populated by pre-pull. The cold start that counts is the cold one, on a node where the files are not in page cache; serving that start-up from the network puts its latency and contention into the critical path.&lt;/p>
&lt;p>&lt;strong>Caching item 5.&lt;/strong> The &lt;code>torch_compile_cache&lt;/code> directory on a persistent volume or a &lt;code>hostPath&lt;/code> NVMe shared between pods of the same model: the first pod compiles, the rest inherit. And &lt;strong>bound the CUDA graph capture&lt;/strong> to the real batch sizes of each service&amp;rsquo;s traffic.&lt;/p>
&lt;p>&lt;strong>When scale-to-zero with pre-warm vs a warm replica.&lt;/strong> The &lt;strong>agent LLM&lt;/strong> (main service, continuous traffic) &lt;strong>never&lt;/strong> does scale-to-zero: a floor of warm replicas on one or two GPUs. The &lt;strong>occasional 70B&lt;/strong> does: scale-to-zero with an optimised cold start, or, if it shares a GPU with a mid-sized model, &lt;strong>sleep mode&lt;/strong> so the wake is sub-second instead of a 28 s start-up (exactly the play in &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&lt;/a>). The optimised cold start in this post is what makes &lt;strong>swap&lt;/strong> and &lt;strong>failover&lt;/strong> between replicas tolerable: a replica that falls over and is replaced in 28 s degrades less than one that takes 90 s. And it connects directly with &lt;a href="https://blog.lo0.es/en/posts/finops-multi-tenancy-gpu-cluster-who-pays/">multi-tenancy&lt;/a>: a short cold start lets you share GPUs between teams with per-tenant scale-to-zero without the first user of each tenant paying for an endless start-up.&lt;/p>
&lt;p>The cross-cutting principle: &lt;strong>cold start is the ceiling on elasticity&lt;/strong>. The five line items, with both halves attacked, a fast loader for the weights and cache plus bounded capture for the graphs, bring it down from minutes to seconds. And below the irreducible floor of ~10 s (CUDA context + allocator), the only way out is not to start from scratch: sleep mode or a warm replica.&lt;/p>
&lt;h2 id="traps-and-things-that-are-not-what-they-seem">Traps and things that are not what they seem&lt;/h2>
&lt;p>&lt;strong>&amp;ldquo;I switched to a fast loader and start-up barely improved.&amp;rdquo;&lt;/strong> Your bottleneck probably was not item 4. If CUDA graph capture eats 54 s, bringing weight loading down from 25 to 5 s takes 20 s off 93, which is barely noticeable. Measure &lt;strong>where&lt;/strong> the time goes before optimising: vLLM&amp;rsquo;s start-up logs break down weight loading versus graph capture.&lt;/p>
&lt;p>&lt;strong>&amp;ldquo;The second time it started in a flash.&amp;rdquo;&lt;/strong> The page cache (for the weights) and the torch.compile cache (for the graphs) lying to you at the same time. The start-up that matters is the &lt;strong>cold&lt;/strong> one, on a clean node. Benchmarking the second start-up measures a situation that almost never occurs in the peak that triggers the autoscaler.&lt;/p>
&lt;p>&lt;strong>&amp;ldquo;Tensorizer/streamer will give me the 18–200× of sleep mode.&amp;rdquo;&lt;/strong> No. Those loaders speed up &lt;strong>only item 4&lt;/strong>. The 18–200× of &lt;a href="https://blog.lo0.es/en/posts/serving-several-models-single-gpu-co-residency-swapping-sleep-mode/">sleep mode&lt;/a> comes from &lt;strong>preserving the other four&lt;/strong> by keeping the process alive. They are tools for different problems: a fast loader is for a real cold start (a new process); sleep mode is for alternating models without killing the process.&lt;/p>
&lt;p>&lt;strong>&amp;quot;&lt;code>enforce_eager&lt;/code> fixes the cold start.&amp;quot;&lt;/strong> It fixes item 5 (no graph capture, near-instant start-up) but &lt;strong>degrades throughput in the hot state&lt;/strong>, since CUDA graphs exist because they speed up the steady state. It is a trade: it wins for very low traffic services where start-up weighs more than the hot path; it loses for a service with sustained load.&lt;/p>
&lt;p>&lt;strong>&amp;ldquo;I bound the CUDA graphs to a single batch size and that is it.&amp;rdquo;&lt;/strong> You bound to the sizes &lt;strong>your traffic uses&lt;/strong>, not to one. A batch outside the captured set falls back to the eager path and runs slower. Too aggressive and you penalise the steady state to gain a few seconds of start-up.&lt;/p>
&lt;p>&lt;strong>&amp;ldquo;Moving the weights to FP8 also speeds up item 5.&amp;rdquo;&lt;/strong> Not directly. FP8 halves the &lt;strong>bytes&lt;/strong> (item 4) and doubles inference throughput, but graph capture and kernel compilation do not depend on the size of the weights, they depend on the &lt;strong>shape&lt;/strong> of the graph and the batch sizes. Item 5 is attacked with caching and bounded capture, not with quantisation.&lt;/p>
&lt;h2 id="conclusion">Conclusion&lt;/h2>
&lt;p>&lt;a href="https://blog.lo0.es/en/posts/pantry-lift-from-disk-to-hbm-why-the-kitchen-opens-late/">From disk to HBM&lt;/a> said it conceptually: moving the weights is one of the five line items of cold start. This runbook turns it into action. For the weights, item 4, there is a clear menu: &lt;strong>safetensors&lt;/strong> as the base (mmap, zero-copy, no pickle), &lt;strong>Run:ai Model Streamer&lt;/strong> for the short path from NVMe (concurrent reads overlapping read and H2D), &lt;strong>Tensorizer&lt;/strong> for streaming from object storage with no local copy, and the cross-cutting lever of &lt;strong>moving fewer bytes&lt;/strong> with FP8. But the forgotten half of the start-up is &lt;strong>item 5&lt;/strong>, CUDA graph capture and JIT compilation, which on a modern server can be as big as the weight loading: you attack it by &lt;strong>caching the compilation&lt;/strong> across pods and &lt;strong>bounding the graph capture&lt;/strong> to the real batch sizes. Attacking only one half leaves the cold start half done; attacking both brings it from ~90 s to ~28 s. And below the irreducible floor of ~10 s imposed by the CUDA context and the allocator, the only way out is &lt;strong>not to start from scratch&lt;/strong>: sleep mode (sub-second wake) or a warm replica (zero). The kitchen opens on time when somebody took care of the produce &lt;strong>and&lt;/strong> of sharpening the knives the night before. Optimising only the delivery from the store leaves the ovens cold and the restaurant shut.&lt;/p>
&lt;h2 id="see-also">See also&lt;/h2>
&lt;ul>
&lt;li>
&lt;p>&lt;a href="https://blog.lo0.es/en/posts/multimodal-on-premise-serving-vlm-vllm/">Multimodal: serving a VLM on-premise with vLLM&lt;/a> — a sister piece in this batch: adding vision to the same inference engine; a VLM has its own cold start (the image encoder weights on top of the LLM).&lt;/p>
&lt;/li>
&lt;li>
&lt;p>&lt;a href="https://blog.lo0.es/en/posts/finops-multi-tenancy-gpu-cluster-who-pays/">FinOps and GPU multi-tenancy with LiteLLM&lt;/a> — a sister piece: a short cold start is what makes per-tenant scale-to-zero viable without penalising the first user of each team.&lt;/p>
&lt;/li>
&lt;li>
&lt;p>&lt;a href="https://blog.lo0.es/en/posts/pantry-lift-from-disk-to-hbm-why-the-kitchen-opens-late/">From disk to HBM: cold start and model loading&lt;/a> — the conceptual post this runbook continues: why moving bytes is only one of five items, and the path disk→page cache→host→PCIe→HBM.&lt;/p>
&lt;/li>
&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: swap and sleep mode&lt;/a> — sleep mode as the shortcut that preserves the four non-weight items and delivers the 18–200× wake.&lt;/p>
&lt;/li>
&lt;li>
&lt;p>&lt;a href="https://blog.lo0.es/en/posts/llm-inference-autoscaling-kubernetes-hpa-keda/">LLM autoscaling on Kubernetes with KEDA&lt;/a> — cold start is the ceiling on elasticity; pre-warming triggered by queue depth is what makes scale-to-zero usable.&lt;/p>
&lt;/li>
&lt;li>
&lt;p>&lt;a href="https://blog.lo0.es/en/posts/capacity-planning-on-premise-llm-inference/">Capacity planning for on-premise inference&lt;/a> — cold start as a parameter of the warm replica vs scale-to-zero decision and of the replica cushion.&lt;/p>
&lt;/li>
&lt;li>
&lt;p>&lt;a href="https://blog.lo0.es/en/posts/sharing-one-gpu-time-slicing-mps-mig/">Sharing a GPU: time-slicing, MPS and MIG&lt;/a> — sharing out one GPU between services; every co-resident service has its own start-up cost to budget for.&lt;/p>
&lt;/li>
&lt;li>
&lt;p>&lt;a href="https://blog.lo0.es/en/posts/model-chain-of-trust-where-the-bytes-live-registry-oci-artefacts/">Model chain of trust (2/4): registry, OCI artefacts and distribution&lt;/a> — the link before the cold start: which registry the bytes come from, how they are cached near the node and what mounting the model as an OCI artefact adds.&lt;/p>
&lt;/li>
&lt;/ul>
&lt;h2 id="references">References&lt;/h2>
&lt;ul>
&lt;li>HuggingFace, &lt;em>Safetensors&lt;/em> (format, zero-copy, security versus pickle): &lt;a href="https://huggingface.co/docs/safetensors/index">https://huggingface.co/docs/safetensors/index&lt;/a>&lt;/li>
&lt;li>HuggingFace, &lt;em>Safetensors is Joining the PyTorch Foundation&lt;/em>: &lt;a href="https://huggingface.co/blog/safetensors-joins-pytorch-foundation">https://huggingface.co/blog/safetensors-joins-pytorch-foundation&lt;/a>&lt;/li>
&lt;li>vLLM, &lt;em>Loading Models with CoreWeave&amp;rsquo;s Tensorizer&lt;/em>: &lt;a href="https://docs.vllm.ai/en/stable/models/extensions/tensorizer/">https://docs.vllm.ai/en/stable/models/extensions/tensorizer/&lt;/a>&lt;/li>
&lt;li>vLLM, &lt;em>Loading models with Run:ai Model Streamer&lt;/em>: &lt;a href="https://docs.vllm.ai/en/stable/models/extensions/runai_model_streamer/">https://docs.vllm.ai/en/stable/models/extensions/runai_model_streamer/&lt;/a>&lt;/li>
&lt;li>NVIDIA, &lt;em>Reducing Cold Start Latency for LLM Inference with NVIDIA Run:ai Model Streamer&lt;/em> (S3/SSD benchmarks, concurrency): &lt;a href="https://developer.nvidia.com/blog/reducing-cold-start-latency-for-llm-inference-with-nvidia-runai-model-streamer/">https://developer.nvidia.com/blog/reducing-cold-start-latency-for-llm-inference-with-nvidia-runai-model-streamer/&lt;/a>&lt;/li>
&lt;li>vLLM Blog, &lt;em>Introduction to torch.compile and How It Works with vLLM&lt;/em> (compilation cache): &lt;a href="https://blog.vllm.ai/2025/08/20/torch-compile.html">https://blog.vllm.ai/2025/08/20/torch-compile.html&lt;/a>&lt;/li>
&lt;li>vLLM, &lt;em>torch.compile integration&lt;/em> (cache in &lt;code>~/.cache/vllm/torch_compile_cache/&lt;/code>): &lt;a href="https://docs.vllm.ai/en/latest/design/torch_compile/">https://docs.vllm.ai/en/latest/design/torch_compile/&lt;/a>&lt;/li>
&lt;li>Red Hat Developer, &lt;em>vLLM with torch.compile: Efficient LLM inference on PyTorch&lt;/em> (CUDA graph capture ~54 s, bounding batch sizes −70%): &lt;a href="https://developers.redhat.com/articles/2025/09/03/vllm-torchcompile-efficient-llm-inference-pytorch">https://developers.redhat.com/articles/2025/09/03/vllm-torchcompile-efficient-llm-inference-pytorch&lt;/a>&lt;/li>
&lt;li>vLLM Blog, &lt;em>Zero-Reload Model Switching with vLLM Sleep Mode&lt;/em> (wake 18–200×): &lt;a href="https://blog.vllm.ai/2025/10/26/sleep-mode.html">https://blog.vllm.ai/2025/10/26/sleep-mode.html&lt;/a>&lt;/li>
&lt;/ul></description></item><item><title>Multimodal on-premise: serving a VLM with vLLM (vision + language)</title><link>https://blog.lo0.es/en/posts/multimodal-on-premise-serving-vlm-vllm/</link><pubDate>Thu, 11 Jun 2026 11:40:00 +0000</pubDate><guid>https://blog.lo0.es/en/posts/multimodal-on-premise-serving-vlm-vllm/</guid><description>&lt;blockquote>
&lt;p>Third batch of an operational series on squeezing a generic on-premise LLM cluster of 4×H100 SXM. Its sibling pieces in this batch are &lt;a href="https://blog.lo0.es/en/posts/finops-multi-tenancy-gpu-cluster-who-pays/">FinOps and GPU multi-tenancy with LiteLLM&lt;/a>, which puts a price on every token, and a visual token costs the same as a text one, and &lt;a href="https://blog.lo0.es/en/posts/speeding-up-model-cold-start-minutes-to-seconds/">Speeding up cold start with Tensorizer&lt;/a>, which matters twice as much here because a VLM loads two models (the vision encoder and the LLM). If the VLM ends up feeding a document assistant, that end-to-end setup with LibreChat and RAG is another story (in preparation).&lt;/p>
&lt;/blockquote>
&lt;h2 id="tldr">TL;DR&lt;/h2>
&lt;p>A vision-language model (VLM) is not magic: it is a normal LLM with a &lt;strong>sensory organ&lt;/strong> bolted on the front. Three pieces in series: a &lt;strong>vision encoder&lt;/strong> (a ViT) that looks at the image, a &lt;strong>projector&lt;/strong> or connector (typically an MLP) that translates the encoder&amp;rsquo;s output into the LLM&amp;rsquo;s embedding space, and the usual &lt;strong>LLM&lt;/strong>, which receives those embeddings as if they were just more tokens. The consequence that governs the entire operational design is brutal in its simplicity: &lt;strong>an image becomes visual tokens, and those tokens cost exactly the same as text tokens&lt;/strong>. A high-resolution document page is not &amp;ldquo;a couple of image tokens&amp;rdquo;: it is &lt;strong>hundreds or thousands&lt;/strong> of tokens entering the context, inflating the prefill (compute-bound) and occupying KV cache (memory-bound) just as if you had pasted a thousand words. In Qwen2.5-VL the arithmetic is literal: the number of visual tokens is $H\times W / (14\times14\times4)$, so an $896\times896$ image is &lt;strong>1,024 tokens&lt;/strong> and an A4 page scanned at a decent resolution easily reaches &lt;strong>1,500–2,500 tokens&lt;/strong> for the image alone. This post opens up the anatomy, works through the token-cost maths and translates it into TTFT and VRAM on an 80 GB H100, explains vLLM&amp;rsquo;s multimodal support (&lt;code>--limit-mm-per-prompt&lt;/code>, pixel budget via &lt;code>mm_processor_kwargs&lt;/code>, chat template) and answers the question that really matters: &lt;strong>classic OCR or VLM&lt;/strong>. The short answer: use Tesseract/PaddleOCR for the bulk of documents with clean text, and reserve the VLM, expensive in tokens and in GPU, for the subset that genuinely needs it: complex layout, nested tables, handwriting, stamps, charts. On the generic 4×H100 SXM cluster, with FP8 so it fits.&lt;/p>
&lt;h2 id="the-analogy-the-reader-who-sees-the-page-versus-the-one-who-only-hears-the-transcript">The analogy: the reader who sees the page versus the one who only hears the transcript&lt;/h2>
&lt;p>Picture two experts you ask to review a scanned contract. To the first you read the contract aloud, a blind transcript, word by word. It is fast, efficient, and for 90 % of the clauses it is enough: the text is the text. But there are things you cannot read out: that the signature at the end is handwritten and does not match the typed name; that there is a &amp;ldquo;PAID&amp;rdquo; stamp struck diagonally across the amount; that the table on page 3 has a merged cell that changes who each row applies to; that there is a pen annotation in the margin. The blind transcript loses all of that or flattens it into gibberish.&lt;/p>
&lt;p>To the second expert you hand the whole page, to look at. They see the signature, they see the stamp, they understand the structure of the table because they perceive the lines and the cells, they read the marginal note. They grasp the document as a visual object with layout, not as a river of characters. They are strictly more capable.&lt;/p>
&lt;p>Why not always use the second one, then? Because &lt;strong>seeing costs more mental bandwidth&lt;/strong>. The first expert receives the contract as a flow of words: cheap, linear. The second receives it as an image that their brain has to parcel up, attend to region by region, and reconstruct. They process a great deal more information per page, most of which, in a clean text document, is redundant with what the transcript would already have given them. If you only need to read the clauses, hiring the expert-who-sees for every page is overpaying for capability you do not use.&lt;/p>
&lt;p>That is the entire thesis of this post. The VLM is the expert who sees; OCR is the blind transcript; and &amp;ldquo;mental bandwidth&amp;rdquo; is &lt;strong>tokens&lt;/strong>. The engineering consists of sending the expert-who-sees only the pages where seeing changes the answer, and resolving the rest with the cheap transcript.&lt;/p>
&lt;h2 id="anatomy-of-a-vlm-encoder-projector-llm">Anatomy of a VLM: encoder, projector, LLM&lt;/h2>
&lt;p>A modern VLM of the Qwen-VL family (&lt;a href="https://huggingface.co/docs/transformers/model_doc/qwen2_5_vl">Qwen2.5-VL model card&lt;/a>; &lt;a href="https://arxiv.org/abs/2511.21631">Qwen3-VL Technical Report&lt;/a>) is made up of three blocks in series:&lt;/p>
&lt;ol>
&lt;li>
&lt;p>&lt;strong>Vision encoder (ViT).&lt;/strong> A Vision Transformer that receives the image, cuts it into patches and produces an embedding per patch (or per group of patches). In Qwen2.5-VL it is a dynamic-resolution ViT: it accepts images of arbitrary size, resizing them to multiples of 28 and splitting them into 14×14 pixel patches, with &lt;em>window attention&lt;/em> to speed up the processing of large images. There is no &amp;ldquo;canonical size&amp;rdquo; everything is squashed to: a large image produces more patches than a small one.&lt;/p>
&lt;/li>
&lt;li>
&lt;p>&lt;strong>Projector / connector.&lt;/strong> The ViT&amp;rsquo;s embedding space is not the LLM&amp;rsquo;s. The projector acts as the translator. In Qwen2.5-VL it is elegant and cheap: it groups every 2×2 block of adjacent patches (four ViT tokens), concatenates them and projects them with a two-layer MLP into a single fused token in the LLM&amp;rsquo;s dimension. That fusion factor of 4 is the reason for the $4$ that appears in the denominator of the token formula below: four ViT patches collapse into one visual token that the LLM sees.&lt;/p>
&lt;/li>
&lt;li>
&lt;p>&lt;strong>The LLM.&lt;/strong> The usual language transformer. It receives a sequence of embeddings that is a mixture of text tokens (from your prompt) and visual tokens (from the image), all in the same space, and does what it knows how to do: attention over the complete sequence and autoregressive generation. To the LLM, a visual token and a text token are indistinguishable in cost: both go through the same QKV projections, both occupy an entry in the KV cache, both take part in the $O(C^2)$ attention.&lt;/p>
&lt;/li>
&lt;/ol>
&lt;div class="diagram" style="max-width:820px;margin:1.5rem auto;">
&lt;svg viewBox="0 0 820 320" xmlns="http://www.w3.org/2000/svg" role="img" aria-label="VLM pipeline: image, vision encoder, projector, visual tokens, LLM, answer">
&lt;defs>&lt;marker id="vlm1" viewBox="0 0 10 10" refX="9" refY="5" markerWidth="7" markerHeight="7" orient="auto">&lt;path d="M0,0 L10,5 L0,10 z" fill="currentColor"/>&lt;/marker>&lt;/defs>
&lt;text x="410" y="22" text-anchor="middle" font-size="15" font-weight="700" fill="currentColor">The VLM pipeline: where the token budget explodes&lt;/text>
&lt;rect x="20" y="60" width="120" height="64" fill="none" stroke="#3b82f6" stroke-width="1.6"/>
&lt;text x="80" y="86" text-anchor="middle" font-size="12" font-weight="700" fill="currentColor">Image&lt;/text>
&lt;text x="80" y="104" text-anchor="middle" font-size="11" fill="currentColor">A4 page&lt;/text>
&lt;text x="80" y="118" text-anchor="middle" font-size="11" fill="currentColor">~1100×1500 px&lt;/text>
&lt;rect x="175" y="60" width="130" height="64" fill="none" stroke="#8b5cf6" stroke-width="1.6"/>
&lt;text x="240" y="82" text-anchor="middle" font-size="12" font-weight="700" fill="currentColor">ViT encoder&lt;/text>
&lt;text x="240" y="100" text-anchor="middle" font-size="11" fill="currentColor">14×14 patches&lt;/text>
&lt;text x="240" y="114" text-anchor="middle" font-size="11" fill="currentColor">window attn&lt;/text>
&lt;rect x="340" y="60" width="130" height="64" fill="none" stroke="#f59e0b" stroke-width="1.6"/>
&lt;text x="405" y="82" text-anchor="middle" font-size="12" font-weight="700" fill="currentColor">Projector&lt;/text>
&lt;text x="405" y="100" text-anchor="middle" font-size="11" fill="currentColor">2×2 fusion → MLP&lt;/text>
&lt;text x="405" y="114" text-anchor="middle" font-size="11" fill="currentColor">÷4 tokens&lt;/text>
&lt;rect x="505" y="60" width="140" height="64" fill="none" stroke="#ef4444" stroke-width="1.6"/>
&lt;text x="575" y="82" text-anchor="middle" font-size="12" font-weight="700" fill="currentColor">Visual tokens&lt;/text>
&lt;text x="575" y="100" text-anchor="middle" font-size="11" fill="currentColor">~1500–2500&lt;/text>
&lt;text x="575" y="114" text-anchor="middle" font-size="11" fill="currentColor">enter the context&lt;/text>
&lt;rect x="680" y="60" width="120" height="64" fill="none" stroke="#22c55e" stroke-width="1.6"/>
&lt;text x="740" y="86" text-anchor="middle" font-size="12" font-weight="700" fill="currentColor">LLM&lt;/text>
&lt;text x="740" y="104" text-anchor="middle" font-size="11" fill="currentColor">attention over&lt;/text>
&lt;text x="740" y="118" text-anchor="middle" font-size="11" fill="currentColor">text + vision&lt;/text>
&lt;path d="M140,92 L175,92" stroke="currentColor" stroke-width="1.6" fill="none" marker-end="url(#vlm1)"/>
&lt;path d="M305,92 L340,92" stroke="currentColor" stroke-width="1.6" fill="none" marker-end="url(#vlm1)"/>
&lt;path d="M470,92 L505,92" stroke="currentColor" stroke-width="1.6" fill="none" marker-end="url(#vlm1)"/>
&lt;path d="M645,92 L680,92" stroke="currentColor" stroke-width="1.6" fill="none" marker-end="url(#vlm1)"/>
&lt;text x="155" y="44" text-anchor="middle" font-size="11" fill="currentColor">pixels&lt;/text>
&lt;text x="322" y="44" text-anchor="middle" font-size="11" fill="currentColor">~8400 patches&lt;/text>
&lt;text x="487" y="44" text-anchor="middle" font-size="11" fill="currentColor">÷4&lt;/text>
&lt;text x="410" y="185" text-anchor="middle" font-size="13" font-weight="700" fill="currentColor">Here the budget explodes: the image is already text to the LLM&lt;/text>
&lt;rect x="120" y="205" width="580" height="26" fill="none" stroke="currentColor" stroke-width="1"/>
&lt;rect x="120" y="205" width="80" height="26" fill="#22c55e"/>
&lt;rect x="200" y="205" width="500" height="26" fill="#ef4444"/>
&lt;text x="160" y="222" text-anchor="middle" font-size="11" fill="#ffffff">prompt ~80 tok&lt;/text>
&lt;text x="450" y="222" text-anchor="middle" font-size="11" fill="#ffffff">visual tokens from the image ~1500–2500 tok&lt;/text>
&lt;text x="410" y="262" text-anchor="middle" font-size="12" fill="currentColor">prefill = process ALL of this (compute-bound) · KV cache = store ALL of this (memory-bound)&lt;/text>
&lt;text x="410" y="284" text-anchor="middle" font-size="12" fill="currentColor">in Qwen2.5-VL: visual tokens = H × W / (14 × 14 × 4)&lt;/text>
&lt;text x="410" y="304" text-anchor="middle" font-size="11" fill="currentColor">896×896 → 1024 tok · one image ≈ a long paragraph… or five pages of text&lt;/text>
&lt;/svg>
&lt;/div>
&lt;p>The idea to internalise: &lt;strong>the encoder and the projector are the sensory organ, but the inference cost lives in the LLM&lt;/strong>, and that cost is paid in tokens. The ViT adds a fixed prefill overhead (processing the image once), but the recurring bill, the LLM prefill and the KV cache throughout the whole generation, is set by the number of visual tokens the projector spits out. That is why the control lever is not &amp;ldquo;which encoder&amp;rdquo;, but &lt;strong>how many pixels you let through&lt;/strong>.&lt;/p>
&lt;h2 id="the-maths-what-it-costs-to-see-a-page">The maths: what it costs to see a page&lt;/h2>
&lt;p>Let us put numbers on &amp;ldquo;an image costs many tokens&amp;rdquo;, with the real Qwen2.5-VL formula (&lt;a href="https://qwenlm.github.io/blog/qwen2.5-vl/">Qwen team, Qwen2.5-VL blog&lt;/a>):&lt;/p>
$$N_{\text{tok}} = \frac{H \times W}{14 \times 14 \times 4} = \frac{H \times W}{784}$$
&lt;p>where $H$ and $W$ are height and width in pixels (rounded to the multiple of 28 the ViT imposes). The $14\times14$ is the patch size; the $\times4$ is the projector&amp;rsquo;s 2×2 fusion. A sanity check against the model card figure: an $896\times896$ image gives&lt;/p>
$$N_{\text{tok}} = \frac{896 \times 896}{784} = \frac{802{,}816}{784} = 1{,}024 \text{ tokens.}$$
&lt;p>It checks out. Now an A4 page scanned at a resolution reasonable for reading small text, say $1120 \times 1568$ px (approximate height $\times$ width, already rounded to multiples of 28):&lt;/p>
$$N_{\text{tok}} = \frac{1120 \times 1568}{784} = \frac{1{,}756{,}160}{784} \approx 2{,}240 \text{ tokens.}$$
&lt;p>&lt;strong>A single page is ~2,240 visual tokens&lt;/strong> before you write a word of prompt. For context: that is roughly what 1,700 words of Spanish text would take up (at ~1.3 tokens/word). If that A4 page contains 600 words of actual text, the VLM is paying &lt;strong>almost 4× in tokens&lt;/strong> to see it rather than read its transcript. If the page is a dense table or a handwritten document that OCR cannot read, that 4× is justified. If it is a paragraph of clean text, it is pure waste.&lt;/p>
&lt;p>Qwen2.5-VL lets you control this with &lt;code>min_pixels&lt;/code> / &lt;code>max_pixels&lt;/code>: the number of tokens per image is dynamic and ranges from about 4 up to 16,384 by default, adjustable with those parameters (&lt;a href="https://huggingface.co/docs/transformers/model_doc/qwen2_5_vl">HF Qwen2.5-VL docs&lt;/a>). Lowering &lt;code>max_pixels&lt;/code> cuts tokens at the expense of resolution, and of the ability to read the fine print. It is exactly the lever of &amp;ldquo;how much mental bandwidth do I give the expert&amp;rdquo;.&lt;/p>
&lt;h3 id="from-token-cost-to-ttft">From token cost to TTFT&lt;/h3>
&lt;p>Visual tokens are not free in latency. The prefill, processing the whole context before the first token, is compute-bound and its cost grows with the context length $C$ (linear in the projections, quadratic in the attention); this is worked through in detail in &lt;a href="https://blog.lo0.es/en/posts/vllm-attention-backend-flashattention-flashinfer-prefill-decode-asymmetry/">vLLM&amp;rsquo;s attention backend&lt;/a>. Every visual token enters that $C$.&lt;/p>
&lt;p>Let us model the TTFT as the time to process the prefill tokens at a given prefill throughput. Take a VLM of around 7–8B on an H100 with an illustrative prefill throughput of ~12,000 tok/s (an example figure; measure it, do not assume it, and it depends on how much the ViT forward pass weighs on top). For a request with an 80-token text prompt plus one page at 2,240 visual tokens:&lt;/p>
$$\text{TTFT} \approx \frac{80 + 2240}{12{,}000 \text{ tok/s}} \approx \frac{2320}{12{,}000} \approx 0{.}19 \text{ s}$$
&lt;p>plus the fixed cost of the vision encoder&amp;rsquo;s forward pass over the ~9,000 ViT patches (another compute-bound slice of prefill). Now five pages in the same request (a short PDF):&lt;/p>
$$\text{TTFT} \approx \frac{80 + 5 \times 2240}{12{,}000} = \frac{11{,}280}{12{,}000} \approx 0{.}94 \text{ s}$$
&lt;p>The TTFT multiplies almost fivefold, because the visual tokens completely dominate the context. And attention scales as $O(C^2)$: five pages are not &amp;ldquo;5× more expensive&amp;rdquo; in the attention component, but ~25× relative to one. The operational lesson: &lt;strong>the number of images and their resolution are your TTFT budget&lt;/strong>, not a detail. Lowering &lt;code>max_pixels&lt;/code> from 2,240 to, say, 1,100 tokens/page (lower resolution, enough for large text) cuts the prefill almost in half.&lt;/p>
&lt;h3 id="vram-the-vlms-weights-and-what-is-left-for-kv">VRAM: the VLM&amp;rsquo;s weights and what is left for KV&lt;/h3>
&lt;p>On an 80 GB H100 SXM, let us budget a VLM of the Qwen-VL family. Take an 8B (LLM) plus the vision encoder (a ViT of a few hundred million parameters; call the whole thing ~8.5B effective parameters for weight purposes). In BF16 (2 bytes/parameter):&lt;/p>
$$M_{\text{BF16}} \approx 8{.}5 \times 10^9 \times 2 \text{ B} \approx 17 \text{ GB.}$$
&lt;p>In FP8 (1 byte/parameter), the weights halve (&lt;a href="https://huggingface.co/Qwen/Qwen3-VL-8B-Instruct-FP8">Qwen3-VL-8B-Instruct-FP8&lt;/a>, with reported quality almost identical to BF16):&lt;/p>
$$M_{\text{FP8}} \approx 8{.}5 \times 10^9 \times 1 \text{ B} \approx 8{.}5 \text{ GB.}$$
&lt;p>For an 8B model this fits comfortably in BF16 on an H100; FP8 matters more for leaving VRAM free for KV cache, and here the KV is the problem, because visual tokens inflate it. The detail of end-to-end FP8 (weights and KV) is in &lt;a href="https://blog.lo0.es/en/posts/fp8-end-to-end-enable-measure-quality-decide/">FP8 end to end&lt;/a>. What is relevant: one page is ~2,240 KV entries; serving several sessions that send long documents consumes KV at a rate a text-only case would never see. With a 32B VLM in FP8 (~32 GB of weights) on an 80 GB H100 you are left with ~45 GB for KV and activations, which with long multimodal contexts run out sooner than &amp;ldquo;text-only&amp;rdquo; intuition suggests. The rule: with a VLM, &lt;strong>plan the VRAM counting the visual tokens in the KV, not just the weights&lt;/strong>.&lt;/p>
&lt;h2 id="serving-a-vlm-with-vllm">Serving a VLM with vLLM&lt;/h2>
&lt;p>vLLM supports image multimodal input natively for the Qwen-VL family and many other VLMs (&lt;a href="https://docs.vllm.ai/en/stable/features/multimodal_inputs/">Multimodal Inputs, vLLM docs&lt;/a>). The gears that matter:&lt;/p>
&lt;p>&lt;strong>&lt;code>--limit-mm-per-prompt&lt;/code>.&lt;/strong> Limits how many multimodal items each prompt accepts, per modality. By default the limit is generous (999 per modality), but in production it is worth setting it low, &lt;code>--limit-mm-per-prompt '{&amp;quot;image&amp;quot;: 2}'&lt;/code>, because every accepted image reserves token and KV budget. If you set a limit of 8 images and someone sends 8 A4 pages, that is ~18,000 visual tokens in a single request: it blows up &lt;code>max_model_len&lt;/code> or the KV. The limit is a budget firebreak, not a cosmetic restriction. (A curious case: setting it to &lt;code>0&lt;/code> for a modality lets you pass &lt;em>precomputed embeddings&lt;/em> without loading the encoder module, saving VRAM, useful if you do the vision encoding outside vLLM.)&lt;/p>
&lt;p>&lt;strong>Pixel budget via &lt;code>mm_processor_kwargs&lt;/code>.&lt;/strong> Here the token formula connects with the configuration. When serving Qwen2.5-VL you pass, for example, &lt;code>--mm-processor-kwargs '{&amp;quot;min_pixels&amp;quot;: 12544, &amp;quot;max_pixels&amp;quot;: 254016}'&lt;/code> (&lt;a href="https://docs.vllm.ai/en/latest/examples/offline_inference/vision_language_multi_image/">vLLM Qwen2.5-VL discussion&lt;/a>). With $254{,}016$ maximum pixels per image, the token ceiling per image is $254{,}016 / 784 \approx 324$ tokens, an aggressive trim that keeps the cost in check at the price of not being able to read very small print. &lt;strong>This is the dial that really governs your token bill&lt;/strong>: raise it for dense documents, lower it for images where fine detail does not matter.&lt;/p>
&lt;p>&lt;strong>The chat template and the image placeholder.&lt;/strong> The model&amp;rsquo;s processor (loaded via &lt;code>AutoProcessor&lt;/code>) inserts special tokens marking where the image goes in the sequence: in Qwen2.5-VL the sequence &lt;code>&amp;lt;|vision_start|&amp;gt;&amp;lt;|image_pad|&amp;gt;&amp;lt;|vision_end|&amp;gt;&lt;/code>, where &lt;code>&amp;lt;|image_pad|&amp;gt;&lt;/code> expands to the exact number of visual tokens the image produced. vLLM applies this template automatically; it is worth knowing there is a documented quirk, the image block tends to be placed before the user&amp;rsquo;s text in the sequence order, regardless of where you put it in the content (&lt;a href="https://github.com/vllm-project/vllm/issues/15125">issue #15125, vllm-project/vllm&lt;/a>), which rarely causes trouble but is worth bearing in mind if the image/text order is semantically important for your prompt.&lt;/p>
&lt;p>&lt;strong>Current models.&lt;/strong> As of June 2026, the reference open family is Qwen3-VL (dense 2B/4B/8B/32B variants and MoE 30B-A3B / 235B-A22B, interleaved contexts of up to 256K tokens combining text, image and video), with published FP8 checkpoints of almost identical quality to BF16 (&lt;a href="https://arxiv.org/abs/2511.21631">Qwen3-VL Technical Report, arXiv 2511.21631&lt;/a>; &lt;a href="https://docs.vllm.ai/projects/recipes/en/latest/Qwen/Qwen3-VL.html">Qwen3-VL Usage Guide, vLLM recipes&lt;/a>). Qwen2.5-VL remains perfectly serviceable and its token formula is the one we have used because it is public and clean; the cost figures are of the same order in both generations.&lt;/p>
&lt;h2 id="classic-ocr-vs-vlm-when-to-use-which">Classic OCR vs VLM: when to use which&lt;/h2>
&lt;p>Here is the decision that pays the bill. Parsing documents to feed them into a &lt;a href="https://blog.lo0.es/en/posts/end-to-end-document-ingestion-pdf-to-indexed-chunk/">document ingestion&lt;/a> pipeline has two paths, and choosing the split between them well is the difference between a healthy cluster and a choking one.&lt;/p>
&lt;p>&lt;strong>Classic OCR (Tesseract, PaddleOCR).&lt;/strong> Text-recognition engines that run &lt;strong>on CPU&lt;/strong>, are cheap, fast and mature. Tesseract is the portable workhorse (edge, offline kiosks, embedded scanners). PaddleOCR is more capable on layout and Asian scripts, with its PP-Structure module for preserving structure, extracting tables and detecting key-value fields (&lt;a href="https://www.codesota.com/ocr/paddleocr-vs-tesseract">PaddleOCR vs Tesseract, CodeSOTA 2026&lt;/a>). On clean, well-scanned text, classic OCR is hard to beat on cost per page: zero GPU, millisecond latency, very high throughput. Its weakness is the &amp;ldquo;hard&amp;rdquo; document: complex multi-column layout, tables with merged cells, handwriting, stamps, charts, poor scan quality. There, pipeline-based OCR either fails or returns gibberish that destroys the structure.&lt;/p>
&lt;p>&lt;strong>VLM.&lt;/strong> It takes the image of the document and generates structured markdown or HTML in a single end-to-end pass, understanding the layout because it &lt;em>sees&lt;/em> it. It shines exactly where classic OCR struggles: nested tables, reading charts, handwriting, understanding the spatial relationship between elements. The price is the one we have been calculating all through this post: expensive in tokens and in GPU. Worth noting a 2026 nuance: the frontier has moved. VLM models specialised in document parsing have appeared, compact (~1–3B), open source, scoring very high on benchmarks such as OmniDocBench (PaddleOCR-VL in the 94–96 point band; dots.ocr 3B at ~88 with full markdown including tables) (&lt;a href="https://parsli.co/blog/llm-ocr-vs-traditional-ocr">LLM-OCR vs Traditional OCR, Parsli 2026&lt;/a>; &lt;a href="https://www.e2enetworks.com/blog/complete-guide-open-source-ocr-models-2025">Open-Source OCR Models, E2E 2025&lt;/a>). In other words: for pure parsing, today you may not want a 32B generalist VLM, but a small specialised VLM-OCR. But &lt;strong>it is still GPU and it still pays tokens per image&lt;/strong>: the cost calculation does not disappear, the model just gets cheaper.&lt;/p>
&lt;div class="diagram" style="max-width:780px;margin:1.5rem auto;">
&lt;svg viewBox="0 0 780 300" xmlns="http://www.w3.org/2000/svg" role="img" aria-label="Decision tree: classic OCR versus VLM">
&lt;defs>&lt;marker id="ocr1" viewBox="0 0 10 10" refX="9" refY="5" markerWidth="7" markerHeight="7" orient="auto">&lt;path d="M0,0 L10,5 L0,10 z" fill="currentColor"/>&lt;/marker>&lt;/defs>
&lt;text x="390" y="24" text-anchor="middle" font-size="15" font-weight="700" fill="currentColor">Classic OCR or VLM? Per-document triage&lt;/text>
&lt;rect x="290" y="44" width="200" height="40" rx="6" fill="none" stroke="currentColor" stroke-width="1.5"/>
&lt;text x="390" y="62" text-anchor="middle" font-size="12" fill="currentColor">Is the text clean and&lt;/text>
&lt;text x="390" y="77" text-anchor="middle" font-size="12" fill="currentColor">the layout simple?&lt;/text>
&lt;rect x="60" y="130" width="280" height="60" fill="none" stroke="#22c55e" stroke-width="1.6"/>
&lt;text x="200" y="152" text-anchor="middle" font-size="12" font-weight="700" fill="currentColor">YES → classic OCR (CPU)&lt;/text>
&lt;text x="200" y="170" text-anchor="middle" font-size="11" fill="currentColor">Tesseract / PaddleOCR · cheap · ms · no GPU&lt;/text>
&lt;text x="200" y="184" text-anchor="middle" font-size="11" fill="currentColor">80–90 % of documents land here&lt;/text>
&lt;rect x="440" y="130" width="280" height="60" fill="none" stroke="#ef4444" stroke-width="1.6"/>
&lt;text x="580" y="152" text-anchor="middle" font-size="12" font-weight="700" fill="currentColor">NO → VLM (GPU)&lt;/text>
&lt;text x="580" y="170" text-anchor="middle" font-size="11" fill="currentColor">tables, handwriting, stamps, charts, layout&lt;/text>
&lt;text x="580" y="184" text-anchor="middle" font-size="11" fill="currentColor">token-expensive · save it for the hard subset&lt;/text>
&lt;path d="M340,84 L200,130" stroke="#22c55e" stroke-width="1.6" fill="none" marker-end="url(#ocr1)"/>
&lt;path d="M440,84 L580,130" stroke="#ef4444" stroke-width="1.6" fill="none" marker-end="url(#ocr1)"/>
&lt;text x="250" y="112" text-anchor="middle" font-size="11" fill="currentColor">yes&lt;/text>
&lt;text x="530" y="112" text-anchor="middle" font-size="11" fill="currentColor">no&lt;/text>
&lt;text x="390" y="226" text-anchor="middle" font-size="13" font-weight="700" fill="currentColor">The optimal split: cheap triage first, VLM only for what needs it&lt;/text>
&lt;text x="390" y="250" text-anchor="middle" font-size="11" fill="currentColor">a light classifier (any tables? low OCR confidence? handwriting?) picks the route&lt;/text>
&lt;text x="390" y="270" text-anchor="middle" font-size="11" fill="currentColor">so the VLM only touches 10–20 % of pages, and the GPU does not drown in clean text&lt;/text>
&lt;/svg>
&lt;/div>
&lt;p>&lt;strong>The split rule.&lt;/strong> It is not &amp;ldquo;OCR or VLM&amp;rdquo; as a global choice, but &lt;strong>per-document triage&lt;/strong>. Run everything through a cheap first filter: classic OCR already returns a confidence score; if it is high and the document has no tables or visual elements, the OCR transcript will do and you have spent CPU. If confidence is low, there are tables, there is handwriting or there are graphical elements, escalate that page to the VLM. In a typical corpus, 80–90 % of pages are resolved with cheap OCR and only the rest consumes GPU. This is what makes an on-premise VLM sustainable: &lt;strong>you do not serve everything with it&lt;/strong>, you use it as the expensive specialist you send only the hard cases.&lt;/p>
&lt;h2 id="applied-to-the-generic-4h100-cluster">Applied to the generic 4×H100 cluster&lt;/h2>
&lt;p>How this fits into a cluster of 4×H100 SXM 80GB:&lt;/p>
&lt;p>&lt;strong>Where to place the VLM.&lt;/strong> A VLM deserves &lt;strong>a whole H100&lt;/strong> or a large slice, for a VLM-specific reason that text-only intuition does not anticipate: &lt;strong>the token cost per image means not many concurrent sessions fit&lt;/strong>. If each request brings one or several pages, each consumes thousands of KV tokens; the KV cache runs out with few simultaneous sessions. Compared with a text-only chat service where dozens of short conversations fit per GPU, a document service saturates the KV with a handful of heavy requests. That is why it makes no sense to carve the GPU finely for the VLM with aggressive MIG (GPU sharing is covered in &lt;a href="https://blog.lo0.es/en/posts/sharing-one-gpu-time-slicing-mps-mig/">sharing a GPU: time-slicing, MPS and MIG&lt;/a>): the bottleneck is not idle compute but the KV, and a small slice runs out of KV before it makes use of the compute.&lt;/p>
&lt;p>&lt;strong>FP8 so it fits and to free up KV.&lt;/strong> Serve the VLM in FP8: at equal reported quality, the weights take up half and you leave the maximum VRAM for the KV cache, which is the scarce resource with multimodal contexts. For a 32B in FP8 (~32 GB) on an H100, those ~45 GB free for KV are what determine how many concurrent pages you can handle.&lt;/p>
&lt;p>&lt;strong>The VLM only for the subset that needs it.&lt;/strong> The most important architectural piece: &lt;strong>do not put the VLM on the critical path of every document&lt;/strong>. The triage from the previous section lives upstream; the bulk (clean text) is resolved with OCR/PaddleOCR on CPU, and in a cluster with 4×H100 there is probably spare CPU to run it in parallel with the GPUs, and only the hard subset reaches the VLM. This frees the H100s for what really needs them and keeps a reasonable cost per document. It connects directly with &lt;a href="https://blog.lo0.es/en/posts/finops-multi-tenancy-gpu-cluster-who-pays/">FinOps and GPU multi-tenancy with LiteLLM&lt;/a>: when you put a price on every token, a visual token costs the same as a text one, so a page through the VLM can cost 4× its transcript, and that should show up in the chargeback of the team that decides to send everything to the VLM for convenience.&lt;/p>
&lt;p>&lt;strong>Double cold start.&lt;/strong> An operational detail that bites: a VLM loads two models into memory, the vision encoder and the LLM, so its cold start is heavier than that of an equivalent LLM. If you are going to do model swapping or on-demand scaling, fast loading matters twice as much; that is where &lt;a href="https://blog.lo0.es/en/posts/speeding-up-model-cold-start-minutes-to-seconds/">speeding up cold start with Tensorizer&lt;/a> comes in. If instead you serve several models on one GPU by rotating them, the swap+sleep pattern (&lt;a href="https://blog.lo0.es/en/posts/serving-several-models-single-gpu-co-residency-swapping-sleep-mode/">serving several models on one GPU&lt;/a>) has to account for the VLM taking up more room and loading more slowly.&lt;/p>
&lt;p>&lt;strong>Consumer GPU, for reference.&lt;/strong> If someone wants to prototype outside the cluster, an RTX 5090 (Blackwell, 32 GB) comfortably serves a 7–8B VLM in FP8 (~8.5 GB of weights) with KV to spare for a few pages per request; a 32B in FP8 (~32 GB) no longer fits with useful KV on that card and calls for the H100. The 5090 is good for validating the triage pipeline and the prompt; the real service belongs on the H100s.&lt;/p>
&lt;h2 id="what-we-have-not-covered">What we have not covered&lt;/h2>
&lt;ul>
&lt;li>&lt;strong>Video.&lt;/strong> Qwen3-VL handles video with dynamic FPS sampling; each frame is an image and the token cost multiplies by the number of sampled frames. One minute of video can be tens of thousands of tokens. It is the most expensive multimodal beast and deserves its own analysis.&lt;/li>
&lt;li>&lt;strong>Prefix caching of images.&lt;/strong> If the same image (a logo, a template) appears in many requests, can its KV be cached? It depends on the backend and on the stability of the placeholder; it connects with the &lt;a href="https://blog.lo0.es/en/posts/vllm-attention-backend-flashattention-flashinfer-prefill-decode-asymmetry/">attention backend&lt;/a>.&lt;/li>
&lt;li>&lt;strong>Evaluation.&lt;/strong> How to measure that the VLM extracts the table correctly (it is not enough for it to &amp;ldquo;look right&amp;rdquo;): faithfulness against a set with structured ground truth.&lt;/li>
&lt;li>&lt;strong>Fine-tuning the VLM&lt;/strong> for a specific document domain (invoices, particular forms), which can put part of the domain&amp;rsquo;s &amp;ldquo;knowing how to see&amp;rdquo; into the weights and reduce what the prompt has to explain.&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/finops-multi-tenancy-gpu-cluster-who-pays/">FinOps and GPU multi-tenancy with LiteLLM&lt;/a> — sibling piece in this batch: when you put a price on every token, a visual token costs the same as a text one, and a page through the VLM can cost 4× its transcript. Chargeback is what disciplines VLM usage.&lt;/li>
&lt;li>&lt;a href="https://blog.lo0.es/en/posts/speeding-up-model-cold-start-minutes-to-seconds/">Speeding up cold start with Tensorizer&lt;/a> — sibling piece: a VLM loads two models (encoder + LLM), so its start-up is heavier and fast loading matters twice as much.&lt;/li>
&lt;li>&lt;a href="https://blog.lo0.es/en/posts/end-to-end-document-ingestion-pdf-to-indexed-chunk/">Document ingestion: from PDF to indexed chunk&lt;/a> — the pipeline where the OCR/VLM triage fits: the bulk through cheap OCR, the hard subset through the VLM.&lt;/li>
&lt;li>&lt;a href="https://blog.lo0.es/en/posts/sharing-one-gpu-time-slicing-mps-mig/">Sharing a GPU: time-slicing, MPS and MIG&lt;/a> — why a small slice is no use to the VLM: the bottleneck is the KV that visual tokens eat, not idle compute.&lt;/li>
&lt;li>&lt;a href="https://blog.lo0.es/en/posts/serving-several-models-single-gpu-co-residency-swapping-sleep-mode/">Serving several models on one GPU: swap + sleep&lt;/a> — rotating the VLM with other models, allowing for the fact that it takes up more VRAM and loads more slowly because of its double model.&lt;/li>
&lt;li>&lt;a href="https://blog.lo0.es/en/posts/vllm-attention-backend-flashattention-flashinfer-prefill-decode-asymmetry/">vLLM&amp;rsquo;s attention backend&lt;/a> — the compute-bound prefill that every visual token fattens; why five pages cost not 5× but ~25× in attention.&lt;/li>
&lt;li>&lt;a href="https://blog.lo0.es/en/posts/fp8-end-to-end-enable-measure-quality-decide/">FP8 end to end: weights and KV&lt;/a> — serving the VLM in FP8 so it fits and, above all, to free up the KV cache that multimodal contexts exhaust sooner than expected.&lt;/li>
&lt;/ul>
&lt;h2 id="references">References&lt;/h2>
&lt;ul>
&lt;li>vLLM, &lt;em>Multimodal Inputs&lt;/em> (&lt;code>limit_mm_per_prompt&lt;/code>, precomputed embeddings): &lt;a href="https://docs.vllm.ai/en/stable/features/multimodal_inputs/">https://docs.vllm.ai/en/stable/features/multimodal_inputs/&lt;/a>&lt;/li>
&lt;li>vLLM, &lt;em>Vision Language Multi Image&lt;/em> (&lt;code>mm_processor_kwargs&lt;/code>, min/max pixels): &lt;a href="https://docs.vllm.ai/en/latest/examples/offline_inference/vision_language_multi_image/">https://docs.vllm.ai/en/latest/examples/offline_inference/vision_language_multi_image/&lt;/a>&lt;/li>
&lt;li>vLLM, &lt;em>Qwen3-VL Usage Guide&lt;/em> (recipes): &lt;a href="https://docs.vllm.ai/projects/recipes/en/latest/Qwen/Qwen3-VL.html">https://docs.vllm.ai/projects/recipes/en/latest/Qwen/Qwen3-VL.html&lt;/a>&lt;/li>
&lt;li>Qwen team, &lt;em>Qwen2.5-VL&lt;/em> (blog, token formula, dynamic resolution): &lt;a href="https://qwenlm.github.io/blog/qwen2.5-vl/">https://qwenlm.github.io/blog/qwen2.5-vl/&lt;/a>&lt;/li>
&lt;li>&lt;em>Qwen2.5-VL&lt;/em> (Transformers docs, min/max pixels, token range): &lt;a href="https://huggingface.co/docs/transformers/model_doc/qwen2_5_vl">https://huggingface.co/docs/transformers/model_doc/qwen2_5_vl&lt;/a>&lt;/li>
&lt;li>&lt;em>Qwen3-VL Technical Report&lt;/em>, arXiv 2511.21631: &lt;a href="https://arxiv.org/abs/2511.21631">https://arxiv.org/abs/2511.21631&lt;/a>&lt;/li>
&lt;li>&lt;em>Qwen3-VL-8B-Instruct-FP8&lt;/em> (FP8 with quality almost identical to BF16): &lt;a href="https://huggingface.co/Qwen/Qwen3-VL-8B-Instruct-FP8">https://huggingface.co/Qwen/Qwen3-VL-8B-Instruct-FP8&lt;/a>&lt;/li>
&lt;li>vLLM issue #15125 (image placeholder order): &lt;a href="https://github.com/vllm-project/vllm/issues/15125">https://github.com/vllm-project/vllm/issues/15125&lt;/a>&lt;/li>
&lt;li>&lt;em>PaddleOCR vs Tesseract&lt;/em>, CodeSOTA 2026: &lt;a href="https://www.codesota.com/ocr/paddleocr-vs-tesseract">https://www.codesota.com/ocr/paddleocr-vs-tesseract&lt;/a>&lt;/li>
&lt;li>&lt;em>LLM-Based OCR vs Traditional OCR&lt;/em>, Parsli 2026: &lt;a href="https://parsli.co/blog/llm-ocr-vs-traditional-ocr">https://parsli.co/blog/llm-ocr-vs-traditional-ocr&lt;/a>&lt;/li>
&lt;li>&lt;em>Open-Source OCR Models 2025&lt;/em>, E2E Networks: &lt;a href="https://www.e2enetworks.com/blog/complete-guide-open-source-ocr-models-2025">https://www.e2enetworks.com/blog/complete-guide-open-source-ocr-models-2025&lt;/a>&lt;/li>
&lt;/ul></description></item><item><title>Serving several models on a single GPU: co-residency, model-swapping and sleep mode</title><link>https://blog.lo0.es/en/posts/serving-several-models-single-gpu-co-residency-swapping-sleep-mode/</link><pubDate>Thu, 11 Jun 2026 02:40:00 +0000</pubDate><guid>https://blog.lo0.es/en/posts/serving-several-models-single-gpu-co-residency-swapping-sleep-mode/</guid><description>&lt;blockquote>
&lt;p>This is the second piece of an operational series on squeezing a generic on-premise LLM cluster of 4×H100 SXM 80 GB with NVLink. Its sibling, &lt;a href="https://blog.lo0.es/en/posts/sharing-one-gpu-time-slicing-mps-mig/">Sharing a GPU: time-slicing, MPS and MIG&lt;/a>, splits the GPU so that several processes can use it at once; this one splits the complementary problem: you have &lt;strong>more models than VRAM&lt;/strong> and you need them to coexist in time, not just in space. The third, &lt;a href="https://blog.lo0.es/en/posts/rag-cpu-data-plane-generation-plane/">The RAG data plane on CPU&lt;/a>, moves off the GPU everything that does not need to be there; and the end-to-end sovereign assistant (fourth instalment, in preparation) assembles all of this behind LibreChat and LiteLLM. Here we assume that VRAM is the scarce resource and that keeping everything loaded at once is not an option.&lt;/p>
&lt;/blockquote>
&lt;h2 id="tldr">TL;DR&lt;/h2>
&lt;p>You have an agent LLM serving almost all the traffic, one or two rerankers for RAG, an alternate model (another family, another language, a fine-tune) and, now and then, a large model for hard tasks. Added up, they do not fit in the 80 GB of an H100. There are three ways to make them coexist, and the key to not confusing them is to understand that &lt;strong>they are three different states of where the weights live&lt;/strong>:&lt;/p>
&lt;ul>
&lt;li>&lt;strong>Co-residency&lt;/strong>: several models loaded at the same time in HBM, if the sum of their VRAM budgets fits in the physical memory. It is the ideal when they fit: zero switching latency. The catch: each engine must cap its own VRAM; if two engines both believe they own the whole 80 GB, the second one to ask for memory dies with OOM. On a GPU shared by time-slicing, sharing time does not protect memory (the processes still compete for the same HBM) and this problem gets worse, as we see in the &lt;a href="https://blog.lo0.es/en/posts/sharing-one-gpu-time-slicing-mps-mig/">sibling piece&lt;/a>.&lt;/li>
&lt;li>&lt;strong>Model-swapping&lt;/strong> (with llama-swap): only one (or very few) resident at a time. The proxy looks at the request&amp;rsquo;s &lt;code>model&lt;/code> field and, if another one is needed, unloads the current model and loads the requested one. Since there is only one inside, each model can use almost the whole GPU. The cost is a full cold start: seconds from local NVMe, minutes from network storage. It mixes engines: it exposes OpenAI-compatible endpoints including &lt;code>/rerank&lt;/code> with &lt;code>llama-server&lt;/code> on GGUF.&lt;/li>
&lt;li>&lt;strong>vLLM sleep mode&lt;/strong> (&lt;code>--enable-sleep-mode&lt;/code>, endpoints &lt;code>/sleep&lt;/code> and &lt;code>/wake_up&lt;/code>): the model is not unloaded, it is put to sleep. The weights are parked in host RAM (level 1) or discarded while the process stays alive (level 2). The wake is &lt;strong>18–200× faster&lt;/strong> than a cold start because the process is still alive and keeps the CUDA allocator, the CUDA graphs and the compiled JIT kernels. The only thing rebuilt is the KV cache, which is discarded on sleep. It needs enough RAM for the sleeping weights.&lt;/li>
&lt;/ul>
&lt;p>The decision tree is short. &lt;strong>Do they all fit?&lt;/strong> Co-residency. &lt;strong>They do not fit, switching is infrequent and you mix engines?&lt;/strong> llama-swap. &lt;strong>Everything is vLLM, there is RAM to spare and wake latency matters?&lt;/strong> Sleep mode. &lt;strong>Sustained load on all of them at once?&lt;/strong> That is not swapping, it is more GPUs or replicas (see &lt;a href="https://blog.lo0.es/en/posts/one-big-replica-or-many-small-ones-platform-decision/">one big vs N small&lt;/a>). This post puts numbers on each of them on an 80 GB H100.&lt;/p>
&lt;h2 id="the-analogy-one-stage-one-spotlight">The analogy: one stage, one spotlight&lt;/h2>
&lt;p>Picture a theatre with a single stage and a single spotlight, the GPU. You have more actors (models) in the company than fit under the light at once. There are three ways to stage the show:&lt;/p>
&lt;ul>
&lt;li>
&lt;p>&lt;strong>Co-residency: several small actors on stage.&lt;/strong> If the cast for this scene is three slight actors, the main LLM, one reranker, another reranker, they all fit under the spotlight at the same time. Switching lines between them is instantaneous: they are already on stage. But the stage has a fixed size; add a fourth, burly actor (the large model) and they do not fit, so someone falls off the edge. That is the &lt;strong>OOM&lt;/strong>.&lt;/p>
&lt;/li>
&lt;li>
&lt;p>&lt;strong>Model-swapping: the actor goes home.&lt;/strong> When an actor finishes their part, they go home (the disk). If the next scene needs them again, they have to come back from home: get dressed, put the make-up on, travel to the theatre. That takes minutes. In exchange, while they are on stage they have the whole stage to themselves. That is the llama-swap model: maximum VRAM per actor, but re-entry costs a full round trip.&lt;/p>
&lt;/li>
&lt;li>
&lt;p>&lt;strong>Sleep mode: the actor waits in the wings.&lt;/strong> Instead of going home, the actor steps out of the spotlight but stays backstage (host RAM), already dressed and made up. When their turn comes, they step in in two strides. No journey, no wardrobe: just crossing the curtain. That is the vLLM wake: the weights come back not from disk but from RAM, and everything that goes into &amp;ldquo;preparing the actor&amp;rdquo;, the allocator, the CUDA graphs, the kernels, is already done because they never left the building.&lt;/p>
&lt;/li>
&lt;/ul>
&lt;p>The operational moral is the same as in the theatre: &lt;strong>the cost of an actor is not only their talent, it is how long they take to walk on when you need them.&lt;/strong> Co-residency pays for the permanent spot; swapping pays for the journey; sleep pays for the RAM of keeping them waiting nearby. The rest of this post is about choosing which, with a VRAM budget and a stopwatch in hand.&lt;/p>
&lt;h2 id="the-three-states-where-the-weights-live">The three states: where the weights live&lt;/h2>
&lt;div class="diagram" style="max-width:820px;margin:1.5rem auto;">
&lt;svg viewBox="0 0 820 360" xmlns="http://www.w3.org/2000/svg" role="img" aria-label="Three states for serving several models: co-residency, swap per request and sleep-wake">
&lt;text x="410" y="24" text-anchor="middle" font-size="15" font-weight="700" fill="currentColor">Where the weights live in each strategy&lt;/text>
&lt;!-- Co-residency -->
&lt;text x="140" y="56" text-anchor="middle" font-size="13" font-weight="700" fill="currentColor">Co-residency&lt;/text>
&lt;rect x="30" y="68" width="220" height="120" fill="none" stroke="#3b82f6" stroke-width="1.6"/>
&lt;text x="140" y="86" text-anchor="middle" font-size="11" font-weight="700" fill="#3b82f6">HBM 80 GB&lt;/text>
&lt;rect x="44" y="96" width="192" height="26" fill="#3b82f6" fill-opacity="0.18" stroke="#3b82f6" stroke-width="1"/>
&lt;text x="140" y="113" text-anchor="middle" font-size="10.5" fill="currentColor">agent LLM&lt;/text>
&lt;rect x="44" y="126" width="92" height="22" fill="#22c55e" fill-opacity="0.18" stroke="#22c55e" stroke-width="1"/>
&lt;text x="90" y="141" text-anchor="middle" font-size="10" fill="currentColor">reranker A&lt;/text>
&lt;rect x="144" y="126" width="92" height="22" fill="#22c55e" fill-opacity="0.18" stroke="#22c55e" stroke-width="1"/>
&lt;text x="190" y="141" text-anchor="middle" font-size="10" fill="currentColor">reranker B&lt;/text>
&lt;rect x="44" y="152" width="192" height="28" fill="none" stroke="#888" stroke-width="1" stroke-dasharray="3 3"/>
&lt;text x="140" y="170" text-anchor="middle" font-size="10" fill="currentColor" opacity="0.75">KV + activations headroom&lt;/text>
&lt;text x="140" y="206" text-anchor="middle" font-size="10.5" fill="currentColor">switch: 0 ms · sum &amp;lt; 80 GB or OOM&lt;/text>
&lt;!-- Swap -->
&lt;text x="410" y="56" text-anchor="middle" font-size="13" font-weight="700" fill="currentColor">Model-swapping (llama-swap)&lt;/text>
&lt;rect x="300" y="68" width="220" height="120" fill="none" stroke="#3b82f6" stroke-width="1.6"/>
&lt;text x="410" y="86" text-anchor="middle" font-size="11" font-weight="700" fill="#3b82f6">HBM 80 GB&lt;/text>
&lt;rect x="314" y="96" width="192" height="84" fill="#f59e0b" fill-opacity="0.18" stroke="#f59e0b" stroke-width="1.2"/>
&lt;text x="410" y="135" text-anchor="middle" font-size="11" fill="currentColor">one resident model&lt;/text>
&lt;text x="410" y="151" text-anchor="middle" font-size="10" fill="currentColor" opacity="0.8">uses almost all the VRAM&lt;/text>
&lt;text x="410" y="206" text-anchor="middle" font-size="10.5" fill="currentColor">the rest on DISK (NVMe / network)&lt;/text>
&lt;rect x="330" y="222" width="160" height="22" fill="#ef4444" fill-opacity="0.12" stroke="#ef4444" stroke-width="1"/>
&lt;text x="410" y="237" text-anchor="middle" font-size="10" fill="#ef4444">switch: unload + load (s–min)&lt;/text>
&lt;!-- Sleep -->
&lt;text x="680" y="56" text-anchor="middle" font-size="13" font-weight="700" fill="currentColor">Sleep mode (vLLM)&lt;/text>
&lt;rect x="570" y="68" width="220" height="120" fill="none" stroke="#3b82f6" stroke-width="1.6"/>
&lt;text x="680" y="86" text-anchor="middle" font-size="11" font-weight="700" fill="#3b82f6">HBM 80 GB&lt;/text>
&lt;rect x="584" y="96" width="192" height="60" fill="#f59e0b" fill-opacity="0.18" stroke="#f59e0b" stroke-width="1.2"/>
&lt;text x="680" y="124" text-anchor="middle" font-size="11" fill="currentColor">awake model&lt;/text>
&lt;text x="680" y="140" text-anchor="middle" font-size="10" fill="currentColor" opacity="0.8">process alive&lt;/text>
&lt;rect x="584" y="160" width="192" height="20" fill="none" stroke="#888" stroke-width="1" stroke-dasharray="3 3"/>
&lt;text x="680" y="174" text-anchor="middle" font-size="9.5" fill="currentColor" opacity="0.7">KV cache (discarded on sleep)&lt;/text>
&lt;text x="680" y="206" text-anchor="middle" font-size="10.5" fill="currentColor">the rest asleep in host RAM&lt;/text>
&lt;rect x="600" y="222" width="160" height="22" fill="#22c55e" fill-opacity="0.14" stroke="#22c55e" stroke-width="1"/>
&lt;text x="680" y="237" text-anchor="middle" font-size="10" fill="#22c55e">wake: RAM→VRAM (hundreds of ms–s)&lt;/text>
&lt;text x="410" y="290" text-anchor="middle" font-size="12.5" font-weight="700" fill="currentColor">The axis is where the weights of the idle models sit&lt;/text>
&lt;text x="410" y="312" text-anchor="middle" font-size="11" fill="currentColor">co-residency: in HBM (cost space) · swap: on disk (cost a trip) · sleep: in RAM (cost RAM)&lt;/text>
&lt;text x="410" y="332" text-anchor="middle" font-size="11" fill="currentColor" opacity="0.8">and that decides the latency of having them available again&lt;/text>
&lt;/svg>
&lt;/div>
&lt;h2 id="co-residency-the-vram-budget">Co-residency: the VRAM budget&lt;/h2>
&lt;p>Co-residing is the default option when they fit: zero switching latency because every model is already in HBM. The whole question is do they fit?, and it is answered with a VRAM budget. The VRAM of an inference engine splits, roughly, into three items:&lt;/p>
$$\text{VRAM}_{\text{model}} = \underbrace{P \cdot b}_{\text{weights}} + \underbrace{\text{KV}}_{\text{cache}} + \underbrace{A}_{\text{activations + overhead}}$$
&lt;p>where $P$ is the number of parameters, $b$ the bytes per parameter given the quantisation, KV the KV-cache budget (which scales with concurrency and context length) and $A$ the overhead of activations, CUDA buffers and fragmentation. The hard rule of co-residency is:&lt;/p>
$$\sum_i \text{VRAM}_{\text{model } i} + \text{headroom} \;&lt;\; \text{VRAM}_{\text{physical}}$$
&lt;p>If the sum overflows, no sharing scheme saves you: the first engine to ask for memory beyond the free gap dies with OOM. And here is the critical nuance that connects to the sibling piece: &lt;strong>splitting the GPU by time does not split the memory&lt;/strong>. Under time-slicing, two processes take turns on compute but share the whole HBM with no isolation; if both assume they own the 80 GB, they collide. Only MIG gives memory partitions with a real boundary, as we see in &lt;a href="https://blog.lo0.es/en/posts/sharing-one-gpu-time-slicing-mps-mig/">Sharing a GPU&lt;/a>. To co-reside properly, each engine must cap itself: in vLLM, &lt;code>--gpu-memory-utilization 0.45&lt;/code> tells it &amp;ldquo;do not use more than 45 % of the GPU&amp;rdquo;; in &lt;code>llama-server&lt;/code>, you control the layers on GPU and the KV size. If you do not set those limits, vLLM claims 90 % of the GPU for itself by default, and there is no room left for anyone else.&lt;/p>
&lt;h3 id="a-worked-example-what-fits-in-80-gb">A worked example: what fits in 80 GB&lt;/h3>
&lt;p>Take the realistic scenario. A 32B agent LLM in FP8 ($b = 1$ byte/param):&lt;/p>
$$P \cdot b = 32 \times 10^9 \cdot 1 \text{ B} = 32 \text{ GB of weights}$$
&lt;p>On top of that comes the KV cache. To serve with decent concurrency and agent contexts (which are long: history, tools, documents), a KV budget of ~12 GB is reasonable, plus some ~2 GB of activations and overhead. Total for the main LLM: ~46 GB. That leaves ~34 GB of the 80. Let us see what fits there:&lt;/p>
&lt;table>
&lt;thead>
&lt;tr>
&lt;th>Service&lt;/th>
&lt;th>Size (FP8/INT8)&lt;/th>
&lt;th>KV + overhead&lt;/th>
&lt;th>Total VRAM&lt;/th>
&lt;/tr>
&lt;/thead>
&lt;tbody>
&lt;tr>
&lt;td>32B agent LLM&lt;/td>
&lt;td>32 GB&lt;/td>
&lt;td>~14 GB&lt;/td>
&lt;td>&lt;strong>~46 GB&lt;/strong>&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>Reranker A (cross-encoder ~0.5B)&lt;/td>
&lt;td>~0.5 GB&lt;/td>
&lt;td>~0.5 GB&lt;/td>
&lt;td>&lt;strong>~1 GB&lt;/strong>&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>Reranker B (cross-encoder ~0.5B)&lt;/td>
&lt;td>~0.5 GB&lt;/td>
&lt;td>~0.5 GB&lt;/td>
&lt;td>&lt;strong>~1 GB&lt;/strong>&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>8B alternate model&lt;/td>
&lt;td>~8 GB&lt;/td>
&lt;td>~4 GB&lt;/td>
&lt;td>&lt;strong>~12 GB&lt;/strong>&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>&lt;strong>Sum&lt;/strong>&lt;/td>
&lt;td>&lt;/td>
&lt;td>&lt;/td>
&lt;td>&lt;strong>~60 GB&lt;/strong>&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>Free headroom&lt;/td>
&lt;td>&lt;/td>
&lt;td>&lt;/td>
&lt;td>&lt;strong>~20 GB&lt;/strong>&lt;/td>
&lt;/tr>
&lt;/tbody>
&lt;/table>
&lt;p>With this they co-reside comfortably: the 32B LLM, the two rerankers (which are featherweights, a few hundred MB to 1 GB each) and even an 8B alternate, leaving 20 GB of cushion. Rerankers are the textbook case for co-residency: so small that they always fit alongside the main LLM, and rotating them would be absurd. Switching between them is instantaneous because they are all loaded.&lt;/p>
&lt;p>When does it break? When the occasional large model arrives, a 70B in FP8:&lt;/p>
$$70 \times 10^9 \cdot 1 \text{ B} = 70 \text{ GB of weights}$$
&lt;p>The 70B&amp;rsquo;s weights alone already eat 70 of the 80 GB. There is no way to co-reside it with the 32B, not remotely. That is where co-residency ends and rotation begins: the 70B can only come in if we evict the 32B from the GPU. That is the exact boundary: co-reside while the sum of budgets fits with headroom; rotate as soon as one model needs, on its own, more VRAM than is left over.&lt;/p>
&lt;h2 id="model-swapping-with-llama-swap-the-actor-goes-home">Model-swapping with llama-swap: the actor goes home&lt;/h2>
&lt;p>When they do not fit at once, the first answer is to rotate: keep one resident model and change it on demand. The canonical tool for this in the on-premise world is &lt;a href="https://github.com/mostlygeek/llama-swap">llama-swap&lt;/a>, a Go proxy that sits in front of your inference servers (llama.cpp, vLLM, TabbyAPI and so on) and starts and stops them as needed.&lt;/p>
&lt;p>The mechanism is elegant in its simplicity. Every OpenAI-compatible request carries a &lt;code>model&lt;/code> field. llama-swap reads that field, looks at which upstream server it has configured for that model, and:&lt;/p>
&lt;ol>
&lt;li>If the requested model is already loaded, it routes the request straight through.&lt;/li>
&lt;li>If another one is loaded, it stops it (freeing its VRAM) and starts the right one.&lt;/li>
&lt;li>When the new server reports ready, it forwards the request.&lt;/li>
&lt;/ol>
&lt;p>The cost of a swap is exactly a full cold start of the new model: unloading the current one (fast, just freeing memory) plus loading the new one (slow, moving the weights from disk to HBM). That second term is the painful one, and its magnitude is decided by where the weights are: from local NVMe it is seconds; from network storage (Ceph RGW, NFS) it can be minutes. The whole analysis of the load path, and why the default loader makes it slow, is in &lt;a href="https://blog.lo0.es/en/posts/pantry-lift-from-disk-to-hbm-why-the-kitchen-opens-late/">From disk to HBM&lt;/a>; here the consequence is enough: &lt;strong>a swap is only viable if it is infrequent&lt;/strong>, because every switch pays that toll in full.&lt;/p>
&lt;p>The advantage in exchange: since there is only one resident, that model can use almost the whole GPU. The 70B that co-resides with nobody fits comfortably if it is the only one inside. And llama-swap mixes engines: you can have a vLLM configured for the big LLM, a &lt;code>llama-server&lt;/code> with GGUF for the alternate, and two &lt;code>llama-server&lt;/code> instances for the rerankers, which expose &lt;code>/rerank&lt;/code>, &lt;code>/v1/rerank&lt;/code> and &lt;code>/v1/reranking&lt;/code> natively. That heterogeneity (GGUF + rerank + vLLM behind a single OpenAI endpoint) is exactly what vLLM alone does not give you, and the main reason to choose llama-swap.&lt;/p>
&lt;h3 id="example-configmap-two-rerankers-rotating-on-the-same-port">Example ConfigMap: two rerankers rotating on the same port&lt;/h3>
&lt;p>A concrete and useful case: you have two rerankers, one multilingual and one specialised in code, that you do not need at the same time and would rather not keep both resident. With llama-swap they rotate on the same endpoint, triggered by the &lt;code>model&lt;/code> field. The &lt;code>ConfigMap&lt;/code> (mounted as the proxy&amp;rsquo;s &lt;code>config.yaml&lt;/code> in a Kubernetes deployment) would be:&lt;/p>
&lt;div class="highlight">&lt;pre tabindex="0" class="chroma">&lt;code class="language-yaml" data-lang="yaml">&lt;span class="line">&lt;span class="cl">&lt;span class="nt">apiVersion&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">v1&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w">&lt;/span>&lt;span class="nt">kind&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">ConfigMap&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w">&lt;/span>&lt;span class="nt">metadata&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">name&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">llama-swap-rerankers&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w">&lt;/span>&lt;span class="nt">data&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">config.yaml&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="p">|&lt;/span>&lt;span class="sd">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="sd"> # How long a model stays loaded after the last request
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="sd"> # before llama-swap unloads it to free VRAM
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="sd"> healthCheckTimeout: 60
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="sd">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="sd"> models:
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="sd"> # Multilingual reranker (GGUF, via llama-server)
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="sd"> &amp;#34;reranker-multilang&amp;#34;:
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="sd"> cmd: &amp;gt;
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="sd"> /usr/bin/llama-server
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="sd"> --model /models/bge-reranker-v2-m3.Q8_0.gguf
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="sd"> --reranking
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="sd"> --port ${PORT}
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="sd"> --n-gpu-layers 99
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="sd"> --ctx-size 8192
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="sd"> # ttl: after 300 s idle, it is unloaded and the GPU is freed
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="sd"> ttl: 300
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="sd">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="sd"> # Code reranker (GGUF, via llama-server)
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="sd"> &amp;#34;reranker-code&amp;#34;:
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="sd"> cmd: &amp;gt;
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="sd"> /usr/bin/llama-server
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="sd"> --model /models/codereranker.Q8_0.gguf
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="sd"> --reranking
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="sd"> --port ${PORT}
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="sd"> --n-gpu-layers 99
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="sd"> --ctx-size 8192
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="sd"> ttl: 300&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>A request to &lt;code>/v1/rerank&lt;/code> with &lt;code>&amp;quot;model&amp;quot;: &amp;quot;reranker-multilang&amp;quot;&lt;/code> starts that server; the next one with &lt;code>&amp;quot;model&amp;quot;: &amp;quot;reranker-code&amp;quot;&lt;/code> stops the multilingual one and starts the code one on the same &lt;code>${PORT}&lt;/code> that llama-swap manages. Since both are small, the swap between them takes one or two seconds: quantised GGUFs weigh a few hundred MB and come from local NVMe. The &lt;code>ttl&lt;/code> controls how long a model stays loaded after its last request: raising it avoids swaps if requests arrive in bursts; lowering it frees the GPU sooner for other uses.&lt;/p>
&lt;blockquote>
&lt;p>&lt;strong>An honest caveat:&lt;/strong> this pattern of &lt;em>two rerankers that rotate&lt;/em> makes sense when they do not fit alongside the rest, or when you want to reserve the VRAM for something else. If they do fit (and a couple of 0.5 GB rerankers almost always fit, as we saw above), co-residing them is strictly better: zero swap latency. llama-swap shines when you rotate large models or mix engines that do not coexist well, not for juggling tiny models that would fit together.&lt;/p>
&lt;/blockquote>
&lt;h2 id="vllm-sleep-mode-the-actor-in-the-wings">vLLM sleep mode: the actor in the wings&lt;/h2>
&lt;p>Swapping has a problem: every switch pays the entire cold start, and a cold start is not just moving weights. As we saw in &lt;a href="https://blog.lo0.es/en/posts/pantry-lift-from-disk-to-hbm-why-the-kitchen-opens-late/">From disk to HBM&lt;/a>, starting an inference engine includes initialising the Python process and the CUDA context, setting up the memory allocator, capturing the CUDA graphs and compiling the JIT kernels (DeepGEMM, FlashInfer, TorchInductor). Moving the weights is only one of five items, and often not the largest.&lt;/p>
&lt;p>vLLM&amp;rsquo;s sleep mode (&lt;code>--enable-sleep-mode&lt;/code>) attacks exactly that: instead of killing the process when a model stops being used, it puts it to sleep, leaving the process alive. There are two levels, and the difference is where the weights go:&lt;/p>
&lt;ul>
&lt;li>&lt;strong>Level 1:&lt;/strong> offloads the weights to host RAM (CPU) and discards the KV cache. The process stays alive. The wake copies the weights from RAM to VRAM, not from disk. Typical wake: ~0.1–0.8 s for small models, ~3–6 s for large ones. It needs enough RAM for the sleeping weights (on the order of the model&amp;rsquo;s size in GB).&lt;/li>
&lt;li>&lt;strong>Level 2:&lt;/strong> discards the weights entirely, keeping only small buffers (rope scaling tensors and the like). The wake does reload the weights from disk, but &lt;strong>everything else, process, allocator, CUDA graphs, JIT kernels, is already done&lt;/strong>. Typical wake: ~0.8–2.6 s for small models. RAM use is almost nil (megabytes).&lt;/li>
&lt;/ul>
&lt;p>The key that explains the numbers: at both levels, keeping the process alive preserves the expensive infrastructure. That is why the benchmark in the &lt;a href="https://blog.vllm.ai/2025/10/26/sleep-mode.html">vLLM blog (Oct 2025)&lt;/a> reports that a wake is 18–200× faster than a full reload, and, most counter-intuitively, &lt;strong>level 2 is still 23–45× faster&lt;/strong> than a cold start despite reloading the weights from the same disk, because it skips the other four items. In their measurements on an A100, a full cycle of 5 model switches goes from 357 s without sleep (≈48 s per switch) to 112 s with level 1 (wake of 0.26 s / 0.82 s) or 125 s with level 2 (0.85 s / 2.58 s).&lt;/p>
&lt;p>The &lt;strong>KV cache is always discarded on sleep.&lt;/strong> That is not a minor detail: it means the first response after the wake rebuilds the KV from scratch, paying a full prefill. That is why the wake is not &amp;ldquo;entirely free&amp;rdquo;: the model is available in sub-second time, but the first request is a little slower until the KV is repopulated. A one-request warm-up after the wake hides that cost.&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">&lt;span class="c1"># Start vLLM with sleep mode (admin endpoints, trusted networks only)&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="nb">export&lt;/span> &lt;span class="nv">VLLM_SERVER_DEV_MODE&lt;/span>&lt;span class="o">=&lt;/span>&lt;span class="m">1&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">vllm serve &amp;lt;model&amp;gt; --enable-sleep-mode --port &lt;span class="m">8001&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="c1"># Sleep (level 1: weights to host RAM)&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">curl -X POST &lt;span class="s1">&amp;#39;localhost:8001/sleep?level=1&amp;#39;&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="c1"># Wake up&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">curl -X POST &lt;span class="s1">&amp;#39;localhost:8001/wake_up&amp;#39;&lt;/span>
&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;blockquote>
&lt;p>&lt;strong>Security warning&lt;/strong> (from vLLM&amp;rsquo;s own blog): the &lt;code>/sleep&lt;/code>, &lt;code>/wake_up&lt;/code>, &lt;code>/collective_rpc&lt;/code> and &lt;code>/reset_prefix_cache&lt;/code> endpoints require &lt;code>VLLM_SERVER_DEV_MODE=1&lt;/code> and should only be exposed on trusted networks, since they can take the service down. They are for internal orchestration (a controller that sleeps and wakes models according to the queue), not for the public plane.&lt;/p>
&lt;/blockquote>
&lt;h2 id="the-latency-maths-why-the-wake-wins">The latency maths: why the wake wins&lt;/h2>
&lt;p>Let us put numbers on &amp;ldquo;the wake comes from RAM, not from disk&amp;rdquo;. The cost of having a model available is, in each strategy, the time to move its weights from wherever they are to HBM, plus, in the swap case, the other four cold-start costs. Take the 34 GB of weights of the 32B LLM in FP8 and compare the three paths.&lt;/p>
&lt;p>&lt;strong>Cold start from NVMe (swap).&lt;/strong> A reasonable Gen4/Gen5 NVMe gives on the order of ~5 GB/s effective per stream with the default loader (the disk&amp;rsquo;s theoretical floor is higher, but single-threaded deserialisation does not saturate it, see &lt;a href="https://blog.lo0.es/en/posts/pantry-lift-from-disk-to-hbm-why-the-kitchen-opens-late/">From disk to HBM&lt;/a>). For 34 GB:&lt;/p>
$$t_{\text{NVMe→HBM}} \approx \frac{34 \text{ GB}}{5 \text{ GB/s}} \approx 6.8 \text{ s just moving bytes}$$
&lt;p>And that is before adding CUDA graph capture and kernel compilation, which add several more seconds. The real cold start of a 32B is around 15–40 s depending on loader and storage. From the network (Ceph RGW), multiply.&lt;/p>
&lt;p>&lt;strong>Level 1 wake from RAM.&lt;/strong> The weights come not from disk but from host RAM, and travel over PCIe Gen5 x16, whose practical host→GPU bandwidth is ~50–64 GB/s. For the same 34 GB:&lt;/p>
$$t_{\text{RAM→HBM}} \approx \frac{34 \text{ GB}}{55 \text{ GB/s}} \approx 0.62 \text{ s}$$
&lt;p>And there is nothing else to pay: the allocator, the graphs and the kernels are already there. The real wake of a model this size lands in the sub-second to a few seconds range that vLLM reports. The speed-up against an NVMe cold start is on the order of:&lt;/p>
$$\frac{t_{\text{cold start}}}{t_{\text{wake}}} \approx \frac{15\text{–}40 \text{ s}}{0.6\text{–}3 \text{ s}} \approx 10\text{–}60\times$$
&lt;p>consistent with the 18–200× from the blog, whose measurements include smaller models, where the relative weight of the preserved CUDA graphs is even larger and the factor goes up.&lt;/p>
&lt;p>&lt;strong>Why the bandwidth difference explains almost everything.&lt;/strong> The key jump is not 5 vs 55 GB/s (a ~11× in transport). It is that the cold start also pays for rebuilding infrastructure, which the wake skips entirely. The table:&lt;/p>
&lt;table>
&lt;thead>
&lt;tr>
&lt;th>Path&lt;/th>
&lt;th>Weight source&lt;/th>
&lt;th>Bandwidth&lt;/th>
&lt;th>34 GB bytes only&lt;/th>
&lt;th>+ CUDA graphs / JIT&lt;/th>
&lt;th>Realistic total&lt;/th>
&lt;/tr>
&lt;/thead>
&lt;tbody>
&lt;tr>
&lt;td>Cold start (swap) NVMe&lt;/td>
&lt;td>disk&lt;/td>
&lt;td>~5 GB/s&lt;/td>
&lt;td>~6.8 s&lt;/td>
&lt;td>yes (several s)&lt;/td>
&lt;td>&lt;strong>15–40 s&lt;/strong>&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>Cold start (swap) network&lt;/td>
&lt;td>network&lt;/td>
&lt;td>~1–2 GB/s&lt;/td>
&lt;td>17–34 s&lt;/td>
&lt;td>yes&lt;/td>
&lt;td>&lt;strong>30 s – min&lt;/strong>&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>Level 1 wake&lt;/td>
&lt;td>host RAM&lt;/td>
&lt;td>~50–64 GB/s&lt;/td>
&lt;td>~0.6 s&lt;/td>
&lt;td>&lt;strong>no&lt;/strong> (preserved)&lt;/td>
&lt;td>&lt;strong>0.6–3 s&lt;/strong>&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>Level 2 wake&lt;/td>
&lt;td>disk&lt;/td>
&lt;td>~5 GB/s&lt;/td>
&lt;td>~6.8 s&lt;/td>
&lt;td>&lt;strong>no&lt;/strong> (preserved)&lt;/td>
&lt;td>&lt;strong>7–10 s&lt;/strong>&lt;/td>
&lt;/tr>
&lt;/tbody>
&lt;/table>
&lt;p>Look at the level 2 row: it reloads the weights from the same disk as the swap (~6.8 s of bytes), but since it does not rebuild graphs or kernels, its total (~7–10 s) still beats a full cold start (15–40 s). It is the proof that &lt;strong>moving bytes is only part of the cost&lt;/strong>, and the part sleep mode exploits.&lt;/p>
&lt;blockquote>
&lt;p>&lt;strong>Assumptions, honestly:&lt;/strong> the bandwidths are indicative. NVMe &amp;ldquo;5 GB/s effective&amp;rdquo; assumes the default loader; with a concurrent streamer it goes up. PCIe &amp;ldquo;55 GB/s&amp;rdquo; assumes Gen5 x16 with a &lt;em>pinned&lt;/em> buffer and NUMA locality; if the buffer lands on the wrong socket, it drops. And the &amp;ldquo;15–40 s&amp;rdquo; cold-start range depends on the model, the quantisation and whether the files are in page cache (the &lt;em>&amp;ldquo;the second time it loaded fast&amp;rdquo;&lt;/em> trap). The numbers are there to reason about orders of magnitude, not to size anything without measuring on your hardware.&lt;/p>
&lt;/blockquote>
&lt;h2 id="the-decision-tree">The decision tree&lt;/h2>
&lt;p>The three strategies do not compete: each wins in a different regime. The tree, in order:&lt;/p>
&lt;div class="diagram" style="max-width:760px;margin:1.5rem auto;">
&lt;svg viewBox="0 0 760 420" xmlns="http://www.w3.org/2000/svg" role="img" aria-label="Decision tree: co-residency, llama-swap, sleep mode or more GPUs">
&lt;defs>&lt;marker id="ar" viewBox="0 0 10 10" refX="9" refY="5" markerWidth="7" markerHeight="7" orient="auto">&lt;path d="M0,0 L10,5 L0,10 z" fill="currentColor"/>&lt;/marker>&lt;/defs>
&lt;rect x="270" y="20" width="220" height="44" fill="currentColor" fill-opacity="0.05" stroke="#888" stroke-width="1.4"/>
&lt;text x="380" y="40" text-anchor="middle" font-size="12" font-weight="700" fill="currentColor">Do they ALL fit at once&lt;/text>
&lt;text x="380" y="56" text-anchor="middle" font-size="11" fill="currentColor">(sum of budgets &amp;lt; VRAM)?&lt;/text>
&lt;path d="M380,64 L380,90" stroke="currentColor" stroke-width="1.4" marker-end="url(#ar)"/>
&lt;text x="250" y="84" text-anchor="middle" font-size="11" font-weight="700" fill="#22c55e">yes&lt;/text>
&lt;rect x="40" y="92" width="200" height="50" fill="#22c55e" fill-opacity="0.14" stroke="#22c55e" stroke-width="1.6"/>
&lt;text x="140" y="113" text-anchor="middle" font-size="12" font-weight="700" fill="currentColor">Co-residency&lt;/text>
&lt;text x="140" y="130" text-anchor="middle" font-size="10" fill="currentColor">each engine caps its own VRAM&lt;/text>
&lt;path d="M320,86 L240,110" stroke="#22c55e" stroke-width="1.4" marker-end="url(#ar)"/>
&lt;text x="430" y="84" text-anchor="middle" font-size="11" font-weight="700" fill="#ef4444">no&lt;/text>
&lt;rect x="270" y="92" width="220" height="44" fill="currentColor" fill-opacity="0.05" stroke="#888" stroke-width="1.4"/>
&lt;text x="380" y="111" text-anchor="middle" font-size="11.5" font-weight="700" fill="currentColor">Sustained load on&lt;/text>
&lt;text x="380" y="127" text-anchor="middle" font-size="11" fill="currentColor">all of them at once?&lt;/text>
&lt;path d="M490,114 L560,114" stroke="currentColor" stroke-width="1.4" marker-end="url(#ar)"/>
&lt;text x="525" y="106" text-anchor="middle" font-size="11" font-weight="700" fill="#ef4444">yes&lt;/text>
&lt;rect x="565" y="92" width="180" height="50" fill="#ef4444" fill-opacity="0.12" stroke="#ef4444" stroke-width="1.6"/>
&lt;text x="655" y="113" text-anchor="middle" font-size="11.5" font-weight="700" fill="currentColor">More GPUs / replicas&lt;/text>
&lt;text x="655" y="130" text-anchor="middle" font-size="10" fill="currentColor">not swapping: capacity&lt;/text>
&lt;path d="M380,136 L380,170" stroke="currentColor" stroke-width="1.4" marker-end="url(#ar)"/>
&lt;text x="408" y="156" text-anchor="middle" font-size="11" font-weight="700" fill="#22c55e">no&lt;/text>
&lt;rect x="270" y="172" width="220" height="44" fill="currentColor" fill-opacity="0.05" stroke="#888" stroke-width="1.4"/>
&lt;text x="380" y="191" text-anchor="middle" font-size="11.5" font-weight="700" fill="currentColor">All vLLM and is there&lt;/text>
&lt;text x="380" y="207" text-anchor="middle" font-size="11" fill="currentColor">RAM to spare?&lt;/text>
&lt;path d="M270,194 L120,194 L120,250" stroke="currentColor" stroke-width="1.4" marker-end="url(#ar)"/>
&lt;text x="180" y="186" text-anchor="middle" font-size="11" font-weight="700" fill="#ef4444">no (mixed engines)&lt;/text>
&lt;rect x="30" y="252" width="200" height="64" fill="#f59e0b" fill-opacity="0.14" stroke="#f59e0b" stroke-width="1.6"/>
&lt;text x="130" y="275" text-anchor="middle" font-size="12" font-weight="700" fill="currentColor">llama-swap&lt;/text>
&lt;text x="130" y="292" text-anchor="middle" font-size="10" fill="currentColor">rotates by `model` field&lt;/text>
&lt;text x="130" y="307" text-anchor="middle" font-size="10" fill="currentColor">GGUF + /rerank + vLLM&lt;/text>
&lt;path d="M490,194 L640,194 L640,250" stroke="currentColor" stroke-width="1.4" marker-end="url(#ar)"/>
&lt;text x="575" y="186" text-anchor="middle" font-size="11" font-weight="700" fill="#3b82f6">yes (latency matters)&lt;/text>
&lt;rect x="540" y="252" width="200" height="64" fill="#3b82f6" fill-opacity="0.14" stroke="#3b82f6" stroke-width="1.6"/>
&lt;text x="640" y="275" text-anchor="middle" font-size="12" font-weight="700" fill="currentColor">Sleep mode&lt;/text>
&lt;text x="640" y="292" text-anchor="middle" font-size="10" fill="currentColor">level 1: weights to RAM&lt;/text>
&lt;text x="640" y="307" text-anchor="middle" font-size="10" fill="currentColor">wake 18–200× vs cold&lt;/text>
&lt;text x="380" y="356" text-anchor="middle" font-size="11.5" font-weight="700" fill="currentColor">And they combine: co-reside the light ones + rotate/sleep the big ones&lt;/text>
&lt;text x="380" y="378" text-anchor="middle" font-size="10.5" fill="currentColor" opacity="0.85">rerankers always co-reside · the occasional 70B sleeps or comes in by swap&lt;/text>
&lt;text x="380" y="398" text-anchor="middle" font-size="10.5" fill="currentColor" opacity="0.85">if everything runs flat out at once, no strategy saves you: you need more silicon&lt;/text>
&lt;/svg>
&lt;/div>
&lt;p>The node most often ignored is the one at the top right: &amp;ldquo;sustained load on all of them at once?&amp;rdquo;. If your four models get constant and simultaneous traffic, neither swapping nor sleep helps, since both assume the models take turns in time. Rotating under sustained load only adds switching latency without solving the underlying problem: &lt;strong>there is not enough compute&lt;/strong>. The answer then is to scale horizontally (more replicas) or spread across more GPUs, a capacity decision analysed in &lt;a href="https://blog.lo0.es/en/posts/one-big-replica-or-many-small-ones-platform-decision/">One big vs N small&lt;/a>. Swapping and sleep are tools for temporally unbalanced workloads: many models, but rarely active at the same time.&lt;/p>
&lt;h2 id="applied-to-the-generic-4h100-cluster">Applied to the generic 4×H100 cluster&lt;/h2>
&lt;p>Let us bring this down to the 4 H100 SXM 80 GB with NVLink. The winning strategy is not to pick one of the three, but to &lt;strong>spread the models across GPUs according to their usage pattern&lt;/strong> and apply to each GPU the strategy it calls for. A reasonable split:&lt;/p>
&lt;p>&lt;strong>H100 #0, the workhorse (co-residency).&lt;/strong> The 32B agent LLM (main service, constant traffic) co-resides with the two rerankers and, if they fit, the 8B alternate. It is the GPU that never rotates: everything living here is used continuously and fits comfortably in 80 GB (the ~60 GB of the example above). Zero switching latency between the LLM and its rerankers, which is exactly what RAG needs, since a fast reranker is useless if you have to wait for a swap every time.&lt;/p>
&lt;p>&lt;strong>H100 #1, light services with MIG.&lt;/strong> If you have many small heterogeneous services, an embedding model, a classifier, a guardrail, an STT/TTS, splitting this GPU with MIG into isolated instances (each with its slice of HBM with a real boundary) gives co-residency with memory isolation, preventing a service that inflates its KV from taking the others down. The detail of when MIG beats time-slicing is in &lt;a href="https://blog.lo0.es/en/posts/sharing-one-gpu-time-slicing-mps-mig/">Sharing a GPU&lt;/a>; the rule here: co-residing light services on one GPU makes sense when they fit and when isolating them is worthwhile, and MIG is the tool for the second part.&lt;/p>
&lt;p>&lt;strong>H100 #2, the occasional large model (sleep mode or swap).&lt;/strong> The 70B that is only invoked for hard tasks does not deserve a dedicated GPU kept awake, since it would sit idle most of the time, burning 700 W for nothing. Two options:&lt;/p>
&lt;ul>
&lt;li>If this GPU also serves a medium model routinely and you only occasionally need the 70B, use sleep mode: put the medium one to sleep (level 1, weights to RAM), wake the 70B. Note that level 1 sleep frees the VRAM, the weights go to RAM, so yes, it fits even though the 70B would not fit alongside an awake medium model. Waking the medium one again is sub-second.&lt;/li>
&lt;li>If the 70B comes as GGUF or you mix engines, llama-swap rotates between the medium model and the 70B by the &lt;code>model&lt;/code> field. Each 70B invocation pays its cold start (seconds from local NVMe), acceptable if it is occasional.&lt;/li>
&lt;/ul>
&lt;p>&lt;strong>H100 #3, replica / overflow.&lt;/strong> The fourth GPU absorbs peaks: a replica of the main LLM for when #0&amp;rsquo;s queue grows, or reserve capacity. There is no swapping here: it is pure capacity, the answer to the tree&amp;rsquo;s &amp;ldquo;sustained load&amp;rdquo; node.&lt;/p>
&lt;p>The cross-cutting principle: &lt;strong>co-reside what is used together and continuously (LLM + rerankers); isolate the light and heterogeneous with MIG; sleep or rotate the large and occasional; replicate what saturates.&lt;/strong> The four GPUs do not all do the same thing, each runs the strategy its load pattern calls for. And the NVLink between them matters for something else (tensor parallelism for the 70B if it would not fit even on one; see &lt;a href="https://blog.lo0.es/en/posts/one-big-replica-or-many-small-ones-platform-decision/">One big vs N small&lt;/a>), but for this post&amp;rsquo;s problem, many models, one GPU, the lever is &lt;strong>when each model needs to be awake&lt;/strong>.&lt;/p>
&lt;h2 id="traps-and-things-that-are-not-what-they-seem">Traps and things that are not what they seem&lt;/h2>
&lt;p>&lt;strong>&amp;ldquo;Co-residing is always better if they fit.&amp;rdquo;&lt;/strong> Almost, but watch the KV cache: co-residing two models means splitting the KV budget between them. If the main LLM needs a large KV for high concurrency and long contexts, adding a flatmate cuts that KV and lowers its throughput. Sometimes it is better to give the whole GPU to the main model and rotate the secondary one. Co-residency is not free: the tenant takes room away from the cache of the one that matters.&lt;/p>
&lt;p>&lt;strong>&amp;ldquo;Sleep mode is like swapping but faster.&amp;rdquo;&lt;/strong> Not exactly. Swapping releases the process; you can have N models configured and only pay RAM/disk for the resident one. Sleep mode keeps one live process per sleeping model, so each sleeping vLLM still occupies its process slot, its RAM (level 1) and its management footprint. Sleep scales well to a few rotating models; for many (10+), level 2 (minimal RAM) or plain swapping fit better. Do not put 15 models into level 1 sleep and expect the RAM to hold.&lt;/p>
&lt;p>&lt;strong>&amp;ldquo;The wake is instantaneous, I lose nothing.&amp;rdquo;&lt;/strong> The model&amp;rsquo;s wake is sub-second, but the KV cache was discarded on sleep. The first request after the wake pays a full prefill to repopulate the KV, slower than normal. If your SLA is strict on the first response after an idle period, add an automatic warm-up after the wake. &lt;a href="https://blog.lo0.es/en/posts/prefix-cache-hit-rate-engineering/">Prefix caching&lt;/a> helps make that reprefill cheaper if there are stable prefixes.&lt;/p>
&lt;p>&lt;strong>&amp;ldquo;llama-swap with a low &lt;code>ttl&lt;/code> saves me VRAM for free.&amp;rdquo;&lt;/strong> It saves you VRAM while nobody uses that model, but every time it comes back it pays the cold start. An aggressive &lt;code>ttl&lt;/code> on a model with bursty traffic turns every burst into a load wait. The right &lt;code>ttl&lt;/code> depends on the temporal pattern of the requests, not on how much VRAM you want to free. Measure it.&lt;/p>
&lt;p>&lt;strong>&amp;ldquo;Time-slicing lets me co-reside more models.&amp;rdquo;&lt;/strong> False and dangerous. Time-slicing shares out compute time, not memory, and all the processes still compete for the same HBM with no isolation. Co-residing via time-slicing does not give you more effective VRAM; it gives you more processes fighting over the same VRAM, and an OOM when the sum overflows. For real memory partitioning, MIG. The detail is in the &lt;a href="https://blog.lo0.es/en/posts/sharing-one-gpu-time-slicing-mps-mig/">sibling piece&lt;/a>.&lt;/p>
&lt;h2 id="conclusion">Conclusion&lt;/h2>
&lt;p>Having more models than VRAM is not a problem of insufficient hardware: it is a problem of &lt;strong>managing a scarce resource over time&lt;/strong>. The intuition of &amp;ldquo;I need one GPU per model&amp;rdquo; is expensive and almost always wrong, because models are rarely used all at once. The three strategies are three answers to the same question, &lt;strong>where do the weights of the models you are not currently using live&lt;/strong>: in HBM if they fit (co-residency, zero latency but they cost space), on disk if switching is rare (swapping, maximum VRAM per model but a return trip of seconds to minutes), or in RAM if switching latency matters (sleep mode, sub-second wake at the cost of tied-up RAM). Sleep mode is the most interesting addition of 2025 because it breaks the false dilemma of &amp;ldquo;everything loaded vs reload every time&amp;rdquo;: by keeping the process alive and preserving the allocator, the CUDA graphs and the kernels, it turns a 30–100 s cold start into a sub-second wake, and it does so even when it reloads the weights from the same disk (level 2), because moving bytes was never the whole cost. On the four-H100 cluster, the play is not to pick one strategy but to spread: co-reside what goes together, isolate the light stuff with MIG, sleep or rotate the large and occasional, replicate what saturates. The GPU is the stage with a single spotlight; the art is knowing which actor walks on, which goes home and which waits in the wings.&lt;/p>
&lt;h2 id="see-also">See also&lt;/h2>
&lt;ul>
&lt;li>
&lt;p>&lt;a href="https://blog.lo0.es/en/posts/speeding-up-model-cold-start-minutes-to-seconds/">Speeding up model cold start: from minutes to seconds&lt;/a> — how to lower the cold-start cost that every swap pays.&lt;/p>
&lt;/li>
&lt;li>
&lt;p>&lt;a href="https://blog.lo0.es/en/posts/sharing-one-gpu-time-slicing-mps-mig/">Sharing a GPU: time-slicing, MPS and MIG&lt;/a> — the sibling piece: how to split one GPU among several processes in space (not in time). Key here to understand why time-slicing does not protect memory and why MIG is what gives co-residency with real HBM isolation.&lt;/p>
&lt;/li>
&lt;li>
&lt;p>&lt;a href="https://blog.lo0.es/en/posts/pantry-lift-from-disk-to-hbm-why-the-kitchen-opens-late/">From disk to HBM: cold start and model loading&lt;/a> — the load path that swapping pays in full on every switch and that sleep mode sidesteps; why moving bytes is only one of five start-up items.&lt;/p>
&lt;/li>
&lt;li>
&lt;p>&lt;a href="https://blog.lo0.es/en/posts/multi-lora-serving-shared-base-concurrent-adapters-sgmv-kernel/">Multi-LoRA serving: fundamentals&lt;/a> — the alternative when the &amp;ldquo;several models&amp;rdquo; are adapters of the same base: instead of rotating whole models, you serve many LoRAs on a co-resident base with no swap cost.&lt;/p>
&lt;/li>
&lt;li>
&lt;p>&lt;a href="https://blog.lo0.es/en/posts/prefix-cache-hit-rate-engineering/">Engineering the prefix cache hit rate&lt;/a> — the KV cache is discarded on sleep; a good hit rate on stable prefixes makes the reprefill of the first request after the wake cheaper.&lt;/p>
&lt;/li>
&lt;li>
&lt;p>&lt;a href="https://blog.lo0.es/en/posts/one-big-replica-or-many-small-ones-platform-decision/">One big vs N small: tensor parallelism and replicas&lt;/a> — the &amp;ldquo;sustained load on all of them at once&amp;rdquo; branch of the tree: when rotating is not enough and you have to spread across more GPUs or replicas.&lt;/p>
&lt;/li>
&lt;li>
&lt;p>&lt;a href="https://blog.lo0.es/en/posts/mixed-nvidia-intel-environments-llm-inference/">Mixed NVIDIA / Intel environments: servers and NUCs&lt;/a> — where to place the light services (rerankers, embeddings) that do not need an H100: sometimes co-residing is not on the big GPU but on more modest hardware.&lt;/p>
&lt;/li>
&lt;/ul>
&lt;h2 id="references">References&lt;/h2>
&lt;ul>
&lt;li>vLLM Blog (Embedded LLM), &lt;em>Zero-Reload Model Switching with vLLM Sleep Mode&lt;/em>, 26 oct 2025: &lt;a href="https://blog.vllm.ai/2025/10/26/sleep-mode.html">https://blog.vllm.ai/2025/10/26/sleep-mode.html&lt;/a>&lt;/li>
&lt;li>vLLM Docs, &lt;em>Sleep Mode&lt;/em>: &lt;a href="https://docs.vllm.ai/en/latest/features/sleep_mode/">https://docs.vllm.ai/en/latest/features/sleep_mode/&lt;/a>&lt;/li>
&lt;li>mostlygeek, &lt;em>llama-swap&lt;/em> (OpenAI/Anthropic-compatible model-swapping proxy): &lt;a href="https://github.com/mostlygeek/llama-swap">https://github.com/mostlygeek/llama-swap&lt;/a>&lt;/li>
&lt;li>llama-swap, &lt;em>Configuration&lt;/em>: &lt;a href="https://github.com/mostlygeek/llama-swap/blob/main/docs/configuration.md">https://github.com/mostlygeek/llama-swap/blob/main/docs/configuration.md&lt;/a>&lt;/li>
&lt;li>NVIDIA, &lt;em>H100 Tensor Core GPU&lt;/em> (HBM3 specs, 80 GB, ~3.35 TB/s): &lt;a href="https://www.nvidia.com/en-us/data-center/h100/">https://www.nvidia.com/en-us/data-center/h100/&lt;/a>&lt;/li>
&lt;li>NVIDIA, &lt;em>Reducing Cold Start Latency for LLM Inference with NVIDIA Run:ai Model Streamer&lt;/em>: &lt;a href="https://developer.nvidia.com/blog/reducing-cold-start-latency-for-llm-inference-with-nvidia-runai-model-streamer/">https://developer.nvidia.com/blog/reducing-cold-start-latency-for-llm-inference-with-nvidia-runai-model-streamer/&lt;/a>&lt;/li>
&lt;/ul></description></item><item><title>QLoRA runbook: from dataset to adapter served in multi-LoRA (operational procedure)</title><link>https://blog.lo0.es/en/posts/qlora-runbook-dataset-to-served-adapter/</link><pubDate>Tue, 09 Jun 2026 03:00:00 +0000</pubDate><guid>https://blog.lo0.es/en/posts/qlora-runbook-dataset-to-served-adapter/</guid><description>&lt;blockquote>
&lt;p>This is the &lt;strong>operational companion&lt;/strong> to &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>. That post takes apart the &lt;em>why&lt;/em>, NF4, double quantisation, paged optimizers, the adapter maths; this is the &lt;em>how&lt;/em>, with commands you copy and paste. If you have not read the fundamentals one, read it first: here we assume you know what an adapter is, why the base lives in 4-bit and why the gradient only touches the adapter.&lt;/p>
&lt;/blockquote>
&lt;h2 id="tldr">TL;DR&lt;/h2>
&lt;p>A reproducible procedure in five phases: &lt;strong>(1)&lt;/strong> pin the environment with fixed versions; &lt;strong>(2)&lt;/strong> prepare the dataset in chat format; &lt;strong>(3)&lt;/strong> train the QLoRA adapter with TRL + PEFT on an &lt;strong>RTX 4090 (24 GB, Ada Lovelace)&lt;/strong> using gradient checkpointing, gradient accumulation and &lt;code>paged_adamw_8bit&lt;/code>; &lt;strong>(4)&lt;/strong> validate and version the adapter as an artefact of &lt;strong>megabytes&lt;/strong>; &lt;strong>(5)&lt;/strong> serve it in &lt;strong>vLLM&lt;/strong> with &lt;code>--enable-lora&lt;/code>, loading it hot without restarting the server and resolving it from object storage. All on-premise, on consumer hardware, without a single data item leaving the perimeter. What follows are the exact commands and the memory budget that separates &amp;ldquo;it fits&amp;rdquo; from &amp;ldquo;OOM&amp;rdquo;.&lt;/p>
&lt;h2 id="the-end-to-end-flow">The end-to-end flow&lt;/h2>
&lt;div class="diagram" style="max-width:780px;margin:1.5rem auto;">
&lt;svg viewBox="0 0 780 200" xmlns="http://www.w3.org/2000/svg" role="img" aria-label="QLoRA operational pipeline: dataset, training, adapter artefact, registry, vLLM serving">
&lt;defs>&lt;marker id="rm" viewBox="0 0 10 10" refX="9" refY="5" markerWidth="7" markerHeight="7" orient="auto">&lt;path d="M0,0 L10,5 L0,10 z" fill="#666"/>&lt;/marker>&lt;/defs>
&lt;rect x="14" y="74" width="118" height="52" rx="7" fill="#eef2f6" stroke="currentColor" stroke-width="1.4"/>
&lt;text x="73" y="98" text-anchor="middle" font-family="sans-serif" font-size="12" font-weight="600" fill="currentColor">1 · Dataset&lt;/text>
&lt;text x="73" y="114" text-anchor="middle" font-family="sans-serif" font-size="10" fill="#555">JSONL chat&lt;/text>
&lt;rect x="172" y="74" width="118" height="52" rx="7" fill="#fff4d6" stroke="#a48000" stroke-width="1.6"/>
&lt;text x="231" y="94" text-anchor="middle" font-family="sans-serif" font-size="12" font-weight="600" fill="#7a5e00">2 · Train&lt;/text>
&lt;text x="231" y="110" text-anchor="middle" font-family="sans-serif" font-size="10" fill="#7a5e00">TRL+PEFT · 4090&lt;/text>
&lt;text x="231" y="122" text-anchor="middle" font-family="sans-serif" font-size="10" fill="#7a5e00">NF4 · paged_adamw&lt;/text>
&lt;rect x="330" y="74" width="118" height="52" rx="7" fill="#fffbe9" stroke="#a48000" stroke-width="1.4"/>
&lt;text x="389" y="94" text-anchor="middle" font-family="sans-serif" font-size="12" font-weight="600" fill="#7a5e00">3 · Adapter&lt;/text>
&lt;text x="389" y="110" text-anchor="middle" font-family="sans-serif" font-size="10" fill="#7a5e00">~17 MB&lt;/text>
&lt;text x="389" y="122" text-anchor="middle" font-family="sans-serif" font-size="10" fill="#7a5e00">safetensors&lt;/text>
&lt;rect x="488" y="74" width="118" height="52" rx="7" fill="#e6d9f2" stroke="#5a2db0" stroke-width="1.4"/>
&lt;text x="547" y="94" text-anchor="middle" font-family="sans-serif" font-size="12" font-weight="600" fill="#42208a">4 · Registry&lt;/text>
&lt;text x="547" y="110" text-anchor="middle" font-family="sans-serif" font-size="10" fill="#42208a">MinIO / S3&lt;/text>
&lt;text x="547" y="122" text-anchor="middle" font-family="sans-serif" font-size="10" fill="#42208a">versioned + sha256&lt;/text>
&lt;rect x="646" y="74" width="118" height="52" rx="7" fill="#d4ecff" stroke="#1f5fa8" stroke-width="1.6"/>
&lt;text x="705" y="94" text-anchor="middle" font-family="sans-serif" font-size="12" font-weight="600" fill="#0d3a66">5 · Serve&lt;/text>
&lt;text x="705" y="110" text-anchor="middle" font-family="sans-serif" font-size="10" fill="#0d3a66">vLLM --enable-lora&lt;/text>
&lt;text x="705" y="122" text-anchor="middle" font-family="sans-serif" font-size="10" fill="#0d3a66">hot loading&lt;/text>
&lt;path d="M132,100 L170,100" stroke="#666" stroke-width="1.5" fill="none" marker-end="url(#rm)"/>
&lt;path d="M290,100 L328,100" stroke="#666" stroke-width="1.5" fill="none" marker-end="url(#rm)"/>
&lt;path d="M448,100 L486,100" stroke="#666" stroke-width="1.5" fill="none" marker-end="url(#rm)"/>
&lt;path d="M606,100 L644,100" stroke="#666" stroke-width="1.5" fill="none" marker-end="url(#rm)"/>
&lt;text x="389" y="36" text-anchor="middle" font-family="sans-serif" font-size="12" font-weight="600" fill="currentColor">Producer (4090) ───────────────▶ Consumer (4090 or cluster)&lt;/text>
&lt;path d="M231,150 L231,168 L547,168 L547,150" stroke="#999" stroke-width="1.2" fill="none" stroke-dasharray="4 3"/>
&lt;text x="389" y="185" text-anchor="middle" font-family="sans-serif" font-size="10" fill="#555">the same machine can be producer and consumer&lt;/text>
&lt;/svg>
&lt;/div>
&lt;h2 id="phase-0--environment-and-versions">Phase 0 — Environment and versions&lt;/h2>
&lt;p>QLoRA is sensitive to the versions of &lt;code>bitsandbytes&lt;/code>, &lt;code>transformers&lt;/code>, &lt;code>peft&lt;/code> and &lt;code>trl&lt;/code>: misaligned combinations give dequant errors or adapters that do not load in vLLM. Pin the environment and do not touch it mid-campaign. Reference versions as of June 2026 (check the specific ones in your index; the exact pin matters less than the coherence between them):&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 venv .venv &lt;span class="o">&amp;amp;&amp;amp;&lt;/span> &lt;span class="nb">source&lt;/span> .venv/bin/activate
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">pip install --upgrade pip
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="c1"># Training (producer)&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">pip install &lt;span class="s2">&amp;#34;torch&amp;gt;=2.4&amp;#34;&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> &lt;span class="s2">&amp;#34;transformers&amp;gt;=4.50&amp;#34;&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> &lt;span class="s2">&amp;#34;peft&amp;gt;=0.14&amp;#34;&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> &lt;span class="s2">&amp;#34;trl&amp;gt;=0.15&amp;#34;&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> &lt;span class="s2">&amp;#34;bitsandbytes&amp;gt;=0.45&amp;#34;&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> &lt;span class="s2">&amp;#34;accelerate&amp;gt;=1.2&amp;#34;&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> &lt;span class="s2">&amp;#34;datasets&amp;gt;=3.2&amp;#34;&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="c1"># Serving (consumer) — in its own environment/image&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">pip install &lt;span class="s2">&amp;#34;vllm&amp;gt;=0.8&amp;#34;&lt;/span>
&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>What each piece does and why it is pinned:&lt;/p>
&lt;table>
&lt;thead>
&lt;tr>
&lt;th>Package&lt;/th>
&lt;th>Role in the flow&lt;/th>
&lt;th>Why the version matters&lt;/th>
&lt;/tr>
&lt;/thead>
&lt;tbody>
&lt;tr>
&lt;td>&lt;code>torch&lt;/code>&lt;/td>
&lt;td>tensor runtime and CUDA kernels&lt;/td>
&lt;td>the CUDA ABI has to match the driver and &lt;code>bitsandbytes&lt;/code>; a major jump breaks the 4-bit kernels.&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>&lt;code>transformers&lt;/code>&lt;/td>
&lt;td>loads the base, the tokenizer and the &lt;code>chat_template&lt;/code>&lt;/td>
&lt;td>it has to &lt;strong>know the architecture&lt;/strong> of the SLM you use; a new model needs a version that supports it.&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>&lt;code>peft&lt;/code>&lt;/td>
&lt;td>implements LoRA/QLoRA: injects the &lt;code>A,B&lt;/code> matrices and writes the &lt;code>adapter_config.json&lt;/code>&lt;/td>
&lt;td>that &lt;code>adapter_config.json&lt;/code> is what &lt;strong>vLLM reads&lt;/strong> when serving; old versions write fields the serving side does not understand.&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>&lt;code>trl&lt;/code>&lt;/td>
&lt;td>the &lt;code>SFTTrainer&lt;/code>: the supervised training loop&lt;/td>
&lt;td>it integrates &lt;code>peft&lt;/code> natively; its API (&lt;code>SFTConfig&lt;/code>) changes between versions, hence the pin.&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>&lt;code>bitsandbytes&lt;/code>&lt;/td>
&lt;td>the NF4 quantisation and &lt;code>paged_adamw_8bit&lt;/code>&lt;/td>
&lt;td>&lt;strong>the most sensitive piece&lt;/strong>: a badly compiled binary gives corrupt dequant or hangs on the first step.&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>&lt;code>accelerate&lt;/code>&lt;/td>
&lt;td>orchestrates device, mixed precision and &lt;code>device_map&lt;/code>&lt;/td>
&lt;td>the silent backend of almost everything; misaligning it with &lt;code>transformers&lt;/code> gives cryptic errors.&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>&lt;code>datasets&lt;/code>&lt;/td>
&lt;td>loads the JSONL (and allows streaming if the corpus is large)&lt;/td>
&lt;td>not very sensitive; any recent 3.x will do.&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>&lt;code>vllm&lt;/code>&lt;/td>
&lt;td>the multi-LoRA serving&lt;/td>
&lt;td>&lt;strong>separate environment or image&lt;/strong>: do not mix its stack with the training &lt;code>bitsandbytes&lt;/code>.&lt;/td>
&lt;/tr>
&lt;/tbody>
&lt;/table>
&lt;p>The golden rule: &lt;strong>coherence between the four above&lt;/strong> (&lt;code>transformers&lt;/code>, &lt;code>peft&lt;/code>, &lt;code>trl&lt;/code>, &lt;code>bitsandbytes&lt;/code>) matters more than the exact number of each. Pin them when starting a campaign and do not move them until it closes.&lt;/p>
&lt;p>Check that the GPU and CUDA are healthy before starting; a badly compiled &lt;code>bitsandbytes&lt;/code> shows up late:&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 -c &lt;span class="s2">&amp;#34;import torch, bitsandbytes; print(torch.cuda.get_device_name(0), torch.cuda.is_available())&amp;#34;&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">nvidia-smi --query-gpu&lt;span class="o">=&lt;/span>name,memory.total,driver_version --format&lt;span class="o">=&lt;/span>csv
&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>For 100 % sovereignty: download the base once from your internal Hugging Face mirror (or a MinIO with the weights) and export &lt;code>HF_HOME&lt;/code> to a local volume. Nothing in this flow needs to leave the perimeter.&lt;/p>
&lt;h2 id="phase-1--preparing-the-dataset">Phase 1 — Preparing the dataset&lt;/h2>
&lt;p>The canonical format for a conversational task is JSONL, one conversation per line, with the model&amp;rsquo;s chat template. Do not invent your own format: use the base tokenizer&amp;rsquo;s &lt;code>chat_template&lt;/code>, because any mismatch between how you train and how you serve degrades quality silently.&lt;/p>
&lt;pre tabindex="0">&lt;code class="language-jsonl" data-lang="jsonl">{&amp;#34;messages&amp;#34;:[{&amp;#34;role&amp;#34;:&amp;#34;system&amp;#34;,&amp;#34;content&amp;#34;:&amp;#34;You are a network support assistant.&amp;#34;},{&amp;#34;role&amp;#34;:&amp;#34;user&amp;#34;,&amp;#34;content&amp;#34;:&amp;#34;The north wing AP does not come up after the outage.&amp;#34;},{&amp;#34;role&amp;#34;:&amp;#34;assistant&amp;#34;,&amp;#34;content&amp;#34;:&amp;#34;First confirm the PoE on the port...&amp;#34;}]}
{&amp;#34;messages&amp;#34;:[{&amp;#34;role&amp;#34;:&amp;#34;user&amp;#34;,&amp;#34;content&amp;#34;:&amp;#34;Generate the VLAN change for customer 42.&amp;#34;},{&amp;#34;role&amp;#34;:&amp;#34;assistant&amp;#34;,&amp;#34;content&amp;#34;:&amp;#34;interface GigabitEthernet0/3\n switchport access vlan 42...&amp;#34;}]}
&lt;/code>&lt;/pre>&lt;p>What each field is and why:&lt;/p>
&lt;table>
&lt;thead>
&lt;tr>
&lt;th>Field&lt;/th>
&lt;th>What it is&lt;/th>
&lt;th>Operational note&lt;/th>
&lt;/tr>
&lt;/thead>
&lt;tbody>
&lt;tr>
&lt;td>&lt;code>messages&lt;/code>&lt;/td>
&lt;td>the complete conversation, a list of turns&lt;/td>
&lt;td>one conversation per JSONL line; it is what &lt;code>apply_chat_template&lt;/code> turns into tokens.&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>&lt;code>role&lt;/code>&lt;/td>
&lt;td>who is speaking: &lt;code>system&lt;/code>, &lt;code>user&lt;/code>, &lt;code>assistant&lt;/code>&lt;/td>
&lt;td>the adapter learns to produce the &lt;code>assistant&lt;/code> turns; the &lt;code>user&lt;/code>/&lt;code>system&lt;/code> ones are context, not target.&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>&lt;code>content&lt;/code>&lt;/td>
&lt;td>the text of the turn&lt;/td>
&lt;td>the &lt;code>system&lt;/code> one sets the persona/task; keep it &lt;strong>identical&lt;/strong> to the one you will use in production or the adapter drifts out of alignment.&lt;/td>
&lt;/tr>
&lt;/tbody>
&lt;/table>
&lt;p>Operational rules that save grief: watch the &lt;strong>ratio of examples&lt;/strong> (a well-curated narrow-task dataset of 2,000-20,000 examples performs better than 200,000 noisy ones), &lt;strong>deduplicate&lt;/strong>, and set aside 5-10 % as a validation split that does NOT go into training. Building the corpus from production signal is covered by &lt;a href="https://blog.lo0.es/en/posts/retrain-closing-the-loop-feedback-dataset-adapter/">Retrain: closing the loop&lt;/a>.&lt;/p>
&lt;h2 id="phase-2--the-training-script">Phase 2 — The training script&lt;/h2>
&lt;p>A minimal, complete script with TRL + PEFT. It trains an r=8 adapter on an 8B SLM quantised to NF4. Each block has its rationale commented.&lt;/p>
&lt;div class="highlight">&lt;pre tabindex="0" class="chroma">&lt;code class="language-python" data-lang="python">&lt;span class="line">&lt;span class="cl">&lt;span class="c1"># train_qlora.py&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="kn">import&lt;/span> &lt;span class="nn">torch&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="kn">from&lt;/span> &lt;span class="nn">datasets&lt;/span> &lt;span class="kn">import&lt;/span> &lt;span class="n">load_dataset&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="kn">from&lt;/span> &lt;span class="nn">transformers&lt;/span> &lt;span class="kn">import&lt;/span> &lt;span class="n">AutoTokenizer&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="n">AutoModelForCausalLM&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="n">BitsAndBytesConfig&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="kn">from&lt;/span> &lt;span class="nn">peft&lt;/span> &lt;span class="kn">import&lt;/span> &lt;span class="n">LoraConfig&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="kn">from&lt;/span> &lt;span class="nn">trl&lt;/span> &lt;span class="kn">import&lt;/span> &lt;span class="n">SFTConfig&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="n">SFTTrainer&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="n">BASE&lt;/span> &lt;span class="o">=&lt;/span> &lt;span class="s2">&amp;#34;Qwen/Qwen3-8B&amp;#34;&lt;/span> &lt;span class="c1"># or whichever SLM you serve; ALWAYS use the same one in train and serve&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="n">OUT&lt;/span> &lt;span class="o">=&lt;/span> &lt;span class="s2">&amp;#34;adapters/soporte-redes-v1&amp;#34;&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="c1"># 1) Base frozen and quantised to 4-bit NF4 with double quantisation&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="n">bnb&lt;/span> &lt;span class="o">=&lt;/span> &lt;span class="n">BitsAndBytesConfig&lt;/span>&lt;span class="p">(&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="n">load_in_4bit&lt;/span>&lt;span class="o">=&lt;/span>&lt;span class="kc">True&lt;/span>&lt;span class="p">,&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="n">bnb_4bit_quant_type&lt;/span>&lt;span class="o">=&lt;/span>&lt;span class="s2">&amp;#34;nf4&amp;#34;&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="c1"># NormalFloat, quantile-optimal for Gaussian weights&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="n">bnb_4bit_use_double_quant&lt;/span>&lt;span class="o">=&lt;/span>&lt;span class="kc">True&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="c1"># quantises the scale constants -&amp;gt; ~0.37 bits/param less&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="n">bnb_4bit_compute_dtype&lt;/span>&lt;span class="o">=&lt;/span>&lt;span class="n">torch&lt;/span>&lt;span class="o">.&lt;/span>&lt;span class="n">bfloat16&lt;/span> &lt;span class="c1"># the matmuls run in BF16 after dequant on the fly&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>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="n">tok&lt;/span> &lt;span class="o">=&lt;/span> &lt;span class="n">AutoTokenizer&lt;/span>&lt;span class="o">.&lt;/span>&lt;span class="n">from_pretrained&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="n">BASE&lt;/span>&lt;span class="p">)&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="n">model&lt;/span> &lt;span class="o">=&lt;/span> &lt;span class="n">AutoModelForCausalLM&lt;/span>&lt;span class="o">.&lt;/span>&lt;span class="n">from_pretrained&lt;/span>&lt;span class="p">(&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="n">BASE&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="n">quantization_config&lt;/span>&lt;span class="o">=&lt;/span>&lt;span class="n">bnb&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="n">torch_dtype&lt;/span>&lt;span class="o">=&lt;/span>&lt;span class="n">torch&lt;/span>&lt;span class="o">.&lt;/span>&lt;span class="n">bfloat16&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="n">device_map&lt;/span>&lt;span class="o">=&lt;/span>&lt;span class="p">{&lt;/span>&lt;span class="s2">&amp;#34;&amp;#34;&lt;/span>&lt;span class="p">:&lt;/span> &lt;span class="mi">0&lt;/span>&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;span class="line">&lt;span class="cl">
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="c1"># 2) The adapter: low rank, attention projections only (aggressive). Raise target_modules if the eval asks for it.&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="n">peft_cfg&lt;/span> &lt;span class="o">=&lt;/span> &lt;span class="n">LoraConfig&lt;/span>&lt;span class="p">(&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="n">r&lt;/span>&lt;span class="o">=&lt;/span>&lt;span class="mi">8&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="n">lora_alpha&lt;/span>&lt;span class="o">=&lt;/span>&lt;span class="mi">16&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="n">lora_dropout&lt;/span>&lt;span class="o">=&lt;/span>&lt;span class="mf">0.05&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="n">bias&lt;/span>&lt;span class="o">=&lt;/span>&lt;span class="s2">&amp;#34;none&amp;#34;&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="n">task_type&lt;/span>&lt;span class="o">=&lt;/span>&lt;span class="s2">&amp;#34;CAUSAL_LM&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="n">target_modules&lt;/span>&lt;span class="o">=&lt;/span>&lt;span class="p">[&lt;/span>&lt;span class="s2">&amp;#34;q_proj&amp;#34;&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="s2">&amp;#34;k_proj&amp;#34;&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="s2">&amp;#34;v_proj&amp;#34;&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="s2">&amp;#34;o_proj&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="p">)&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="n">ds&lt;/span> &lt;span class="o">=&lt;/span> &lt;span class="n">load_dataset&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="s2">&amp;#34;json&amp;#34;&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="n">data_files&lt;/span>&lt;span class="o">=&lt;/span>&lt;span class="p">{&lt;/span>&lt;span class="s2">&amp;#34;train&amp;#34;&lt;/span>&lt;span class="p">:&lt;/span> &lt;span class="s2">&amp;#34;data/train.jsonl&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="s2">&amp;#34;eval&amp;#34;&lt;/span>&lt;span class="p">:&lt;/span> &lt;span class="s2">&amp;#34;data/eval.jsonl&amp;#34;&lt;/span>&lt;span class="p">})&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="c1"># 3) Training config designed to fit in 24 GB&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="n">cfg&lt;/span> &lt;span class="o">=&lt;/span> &lt;span class="n">SFTConfig&lt;/span>&lt;span class="p">(&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="n">output_dir&lt;/span>&lt;span class="o">=&lt;/span>&lt;span class="n">OUT&lt;/span>&lt;span class="p">,&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="n">per_device_train_batch_size&lt;/span>&lt;span class="o">=&lt;/span>&lt;span class="mi">1&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="c1"># small real batch&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="n">gradient_accumulation_steps&lt;/span>&lt;span class="o">=&lt;/span>&lt;span class="mi">16&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="c1"># EFFECTIVE batch = 1*16 = 16, without paying its VRAM all at once&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="n">gradient_checkpointing&lt;/span>&lt;span class="o">=&lt;/span>&lt;span class="kc">True&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="c1"># recomputes activations in backward: trades compute for memory&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="n">optim&lt;/span>&lt;span class="o">=&lt;/span>&lt;span class="s2">&amp;#34;paged_adamw_8bit&amp;#34;&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="c1"># paged optimizer: the airbag against VRAM peaks&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="n">learning_rate&lt;/span>&lt;span class="o">=&lt;/span>&lt;span class="mf">2e-4&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="n">lr_scheduler_type&lt;/span>&lt;span class="o">=&lt;/span>&lt;span class="s2">&amp;#34;cosine&amp;#34;&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="n">warmup_ratio&lt;/span>&lt;span class="o">=&lt;/span>&lt;span class="mf">0.03&lt;/span>&lt;span class="p">,&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="n">num_train_epochs&lt;/span>&lt;span class="o">=&lt;/span>&lt;span class="mi">3&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="n">bf16&lt;/span>&lt;span class="o">=&lt;/span>&lt;span class="kc">True&lt;/span>&lt;span class="p">,&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="n">max_length&lt;/span>&lt;span class="o">=&lt;/span>&lt;span class="mi">2048&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="c1"># bounds the sequence: activations scale with it&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="n">logging_steps&lt;/span>&lt;span class="o">=&lt;/span>&lt;span class="mi">10&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="n">eval_strategy&lt;/span>&lt;span class="o">=&lt;/span>&lt;span class="s2">&amp;#34;steps&amp;#34;&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="n">eval_steps&lt;/span>&lt;span class="o">=&lt;/span>&lt;span class="mi">100&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="n">save_steps&lt;/span>&lt;span class="o">=&lt;/span>&lt;span class="mi">200&lt;/span>&lt;span class="p">,&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="n">report_to&lt;/span>&lt;span class="o">=&lt;/span>&lt;span class="s2">&amp;#34;none&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="p">)&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="n">trainer&lt;/span> &lt;span class="o">=&lt;/span> &lt;span class="n">SFTTrainer&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="n">model&lt;/span>&lt;span class="o">=&lt;/span>&lt;span class="n">model&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="n">args&lt;/span>&lt;span class="o">=&lt;/span>&lt;span class="n">cfg&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="n">peft_config&lt;/span>&lt;span class="o">=&lt;/span>&lt;span class="n">peft_cfg&lt;/span>&lt;span class="p">,&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="n">train_dataset&lt;/span>&lt;span class="o">=&lt;/span>&lt;span class="n">ds&lt;/span>&lt;span class="p">[&lt;/span>&lt;span class="s2">&amp;#34;train&amp;#34;&lt;/span>&lt;span class="p">],&lt;/span> &lt;span class="n">eval_dataset&lt;/span>&lt;span class="o">=&lt;/span>&lt;span class="n">ds&lt;/span>&lt;span class="p">[&lt;/span>&lt;span class="s2">&amp;#34;eval&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="n">processing_class&lt;/span>&lt;span class="o">=&lt;/span>&lt;span class="n">tok&lt;/span>&lt;span class="p">)&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="n">trainer&lt;/span>&lt;span class="o">.&lt;/span>&lt;span class="n">train&lt;/span>&lt;span class="p">()&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="n">trainer&lt;/span>&lt;span class="o">.&lt;/span>&lt;span class="n">save_model&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="n">OUT&lt;/span>&lt;span class="p">)&lt;/span> &lt;span class="c1"># saves ONLY the adapter (MB), not the base&lt;/span>
&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;h3 id="bitsandbytesconfig--how-the-base-is-quantised">&lt;code>BitsAndBytesConfig&lt;/code> — how the base is quantised&lt;/h3>
&lt;table>
&lt;thead>
&lt;tr>
&lt;th>Option&lt;/th>
&lt;th>What it does&lt;/th>
&lt;th>Why this value / when to change it&lt;/th>
&lt;/tr>
&lt;/thead>
&lt;tbody>
&lt;tr>
&lt;td>&lt;code>load_in_4bit=True&lt;/code>&lt;/td>
&lt;td>loads the base weights in 4-bit&lt;/td>
&lt;td>it is the foundation of QLoRA: without it the 8B does not even fit to train.&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>&lt;code>bnb_4bit_quant_type=&amp;quot;nf4&amp;quot;&lt;/code>&lt;/td>
&lt;td>uses the NF4 format (quantile-optimal for Gaussian weights)&lt;/td>
&lt;td>&lt;code>&amp;quot;fp4&amp;quot;&lt;/code> exists, but NF4 performs better on transformer weights; leave NF4.&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>&lt;code>bnb_4bit_use_double_quant=True&lt;/code>&lt;/td>
&lt;td>quantises the scale constants themselves&lt;/td>
&lt;td>saves ~0.37 bits/param (hundreds of MB on an 8B); the margin separating &amp;ldquo;it fits&amp;rdquo; from &amp;ldquo;OOM&amp;rdquo;. Leave it at &lt;code>True&lt;/code>.&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>&lt;code>bnb_4bit_compute_dtype=torch.bfloat16&lt;/code>&lt;/td>
&lt;td>matmul precision after undoing the quantisation on the fly&lt;/td>
&lt;td>BF16 on Ada/Hopper (4090, H100); use &lt;code>float16&lt;/code> only on GPUs without BF16.&lt;/td>
&lt;/tr>
&lt;/tbody>
&lt;/table>
&lt;h3 id="loraconfig--the-shape-of-the-adapter">&lt;code>LoraConfig&lt;/code> — the shape of the adapter&lt;/h3>
&lt;table>
&lt;thead>
&lt;tr>
&lt;th>Option&lt;/th>
&lt;th>What it does&lt;/th>
&lt;th>Why this value / when to change it&lt;/th>
&lt;/tr>
&lt;/thead>
&lt;tbody>
&lt;tr>
&lt;td>&lt;code>r=8&lt;/code>&lt;/td>
&lt;td>adapter rank: its correction capacity&lt;/td>
&lt;td>4-8 for a narrow task (aggressive); raise it to 16-64 only if the eval shows underfitting.&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>&lt;code>lora_alpha=16&lt;/code>&lt;/td>
&lt;td>scale factor of the delta (effective &lt;code>α/r&lt;/code>)&lt;/td>
&lt;td>common convention &lt;code>α=2r&lt;/code>; it modulates how much the adapter &amp;ldquo;weighs&amp;rdquo; on the base.&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>&lt;code>lora_dropout=0.05&lt;/code>&lt;/td>
&lt;td>regularisation on the adapter&lt;/td>
&lt;td>0.05-0.1 with small datasets (avoids overfit); 0 if the corpus is large.&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>&lt;code>bias=&amp;quot;none&amp;quot;&lt;/code>&lt;/td>
&lt;td>does not train the bias terms&lt;/td>
&lt;td>&lt;code>&amp;quot;none&amp;quot;&lt;/code> is the standard; &lt;code>&amp;quot;all&amp;quot;&lt;/code>/&lt;code>&amp;quot;lora_only&amp;quot;&lt;/code> rarely help and cost params.&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>&lt;code>task_type=&amp;quot;CAUSAL_LM&amp;quot;&lt;/code>&lt;/td>
&lt;td>objective/head type&lt;/td>
&lt;td>fixed for a generative LLM.&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>&lt;code>target_modules=[q,k,v,o]&lt;/code>&lt;/td>
&lt;td>which matrices get an adapter&lt;/td>
&lt;td>attention only = cheap and aggressive; add &lt;code>gate_proj&lt;/code>/&lt;code>up_proj&lt;/code>/&lt;code>down_proj&lt;/code> (MLP) if the task demands rewriting more behaviour and the eval asks for it.&lt;/td>
&lt;/tr>
&lt;/tbody>
&lt;/table>
&lt;h3 id="sftconfig--the-memory-budget-and-the-loop">&lt;code>SFTConfig&lt;/code> — the memory budget and the loop&lt;/h3>
&lt;table>
&lt;thead>
&lt;tr>
&lt;th>Option&lt;/th>
&lt;th>What it does&lt;/th>
&lt;th>Why this value / when to change it&lt;/th>
&lt;/tr>
&lt;/thead>
&lt;tbody>
&lt;tr>
&lt;td>&lt;code>per_device_train_batch_size=1&lt;/code>&lt;/td>
&lt;td>microbatch per GPU&lt;/td>
&lt;td>1 on 24 GB; the real batch is built by &lt;code>gradient_accumulation_steps&lt;/code>.&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>&lt;code>gradient_accumulation_steps=16&lt;/code>&lt;/td>
&lt;td>accumulates 16 microbatches before updating&lt;/td>
&lt;td>&lt;strong>effective&lt;/strong> batch = 1×16 = 16 without paying its VRAM all at once; raise it if you shorten the sequence and want a bigger effective batch.&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>&lt;code>gradient_checkpointing=True&lt;/code>&lt;/td>
&lt;td>recomputes activations in the backward pass instead of storing them&lt;/td>
&lt;td>essential on a 4090: ~20-30 % slower in exchange for much less VRAM.&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>&lt;code>optim=&amp;quot;paged_adamw_8bit&amp;quot;&lt;/code>&lt;/td>
&lt;td>Adam optimizer in 8-bit + states pageable to RAM&lt;/td>
&lt;td>less state VRAM &lt;strong>and&lt;/strong> the airbag that avoids the OOM at the peaks.&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>&lt;code>learning_rate=2e-4&lt;/code>&lt;/td>
&lt;td>learning rate of the adapter&lt;/td>
&lt;td>1e-4-3e-4 is the typical QLoRA range; adapters tolerate a higher LR than a full fine-tune.&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>&lt;code>lr_scheduler_type=&amp;quot;cosine&amp;quot;&lt;/code>&lt;/td>
&lt;td>LR decay curve&lt;/td>
&lt;td>&lt;code>cosine&lt;/code> or &lt;code>linear&lt;/code>; cosine usually gives a smooth drop at the end.&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>&lt;code>warmup_ratio=0.03&lt;/code>&lt;/td>
&lt;td>warms the LR over the first 3 % of steps&lt;/td>
&lt;td>avoids the instability of the first steps.&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>&lt;code>num_train_epochs=3&lt;/code>&lt;/td>
&lt;td>complete passes over the dataset&lt;/td>
&lt;td>1-3; watch the &lt;em>eval loss&lt;/em> so as not to overfit.&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>&lt;code>bf16=True&lt;/code>&lt;/td>
&lt;td>compute and adapter precision&lt;/td>
&lt;td>BF16 on Ada/Hopper; &lt;code>fp16=True&lt;/code> if your GPU has no BF16.&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>&lt;code>max_length=2048&lt;/code>&lt;/td>
&lt;td>maximum sequence length&lt;/td>
&lt;td>&lt;strong>the #1 VRAM lever&lt;/strong> for activations: shorten it first if there is an OOM.&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>&lt;code>eval_strategy&lt;/code>/&lt;code>eval_steps&lt;/code>/&lt;code>save_steps&lt;/code>&lt;/td>
&lt;td>validation and checkpoint cadence&lt;/td>
&lt;td>tune them to the dataset size; evaluating often costs time.&lt;/td>
&lt;/tr>
&lt;/tbody>
&lt;/table>
&lt;p>The four pieces that make it fit on a 4090 are: &lt;code>per_device_train_batch_size=1&lt;/code> + &lt;code>gradient_accumulation_steps&lt;/code> (a large effective batch without its memory cost all at once), &lt;code>gradient_checkpointing=True&lt;/code> (recomputing activations instead of storing them) and &lt;code>optim=&amp;quot;paged_adamw_8bit&amp;quot;&lt;/code> (paging states to RAM at the peaks). Remove any of the three with long sequences and you will see the OOM.&lt;/p>
&lt;p>A declarative alternative with &lt;strong>Axolotl&lt;/strong> if you prefer YAML over Python (same result):&lt;/p>
&lt;div class="highlight">&lt;pre tabindex="0" class="chroma">&lt;code class="language-yaml" data-lang="yaml">&lt;span class="line">&lt;span class="cl">&lt;span class="nt">base_model&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">Qwen/Qwen3-8B&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w">&lt;/span>&lt;span class="nt">load_in_4bit&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="kc">true&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w">&lt;/span>&lt;span class="nt">adapter&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">qlora&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w">&lt;/span>&lt;span class="nt">lora_r&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="m">8&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w">&lt;/span>&lt;span class="nt">lora_alpha&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="m">16&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w">&lt;/span>&lt;span class="nt">lora_target_modules&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="p">[&lt;/span>&lt;span class="l">q_proj, k_proj, v_proj, o_proj]&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w">&lt;/span>&lt;span class="nt">sequence_len&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="m">2048&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w">&lt;/span>&lt;span class="nt">micro_batch_size&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="m">1&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w">&lt;/span>&lt;span class="nt">gradient_accumulation_steps&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="m">16&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w">&lt;/span>&lt;span class="nt">gradient_checkpointing&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="kc">true&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w">&lt;/span>&lt;span class="nt">optimizer&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">paged_adamw_8bit&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w">&lt;/span>&lt;span class="nt">learning_rate&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="m">0.0002&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w">&lt;/span>&lt;span class="nt">num_epochs&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="m">3&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w">&lt;/span>&lt;span class="nt">bf16&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="kc">true&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w">&lt;/span>&lt;span class="nt">datasets&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>- &lt;span class="nt">path&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">data/train.jsonl&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">type&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">chat_template&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;h2 id="phase-3--launch-and-monitor">Phase 3 — Launch and monitor&lt;/h2>
&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">&lt;span class="c1"># Simple launch on one GPU&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">python train_qlora.py
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="c1"># In another terminal: watch VRAM. If it gets close to the ceiling, lower max_length or raise grad accumulation.&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">watch -n &lt;span class="m">2&lt;/span> nvidia-smi --query-gpu&lt;span class="o">=&lt;/span>memory.used,memory.total,utilization.gpu --format&lt;span class="o">=&lt;/span>csv
&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>Approximate VRAM budget when training the 8B on the 4090, and what to touch when it gets tight:&lt;/p>
&lt;table>
&lt;thead>
&lt;tr>
&lt;th>Component&lt;/th>
&lt;th style="text-align:right">Approx. VRAM&lt;/th>
&lt;th>Lever if there is an OOM&lt;/th>
&lt;/tr>
&lt;/thead>
&lt;tbody>
&lt;tr>
&lt;td>8B NF4 base (frozen)&lt;/td>
&lt;td style="text-align:right">~4.0 GB&lt;/td>
&lt;td>— (fixed)&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>Adapter + grad + Adam states&lt;/td>
&lt;td style="text-align:right">~0.3-0.7 GB&lt;/td>
&lt;td>lower &lt;code>r&lt;/code>&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>Activations (batch × sequence)&lt;/td>
&lt;td style="text-align:right">~6-14 GB&lt;/td>
&lt;td>lower &lt;code>max_length&lt;/code>, &lt;code>batch_size&lt;/code>; raise &lt;code>grad_accum&lt;/code>&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>Dequant buffers / workspace&lt;/td>
&lt;td style="text-align:right">~1-2 GB&lt;/td>
&lt;td>—&lt;/td>
&lt;/tr>
&lt;/tbody>
&lt;/table>
&lt;p>Table of quick OOM remedies, in order of cost: &lt;strong>(1)&lt;/strong> lower &lt;code>max_length&lt;/code>; &lt;strong>(2)&lt;/strong> confirm &lt;code>gradient_checkpointing=True&lt;/code>; &lt;strong>(3)&lt;/strong> raise &lt;code>gradient_accumulation_steps&lt;/code> and lower &lt;code>per_device_train_batch_size&lt;/code> to 1; &lt;strong>(4)&lt;/strong> use &lt;code>paged_adamw_8bit&lt;/code> (already in the script); &lt;strong>(5)&lt;/strong> as a last resort lower &lt;code>r&lt;/code>. If after all that it still does not fit, the sequence or the model is too large for 24 GB: either you bound it, or you move up the hardware.&lt;/p>
&lt;h2 id="phase-4--validating-the-adapter">Phase 4 — Validating the adapter&lt;/h2>
&lt;p>Never promote an adapter on the strength of the &lt;em>training loss&lt;/em>. Measure against the validation split you set aside and against a handful of real prompts.&lt;/p>
&lt;div class="highlight">&lt;pre tabindex="0" class="chroma">&lt;code class="language-python" data-lang="python">&lt;span class="line">&lt;span class="cl">&lt;span class="c1"># quick_eval.py&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="kn">import&lt;/span> &lt;span class="nn">torch&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="kn">from&lt;/span> &lt;span class="nn">transformers&lt;/span> &lt;span class="kn">import&lt;/span> &lt;span class="n">AutoTokenizer&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="n">AutoModelForCausalLM&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="n">BitsAndBytesConfig&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="kn">from&lt;/span> &lt;span class="nn">peft&lt;/span> &lt;span class="kn">import&lt;/span> &lt;span class="n">PeftModel&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="n">bnb&lt;/span> &lt;span class="o">=&lt;/span> &lt;span class="n">BitsAndBytesConfig&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="n">load_in_4bit&lt;/span>&lt;span class="o">=&lt;/span>&lt;span class="kc">True&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="n">bnb_4bit_quant_type&lt;/span>&lt;span class="o">=&lt;/span>&lt;span class="s2">&amp;#34;nf4&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="n">bnb_4bit_use_double_quant&lt;/span>&lt;span class="o">=&lt;/span>&lt;span class="kc">True&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="n">bnb_4bit_compute_dtype&lt;/span>&lt;span class="o">=&lt;/span>&lt;span class="n">torch&lt;/span>&lt;span class="o">.&lt;/span>&lt;span class="n">bfloat16&lt;/span>&lt;span class="p">)&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="n">tok&lt;/span> &lt;span class="o">=&lt;/span> &lt;span class="n">AutoTokenizer&lt;/span>&lt;span class="o">.&lt;/span>&lt;span class="n">from_pretrained&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="s2">&amp;#34;Qwen/Qwen3-8B&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="n">base&lt;/span> &lt;span class="o">=&lt;/span> &lt;span class="n">AutoModelForCausalLM&lt;/span>&lt;span class="o">.&lt;/span>&lt;span class="n">from_pretrained&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="s2">&amp;#34;Qwen/Qwen3-8B&amp;#34;&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="n">quantization_config&lt;/span>&lt;span class="o">=&lt;/span>&lt;span class="n">bnb&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="n">device_map&lt;/span>&lt;span class="o">=&lt;/span>&lt;span class="p">{&lt;/span>&lt;span class="s2">&amp;#34;&amp;#34;&lt;/span>&lt;span class="p">:&lt;/span> &lt;span class="mi">0&lt;/span>&lt;span class="p">})&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="n">model&lt;/span> &lt;span class="o">=&lt;/span> &lt;span class="n">PeftModel&lt;/span>&lt;span class="o">.&lt;/span>&lt;span class="n">from_pretrained&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="n">base&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="s2">&amp;#34;adapters/soporte-redes-v1&amp;#34;&lt;/span>&lt;span class="p">)&lt;/span> &lt;span class="c1"># base + adapter&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="n">msgs&lt;/span> &lt;span class="o">=&lt;/span> &lt;span class="p">[{&lt;/span>&lt;span class="s2">&amp;#34;role&amp;#34;&lt;/span>&lt;span class="p">:&lt;/span> &lt;span class="s2">&amp;#34;user&amp;#34;&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="s2">&amp;#34;content&amp;#34;&lt;/span>&lt;span class="p">:&lt;/span> &lt;span class="s2">&amp;#34;The north wing AP does not come up after the outage.&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="n">ids&lt;/span> &lt;span class="o">=&lt;/span> &lt;span class="n">tok&lt;/span>&lt;span class="o">.&lt;/span>&lt;span class="n">apply_chat_template&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="n">msgs&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="n">add_generation_prompt&lt;/span>&lt;span class="o">=&lt;/span>&lt;span class="kc">True&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="n">return_tensors&lt;/span>&lt;span class="o">=&lt;/span>&lt;span class="s2">&amp;#34;pt&amp;#34;&lt;/span>&lt;span class="p">)&lt;/span>&lt;span class="o">.&lt;/span>&lt;span class="n">to&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="mi">0&lt;/span>&lt;span class="p">)&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="nb">print&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="n">tok&lt;/span>&lt;span class="o">.&lt;/span>&lt;span class="n">decode&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="n">model&lt;/span>&lt;span class="o">.&lt;/span>&lt;span class="n">generate&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="n">ids&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="n">max_new_tokens&lt;/span>&lt;span class="o">=&lt;/span>&lt;span class="mi">256&lt;/span>&lt;span class="p">)[&lt;/span>&lt;span class="mi">0&lt;/span>&lt;span class="p">],&lt;/span> &lt;span class="n">skip_special_tokens&lt;/span>&lt;span class="o">=&lt;/span>&lt;span class="kc">True&lt;/span>&lt;span class="p">))&lt;/span>
&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>For a serious verdict, run the adapter through your eval suite (the layer described by &lt;a href="https://blog.lo0.es/en/posts/evals-llm-layer-after-tracing/">LLM evals&lt;/a>) and compare against the base &lt;strong>without&lt;/strong> an adapter and against the previous version of the adapter. Promote only if it wins on the task metric without regressing on safety/format.&lt;/p>
&lt;h2 id="phase-5--versioning-the-adapter-as-an-artefact">Phase 5 — Versioning the adapter as an artefact&lt;/h2>
&lt;p>The adapter is a pair of files of a few MB (&lt;code>adapter_model.safetensors&lt;/code> + &lt;code>adapter_config.json&lt;/code>). Treat it as a versioned, signed, traceable artefact, not as a loose file.&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">&lt;span class="c1"># Reproducible checksum + upload to internal object storage (MinIO/S3)&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">sha256sum adapters/soporte-redes-v1/adapter_model.safetensors &amp;gt; adapters/soporte-redes-v1/SHA256
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">aws --endpoint-url https://minio.interno s3 cp &lt;span class="se">\
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="se">&lt;/span> adapters/soporte-redes-v1/ s3://adapters/soporte-redes/v1/ --recursive
&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>A convention that works: &lt;code>s3://adapters/&amp;lt;task-or-customer&amp;gt;/&amp;lt;version&amp;gt;/&lt;/code>. Immutable per version, with its &lt;code>SHA256&lt;/code>. Deleting a customer is deleting a prefix of a few MB, not retraining anything. Versioning 500 adapters costs what versioning 500 heavy configuration files costs.&lt;/p>
&lt;h2 id="phase-6--serving-in-multi-lora-with-vllm">Phase 6 — Serving in multi-LoRA with vLLM&lt;/h2>
&lt;p>The consumer loads &lt;strong>one&lt;/strong> shared base and applies the adapter delta per request. Startup with static adapters declared:&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">&lt;span class="nv">VLLM_ALLOW_RUNTIME_LORA_UPDATING&lt;/span>&lt;span class="o">=&lt;/span>True &lt;span class="se">\
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="se">&lt;/span>vllm serve Qwen/Qwen3-8B &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-lora &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-loras &lt;span class="m">8&lt;/span> &lt;span class="se">\ &lt;/span> &lt;span class="c1"># max nº of DIFFERENT adapters per batch (not the total loadable)&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> --max-lora-rank &lt;span class="m">8&lt;/span> &lt;span class="se">\ &lt;/span> &lt;span class="c1"># = the maximum rank of your adapters; do not inflate it (wastes memory)&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> --max-cpu-loras &lt;span class="m">64&lt;/span> &lt;span class="se">\ &lt;/span> &lt;span class="c1"># adapters cached in RAM for fast swap to VRAM&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> --lora-modules soporte-redes&lt;span class="o">=&lt;/span>/srv/adapters/soporte-redes/v1
&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>Each flag, what it controls and how to size it:&lt;/p>
&lt;table>
&lt;thead>
&lt;tr>
&lt;th>Flag / variable&lt;/th>
&lt;th>What it controls&lt;/th>
&lt;th>How to size it&lt;/th>
&lt;/tr>
&lt;/thead>
&lt;tbody>
&lt;tr>
&lt;td>&lt;code>--enable-lora&lt;/code>&lt;/td>
&lt;td>turns on adapter support&lt;/td>
&lt;td>mandatory; without it, vLLM ignores any &lt;code>model&lt;/code> that is an adapter.&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>&lt;code>--max-loras 8&lt;/code>&lt;/td>
&lt;td>nº of &lt;strong>different adapters in a single batch&lt;/strong>&lt;/td>
&lt;td>more adapters per batch makes the SGMV kernels more expensive; 8-32 is reasonable. It is not the total loadable.&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>&lt;code>--max-lora-rank 8&lt;/code>&lt;/td>
&lt;td>maximum rank the server reserves&lt;/td>
&lt;td>set it &lt;strong>equal to the real rank&lt;/strong> of your adapters (8 here); inflating it wastes VRAM and performance.&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>&lt;code>--max-cpu-loras 64&lt;/code>&lt;/td>
&lt;td>adapters cached in RAM ready to page to VRAM&lt;/td>
&lt;td>≥ nº of active adapters; it is the &amp;ldquo;bench&amp;rdquo; from which fast swapping happens.&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>&lt;code>--lora-modules name=path&lt;/code>&lt;/td>
&lt;td>declares &lt;strong>static&lt;/strong> adapters at startup&lt;/td>
&lt;td>useful for the fixed ones; leave it out if everything goes through dynamic loading/Resolver.&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>&lt;code>VLLM_ALLOW_RUNTIME_LORA_UPDATING=True&lt;/code>&lt;/td>
&lt;td>enables the hot load/unload endpoints&lt;/td>
&lt;td>essential for &lt;code>/v1/load_lora_adapter&lt;/code>; without it, the server is static.&lt;/td>
&lt;/tr>
&lt;/tbody>
&lt;/table>
&lt;p>&lt;code>--max-loras&lt;/code> limits the different adapters &lt;strong>per batch&lt;/strong>, not how many you can have loaded; the bulk lives on CPU (&lt;code>--max-cpu-loras&lt;/code>) and is paged to VRAM on demand. Set &lt;code>--max-lora-rank&lt;/code> to the real rank (8 here): inflating it wastes memory and performance. Requests pick the adapter through the &lt;code>model&lt;/code> field:&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">curl http://localhost:8000/v1/chat/completions -H &lt;span class="s2">&amp;#34;Content-Type: application/json&amp;#34;&lt;/span> -d &lt;span class="s1">&amp;#39;{
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="s1"> &amp;#34;model&amp;#34;: &amp;#34;soporte-redes&amp;#34;,
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="s1"> &amp;#34;messages&amp;#34;: [{&amp;#34;role&amp;#34;:&amp;#34;user&amp;#34;,&amp;#34;content&amp;#34;:&amp;#34;The north wing AP does not come up after the outage.&amp;#34;}]
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="s1">}&amp;#39;&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="c1"># model:&amp;#34;Qwen/Qwen3-8B&amp;#34; (no adapter) uses the bare base on the same server&lt;/span>
&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>&lt;strong>Hot loading&lt;/strong> of a new adapter without restarting (thanks to &lt;code>VLLM_ALLOW_RUNTIME_LORA_UPDATING=True&lt;/code>):&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">curl -X POST http://localhost:8000/v1/load_lora_adapter -H &lt;span class="s2">&amp;#34;Content-Type: application/json&amp;#34;&lt;/span> -d &lt;span class="s1">&amp;#39;{
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="s1"> &amp;#34;lora_name&amp;#34;: &amp;#34;cliente-42&amp;#34;,
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="s1"> &amp;#34;lora_path&amp;#34;: &amp;#34;/srv/adapters/cliente-42/v3&amp;#34;
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="s1">}&amp;#39;&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="c1"># and to free VRAM/CPU when a customer goes idle:&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">curl -X POST http://localhost:8000/v1/unload_lora_adapter -H &lt;span class="s2">&amp;#34;Content-Type: application/json&amp;#34;&lt;/span> -d &lt;span class="s1">&amp;#39;{&amp;#34;lora_name&amp;#34;:&amp;#34;cliente-42&amp;#34;}&amp;#39;&lt;/span>
&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>For multi-tenant at scale, avoid declaring hundreds of adapters by hand: the &lt;strong>LoRAResolver&lt;/strong> resolves and loads the adapter from local storage or S3 the first time an unknown &lt;code>model&lt;/code> arrives, so the server stays lean and the adapters are pulled lazily from your MinIO. The internals of &lt;em>how&lt;/em> thousands of concurrent adapters are batched (SGMV kernels, unified paging, the heterogeneous gather/scatter) are in &lt;a href="https://blog.lo0.es/en/posts/multi-lora-serving-shared-base-concurrent-adapters-sgmv-kernel/">Multi-LoRA serving&lt;/a>; this runbook only switches them on. To squeeze the base&amp;rsquo;s decode throughput on a 4090, combine this with &lt;a href="https://blog.lo0.es/en/posts/optimising-decode-vllm-squeezing-every-token-small-hardware/">Optimising decode in vLLM&lt;/a>.&lt;/p>
&lt;h2 id="serving-multi-adapter-vs-merging-per-task">Serving multi-adapter vs merging per task&lt;/h2>
&lt;p>Two deployment architectures, and the procedure changes:&lt;/p>
&lt;p>&lt;strong>Serving multi-LoRA (the above).&lt;/strong> A shared base + N hot adapters. It is the default sovereign pattern: minimum footprint, per-customer isolation, hot-swap. Use standard QLoRA and merge nothing.&lt;/p>
&lt;p>&lt;strong>Merging per task.&lt;/strong> If you want a single quantised-and-adapted artefact per task (no adapter at runtime), do not merge a standard QLoRA adapter into the 4-bit base: the merge reintroduces precision that NF4 does not represent and on requantising you lose part of what was learnt. For that case, train with &lt;strong>QA-LoRA&lt;/strong> (quantization-aware), which merges cleanly onto a quantised base. It is an architecture decision, not a quality one; the conceptual detail is in the &lt;a href="https://blog.lo0.es/en/posts/qlora-multi-lora-limit-small-models/">fundamentals post&lt;/a>.&lt;/p>
&lt;h2 id="checklist-of-operational-gotchas">Checklist of operational gotchas&lt;/h2>
&lt;ul>
&lt;li>&lt;strong>Coherent chat template&lt;/strong> between training and serving. The most common and most silent mismatch: you train with one &lt;code>chat_template&lt;/code> and serve with another. Use the base&amp;rsquo;s on both sides.&lt;/li>
&lt;li>&lt;strong>Exactly the same base&lt;/strong> (revision included) in &lt;code>train&lt;/code> and &lt;code>serve&lt;/code>. An adapter trained on &lt;code>Qwen3-8B&lt;/code> is not valid on another revision of the model.&lt;/li>
&lt;li>&lt;strong>&lt;code>--max-lora-rank&lt;/code> ≥ the rank of ALL the adapters&lt;/strong> served together, but no more: inflating it wastes VRAM.&lt;/li>
&lt;li>&lt;strong>KV budget vs &lt;code>--max-loras&lt;/code>.&lt;/strong> The bottleneck in serving is not the adapters (MB), it is the KV cache and concurrency; see &lt;a href="https://blog.lo0.es/en/posts/roofline-flips-optimising-small-models/">Inverted roofline&lt;/a> for the SLM regime.&lt;/li>
&lt;li>&lt;strong>&lt;code>r&lt;/code> too low&lt;/strong> = underfitting if the task demands rewriting a lot of behaviour. Raise &lt;code>r&lt;/code> only if the eval asks for it.&lt;/li>
&lt;li>&lt;strong>Do not promote on training loss.&lt;/strong> Validate against the reserved split + real prompts + a safety regression.&lt;/li>
&lt;li>&lt;strong>Version and make immutable&lt;/strong> every adapter with its &lt;code>SHA256&lt;/code>; never overwrite a served version.&lt;/li>
&lt;/ul>
&lt;h2 id="applied-to-on-premise-infrastructure">Applied to on-premise infrastructure&lt;/h2>
&lt;p>On an &lt;strong>RTX 4090 (24 GB)&lt;/strong> the same machine is producer and consumer: you train a customer&amp;rsquo;s adapter in hours and serve it on the same server on top of the shared base. It is the canonical case for multi-tenant demos and platform prototypes.&lt;/p>
&lt;p>On a &lt;strong>generic 4×H100 SXM cluster (320 GB, NVLink, native FP8)&lt;/strong> QLoRA stops being necessary in order to &lt;em>fit&lt;/em>, but it serves to &lt;strong>parallelise production&lt;/strong> (several adapter jobs at once) and to keep the quantised format consistent between training and serious serving of hundreds of concurrent adapters. The base can run in native FP8; the mechanics of the runbook do not change, only the scale.&lt;/p>
&lt;h2 id="see-also">See also&lt;/h2>
&lt;ul>
&lt;li>&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 fundamentals post: the why of NF4, double quantisation, paged optimizers and the adapter maths. This runbook is its executable face.&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 consumer internals we only switch on here: SGMV, unified paging, heterogeneous batching of thousands of adapters.&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> — how to squeeze the decode throughput of the base you serve the adapters on top of, on a 4090.&lt;/li>
&lt;li>&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> — where the Phase 1 dataset comes from.&lt;/li>
&lt;li>&lt;a href="https://blog.lo0.es/en/posts/evals-llm-layer-after-tracing/">LLM evals: the layer after tracing&lt;/a> — how to validate the Phase 4 adapter with judgement, not with the training loss.&lt;/li>
&lt;li>&lt;a href="https://blog.lo0.es/en/posts/roofline-flips-optimising-small-models/">Inverted roofline on small models&lt;/a> — the performance regime that explains why the serving bottleneck is the KV cache, not the adapters.&lt;/li>
&lt;li>&lt;a href="https://blog.lo0.es/en/posts/aggressive-quantisation-4-bit-to-ternary/">Aggressive quantisation: from 4-bit to ternary&lt;/a> — what happens to the quantised base below NF4 underneath the adapter.&lt;/li>
&lt;/ul>
&lt;h2 id="references">References&lt;/h2>
&lt;ul>
&lt;li>Dettmers, T., Pagnoni, A., Holtzman, A., Zettlemoyer, L. &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>Hugging Face TRL — PEFT integration (SFTTrainer + QLoRA): &lt;a href="https://huggingface.co/docs/trl/peft_integration">https://huggingface.co/docs/trl/peft_integration&lt;/a>&lt;/li>
&lt;li>Hugging Face PEFT: &lt;a href="https://github.com/huggingface/peft">https://github.com/huggingface/peft&lt;/a>&lt;/li>
&lt;li>bitsandbytes: &lt;a href="https://github.com/bitsandbytes-foundation/bitsandbytes">https://github.com/bitsandbytes-foundation/bitsandbytes&lt;/a>&lt;/li>
&lt;li>vLLM — LoRA Adapters (serving, dynamic loading, LoRAResolver): &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>Axolotl: &lt;a href="https://github.com/axolotl-ai-cloud/axolotl">https://github.com/axolotl-ai-cloud/axolotl&lt;/a>&lt;/li>
&lt;li>Xu, Y. et al. &lt;em>QA-LoRA: Quantization-Aware Low-Rank Adaptation&lt;/em>. ICLR 2024. &lt;a href="https://arxiv.org/abs/2309.14717">https://arxiv.org/abs/2309.14717&lt;/a>&lt;/li>
&lt;/ul></description></item><item><title>The signature-dish specialist: vLLM's attention backend (FlashAttention, FlashInfer and the prefill/decode asymmetry)</title><link>https://blog.lo0.es/en/posts/vllm-attention-backend-flashattention-flashinfer-prefill-decode-asymmetry/</link><pubDate>Mon, 08 Jun 2026 05:40:00 +0200</pubDate><guid>https://blog.lo0.es/en/posts/vllm-attention-backend-flashattention-flashinfer-prefill-decode-asymmetry/</guid><description>&lt;blockquote>
&lt;p>Part of the &lt;em>under the engine&lt;/em> series. The &lt;a href="https://blog.lo0.es/en/posts/pagedattention-vllm-block-manager/">PagedAttention post&lt;/a> explained where the KV lives (in paged blocks). This one explains who reads it and how: the attention kernel. And it connects with &lt;a href="https://blog.lo0.es/en/posts/flashattention-librarian-io-awareness-async-blackwell-asymmetry/">FlashAttention v1-v4&lt;/a>, which took that kernel apart from the inside; here we look at the level above, how vLLM chooses between several kernels and why it needs more than one.&lt;/p>
&lt;/blockquote>
&lt;h2 id="tldr">TL;DR&lt;/h2>
&lt;p>An LLM forward is, for the most part, standard matrix multiplications that any library does well. The exception that decides performance is &lt;strong>attention&lt;/strong>, and having one good kernel is not enough: you need two, because the two phases of inference are physically opposite problems. &lt;strong>Prefill&lt;/strong> processes the whole prompt: many queries against many keys, dense and &lt;strong>compute-bound&lt;/strong>, the home ground of &lt;a href="https://blog.lo0.es/en/posts/flashattention-librarian-io-awareness-async-blackwell-asymmetry/">FlashAttention&lt;/a>&amp;rsquo;s IO-aware tiling. &lt;strong>Decode&lt;/strong> generates one token: a single query against all the accumulated KV, thin and &lt;strong>memory-bound&lt;/strong>, where the only thing that matters is saturating HBM bandwidth while reading the paged KV. That is why vLLM does not have &amp;ldquo;the attention kernel&amp;rdquo; but a &lt;strong>switchable backend&lt;/strong> (FLASH_ATTN, FLASHINFER, TRITON_ATTN and others) plus logic that picks according to the GPU: by default FA4 on Blackwell (SM100), FA3 on Hopper (SM90), FA2 elsewhere, with &lt;strong>FlashInfer&lt;/strong> as the alternative that compiles bespoke kernels (JIT) and knows how to do &lt;strong>cascade attention&lt;/strong> for shared prefixes. This post explains why prefill and decode are opposites (with arithmetic intensity), how the backend reads paged KV, how the engine chooses, what FlashInfer brings, the 10 knobs and the trap of pinning a backend blindly. On the generic 4×H100 SXM cluster.&lt;/p>
&lt;h2 id="where-you-are-the-specialist-not-the-commis">Where you are: the specialist, not the commis&lt;/h2>
&lt;p>In the kitchen, almost all the work is chopping and sautéing: standard operations any competent commis chef executes, the matrix multiplications of the &lt;em>feed-forward&lt;/em> layers and the projections. There is one dish that is not delegated: the signature dish, the one that defines the restaurant. That dish is &lt;strong>attention&lt;/strong>, and it has a quirk: it is cooked in two radically different ways depending on the moment of service.&lt;/p>
&lt;p>During &lt;em>prefill&lt;/em>, when a new order arrives with its whole prompt, you cook big: a lot of raw material at once, a lot of fire, an intense operation that fills the stoves. During &lt;em>decode&lt;/em>, when a table asks for &amp;ldquo;one more dish&amp;rdquo;, you cook à la carte: a single dish, but you have to go to the larder and bring back every ingredient that table has accumulated over the whole meal. One is a problem of firepower; the other, of larder speed. The same specialist does not do both well. That is why vLLM has several, and a chef who decides which one steps up depending on the GPU and the phase. That is the &lt;strong>attention backend&lt;/strong>.&lt;/p>
&lt;h2 id="why-prefill-and-decode-are-opposite-problems">Why prefill and decode are opposite problems&lt;/h2>
&lt;p>This is the central idea, and one sum proves it: &lt;strong>arithmetic intensity&lt;/strong> (FLOPs per byte read). An operation with high intensity is limited by compute; one with low intensity, by memory.&lt;/p>
&lt;p>&lt;strong>Prefill.&lt;/strong> We serve $N$ queries (the whole prompt) against $N$ keys. The $QK^\top$ operation and the $\text{softmax}\cdot V$ do on the order of $N^2 d$ FLOPs and read on the order of $N d$ of data. Intensity grows with $N$:&lt;/p>
$$I_\text{prefill} \sim \frac{N^2 d}{N d} = N$$
&lt;p>With large $N$ (a prompt of thousands of tokens), intensity is high: &lt;strong>compute-bound&lt;/strong>. This is where FlashAttention&amp;rsquo;s tiling squeezes the tensor cores and where you get close to the GPU&amp;rsquo;s peak TFLOPS.&lt;/p>
&lt;p>&lt;strong>Decode.&lt;/strong> We serve one query (the new token) against $L$ keys (all the accumulated KV). FLOPs on the order of $L d$; bytes read on the order of $L d s$ (the entire KV has to be read from HBM). Intensity is:&lt;/p>
$$I_\text{decode} \sim \frac{L d}{L d s} = \frac{1}{s} \quad (\approx 0.5 \text{ FLOP/byte in FP16})$$
&lt;p>Constant and tiny: &lt;strong>memory-bound&lt;/strong>. The decode kernel is not limited by how much the GPU can compute but by how fast it reads the KV from HBM. It makes no difference that the H100 has 132 idle SMs (&lt;a href="https://blog.lo0.es/en/posts/sms-cuda-streams-cuda-graphs-inference/">see the SM post&lt;/a>): the bottleneck is the 3.35 TB/s of bandwidth, and the decode kernel exists so as not to waste a single one of those bytes/s.&lt;/p>
&lt;svg viewBox="0 0 720 220" xmlns="http://www.w3.org/2000/svg" font-family="sans-serif" font-size="12" role="img" aria-label="Prefill compute-bound vs decode memory-bound">
&lt;line x1="60" y1="180" x2="700" y2="180" stroke="currentColor" stroke-width="1.5"/>
&lt;line x1="60" y1="180" x2="60" y2="30" stroke="currentColor" stroke-width="1.5"/>
&lt;text x="40" y="34" fill="currentColor" font-size="11" transform="rotate(-90 40 34)">performance&lt;/text>
&lt;text x="380" y="205" text-anchor="middle" fill="currentColor" font-size="11">arithmetic intensity (FLOP/byte)&lt;/text>
&lt;path d="M60 180 L300 60" stroke="#16a34a" stroke-width="2" fill="none"/>
&lt;line x1="300" y1="60" x2="700" y2="60" stroke="#16a34a" stroke-width="2"/>
&lt;text x="500" y="52" fill="#16a34a" font-size="11">compute roof (peak TFLOPS)&lt;/text>
&lt;circle cx="95" cy="160" r="5" fill="#2563eb"/>&lt;text x="105" y="158" fill="#2563eb">decode (I≈0.5): memory-bound, limited by HBM BW&lt;/text>
&lt;circle cx="430" cy="60" r="5" fill="#7c3aed"/>&lt;text x="330" y="48" fill="#7c3aed">prefill (I≈N): compute-bound, limited by compute&lt;/text>
&lt;text x="70" y="120" fill="currentColor" font-size="10">the ramp = the&lt;/text>
&lt;text x="70" y="134" fill="currentColor" font-size="10">memory-bound region&lt;/text>
&lt;/svg>
&lt;p>The design consequence: a kernel optimised for prefill (dense tiling, maximum tensor-core occupancy) is not the optimum for decode (coalesced reads of the paged KV, minimum latency). Serious servers have different kernels, or one kernel with two paths. In models with &lt;strong>MLA&lt;/strong> (multi-head latent attention), vLLM goes as far as using &lt;strong>separate backends&lt;/strong> for prefill and decode, selectable independently (&lt;a href="https://docs.vllm.ai/en/latest/design/attention_backends/">attention backends, vLLM&lt;/a>).&lt;/p>
&lt;h2 id="the-schedulers-trick-prefill-and-decode-in-the-same-forward">The scheduler&amp;rsquo;s trick: prefill and decode in the same forward&lt;/h2>
&lt;p>Here the circle closes with the &lt;a href="https://blog.lo0.es/en/posts/vllm-scheduler-step-maitre-d-each-round/">scheduler post&lt;/a>. Because vLLM V1 mixes requests in prefill and in decode in every step, one single forward has to serve both. The backend receives metadata telling it, for each sequence in the batch, how many queries it brings and how much KV it has to read, and applies the right path to each one. That is why the attention backend and the scheduler are coupled: the first has to digest the heterogeneous batch the second builds.&lt;/p>
&lt;h2 id="how-the-backend-reads-the-paged-kv">How the backend reads the paged KV&lt;/h2>
&lt;p>The kernel does not receive a contiguous KV tensor: it receives the &lt;strong>block table&lt;/strong> from the &lt;a href="https://blog.lo0.es/en/posts/pagedattention-vllm-block-manager/">block manager&lt;/a> and does a gather over the physical blocks. This imposes a real constraint on the backend: it has to support the paged &lt;em>layout&lt;/em> and vLLM&amp;rsquo;s &lt;code>block_size&lt;/code>. Not every kernel in the world does; the ones vLLM integrates (FlashAttention, FlashInfer, Triton) are adapted to read KV in fixed-size blocks scattered across HBM. That is why you cannot plug in any attention kernel from a paper: it has to speak the language of the pigeonhole larder.&lt;/p>
&lt;h2 id="the-backends-and-how-the-engine-chooses">The backends and how the engine chooses&lt;/h2>
&lt;p>vLLM exposes a &lt;strong>backend abstraction&lt;/strong> with several implementations (&lt;a href="https://deepwiki.com/vllm-project/vllm/8.2-flashattention-and-flashinfer">vLLM deepwiki&lt;/a>):&lt;/p>
&lt;ul>
&lt;li>&lt;strong>FLASH_ATTN&lt;/strong> — the &lt;a href="https://blog.lo0.es/en/posts/flashattention-librarian-io-awareness-async-blackwell-asymmetry/">FlashAttention&lt;/a> family. By default the version is picked by architecture: FA4 on SM100 (Blackwell), FA3 on SM90 (Hopper), FA2 elsewhere, configurable with &lt;code>flash_attn_version&lt;/code>.&lt;/li>
&lt;li>&lt;strong>FLASHINFER&lt;/strong> — an attention engine with &lt;strong>JIT&lt;/strong> compilation and specialisable &lt;em>kernels&lt;/em>; strong on heterogeneous KV and shared prefixes.&lt;/li>
&lt;li>&lt;strong>TRITON_ATTN&lt;/strong> — written in Triton, portable and with no dependency on precompiled CUDA binaries (&lt;a href="https://vllm.ai/blog/2026-03-04-vllm-triton-backend-deep-dive">Triton backend deep dive, vLLM, mar-2026&lt;/a>).&lt;/li>
&lt;li>Specific backends for &lt;strong>MLA&lt;/strong> and for non-NVIDIA hardware.&lt;/li>
&lt;/ul>
&lt;p>Selection is automatic unless you force it with &lt;code>VLLM_ATTENTION_BACKEND&lt;/code>. The heuristic tries FlashAttention first; on Blackwell (SM100) the fallback order for MLA is TRT-LLM Ragged → FlashInfer → others; on other GPUs only FlashAttention is considered for the main path (&lt;a href="https://docs.vllm.ai/en/latest/design/attention_backends/">attention backends, vLLM&lt;/a>). The decision depends on: architecture (SM), dtype (FP16/BF16/FP8), head dimension, and whether the load needs a feature only one backend has (cascade attention, certain &lt;em>soft caps&lt;/em>, FP8 in KV).&lt;/p>
&lt;svg viewBox="0 0 720 220" xmlns="http://www.w3.org/2000/svg" font-family="sans-serif" font-size="12" role="img" aria-label="Attention backend selection">
&lt;rect x="270" y="20" width="180" height="36" rx="6" fill="none" stroke="currentColor" stroke-width="1.5"/>
&lt;text x="360" y="43" text-anchor="middle" fill="currentColor">which architecture (SM)?&lt;/text>
&lt;rect x="40" y="100" width="160" height="36" rx="6" fill="none" stroke="#7c3aed" stroke-width="1.5"/>
&lt;text x="120" y="123" text-anchor="middle" fill="#7c3aed">SM100 Blackwell → FA4&lt;/text>
&lt;rect x="280" y="100" width="160" height="36" rx="6" fill="none" stroke="#2563eb" stroke-width="1.5"/>
&lt;text x="360" y="123" text-anchor="middle" fill="#2563eb">SM90 Hopper → FA3&lt;/text>
&lt;rect x="520" y="100" width="160" height="36" rx="6" fill="none" stroke="#16a34a" stroke-width="1.5"/>
&lt;text x="600" y="123" text-anchor="middle" fill="#16a34a">rest → FA2&lt;/text>
&lt;path d="M320 56 L120 100" stroke="currentColor" stroke-width="1.2" marker-end="url(#c)"/>
&lt;path d="M360 56 V100" stroke="currentColor" stroke-width="1.2" marker-end="url(#c)"/>
&lt;path d="M400 56 L600 100" stroke="currentColor" stroke-width="1.2" marker-end="url(#c)"/>
&lt;rect x="200" y="170" width="320" height="36" rx="6" fill="none" stroke="currentColor" stroke-width="1.5" stroke-dasharray="4 3"/>
&lt;text x="360" y="193" text-anchor="middle" fill="currentColor">special feature? (cascade, FP8 KV, MLA) → FlashInfer / specific&lt;/text>
&lt;path d="M360 136 V170" stroke="currentColor" stroke-width="1.2" marker-end="url(#c)"/>
&lt;defs>&lt;marker id="c" markerWidth="8" markerHeight="8" refX="7" refY="4" orient="auto">&lt;path d="M0 0 L8 4 L0 8 z" fill="currentColor"/>&lt;/marker>&lt;/defs>
&lt;/svg>
&lt;h2 id="what-flashinfer-brings-jit-and-cascade-attention">What FlashInfer brings: JIT and cascade attention&lt;/h2>
&lt;p>FlashInfer does not compete with FlashAttention on &amp;ldquo;being a bit faster&amp;rdquo;; it attacks a different problem: the &lt;strong>heterogeneity&lt;/strong> of the KV in real serving (&lt;a href="https://arxiv.org/abs/2501.01005">FlashInfer, arXiv 2501.01005&lt;/a>). Two ideas:&lt;/p>
&lt;p>&lt;strong>JIT compilation.&lt;/strong> Instead of a monolithic kernel, FlashInfer generates bespoke kernels for the attention variant, the problem shape and the KV layout you have, injecting &lt;em>functors&lt;/em> (query/key/logit transformations, masks). It specialises rather than generalises.&lt;/p>
&lt;p>&lt;strong>Cascade attention.&lt;/strong> Here is the jewel for serving with shared prefixes. If $R$ requests share a prefix of $P$ tokens (a common system prompt), naive attention would read that prefix $R$ times. Cascade attention computes it once against the shared prefix and then combines with each request&amp;rsquo;s own suffix:&lt;/p>
$$\text{reads: } \underbrace{R \cdot (P + s_i)}_{\text{naive}} \;\longrightarrow\; \underbrace{P + \textstyle\sum_i s_i}_{\text{cascade}}$$
&lt;p>With $R=50$ requests and a prefix $P=1000$, that is reading 50,000 prefix tokens versus 1,000. It is the &lt;strong>natural complement&lt;/strong> to &lt;a href="https://blog.lo0.es/en/posts/prefix-cache-hit-rate-engineering/">prefix caching&lt;/a>: the block manager shares the prefix&amp;rsquo;s &lt;em>memory&lt;/em>, and cascade attention shares the &lt;em>compute&lt;/em> of attending over it.&lt;/p>
&lt;h2 id="the-arithmetic-that-matters-when-switching-backend-gives-you-something">The arithmetic that matters: when switching backend gives you something&lt;/h2>
&lt;p>The backend only moves the needle where attention is the bottleneck. In memory-bound decode, a kernel that exploits HBM bandwidth better gives a real improvement; in compute-bound prefill with long sequences, FA3/FA4 getting close to the tensor-core peak gives a real improvement. But if your bottleneck is in another layer, &lt;a href="https://blog.lo0.es/en/posts/sms-cuda-streams-cuda-graphs-inference/">launch overhead&lt;/a>, a badly sized &lt;a href="https://blog.lo0.es/en/posts/vllm-scheduler-step-maitre-d-each-round/">scheduler&lt;/a>, the &lt;a href="https://blog.lo0.es/en/posts/pantry-lift-from-disk-to-hbm-why-the-kitchen-opens-late/">cold start&lt;/a>, then switching backend does not touch that part. The rule, once again: measure the regime before optimising.&lt;/p>
&lt;h2 id="the-10-knobs">The 10 knobs&lt;/h2>
&lt;table>
&lt;thead>
&lt;tr>
&lt;th>#&lt;/th>
&lt;th>Knob&lt;/th>
&lt;th>What it controls&lt;/th>
&lt;th>Cost / risk&lt;/th>
&lt;/tr>
&lt;/thead>
&lt;tbody>
&lt;tr>
&lt;td>1&lt;/td>
&lt;td>&lt;code>VLLM_ATTENTION_BACKEND&lt;/code>&lt;/td>
&lt;td>force a backend&lt;/td>
&lt;td>mismatch with hardware/feature&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>2&lt;/td>
&lt;td>&lt;code>flash_attn_version&lt;/code> (2/3/4)&lt;/td>
&lt;td>FA version&lt;/td>
&lt;td>version unsupported on your SM&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>3&lt;/td>
&lt;td>enable FlashInfer&lt;/td>
&lt;td>JIT + cascade&lt;/td>
&lt;td>initial JIT compilation time&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>4&lt;/td>
&lt;td>cascade attention&lt;/td>
&lt;td>prefix compute reuse&lt;/td>
&lt;td>only helps with a heavily shared prefix&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>5&lt;/td>
&lt;td>&lt;code>kv_cache_dtype&lt;/code> (FP8)&lt;/td>
&lt;td>FP8 support in the kernel&lt;/td>
&lt;td>not every backend/SM supports it&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>6&lt;/td>
&lt;td>&lt;code>block_size&lt;/code>&lt;/td>
&lt;td>layout the kernel must read&lt;/td>
&lt;td>consistency with PagedAttention&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>7&lt;/td>
&lt;td>MLA prefill backend&lt;/td>
&lt;td>kernel for the dense phase&lt;/td>
&lt;td>MLA models only&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>8&lt;/td>
&lt;td>MLA decode backend&lt;/td>
&lt;td>kernel for the thin phase&lt;/td>
&lt;td>MLA models only&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>9&lt;/td>
&lt;td>soft cap / sliding window&lt;/td>
&lt;td>features that restrict backends&lt;/td>
&lt;td>fewer kernel options&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>10&lt;/td>
&lt;td>head_dim / variant&lt;/td>
&lt;td>which kernels are eligible&lt;/td>
&lt;td>exotic models with no support&lt;/td>
&lt;/tr>
&lt;/tbody>
&lt;/table>
&lt;h2 id="how-it-connects-to-the-rest-of-the-stack">How it connects to the rest of the stack&lt;/h2>
&lt;p>&lt;strong>With FlashAttention.&lt;/strong> The &lt;a href="https://blog.lo0.es/en/posts/flashattention-librarian-io-awareness-async-blackwell-asymmetry/">FA post&lt;/a> explains the kernel from the inside (tiling, online softmax, FA1-4); this is the level above, how vLLM chooses between kernels and why it needs more than one.&lt;/p>
&lt;p>&lt;strong>With PagedAttention.&lt;/strong> The backend &lt;strong>reads&lt;/strong> the KV that the &lt;a href="https://blog.lo0.es/en/posts/pagedattention-vllm-block-manager/">block manager&lt;/a> places in blocks; it has to speak block-table.&lt;/p>
&lt;p>&lt;strong>With the scheduler.&lt;/strong> The &lt;a href="https://blog.lo0.es/en/posts/vllm-scheduler-step-maitre-d-each-round/">scheduler&lt;/a> builds mixed prefill+decode batches; the backend has to serve both regimes in a single forward.&lt;/p>
&lt;p>&lt;strong>With CUDA graphs.&lt;/strong> Attention kernels are captured in the &lt;a href="https://blog.lo0.es/en/posts/sms-cuda-streams-cuda-graphs-inference/">CUDA graphs&lt;/a>; a backend that launches many small kernels benefits more from capture.&lt;/p>
&lt;p>&lt;strong>With prefix caching.&lt;/strong> Cascade attention is the &lt;em>compute&lt;/em> side of what &lt;a href="https://blog.lo0.es/en/posts/prefix-cache-hit-rate-engineering/">prefix caching&lt;/a> does in &lt;em>memory&lt;/em>.&lt;/p>
&lt;p>&lt;strong>With FP8.&lt;/strong> Attending over KV in &lt;a href="https://blog.lo0.es/en/posts/fp8-end-to-end-enable-measure-quality-decide/">FP8&lt;/a> requires the backend to have the FP8 path; not all of them do on every architecture.&lt;/p>
&lt;h2 id="traps-and-things-that-are-not-what-they-look-like">Traps and things that are not what they look like&lt;/h2>
&lt;p>&lt;strong>&amp;ldquo;FlashInfer is always faster than FlashAttention.&amp;rdquo;&lt;/strong> No. FlashInfer wins when its specialisation (cascade, heterogeneous KV, a particular attention variant) applies to your load; in classic dense prefill, FA3/FA4 usually does as well or better. It depends on the regime, there is no universal winner.&lt;/p>
&lt;p>&lt;strong>&amp;ldquo;A good attention kernel works for everything.&amp;rdquo;&lt;/strong> The underlying error of this post. Prefill and decode are compute-bound and memory-bound respectively; a kernel tuned for one wastes effort on the other. That is why there are separate paths (and separate backends in MLA).&lt;/p>
&lt;p>&lt;strong>&amp;ldquo;Decode is compute-bound because the GPU is at 100%.&amp;rdquo;&lt;/strong> &lt;code>nvidia-smi&lt;/code> at 100% is misleading (&lt;a href="https://blog.lo0.es/en/posts/sms-cuda-streams-cuda-graphs-inference/">see the SM post&lt;/a>): decode is &lt;strong>memory-bound&lt;/strong>, the GPU is moving KV, not computing. Optimising decode&amp;rsquo;s compute is polishing what is not the bottleneck.&lt;/p>
&lt;p>&lt;strong>&amp;ldquo;I pin &lt;code>VLLM_ATTENTION_BACKEND&lt;/code> and forget about it.&amp;rdquo;&lt;/strong> Pinning a backend by hand can leave you on a suboptimal one when you change GPU or version, or force a slow fallback if your hardware does not support what you asked for. Autoselection usually gets it right; pin it only with a measurement that justifies it.&lt;/p>
&lt;p>&lt;strong>&amp;ldquo;Cascade attention always helps.&amp;rdquo;&lt;/strong> Only with a &lt;strong>heavily shared&lt;/strong> prefix across many concurrent requests. If each request has its own context, there is nothing to share and the overhead of organising the cascade is not amortised.&lt;/p>
&lt;p>&lt;strong>&amp;ldquo;The attention backend is the bottleneck, that is why I am slow.&amp;rdquo;&lt;/strong> Almost always the bottleneck is higher up (launch, scheduling, memory) or lower down (bandwidth). The backend matters where attention dominates; measure it with &lt;code>nsys&lt;/code>/DCGM before changing it.&lt;/p>
&lt;h2 id="conclusion">Conclusion&lt;/h2>
&lt;p>Of everything an LLM does while generating text, almost all of it is matrix multiplications that any library resolves. Performance is decided in a single kernel, attention, and the surprise is that it is not even &lt;em>one&lt;/em> kernel: it is two opposite problems wearing the same name. Prefill wants fire, dense compute over thousands of tokens, and decode wants a fast larder, reading a token&amp;rsquo;s entire KV with the minimum waste of bandwidth. That is why vLLM did not pick a winning kernel but an abstraction that switches: FlashAttention tuned to each architecture for the general case, FlashInfer compiling bespoke kernels when there is heterogeneity or prefixes to share, Triton for portability. The head chef does not cook the signature dish just one way: he looks at who is ordering and at what point of service, and sends out the right specialist. The lesson for whoever is tuning is the usual one in this series: before changing specialist, make sure the signature dish really is what is holding you back.&lt;/p>
&lt;h2 id="see-also">See also&lt;/h2>
&lt;ul>
&lt;li>
&lt;p>&lt;a href="https://blog.lo0.es/en/posts/multimodal-on-premise-serving-vlm-vllm/">Multimodal on-premise: serving a VLM with vLLM (vision + language)&lt;/a> — the token and KV-cache cost that images add to prefill in a VLM.&lt;/p>
&lt;/li>
&lt;li>
&lt;p>&lt;a href="https://blog.lo0.es/en/posts/flashattention-librarian-io-awareness-async-blackwell-asymmetry/">FlashAttention v1/v2/v3/v4&lt;/a> — the kernel from the inside; this post is the level above (how a kernel is chosen).&lt;/p>
&lt;/li>
&lt;li>
&lt;p>&lt;a href="https://blog.lo0.es/en/posts/pagedattention-vllm-block-manager/">PagedAttention and the block manager&lt;/a> — the paged KV the backend reads via the block table.&lt;/p>
&lt;/li>
&lt;li>
&lt;p>&lt;a href="https://blog.lo0.es/en/posts/vllm-scheduler-step-maitre-d-each-round/">The pass: vLLM&amp;rsquo;s scheduler step&lt;/a> — the mixed prefill+decode batch the backend digests in one forward.&lt;/p>
&lt;/li>
&lt;li>
&lt;p>&lt;a href="https://blog.lo0.es/en/posts/sms-cuda-streams-cuda-graphs-inference/">SMs, CUDA streams and CUDA graphs&lt;/a> — why &lt;code>nvidia-smi&lt;/code> at 100% does not mean compute-bound, and where attention kernels are captured.&lt;/p>
&lt;/li>
&lt;li>
&lt;p>&lt;a href="https://blog.lo0.es/en/posts/prefix-cache-hit-rate-engineering/">Prefix cache hit rate engineering&lt;/a> — the memory side of what cascade attention does in compute.&lt;/p>
&lt;/li>
&lt;li>
&lt;p>&lt;a href="https://blog.lo0.es/en/posts/fp8-end-to-end-enable-measure-quality-decide/">FP8 end-to-end: weights and KV&lt;/a> — the FP8 path the backend needs to support.&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> — why one forward has to serve prefill and decode at the same time.&lt;/p>
&lt;/li>
&lt;li>
&lt;p>&lt;a href="https://blog.lo0.es/en/posts/kv-cache-working-memory-llm-inference/">KV cache: the working memory&lt;/a> — the data the decode kernel reads in full on every step.&lt;/p>
&lt;/li>
&lt;/ul>
&lt;h2 id="references">References&lt;/h2>
&lt;ul>
&lt;li>vLLM, &lt;em>Attention Backends&lt;/em> (selección, FA2/3/4 por arquitectura, MLA): &lt;a href="https://docs.vllm.ai/en/latest/design/attention_backends/">https://docs.vllm.ai/en/latest/design/attention_backends/&lt;/a>.&lt;/li>
&lt;li>vLLM / DeepWiki, &lt;em>FlashAttention and FlashInfer&lt;/em>: &lt;a href="https://deepwiki.com/vllm-project/vllm/8.2-flashattention-and-flashinfer">https://deepwiki.com/vllm-project/vllm/8.2-flashattention-and-flashinfer&lt;/a>.&lt;/li>
&lt;li>vLLM, &lt;em>Triton Attention Backend Deep Dive&lt;/em> (mar-2026): &lt;a href="https://vllm.ai/blog/2026-03-04-vllm-triton-backend-deep-dive">https://vllm.ai/blog/2026-03-04-vllm-triton-backend-deep-dive&lt;/a>.&lt;/li>
&lt;li>Z. Ye et al., &lt;em>FlashInfer: Efficient and Customizable Attention Engine for LLM Inference Serving&lt;/em> (arXiv 2501.01005): &lt;a href="https://arxiv.org/abs/2501.01005">https://arxiv.org/abs/2501.01005&lt;/a>.&lt;/li>
&lt;li>T. Dao, &lt;em>FlashAttention-2&lt;/em> / &lt;em>FlashAttention-3&lt;/em> (kernel IO-aware, async Hopper): &lt;a href="https://github.com/Dao-AILab/flash-attention">https://github.com/Dao-AILab/flash-attention&lt;/a>.&lt;/li>
&lt;/ul></description></item><item><title>The pantry of pigeonholes: PagedAttention and the vLLM block manager</title><link>https://blog.lo0.es/en/posts/pagedattention-vllm-block-manager/</link><pubDate>Mon, 08 Jun 2026 05:20:00 +0200</pubDate><guid>https://blog.lo0.es/en/posts/pagedattention-vllm-block-manager/</guid><description>&lt;blockquote>
&lt;p>Part of the &lt;em>under the engine&lt;/em> series. The &lt;a href="https://blog.lo0.es/en/posts/vllm-scheduler-step-maitre-d-each-round/">scheduler post&lt;/a> ended with a loose end: the scheduler has a second budget, the &lt;strong>KV blocks&lt;/strong>, and when they run out it preempts. This post opens up that budget. It is the piece the &lt;a href="https://blog.lo0.es/en/posts/kv-cache-working-memory-llm-inference/">KV cache post&lt;/a> took for granted, &lt;em>what&lt;/em> gets stored, in order to explain &lt;em>how it is managed in memory&lt;/em>. And it is the one the &lt;a href="https://blog.lo0.es/en/posts/flashattention-librarian-io-awareness-async-blackwell-asymmetry/">FlashAttention post&lt;/a> had been promising for months.&lt;/p>
&lt;/blockquote>
&lt;h2 id="tldr">TL;DR&lt;/h2>
&lt;p>The &lt;a href="https://blog.lo0.es/en/posts/kv-cache-working-memory-llm-inference/">KV cache&lt;/a> grows a little with every token generated, and the problem was never its &lt;strong>total size&lt;/strong> but the &lt;strong>way it is reserved&lt;/strong>. The first servers asked, per request, for a contiguous chunk of HBM the size of the maximum possible context. Since almost no request reaches that maximum, the result was catastrophic: &lt;strong>60-80% of the HBM wasted&lt;/strong> on fragmentation. PagedAttention applies to the KV the oldest and most battle-tested idea in operating systems, &lt;strong>paging&lt;/strong>: split the KV into &lt;strong>fixed-size blocks&lt;/strong> (16 tokens by default), store them in non-contiguous HBM wherever there is room, and keep a &lt;strong>block table&lt;/strong> that translates each sequence&amp;rsquo;s &lt;em>logical&lt;/em> block into its &lt;em>physical&lt;/em> one. Waste falls to &lt;strong>~4%&lt;/strong> (only the last block, half-filled). And since each block can be identified by the &lt;strong>hash of its content&lt;/strong>, two requests that share a prefix point at the &lt;strong>same physical block&lt;/strong> and share memory, with &lt;strong>copy-on-write&lt;/strong> when one of them diverges: that is the engine of &lt;a href="https://blog.lo0.es/en/posts/prefix-cache-hit-rate-engineering/">prefix caching&lt;/a>. This post explains fragmentation with numbers, the block manager, the block table, COW, the block-size trade-off, the 10 knobs and the trap of confusing &lt;em>&amp;ldquo;fragmentation solved&amp;rdquo;&lt;/em> with &lt;em>&amp;ldquo;zero waste&amp;rdquo;&lt;/em>. On the generic 4×H100 SXM cluster.&lt;/p>
&lt;h2 id="where-you-are-the-pantry-underneath-the-scheduler">Where you are: the pantry, underneath the scheduler&lt;/h2>
&lt;p>Go back to the kitchen of the &lt;a href="https://blog.lo0.es/en/posts/vllm-scheduler-step-maitre-d-each-round/">previous post&lt;/a>. The front-of-house manager assembles trays, but behind him there is a pantry where the ingredients each table accumulates over its meal are stored, its KV cache. The question in this post is how that pantry is organised.&lt;/p>
&lt;p>The naive way: each table gets a whole contiguous shelf, sized for the greediest imaginable customer. The problem is obvious: a table that orders little leaves almost all of its shelf empty, but that shelf is already reserved and nobody else can use it. With many tables, the pantry fills up with half-empty shelves and no new table fits, even though the gaps added together leave plenty of room.&lt;/p>
&lt;p>The PagedAttention way: the pantry is divided into &lt;strong>small, identical pigeonholes&lt;/strong>. Each table is given the pigeonholes it needs, one at a time, wherever there is room, and they do not have to be next to each other. A &lt;strong>map book&lt;/strong> records which physical pigeonholes each table has and in what order. When a table leaves, its pigeonholes go back to the pile. There are no half-empty shelves: the only waste is the last pigeonhole of each table, the half-filled one. That is, almost literally, the virtual memory of an operating system applied to the KV cache.&lt;/p>
&lt;h2 id="why-contiguous-memory-fragmented">Why contiguous memory fragmented&lt;/h2>
&lt;p>Reserving contiguously and up front produces three distinct kinds of waste:&lt;/p>
&lt;ol>
&lt;li>&lt;strong>Reservation fragmentation.&lt;/strong> You set aside space for &lt;code>max_model_len&lt;/code> (8192 tokens, say) even though the request will use 800. Reserved and never used.&lt;/li>
&lt;li>&lt;strong>Internal fragmentation.&lt;/strong> Within what you reserved, everything above what you actually use at any given moment.&lt;/li>
&lt;li>&lt;strong>External fragmentation.&lt;/strong> Gaps between contiguous reservations that are too small for a new request, even though added together they would be plenty.&lt;/li>
&lt;/ol>
&lt;p>The original vLLM paper measured that earlier systems &lt;strong>wasted 60% to 80%&lt;/strong> of the KV memory through these three routes (&lt;a href="https://arxiv.org/pdf/2309.06180">Kwon et al., SOSP 2023&lt;/a>). That is: on a GPU with room for 100 real requests, only 20-40 fitted. Paging attacks all three at once. It eliminates reservation waste (on-demand allocation) and external waste (blocks need not be contiguous), and leaves only a remainder of the internal kind: the last partial block.&lt;/p>
&lt;h2 id="the-mechanism-blocks-block-table-and-the-kernel-gather">The mechanism: blocks, block table and the kernel gather&lt;/h2>
&lt;p>The KV of a sequence is chopped into &lt;strong>logical blocks&lt;/strong> of $b$ tokens (by default $b = 16$). Each logical block is mapped, via the &lt;strong>block table&lt;/strong>, to a &lt;strong>physical block&lt;/strong> somewhere in the HBM. The block table is the &amp;ldquo;map book&amp;rdquo;: a per-request list of which physical block corresponds to each logical one (&lt;a href="https://docs.vllm.ai/en/v0.6.1/automatic_prefix_caching/details.html">vLLM implementation&lt;/a>).&lt;/p>
&lt;svg viewBox="0 0 720 250" xmlns="http://www.w3.org/2000/svg" font-family="sans-serif" font-size="12" role="img" aria-label="Logical blocks, block table and non-contiguous physical blocks">
&lt;text x="20" y="20" fill="currentColor" font-size="13">Request A — logical blocks (sequence order)&lt;/text>
&lt;rect x="20" y="30" width="50" height="34" fill="none" stroke="#2563eb" stroke-width="1.5"/>&lt;text x="45" y="52" text-anchor="middle" fill="#2563eb">L0&lt;/text>
&lt;rect x="75" y="30" width="50" height="34" fill="none" stroke="#2563eb" stroke-width="1.5"/>&lt;text x="100" y="52" text-anchor="middle" fill="#2563eb">L1&lt;/text>
&lt;rect x="130" y="30" width="50" height="34" fill="none" stroke="#2563eb" stroke-width="1.5"/>&lt;text x="155" y="52" text-anchor="middle" fill="#2563eb">L2&lt;/text>
&lt;rect x="185" y="30" width="50" height="34" fill="none" stroke="#2563eb" stroke-width="1.5" stroke-dasharray="4 3"/>&lt;text x="210" y="52" text-anchor="middle" fill="#2563eb">L3*&lt;/text>
&lt;text x="20" y="100" fill="currentColor" font-size="13">block table: L0→F7 · L1→F2 · L2→F9 · L3→F4 (last one, half-filled)&lt;/text>
&lt;text x="20" y="135" fill="currentColor" font-size="13">Physical HBM — pigeonholes wherever there is room (non-contiguous)&lt;/text>
&lt;rect x="20" y="150" width="40" height="34" fill="none" stroke="currentColor"/>&lt;text x="40" y="171" text-anchor="middle" fill="currentColor" font-size="10">F0&lt;/text>
&lt;rect x="65" y="150" width="40" height="34" fill="#16a34a" opacity="0.7"/>&lt;text x="85" y="171" text-anchor="middle" fill="currentColor" font-size="10">F2·L1&lt;/text>
&lt;rect x="110" y="150" width="40" height="34" fill="none" stroke="currentColor"/>&lt;text x="130" y="171" text-anchor="middle" fill="currentColor" font-size="10">F3&lt;/text>
&lt;rect x="155" y="150" width="40" height="34" fill="#f59e0b" opacity="0.7"/>&lt;text x="175" y="171" text-anchor="middle" fill="currentColor" font-size="10">F4·L3&lt;/text>
&lt;rect x="200" y="150" width="40" height="34" fill="none" stroke="currentColor"/>&lt;text x="220" y="171" text-anchor="middle" fill="currentColor" font-size="10">F5&lt;/text>
&lt;rect x="290" y="150" width="40" height="34" fill="#16a34a" opacity="0.7"/>&lt;text x="310" y="171" text-anchor="middle" fill="currentColor" font-size="10">F7·L0&lt;/text>
&lt;rect x="380" y="150" width="40" height="34" fill="#16a34a" opacity="0.7"/>&lt;text x="400" y="171" text-anchor="middle" fill="currentColor" font-size="10">F9·L2&lt;/text>
&lt;text x="20" y="220" fill="currentColor" font-size="11">The attention kernel does a gather: it walks the block table and reads F7,F2,F9,F4 as if they were contiguous.&lt;/text>
&lt;text x="20" y="238" fill="#f59e0b" font-size="11">* Only F4 is half-filled: that is the only waste (≈ half a block per sequence).&lt;/text>
&lt;/svg>
&lt;p>The key point is that the attention kernel knows how to read like that. Instead of assuming a contiguous KV tensor, the PagedAttention kernel receives the block table and does a &lt;strong>gather&lt;/strong>: for each sequence it walks its physical blocks in logical order and reads K and V as if they were together. That is why PagedAttention is not just a data structure: it is a &lt;strong>kernel&lt;/strong> that knows how to attend over paged memory. And that is why the &lt;a href="https://blog.lo0.es/en/posts/flashattention-librarian-io-awareness-async-blackwell-asymmetry/">attention backend&lt;/a> and the block manager are tied together, the second deciding where the KV lives and the first knowing how to read it from there.&lt;/p>
&lt;h2 id="the-block-manager-the-pantry-librarian">The block manager: the pantry librarian&lt;/h2>
&lt;p>The &lt;strong>block manager&lt;/strong> (in V1, the &lt;code>KVCacheManager&lt;/code>) is the one that keeps the map book. Its responsibilities:&lt;/p>
&lt;ul>
&lt;li>Maintain a pool of free physical blocks (a queue of available blocks).&lt;/li>
&lt;li>Allocate blocks to a sequence as it grows (a new block every $b$ tokens).&lt;/li>
&lt;li>Free the blocks when the sequence finishes or is preempted.&lt;/li>
&lt;li>Maintain the block tables (logical→physical) of each request.&lt;/li>
&lt;li>Manage prefix caching: detect blocks with identical content and share them.&lt;/li>
&lt;li>When free blocks run out, tell the scheduler to preempt (see &lt;a href="https://blog.lo0.es/en/posts/vllm-scheduler-step-maitre-d-each-round/">the scheduler post&lt;/a>).&lt;/li>
&lt;/ul>
&lt;p>When the block manager says &amp;ldquo;no blocks left&amp;rdquo;, the scheduler has to take somebody off the train. That is why the two budgets, tokens and blocks, are the two hands of the same engine.&lt;/p>
&lt;h2 id="prefix-caching-sharing-pigeonholes-with-copy-on-write">Prefix caching: sharing pigeonholes with copy-on-write&lt;/h2>
&lt;p>Here is the elegant part. If two requests start with the same prefix, the same system prompt, the same context document, the first KV blocks of both are identical byte for byte. Why compute and store them twice?&lt;/p>
&lt;p>vLLM gives each block a &lt;strong>hash&lt;/strong> summarising its content (the tokens that formed it, plus the hash of the previous block, so that the hash captures position). It keeps a global table of blocks by hash. When a new request produces a block whose hash already exists, it allocates no new memory: it points its block table at the physical block that was already there (&lt;a href="https://docs.vllm.ai/en/v0.8.1/design/automatic_prefix_caching.html">automatic prefix caching, vLLM&lt;/a>).&lt;/p>
&lt;svg viewBox="0 0 720 200" xmlns="http://www.w3.org/2000/svg" font-family="sans-serif" font-size="12" role="img" aria-label="Prefix caching with copy-on-write">
&lt;text x="20" y="20" fill="currentColor" font-size="13">Common prefix (system prompt) shared between A and B&lt;/text>
&lt;rect x="20" y="35" width="55" height="34" fill="#16a34a" opacity="0.7"/>&lt;text x="47" y="56" text-anchor="middle" fill="currentColor" font-size="10">F1&lt;/text>
&lt;rect x="80" y="35" width="55" height="34" fill="#16a34a" opacity="0.7"/>&lt;text x="107" y="56" text-anchor="middle" fill="currentColor" font-size="10">F2&lt;/text>
&lt;text x="200" y="56" fill="currentColor">← A and B both point here (shared memory)&lt;/text>
&lt;path d="M47 90 V70" stroke="#2563eb" stroke-width="1.5" marker-end="url(#b)"/>&lt;text x="47" y="105" text-anchor="middle" fill="#2563eb" font-size="10">A&lt;/text>
&lt;path d="M107 90 V70" stroke="#7c3aed" stroke-width="1.5" marker-end="url(#b)"/>&lt;text x="120" y="105" text-anchor="middle" fill="#7c3aed" font-size="10">B&lt;/text>
&lt;text x="20" y="140" fill="currentColor" font-size="13">A and B diverge → copy-on-write: B copies the block before writing&lt;/text>
&lt;rect x="20" y="150" width="55" height="30" fill="#2563eb" opacity="0.7"/>&lt;text x="47" y="169" text-anchor="middle" fill="currentColor" font-size="10">F3·A&lt;/text>
&lt;rect x="90" y="150" width="55" height="30" fill="#7c3aed" opacity="0.7"/>&lt;text x="117" y="169" text-anchor="middle" fill="currentColor" font-size="10">F8·B (copy)&lt;/text>
&lt;defs>&lt;marker id="b" markerWidth="8" markerHeight="8" refX="7" refY="4" orient="auto">&lt;path d="M0 0 L8 4 L0 8 z" fill="currentColor"/>&lt;/marker>&lt;/defs>
&lt;/svg>
&lt;p>&lt;strong>Copy-on-write&lt;/strong> is the safeguard: while A and B share a block, neither can modify it. The moment one of the two needs to write something different into that block (because their sequences diverge, or in &lt;em>parallel sampling&lt;/em> / beam search where several branches share a prefix), the block manager copies the block for that branch and only then writes (&lt;a href="https://docs.vllm.ai/en/v0.6.1/automatic_prefix_caching/details.html">details, vLLM&lt;/a>). It is the same COW that &lt;code>fork()&lt;/code> uses in an OS: share until somebody writes.&lt;/p>
&lt;p>The saving is direct: if 50 requests share a system prompt of 1,000 tokens, instead of 50 copies of that prefix&amp;rsquo;s KV there is one. How to maximise that saving in practice is the subject of the &lt;a href="https://blog.lo0.es/en/posts/prefix-cache-hit-rate-engineering/">prefix cache hit rate post&lt;/a>.&lt;/p>
&lt;h2 id="the-maths-that-matter-how-much-kv-how-many-blocks">The maths that matter: how much KV, how many blocks&lt;/h2>
&lt;p>&lt;strong>KV bytes per token.&lt;/strong> For a transformer block with $L$ layers, $h_{kv}$ KV heads (GQA), per-head dimension $d$ and $s$ bytes per element (2 in FP16):&lt;/p>
$$\text{KV/token} = 2 \cdot L \cdot h_{kv} \cdot d \cdot s$$
&lt;p>For a Llama-70B ($L=80$, $h_{kv}=8$, $d=128$, FP16):&lt;/p>
$$\text{KV/token} = 2 \cdot 80 \cdot 8 \cdot 128 \cdot 2 = 327680 \text{ bytes} \approx 320 \text{ KB}$$
&lt;p>A block of 16 tokens takes up $16 \times 320\,\text{KB} = 5.12$ MB.&lt;/p>
&lt;p>&lt;strong>How many requests fit.&lt;/strong> If after loading the weights ~120 GB of the node&amp;rsquo;s 320 are left for KV:&lt;/p>
$$\text{KV tokens} = \frac{120 \cdot 10^9}{327680} \approx 366000 \text{ tokens} \approx 22900 \text{ blocks}$$
&lt;p>With average contexts of 4,000 tokens (250 blocks each), that is ~90 concurrent requests. That number, not &lt;code>max_num_seqs&lt;/code>, is the real concurrency ceiling, and it is exactly the &amp;ldquo;block budget&amp;rdquo; of the &lt;a href="https://blog.lo0.es/en/posts/vllm-scheduler-step-maitre-d-each-round/">scheduler&lt;/a>.&lt;/p>
&lt;p>&lt;strong>The waste that remains.&lt;/strong> PagedAttention does not get to zero: each sequence wastes, on average, half a block (the last one, half-filled). With 16-token blocks and sequences of 4,000, that is $8 / 4000 = 0.2\%$ per sequence, and the famous aggregate &amp;ldquo;~4%&amp;rdquo; from the paper includes other overheads. The lesson: the waste does not disappear, it is &lt;strong>bounded&lt;/strong> to the size of one block.&lt;/p>
&lt;h2 id="the-block-size-trade-off">The block-size trade-off&lt;/h2>
&lt;p>The &lt;code>block_size&lt;/code> (16 by default) is a trade-off, not a magic constant:&lt;/p>
&lt;table>
&lt;thead>
&lt;tr>
&lt;th>Block&lt;/th>
&lt;th>Advantage&lt;/th>
&lt;th>Drawback&lt;/th>
&lt;/tr>
&lt;/thead>
&lt;tbody>
&lt;tr>
&lt;td>Small (8)&lt;/td>
&lt;td>less internal waste; finer-grained prefix sharing&lt;/td>
&lt;td>more block table entries; more management and gather overhead&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>Large (32)&lt;/td>
&lt;td>fewer metadata; more efficient gather&lt;/td>
&lt;td>more waste in the last block; prefix caching shares at a coarser grain (fewer hits)&lt;/td>
&lt;/tr>
&lt;/tbody>
&lt;/table>
&lt;p>A large block shares worse: prefix caching can only reuse complete and identical blocks, so with 32-token blocks two prompts that agree on 20 tokens share nothing (they do not fill a common block), whereas with 8-token blocks they share two blocks. The default of 16 is the point vLLM found reasonable for most workloads; it is worth testing if your workload has short, heavily repeated prefixes.&lt;/p>
&lt;h2 id="the-10-knobs">The 10 knobs&lt;/h2>
&lt;table>
&lt;thead>
&lt;tr>
&lt;th>#&lt;/th>
&lt;th>Knob&lt;/th>
&lt;th>What it controls&lt;/th>
&lt;th>Cost if you overdo it&lt;/th>
&lt;/tr>
&lt;/thead>
&lt;tbody>
&lt;tr>
&lt;td>1&lt;/td>
&lt;td>&lt;code>block_size&lt;/code>&lt;/td>
&lt;td>tokens per block&lt;/td>
&lt;td>waste / overhead (see table)&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>2&lt;/td>
&lt;td>&lt;code>enable_prefix_caching&lt;/code>&lt;/td>
&lt;td>sharing blocks by hash&lt;/td>
&lt;td>almost none; usually on&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>3&lt;/td>
&lt;td>&lt;code>gpu_memory_utilization&lt;/code>&lt;/td>
&lt;td>how many physical blocks there are&lt;/td>
&lt;td>OOM if too high&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>4&lt;/td>
&lt;td>&lt;code>kv_cache_dtype&lt;/code> (FP8)&lt;/td>
&lt;td>bytes per KV element&lt;/td>
&lt;td>quality (measure, do not assume)&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>5&lt;/td>
&lt;td>&lt;code>swap_space&lt;/code>&lt;/td>
&lt;td>blocks that fit on the host (SWAP)&lt;/td>
&lt;td>PCIe traffic on preemption&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>6&lt;/td>
&lt;td>&lt;code>max_model_len&lt;/code>&lt;/td>
&lt;td>maximum length per request&lt;/td>
&lt;td>fewer requests if too high&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>7&lt;/td>
&lt;td>eviction policy&lt;/td>
&lt;td>who gets blocks taken away&lt;/td>
&lt;td>prefix cache hit rate&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>8&lt;/td>
&lt;td>sliding window&lt;/td>
&lt;td>discarding old KV&lt;/td>
&lt;td>quality on long contexts&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>9&lt;/td>
&lt;td>TP / KV sharding&lt;/td>
&lt;td>splitting the KV across GPUs&lt;/td>
&lt;td>NVLink traffic&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>10&lt;/td>
&lt;td>num_gpu_blocks (override)&lt;/td>
&lt;td>forcing the block count&lt;/td>
&lt;td>OOM or underuse&lt;/td>
&lt;/tr>
&lt;/tbody>
&lt;/table>
&lt;h2 id="how-it-connects-to-the-rest-of-the-stack">How it connects to the rest of the stack&lt;/h2>
&lt;p>&lt;strong>With the scheduler.&lt;/strong> The &amp;ldquo;block budget&amp;rdquo; of the &lt;a href="https://blog.lo0.es/en/posts/vllm-scheduler-step-maitre-d-each-round/">scheduler&lt;/a> is administered by this block manager. When it says there are no blocks, the scheduler preempts (RECOMPUTE by default).&lt;/p>
&lt;p>&lt;strong>With the KV cache.&lt;/strong> The &lt;a href="https://blog.lo0.es/en/posts/kv-cache-working-memory-llm-inference/">KV cache post&lt;/a> explains &lt;em>what&lt;/em> each token stores; this one, &lt;em>how&lt;/em> it is placed in memory without fragmenting.&lt;/p>
&lt;p>&lt;strong>With prefix caching.&lt;/strong> COW and block hashes are the mechanism; &lt;a href="https://blog.lo0.es/en/posts/prefix-cache-hit-rate-engineering/">hit rate engineering&lt;/a> is how to squeeze it (prompt structure, prefix-aware routing).&lt;/p>
&lt;p>&lt;strong>With KV quantisation.&lt;/strong> Moving the KV to &lt;a href="https://blog.lo0.es/en/posts/fp8-end-to-end-enable-measure-quality-decide/">FP8&lt;/a> halves the bytes/token: the same node holds twice the tokens. It is the most direct lever on concurrency.&lt;/p>
&lt;p>&lt;strong>With the attention backend.&lt;/strong> The &lt;a href="https://blog.lo0.es/en/posts/flashattention-librarian-io-awareness-async-blackwell-asymmetry/">FlashAttention/FlashInfer&lt;/a> kernel has to know how to attend over paged blocks; the block manager decides where they live, the kernel knows how to read them.&lt;/p>
&lt;p>&lt;strong>With disaggregated serving.&lt;/strong> Moving a request from a prefill pool to a decode pool in &lt;a href="https://blog.lo0.es/en/posts/disaggregated-serving-prefill-decode-specialised-pods/">disaggregated serving&lt;/a> is, at bottom, &lt;strong>transferring its KV blocks&lt;/strong> between engines, over NVLink or over the network.&lt;/p>
&lt;p>&lt;strong>With multi-LoRA.&lt;/strong> In &lt;a href="https://blog.lo0.es/en/posts/multi-lora-serving-shared-base-concurrent-adapters-sgmv-kernel/">multi-LoRA serving&lt;/a>, the base shares prefix KV between requests from different adapters as long as the prefix is identical.&lt;/p>
&lt;h2 id="traps-and-things-that-are-not-what-they-seem">Traps and things that are not what they seem&lt;/h2>
&lt;p>&lt;strong>&amp;ldquo;PagedAttention eliminates waste.&amp;rdquo;&lt;/strong> It &lt;strong>bounds&lt;/strong> it, it does not eliminate it. What remains is the last partial block per sequence (~half a block) plus the block table metadata. It is ~4% instead of 60-80%, but it is not zero. Sizing as if it were zero leaves you without a cushion.&lt;/p>
&lt;p>&lt;strong>&amp;ldquo;Bigger blocks always perform better.&amp;rdquo;&lt;/strong> The gather is somewhat more efficient, yes, but you lose &lt;em>sharing&lt;/em> granularity: prefix caching shares worse and the waste in the last block grows. On workloads with many short repeated prefixes, small blocks can win.&lt;/p>
&lt;p>&lt;strong>&amp;ldquo;Prefix caching shares KV between users, that is a privacy problem.&amp;rdquo;&lt;/strong> It shares only blocks that are &lt;strong>identical token by token&lt;/strong> (same system prompt, same document). It does not expose one user&amp;rsquo;s content to another: if the tokens do not match, there is no common block. What is worth watching is &lt;strong>information leaking through timing side channels&lt;/strong> (a hit is faster than a miss), relevant only in very adversarial multi-tenant scenarios.&lt;/p>
&lt;p>&lt;strong>&amp;ldquo;FP8 in the KV is free: twice the concurrency.&amp;rdquo;&lt;/strong> It does double the tokens that fit, yes, but KV in FP8 &lt;strong>degrades quality&lt;/strong> measurably on long contexts. It is a real lever, not a free lunch: quality has to be measured (&lt;a href="https://blog.lo0.es/en/posts/fp8-end-to-end-enable-measure-quality-decide/">FP8 end-to-end&lt;/a>), not assumed.&lt;/p>
&lt;p>&lt;strong>&amp;ldquo;Going back to contiguous memory would be simpler and almost as good.&amp;rdquo;&lt;/strong> That is nostalgia for the contiguous tensor. The &amp;ldquo;simple&amp;rdquo; option reintroduces 60-80% fragmentation: on a GPU, that is the difference between 30 and 90 concurrent requests. The complexity of the block table pays for itself many times over.&lt;/p>
&lt;p>&lt;strong>SWAP versus RECOMPUTE when preempting.&lt;/strong> Configuring a lot of &lt;code>swap_space&lt;/code> &amp;ldquo;so as not to lose KV&amp;rdquo; puts gigabyte transfers over &lt;a href="https://blog.lo0.es/en/posts/pcie-topology-gpudirect-p2p-acs-security-guard/">PCIe&lt;/a> on the critical path. In V1, RECOMPUTE is usually better; swap is for specific cases.&lt;/p>
&lt;h2 id="conclusion">Conclusion&lt;/h2>
&lt;p>The bottleneck in serving an LLM was never only how much memory you have, but &lt;strong>how you hand it out&lt;/strong>. The first servers treated the KV cache as a contiguous shelf per client and threw two thirds of the HBM in the bin without it showing up on any dashboard. PagedAttention stole from the operating system its best idea of fifty years ago, paging, and applied it at the exact place where it hurt: small pigeonholes, a map book, on-demand allocation and, as a bonus, the possibility that two requests starting the same way share the same pigeonholes until they stop resembling each other. The result is not magic: the waste is still there, but bounded to the size of one block instead of to the size of the worst imaginable case. And that difference, from 70% to 4%, is what turned a GPU serving thirty clients into one serving ninety, without touching the hardware. The pantry did not get bigger; it got better organised.&lt;/p>
&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 block budget this block manager administers; when it runs out, preemption.&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> — &lt;em>what&lt;/em> each token stores, the data that gets paged here.&lt;/li>
&lt;li>&lt;a href="https://blog.lo0.es/en/posts/prefix-cache-hit-rate-engineering/">Prefix cache hit rate engineering&lt;/a> — how to squeeze the block sharing that COW makes possible.&lt;/li>
&lt;li>&lt;a href="https://blog.lo0.es/en/posts/flashattention-librarian-io-awareness-async-blackwell-asymmetry/">FlashAttention v1/v2/v3/v4&lt;/a> — the kernel that knows how to attend over paged KV.&lt;/li>
&lt;li>&lt;a href="https://blog.lo0.es/en/posts/fp8-end-to-end-enable-measure-quality-decide/">FP8 end-to-end: weights and KV&lt;/a> — halving the bytes/token and doubling concurrency, while measuring quality.&lt;/li>
&lt;li>&lt;a href="https://blog.lo0.es/en/posts/disaggregated-serving-prefill-decode-specialised-pods/">Disaggregated serving: prefill and decode separated&lt;/a> — moving a request between pools is transferring its KV blocks.&lt;/li>
&lt;li>&lt;a href="https://blog.lo0.es/en/posts/pcie-topology-gpudirect-p2p-acs-security-guard/">PCIe, GPUDirect P2P and ACS&lt;/a> — where the blocks travel when SWAP happens or KV moves between GPUs.&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> — sharing a prefix between requests from different adapters.&lt;/li>
&lt;/ul>
&lt;h2 id="references">References&lt;/h2>
&lt;ul>
&lt;li>W. Kwon et al., &lt;em>Efficient Memory Management for Large Language Model Serving with PagedAttention&lt;/em> (SOSP 2023): &lt;a href="https://arxiv.org/pdf/2309.06180">https://arxiv.org/pdf/2309.06180&lt;/a>.&lt;/li>
&lt;li>vLLM, &lt;em>Automatic Prefix Caching&lt;/em> (design, block hashing): &lt;a href="https://docs.vllm.ai/en/v0.8.1/design/automatic_prefix_caching.html">https://docs.vllm.ai/en/v0.8.1/design/automatic_prefix_caching.html&lt;/a>.&lt;/li>
&lt;li>vLLM, &lt;em>Automatic Prefix Caching — Implementation&lt;/em> (block table, COW): &lt;a href="https://docs.vllm.ai/en/v0.6.1/automatic_prefix_caching/details.html">https://docs.vllm.ai/en/v0.6.1/automatic_prefix_caching/details.html&lt;/a>.&lt;/li>
&lt;li>H. Elshafie, &lt;em>Paged Attention from First Principles: A View Inside vLLM&lt;/em>: &lt;a href="https://hamzaelshafie.bearblog.dev/paged-attention-from-first-principles-a-view-inside-vllm/">https://hamzaelshafie.bearblog.dev/paged-attention-from-first-principles-a-view-inside-vllm/&lt;/a>.&lt;/li>
&lt;li>&lt;em>vAttention: Dynamic Memory Management for Serving LLMs without PagedAttention&lt;/em> (alternative, critical context): &lt;a href="https://arxiv.org/pdf/2405.04437">https://arxiv.org/pdf/2405.04437&lt;/a>.&lt;/li>
&lt;/ul></description></item><item><title>The pass: the maître d' who builds every round — vLLM's scheduler step</title><link>https://blog.lo0.es/en/posts/vllm-scheduler-step-maitre-d-each-round/</link><pubDate>Mon, 08 Jun 2026 05:00:00 +0200</pubDate><guid>https://blog.lo0.es/en/posts/vllm-scheduler-step-maitre-d-each-round/</guid><description>&lt;blockquote>
&lt;p>Part of the &lt;em>under the engine&lt;/em> series. Earlier posts looked at the silicon that runs the kernels (&lt;a href="https://blog.lo0.es/en/posts/sms-cuda-streams-cuda-graphs-inference/">SMs and CUDA graphs&lt;/a>) and at loading the weights (&lt;a href="https://blog.lo0.es/en/posts/pantry-lift-from-disk-to-hbm-why-the-kitchen-opens-late/">from disk to HBM&lt;/a>). This one goes up a floor: &lt;em>who decides&lt;/em> what runs in each forward. Before the GPU launches a single kernel, someone has had to build this round&amp;rsquo;s order. That someone is the &lt;strong>scheduler&lt;/strong>, and it is the heart of &lt;a href="https://blog.lo0.es/en/posts/continuous-batching-orca-vllm-chunked-prefill-goodput/">continuous batching&lt;/a>.&lt;/p>
&lt;/blockquote>
&lt;h2 id="tldr">TL;DR&lt;/h2>
&lt;p>An LLM server does not serve one whole request and then the next: it advances every live request at once, a little on each engine iteration. The piece that decides &lt;em>how far&lt;/em> each one advances in each step is the &lt;strong>scheduler&lt;/strong>, and its output is deceptively simple: a dictionary &lt;code>{req_id: number of tokens}&lt;/code> that the model runner turns into a single forward on the GPU. vLLM V1&amp;rsquo;s most important decision was to &lt;strong>erase the distinction between prefill and decode&lt;/strong>: for the scheduler, a prompt token and a freshly generated token are the same thing, tokens that need processing, and that is why it can put a 4000-token prompt in the same batch as 200 one-token decodes. There are four pieces: the &lt;strong>token budget&lt;/strong> (&lt;code>max_num_batched_tokens&lt;/code>), a fixed-size tray filled every round; &lt;strong>chunked prefill&lt;/strong>, which splits a huge prompt into chunks so it does not hog the tray and blow up everyone else&amp;rsquo;s latency; the two &lt;strong>queues&lt;/strong> (waiting, in arrival order, and running); and &lt;strong>preemption&lt;/strong>, because when the KV cache runs out somebody has to get off the train. This post explains the loop, the arithmetic of the budget and of concurrency, the 10 knobs and the headline trap: raising the budget improves &lt;em>throughput&lt;/em> but worsens &lt;em>ITL&lt;/em> (inter-token latency), and almost nobody measures both at once. On the generic 4×H100 SXM cluster.&lt;/p>
&lt;h2 id="where-you-are-the-pass-before-the-kitchen-starts">Where you are: the pass, before the kitchen starts&lt;/h2>
&lt;p>Picture the pass of a restaurant with a busy dining room. There is no one cook per customer; there is a shared kitchen and a &lt;strong>maître d&amp;rsquo;&lt;/strong> who, every few seconds, looks at all the open orders and builds a &lt;strong>tray&lt;/strong> to send to the stoves. That tray holds, say, 8000 &amp;ldquo;units of work&amp;rdquo;. The maître d&amp;rsquo; decides what goes in: tables already eating (requests in &lt;em>decode&lt;/em>) get one more dish each; new tables that have just ordered (requests in &lt;em>prefill&lt;/em>, with their whole prompt still to process) get as much of their order as fits in whatever is left of the tray. The tray goes out, the kitchen executes it in one go, and it starts again. Hundreds of times per second.&lt;/p>
&lt;p>That maître d&amp;rsquo; is the &lt;strong>scheduler&lt;/strong>. The kitchen is the GPU running a &lt;em>forward pass&lt;/em>. And the house rule is that the kitchen never stops to wait for a single table: if a new order is enormous (a 30,000-token prompt), it is not sent whole in one go blocking everyone else, it is sent in chunks. That is, in one sentence, the whole mechanics of vLLM&amp;rsquo;s scheduler.&lt;/p>
&lt;h2 id="the-engine-loop-one-dictionary-per-iteration">The engine loop: one dictionary per iteration&lt;/h2>
&lt;p>The inference engine is a very short loop. On each pass:&lt;/p>
&lt;ol>
&lt;li>The &lt;strong>scheduler&lt;/strong> looks at the queues and produces a decision.&lt;/li>
&lt;li>The &lt;strong>model runner&lt;/strong> runs a forward with that batch on the GPU.&lt;/li>
&lt;li>The &lt;strong>sampler&lt;/strong> draws one new token for each active sequence.&lt;/li>
&lt;li>State is updated (KV cache, positions, finished requests) and it goes back to 1.&lt;/li>
&lt;/ol>
&lt;p>The surprising part is the shape of the decision in step 1. In vLLM V1 it is not a complex structure with phases: it is literally a dictionary&lt;/p>
$$\text{schedule} = \{\, \text{req id} \rightarrow n_\text{tokens} \,\}$$
&lt;p>that says, for each request entering this round, how many tokens of it get processed. For a request that is generating text, &lt;code>n_tokens = 1&lt;/code> (one autoregressive step). For a new request, &lt;code>n_tokens&lt;/code> can be the entire length of its prompt. And it can be anything in between, a &lt;em>chunk&lt;/em> of prompt, under chunked prefill, prefix caching or speculative decoding (&lt;a href="https://docs.vllm.ai/en/v0.9.2/usage/v1_guide.html">vLLM V1 docs&lt;/a>).&lt;/p>
&lt;svg viewBox="0 0 720 250" xmlns="http://www.w3.org/2000/svg" font-family="sans-serif" font-size="13" role="img" aria-label="Inference engine loop with the scheduler">
&lt;rect x="20" y="100" width="120" height="50" rx="6" fill="none" stroke="currentColor" stroke-width="1.5"/>
&lt;text x="80" y="122" text-anchor="middle" fill="currentColor">Scheduler&lt;/text>
&lt;text x="80" y="140" text-anchor="middle" fill="#2563eb" font-size="11">{req_id: n_tokens}&lt;/text>
&lt;rect x="200" y="100" width="120" height="50" rx="6" fill="none" stroke="currentColor" stroke-width="1.5"/>
&lt;text x="260" y="122" text-anchor="middle" fill="currentColor">Model runner&lt;/text>
&lt;text x="260" y="140" text-anchor="middle" fill="#16a34a" font-size="11">1 forward (GPU)&lt;/text>
&lt;rect x="380" y="100" width="120" height="50" rx="6" fill="none" stroke="currentColor" stroke-width="1.5"/>
&lt;text x="440" y="122" text-anchor="middle" fill="currentColor">Sampler&lt;/text>
&lt;text x="440" y="140" text-anchor="middle" fill="#16a34a" font-size="11">+1 token / seq&lt;/text>
&lt;rect x="560" y="100" width="130" height="50" rx="6" fill="none" stroke="currentColor" stroke-width="1.5"/>
&lt;text x="625" y="122" text-anchor="middle" fill="currentColor">Update&lt;/text>
&lt;text x="625" y="140" text-anchor="middle" fill="currentColor" font-size="11">KV, positions&lt;/text>
&lt;path d="M140 125 H200" stroke="currentColor" stroke-width="1.5" marker-end="url(#a)"/>
&lt;path d="M320 125 H380" stroke="currentColor" stroke-width="1.5" marker-end="url(#a)"/>
&lt;path d="M500 125 H560" stroke="currentColor" stroke-width="1.5" marker-end="url(#a)"/>
&lt;path d="M625 100 V60 H80 V100" stroke="currentColor" stroke-width="1.5" fill="none" marker-end="url(#a)"/>
&lt;text x="350" y="48" text-anchor="middle" fill="currentColor" font-size="11">one iteration = one step (ms order on H100)&lt;/text>
&lt;defs>&lt;marker id="a" markerWidth="8" markerHeight="8" refX="7" refY="4" orient="auto">&lt;path d="M0 0 L8 4 L0 8 z" fill="currentColor"/>&lt;/marker>&lt;/defs>
&lt;/svg>
&lt;p>That the decision fits in a &lt;code>{id: number}&lt;/code> dictionary is not a neat implementation detail: it is &lt;strong>why continuous batching works&lt;/strong>. Because the scheduler does not think in &amp;ldquo;phases&amp;rdquo; but in &amp;ldquo;how many tokens for each&amp;rdquo;, it can mix requests at any point of their life in the same forward. The GPU receives a single heterogeneous token tensor and processes it in one go.&lt;/p>
&lt;h2 id="the-death-of-the-prefilldecode-distinction">The death of the prefill/decode distinction&lt;/h2>
&lt;p>This is the idea that costs the most effort and matters the most. In the first LLM server architectures, a request lived in &lt;strong>two separate phases&lt;/strong>: first &lt;em>prefill&lt;/em> (process the whole prompt and fill the &lt;a href="https://blog.lo0.es/en/posts/kv-cache-working-memory-llm-inference/">KV cache&lt;/a>), then &lt;em>decode&lt;/em> (generate token by token). The scheduler had to choreograph the move from one phase to the other, and mixing them was hard.&lt;/p>
&lt;p>vLLM V1 &lt;strong>removed the distinction&lt;/strong> (&lt;a href="https://openlm.ai/vllm-v1/">V1 design&lt;/a>). The scheduler treats prompt tokens and generated tokens uniformly: they are all tokens the model has to process in a forward. The practical consequence is enormous. A 4000-token prompt and a sequence that has generated 800 tokens and needs one more are, to the scheduler, &amp;ldquo;4000 tokens of request A&amp;rdquo; and &amp;ldquo;1 token of request B&amp;rdquo;. They fit together on the same tray. There is no phase choreography, just a budget to hand out.&lt;/p>
&lt;p>This unlocks the pattern that really pays: &lt;strong>mixing prefill and decode in every step&lt;/strong>. Prefill is &lt;em>compute-bound&lt;/em> work (a lot of matrix to multiply); decode is &lt;em>memory-bound&lt;/em> (little compute, a lot of KV to move). Mixing them in the same batch fills the gaps: while the GPU is busy with the heavy prefill, it advances the light decodes &amp;ldquo;for free&amp;rdquo;. It is the same efficiency principle as &lt;a href="https://blog.lo0.es/en/posts/continuous-batching-orca-vllm-chunked-prefill-goodput/">continuous batching&lt;/a>, taken to its cleanest form.&lt;/p>
&lt;h2 id="the-token-budget-the-fixed-size-tray">The token budget: the fixed-size tray&lt;/h2>
&lt;p>The tray has a size: &lt;code>max_num_batched_tokens&lt;/code>. It is the maximum number of tokens the scheduler can put in a single step (&lt;a href="https://docs.vllm.ai/en/stable/configuration/optimization/">vLLM optimisation&lt;/a>). The policy, with chunked prefill on (it always is in V1), is clear:&lt;/p>
&lt;ol>
&lt;li>&lt;strong>Decodes first.&lt;/strong> Room is reserved for one token per request in the &lt;em>running&lt;/em> queue. They are cheap, and they are diners already eating: they are not left waiting.&lt;/li>
&lt;li>&lt;strong>What is left goes to prefills.&lt;/strong> With the remaining budget, prompt tokens from &lt;em>waiting&lt;/em> requests are added, in arrival order (FCFS), split into chunks if needed.&lt;/li>
&lt;/ol>
&lt;p>An example with numbers. Budget &lt;code>max_num_batched_tokens = 8192&lt;/code>, and at this instant there are 200 requests generating:&lt;/p>
$$\text{decode} = 200 \times 1 = 200 \text{ tokens}$$
$$\text{free budget} = 8192 - 200 = 7992 \text{ tokens for prefill}$$
&lt;p>If a new request arrives with a 4000-token prompt, it fits whole in this step (4000 &amp;lt; 7992) and 3992 are left for another. If one arrives with 30,000 tokens, it does not fit: the scheduler sends it a 7992-token chunk this step, and the remaining 22,008 in later steps. That is chunked prefill.&lt;/p>
&lt;svg viewBox="0 0 720 170" xmlns="http://www.w3.org/2000/svg" font-family="sans-serif" font-size="12" role="img" aria-label="Token budget split within a step">
&lt;text x="20" y="24" fill="currentColor" font-size="13">Tray: max_num_batched_tokens = 8192 tokens/step&lt;/text>
&lt;rect x="20" y="40" width="680" height="40" fill="none" stroke="currentColor" stroke-width="1.5"/>
&lt;rect x="20" y="40" width="20" height="40" fill="#2563eb"/>
&lt;rect x="40" y="40" width="660" height="40" fill="#16a34a" opacity="0.75"/>
&lt;text x="30" y="100" text-anchor="middle" fill="#2563eb" font-size="10">200 decode&lt;/text>
&lt;text x="370" y="100" text-anchor="middle" fill="#16a34a" font-size="11">7992 for prefill (a 4000-token prompt fits whole; a 30000-token one goes in chunks)&lt;/text>
&lt;text x="20" y="140" fill="currentColor">Decode first (diners eating) · prefill fills the rest (FCFS) · if it does not fit, it is chunked&lt;/text>
&lt;/svg>
&lt;p>The budget matters because it fixes how many forwards are needed to swallow a prompt. A 30,000-token prompt with an 8192 tray takes 4 steps in prefill alone before it emits its first token. With a 2048 tray it takes 15 steps, but each of those steps leaves more room for other people&amp;rsquo;s decodes, so the other diners feel the jam less.&lt;/p>
&lt;h2 id="the-two-queues-and-preemption-when-someone-gets-off-the-train">The two queues and preemption: when someone gets off the train&lt;/h2>
&lt;p>The scheduler handles two queues. The &lt;strong>waiting&lt;/strong> queue holds requests that have not started yet (their prompt has not been processed), served in arrival order, FCFS by default, though there is a &lt;code>priority&lt;/code> policy. The &lt;strong>running&lt;/strong> queue holds those already alive and generating (&lt;a href="https://audreywongkg.medium.com/understanding-vllm-scheduling-token-budgets-chunked-prefill-and-policies-2c879e3980e3">vLLM scheduling&lt;/a>).&lt;/p>
&lt;p>There is a second budget, harder than the token one: the &lt;strong>KV cache&lt;/strong>. Every live token occupies KV blocks in HBM, and they are finite (&lt;code>gpu_memory_utilization&lt;/code> fixes them). When the scheduler wants to advance the running requests but there are no free blocks for the next token&amp;rsquo;s KV, someone has to get off: that is &lt;strong>preemption&lt;/strong>.&lt;/p>
&lt;p>vLLM V1 preempts by &lt;strong>RECOMPUTE&lt;/strong> by default, not by SWAP (&lt;a href="https://docs.vllm.ai/en/v0.9.2/usage/v1_guide.html">V1 guide&lt;/a>). The difference:&lt;/p>
&lt;ul>
&lt;li>&lt;strong>SWAP&lt;/strong>: copy the victim&amp;rsquo;s KV to host RAM and bring it back later. It moves gigabytes across PCIe (see &lt;a href="https://blog.lo0.es/en/posts/pcie-topology-gpudirect-p2p-acs-security-guard/">the PCIe and P2P post&lt;/a>).&lt;/li>
&lt;li>&lt;strong>RECOMPUTE&lt;/strong>: throw the victim&amp;rsquo;s KV away and, when there is room again, redo its prefill from scratch. It sounds expensive, but in the V1 architecture it works out cheaper than swapping, because prefill is work the GPU does very fast and you save the round trip across the bus.&lt;/li>
&lt;/ul>
&lt;p>The victim is usually the &lt;strong>newest&lt;/strong> request in the running queue (so as not to penalise whoever has been waiting longest for their answer). The danger is &lt;strong>thrashing&lt;/strong>: admit too many requests at once and the system enters a preempt-recompute-preempt cycle that drives throughput into the floor. That is why the second ceiling exists.&lt;/p>
&lt;h2 id="the-arithmetic-that-matters-concurrency-and-the-budget-trade-off">The arithmetic that matters: concurrency and the budget trade-off&lt;/h2>
&lt;p>&lt;strong>How many requests fit at once.&lt;/strong> The real concurrency limit is not &lt;code>max_num_seqs&lt;/code> (the nominal ceiling on simultaneous sequences); it is usually the &lt;strong>KV cache&lt;/strong>. If a node has $B$ free KV blocks, each block holds $b$ tokens (16 by default), and each request occupies $L$ context tokens on average, the maximum sustainable concurrency is:&lt;/p>
$$N_\text{max} \approx \frac{B \cdot b}{L}$$
&lt;p>Take a 70B model in FP16 on 4×H100 SXM (320 GB), with the bulk of HBM in weights and, say, ~120 GB free for KV. With a KV of ~0.3 MB/token (an order-of-magnitude figure, it depends on layers and heads), that is ~400,000 KV tokens. With average contexts of 4000 tokens:&lt;/p>
$$N_\text{max} \approx \frac{400000}{4000} = 100 \text{ concurrent requests}$$
&lt;p>Raising &lt;code>max_num_seqs&lt;/code> to 400 does not give you 400 concurrent: it gives you preemption and thrashing as soon as contexts grow. The KV rules.&lt;/p>
&lt;p>&lt;strong>The budget trade-off.&lt;/strong> Raising &lt;code>max_num_batched_tokens&lt;/code> puts more work in each forward, so fewer forwards for the same total work, and more &lt;em>throughput&lt;/em>. But a large budget lets a huge prefill occupy almost the whole tray in one step, and that step takes longer, so everyone else&amp;rsquo;s decodes wait for that entire step, and &lt;strong>ITL&lt;/strong> (inter-token latency) rises for everybody. The rule of thumb (&lt;a href="https://docs.vllm.ai/en/stable/configuration/optimization/">vLLM optimisation&lt;/a>):&lt;/p>
&lt;table>
&lt;thead>
&lt;tr>
&lt;th>Budget&lt;/th>
&lt;th>Effect&lt;/th>
&lt;th>At the cost of&lt;/th>
&lt;/tr>
&lt;/thead>
&lt;tbody>
&lt;tr>
&lt;td>Low (e.g. 2048)&lt;/td>
&lt;td>more interleaving, stable ITL&lt;/td>
&lt;td>less peak throughput&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>High (e.g. 16384)&lt;/td>
&lt;td>maximum throughput&lt;/td>
&lt;td>ITL spikes when a large prefill arrives&lt;/td>
&lt;/tr>
&lt;/tbody>
&lt;/table>
&lt;p>There is no &amp;ldquo;correct&amp;rdquo; value: there is a point for your load. And you only find it by &lt;strong>measuring throughput and ITL at the same time&lt;/strong>, which is exactly what almost nobody does.&lt;/p>
&lt;h2 id="the-schedulers-10-knobs">The scheduler&amp;rsquo;s 10 knobs&lt;/h2>
&lt;table>
&lt;thead>
&lt;tr>
&lt;th>#&lt;/th>
&lt;th>Knob&lt;/th>
&lt;th>What it controls&lt;/th>
&lt;th>Cost if you overdo it&lt;/th>
&lt;/tr>
&lt;/thead>
&lt;tbody>
&lt;tr>
&lt;td>1&lt;/td>
&lt;td>&lt;code>max_num_batched_tokens&lt;/code>&lt;/td>
&lt;td>tray size per step&lt;/td>
&lt;td>high ITL if too large&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>2&lt;/td>
&lt;td>&lt;code>max_num_seqs&lt;/code>&lt;/td>
&lt;td>nominal concurrency ceiling&lt;/td>
&lt;td>preemption if the KV falls short&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>3&lt;/td>
&lt;td>&lt;code>gpu_memory_utilization&lt;/code>&lt;/td>
&lt;td>KV blocks available&lt;/td>
&lt;td>OOM if too high&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>4&lt;/td>
&lt;td>chunked prefill (threshold)&lt;/td>
&lt;td>prompt chunk size&lt;/td>
&lt;td>chunking overhead if too fine&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>5&lt;/td>
&lt;td>policy (&lt;code>fcfs&lt;/code>/&lt;code>priority&lt;/code>)&lt;/td>
&lt;td>who gets served first&lt;/td>
&lt;td>starvation of low priority&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>6&lt;/td>
&lt;td>preemption mode&lt;/td>
&lt;td>RECOMPUTE vs SWAP&lt;/td>
&lt;td>PCIe traffic / recompute&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>7&lt;/td>
&lt;td>&lt;code>enable_prefix_caching&lt;/code>&lt;/td>
&lt;td>reuse prefix KV&lt;/td>
&lt;td>little; almost always on&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>8&lt;/td>
&lt;td>&lt;code>max_model_len&lt;/code>&lt;/td>
&lt;td>maximum context per request&lt;/td>
&lt;td>pessimistic KV reservation&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>9&lt;/td>
&lt;td>CUDA graph sizes&lt;/td>
&lt;td>align batch with buckets&lt;/td>
&lt;td>padding / capture (see below)&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>10&lt;/td>
&lt;td>speculative tokens&lt;/td>
&lt;td>extra tokens per step&lt;/td>
&lt;td>wasted work if acceptance drops&lt;/td>
&lt;/tr>
&lt;/tbody>
&lt;/table>
&lt;h2 id="how-it-connects-to-the-rest-of-the-stack">How it connects to the rest of the stack&lt;/h2>
&lt;p>&lt;strong>With continuous batching.&lt;/strong> The scheduler &lt;em>is&lt;/em> continuous batching turned into code. The &lt;a href="https://blog.lo0.es/en/posts/continuous-batching-orca-vllm-chunked-prefill-goodput/">batching post&lt;/a> explains the &lt;em>what&lt;/em> (advance every request at once); this one explains the &lt;em>how&lt;/em> (the per-step token dictionary).&lt;/p>
&lt;p>&lt;strong>With the KV cache and the block manager.&lt;/strong> The second budget, the blocks, is managed by the &lt;a href="https://blog.lo0.es/en/posts/pagedattention-vllm-block-manager/">PagedAttention block manager&lt;/a>. The scheduler asks for blocks; if there are none, it preempts. The two pieces are coupled through memory.&lt;/p>
&lt;p>&lt;strong>With CUDA graphs.&lt;/strong> &lt;a href="https://blog.lo0.es/en/posts/sms-cuda-streams-cuda-graphs-inference/">CUDA graphs&lt;/a> are captured for specific batch sizes (&lt;em>buckets&lt;/em>). The scheduler should produce batches whose size lands in those buckets to avoid padding; otherwise part of the graph&amp;rsquo;s benefit is lost.&lt;/p>
&lt;p>&lt;strong>With chunked prefill and the prefix cache.&lt;/strong> Chunking a prompt interacts with &lt;a href="https://blog.lo0.es/en/posts/prefix-cache-hit-rate-engineering/">prefix caching&lt;/a>: chunks that match an already cached prefix skip the compute, and the scheduler reflects that by lowering that request&amp;rsquo;s &lt;code>n_tokens&lt;/code>.&lt;/p>
&lt;p>&lt;strong>With speculative decoding.&lt;/strong> &lt;a href="https://blog.lo0.es/en/posts/speculative-decoding-fundamentals-maths-state-of-play/">Speculative decoding&lt;/a> makes a step verify several tokens at once; the scheduler models it as &lt;code>n_tokens &amp;gt; 1&lt;/code> for a request in decode.&lt;/p>
&lt;p>&lt;strong>With disaggregated serving.&lt;/strong> In &lt;a href="https://blog.lo0.es/en/posts/disaggregated-serving-prefill-decode-specialised-pods/">disaggregated prefill/decode&lt;/a> there are two schedulers, one per pool, each with its own budget; the phase distinction that V1 erased &lt;em>inside&lt;/em> an engine reappears &lt;em>between&lt;/em> engines.&lt;/p>
&lt;p>&lt;strong>With autoscaling.&lt;/strong> The metrics that drive &lt;a href="https://blog.lo0.es/en/posts/llm-inference-autoscaling-kubernetes-hpa-keda/">autoscaling with KEDA&lt;/a>, waiting-queue length and preempted requests, come straight out of the scheduler&amp;rsquo;s state.&lt;/p>
&lt;h2 id="traps-and-things-that-are-not-what-they-look-like">Traps and things that are not what they look like&lt;/h2>
&lt;p>&lt;strong>&amp;ldquo;Raising &lt;code>max_num_batched_tokens&lt;/code> always helps.&amp;rdquo;&lt;/strong> It improves throughput and worsens ITL. If you only look at tokens/s in a large-batch benchmark, you &amp;ldquo;confirm&amp;rdquo; that more is better; in interactive production, your users feel the stutter. Measure both metrics or you are not measuring.&lt;/p>
&lt;p>&lt;strong>&amp;ldquo;The engine does all the prefills first and then the decodes.&amp;rdquo;&lt;/strong> That is the old architecture&amp;rsquo;s intuition. In V1 there are no phases: each step mixes prefill and decode according to the budget. Reasoning with the phase model leads to wrong conclusions about why latency rises.&lt;/p>
&lt;p>&lt;strong>&amp;ldquo;More &lt;code>max_num_seqs&lt;/code> = more throughput.&amp;rdquo;&lt;/strong> Only until the KV cache runs out. Past that, more nominal concurrency produces &lt;strong>preemption&lt;/strong>, and cascading preemption (thrashing) &lt;em>lowers&lt;/em> throughput. The real ceiling is the KV, not the parameter.&lt;/p>
&lt;p>&lt;strong>&amp;ldquo;RECOMPUTE is a waste, SWAP is better.&amp;rdquo;&lt;/strong> In V1, RECOMPUTE usually wins: prefill is extremely fast on GPU and SWAP puts a gigabyte-scale round trip across PCIe on the critical path. Switching to SWAP &amp;ldquo;to avoid recomputing&amp;rdquo; can make latency worse.&lt;/p>
&lt;p>&lt;strong>&amp;ldquo;The scheduler is the bottleneck.&amp;rdquo;&lt;/strong> Almost never. The decision is a dictionary built in microseconds; the cost of the round is the &lt;em>forward&lt;/em> on the GPU, three orders of magnitude above. If your scheduling CPU shows up in the profiler, the problem is usually host-thread jitter (see &lt;a href="https://blog.lo0.es/en/posts/numa-hugepages-cpu-isolation-gpu-waits-kernel/">NUMA and CPU isolation&lt;/a>), not the scheduler logic.&lt;/p>
&lt;p>&lt;strong>Chunked prefill that is too fine.&lt;/strong> Tiny chunks make a large prompt take many steps and add fixed per-step overhead. Chunking is there to &lt;em>bound&lt;/em> the ITL impact, not to pulverise the prompt.&lt;/p>
&lt;h2 id="conclusion">Conclusion&lt;/h2>
&lt;p>All the power of a modern LLM server, swallowing hundreds of requests at once without any of them blocking the rest, rests on a decision that fits in a &lt;code>{request: how many tokens}&lt;/code> dictionary, taken hundreds of times per second. The idea that made it possible was not a faster kernel or a bigger GPU: it was &lt;strong>to stop thinking in phases&lt;/strong>. When a prompt token and a generated token are the same thing, the scheduler can fill every tray by mixing the heavy and the light, and the kitchen never stops. The rest is two budgets, tokens and KV blocks, and a rule for when the second runs out. The maître d&amp;rsquo; does not cook; he only decides what goes to the stoves in each round. But that decision, repeated without pause, is what separates an idle GPU waiting for orders from a kitchen running flat out. And the uncomfortable lesson for whoever is tuning: throughput and latency meet in the budget, and optimising one blindly means worsening the other without noticing.&lt;/p>
&lt;h2 id="see-also">See also&lt;/h2>
&lt;ul>
&lt;li>&lt;a href="https://blog.lo0.es/en/posts/continuous-batching-orca-vllm-chunked-prefill-goodput/">Continuous batching: why we do not wait for a request to finish&lt;/a> — the &lt;em>what&lt;/em>; this post is the &lt;em>how&lt;/em> that implements it.&lt;/li>
&lt;li>&lt;a href="https://blog.lo0.es/en/posts/pagedattention-vllm-block-manager/">PagedAttention and the block manager&lt;/a> — the scheduler&amp;rsquo;s second budget, the KV blocks; when they run out, preemption.&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> — what each live token occupies and why concurrency is limited by memory, not by the parameter.&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> — the capture buckets the scheduler should respect to avoid paying for padding.&lt;/li>
&lt;li>&lt;a href="https://blog.lo0.es/en/posts/prefix-cache-hit-rate-engineering/">Prefix cache hit rate engineering&lt;/a> — how cached chunks lower the &lt;code>n_tokens&lt;/code> the scheduler assigns.&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 &lt;code>n_tokens &amp;gt; 1&lt;/code> case in decode.&lt;/li>
&lt;li>&lt;a href="https://blog.lo0.es/en/posts/disaggregated-serving-prefill-decode-specialised-pods/">Disaggregated serving: prefill and decode apart&lt;/a> — two schedulers, the phase distinction that returns between engines.&lt;/li>
&lt;li>&lt;a href="https://blog.lo0.es/en/posts/llm-inference-autoscaling-kubernetes-hpa-keda/">LLM autoscaling with KEDA&lt;/a> — scheduler metrics (waiting queue, preempted requests) as a scaling signal.&lt;/li>
&lt;/ul>
&lt;h2 id="references">References&lt;/h2>
&lt;ul>
&lt;li>vLLM, &lt;em>vLLM V1: A Major Upgrade to vLLM&amp;rsquo;s Core Architecture&lt;/em>: &lt;a href="https://openlm.ai/vllm-v1/">https://openlm.ai/vllm-v1/&lt;/a>.&lt;/li>
&lt;li>vLLM, &lt;em>vLLM V1 User Guide&lt;/em> (chunked prefill por defecto, preemption RECOMPUTE): &lt;a href="https://docs.vllm.ai/en/v0.9.2/usage/v1_guide.html">https://docs.vllm.ai/en/v0.9.2/usage/v1_guide.html&lt;/a>.&lt;/li>
&lt;li>vLLM, &lt;em>Optimization and Tuning&lt;/em> (&lt;code>max_num_batched_tokens&lt;/code>, presupuesto y trade-off): &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>A. Wong, &lt;em>Understanding vLLM Scheduling: Token Budgets, Chunked Prefill, and Policies&lt;/em>: &lt;a href="https://audreywongkg.medium.com/understanding-vllm-scheduling-token-budgets-chunked-prefill-and-policies-2c879e3980e3">https://audreywongkg.medium.com/understanding-vllm-scheduling-token-budgets-chunked-prefill-and-policies-2c879e3980e3&lt;/a>.&lt;/li>
&lt;li>W. Kwon et al., &lt;em>Efficient Memory Management for Large Language Model Serving with PagedAttention&lt;/em> (SOSP 2023): &lt;a href="https://arxiv.org/pdf/2309.06180">https://arxiv.org/pdf/2309.06180&lt;/a>.&lt;/li>
&lt;/ul></description></item><item><title>The chef who calls out every order: SMs, CUDA streams and CUDA graphs, or why the GPU gets bored generating tokens</title><link>https://blog.lo0.es/en/posts/sms-cuda-streams-cuda-graphs-inference/</link><pubDate>Sun, 07 Jun 2026 09:00:00 +0200</pubDate><guid>https://blog.lo0.es/en/posts/sms-cuda-streams-cuda-graphs-inference/</guid><description>&lt;blockquote>
&lt;p>This closes the &amp;ldquo;outside the API&amp;rdquo; pair. The &lt;a href="https://blog.lo0.es/en/posts/pantry-lift-from-disk-to-hbm-why-the-kitchen-opens-late/">previous post&lt;/a> moved the weights from disk into HBM; here we look at what happens &lt;strong>once they are inside&lt;/strong>, in the silicon that executes them. It is the floor below the kernel launch that the &lt;a href="https://blog.lo0.es/en/posts/numa-hugepages-cpu-isolation-gpu-waits-kernel/">NUMA post&lt;/a> mentioned without opening: &lt;em>who&lt;/em> launches those kernels, &lt;em>how&lt;/em>, and why in decode the GPU spends more time waiting for orders than computing.&lt;/p>
&lt;/blockquote>
&lt;h2 id="tldr">TL;DR&lt;/h2>
&lt;p>An H100 has &lt;strong>~132 streaming multiprocessors (SMs)&lt;/strong>, the &amp;ldquo;hobs&amp;rdquo; that execute the compute, and &lt;strong>occupancy&lt;/strong> measures how many &lt;em>warps&lt;/em> (groups of 32 threads) it has active to hide latency. But the &lt;strong>decode&lt;/strong> bottleneck is rarely the power of those SMs. Each decode step launches &lt;strong>hundreds of tiny kernels&lt;/strong> (several projections per layer × ~80 layers), and &lt;strong>each kernel launch costs 5-10 µs of serial CPU&lt;/strong>. Since decode kernels are small (small batch, a single token), the GPU &lt;strong>finishes them before the CPU calls out the next one&lt;/strong>: bubbles appear and the GPU sits bored waiting for orders. That regime is called &lt;strong>launch-bound&lt;/strong>, and it is the deep reason, not power, not memory, why &lt;code>--enforce-eager&lt;/code> delivers &lt;strong>54 tok/s&lt;/strong> where with optimisations you reach &lt;strong>89-140&lt;/strong>. The solution is &lt;strong>CUDA graphs&lt;/strong>: record the entire kernel sequence &lt;strong>once&lt;/strong> and replay it as &lt;strong>a single submission&lt;/strong>, removing the per-launch overhead (~28% of the latency per iteration). vLLM captures ~&lt;strong>102 graphs&lt;/strong> at startup and &lt;strong>pads&lt;/strong> the real batch to the nearest &lt;em>bucket&lt;/em> so it can replay a graph with fixed shapes. This post explains SM, occupancy, streams, launch overhead with the maths, CUDA graphs, the 10 knobs, and the trap that this capture &lt;strong>is the second half of the cold start&lt;/strong> from the previous post. With scepticism about what actually moves the needle. On the generic 4×H100 SXM cluster.&lt;/p>
&lt;h2 id="where-you-are-the-silicon-below-the-kernel-launch">Where you are: the silicon, below the kernel launch&lt;/h2>
&lt;div class="diagram" style="max-width:560px;margin:1.2rem auto;">
&lt;svg viewBox="0 0 560 320" xmlns="http://www.w3.org/2000/svg" role="img" aria-label="You are in the execution silicon, below the host kernel launch">
&lt;text x="280" y="24" text-anchor="middle" font-family="sans-serif" font-size="13" font-weight="700" fill="currentColor">The vertical stack · you are in the silicon&lt;/text>
&lt;rect x="120" y="40" width="320" height="38" rx="6" fill="currentColor" fill-opacity="0.05" stroke="#888" stroke-width="1.3"/>
&lt;text x="280" y="64" text-anchor="middle" font-family="sans-serif" font-size="12" fill="currentColor">Engine · vLLM (batching, sampling, scheduler)&lt;/text>
&lt;rect x="120" y="84" width="320" height="38" rx="6" fill="currentColor" fill-opacity="0.05" stroke="#888" stroke-width="1.3"/>
&lt;text x="280" y="108" text-anchor="middle" font-family="sans-serif" font-size="12" fill="currentColor">Host · CPU launches kernels (NUMA post)&lt;/text>
&lt;rect x="120" y="128" width="320" height="58" rx="6" fill="#dceede" stroke="#3c8c54" stroke-width="3"/>
&lt;text x="280" y="152" text-anchor="middle" font-family="sans-serif" font-size="12.5" font-weight="700" fill="#1f5c34">YOU ARE HERE · CUDA: streams, kernels, CUDA graphs&lt;/text>
&lt;text x="280" y="170" text-anchor="middle" font-family="sans-serif" font-size="10.5" fill="#2c6b42">the order queue that reaches the silicon&lt;/text>
&lt;rect x="120" y="192" width="320" height="38" rx="6" fill="#eef3fb" stroke="#4a6fa5" stroke-width="1.4"/>
&lt;text x="280" y="216" text-anchor="middle" font-family="sans-serif" font-size="12" fill="currentColor">SMs · 132 hobs execute the warps&lt;/text>
&lt;rect x="120" y="236" width="320" height="38" rx="6" fill="currentColor" fill-opacity="0.05" stroke="#888" stroke-width="1.3"/>
&lt;text x="280" y="260" text-anchor="middle" font-family="sans-serif" font-size="12" fill="currentColor">HBM · 3.35 TB/s — the weights the SMs read&lt;/text>
&lt;text x="280" y="298" text-anchor="middle" font-family="sans-serif" font-size="11" fill="currentColor" opacity="0.75">the post's question: are the SMs computing, or waiting for orders?&lt;/text>
&lt;/svg>
&lt;/div>
&lt;h2 id="the-analogy-the-chef-who-calls-out-every-order">The analogy: the chef who calls out every order&lt;/h2>
&lt;p>Last scene in the restaurant of the series. The kitchen is set up, the pantry is stocked (the &lt;a href="https://blog.lo0.es/en/posts/pantry-lift-from-disk-to-hbm-why-the-kitchen-opens-late/">previous post&lt;/a>). Now the food has to be &lt;strong>plated&lt;/strong>. The &lt;strong>hobs&lt;/strong> are the SMs: 132 stations cooking in parallel. The &lt;strong>head chef&lt;/strong> is the CPU: he calls out the orders, each &lt;em>kernel launch&lt;/em> is a shout of &amp;ldquo;one matrix multiplication, coming up!&amp;rdquo;. The cooks (the SMs) execute what the chef calls out.&lt;/p>
&lt;p>In &lt;strong>prefill&lt;/strong>, processing the whole prompt, each order is a huge dish: a giant matmul over hundreds of tokens at once. The chef calls out an order and the hobs take a good while to produce it. The chef has plenty of time to call out the next one. The hobs are &lt;strong>flat out&lt;/strong>: compute-bound.&lt;/p>
&lt;p>In &lt;strong>decode&lt;/strong>, generating one token at a time, each order is minuscule: a matmul over &lt;strong>a single token&lt;/strong>. The hob finishes it in an instant&amp;hellip; and sits staring at the chef waiting for the next one. But the chef can only call out &lt;strong>one order every 5-10 µs&lt;/strong>, and there are &lt;strong>hundreds of orders per token&lt;/strong>. The hobs, lightning fast, &lt;strong>get bored&lt;/strong> between shouts. The restaurant is not slow because the cooks are bad: it is slow because &lt;strong>the chef does not call out fast enough&lt;/strong>. That is the &lt;em>launch-bound&lt;/em> regime.&lt;/p>
&lt;p>The solution is not more hobs or faster cooks. It is &lt;strong>to stop calling out order by order&lt;/strong>. If the chef prints &lt;strong>the whole night&amp;rsquo;s sequence on a single sheet&lt;/strong> and hands it to the line (&amp;ldquo;do this, in this order, without waiting for me&amp;rdquo;), the hobs run without pauses. That is a &lt;strong>CUDA graph&lt;/strong>: record the kernel sequence once and replay it in one go, without the CPU calling out each one. And &lt;code>--enforce-eager&lt;/code> is exactly the opposite: forcing the chef to call out order by order, all night long.&lt;/p>
&lt;h2 id="the-mechanism-sm-warps-and-occupancy">The mechanism: SM, warps and occupancy&lt;/h2>
&lt;p>An H100 SXM has &lt;strong>~132 SMs&lt;/strong>. Each SM executes threads in groups of 32 called &lt;strong>warps&lt;/strong>, and can have several warps &amp;ldquo;in flight&amp;rdquo; at once. &lt;strong>Occupancy&lt;/strong> is the fraction of active warps against the maximum the SM supports. What is the point of having many active warps? To &lt;strong>hide latency&lt;/strong>: while one warp waits for data from HBM (hundreds of cycles), the SM executes another ready warp. With few warps, the SM runs out of anyone to give a turn to and stalls.&lt;/p>
&lt;p>But, and this is key, occupancy is a &lt;strong>necessary, not sufficient&lt;/strong> condition, and it only matters if the SM &lt;strong>has work to do&lt;/strong>. In decode, the typical problem is not low occupancy &lt;strong>inside&lt;/strong> a kernel: it is that &lt;strong>between&lt;/strong> kernels the SM has nothing, because the CPU has not launched the next one yet. Raising the occupancy of a kernel that lasts 8 µs does not help if the GPU spends 6 µs waiting for it to be launched.&lt;/p>
&lt;h2 id="the-mechanism-streams-the-order-queue">The mechanism: streams, the order queue&lt;/h2>
&lt;p>A &lt;strong>CUDA stream&lt;/strong> is a queue of operations that the GPU executes &lt;strong>in order&lt;/strong>. Operations in the same stream are sequential; operations in different streams can &lt;strong>overlap&lt;/strong>. That is what allows, for instance, copying data H2D in one stream while another stream computes, the compute/copy overlap. vLLM uses streams to overlap work, but a stream on its own does &lt;strong>not remove&lt;/strong> the cost of launching each kernel: it only decides ordering and parallelism. The launch cost is still there, order by order, until the graphs come in.&lt;/p>
&lt;h2 id="the-maths-that-matters-when-the-gpu-is-left-waiting">The maths that matters: when the GPU is left waiting&lt;/h2>
&lt;p>The number that governs everything: &lt;strong>a kernel launch costs 5-10 µs of CPU&lt;/strong>, serially. Take a Llama-70B with ~80 layers. Each layer, without fusion, launches on the order of &lt;strong>~10 kernels&lt;/strong> (Q/K/V projections, attention, output projection, the two or three MLP matmuls, the normalisations, RoPE and so on). That gives:&lt;/p>
$$ N_{\text{kernels}} \approx 80 \text{ layers} \times 10 \approx 800 \text{ launches per token} $$
&lt;p>At 5 µs per launch, &lt;strong>serially&lt;/strong>:&lt;/p>
$$ T_{\text{launch}} \approx 800 \times 5\,\mu s = 4{.}0 \text{ ms per token} $$
&lt;p>Those 4 ms are &lt;strong>CPU calling out orders only&lt;/strong>, not counting how long the SMs take to cook. If the GPU could compute instantaneously, the launch ceiling would be ~250 tok/s, and with synchronisation points between kernels, worse. Now compare with the &lt;strong>memory&lt;/strong> ceiling of decode: each token reads the 140 GB of weights once from HBM:&lt;/p>
$$ T_{\text{mem}} = \frac{140 \text{ GB}}{3{.}35 \text{ TB/s}} \approx 42 \text{ ms} \;\Rightarrow\; \approx 24 \text{ tok/s (one sequence, no batch)} $$
&lt;p>Here is the subtlety almost nobody has in mind. For &lt;strong>a single sequence&lt;/strong>, decode is memory-bound at ~24 tok/s, and the 4 ms of launch fit inside the 42 ms of reading: the launch hides. &lt;strong>But batching changes everything.&lt;/strong> When serving a batch of B sequences, the weights are read &lt;strong>once&lt;/strong> and serve all B, so the memory cost per token amortises and falls. The GPU stops being memory-bound&amp;hellip; and what was underneath emerges: the launch cost, which &lt;strong>does not amortise with batch&lt;/strong> because you have to launch the same kernel sequence regardless. The result: &lt;strong>the better you batch, the more launch-bound you become&lt;/strong>, and the more CUDA graphs pay off. That is why the raw measurement confirms it, &lt;code>--enforce-eager&lt;/code> gives &lt;strong>54 tok/s&lt;/strong> where graphs give &lt;strong>89&lt;/strong>, and up to &lt;strong>8×&lt;/strong> in configurations where decode is very small and launch dominates completely.&lt;/p>
&lt;div class="diagram" style="max-width:680px;margin:1.4rem auto;">
&lt;svg viewBox="0 0 680 250" xmlns="http://www.w3.org/2000/svg" role="img" aria-label="Eager launches kernel by kernel with bubbles; the CUDA graph replays everything in one go with no gaps">
&lt;text x="340" y="22" text-anchor="middle" font-family="sans-serif" font-size="13" font-weight="700" fill="currentColor">Eager vs CUDA graph on the GPU timeline&lt;/text>
&lt;text x="60" y="58" font-family="sans-serif" font-size="11.5" font-weight="600" fill="#c1121f">Eager (order by order)&lt;/text>
&lt;rect x="60" y="66" width="40" height="26" rx="3" fill="#e76f51" fill-opacity="0.85" stroke="#c1121f" stroke-width="1"/>
&lt;rect x="118" y="66" width="40" height="26" rx="3" fill="#e76f51" fill-opacity="0.85" stroke="#c1121f" stroke-width="1"/>
&lt;rect x="176" y="66" width="40" height="26" rx="3" fill="#e76f51" fill-opacity="0.85" stroke="#c1121f" stroke-width="1"/>
&lt;rect x="234" y="66" width="40" height="26" rx="3" fill="#e76f51" fill-opacity="0.85" stroke="#c1121f" stroke-width="1"/>
&lt;rect x="292" y="66" width="40" height="26" rx="3" fill="#e76f51" fill-opacity="0.85" stroke="#c1121f" stroke-width="1"/>
&lt;rect x="100" y="72" width="18" height="14" fill="currentColor" fill-opacity="0.12"/>
&lt;rect x="158" y="72" width="18" height="14" fill="currentColor" fill-opacity="0.12"/>
&lt;rect x="216" y="72" width="18" height="14" fill="currentColor" fill-opacity="0.12"/>
&lt;rect x="274" y="72" width="18" height="14" fill="currentColor" fill-opacity="0.12"/>
&lt;text x="340" y="84" font-family="sans-serif" font-size="10.5" fill="currentColor" opacity="0.7">kernel&lt;/text>
&lt;text x="430" y="84" font-family="sans-serif" font-size="10.5" fill="currentColor" opacity="0.7">↑ gaps = GPU waiting for the CPU to launch&lt;/text>
&lt;text x="60" y="150" font-family="sans-serif" font-size="11.5" font-weight="600" fill="#2a9d8f">CUDA graph (whole sheet)&lt;/text>
&lt;rect x="60" y="158" width="40" height="26" rx="3" fill="#2a9d8f" fill-opacity="0.85" stroke="#1f7a6e" stroke-width="1"/>
&lt;rect x="100" y="158" width="40" height="26" rx="3" fill="#2a9d8f" fill-opacity="0.85" stroke="#1f7a6e" stroke-width="1"/>
&lt;rect x="140" y="158" width="40" height="26" rx="3" fill="#2a9d8f" fill-opacity="0.85" stroke="#1f7a6e" stroke-width="1"/>
&lt;rect x="180" y="158" width="40" height="26" rx="3" fill="#2a9d8f" fill-opacity="0.85" stroke="#1f7a6e" stroke-width="1"/>
&lt;rect x="220" y="158" width="40" height="26" rx="3" fill="#2a9d8f" fill-opacity="0.85" stroke="#1f7a6e" stroke-width="1"/>
&lt;text x="340" y="176" font-family="sans-serif" font-size="10.5" fill="currentColor" opacity="0.7">no gaps: a single submission, replay&lt;/text>
&lt;line x1="60" y1="210" x2="620" y2="210" stroke="currentColor" stroke-width="1.2" opacity="0.4"/>
&lt;text x="60" y="228" font-family="sans-serif" font-size="10.5" fill="currentColor" opacity="0.8">time → same kernels, same GPU; the graph removes the launch bubbles&lt;/text>
&lt;/svg>
&lt;/div>
&lt;h2 id="vllms-cuda-graphs-specifically">vLLM&amp;rsquo;s CUDA graphs, specifically&lt;/h2>
&lt;p>vLLM does not capture a single graph: it captures &lt;strong>~102&lt;/strong> at startup, on the order of 51 &lt;em>piecewise&lt;/em> (for the mixed prefill+decode steps) and 51 &lt;em>full&lt;/em> (for pure decode). Each one is recorded for a &lt;strong>fixed batch size&lt;/strong> (a &lt;em>bucket&lt;/em>: 1, 2, 4, 8&amp;hellip; up to a maximum). In service the real batch almost never falls exactly on a bucket, so vLLM &lt;strong>pads with zeros&lt;/strong> up to the next bucket up, replays that graph, and trims the output to the real size. That is the price of graphs: they need &lt;strong>static shapes&lt;/strong>, and padding is what makes them static.&lt;/p>
&lt;p>This has two consequences that show up in the knobs and the traps:&lt;/p>
&lt;p>&lt;strong>Capture costs time and memory.&lt;/strong> Recording 102 graphs at startup adds seconds to the cold start, &lt;strong>the second half&lt;/strong> of the startup that the &lt;a href="https://blog.lo0.es/en/posts/pantry-lift-from-disk-to-hbm-why-the-kitchen-opens-late/">previous post&lt;/a> left pending, and consumes HBM (each graph retains its buffers). The &lt;code>FULL_AND_PIECEWISE&lt;/code> mode (the default) is the fastest in service but the one that asks for the most memory and the longest capture; &lt;code>FULL_DECODE_ONLY&lt;/code> saves both in exchange for not accelerating the mixed steps.&lt;/p>
&lt;p>&lt;strong>Padding wastes some compute.&lt;/strong> Padding a batch of 33 up to the bucket of 64 computes 31 phantom sequences. It is a small cost against what removing the launch overhead saves, but it exists, and it grows if the buckets are badly chosen.&lt;/p>
&lt;h2 id="the-10-knobs-worth-touching">The 10 knobs worth touching&lt;/h2>
&lt;h3 id="knob-1--measure-whether-decode-is-launch-bound">Knob 1 — Measure whether decode is launch-bound&lt;/h3>
&lt;p>Before touching anything: is the GPU computing or waiting? With &lt;code>nsys&lt;/code> (Nsight Systems) you can see the &lt;strong>gaps between kernels&lt;/strong> on the timeline; if there are gaps in decode, it is launch-bound and graphs will help. If the GPU is at 100% with no gaps, the bottleneck is something else (memory or compute) and graphs will not work miracles. &lt;code>nvidia-smi dmon&lt;/code> showing low utilisation in decode but poor TPS is the cheap signal.&lt;/p>
&lt;h3 id="knob-2--do-not-use---enforce-eager-in-production">Knob 2 — Do not use &lt;code>--enforce-eager&lt;/code> in production&lt;/h3>
&lt;p>&lt;code>--enforce-eager&lt;/code> &lt;strong>disables CUDA graphs&lt;/strong>. It is a &lt;strong>debugging&lt;/strong> tool (to isolate which kernel fails), not a production one. Leaving it on &amp;ldquo;because it used to start up&amp;rdquo; throws away 26-50% of decode throughput. If it is in your production command, take it out and measure.&lt;/p>
&lt;h3 id="knob-3--capture-buckets-cudagraph_capture_sizes">Knob 3 — Capture buckets (&lt;code>cudagraph_capture_sizes&lt;/code>)&lt;/h3>
&lt;p>Which batch sizes to capture. Buckets too far apart make padding expensive; too many of them make capture slow and eat HBM. Tuning them to the &lt;strong>real distribution&lt;/strong> of batch sizes you see in production is the fine adjustment, but only after measuring that distribution.&lt;/p>
&lt;h3 id="knob-4--cuda-graph-mode">Knob 4 — CUDA graph mode&lt;/h3>
&lt;p>&lt;code>FULL_AND_PIECEWISE&lt;/code> (default, fastest, more memory/capture), &lt;code>FULL_DECODE_ONLY&lt;/code> (saves memory and capture, ideal for pure decode pods in &lt;a href="https://blog.lo0.es/en/posts/disaggregated-serving-prefill-decode-specialised-pods/">disaggregated serving&lt;/a>), &lt;code>PIECEWISE&lt;/code>, or &lt;code>NONE&lt;/code> (= eager). The right mode depends on whether the pod does pure or mixed decode.&lt;/p>
&lt;h3 id="knob-5--torchcompile">Knob 5 — &lt;code>torch.compile&lt;/code>&lt;/h3>
&lt;p>vLLM leans on &lt;code>torch.compile&lt;/code> to fuse and optimise kernels before capturing them in graphs. Fewer kernels (fusion) = fewer launches = less dependence on the graph and better decode even in eager. The compilation level is a knob, with its startup time cost.&lt;/p>
&lt;h3 id="knob-6--batch-size-fill-the-hobs">Knob 6 — Batch size: fill the hobs&lt;/h3>
&lt;p>Memory-bound decode amortises with batching (as we saw in &lt;a href="https://blog.lo0.es/en/posts/continuous-batching-orca-vllm-chunked-prefill-goodput/">continuous batching&lt;/a>): read the weights once for B sequences. More batch = more SM occupancy &lt;strong>and&lt;/strong> more memory amortisation. The limit is set by the HBM available for the &lt;a href="https://blog.lo0.es/en/posts/kv-cache-working-memory-llm-inference/">KV cache&lt;/a>. It is the knob that moves aggregate throughput most.&lt;/p>
&lt;h3 id="knob-7--do-not-break-stream-overlap">Knob 7 — Do not break stream overlap&lt;/h3>
&lt;p>vLLM overlaps compute and copy with streams. Patching the code to &amp;ldquo;simplify&amp;rdquo; can serialise what was overlapped. If you do not know why there are several streams, do not collapse them.&lt;/p>
&lt;h3 id="knob-8--persistence-mode--locked-clocks">Knob 8 — Persistence mode + locked clocks&lt;/h3>
&lt;p>&lt;code>nvidia-smi -pm 1&lt;/code> keeps the driver resident (avoiding reinitialisations that add launch latency). Locking clocks to the boost frequency stops the GPU dropping P-state between tiny decode kernels and paying the ramp-up latency. It is the same &lt;em>anti-jitter&lt;/em> spirit as the &lt;a href="https://blog.lo0.es/en/posts/numa-hugepages-cpu-isolation-gpu-waits-kernel/">NUMA post&lt;/a>, applied to the GPU.&lt;/p>
&lt;h3 id="knob-9--fused-kernels-flashattention-fp8-kernels">Knob 9 — Fused kernels (FlashAttention, FP8 kernels)&lt;/h3>
&lt;p>Fewer kernels = fewer orders to call out. &lt;a href="https://blog.lo0.es/en/posts/speculative-decoding-fundamentals-maths-state-of-play/">FlashAttention&lt;/a> fuses attention into one kernel instead of several; fused FP8 kernels reduce the count. Fusion attacks the problem at the root: it does not speed up the launch, it &lt;strong>removes launches&lt;/strong>.&lt;/p>
&lt;h3 id="knob-10--accept-the-capture-cost-in-the-cold-start">Knob 10 — Accept the capture cost in the cold start&lt;/h3>
&lt;p>Graph capture adds seconds to startup. In a pod that lives for hours, it amortises easily. In one that scales up and down every minute, that cost is paid over and over; there, &lt;code>FULL_DECODE_ONLY&lt;/code> (shorter capture) or accepting slightly less throughput can be worth it. It is the same warm-vs-elastic tension as in the &lt;a href="https://blog.lo0.es/en/posts/pantry-lift-from-disk-to-hbm-why-the-kitchen-opens-late/">cold start&lt;/a>.&lt;/p>
&lt;h3 id="summary-table">Summary table&lt;/h3>
&lt;table>
&lt;thead>
&lt;tr>
&lt;th>#&lt;/th>
&lt;th>Knob&lt;/th>
&lt;th>What it attacks&lt;/th>
&lt;th>Risk / cost&lt;/th>
&lt;/tr>
&lt;/thead>
&lt;tbody>
&lt;tr>
&lt;td>1&lt;/td>
&lt;td>&lt;code>nsys&lt;/code> / &lt;code>dmon&lt;/code>&lt;/td>
&lt;td>knowing whether it is launch-bound&lt;/td>
&lt;td>none; do it first&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>2&lt;/td>
&lt;td>remove &lt;code>--enforce-eager&lt;/code>&lt;/td>
&lt;td>graphs disabled&lt;/td>
&lt;td>it was for debugging; reactivate if a bug returns&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>3&lt;/td>
&lt;td>capture buckets&lt;/td>
&lt;td>expensive padding / slow capture&lt;/td>
&lt;td>requires measuring the real distribution&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>4&lt;/td>
&lt;td>graph mode&lt;/td>
&lt;td>memory and capture&lt;/td>
&lt;td>less coverage on mixed steps&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>5&lt;/td>
&lt;td>&lt;code>torch.compile&lt;/code>&lt;/td>
&lt;td>unfused kernels&lt;/td>
&lt;td>startup time&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>6&lt;/td>
&lt;td>batch size&lt;/td>
&lt;td>occupancy + memory&lt;/td>
&lt;td>HBM for KV cache&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>7&lt;/td>
&lt;td>streams&lt;/td>
&lt;td>broken overlap&lt;/td>
&lt;td>do not touch if not understood&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>8&lt;/td>
&lt;td>persistence + clocks&lt;/td>
&lt;td>jitter / P-states&lt;/td>
&lt;td>electrical consumption&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>9&lt;/td>
&lt;td>fused kernels&lt;/td>
&lt;td>number of launches&lt;/td>
&lt;td>kernel compatibility&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>10&lt;/td>
&lt;td>capture vs cold start&lt;/td>
&lt;td>slower startup&lt;/td>
&lt;td>less throughput if trimmed&lt;/td>
&lt;/tr>
&lt;/tbody>
&lt;/table>
&lt;h2 id="how-this-connects-with-the-rest-of-the-stack">How this connects with the rest of the stack&lt;/h2>
&lt;p>&lt;strong>With the cold start.&lt;/strong> CUDA graph capture is the &lt;strong>second half&lt;/strong> of the startup that the &lt;a href="https://blog.lo0.es/en/posts/pantry-lift-from-disk-to-hbm-why-the-kitchen-opens-late/">previous post&lt;/a> opened: loading weights + capturing graphs = the complete cold start.&lt;/p>
&lt;p>&lt;strong>With continuous batching.&lt;/strong> &lt;a href="https://blog.lo0.es/en/posts/continuous-batching-orca-vllm-chunked-prefill-goodput/">Continuous batching&lt;/a> is what &lt;strong>makes decode launch-bound&lt;/strong> (it amortises memory and leaves the launch exposed), and that is why graphs and batching reinforce each other.&lt;/p>
&lt;p>&lt;strong>With the KV cache.&lt;/strong> The &lt;a href="https://blog.lo0.es/en/posts/kv-cache-working-memory-llm-inference/">KV cache&lt;/a> decides how much batch fits in HBM, and the batch decides SM occupancy and how much launch overhead matters. Everything is coupled through memory.&lt;/p>
&lt;p>&lt;strong>With the interconnect.&lt;/strong> In TP, between the compute kernels there are &lt;strong>all-reduces&lt;/strong> (&lt;a href="https://blog.lo0.es/en/posts/nvlink-nvswitch-nccl-cable-every-token-crosses/">NVLink/NCCL&lt;/a>) that also get launched and synchronised. vLLM&amp;rsquo;s custom all-reduce is integrated into the same graph so the sequence is not broken by a CPU synchronisation.&lt;/p>
&lt;p>&lt;strong>With NUMA.&lt;/strong> &lt;em>Who&lt;/em> launches the kernels is the CPU from the &lt;a href="https://blog.lo0.es/en/posts/numa-hugepages-cpu-isolation-gpu-waits-kernel/">host post&lt;/a>; if that thread suffers jitter or lands on the wrong socket, launch overhead gets worse. Graphs reduce the dependence on that thread, which is another reason they help.&lt;/p>
&lt;p>&lt;strong>With disaggregated serving.&lt;/strong> The &lt;strong>pure decode&lt;/strong> pods of &lt;a href="https://blog.lo0.es/en/posts/disaggregated-serving-prefill-decode-specialised-pods/">disaggregated serving&lt;/a> are the ideal case for &lt;code>FULL_DECODE_ONLY&lt;/code>: they maximise the graph benefit exactly in the most launch-bound phase.&lt;/p>
&lt;h2 id="traps-and-things-that-are-not-what-they-look-like">Traps and things that are not what they look like&lt;/h2>
&lt;p>&lt;strong>&amp;ldquo;Raising occupancy will fix slow decode.&amp;rdquo;&lt;/strong> Not if the problem is launch-bound. Occupancy matters &lt;strong>inside&lt;/strong> a kernel that has work; if the GPU is idle &lt;strong>between&lt;/strong> kernels waiting for the CPU, more occupancy does not touch that bubble. Measure before optimising what is not the bottleneck.&lt;/p>
&lt;p>&lt;strong>&amp;ldquo;CUDA graphs always speed things up.&amp;rdquo;&lt;/strong> They speed things up when decode is launch-bound. If the GPU is already at 100% (compute-bound in prefill, or memory saturated with a huge batch), graphs add little. Their territory is decode with small kernels.&lt;/p>
&lt;p>&lt;strong>&amp;quot;&lt;code>--enforce-eager&lt;/code> gives more stable results.&amp;quot;&lt;/strong> It gives &lt;strong>slower&lt;/strong> results. The stability it appears to give is that it avoids graph capture bugs on new hardware (for example a newly supported architecture). It is a temporary patch, not a production configuration.&lt;/p>
&lt;p>&lt;strong>Capturing too many buckets &amp;ldquo;just in case&amp;rdquo;.&lt;/strong> Each bucket adds capture time and HBM. Capturing 30 sizes when in production you only see 4 means paying cold start and memory for graphs that are never replayed. Tune to the real distribution.&lt;/p>
&lt;p>&lt;strong>Confusing utilisation with efficiency.&lt;/strong> &lt;code>nvidia-smi&lt;/code> at 100% &amp;ldquo;utilisation&amp;rdquo; only says &lt;strong>there is a kernel running&lt;/strong>, not that the SM is full of useful work. A low-occupancy kernel keeps &amp;ldquo;utilisation&amp;rdquo; high while wasting the SM. &lt;code>nvidia-smi&lt;/code> utilisation is a coarse thermometer; to know whether the silicon is performing you need &lt;code>nsys&lt;/code>/DCGM and to look at real occupancy and gaps.&lt;/p>
&lt;p>&lt;strong>Optimising the silicon before memory.&lt;/strong> If decode is limited by HBM bandwidth (large batch, large model), fighting with graphs and occupancy is polishing what is not the bottleneck. The right order: measure the regime (memory / compute / launch) and attack the one in charge.&lt;/p>
&lt;h2 id="conclusion">Conclusion&lt;/h2>
&lt;p>Intuition says that a GPU generating slow tokens is &amp;ldquo;working hard&amp;rdquo;. Almost never: in decode it is &lt;strong>waiting for orders&lt;/strong>. The 132 SMs cook a tiny token in an instant and then sit staring at the CPU, which can only call out one order every 5-10 µs and has hundreds to call out per token. That bottleneck, neither power nor memory but &lt;strong>launch&lt;/strong>, is invisible on any dashboard that looks at &amp;ldquo;GPU utilisation&amp;rdquo;, and it is the real reason why &lt;code>--enforce-eager&lt;/code> performs at half speed. CUDA graphs solve it with a simple idea: stop calling out order by order and hand over the &lt;strong>whole sheet&lt;/strong> for the night, so the silicon runs without pauses. And there is an uncomfortable truth that reorders the optimisation priority: &lt;strong>the better you batch, the more launch-bound you become&lt;/strong>, because batching kills the memory bottleneck and exposes the launch one. That is why graphs and batching are not separate optimisations: they are the same lever seen from two sides. The chef who learns not to call out every dish is what finally makes the kitchen run as fast as the hobs always could.&lt;/p>
&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: vLLM&amp;rsquo;s scheduler step&lt;/a> — &lt;em>who&lt;/em> assembles the batch whose sizes should land in the CUDA graph capture buckets; scheduler and graphs are coupled through batch size.&lt;/li>
&lt;li>&lt;a href="https://blog.lo0.es/en/posts/pantry-lift-from-disk-to-hbm-why-the-kitchen-opens-late/">From disk to HBM: cold start and model loading&lt;/a> — the first half of startup; the graph capture in this post is the second half of the same cold start.&lt;/li>
&lt;li>&lt;a href="https://blog.lo0.es/en/posts/numa-hugepages-cpu-isolation-gpu-waits-kernel/">The floor next door: NUMA, hugepages and CPU isolation&lt;/a> — &lt;em>who&lt;/em> launches the kernels is that host thread; its jitter is the launch overhead graphs reduce.&lt;/li>
&lt;li>&lt;a href="https://blog.lo0.es/en/posts/nvlink-nvswitch-nccl-cable-every-token-crosses/">The shared table: NVLink, NVSwitch and NCCL&lt;/a> — the TP all-reduces are launched and synchronised between kernels; vLLM&amp;rsquo;s custom all-reduce is integrated into the same graph.&lt;/li>
&lt;li>&lt;a href="https://blog.lo0.es/en/posts/continuous-batching-orca-vllm-chunked-prefill-goodput/">Continuous batching&lt;/a> — what makes decode launch-bound by amortising memory; hence batching and graphs reinforce each other.&lt;/li>
&lt;li>&lt;a href="https://blog.lo0.es/en/posts/kv-cache-working-memory-llm-inference/">KV cache&lt;/a> — the memory that decides how much batch fits, and therefore SM occupancy and how much launch overhead weighs.&lt;/li>
&lt;li>&lt;a href="https://blog.lo0.es/en/posts/disaggregated-serving-prefill-decode-specialised-pods/">Disaggregated serving: prefill and decode separated&lt;/a> — pure decode pods are the ideal case for &lt;code>FULL_DECODE_ONLY&lt;/code>.&lt;/li>
&lt;li>&lt;a href="https://blog.lo0.es/en/posts/quantization-llm-inference-fp8-int4-gguf/">Quantization for inference&lt;/a> — fused FP8 kernels reduce the number of launches at the root.&lt;/li>
&lt;li>&lt;a href="https://blog.lo0.es/en/posts/gpu-observability-llm-inference-twelve-dcgm-vllm-metrics/">GPU observability with DCGM&lt;/a> — where real occupancy shows up, and the counters that separate &amp;ldquo;utilisation&amp;rdquo; from efficiency.&lt;/li>
&lt;/ul>
&lt;h2 id="references">References&lt;/h2>
&lt;ul>
&lt;li>vLLM, &lt;em>CUDA Graphs&lt;/em> (diseño, modos FULL/PIECEWISE, captura): &lt;a href="https://docs.vllm.ai/en/stable/design/cuda_graphs/">https://docs.vllm.ai/en/stable/design/cuda_graphs/&lt;/a>.&lt;/li>
&lt;li>vLLM, &lt;em>Inside vLLM: Anatomy of a High-Throughput LLM Inference System&lt;/em>: &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>NVIDIA, &lt;em>Getting Started with CUDA Graphs&lt;/em>: &lt;a href="https://developer.nvidia.com/blog/cuda-graphs/">https://developer.nvidia.com/blog/cuda-graphs/&lt;/a>.&lt;/li>
&lt;li>NVIDIA, &lt;em>Achieved Occupancy&lt;/em> (ocupación de SM): &lt;a href="https://archive.docs.nvidia.com/gameworks/content/developertools/desktop/analysis/report/cudaexperiments/kernellevel/achievedoccupancy.htm">https://archive.docs.nvidia.com/gameworks/content/developertools/desktop/analysis/report/cudaexperiments/kernellevel/achievedoccupancy.htm&lt;/a>.&lt;/li>
&lt;li>PyTorch, &lt;em>torch.compile y CUDA Graphs para inferencia LLM&lt;/em>: &lt;a href="https://docs.vllm.ai/en/stable/design/cuda_graphs/">https://docs.vllm.ai/en/stable/design/cuda_graphs/&lt;/a>.&lt;/li>
&lt;li>&lt;em>Understanding the Overheads of Launching CUDA Kernels&lt;/em> (ICPP 2019): &lt;a href="https://www.hpcs.cs.tsukuba.ac.jp/icpp2019/data/posters/Poster17-abst.pdf">https://www.hpcs.cs.tsukuba.ac.jp/icpp2019/data/posters/Poster17-abst.pdf&lt;/a>.&lt;/li>
&lt;/ul></description></item><item><title>The pantry lift: from disk to HBM, or why the kitchen opens late</title><link>https://blog.lo0.es/en/posts/pantry-lift-from-disk-to-hbm-why-the-kitchen-opens-late/</link><pubDate>Sun, 07 Jun 2026 08:30:00 +0200</pubDate><guid>https://blog.lo0.es/en/posts/pantry-lift-from-disk-to-hbm-why-the-kitchen-opens-late/</guid><description>&lt;blockquote>
&lt;p>This is a trip down to the basement. The &lt;a href="https://blog.lo0.es/en/posts/nvlink-nvswitch-nccl-cable-every-token-crosses/">under the engine&lt;/a> series optimised the &lt;strong>hot path&lt;/strong>, what happens with every token once you are already serving. This post looks at the journey that comes &lt;strong>before&lt;/strong> serving: how the weights climb from disk to HBM. It is the first of a pair on the two things that happen outside the API and that almost nobody times: &lt;strong>model loading&lt;/strong> (this one) and &lt;a href="https://blog.lo0.es/en/posts/sms-cuda-streams-cuda-graphs-inference/">execution on the silicon&lt;/a> (the next one).&lt;/p>
&lt;/blockquote>
&lt;h2 id="tldr">TL;DR&lt;/h2>
&lt;p>Before an inference pod generates its first token, it has to &lt;strong>push the whole model into HBM&lt;/strong>. A Llama-70B in FP16 is &lt;strong>140 GB&lt;/strong> travelling along a path nobody draws: &lt;strong>disk → page cache → host buffer → PCIe → HBM&lt;/strong>. Intuition fails here: HBM is not the bottleneck, it moves 3.35 TB/s and swallows 140 GB in &lt;strong>42 ms&lt;/strong>; the bottleneck is the &lt;strong>supply chain&lt;/strong>. The Gen5 NVMe disk reads at ~14 GB/s (10 s for 140 GB); PCIe Gen5 copies host→GPU at ~50 GB/s (2.8 s); and the &lt;strong>default safetensors loader&lt;/strong>, which deserialises tensor by tensor and bounces every byte through a CPU buffer, inflates all of that up to &lt;strong>30-60 s&lt;/strong>. That time is the &lt;strong>cold start&lt;/strong>, and it is the hidden tax paid by &lt;a href="https://blog.lo0.es/en/posts/llm-inference-autoscaling-kubernetes-hpa-keda/">autoscaling&lt;/a> (scale-from-zero), by &lt;a href="https://blog.lo0.es/en/posts/canary-blue-green-shadow-llm-models/">canary/blue-green&lt;/a> and by &lt;a href="https://blog.lo0.es/en/posts/disaggregated-serving-prefill-decode-specialised-pods/">disaggregated serving&lt;/a> every time a pod is born. There are three families of solution, &lt;strong>GPUDirect Storage&lt;/strong> (direct DMA disk→HBM, no bounce through the CPU), &lt;strong>fastsafetensors&lt;/strong> (4.8-7.5× over the default loader) and the &lt;strong>Run:ai Model Streamer&lt;/strong> (concurrent reads that saturate the disk), plus the simplest lever of all: &lt;strong>move fewer bytes&lt;/strong> (FP8 is half of FP16). This post explains the path, the maths, the 10 knobs, and the cruellest trap: &lt;em>&amp;ldquo;the second time it loaded fast&amp;rdquo;&lt;/em> is not your loader being good, it is the &lt;strong>page cache&lt;/strong> lying to you. On the generic 4×H100 SXM cluster.&lt;/p>
&lt;h2 id="where-you-are-the-basement-before-opening">Where you are: the basement, before opening&lt;/h2>
&lt;div class="diagram" style="max-width:560px;margin:1.2rem auto;">
&lt;svg viewBox="0 0 560 340" xmlns="http://www.w3.org/2000/svg" role="img" aria-label="The loading path: from disk to HBM, before the hot path of the token">
&lt;text x="280" y="24" text-anchor="middle" font-family="sans-serif" font-size="13" font-weight="700" fill="currentColor">The loading path · before the first token&lt;/text>
&lt;rect x="120" y="40" width="320" height="40" rx="6" fill="currentColor" fill-opacity="0.05" stroke="#888" stroke-width="1.3"/>
&lt;text x="280" y="65" text-anchor="middle" font-family="sans-serif" font-size="12" fill="currentColor">HBM · 80 GB, 3.35 TB/s — the destination, never the bottleneck&lt;/text>
&lt;rect x="120" y="86" width="320" height="40" rx="6" fill="currentColor" fill-opacity="0.05" stroke="#888" stroke-width="1.3"/>
&lt;text x="280" y="111" text-anchor="middle" font-family="sans-serif" font-size="12" fill="currentColor">PCIe Gen5 x16 · ~50 GB/s host→GPU (H2D)&lt;/text>
&lt;rect x="120" y="132" width="320" height="58" rx="6" fill="#dceede" stroke="#3c8c54" stroke-width="3"/>
&lt;text x="280" y="156" text-anchor="middle" font-family="sans-serif" font-size="12.5" font-weight="700" fill="#1f5c34">YOU ARE HERE · loader + page cache + host buffer&lt;/text>
&lt;text x="280" y="174" text-anchor="middle" font-family="sans-serif" font-size="10.5" fill="#2c6b42">deserialise, bounce through CPU or direct DMA (GDS)&lt;/text>
&lt;rect x="120" y="196" width="320" height="40" rx="6" fill="#eef3fb" stroke="#4a6fa5" stroke-width="1.4"/>
&lt;text x="280" y="221" text-anchor="middle" font-family="sans-serif" font-size="12" fill="currentColor">NVMe Gen5 · ~14 GB/s per disk — the origin&lt;/text>
&lt;rect x="120" y="248" width="320" height="40" rx="6" fill="currentColor" fill-opacity="0.05" stroke="#888" stroke-width="1.3"/>
&lt;text x="280" y="273" text-anchor="middle" font-family="sans-serif" font-size="12" fill="currentColor">Network / Ceph RGW · shared weights (slower still)&lt;/text>
&lt;text x="280" y="312" text-anchor="middle" font-family="sans-serif" font-size="11" fill="currentColor" opacity="0.75">The hot path of the token lives above; this post opens up what is below&lt;/text>
&lt;text x="280" y="330" text-anchor="middle" font-family="sans-serif" font-size="11" fill="currentColor" opacity="0.75">the journey that decides whether the kitchen opens in 10 s or in 60 s&lt;/text>
&lt;/svg>
&lt;/div>
&lt;h2 id="the-analogy-the-pantry-lift">The analogy: the pantry lift&lt;/h2>
&lt;p>Let us stay in the restaurant of the series. The &lt;a href="https://blog.lo0.es/en/posts/nvlink-nvswitch-nccl-cable-every-token-crosses/">shared table&lt;/a> was the NVSwitch, the &lt;a href="https://blog.lo0.es/en/posts/numa-hugepages-cpu-isolation-gpu-waits-kernel/">floor next door&lt;/a> was NUMA, the &lt;a href="https://blog.lo0.es/en/posts/cpu-memory-topology-manager-rke2/">maître&lt;/a> was the kubelet. All of that describes the restaurant &lt;strong>running&lt;/strong>, with diners seated. But there is a moment no post looked at: &lt;strong>before opening&lt;/strong>, somebody has to bring the entire pantry up from the basement store to the kitchen.&lt;/p>
&lt;p>The model weights are the ingredients. They live in the &lt;strong>basement store&lt;/strong> (the disk). The kitchen, the &lt;strong>hot line&lt;/strong> where tokens are plated, is the GPU&amp;rsquo;s HBM. And between one and the other there is a &lt;strong>service lift&lt;/strong>: the path disk → host → PCIe → HBM. The kitchen cannot serve the first dish until the pantry is upstairs and in place. That restocking time is the &lt;strong>cold start&lt;/strong>.&lt;/p>
&lt;p>The intuition trap: the kitchen (HBM) is enormous and blazingly fast, it places ingredients at 3.35 TB/s. So we blame the kitchen when the restaurant opens late. But the kitchen is standing idle &lt;strong>waiting for the lift&lt;/strong>. The bottleneck is never the hot line: it is the &lt;strong>lift and the store&lt;/strong>. And worse still, there is a porter (the default loader) who, instead of carrying whole crates, &lt;strong>takes the ingredients out one by one, writes each one down in a notebook and packs them again&lt;/strong> before sending them up. That porter, not the lift, is half the problem.&lt;/p>
&lt;h2 id="the-mechanism-what-really-happens-when-a-model-loads">The mechanism: what really happens when a model loads&lt;/h2>
&lt;p>When vLLM starts up with a model in &lt;code>safetensors&lt;/code>, the 140 GB of the Llama-70B FP16 make this journey:&lt;/p>
&lt;ol>
&lt;li>&lt;strong>Disk → page cache.&lt;/strong> The kernel reads the &lt;code>.safetensors&lt;/code> files from NVMe into the &lt;strong>page cache&lt;/strong> (host RAM). If this is the first time after a reboot, it is a physical read from disk (~14 GB/s on Gen5). If the files are already in page cache from an earlier start-up, this is almost free, and here is where the trap we will see is born.&lt;/li>
&lt;li>&lt;strong>Deserialise.&lt;/strong> The default Hugging Face loader &lt;code>mmap&lt;/code>s the file and builds the tensors &lt;strong>one by one&lt;/strong>, copying them into a CPU tensor before moving them. This is single-threaded CPU work that saturates neither the disk nor PCIe: most of the &amp;ldquo;slow&amp;rdquo; loading time goes here, not on moving bytes.&lt;/li>
&lt;li>&lt;strong>Host → HBM (H2D).&lt;/strong> Each tensor is copied from the host buffer into HBM over &lt;strong>PCIe Gen5 x16&lt;/strong> (~50 GB/s in practice). For the DMA to be efficient, the host buffer should be &lt;strong>pinned&lt;/strong>, which connects directly with the &lt;a href="https://blog.lo0.es/en/posts/numa-hugepages-cpu-isolation-gpu-waits-kernel/">hugepages and pinned memory&lt;/a> of the NUMA post.&lt;/li>
&lt;li>&lt;strong>Place in HBM.&lt;/strong> HBM receives the 140 GB. At 3.35 TB/s, &lt;strong>this takes 42 ms&lt;/strong>. It is never the bottleneck.&lt;/li>
&lt;/ol>
&lt;p>The path has a &lt;strong>shortcut&lt;/strong>: &lt;strong>GPUDirect Storage (GDS)&lt;/strong>. Instead of bouncing through the CPU buffer (steps 2-3), a DMA engine close to the NVMe controller writes &lt;strong>straight from disk to HBM&lt;/strong>, without involving the CPU. It is the same principle as the &lt;a href="https://blog.lo0.es/en/posts/network-numa-cilium-ebpf-dranet-fourth-leg-pinning/">GPUDirect RDMA of the network&lt;/a>: get the CPU out of the way. &lt;code>fastsafetensors&lt;/code> uses GDS and reaches &lt;strong>26.4 GB/s&lt;/strong> reading a Llama-70B from NVMe across 4 GPUs.&lt;/p>
&lt;div class="diagram" style="max-width:680px;margin:1.4rem auto;">
&lt;svg viewBox="0 0 680 250" xmlns="http://www.w3.org/2000/svg" role="img" aria-label="Two paths: default loader bouncing through the CPU vs direct GPUDirect Storage">
&lt;text x="340" y="22" text-anchor="middle" font-family="sans-serif" font-size="13" font-weight="700" fill="currentColor">Two paths from disk to HBM&lt;/text>
&lt;text x="170" y="48" text-anchor="middle" font-family="sans-serif" font-size="11.5" font-weight="600" fill="#c1121f">Default: bounce through the CPU&lt;/text>
&lt;rect x="60" y="60" width="90" height="40" rx="5" fill="#eef3fb" stroke="#4a6fa5" stroke-width="1.3"/>
&lt;text x="105" y="84" text-anchor="middle" font-family="sans-serif" font-size="11" fill="currentColor">NVMe&lt;/text>
&lt;rect x="190" y="60" width="90" height="40" rx="5" fill="currentColor" fill-opacity="0.06" stroke="#888" stroke-width="1.3"/>
&lt;text x="235" y="78" text-anchor="middle" font-family="sans-serif" font-size="10.5" fill="currentColor">CPU buffer&lt;/text>
&lt;text x="235" y="92" text-anchor="middle" font-family="sans-serif" font-size="9" fill="currentColor" opacity="0.7">+ deserialise&lt;/text>
&lt;line x1="150" y1="80" x2="188" y2="80" stroke="#c1121f" stroke-width="2" marker-end="url(#dha)"/>
&lt;defs>&lt;marker id="dha" 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="#c1121f"/>&lt;/marker>&lt;/defs>
&lt;rect x="320" y="60" width="90" height="40" rx="5" fill="#dceede" stroke="#3c8c54" stroke-width="1.5"/>
&lt;text x="365" y="84" text-anchor="middle" font-family="sans-serif" font-size="11" fill="#1f5c34">HBM&lt;/text>
&lt;line x1="280" y1="80" x2="318" y2="80" stroke="#c1121f" stroke-width="2" marker-end="url(#dha)"/>
&lt;text x="235" y="120" text-anchor="middle" font-family="sans-serif" font-size="9.5" fill="currentColor" opacity="0.7">the CPU touches every byte · single-threaded · slow&lt;/text>
&lt;text x="170" y="168" text-anchor="middle" font-family="sans-serif" font-size="11.5" font-weight="600" fill="#2a9d8f">GPUDirect Storage: direct DMA&lt;/text>
&lt;rect x="60" y="180" width="90" height="40" rx="5" fill="#eef3fb" stroke="#4a6fa5" stroke-width="1.3"/>
&lt;text x="105" y="204" text-anchor="middle" font-family="sans-serif" font-size="11" fill="currentColor">NVMe&lt;/text>
&lt;rect x="320" y="180" width="90" height="40" rx="5" fill="#dceede" stroke="#3c8c54" stroke-width="1.5"/>
&lt;text x="365" y="204" text-anchor="middle" font-family="sans-serif" font-size="11" fill="#1f5c34">HBM&lt;/text>
&lt;path d="M150,200 C230,200 240,200 318,200" stroke="#2a9d8f" stroke-width="2.4" fill="none" marker-end="url(#dhb)"/>
&lt;defs>&lt;marker id="dhb" 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="#2a9d8f"/>&lt;/marker>&lt;/defs>
&lt;text x="235" y="240" text-anchor="middle" font-family="sans-serif" font-size="9.5" fill="currentColor" opacity="0.7">the CPU never touches the data · ~26 GB/s NVMe→HBM&lt;/text>
&lt;line x1="470" y1="55" x2="470" y2="240" stroke="currentColor" stroke-width="1" opacity="0.25"/>
&lt;text x="575" y="90" text-anchor="middle" font-family="sans-serif" font-size="10.5" font-weight="600" fill="currentColor">140 GB FP16&lt;/text>
&lt;text x="575" y="112" text-anchor="middle" font-family="sans-serif" font-size="10" fill="currentColor" opacity="0.8">HBM: 42 ms&lt;/text>
&lt;text x="575" y="130" text-anchor="middle" font-family="sans-serif" font-size="10" fill="currentColor" opacity="0.8">PCIe: 2.8 s&lt;/text>
&lt;text x="575" y="148" text-anchor="middle" font-family="sans-serif" font-size="10" fill="currentColor" opacity="0.8">NVMe: 10 s&lt;/text>
&lt;text x="575" y="170" text-anchor="middle" font-family="sans-serif" font-size="10" fill="#c1121f" opacity="0.9">default: 30-60 s&lt;/text>
&lt;/svg>
&lt;/div>
&lt;h2 id="why-the-problem-exists-the-economics-of-bytes">Why the problem exists: the economics of bytes&lt;/h2>
&lt;p>The size of the model in bytes is decided by quantisation, and that sets the floor of the cold start, because &lt;strong>all&lt;/strong> those bytes have to move before the first token. For a 70B parameter model:&lt;/p>
&lt;table>
&lt;thead>
&lt;tr>
&lt;th>Format&lt;/th>
&lt;th>Bytes/param&lt;/th>
&lt;th>Size 70B&lt;/th>
&lt;th>Read 1 NVMe @14 GB/s&lt;/th>
&lt;th>H2D PCIe @50 GB/s&lt;/th>
&lt;th>HBM @3.35 TB/s&lt;/th>
&lt;/tr>
&lt;/thead>
&lt;tbody>
&lt;tr>
&lt;td>FP16 / BF16&lt;/td>
&lt;td>2&lt;/td>
&lt;td>140 GB&lt;/td>
&lt;td>10.0 s&lt;/td>
&lt;td>2.8 s&lt;/td>
&lt;td>42 ms&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>FP8&lt;/td>
&lt;td>1&lt;/td>
&lt;td>70 GB&lt;/td>
&lt;td>5.0 s&lt;/td>
&lt;td>1.4 s&lt;/td>
&lt;td>21 ms&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>INT4 (GPTQ/AWQ)&lt;/td>
&lt;td>0.5&lt;/td>
&lt;td>~35 GB&lt;/td>
&lt;td>2.5 s&lt;/td>
&lt;td>0.7 s&lt;/td>
&lt;td>10 ms&lt;/td>
&lt;/tr>
&lt;/tbody>
&lt;/table>
&lt;p>Three readings of this table:&lt;/p>
&lt;p>&lt;strong>HBM never shows up as a problem.&lt;/strong> The last column is always milliseconds. Anyone saying &amp;ldquo;the GPU is slow to load&amp;rdquo; is blaming the wrong place.&lt;/p>
&lt;p>&lt;strong>Quantising is the most underrated cold start lever.&lt;/strong> Going from FP16 to FP8 does not only double inference throughput (less HBM bandwidth per token, as we saw in &lt;a href="https://blog.lo0.es/en/posts/quantization-llm-inference-fp8-int4-gguf/">quantization&lt;/a>): &lt;strong>it also halves the cold start&lt;/strong>, because there are half as many bytes to push up. It is a two-for-one that sizing usually ignores.&lt;/p>
&lt;p>&lt;strong>The disk is the bottleneck on bytes; the loader is the bottleneck on time.&lt;/strong> The figures in the table are the &lt;strong>theoretical floor&lt;/strong>, just moving bytes. The default loader adds single-threaded deserialisation on top, which is the difference between the theoretical 10 s and the real 30-60 s. That is why the solutions attack on two fronts: &lt;strong>fewer bytes&lt;/strong> (quantisation) and &lt;strong>a better porter&lt;/strong> (concurrent loaders / GDS).&lt;/p>
&lt;h2 id="the-maths-that-matter-cold-start-as-a-tax-on-autoscaling">The maths that matter: cold start as a tax on autoscaling&lt;/h2>
&lt;p>Cold start is not paid once. It is paid &lt;strong>every time a pod is born&lt;/strong>. And on an elastic platform, pods are born continuously.&lt;/p>
&lt;p>Take an &lt;a href="https://blog.lo0.es/en/posts/llm-inference-autoscaling-kubernetes-hpa-keda/">autoscaling setup with KEDA&lt;/a> that scales from 2 to 6 replicas when the queue grows. The 4 new replicas take this long to be ready:&lt;/p>
$$ T_{\text{ready}} = T_{\text{schedule}} + T_{\text{pull-image}} + T_{\text{load-weights}} + T_{\text{cuda-graphs}} $$
&lt;p>With a Llama-70B FP16 and the default loader, $T_{\text{load-weights}}$ dominates: it can be &lt;strong>40 s&lt;/strong> of the ~60 s total. During those 40 s, the queue that triggered the autoscaling &lt;strong>keeps growing&lt;/strong>, since the new replicas absorb no traffic until they have loaded. The real number in the formula is not &amp;ldquo;how many replicas&amp;rdquo;, it is &lt;strong>how long each one takes to start serving&lt;/strong>, and that number is written by the loading path.&lt;/p>
&lt;p>This has a harsh operational consequence: &lt;strong>scale-to-zero is unworkable for workloads with a latency SLO if the cold start is 40 s.&lt;/strong> Nobody waits 40 s for the first token. The real elasticity of an inference platform is not limited by GPU availability, it is limited by &lt;strong>how long that GPU takes to have the model inside it&lt;/strong>. Bringing the cold start down from 40 s to 8 s (with streamer + FP8) is what turns &amp;ldquo;theoretical scale-to-zero&amp;rdquo; into &amp;ldquo;usable scale-to-zero&amp;rdquo;.&lt;/p>
&lt;p>The published numbers give the order of magnitude of the improvement: &lt;code>fastsafetensors&lt;/code> cuts start-up from &lt;strong>12.39 s to 4.74 s&lt;/strong> on a Llama-2-13B over 4×L40S, and from &lt;strong>16.04 s to 6.88 s&lt;/strong> on 1×A100, &lt;strong>4.8-7.5×&lt;/strong> over the default deserialiser. The &lt;strong>Run:ai Model Streamer&lt;/strong> loads in &lt;strong>4.88 s from S3 at concurrency 32&lt;/strong> and &lt;strong>7.53 s from SSD IO2 at concurrency 8&lt;/strong>; integrated into vLLM, total time to &lt;em>ready&lt;/em> drops to ~23 s from S3. There is no magic here: it is &lt;strong>getting the CPU out of the loop&lt;/strong> (GDS) and &lt;strong>reading in parallel&lt;/strong> (concurrency) to saturate the disk instead of leaving it half idle while one thread deserialises.&lt;/p>
&lt;h2 id="the-10-knobs-worth-touching">The 10 knobs worth touching&lt;/h2>
&lt;h3 id="knob-1--measure-where-the-time-goes-read-vs-deserialise-vs-h2d">Knob 1 — Measure where the time goes (read vs deserialise vs H2D)&lt;/h3>
&lt;p>Before touching anything: time it. Is the time going on reading from disk, on deserialising, or on the H2D? &lt;code>iostat -x 1&lt;/code> during the load tells you whether the NVMe is saturated (disk bottleneck) or nearly idle (loader/CPU bottleneck). If the disk is at 20%, the problem is &lt;strong>not&lt;/strong> the disk: it is the porter. Changing disk would fix nothing; changing loader would.&lt;/p>
&lt;h3 id="knob-2----load-format-choosing-the-porter">Knob 2 — &lt;code>--load-format&lt;/code>: choosing the porter&lt;/h3>
&lt;p>vLLM exposes several loaders via &lt;code>--load-format&lt;/code>: &lt;code>safetensors&lt;/code> (default), &lt;code>runai_streamer&lt;/code>, &lt;code>fastsafetensors&lt;/code>, &lt;code>tensorizer&lt;/code>. The default is the slowest. Changing one flag can be the cheapest 4-7× there is.&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">&lt;span class="c1"># Run:ai Model Streamer (concurrent reads, saturates the disk)&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">vllm serve meta-llama/Llama-3.1-70B --load-format runai_streamer
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="c1"># fastsafetensors (GPUDirect Storage, direct DMA disk→HBM)&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">vllm serve meta-llama/Llama-3.1-70B --load-format fastsafetensors
&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;h3 id="knob-3--streamer-concurrency">Knob 3 — Streamer concurrency&lt;/h3>
&lt;p>The Run:ai Model Streamer splits the read across N threads according to the size of each tensor in order to saturate the storage bandwidth. Concurrency is the key parameter: &lt;strong>16 is usually enough for local NVMe; 32 (sometimes 64)&lt;/strong> for high-throughput network storage. One thread does not saturate a Gen5 NVMe; 32 do.&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 &amp;lt;model&amp;gt; --load-format runai_streamer &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-loader-extra-config &lt;span class="s1">&amp;#39;{&amp;#34;concurrency&amp;#34;: 32}&amp;#39;&lt;/span>
&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;h3 id="knob-4--gpudirect-storage-gds--fastsafetensors">Knob 4 — GPUDirect Storage (GDS) + fastsafetensors&lt;/h3>
&lt;p>If there is an &lt;code>nvidia-fs&lt;/code> driver, a supported filesystem and local NVMe, GDS writes straight disk→HBM with no bounce through the CPU. It is the difference between the two paths in the diagram. &lt;strong>But&lt;/strong>: it requires the stack to be in place, and it only wins if the bottleneck is the CPU bounce, not the disk itself. Check with &lt;code>gdscheck&lt;/code>.&lt;/p>
&lt;h3 id="knob-5--local-nvme-for-the-weights-not-network">Knob 5 — Local NVMe for the weights, not network&lt;/h3>
&lt;p>Serving the weights from Ceph RGW / NFS is convenient (one shared place) but it puts the &lt;strong>network&lt;/strong> into the loading path. For cold start, &lt;strong>weights on the node&amp;rsquo;s local NVMe&lt;/strong> (or a local cache). Network storage is for the model repository; the inference node should have a hot local copy.&lt;/p>
&lt;h3 id="knob-6--pre-pull--local-model-cache">Knob 6 — Pre-pull / local model cache&lt;/h3>
&lt;p>An &lt;code>initContainer&lt;/code> that downloads the model onto a &lt;code>local&lt;/code> or &lt;code>hostPath&lt;/code> NVMe volume before starting vLLM turns a cold start &amp;ldquo;from the network&amp;rdquo; into one &amp;ldquo;from local NVMe&amp;rdquo;. Combined with a per-node cache DaemonSet, new pods on an already warm node read from local disk, not from the network.&lt;/p>
&lt;h3 id="knob-7--quantisation-move-fewer-bytes">Knob 7 — Quantisation: move fewer bytes&lt;/h3>
&lt;p>Weights already in FP8 or INT4 on disk = half or a quarter of the cold start. It is the knob from the table above. And it composes with all the others: FP8 + streamer + GDS is multiplicative.&lt;/p>
&lt;h3 id="knob-8--parallel-loading-across-gpus-vllm-v1">Knob 8 — Parallel loading across GPUs (vLLM V1)&lt;/h3>
&lt;p>vLLM&amp;rsquo;s V1 engine (the default since 0.19) loads the weight shards &lt;strong>in parallel&lt;/strong> across the GPUs of a TP group, instead of sequentially. At TP=4, each GPU loads its quarter at the same time. Check it is active; in old versions loading was serial and the cold start of TP=4 was almost 4× that of TP=1.&lt;/p>
&lt;h3 id="knob-9--numa-locality-of-the-nvme">Knob 9 — NUMA locality of the NVMe&lt;/h3>
&lt;p>The NVMe hangs off a &lt;strong>PCIe root under a specific socket&lt;/strong>, exactly the same NUMA map as the &lt;a href="https://blog.lo0.es/en/posts/numa-hugepages-cpu-isolation-gpu-waits-kernel/">host post&lt;/a>. If the load&amp;rsquo;s host buffer lands on the wrong socket, the H2D crosses the UPI. The fifth list to align, alongside &lt;code>isolcpus&lt;/code>, &lt;code>reserved-cpus&lt;/code> and the NIC IRQs: &lt;strong>which socket is local to the NVMe and to the destination GPU&lt;/strong>. &lt;code>nvidia-smi topo -m&lt;/code> shows it.&lt;/p>
&lt;h3 id="knob-10--do-not-pay-the-cold-start-keep-pods-warm">Knob 10 — Do not pay the cold start: keep pods warm&lt;/h3>
&lt;p>Sometimes the answer is not to load faster, but &lt;strong>not to unload&lt;/strong>. A floor of always-live replicas (no scale-to-zero), or a pool of preloaded warm standbys, swaps &amp;ldquo;wait 40 s&amp;rdquo; for &amp;ldquo;0 s&amp;rdquo;. It is the cost of idle GPU in exchange for start-up latency: a &lt;a href="https://blog.lo0.es/en/posts/capacity-planning-on-premise-llm-inference/">capacity planning&lt;/a> decision, not a technical one.&lt;/p>
&lt;h3 id="summary-table">Summary table&lt;/h3>
&lt;table>
&lt;thead>
&lt;tr>
&lt;th>#&lt;/th>
&lt;th>Knob&lt;/th>
&lt;th>What it attacks&lt;/th>
&lt;th>Risk / cost&lt;/th>
&lt;/tr>
&lt;/thead>
&lt;tbody>
&lt;tr>
&lt;td>1&lt;/td>
&lt;td>&lt;code>iostat&lt;/code> while loading&lt;/td>
&lt;td>knowing whether the bottleneck is disk or loader&lt;/td>
&lt;td>none; always do it first&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>2&lt;/td>
&lt;td>&lt;code>--load-format&lt;/code>&lt;/td>
&lt;td>single-threaded deserialisation&lt;/td>
&lt;td>format compatibility&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>3&lt;/td>
&lt;td>streamer concurrency&lt;/td>
&lt;td>underused disk&lt;/td>
&lt;td>host RAM for buffers&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>4&lt;/td>
&lt;td>GPUDirect Storage&lt;/td>
&lt;td>bounce through the CPU&lt;/td>
&lt;td>requires nvidia-fs + supported FS&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>5&lt;/td>
&lt;td>local NVMe vs network&lt;/td>
&lt;td>the network in the path&lt;/td>
&lt;td>duplicating weights per node&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>6&lt;/td>
&lt;td>pre-pull / node cache&lt;/td>
&lt;td>network on every start-up&lt;/td>
&lt;td>local disk space&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>7&lt;/td>
&lt;td>FP8/INT4 quantisation&lt;/td>
&lt;td>bytes to move&lt;/td>
&lt;td>quality (measure, do not assume)&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>8&lt;/td>
&lt;td>V1 parallel loading&lt;/td>
&lt;td>serial loading across GPUs&lt;/td>
&lt;td>none if V1 is active&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>9&lt;/td>
&lt;td>NUMA of the NVMe&lt;/td>
&lt;td>H2D crossing the UPI&lt;/td>
&lt;td>align with the rest of the NUMA lists&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>10&lt;/td>
&lt;td>warm pods / no zero&lt;/td>
&lt;td>the whole cold start&lt;/td>
&lt;td>idle GPU paid for&lt;/td>
&lt;/tr>
&lt;/tbody>
&lt;/table>
&lt;h2 id="how-it-connects-with-the-rest-of-the-stack">How it connects with the rest of the stack&lt;/h2>
&lt;p>&lt;strong>With autoscaling.&lt;/strong> The whole of &lt;a href="https://blog.lo0.es/en/posts/llm-inference-autoscaling-kubernetes-hpa-keda/">scale-from-zero with KEDA&lt;/a> rests on this: real elasticity is limited by the cold start, not by GPU availability. An autoscaler with 40 s of loading reacts late to every peak.&lt;/p>
&lt;p>&lt;strong>With disaggregated serving.&lt;/strong> In &lt;a href="https://blog.lo0.es/en/posts/disaggregated-serving-prefill-decode-specialised-pods/">disaggregated prefill/decode&lt;/a>, bringing up a decode pool on demand pays the cold start of loading the model into each new pod. The elasticity of the pattern depends on how fast those pods start.&lt;/p>
&lt;p>&lt;strong>With canary/blue-green.&lt;/strong> Every &lt;a href="https://blog.lo0.es/en/posts/canary-blue-green-shadow-llm-models/">canary deployment&lt;/a> loads a new version of the model alongside the old one. The validation time of a canary includes its cold start; large models make deployments slower and more expensive.&lt;/p>
&lt;p>&lt;strong>With NUMA and hugepages.&lt;/strong> The load&amp;rsquo;s host buffer wants to be &lt;strong>pinned&lt;/strong> and &lt;strong>NUMA-local&lt;/strong>, the same thing the &lt;a href="https://blog.lo0.es/en/posts/numa-hugepages-cpu-isolation-gpu-waits-kernel/">host post&lt;/a> asked for on the hot path. The loading path is another client of the same NUMA map.&lt;/p>
&lt;p>&lt;strong>With quantisation.&lt;/strong> &lt;a href="https://blog.lo0.es/en/posts/quantization-llm-inference-fp8-int4-gguf/">FP8/INT4&lt;/a> is not only inference throughput: it is the direct lever on the bytes of the cold start.&lt;/p>
&lt;p>&lt;strong>With capacity planning.&lt;/strong> &lt;a href="https://blog.lo0.es/en/posts/capacity-planning-on-premise-llm-inference/">Sizing&lt;/a> that ignores cold start underestimates how many replicas are needed to absorb a peak: if they take 40 s to start, you need a bigger permanent cushion.&lt;/p>
&lt;h2 id="traps-and-things-that-are-not-what-they-seem">Traps and things that are not what they seem&lt;/h2>
&lt;p>&lt;strong>&amp;ldquo;The second time it loaded really fast.&amp;rdquo;&lt;/strong> This is the star trap. The first load filled the &lt;strong>page cache&lt;/strong> (host RAM); the second reads from RAM, not from disk, and flies. But in production pods are ephemeral and are born on different nodes: the start-up that counts is the &lt;strong>cold&lt;/strong> one, on a node where those files are not in page cache. Benchmarking the second load is measuring a situation that almost never happens at the moment that matters (the peak that triggers the autoscaler).&lt;/p>
&lt;p>&lt;strong>&amp;ldquo;GDS always speeds things up.&amp;rdquo;&lt;/strong> No. GDS removes the bounce through the CPU; if your bottleneck is the disk itself (saturated NVMe) or the deserialisation, GDS does not touch that part. Measure first (knob 1). It also demands &lt;code>nvidia-fs&lt;/code>, a supported filesystem, and sometimes it does not work over whatever network storage you have.&lt;/p>
&lt;p>&lt;strong>&amp;ldquo;mmap makes loading instant.&amp;rdquo;&lt;/strong> &lt;code>mmap&lt;/code> maps the file but &lt;strong>reads nothing yet&lt;/strong>: the cost is deferred to the first access to each page. The time does not disappear, it moves, and the &lt;strong>first token&lt;/strong> pays the page faults that start-up did not. You have moved the cold start into the latency of the first request, which is probably a worse place to have it.&lt;/p>
&lt;p>&lt;strong>Weights on network storage &amp;ldquo;because it is cleaner&amp;rdquo;.&lt;/strong> Sharing a model repository on Ceph RGW is fine for storing them; serving the cold start from there puts the network (and its latency and its contention) into the critical path. Local NVMe cache on the inference node.&lt;/p>
&lt;p>&lt;strong>Loading FP16 and quantising at start-up.&lt;/strong> Quantising on the fly during loading (for example FP16→FP8 on the GPU) can be &lt;strong>slower&lt;/strong> than having the weights already quantised on disk: you move twice the bytes and on top of that you do conversion work. If you are going to serve in FP8, store the weights in FP8.&lt;/p>
&lt;p>&lt;strong>Optimising the load and ignoring &lt;code>T_cuda-graphs&lt;/code>.&lt;/strong> Bringing weight loading down to 8 s and forgetting that CUDA graph capture adds several more seconds leaves the cold start half done. That second half of start-up is the subject of the &lt;a href="https://blog.lo0.es/en/posts/sms-cuda-streams-cuda-graphs-inference/">next post&lt;/a>.&lt;/p>
&lt;h2 id="conclusion">Conclusion&lt;/h2>
&lt;p>The whole series optimised what happens &lt;strong>with every token&lt;/strong>: the cable, the host, the network, the silicon. But before the first token there is a journey almost nobody times and that decides whether an inference pod opens in 10 s or in 60 s: pushing the model from disk to HBM. Intuition blames the GPU, and the GPU is innocent: HBM swallows 140 GB in 42 ms. The bottleneck is the &lt;strong>supply chain&lt;/strong>: a disk reading at 14 GB/s, a PCIe copying at 50, and above all a default loader that deserialises tensor by tensor with a single thread and turns 10 s of bytes into 60 s of waiting. The solutions attack the two right fronts, &lt;strong>fewer bytes&lt;/strong> (quantisation) and &lt;strong>better transport&lt;/strong> (GDS, concurrent streamers, local NVMe), and give 4-7× almost for free. And above the technique, an idea that reorders the priorities: on an elastic platform, cold start &lt;strong>is not a start-up detail, it is the ceiling on elasticity&lt;/strong>. The fastest GPU in the world does not scale if it takes 40 s to have the model inside. The pantry lift, the one nobody timed, is what decides what time the kitchen really opens.&lt;/p>
&lt;h2 id="see-also">See also&lt;/h2>
&lt;ul>
&lt;li>
&lt;p>&lt;a href="https://blog.lo0.es/en/posts/speeding-up-model-cold-start-minutes-to-seconds/">Speeding up model cold start: from minutes to seconds&lt;/a> — the runbook that attacks each of the five line items of start-up.&lt;/p>
&lt;/li>
&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> — sleep mode dodges this cold start by parking the weights in RAM instead of on disk.&lt;/p>
&lt;/li>
&lt;li>
&lt;p>&lt;a href="https://blog.lo0.es/en/posts/pcie-topology-gpudirect-p2p-acs-security-guard/">The corridors and the guard: PCIe, GPUDirect P2P and ACS&lt;/a> — the bus GDS uses to push the weights up from NVMe; ACS enabled throttles that direct path just as it throttles P2P between GPUs.&lt;/p>
&lt;/li>
&lt;li>
&lt;p>&lt;a href="https://blog.lo0.es/en/posts/vllm-scheduler-step-maitre-d-each-round/">The pass: vLLM&amp;rsquo;s scheduler step&lt;/a> — cold start is the ceiling on the elasticity the scheduler exercises: preemption and scale-from-zero pay this load in every new pod.&lt;/p>
&lt;/li>
&lt;li>
&lt;p>&lt;a href="https://blog.lo0.es/en/posts/nvlink-nvswitch-nccl-cable-every-token-crosses/">The shared table: NVLink, NVSwitch and NCCL&lt;/a> — the first of &amp;ldquo;under the engine&amp;rdquo;; this post goes down to the basement that series took for granted as full.&lt;/p>
&lt;/li>
&lt;li>
&lt;p>&lt;a href="https://blog.lo0.es/en/posts/numa-hugepages-cpu-isolation-gpu-waits-kernel/">The floor next door: NUMA, hugepages and CPU isolation&lt;/a> — the pinned, NUMA-local memory the loading path needs for an efficient H2D.&lt;/p>
&lt;/li>
&lt;li>
&lt;p>&lt;a href="https://blog.lo0.es/en/posts/network-numa-cilium-ebpf-dranet-fourth-leg-pinning/">The kitchen door: network NUMA, Cilium eBPF and DRANET&lt;/a> — the same &amp;ldquo;get the CPU out of the way&amp;rdquo; principle (GPUDirect) that GDS applies here to the disk.&lt;/p>
&lt;/li>
&lt;li>
&lt;p>&lt;a href="https://blog.lo0.es/en/posts/sms-cuda-streams-cuda-graphs-inference/">SM, CUDA streams and CUDA graphs&lt;/a> — the second half of the cold start (graph capture) and what happens on the silicon once the weights are inside.&lt;/p>
&lt;/li>
&lt;li>
&lt;p>&lt;a href="https://blog.lo0.es/en/posts/quantization-llm-inference-fp8-int4-gguf/">Quantization for inference&lt;/a> — the lever that halves the bytes of the cold start, not just HBM bandwidth per token.&lt;/p>
&lt;/li>
&lt;li>
&lt;p>&lt;a href="https://blog.lo0.es/en/posts/llm-inference-autoscaling-kubernetes-hpa-keda/">LLM autoscaling on Kubernetes with KEDA&lt;/a> — why cold start is the real ceiling on elasticity and kills scale-to-zero.&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 separated&lt;/a> — bringing up pools on demand pays the cold start in every new pod.&lt;/p>
&lt;/li>
&lt;li>
&lt;p>&lt;a href="https://blog.lo0.es/en/posts/canary-blue-green-shadow-llm-models/">Canary, blue-green and shadow for models&lt;/a> — the validation time of a canary includes its model loading.&lt;/p>
&lt;/li>
&lt;li>
&lt;p>&lt;a href="https://blog.lo0.es/en/posts/capacity-planning-on-premise-llm-inference/">Capacity planning for on-premise inference&lt;/a> — cold start as a parameter of the replica cushion and of the warm-vs-zero decision.&lt;/p>
&lt;/li>
&lt;/ul>
&lt;h2 id="references">References&lt;/h2>
&lt;ul>
&lt;li>NVIDIA, &lt;em>Reducing Cold Start Latency for LLM Inference with NVIDIA Run:ai Model Streamer&lt;/em>: &lt;a href="https://developer.nvidia.com/blog/reducing-cold-start-latency-for-llm-inference-with-nvidia-runai-model-streamer/">https://developer.nvidia.com/blog/reducing-cold-start-latency-for-llm-inference-with-nvidia-runai-model-streamer/&lt;/a>.&lt;/li>
&lt;li>vLLM, &lt;em>Loading models with Run:ai Model Streamer&lt;/em>: &lt;a href="https://docs.vllm.ai/en/stable/models/extensions/runai_model_streamer/">https://docs.vllm.ai/en/stable/models/extensions/runai_model_streamer/&lt;/a>.&lt;/li>
&lt;li>vLLM, &lt;em>Loading model weights with fastsafetensors&lt;/em>: &lt;a href="https://docs.vllm.ai/en/stable/models/extensions/fastsafetensor/">https://docs.vllm.ai/en/stable/models/extensions/fastsafetensor/&lt;/a>.&lt;/li>
&lt;li>foundation-model-stack, &lt;em>fastsafetensors&lt;/em> (high-performance loader, GDS): &lt;a href="https://github.com/foundation-model-stack/fastsafetensors">https://github.com/foundation-model-stack/fastsafetensors&lt;/a>.&lt;/li>
&lt;li>&lt;em>Speeding up Model Loading with fastsafetensors&lt;/em> (arXiv 2505.23072): &lt;a href="https://arxiv.org/html/2505.23072v1">https://arxiv.org/html/2505.23072v1&lt;/a>.&lt;/li>
&lt;li>NVIDIA, &lt;em>GPUDirect Storage: A Direct Path Between Storage and GPU Memory&lt;/em>: &lt;a href="https://developer.nvidia.com/blog/gpudirect-storage/">https://developer.nvidia.com/blog/gpudirect-storage/&lt;/a>.&lt;/li>
&lt;li>NVIDIA, &lt;em>Magnum IO GPUDirect Storage&lt;/em> (benchmarking and configuration): &lt;a href="https://developer.nvidia.com/gpudirect-storage">https://developer.nvidia.com/gpudirect-storage&lt;/a>.&lt;/li>
&lt;/ul></description></item><item><title>The floor next door: NUMA, hugepages and CPU isolation, or why your GPU waits on the kernel</title><link>https://blog.lo0.es/en/posts/numa-hugepages-cpu-isolation-gpu-waits-kernel/</link><pubDate>Sat, 06 Jun 2026 07:30:00 +0200</pubDate><guid>https://blog.lo0.es/en/posts/numa-hugepages-cpu-isolation-gpu-waits-kernel/</guid><description>&lt;blockquote>
&lt;p>Second post in the &amp;ldquo;below the engine&amp;rdquo; series. The &lt;a href="https://blog.lo0.es/en/posts/nvlink-nvswitch-nccl-cable-every-token-crosses/">first&lt;/a> opened up the cable between GPUs (NVLink/NCCL). This one goes down to the host: the cores, the memory and the kernel that surround those GPUs and that, badly configured, leave them waiting. The &lt;a href="https://blog.lo0.es/en/posts/cpu-memory-topology-manager-rke2/">third&lt;/a> will explain how Kubernetes automates all of this; here is the raw layer, the one you have to understand before delegating it.&lt;/p>
&lt;/blockquote>
&lt;h2 id="tldr">TL;DR&lt;/h2>
&lt;p>A node with 4×H100 SXM is, physically, a two-socket server = two NUMA domains. Each socket has its own cores, its own memory channels and PCIe lanes towards half of the GPUs and NICs. Inference is not only GPU: the host does work on the hot path of every token, launching CUDA kernels, sampling the next token, tokenising, moving &lt;em>pinned&lt;/em> buffers between host and GPU, running the NCCL threads. If those threads and their memory land on the socket that is &lt;strong>not&lt;/strong> local to the GPU, every access crosses the inter-socket link (UPI/Infinity Fabric): &lt;strong>2-3× slower and with p99 spikes&lt;/strong>. There are three kernel levers that decide the latency tail and that almost nobody touches: locality (NUMA affinity: CPU, memory, GPU and NIC on the same &amp;ldquo;floor&amp;rdquo;), page tables (hugepages: a few large pages instead of millions of small ones, plus &lt;em>pinned memory&lt;/em> for DMA), and jitter (CPU isolation with &lt;code>isolcpus&lt;/code>/&lt;code>nohz_full&lt;/code>/&lt;code>rcu_nocbs&lt;/code> plus IRQ affinity, so that the kernel does not interrupt the thread launching the next decode kernel). This post explains the mechanism, gives the 10 real knobs, and connects with the interconnect and latency-bound decode. With scepticism about which knobs move the needle in inference and which are cargo cult inherited from low-latency trading.&lt;/p>
&lt;h2 id="where-you-are-the-host-below-the-cable">Where you are: the host below the cable&lt;/h2>
&lt;div class="diagram" style="max-width:560px;margin:1.2rem auto;">
&lt;svg viewBox="0 0 560 320" xmlns="http://www.w3.org/2000/svg" role="img" aria-label="You are at the host: NUMA, kernel, memory, below the interconnect">
&lt;text x="280" y="24" text-anchor="middle" font-family="sans-serif" font-size="13" font-weight="700" fill="currentColor">The vertical stack · you are at the host&lt;/text>
&lt;rect x="120" y="40" width="320" height="38" rx="6" fill="#f4f4f4" stroke="#888" stroke-width="1.3"/>
&lt;text x="280" y="64" text-anchor="middle" font-family="sans-serif" font-size="12" fill="#333">Engine · vLLM / SGLang (TP, batching)&lt;/text>
&lt;rect x="120" y="84" width="320" height="38" rx="6" fill="#f4f4f4" stroke="#888" stroke-width="1.3"/>
&lt;text x="280" y="108" text-anchor="middle" font-family="sans-serif" font-size="12" fill="#333">CUDA + NCCL (collectives)&lt;/text>
&lt;rect x="120" y="128" width="320" height="38" rx="6" fill="#eef3fb" stroke="#4a6fa5" stroke-width="1.4"/>
&lt;text x="280" y="152" text-anchor="middle" font-family="sans-serif" font-size="12" fill="#333">NVLink + NVSwitch (previous post)&lt;/text>
&lt;rect x="120" y="172" width="320" height="58" rx="6" fill="#dceede" stroke="#3c8c54" stroke-width="3"/>
&lt;text x="280" y="196" text-anchor="middle" font-family="sans-serif" font-size="12.5" font-weight="700" fill="#1f5c34">YOU ARE HERE · host: NUMA + kernel + memory&lt;/text>
&lt;text x="280" y="214" text-anchor="middle" font-family="sans-serif" font-size="10.5" fill="#2c6b42">cores, memory channels, scheduler, IRQs&lt;/text>
&lt;rect x="120" y="236" width="320" height="38" rx="6" fill="#f4f4f4" stroke="#888" stroke-width="1.3"/>
&lt;text x="280" y="260" text-anchor="middle" font-family="sans-serif" font-size="12" fill="#333">Hardware · 2 sockets, PCIe, HBM&lt;/text>
&lt;text x="280" y="298" text-anchor="middle" font-family="sans-serif" font-size="10.5" font-style="italic" fill="#777">the GPU computes, but the host launches, samples and moves data per token&lt;/text>
&lt;/svg>
&lt;/div>
&lt;h2 id="the-analogy-the-two-floor-office">The analogy: the two-floor office&lt;/h2>
&lt;p>Picture a consultancy in a building with two floors. On each floor there are desks (CPU cores), an archive with the files (the memory of that socket) and a loading bay connecting to the outside (the PCIe lanes towards the GPUs and NICs of that socket). An analyst works fast &lt;strong>while everything they need is on their floor&lt;/strong>: they reach out and take the file from the archive next to them.&lt;/p>
&lt;p>The problem starts when the analyst is on floor 1 but their file is in the archive on floor 2. Every time they need it, they &lt;strong>take the lift&lt;/strong>. The work &amp;ldquo;functions&amp;rdquo;, but every lookup costs a trip. And if on top of that the loading bay through which their materials arrive (their GPU) is on the other floor, &lt;strong>every delivery crosses the building&lt;/strong>. This is NUMA: local access (same floor) is fast; remote access (another floor, via the inter-socket link) is 2-3× slower.&lt;/p>
&lt;p>And there are two more ways to ruin that analyst even when they are on the right floor:&lt;/p>
&lt;ul>
&lt;li>
&lt;p>&lt;strong>Interrupting them constantly.&lt;/strong> Every few minutes, a tannoy announcement, a colleague with a question, a fire alarm test. Every interruption breaks their concentration right when they were about to deliver. This is kernel jitter: the scheduler tick, device IRQs, RCU callbacks, interrupting the host thread right when it was about to launch the next GPU kernel. CPU isolation is putting them in an &lt;strong>office with a &amp;ldquo;do not disturb&amp;rdquo; sign&lt;/strong>.&lt;/p>
&lt;/li>
&lt;li>
&lt;p>&lt;strong>Giving them an index of a thousand tiny tabs.&lt;/strong> If to find each file they have to search an index with a million minuscule entries, they lose time in the search. If the index has &lt;strong>a few large entries&lt;/strong>, they find it instantly. These are hugepages: pages of 2 MB or 1 GB instead of 4 KB reduce the pressure on the TLB (the cache of the page index).&lt;/p>
&lt;/li>
&lt;/ul>
&lt;p>The thesis: &lt;strong>the GPU is expensive and fast, but it spends a surprising fraction of decode waiting on the host.&lt;/strong> If the host is on the wrong floor, interrupted, and searching a giant index, the GPU, the 30,000 € resource, waits. The three levers in this post exist so that it does not wait.&lt;/p>
&lt;h2 id="the-mechanism-what-the-host-does-on-the-token-path">The mechanism: what the host does on the token path&lt;/h2>
&lt;p>It is tempting to think that in inference &amp;ldquo;the GPU does everything&amp;rdquo;. It is not true. For every token, the host (CPU) does, at a minimum:&lt;/p>
&lt;ul>
&lt;li>&lt;strong>Launch the CUDA kernels&lt;/strong> for each operation. The GPU does not decide what to execute; the host keeps putting kernels in its queue. In decode, where each kernel is short, the host has to run &lt;strong>ahead&lt;/strong> feeding the queue; if the host thread stalls, the GPU runs out of work: a bubble.&lt;/li>
&lt;li>Sample the next token (argmax/top-p/top-k over the logits), which comes back from device to host.&lt;/li>
&lt;li>Tokenise the input and detokenise the output.&lt;/li>
&lt;li>&lt;strong>Move pinned buffers&lt;/strong> (page-locked) between host and GPU by DMA: prompts, logits, and in offload configurations, part of the KV cache.&lt;/li>
&lt;li>&lt;strong>Run the NCCL threads&lt;/strong> that coordinate the collectives of the &lt;a href="https://blog.lo0.es/en/posts/nvlink-nvswitch-nccl-cable-every-token-crosses/">previous post&lt;/a>.&lt;/li>
&lt;/ul>
&lt;p>All of that is CPU and host memory work. And all of it suffers if:&lt;/p>
&lt;ol>
&lt;li>&lt;strong>The process runs on the socket that is not local to its GPU&lt;/strong> → every DMA and every memory access crosses the inter-socket link.&lt;/li>
&lt;li>&lt;strong>The kernel interrupts the threads&lt;/strong> → bubbles in the GPU queue.&lt;/li>
&lt;li>&lt;strong>The memory is not pinned or uses small pages&lt;/strong> → page faults, TLB misses, and worse: if the memory of the DMA transfer is not pinned, the driver makes an intermediate copy.&lt;/li>
&lt;/ol>
&lt;h3 id="the-map-nvidia-smi-topo--m">The map: &lt;code>nvidia-smi topo -m&lt;/code>&lt;/h3>
&lt;p>Everything starts by looking at the map. &lt;code>nvidia-smi topo -m&lt;/code> shows, for each GPU, which NUMA node and which cores it is local to, and through what kind of path it talks to each NIC and to each other GPU:&lt;/p>
&lt;pre tabindex="0">&lt;code> GPU0 GPU1 GPU2 GPU3 NIC0 CPU Affinity NUMA Affinity
GPU0 X NV18 NV18 NV18 PIX 0-31,64-95 0
GPU1 NV18 X NV18 NV18 SYS 0-31,64-95 0
GPU2 NV18 NV18 X NV18 SYS 32-63,96-127 1
GPU3 NV18 NV18 NV18 X SYS 32-63,96-127 1
&lt;/code>&lt;/pre>&lt;p>Read it like this: GPU0 and GPU1 are local to NUMA node 0 (cores 0-31, 64-95); GPU2 and GPU3 to NUMA node 1. &lt;code>NV18&lt;/code> between GPUs = 18 NVLink links (the good stuff, from the previous post). In the NIC column: &lt;code>PIX&lt;/code> = a single PCIe switch in between (optimal for GPUDirect RDMA); &lt;code>SYS&lt;/code> = the path crosses the inter-socket link (the worst). &lt;strong>The rule&lt;/strong>: the process serving on GPU0/1 must be pinned to cores 0-31/64-95 and to the memory of node 0; and if it also uses RDMA, you want the NIC that is on &lt;code>PIX&lt;/code> with its GPU.&lt;/p>
&lt;h2 id="the-maths-that-matter-a-kernel-tick-is-a-gpu-bubble">The maths that matter: a kernel tick is a GPU bubble&lt;/h2>
&lt;p>The figure worth internalising is not the bandwidth one, it is the jitter one. In decode, the host launches many short kernels per token. If the host thread that launches them is &lt;strong>preempted&lt;/strong> by the scheduler (a timer tick, an IRQ, an RCU callback) for $t_{\text{stall}}$, and the GPU drains its queue in that time, a bubble appears: the GPU stops.&lt;/p>
&lt;p>Put numbers on it. A typical timer tick or the handling of an IRQ costs of the order of &lt;strong>tens of microseconds&lt;/strong> of diversion. If a decode kernel lasts around 50-100 µs and the queue carries 2-3 kernels in flight, a host stall of &lt;strong>50-100 µs&lt;/strong> drains the queue and the GPU sits idle until the host resumes. Multiply by the interrupt frequency of a &lt;strong>non&lt;/strong>-isolated kernel (the default tick is 250-1000 Hz, plus network and disk IRQs): the p99/p999 tail of TTFT and of inter-token time fills up with these episodes.&lt;/p>
$$ \text{jitter}_{p99} \approx f_{\text{interrupts}} \times t_{\text{stall}} \times \mathbb{1}[\text{GPU queue drained}] $$
&lt;p>The intuition: in average throughput it is barely noticeable (the bubbles average out), but in &lt;strong>the tail&lt;/strong>, which is what an SLO measures, kernel jitter is a first-order contributor. That is why CPU isolation, which was born in low-latency trading, makes sense in LLM decode: &lt;strong>it is the same physics, a critical thread that cannot afford the kernel stopping it&lt;/strong>.&lt;/p>
&lt;p>And the NUMA cost, in parallel: a remote memory access (another floor) has latency around 1.5-2× the local one and half the bandwidth. For the pinned buffers moved by DMA on every step, and for the vLLM scheduler structures that live on the host, that penalty is paid token by token.&lt;/p>
&lt;h2 id="the-three-levers-one-by-one">The three levers, one by one&lt;/h2>
&lt;h3 id="locality-numa-getting-everything-on-the-same-floor">Locality (NUMA): getting everything on the same floor&lt;/h3>
&lt;p>The goal is that the inference process using GPU0/1 has its cores, its memory and (if applicable) its NIC on NUMA node 0. Raw, without Kubernetes:&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">&lt;span class="c1"># Pin process to node 0 (cores and memory) to serve on GPU0/1&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">numactl --cpunodebind&lt;span class="o">=&lt;/span>&lt;span class="m">0&lt;/span> --membind&lt;span class="o">=&lt;/span>&lt;span class="m">0&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> vllm serve meta-llama/Llama-3-70B --tensor-parallel-size &lt;span class="m">2&lt;/span>
&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>&lt;code>--membind=0&lt;/code> is the key: it forces &lt;strong>all&lt;/strong> the process memory to be allocated on node 0. Without &lt;code>--membind&lt;/code>, the kernel can place pages on node 1 under pressure, and you start paying for the lift without knowing it.&lt;/p>
&lt;h3 id="page-tables-hugepages-a-few-large-pages-and-pinned-memory">Page tables (hugepages): a few large pages and pinned memory&lt;/h3>
&lt;p>Two different things under the same umbrella. First, hugepages reduce TLB pressure for the large host buffers (pinned, KV offload). Second, pinned memory (page-locked) is what allows direct DMA with no intermediate copy. The silent trap is transparent hugepages (THP): their background compaction causes &lt;strong>latency spikes&lt;/strong>, exactly what you do not want.&lt;/p>
&lt;h3 id="jitter-cpu-isolation-the-office-with-do-not-disturb">Jitter (CPU isolation): the office with &amp;ldquo;do not disturb&amp;rdquo;&lt;/h3>
&lt;p>Three kernel boot parameters, coordinated:&lt;/p>
&lt;pre tabindex="0">&lt;code>isolcpus=2-31,66-95 # take these cores out of the scheduler&amp;#39;s balancing
nohz_full=2-31,66-95 # tickless: no timer tick if there is only 1 runnable thread
rcu_nocbs=2-31,66-95 # offload RCU callbacks to housekeeping cores
&lt;/code>&lt;/pre>&lt;p>&lt;code>isolcpus&lt;/code> sets the cores aside; &lt;strong>you&lt;/strong> have to pin the inference threads there (the non-isolated cores, 0-1, are left for the system). &lt;code>nohz_full&lt;/code> removes the periodic tick (it only works if there is &lt;strong>a single&lt;/strong> runnable thread on the core). &lt;code>rcu_nocbs&lt;/code> takes RCU work off those cores. And separately, IRQ affinity: moving device interrupts away from the inference cores.&lt;/p>
&lt;h2 id="the-10-knobs-worth-touching">The 10 knobs worth touching&lt;/h2>
&lt;p>Ordered by impact/frequency. Almost all of them are sysctls, kernel boot parameters or &lt;code>numactl&lt;/code>. The low-level reference is &lt;a href="https://rigtorp.se/low-latency-guide/">Rigtorp&amp;rsquo;s low-latency guide&lt;/a> and &lt;a href="https://access.redhat.com/articles/3720611">Red Hat&amp;rsquo;s real-time docs&lt;/a>.&lt;/p>
&lt;h3 id="knob-1--nvidia-smi-topo--m-look-at-the-map-before-touching-anything">Knob 1 — &lt;code>nvidia-smi topo -m&lt;/code>: look at the map before touching anything&lt;/h3>
&lt;p>Just as in the interconnect post: &lt;strong>the map first&lt;/strong>. Which GPU is local to which NUMA node and to which cores, and what path (PIX/PHB/SYS) there is to each NIC. Without this, any pinning is blind. Half of the &amp;ldquo;inference has latency spikes&amp;rdquo; problems are processes running on the wrong socket without anyone having looked.&lt;/p>
&lt;h3 id="knob-2--numactl---cpunodebind---membind-pin-to-the-local-node">Knob 2 — &lt;code>numactl --cpunodebind --membind&lt;/code>: pin to the local node&lt;/h3>
&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">numactl --cpunodebind&lt;span class="o">=&lt;/span>&lt;span class="m">0&lt;/span> --membind&lt;span class="o">=&lt;/span>&lt;span class="m">0&lt;/span> &amp;lt;process&amp;gt; &lt;span class="c1"># cores AND memory on node 0&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">numactl --hardware &lt;span class="c1"># see nodes, distances, free memory&lt;/span>
&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>The &lt;code>--membind&lt;/code> is what really matters: without it, the memory scatters. It is the highest-impact knob on sustained throughput.&lt;/p>
&lt;h3 id="knob-3--kernelnuma_balancing0-turn-off-automatic-migration">Knob 3 — &lt;code>kernel.numa_balancing=0&lt;/code>: turn off automatic migration&lt;/h3>
&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">sysctl -w kernel.numa_balancing&lt;span class="o">=&lt;/span>&lt;span class="m">0&lt;/span>
&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>The kernel&amp;rsquo;s &lt;em>automatic NUMA balancing&lt;/em> migrates pages between nodes trying to &amp;ldquo;bring them closer&amp;rdquo;, but that background work &lt;strong>causes jitter&lt;/strong> and, with the memory already pinned by knob 2, it adds nothing. On dedicated inference nodes, turn it off.&lt;/p>
&lt;h3 id="knob-4--explicit-hugepages-1-gb-for-host-buffers">Knob 4 — Explicit hugepages (1 GB) for host buffers&lt;/h3>
&lt;pre tabindex="0">&lt;code># Kernel boot, for KV offload / large pinned buffers
default_hugepagesz=1G hugepagesz=1G hugepages=32
&lt;/code>&lt;/pre>&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">grep Huge /proc/meminfo &lt;span class="c1"># verify the reservation&lt;/span>
&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>Useful &lt;strong>when there is host memory on the hot path&lt;/strong> (vLLM with &lt;code>--cpu-offload-gb&lt;/code>, or large pinned buffers). If your deployment does not touch host memory in the hot path, explicit hugepages add little, so do not set them out of cargo cult.&lt;/p>
&lt;h3 id="knob-5--thp-on-madvise-or-never-avoid-the-compaction-spikes">Knob 5 — THP on &lt;code>madvise&lt;/code> or &lt;code>never&lt;/code>: avoid the compaction spikes&lt;/h3>
&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">&lt;span class="nb">echo&lt;/span> madvise &amp;gt; /sys/kernel/mm/transparent_hugepage/enabled
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="nb">echo&lt;/span> never &amp;gt; /sys/kernel/mm/transparent_hugepage/defrag
&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>Transparent hugepages on &lt;code>always&lt;/code> save TLB but their &lt;strong>compaction&lt;/strong> triggers unpredictable latency. For tail-sensitive workloads, &lt;code>madvise&lt;/code> (only where the app asks for it) or &lt;code>never&lt;/code> is what is recommended. It is one of the few knobs with clear consensus: &lt;strong>THP always is bad for latency&lt;/strong>.&lt;/p>
&lt;h3 id="knob-6--isolcpus-set-the-inference-cores-aside-from-the-scheduler">Knob 6 — &lt;code>isolcpus&lt;/code>: set the inference cores aside from the scheduler&lt;/h3>
&lt;pre tabindex="0">&lt;code>isolcpus=2-31,66-95
&lt;/code>&lt;/pre>&lt;p>Takes those cores out of the scheduler&amp;rsquo;s load balancing; the system (kernel threads, daemons) stays on the non-isolated ones. &lt;strong>You have to pin&lt;/strong> the inference threads explicitly to the isolated cores (via &lt;code>numactl&lt;/code>/&lt;code>taskset&lt;/code> or, on K8s, the CPU Manager of the next post). Isolating without pinning is useless.&lt;/p>
&lt;h3 id="knob-7--nohz_full--rcu_nocbs-tickless-and-no-rcu-on-the-critical-cores">Knob 7 — &lt;code>nohz_full&lt;/code> + &lt;code>rcu_nocbs&lt;/code>: tickless and no RCU on the critical cores&lt;/h3>
&lt;pre tabindex="0">&lt;code>nohz_full=2-31,66-95 rcu_nocbs=2-31,66-95
&lt;/code>&lt;/pre>&lt;p>Removes the periodic timer tick and the RCU callbacks from the inference cores. &lt;strong>Two warnings from practice&lt;/strong>: &lt;code>nohz_full&lt;/code> only eliminates the tick if there is &lt;strong>a single runnable thread&lt;/strong> on the core (if you pin two threads there, the tick comes back); and &lt;code>nohz_full&lt;/code> &lt;strong>is not compatible with the &lt;code>intel_pstate&lt;/code> driver&lt;/strong> in some configurations, which has to be validated, not assumed.&lt;/p>
&lt;h3 id="knob-8--irq-affinity-interrupts-away-from-the-inference-cores">Knob 8 — IRQ affinity: interrupts, away from the inference cores&lt;/h3>
&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">systemctl stop irqbalance &lt;span class="c1"># or configure it to respect isolcpus&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="c1"># move a device&amp;#39;s IRQs to the housekeeping cores (0-1)&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="nb">echo&lt;/span> &lt;span class="m">3&lt;/span> &amp;gt; /proc/irq/&amp;lt;N&amp;gt;/smp_affinity &lt;span class="c1"># mask for cores 0-1&lt;/span>
&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>A network or disk IRQ landing on an inference core is a direct interruption of the thread feeding the GPU. Move them to the housekeeping cores. (&lt;code>irqbalance&lt;/code> can respect &lt;code>isolcpus&lt;/code> automatically if it is configured to.)&lt;/p>
&lt;h3 id="knob-9--cpu-governor-performance--c-states">Knob 9 — CPU governor &lt;code>performance&lt;/code> + C-states&lt;/h3>
&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">cpupower frequency-set -g performance
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="c1"># stop idle cores entering deep C-states (wakeup latency)&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">cpupower idle-set -D &lt;span class="m">0&lt;/span> &lt;span class="c1"># or limit the C-state depth&lt;/span>
&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>With the &lt;code>powersave&lt;/code>/&lt;code>ondemand&lt;/code> governor, a core that was idle takes time to ramp up its frequency: wakeup latency exactly when work arrives. &lt;code>performance&lt;/code> keeps it flat out. On servers dedicated to inference, the energy saving does not compensate for the latency tail.&lt;/p>
&lt;h3 id="knob-10--memory-locking--swappiness0">Knob 10 — Memory locking + &lt;code>swappiness=0&lt;/code>&lt;/h3>
&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">sysctl -w vm.swappiness&lt;span class="o">=&lt;/span>&lt;span class="m">0&lt;/span> &lt;span class="c1"># do not evict inference pages to swap&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="c1"># and in the app / container: ulimit -l unlimited (memlock) for pinned memory&lt;/span>
&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>An inference page that the kernel decides to swap to disk is a page fault of milliseconds on the hot path. &lt;code>swappiness=0&lt;/code> and adequate &lt;code>memlock&lt;/code> limits (so the driver can pin memory) close that door. On K8s, this translates into &lt;code>Guaranteed&lt;/code> QoS and memory limits, the bridge to the next post.&lt;/p>
&lt;h3 id="summary-table">Summary table&lt;/h3>
&lt;table>
&lt;thead>
&lt;tr>
&lt;th>#&lt;/th>
&lt;th>Knob&lt;/th>
&lt;th>Mechanism&lt;/th>
&lt;th>What it attacks&lt;/th>
&lt;/tr>
&lt;/thead>
&lt;tbody>
&lt;tr>
&lt;td>1&lt;/td>
&lt;td>&lt;code>nvidia-smi topo -m&lt;/code>&lt;/td>
&lt;td>diagnosis&lt;/td>
&lt;td>see GPU–NUMA–NIC affinity&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>2&lt;/td>
&lt;td>&lt;code>numactl --cpunodebind --membind&lt;/code>&lt;/td>
&lt;td>pinning&lt;/td>
&lt;td>locality (the biggest lever)&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>3&lt;/td>
&lt;td>&lt;code>kernel.numa_balancing=0&lt;/code>&lt;/td>
&lt;td>sysctl&lt;/td>
&lt;td>jitter from page migration&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>4&lt;/td>
&lt;td>explicit 1G hugepages&lt;/td>
&lt;td>boot param&lt;/td>
&lt;td>TLB on host buffers&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>5&lt;/td>
&lt;td>THP &lt;code>madvise&lt;/code>/&lt;code>never&lt;/code>&lt;/td>
&lt;td>sysfs&lt;/td>
&lt;td>compaction spikes&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>6&lt;/td>
&lt;td>&lt;code>isolcpus&lt;/code>&lt;/td>
&lt;td>boot param&lt;/td>
&lt;td>scheduler off the critical cores&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>7&lt;/td>
&lt;td>&lt;code>nohz_full&lt;/code>+&lt;code>rcu_nocbs&lt;/code>&lt;/td>
&lt;td>boot param&lt;/td>
&lt;td>tick + RCU jitter&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>8&lt;/td>
&lt;td>IRQ affinity&lt;/td>
&lt;td>&lt;code>/proc/irq&lt;/code>&lt;/td>
&lt;td>device interrupts&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>9&lt;/td>
&lt;td>&lt;code>performance&lt;/code> governor&lt;/td>
&lt;td>cpupower&lt;/td>
&lt;td>frequency wakeup latency&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>10&lt;/td>
&lt;td>&lt;code>swappiness=0&lt;/code> + memlock&lt;/td>
&lt;td>sysctl/ulimit&lt;/td>
&lt;td>page faults on the hot path&lt;/td>
&lt;/tr>
&lt;/tbody>
&lt;/table>
&lt;h2 id="how-it-connects-with-the-rest-of-the-stack">How it connects with the rest of the stack&lt;/h2>
&lt;p>&lt;strong>With the interconnect (previous post).&lt;/strong> The NCCL host threads want cores local to their GPU; and for multi-node, the RDMA NIC must be on the &lt;code>PIX&lt;/code> path with its GPU (knob 1). A GPUDirect RDMA with the NIC under the other socket loses half its advantage. NUMA and NVLink are the same story seen from the host and from the cable.&lt;/p>
&lt;p>&lt;strong>With vLLM and decode.&lt;/strong> &lt;a href="https://blog.lo0.es/en/posts/optimising-decode-vllm-squeezing-every-token-small-hardware/">Decode is latency-bound&lt;/a>: the host thread feeding the kernel queue is exactly the one CPU isolation protects. And vLLM&amp;rsquo;s &lt;code>--cpu-offload-gb&lt;/code> puts host memory on the hot path, where NUMA locality plus hugepages (knobs 2, 4) go from &amp;ldquo;nice&amp;rdquo; to &amp;ldquo;critical&amp;rdquo;. &lt;a href="https://blog.lo0.es/en/posts/continuous-batching-orca-vllm-chunked-prefill-goodput/">Continuous batching&lt;/a> helps here too: more tokens per iteration amortise both the collective latency and the fixed cost of the host launches.&lt;/p>
&lt;p>&lt;strong>With Kubernetes (next post).&lt;/strong> Everything in this post is done &lt;strong>by hand&lt;/strong> (numactl, taskset, boot parameters). In production it is not done by hand: the kubelet automates it with CPU Manager, Memory Manager and Topology Manager. The &lt;a href="https://blog.lo0.es/en/posts/cpu-memory-topology-manager-rke2/">next post&lt;/a> is exactly how this is declared so that every vLLM pod is born pinned to the right NUMA node, with no scripts.&lt;/p>
&lt;p>&lt;strong>With observability.&lt;/strong> The p99 spikes from jitter or from remote access &lt;strong>are visible&lt;/strong>: in DCGM, low GPU utilisation with a full queue (bubbles); in system metrics, inter-socket traffic and CPU migrations. &lt;a href="https://blog.lo0.es/en/posts/gpu-observability-llm-inference-twelve-dcgm-vllm-metrics/">GPU observability with DCGM&lt;/a> is where you diagnose a &amp;ldquo;the GPU is at 60 % and I do not know why&amp;rdquo; that many times is the host waiting.&lt;/p>
&lt;p>&lt;strong>With capacity planning.&lt;/strong> Reserving cores for the system (housekeeping) and dedicating the rest to inference changes the calculation of how many pods/replicas fit per node. &lt;a href="https://blog.lo0.es/en/posts/capacity-planning-on-premise-llm-inference/">Capacity planning&lt;/a> has to count those reserved cores, not assume that all 128 vCPUs are available for serving.&lt;/p>
&lt;h2 id="traps-and-things-that-are-not-what-they-look-like">Traps and things that are not what they look like&lt;/h2>
&lt;p>&lt;strong>Cargo cult from low-latency trading.&lt;/strong> Many &lt;code>isolcpus&lt;/code>/&lt;code>nohz_full&lt;/code> guides come from HFT, where the last microsecond is squeezed out. In LLM inference, CPU isolation &lt;strong>does&lt;/strong> help on the decode tail, but do not expect a miracle: if your bottleneck is HBM bandwidth or the interconnect, isolating cores does not move the needle. Measure first; apply it where the host is the limit.&lt;/p>
&lt;p>&lt;strong>Isolating without pinning.&lt;/strong> &lt;code>isolcpus&lt;/code> takes the cores out of the scheduler, but if you do not pin the inference threads there, those cores stay empty and inference runs on the housekeeping ones, worse than before. Isolating and pinning always go together.&lt;/p>
&lt;p>&lt;strong>Forgotten &lt;code>--membind&lt;/code>.&lt;/strong> Pinning cores but not memory (&lt;code>--cpunodebind&lt;/code> without &lt;code>--membind&lt;/code>) lets the pages scatter to the other node under pressure. Memory pinning is the half that gets forgotten most and the one that pays off most.&lt;/p>
&lt;p>&lt;strong>THP &lt;code>always&lt;/code> &amp;ldquo;because it saves TLB&amp;rdquo;.&lt;/strong> It saves TLB and throws in latency spikes from compaction. For workloads with a tail SLO, it is a bad trade. &lt;code>madvise&lt;/code>/&lt;code>never&lt;/code>.&lt;/p>
&lt;p>&lt;strong>&lt;code>nohz_full&lt;/code> with two threads on the core.&lt;/strong> Tickless only works with a single runnable thread per core. If you pin two inference threads to the same isolated core, the tick comes back and you have complicated the kernel boot for nothing.&lt;/p>
&lt;p>&lt;strong>Assuming the topology instead of reading it.&lt;/strong> Different servers wire GPUs and NICs to different sockets. &lt;code>nvidia-smi topo -m&lt;/code> and &lt;code>numactl --hardware&lt;/code> are the truth; the manufacturer&amp;rsquo;s diagram is indicative. Read it on &lt;strong>every&lt;/strong> node model.&lt;/p>
&lt;h2 id="conclusion">Conclusion&lt;/h2>
&lt;p>The GPU is the expensive resource, but in decode it spends a surprising part of its time waiting on the host: waiting for it to launch the next kernel, to sample, to move a buffer. If that host is on the wrong floor (remote NUMA), interrupted (kernel jitter) or searching a giant index (4 KB pages), the GPU is left with an empty queue and p99 blows up, without any API dashboard saying why. Of the ten knobs, the first (&lt;strong>reading the map with &lt;code>nvidia-smi topo -m&lt;/code>&lt;/strong>) and the second (&lt;strong>pinning cores and memory to the local node with &lt;code>--membind&lt;/code>&lt;/strong>) solve most of it; CPU isolation (&lt;code>isolcpus&lt;/code>/&lt;code>nohz_full&lt;/code>/IRQ affinity) is the second layer, the one that trims the decode tail, and it makes sense &lt;strong>where the host is the limit&lt;/strong>, not as a ritual. The idea that reorders the intuition: inference is not &amp;ldquo;all GPU&amp;rdquo;; it is a dance between GPU and host, and the host dances better close by, uninterrupted, and with few large pages. The next post shows how Kubernetes choreographs that dance for every pod without a single hand-written script.&lt;/p>
&lt;h2 id="see-also">See also&lt;/h2>
&lt;ul>
&lt;li>&lt;a href="https://blog.lo0.es/en/posts/pcie-topology-gpudirect-p2p-acs-security-guard/">The corridors and the guard: PCIe, GPUDirect P2P and ACS&lt;/a> — the PCIe affinity that &lt;code>nvidia-smi topo -m&lt;/code> prints &lt;em>is&lt;/em> the NUMA affinity of this post; placing NIC and NVMe on the right socket avoids the SYS path.&lt;/li>
&lt;li>&lt;a href="https://blog.lo0.es/en/posts/nvlink-nvswitch-nccl-cable-every-token-crosses/">NVLink, NVSwitch and NCCL: the cable every token crosses&lt;/a> — the previous post in the series; the NCCL host threads and the RDMA NIC want the same NUMA locality explained here.&lt;/li>
&lt;li>&lt;a href="https://blog.lo0.es/en/posts/cpu-memory-topology-manager-rke2/">RKE2 resource managers: how the kubelet pins NUMA for you&lt;/a> — the next post; the declarative automation of everything done here by hand with numactl and isolcpus.&lt;/li>
&lt;li>&lt;a href="https://blog.lo0.es/en/posts/anatomy-on-premise-llm-inference-stack-seven-layers/">The on-premise LLM inference stack in seven layers&lt;/a> — the complete building; this post is the basement (host/kernel) everything rests on.&lt;/li>
&lt;li>&lt;a href="https://blog.lo0.es/en/posts/optimising-decode-vllm-squeezing-every-token-small-hardware/">Decode optimisations in vLLM&lt;/a> — the latency-bound phase where host jitter turns into a p99 tail and where CPU isolation pays off.&lt;/li>
&lt;li>&lt;a href="https://blog.lo0.es/en/posts/continuous-batching-orca-vllm-chunked-prefill-goodput/">Continuous batching&lt;/a> — batching amortises the fixed cost of the host launches as well as that of the collectives.&lt;/li>
&lt;li>&lt;a href="https://blog.lo0.es/en/posts/gpu-observability-llm-inference-twelve-dcgm-vllm-metrics/">GPU observability with DCGM&lt;/a> — what a GPU &amp;ldquo;at 60 % for no reason&amp;rdquo; looks like when it is really the host waiting: bubbles, CPU migrations, inter-socket traffic.&lt;/li>
&lt;li>&lt;a href="https://blog.lo0.es/en/posts/capacity-planning-on-premise-llm-inference/">Capacity planning for on-premise inference&lt;/a> — why you have to subtract the reserved housekeeping cores from the compute budget per node.&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> — NUMA affinity gets more complicated (and more important) when the node mixes GPUs, accelerators and heterogeneous NICs.&lt;/li>
&lt;li>&lt;a href="https://blog.lo0.es/en/posts/network-numa-cilium-ebpf-dranet-fourth-leg-pinning/">The kitchen door the maître never looked at: network NUMA, Cilium eBPF and DRANET&lt;/a> — the NIC&amp;rsquo;s IRQ affinity is a third list to reconcile with &lt;code>isolcpus&lt;/code> and &lt;code>reserved-cpus&lt;/code>; the &lt;code>NET_RX&lt;/code> softirq is this same jitter coming in over the network.&lt;/li>
&lt;li>&lt;a href="https://blog.lo0.es/en/posts/pantry-lift-from-disk-to-hbm-why-the-kitchen-opens-late/">From disk to HBM: cold start and model loading&lt;/a> — the host buffer of the weight loading wants to be &lt;em>pinned&lt;/em> and NUMA-local, exactly what is asked here for the hot path; it is another client of the same NUMA map.&lt;/li>
&lt;li>&lt;a href="https://blog.lo0.es/en/posts/sms-cuda-streams-cuda-graphs-inference/">SM, CUDA streams and CUDA graphs&lt;/a> — &lt;em>who&lt;/em> launches the kernels is the host thread of this post; its jitter is the launch overhead that CUDA graphs come to reduce.&lt;/li>
&lt;/ul>
&lt;h2 id="references">References&lt;/h2>
&lt;ul>
&lt;li>Erik Rigtorp, &lt;em>Low Latency Tuning Guide&lt;/em> (isolcpus, nohz_full, IRQ affinity, THP): &lt;a href="https://rigtorp.se/low-latency-guide/">https://rigtorp.se/low-latency-guide/&lt;/a>.&lt;/li>
&lt;li>Red Hat, &lt;em>Usage, constraints and implications of isolcpus=, nohz_full= and rcu_nocbs=&lt;/em>: &lt;a href="https://access.redhat.com/articles/3720611">https://access.redhat.com/articles/3720611&lt;/a>.&lt;/li>
&lt;li>NVIDIA, &lt;em>nvidia-smi topo -m&lt;/em> and the GPU–NUMA–NIC affinity matrix (CUDA docs / Developer Forums): &lt;a href="https://forums.developer.nvidia.com/t/nvidia-smi-topo-m-revisited/216584">https://forums.developer.nvidia.com/t/nvidia-smi-topo-m-revisited/216584&lt;/a>.&lt;/li>
&lt;li>Chaim Rand, &lt;em>The Crucial Role of NUMA Awareness in High-Performance Deep Learning&lt;/em>: &lt;a href="https://chaimrand.medium.com/the-crucial-role-of-numa-awareness-in-high-performance-deep-learning-99ae3e8eb49a">https://chaimrand.medium.com/the-crucial-role-of-numa-awareness-in-high-performance-deep-learning-99ae3e8eb49a&lt;/a>.&lt;/li>
&lt;li>SUSE Labs, &lt;em>CPU Isolation – nohz_full (part 3)&lt;/em>: &lt;a href="https://www.suse.com/c/cpu-isolation-nohz_full-part-3/">https://www.suse.com/c/cpu-isolation-nohz_full-part-3/&lt;/a>.&lt;/li>
&lt;li>Linux kernel, &lt;em>Automatic NUMA Balancing&lt;/em> and &lt;em>Transparent Hugepage Support&lt;/em> (Documentation/admin-guide): &lt;a href="https://docs.kernel.org/admin-guide/mm/transhuge.html">https://docs.kernel.org/admin-guide/mm/transhuge.html&lt;/a>.&lt;/li>
&lt;/ul></description></item><item><title>The shared bench: NVLink, NVSwitch and NCCL, the cable every token crosses in tensor parallel</title><link>https://blog.lo0.es/en/posts/nvlink-nvswitch-nccl-cable-every-token-crosses/</link><pubDate>Sat, 06 Jun 2026 07:00:00 +0200</pubDate><guid>https://blog.lo0.es/en/posts/nvlink-nvswitch-nccl-cable-every-token-crosses/</guid><description>&lt;blockquote>
&lt;p>This post goes one floor below the engine. In the &lt;a href="https://blog.lo0.es/en/posts/anatomy-on-premise-llm-inference-stack-seven-layers/">seven-layer inference stack&lt;/a> and in &lt;a href="https://blog.lo0.es/en/posts/one-big-replica-or-many-small-ones-platform-decision/">one big replica or many small ones&lt;/a> the decision was &lt;em>how many&lt;/em> GPUs and &lt;em>how&lt;/em> to split the model; here we explain the cable that makes that split work, or that strangles it. It is the first of a mini-series &amp;ldquo;below the engine&amp;rdquo;: interconnect (this one) → kernel and NUMA → Kubernetes resource managers.&lt;/p>
&lt;/blockquote>
&lt;h2 id="tldr">TL;DR&lt;/h2>
&lt;p>&lt;strong>Tensor parallelism (TP) does not split a model into four chunks that run on their own.&lt;/strong> It spreads each layer across the GPUs, but after attention and after the MLP the GPUs have to &lt;strong>add up their partial results&lt;/strong> with an &lt;em>all-reduce&lt;/em>. On a Llama-70B with 80 layers, that is around 160 all-reduces &lt;strong>per generated token&lt;/strong>. That all-reduce travels over the interconnect, so the interconnect sits on the &lt;strong>critical path of every token&lt;/strong>, not in the background plumbing. On an HGX H100 baseboard, the 8 GPUs all talk to each other at &lt;strong>900 GB/s bidirectional&lt;/strong> via four NVSwitch chips; without NVSwitch/NVLink, that same traffic falls back to the CPU over PCIe and loses an order of magnitude. NCCL is the library that decides how each collective is done (ring, tree, or NVLS = NVLink-SHARP, which offloads the summation to the switch itself). And there is an asymmetry almost nobody keeps in mind: &lt;strong>decode is latency-bound&lt;/strong> (tiny messages, 16 KB) and &lt;strong>prefill is bandwidth-bound&lt;/strong> (huge batched activations), which is why &amp;ldquo;more NVLink bandwidth&amp;rdquo; speeds up prefill but barely touches token-by-token decode. This post explains the mechanism, gives the 10 real NCCL/driver knobs worth touching, and connects with vLLM&amp;rsquo;s custom all-reduce, disaggregated serving and GPU observability. With scepticism about which levers move the needle.&lt;/p>
&lt;h2 id="where-you-are-the-floor-below-the-engine">Where you are: the floor below the engine&lt;/h2>
&lt;div class="diagram" style="max-width:560px;margin:1.2rem auto;">
&lt;svg viewBox="0 0 560 320" xmlns="http://www.w3.org/2000/svg" role="img" aria-label="Stack layers: you are at the interconnect, below the engine">
&lt;text x="280" y="24" text-anchor="middle" font-family="sans-serif" font-size="13" font-weight="700" fill="currentColor">The vertical stack · you are at the cable&lt;/text>
&lt;rect x="120" y="40" width="320" height="40" rx="6" fill="#f4f4f4" stroke="#888" stroke-width="1.3"/>
&lt;text x="280" y="65" text-anchor="middle" font-family="sans-serif" font-size="12" fill="#333">Model · weights, quantisation, KV cache&lt;/text>
&lt;rect x="120" y="86" width="320" height="40" rx="6" fill="#f4f4f4" stroke="#888" stroke-width="1.3"/>
&lt;text x="280" y="111" text-anchor="middle" font-family="sans-serif" font-size="12" fill="#333">Engine · vLLM / SGLang (TP, PP, batching)&lt;/text>
&lt;rect x="120" y="132" width="320" height="40" rx="6" fill="#eef3fb" stroke="#4a6fa5" stroke-width="1.4"/>
&lt;text x="280" y="157" text-anchor="middle" font-family="sans-serif" font-size="12" fill="#333">CUDA · kernels + NCCL (collectives)&lt;/text>
&lt;rect x="120" y="178" width="320" height="52" rx="6" fill="#dceede" stroke="#3c8c54" stroke-width="3"/>
&lt;text x="280" y="200" text-anchor="middle" font-family="sans-serif" font-size="12.5" font-weight="700" fill="#1f5c34">YOU ARE HERE · NVLink + NVSwitch&lt;/text>
&lt;text x="280" y="218" text-anchor="middle" font-family="sans-serif" font-size="10.5" fill="#2c6b42">the physical interconnect between GPUs&lt;/text>
&lt;rect x="120" y="236" width="320" height="40" rx="6" fill="#f4f4f4" stroke="#888" stroke-width="1.3"/>
&lt;text x="280" y="261" text-anchor="middle" font-family="sans-serif" font-size="12" fill="#333">Hardware · H100 SXM GPU, HBM3, SM&lt;/text>
&lt;text x="280" y="298" text-anchor="middle" font-family="sans-serif" font-size="10.5" font-style="italic" fill="#777">every all-reduce from the engine above crosses this layer, per token&lt;/text>
&lt;/svg>
&lt;/div>
&lt;h2 id="the-analogy-four-mechanics-and-a-single-bench">The analogy: four mechanics and a single bench&lt;/h2>
&lt;p>Four mechanics assemble one and the same car engine. It is not that each builds their own engine in parallel, that would be four cars (four replicas of the model, a different strategy). Here they build &lt;strong>a single one, at the same time&lt;/strong>, splitting the parts: one does the pistons, another the cylinder head, another the crankshaft. The problem is that the parts fit into each other: before going on, &lt;strong>all four have to put together what they have and check that it matches&lt;/strong>. That &amp;ldquo;put together and check&amp;rdquo; happens dozens of times during the build.&lt;/p>
&lt;p>There are two ways to organise the workshop:&lt;/p>
&lt;ul>
&lt;li>
&lt;p>&lt;strong>A single large bench, everyone around it (NVSwitch).&lt;/strong> Each mechanic reaches out and hands their part directly to any other, all at once, without standing up. It is instant and simultaneous. This is &lt;strong>NVLink + NVSwitch&lt;/strong>: the GPUs form an &lt;em>all-to-all&lt;/em> where anyone talks to anyone at 900 GB/s at the same time.&lt;/p>
&lt;/li>
&lt;li>
&lt;p>&lt;strong>Four separate workshops with a courier (PCIe via CPU).&lt;/strong> Every part a mechanic wants to hand to another goes into a box, goes down to reception (host memory, via CPU), and from there up to the destination workshop. Slower, and serialised by reception. This is what happens when &lt;strong>there is no NVLink&lt;/strong>: inter-GPU traffic falls back to PCIe and bounces through the CPU, around 14× slower than NVLink.&lt;/p>
&lt;/li>
&lt;/ul>
&lt;p>The thesis of the post follows on its own: &lt;strong>tensor parallelism only makes sense if the mechanics share the bench.&lt;/strong> As soon as the &amp;ldquo;put together and check&amp;rdquo; (the all-reduce) has to go through reception, splitting the work costs more than it saves. That is why, on a serious platform, TP &lt;strong>does not cross the NVLink boundary&lt;/strong>: TP=4 or 8 &lt;em>inside&lt;/em> the baseboard where there is NVSwitch, and above that you replicate or use pipeline, never stretch TP over PCIe or over the network. When each option makes sense is covered in &lt;a href="https://blog.lo0.es/en/posts/one-big-replica-or-many-small-ones-platform-decision/">one big replica or many small ones&lt;/a>; here we explain &lt;em>why&lt;/em> the cable dictates that decision.&lt;/p>
&lt;h2 id="the-mechanism-what-an-all-reduce-really-is-and-why-there-are-160-per-token">The mechanism: what an all-reduce really is and why there are 160 per token&lt;/h2>
&lt;p>Tensor parallelism splits the weight matrices by columns/rows across the $N$ GPUs. Each GPU computes a portion of the layer output. But the next operation needs the complete output, so it has to be recombined. That recombination is a collective operation: an &lt;code>all-reduce&lt;/code>, which adds the partial tensors of all the GPUs element by element and leaves the result &lt;strong>identical on all of them&lt;/strong>.&lt;/p>
&lt;p>In a standard transformer block there are &lt;strong>two synchronisation points per layer&lt;/strong>:&lt;/p>
&lt;ol>
&lt;li>After the output projection of attention (the &lt;code>o_proj&lt;/code> that recombines the split heads).&lt;/li>
&lt;li>After the second matrix of the MLP (the &lt;code>down_proj&lt;/code> that recombines the split feed-forward).&lt;/li>
&lt;/ol>
$$ \text{all-reduces per token} = 2 \times L_{\text{layers}} $$
&lt;p>For a Llama-70B ($L = 80$): $2 \times 80 = 160$ all-reduces &lt;strong>per generated token&lt;/strong>. Not per request, not per sequence: &lt;strong>per token&lt;/strong>. Multiply by decode throughput and you understand why the interconnect is not background infrastructure but a hot path.&lt;/p>
&lt;div class="diagram" style="max-width:800px;margin:1.2rem auto;">
&lt;svg viewBox="0 0 800 300" xmlns="http://www.w3.org/2000/svg" role="img" aria-label="All-reduce in the decode loop of tensor parallel">
&lt;defs>&lt;marker id="nva" viewBox="0 0 10 10" refX="9" refY="5" markerWidth="7" markerHeight="7" orient="auto">&lt;path d="M0,0 L10,5 L0,10 z" fill="#666"/>&lt;/marker>&lt;/defs>
&lt;text x="400" y="22" text-anchor="middle" font-family="sans-serif" font-size="13" font-weight="700" fill="currentColor">One transformer layer at TP=4 · two all-reduces per layer&lt;/text>
&lt;!-- 4 GPUs -->
&lt;g>
&lt;rect x="40" y="50" width="150" height="40" rx="6" fill="#dceede" stroke="#3c8c54" stroke-width="1.4"/>&lt;text x="115" y="75" text-anchor="middle" font-family="sans-serif" font-size="11" font-weight="700" fill="#222">GPU0 · ¼ heads&lt;/text>
&lt;rect x="40" y="98" width="150" height="40" rx="6" fill="#dceede" stroke="#3c8c54" stroke-width="1.4"/>&lt;text x="115" y="123" text-anchor="middle" font-family="sans-serif" font-size="11" font-weight="700" fill="#222">GPU1 · ¼ heads&lt;/text>
&lt;rect x="40" y="146" width="150" height="40" rx="6" fill="#dceede" stroke="#3c8c54" stroke-width="1.4"/>&lt;text x="115" y="171" text-anchor="middle" font-family="sans-serif" font-size="11" font-weight="700" fill="#222">GPU2 · ¼ heads&lt;/text>
&lt;rect x="40" y="194" width="150" height="40" rx="6" fill="#dceede" stroke="#3c8c54" stroke-width="1.4"/>&lt;text x="115" y="219" text-anchor="middle" font-family="sans-serif" font-size="11" font-weight="700" fill="#222">GPU3 · ¼ heads&lt;/text>
&lt;/g>
&lt;!-- AllReduce 1 -->
&lt;rect x="240" y="70" width="120" height="144" rx="9" fill="#f7efda" stroke="#c79a32" stroke-width="2"/>
&lt;text x="300" y="130" text-anchor="middle" font-family="sans-serif" font-size="12" font-weight="700" fill="#222">ALL-REDUCE&lt;/text>
&lt;text x="300" y="148" text-anchor="middle" font-family="sans-serif" font-size="10" fill="#444">attn o_proj&lt;/text>
&lt;text x="300" y="164" text-anchor="middle" font-family="sans-serif" font-size="9" fill="#777">(NVLink)&lt;/text>
&lt;!-- MLP -->
&lt;rect x="410" y="70" width="120" height="144" rx="6" fill="#eef3fb" stroke="#4a6fa5" stroke-width="1.4"/>
&lt;text x="470" y="135" text-anchor="middle" font-family="sans-serif" font-size="11" font-weight="700" fill="#222">MLP&lt;/text>
&lt;text x="470" y="152" text-anchor="middle" font-family="sans-serif" font-size="9.5" fill="#444">(split)&lt;/text>
&lt;!-- AllReduce 2 -->
&lt;rect x="580" y="70" width="120" height="144" rx="9" fill="#f7efda" stroke="#c79a32" stroke-width="2"/>
&lt;text x="640" y="130" text-anchor="middle" font-family="sans-serif" font-size="12" font-weight="700" fill="#222">ALL-REDUCE&lt;/text>
&lt;text x="640" y="148" text-anchor="middle" font-family="sans-serif" font-size="10" fill="#444">mlp down_proj&lt;/text>
&lt;text x="640" y="164" text-anchor="middle" font-family="sans-serif" font-size="9" fill="#777">(NVLink)&lt;/text>
&lt;!-- next layer -->
&lt;rect x="720" y="98" width="60" height="88" rx="6" fill="#f4f4f4" stroke="#888" stroke-width="1.2"/>
&lt;text x="750" y="138" text-anchor="middle" font-family="sans-serif" font-size="10" font-weight="700" fill="#333">layer&lt;/text>
&lt;text x="750" y="152" text-anchor="middle" font-family="sans-serif" font-size="10" font-weight="700" fill="#333">i+1&lt;/text>
&lt;path d="M190,142 L240,142" fill="none" stroke="#666" stroke-width="1.5" marker-end="url(#nva)"/>
&lt;path d="M360,142 L410,142" fill="none" stroke="#666" stroke-width="1.5" marker-end="url(#nva)"/>
&lt;path d="M530,142 L580,142" fill="none" stroke="#666" stroke-width="1.5" marker-end="url(#nva)"/>
&lt;path d="M700,142 L720,142" fill="none" stroke="#666" stroke-width="1.5" marker-end="url(#nva)"/>
&lt;text x="400" y="262" text-anchor="middle" font-family="sans-serif" font-size="11" fill="#444">× 80 layers = 160 all-reduces per token · each one crosses the interconnect&lt;/text>
&lt;text x="400" y="282" text-anchor="middle" font-family="sans-serif" font-size="10.5" font-style="italic" fill="#999">if the cable is slow, decode collapses — the engine waits on the cable&lt;/text>
&lt;/svg>
&lt;/div>
&lt;h3 id="how-the-all-reduce-is-done-ring-tree-nvls">How the all-reduce is done: ring, tree, NVLS&lt;/h3>
&lt;p>NCCL does not have a single way of doing an all-reduce; it picks an algorithm according to topology and message size:&lt;/p>
&lt;ul>
&lt;li>&lt;strong>Ring.&lt;/strong> The GPUs form a ring; each one passes a chunk to its neighbour, adds, and rotates. It takes $2(N-1)$ steps. It is &lt;strong>bandwidth-optimal&lt;/strong> for large messages: the cost of moving the data is $\frac{2(N-1)}{N} \times M$ bytes over the link, almost independent of $N$. The downside: $2(N-1)$ latency hops, bad for small messages.&lt;/li>
&lt;li>&lt;strong>Tree.&lt;/strong> Tree reduction: $\log N$ levels. &lt;strong>Better latency&lt;/strong> for small messages and many nodes, worse bandwidth utilisation.&lt;/li>
&lt;li>&lt;strong>NVLS (NVLink SHARP).&lt;/strong> The Hopper trick: the summation &lt;strong>is not done by the GPUs, it is done by the NVSwitch&lt;/strong>. The switch has reduction units; the GPUs send their tensors, the switch adds them in flight and returns the result. It takes work away from the GPUs (frees up SMs) and reduces hops. Available &lt;strong>only with a 3rd-generation NVSwitch (NVLink4) plus Hopper or newer&lt;/strong>.&lt;/li>
&lt;/ul>
&lt;p>The mental rule: &lt;strong>decode (tiny messages) wants latency → tree/LL or vLLM&amp;rsquo;s custom kernel; prefill (huge messages) wants bandwidth → ring/NVLS&lt;/strong>. That is why there is no global &amp;ldquo;optimal NCCL_ALGO&amp;rdquo;; it depends on which phase you are looking at.&lt;/p>
&lt;h2 id="the-maths-that-matter-why-decode-and-prefill-stress-the-cable-the-other-way-round">The maths that matter: why decode and prefill stress the cable the other way round&lt;/h2>
&lt;p>Here is the asymmetry almost everyone skips. The size of the tensor that gets all-reduced in each layer is, approximately:&lt;/p>
$$ M \approx B \times S \times h \times 2\ \text{bytes (BF16)} $$
&lt;p>where $B$ = batch, $S$ = tokens processed in this forward pass, $h$ = hidden size.&lt;/p>
&lt;p>&lt;strong>In decode&lt;/strong>, you generate &lt;strong>1 token per sequence&lt;/strong> per iteration. For a single sequence ($B \times S = 1$) and $h = 8192$ (Llama-70B):&lt;/p>
$$ M_{\text{decode}} \approx 1 \times 8192 \times 2 = 16\ \text{KB per all-reduce} $$
&lt;p>16 KB is tiny. At 900 GB/s, moving 16 KB takes around 18 nanoseconds of pure transfer, but the real cost is dominated by the &lt;strong>launch latency of the collective&lt;/strong> (synchronisation, kernel launch), of the order of single-digit microseconds. With 160 all-reduces per token:&lt;/p>
$$ t_{\text{comms/token}} \approx 160 \times (5\text{–}10\,\mu s) \approx 0.8\text{–}1.6\ \text{ms} $$
&lt;p>That is the communication floor per token, &lt;strong>independent of bandwidth&lt;/strong>. An uncomfortable and counter-intuitive implication: &lt;strong>buying more NVLink bandwidth does not speed up token-by-token decode of a single sequence.&lt;/strong> What helps in decode is &lt;strong>lowering the latency per collective&lt;/strong> (LL protocol, vLLM&amp;rsquo;s custom all-reduce, NVLS to remove hops) and batching (raising $B$ amortises the fixed latency over more tokens, the deep reason continuous batching exists, covered in &lt;a href="https://blog.lo0.es/en/posts/continuous-batching-orca-vllm-chunked-prefill-goodput/">continuous batching&lt;/a>).&lt;/p>
&lt;p>&lt;strong>In prefill&lt;/strong>, you process the whole prompt at once: $S$ can be thousands of tokens, and with batching $B \times S$ reaches tens of thousands. There:&lt;/p>
$$ M_{\text{prefill}} \approx 8000 \times 8192 \times 2 \approx 131\ \text{MB per all-reduce} $$
&lt;p>131 MB does stress the bandwidth. At 900 GB/s (NVSwitch) the ring all-reduce moves $\frac{2 \cdot 3}{4} \times 131 \approx 196$ MB effective in around 0.22 ms; over PCIe (around 64 GB/s aggregate, bouncing through the CPU) it would be &lt;strong>around 3 ms and serialised&lt;/strong>. Here the cable is the bottleneck and NVLS/bandwidth rule.&lt;/p>
&lt;p>Summary in one line: &lt;strong>prefill is bandwidth-bound, decode is latency-bound.&lt;/strong> Any interconnect tuning that does not say which phase it helps is noise.&lt;/p>
&lt;h2 id="the-hardware-nvlink-4-and-nvswitch-on-the-hgx-baseboard">The hardware: NVLink 4 and NVSwitch on the HGX baseboard&lt;/h2>
&lt;p>On the generic reference cluster, &lt;strong>4×H100 SXM&lt;/strong> inside an HGX baseboard, the concrete figures:&lt;/p>
&lt;ul>
&lt;li>Each H100 SXM5 has &lt;strong>18 NVLink 4 links&lt;/strong>, each one 50 GB/s bidirectional ⇒ &lt;strong>900 GB/s bidirectional aggregate per GPU&lt;/strong>. That is &lt;strong>&amp;gt;14× the bandwidth of a PCIe Gen4 x16&lt;/strong> (around 64 GB/s bidir).&lt;/li>
&lt;li>On an 8-GPU HGX H100 baseboard, the 18 links of each GPU are spread against &lt;strong>four 3rd-generation NVSwitch&lt;/strong> chips (grouped 5+4+4+5). The result is all-to-all: any GPU talks to any other at 900 GB/s simultaneously, without going through CPU or PCIe.&lt;/li>
&lt;li>A 4-GPU baseboard is half a board: same principle, via NVSwitch. &lt;strong>Design key&lt;/strong>: if your 4 H100s are connected by NVSwitch, you have real all-to-all; if they are on &lt;strong>different boards connected by PCIe&lt;/strong> (some &amp;ldquo;4×PCIe&amp;rdquo; configurations), &lt;strong>you do not have NVLink between all of them&lt;/strong> and TP=4 suffers. Verify it, do not assume it.&lt;/li>
&lt;/ul>
&lt;div class="diagram" style="max-width:800px;margin:1.2rem auto;">
&lt;svg viewBox="0 0 800 300" xmlns="http://www.w3.org/2000/svg" role="img" aria-label="NVSwitch all-to-all vs PCIe through the CPU">
&lt;defs>&lt;marker id="nvt" 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="#3c8c54"/>&lt;/marker>&lt;/defs>
&lt;text x="200" y="24" text-anchor="middle" font-family="sans-serif" font-size="12.5" font-weight="700" fill="#1f5c34">With NVSwitch · all-to-all 900 GB/s&lt;/text>
&lt;circle cx="120" cy="80" r="26" fill="#dceede" stroke="#3c8c54" stroke-width="1.6"/>&lt;text x="120" y="84" text-anchor="middle" font-family="sans-serif" font-size="11" font-weight="700" fill="#222">G0&lt;/text>
&lt;circle cx="280" cy="80" r="26" fill="#dceede" stroke="#3c8c54" stroke-width="1.6"/>&lt;text x="280" y="84" text-anchor="middle" font-family="sans-serif" font-size="11" font-weight="700" fill="#222">G1&lt;/text>
&lt;circle cx="120" cy="220" r="26" fill="#dceede" stroke="#3c8c54" stroke-width="1.6"/>&lt;text x="120" y="224" text-anchor="middle" font-family="sans-serif" font-size="11" font-weight="700" fill="#222">G2&lt;/text>
&lt;circle cx="280" cy="220" r="26" fill="#dceede" stroke="#3c8c54" stroke-width="1.6"/>&lt;text x="280" y="224" text-anchor="middle" font-family="sans-serif" font-size="11" font-weight="700" fill="#222">G3&lt;/text>
&lt;rect x="170" y="130" width="60" height="40" rx="6" fill="#f7efda" stroke="#c79a32" stroke-width="1.8"/>&lt;text x="200" y="155" text-anchor="middle" font-family="sans-serif" font-size="10" font-weight="700" fill="#222">NVSw&lt;/text>
&lt;path d="M138,98 L180,135" stroke="#3c8c54" stroke-width="1.6" fill="none"/>
&lt;path d="M262,98 L220,135" stroke="#3c8c54" stroke-width="1.6" fill="none"/>
&lt;path d="M138,202 L180,165" stroke="#3c8c54" stroke-width="1.6" fill="none"/>
&lt;path d="M262,202 L220,165" stroke="#3c8c54" stroke-width="1.6" fill="none"/>
&lt;path d="M120,106 L120,194" stroke="#3c8c54" stroke-width="1.2" fill="none" stroke-dasharray="3 2"/>
&lt;path d="M280,106 L280,194" stroke="#3c8c54" stroke-width="1.2" fill="none" stroke-dasharray="3 2"/>
&lt;line x1="420" y1="40" x2="420" y2="270" stroke="#ccc" stroke-width="1"/>
&lt;text x="610" y="24" text-anchor="middle" font-family="sans-serif" font-size="12.5" font-weight="700" fill="#a85454">No NVLink · PCIe via CPU (around 14× slower)&lt;/text>
&lt;circle cx="530" cy="80" r="26" fill="#f3dede" stroke="#b35454" stroke-width="1.6"/>&lt;text x="530" y="84" text-anchor="middle" font-family="sans-serif" font-size="11" font-weight="700" fill="#222">G0&lt;/text>
&lt;circle cx="690" cy="80" r="26" fill="#f3dede" stroke="#b35454" stroke-width="1.6"/>&lt;text x="690" y="84" text-anchor="middle" font-family="sans-serif" font-size="11" font-weight="700" fill="#222">G1&lt;/text>
&lt;circle cx="530" cy="220" r="26" fill="#f3dede" stroke="#b35454" stroke-width="1.6"/>&lt;text x="530" y="224" text-anchor="middle" font-family="sans-serif" font-size="11" font-weight="700" fill="#222">G2&lt;/text>
&lt;circle cx="690" cy="220" r="26" fill="#f3dede" stroke="#b35454" stroke-width="1.6"/>&lt;text x="690" y="224" text-anchor="middle" font-family="sans-serif" font-size="11" font-weight="700" fill="#222">G3&lt;/text>
&lt;rect x="580" y="130" width="60" height="40" rx="6" fill="#e8e8e8" stroke="#888" stroke-width="1.6"/>&lt;text x="610" y="155" text-anchor="middle" font-family="sans-serif" font-size="10" font-weight="700" fill="#333">CPU&lt;/text>
&lt;path d="M548,98 L590,135" stroke="#b35454" stroke-width="1.4" fill="none"/>
&lt;path d="M672,98 L630,135" stroke="#b35454" stroke-width="1.4" fill="none"/>
&lt;path d="M548,202 L590,165" stroke="#b35454" stroke-width="1.4" fill="none"/>
&lt;path d="M672,202 L630,165" stroke="#b35454" stroke-width="1.4" fill="none"/>
&lt;text x="610" y="292" text-anchor="middle" font-family="sans-serif" font-size="10" font-style="italic" fill="#999">all inter-GPU traffic bounces through host memory, serialised&lt;/text>
&lt;/svg>
&lt;/div>
&lt;h2 id="the-10-knobs-worth-touching">The 10 knobs worth touching&lt;/h2>
&lt;p>Almost all of them are NCCL environment variables (injected into the inference engine process) or driver settings. Ordered by impact/frequency in an on-premise deployment. The canonical detail is in the &lt;a href="https://docs.nvidia.com/deeplearning/nccl/user-guide/docs/env.html">NCCL env vars docs&lt;/a>.&lt;/p>
&lt;h3 id="knob-1--nccl_debug--topology-dump-see-what-is-happening-before-touching-anything">Knob 1 — &lt;code>NCCL_DEBUG&lt;/code> + topology dump: see what is happening before touching anything&lt;/h3>
&lt;p>Do not optimise blind: &lt;strong>first confirm which topology and algorithms NCCL picked&lt;/strong>. This tells you whether it really is using NVLink or whether, silently, it fell back to PCIe/SHM, the number one failure and the most expensive one.&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">&lt;span class="nv">NCCL_DEBUG&lt;/span>&lt;span class="o">=&lt;/span>INFO &lt;span class="c1"># prints topology, rings/trees built, transport chosen&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="nv">NCCL_DEBUG_SUBSYS&lt;/span>&lt;span class="o">=&lt;/span>GRAPH,TUNING,NET &lt;span class="c1"># narrows it down to what matters&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="c1"># look in the log for: &amp;#34;via NVLink&amp;#34; / &amp;#34;via P2P&amp;#34; (good) vs &amp;#34;via SHM&amp;#34; / &amp;#34;via PCI&amp;#34; (bad)&lt;/span>
&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>If you see &lt;code>via SHM&lt;/code> or &lt;code>via PCI&lt;/code> between GPUs that should have NVLink, &lt;strong>you have a topology problem&lt;/strong> (PCIe ACS enabled, IOMMU, GPUs on different boards) and no other knob fixes it. This is knob 1 for a reason: half of the &amp;ldquo;NVLink is slow&amp;rdquo; cases are &amp;ldquo;NVLink is not being used&amp;rdquo;.&lt;/p>
&lt;h3 id="knob-2--nccl_algo-ring-vs-tree-vs-nvls">Knob 2 — &lt;code>NCCL_ALGO&lt;/code>: ring vs tree vs NVLS&lt;/h3>
&lt;p>Forces or excludes algorithms. By default NCCL picks according to size, and it usually gets it right; touch it only with a measurement in front of you.&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">&lt;span class="nv">NCCL_ALGO&lt;/span>&lt;span class="o">=&lt;/span>NVLS,Tree,Ring &lt;span class="c1"># order of preference&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="nv">NCCL_ALGO&lt;/span>&lt;span class="o">=&lt;/span>^Ring &lt;span class="c1"># exclude Ring (^ prefix)&lt;/span>
&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>Rule: prefill/training (bandwidth) ⇒ Ring/NVLS; decode (latency) ⇒ Tree or, better, vLLM&amp;rsquo;s custom kernel (knob 10/stack). In most inference workloads, &lt;strong>leaving it on auto and validating with knob 1 is the right thing&lt;/strong>; forcing it &amp;ldquo;just in case&amp;rdquo; usually makes things worse.&lt;/p>
&lt;h3 id="knob-3--nccl_proto-ll--ll128--simple">Knob 3 — &lt;code>NCCL_PROTO&lt;/code>: LL / LL128 / Simple&lt;/h3>
&lt;p>The protocol controls the latency/bandwidth trade-off at low level:&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">&lt;span class="nv">NCCL_PROTO&lt;/span>&lt;span class="o">=&lt;/span>Simple &lt;span class="c1"># maximum bandwidth, more latency (large messages)&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="nv">NCCL_PROTO&lt;/span>&lt;span class="o">=&lt;/span>LL &lt;span class="c1"># low-latency, half-bandwidth (tiny messages: decode)&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="nv">NCCL_PROTO&lt;/span>&lt;span class="o">=&lt;/span>LL128 &lt;span class="c1"># compromise, default on platforms that support it&lt;/span>
&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>&lt;code>LL&lt;/code> (low-latency) uses flags instead of barriers and wins on the 16 KB messages of decode; &lt;code>Simple&lt;/code> wins on the 131 MB of prefill. The default &lt;code>LL,LL128,Simple&lt;/code> lets NCCL choose by size, which again is normally the best option.&lt;/p>
&lt;h3 id="knob-4--nccl_nvls_enable-offload-the-summation-to-the-nvswitch">Knob 4 — &lt;code>NCCL_NVLS_ENABLE&lt;/code>: offload the summation to the NVSwitch&lt;/h3>
&lt;p>NVLink SHARP (NVLS) makes the switch do the reduction, freeing up GPU SMs:&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">&lt;span class="nv">NCCL_NVLS_ENABLE&lt;/span>&lt;span class="o">=&lt;/span>&lt;span class="m">1&lt;/span> &lt;span class="c1"># default: ON where there is an NVLink4+ NVSwitch (Hopper)&lt;/span>
&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>&lt;strong>An important sceptical caveat&lt;/strong>: NVLS &lt;strong>requires NVSwitch&lt;/strong> (3rd gen, NVLink4). On a node with NVLink through direct GPU-to-GPU &lt;em>bridges&lt;/em> (no switch) or on 4×PCIe, &lt;strong>NVLS is not available&lt;/strong> and this knob does nothing. Before &amp;ldquo;enabling it&amp;rdquo;, confirm with knob 1 that your topology has a switch. Where it does apply, its biggest advantage is freeing SMs for compute, which is relevant when comms and kernels compete (knob 5).&lt;/p>
&lt;h3 id="knob-5--nccl_min_nchannels--nccl_max_nchannels-how-many-sms-communication-steals">Knob 5 — &lt;code>NCCL_MIN_NCHANNELS&lt;/code> / &lt;code>NCCL_MAX_NCHANNELS&lt;/code>: how many SMs communication steals&lt;/h3>
&lt;p>Each NCCL &amp;ldquo;channel&amp;rdquo; consumes GPU SMs to move data. More channels = more collective bandwidth, but &lt;strong>fewer SMs for the inference kernel&lt;/strong>. It is a split of a fixed resource.&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">&lt;span class="nv">NCCL_MIN_NCHANNELS&lt;/span>&lt;span class="o">=&lt;/span>&lt;span class="m">4&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="nv">NCCL_MAX_NCHANNELS&lt;/span>&lt;span class="o">=&lt;/span>&lt;span class="m">16&lt;/span> &lt;span class="c1"># raising it helps prefill (bandwidth); steals SMs from decode&lt;/span>
&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>In decode, where the GPU is under-used on compute but tied to latency, trimming channels rarely hurts and sometimes helps; in prefill, more channels squeeze out the bandwidth. A measurement knob, not a faith knob.&lt;/p>
&lt;h3 id="knob-6--nccl_buffsize-the-buffer-size-per-channel">Knob 6 — &lt;code>NCCL_BUFFSIZE&lt;/code>: the buffer size per channel&lt;/h3>
&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">&lt;span class="nv">NCCL_BUFFSIZE&lt;/span>&lt;span class="o">=&lt;/span>&lt;span class="m">8388608&lt;/span> &lt;span class="c1"># 8 MB (default 4 MB); larger buffers → better BW on large messages&lt;/span>
&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>Raising it helps bandwidth-bound prefill at the cost of memory per channel. For workloads dominated by small messages (pure decode), the default is plenty.&lt;/p>
&lt;h3 id="knob-7--nccl_p2p_level--nccl_p2p_disable-guarantee-p2p-over-nvlink">Knob 7 — &lt;code>NCCL_P2P_LEVEL&lt;/code> / &lt;code>NCCL_P2P_DISABLE&lt;/code>: guarantee P2P over NVLink&lt;/h3>
&lt;p>P2P is what lets one GPU read another&amp;rsquo;s memory directly over NVLink without going through the host. If it is disabled or degraded, traffic falls back to SHM/PCIe.&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">&lt;span class="nv">NCCL_P2P_LEVEL&lt;/span>&lt;span class="o">=&lt;/span>NVL &lt;span class="c1"># use P2P up to the NVLink level&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="c1"># NCCL_P2P_DISABLE=1 ← only as a workaround if P2P HANGS (multi-NUMA PCIe, certain Blackwell)&lt;/span>
&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>Watch out for the trap: &lt;code>NCCL_P2P_DISABLE=1&lt;/code> and &lt;code>--disable-custom-all-reduce&lt;/code> are recommended as a &lt;strong>patch&lt;/strong> when vLLM hangs on PCIe-only multi-NUMA topologies. It is a &lt;strong>robustness patch that sacrifices performance&lt;/strong>: use it if it hangs, never &amp;ldquo;by default&amp;rdquo;.&lt;/p>
&lt;h3 id="knob-8--gpudirect-rdma-for-multi-node-nccl_net_gdr_level">Knob 8 — GPUDirect RDMA for multi-node: &lt;code>NCCL_NET_GDR_LEVEL&lt;/code>&lt;/h3>
&lt;p>When TP fits in one node, this does not apply. When you have to cross nodes (huge model, pipeline parallel between baseboards), GPUDirect RDMA lets the GPU talk to the NIC &lt;strong>without bouncing through host memory&lt;/strong>:&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">&lt;span class="nv">NCCL_NET_GDR_LEVEL&lt;/span>&lt;span class="o">=&lt;/span>PHB &lt;span class="c1"># enables GDR according to GPU–NIC proximity on the PCIe bus&lt;/span>
&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>Without GDR, every inter-node hop adds a host copy. With InfiniBand/RoCE plus GDR, the KV or the activations travel GPU→NIC→network→NIC→GPU. It is the basis of serious multi-node work and of &lt;a href="https://blog.lo0.es/en/posts/mixed-nvidia-intel-environments-llm-inference/">mixed environments&lt;/a>.&lt;/p>
&lt;h3 id="knob-9--nccl_ib_hca--nccl_socket_ifname-pin-the-right-nic">Knob 9 — &lt;code>NCCL_IB_HCA&lt;/code> / &lt;code>NCCL_SOCKET_IFNAME&lt;/code>: pin the right NIC&lt;/h3>
&lt;p>The most common and silent multi-node mistake: NCCL picks the &lt;strong>management NIC&lt;/strong> (1 GbE) instead of the fabric one (InfiniBand/100 GbE). Result: collectives at a snail&amp;rsquo;s pace with no visible error.&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">&lt;span class="nv">NCCL_SOCKET_IFNAME&lt;/span>&lt;span class="o">=&lt;/span>eth0 &lt;span class="c1"># control interface (bootstrap)&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="nv">NCCL_IB_HCA&lt;/span>&lt;span class="o">=&lt;/span>mlx5_0,mlx5_1 &lt;span class="c1"># the real InfiniBand HCAs of the fabric&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="nv">NCCL_IB_GID_INDEX&lt;/span>&lt;span class="o">=&lt;/span>&lt;span class="m">3&lt;/span> &lt;span class="c1"># correct GID for RoCE v2&lt;/span>
&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>Pin them explicitly. &amp;ldquo;Auto&amp;rdquo; gets it right on clean clusters and fails as soon as there is more than one NIC.&lt;/p>
&lt;h3 id="knob-10--driver-persistence-mode-clocks-and-nvlink-error-counters">Knob 10 — Driver: persistence mode, clocks and NVLink error counters&lt;/h3>
&lt;p>Below NCCL, the driver has levers and, above all, &lt;strong>telemetry you have to look at&lt;/strong>:&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">nvidia-smi -pm &lt;span class="m">1&lt;/span> &lt;span class="c1"># persistence mode: avoids driver re-init (latency/jitter)&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">nvidia-smi nvlink --status &lt;span class="c1"># are all 18 links active and at full speed?&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">nvidia-smi nvlink -e &lt;span class="c1"># error/CRC counters per link&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">nvidia-smi -q -d ECC &lt;span class="c1"># memory errors that degrade silently&lt;/span>
&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>An NVLink link that negotiates at half speed or accumulates CRC errors degrades the all-reduce &lt;strong>without raising any error&lt;/strong>: the system &amp;ldquo;works&amp;rdquo;, it is just slower. These counters are the difference between diagnosing in five minutes and chasing a ghost for days. They are integrated in DCGM (knob/stack: observability).&lt;/p>
&lt;h3 id="summary-table">Summary table&lt;/h3>
&lt;table>
&lt;thead>
&lt;tr>
&lt;th>#&lt;/th>
&lt;th>Knob&lt;/th>
&lt;th>Variable / command&lt;/th>
&lt;th>Phase it helps&lt;/th>
&lt;/tr>
&lt;/thead>
&lt;tbody>
&lt;tr>
&lt;td>1&lt;/td>
&lt;td>Topology diagnosis&lt;/td>
&lt;td>&lt;code>NCCL_DEBUG=INFO&lt;/code> + &lt;code>SUBSYS=GRAPH&lt;/code>&lt;/td>
&lt;td>always, first&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>2&lt;/td>
&lt;td>Collective algorithm&lt;/td>
&lt;td>&lt;code>NCCL_ALGO&lt;/code> (NVLS/Tree/Ring)&lt;/td>
&lt;td>depends on phase; auto usually wins&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>3&lt;/td>
&lt;td>Protocol&lt;/td>
&lt;td>&lt;code>NCCL_PROTO&lt;/code> (LL/LL128/Simple)&lt;/td>
&lt;td>LL=decode, Simple=prefill&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>4&lt;/td>
&lt;td>NVLink SHARP&lt;/td>
&lt;td>&lt;code>NCCL_NVLS_ENABLE=1&lt;/code>&lt;/td>
&lt;td>prefill; frees SMs (requires NVSwitch)&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>5&lt;/td>
&lt;td>Channels (SMs)&lt;/td>
&lt;td>&lt;code>NCCL_MIN/MAX_NCHANNELS&lt;/code>&lt;/td>
&lt;td>+prefill bandwidth / −SM theft in decode&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>6&lt;/td>
&lt;td>Buffer&lt;/td>
&lt;td>&lt;code>NCCL_BUFFSIZE&lt;/code>&lt;/td>
&lt;td>bandwidth-bound prefill&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>7&lt;/td>
&lt;td>NVLink P2P&lt;/td>
&lt;td>&lt;code>NCCL_P2P_LEVEL=NVL&lt;/code>&lt;/td>
&lt;td>critical; disable only if it hangs&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>8&lt;/td>
&lt;td>GPUDirect RDMA&lt;/td>
&lt;td>&lt;code>NCCL_NET_GDR_LEVEL&lt;/code>&lt;/td>
&lt;td>multi-node&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>9&lt;/td>
&lt;td>Fabric NIC&lt;/td>
&lt;td>&lt;code>NCCL_IB_HCA&lt;/code>/&lt;code>SOCKET_IFNAME&lt;/code>&lt;/td>
&lt;td>multi-node (avoids mgmt NIC)&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>10&lt;/td>
&lt;td>Driver + telemetry&lt;/td>
&lt;td>&lt;code>nvidia-smi -pm 1&lt;/code> / &lt;code>nvlink -e&lt;/code>&lt;/td>
&lt;td>jitter + silent diagnosis&lt;/td>
&lt;/tr>
&lt;/tbody>
&lt;/table>
&lt;h2 id="how-it-connects-with-the-rest-of-the-stack">How it connects with the rest of the stack&lt;/h2>
&lt;p>The interconnect is not an island; it touches almost every layer above.&lt;/p>
&lt;p>&lt;strong>With vLLM, the custom all-reduce.&lt;/strong> vLLM does not always use NCCL: for the tiny messages of decode (&lt;code>world_size==2&lt;/code> or a fully-connected NVLink topology, below a certain &lt;code>max_size&lt;/code>) it uses its &lt;strong>own all-reduce kernel&lt;/strong> that beats NCCL on latency, exactly the decode bottleneck we saw in the maths. It falls back to NCCL for large messages and for topologies without NVLink (where its custom kernel &amp;ldquo;adds little over NCCL&amp;rdquo;). The flag &lt;code>--disable-custom-all-reduce&lt;/code> / &lt;code>VLLM_DISABLE_CUSTOM_ALL_REDUCE&lt;/code> turns it off; it is the patch for hangs on multi-NUMA PCIe. Translation: &lt;strong>the most effective decode latency knob is sometimes not an NCCL one, it is choosing well between vLLM&amp;rsquo;s custom kernel and NCCL.&lt;/strong>&lt;/p>
&lt;p>&lt;strong>With TP vs replicas.&lt;/strong> Everything in &lt;a href="https://blog.lo0.es/en/posts/one-big-replica-or-many-small-ones-platform-decision/">one big replica or many small ones&lt;/a> rests on this: high TP is only viable inside the NVLink domain. The boundary of &amp;ldquo;TP=4 or 4 replicas at TP=1?&amp;rdquo; is drawn by the cable: crossing NVLink with TP means paying for the all-reduce at PCIe prices.&lt;/p>
&lt;p>&lt;strong>With disaggregated serving.&lt;/strong> In &lt;a href="https://blog.lo0.es/en/posts/disaggregated-serving-prefill-decode-specialised-pods/">disaggregated prefill/decode&lt;/a>, the KV cache generated in the prefill pool has to travel to the decode pool. That transfer is &lt;strong>another consumer of the interconnect&lt;/strong> (NVLink intra-node, GPUDirect RDMA inter-node) and competes with the all-reduces. Designing the disaggregation without counting the cost of transferring the KV is the classic trap.&lt;/p>
&lt;p>&lt;strong>With MoE.&lt;/strong> Mixture-of-Experts models add expert parallelism: an &lt;code>all-to-all&lt;/code> (not an all-reduce) that routes each token to its expert, possibly on another GPU. It is a different communication pattern and heavier on bandwidth; &lt;a href="https://blog.lo0.es/en/posts/moe-inference-fundamentals-expert-parallel-deepseek-economics/">MoE in inference&lt;/a> lives or dies by the same cable, with an even more demanding collective.&lt;/p>
&lt;p>&lt;strong>With GPU observability.&lt;/strong> The NVLink counters (&lt;code>nvidia-smi nvlink -e&lt;/code>, TX/RX bytes per link, CRC errors) and NVSwitch utilisation are exposed via DCGM and land in Prometheus/Grafana. The question &amp;ldquo;is the interconnect healthy and saturated?&amp;rdquo; is answered there, alongside the rest of &lt;a href="https://blog.lo0.es/en/posts/gpu-observability-llm-inference-twelve-dcgm-vllm-metrics/">GPU observability with DCGM&lt;/a>. A slow all-reduce shows up sooner in an NVLink error counter than in API latency.&lt;/p>
&lt;p>&lt;strong>With capacity planning.&lt;/strong> The &lt;a href="https://blog.lo0.es/en/posts/capacity-planning-on-premise-llm-inference/">inference sizing&lt;/a> that assumes &amp;ldquo;TP=4 scales almost linearly&amp;rdquo; &lt;strong>only holds inside NVLink&lt;/strong>. Outside it, scaling efficiency collapses and the capacity plan lies. The cable is a parameter of the capacity model, not a detail.&lt;/p>
&lt;h2 id="traps-and-things-that-are-not-what-they-look-like">Traps and things that are not what they look like&lt;/h2>
&lt;p>&lt;strong>&amp;ldquo;More NVLink bandwidth = faster decode.&amp;rdquo;&lt;/strong> False for a single sequence. Decode is latency-bound; bandwidth is barely touched by 16 KB messages. What speeds up decode is batching (amortising the fixed latency) and lowering latency per collective (LL, custom kernel, NVLS). Bandwidth rules in prefill.&lt;/p>
&lt;p>&lt;strong>&amp;ldquo;I have 4 H100s, so I have NVLink between all four.&amp;rdquo;&lt;/strong> Not necessarily. There are configurations where the GPUs are on different boards joined by PCIe, or with NVLink bridges only in pairs. Confirm it with &lt;code>nvidia-smi nvlink --status&lt;/code> and knob 1 before planning TP=4. A TP=4 over P2P-over-PCIe performs far worse than the brochure says.&lt;/p>
&lt;p>&lt;strong>Forcing &lt;code>NCCL_ALGO&lt;/code>/&lt;code>NCCL_PROTO&lt;/code> &amp;ldquo;to go faster&amp;rdquo;.&lt;/strong> NCCL picks well by size in most cases. Forcing an algorithm without measuring usually makes one of the two phases worse. The correct sequence is: knob 1 (see what it is doing) → measure → touch only if there is evidence.&lt;/p>
&lt;p>&lt;strong>Disabling P2P/custom all-reduce by default.&lt;/strong> They are robustness patches for broken topologies (multi-NUMA PCIe, certain Blackwell). Leaving them on &amp;ldquo;for stability&amp;rdquo; on a node with healthy NVLink throws performance in the bin.&lt;/p>
&lt;p>&lt;strong>Stretching TP over the network.&lt;/strong> TP=8 crossing two nodes over InfiniBand because &amp;ldquo;there is bandwidth&amp;rdquo; ignores that the per-layer all-reduce now pays network latency ×160 per token. To cross nodes, pipeline parallel (which communicates once per micro-batch, not per layer) almost always wins. The communication pattern, not just the bandwidth, decides.&lt;/p>
&lt;p>&lt;strong>Ignoring the NVLink error counters.&lt;/strong> A degraded link does not raise an exception: the system works, it is just slow. Without watching &lt;code>nvlink -e&lt;/code> and ECC, you chase a performance ghost that a counter would have pointed out in five minutes.&lt;/p>
&lt;h2 id="conclusion">Conclusion&lt;/h2>
&lt;p>Tensor parallelism sells a simple promise, split the model, multiply the VRAM, serve models that do not fit in one GPU, but the small print is that every layer forces the GPUs to come together and add up, twice, dozens of times per token. That all-reduce is the real hidden protagonist of performance, and it lives in the cable: NVLink does it on the shared bench of the NVSwitch at 900 GB/s, or PCIe drags it through the CPU&amp;rsquo;s reception desk 14× slower. Of the ten knobs, the first one, &lt;strong>looking with &lt;code>NCCL_DEBUG&lt;/code> at what is really happening&lt;/strong>, solves half the problems, because half of the &amp;ldquo;NVLink is slow&amp;rdquo; cases are &amp;ldquo;NVLink is not being used&amp;rdquo;. The rest are refinements that only mean something if you know &lt;strong>which phase&lt;/strong> you are in: prefill wants bandwidth (NVLS, Simple, channels, buffer), decode wants latency (LL, vLLM&amp;rsquo;s custom kernel, batching). And above all, an idea that reorders the intuition: in on-premise inference, the interconnect is not plumbing you install and forget, it is a hot path, a capacity parameter and, when it degrades silently, the root cause that no API dashboard will point out to you unless you look at the counters of the cable itself.&lt;/p>
&lt;h2 id="see-also">See also&lt;/h2>
&lt;ul>
&lt;li>&lt;a href="https://blog.lo0.es/en/posts/pcie-topology-gpudirect-p2p-acs-security-guard/">The corridors and the guard: PCIe, GPUDirect P2P and ACS&lt;/a> — the &lt;em>other&lt;/em> bus in the node; what does not fit on the NVLink bench (disk, network, KV between nodes) travels over PCIe, and ACS decides whether GPUDirect goes straight through or bounces via the root complex.&lt;/li>
&lt;li>&lt;a href="https://blog.lo0.es/en/posts/anatomy-on-premise-llm-inference-stack-seven-layers/">The on-premise LLM inference stack in seven layers&lt;/a> — the complete building where the interconnect is the foundation the seven layers rest on; here that foundation is opened up.&lt;/li>
&lt;li>&lt;a href="https://blog.lo0.es/en/posts/one-big-replica-or-many-small-ones-platform-decision/">One big replica or many small ones: TP and replicas&lt;/a> — the decision of how many GPUs and how to split the model; this post explains &lt;em>why&lt;/em> the NVLink limit draws that boundary.&lt;/li>
&lt;li>&lt;a href="https://blog.lo0.es/en/posts/disaggregated-serving-prefill-decode-specialised-pods/">Disaggregated serving: prefill and decode separated&lt;/a> — moving the KV cache between pools is another consumer of the same interconnect that competes with the all-reduces.&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 deep reason batching speeds up decode is that it amortises the fixed all-reduce latency over more tokens.&lt;/li>
&lt;li>&lt;a href="https://blog.lo0.es/en/posts/optimising-decode-vllm-squeezing-every-token-small-hardware/">Decode optimisations in vLLM&lt;/a> — the latency-bound phase where vLLM&amp;rsquo;s custom all-reduce and the LL protocol decide the TPS per sequence.&lt;/li>
&lt;li>&lt;a href="https://blog.lo0.es/en/posts/moe-inference-fundamentals-expert-parallel-deepseek-economics/">MoE in inference&lt;/a> — expert parallelism adds an even more demanding &lt;code>all-to-all&lt;/code> over the same cable.&lt;/li>
&lt;li>&lt;a href="https://blog.lo0.es/en/posts/gpu-observability-llm-inference-twelve-dcgm-vllm-metrics/">GPU observability with DCGM&lt;/a> — where the NVLink and NVSwitch counters land to answer &amp;ldquo;is the interconnect healthy and saturated?&amp;rdquo;.&lt;/li>
&lt;li>&lt;a href="https://blog.lo0.es/en/posts/capacity-planning-on-premise-llm-inference/">Capacity planning for on-premise inference&lt;/a> — why &amp;ldquo;TP scales almost linearly&amp;rdquo; is only true inside the NVLink domain, and how the cable enters the capacity model.&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> — when you cross the node boundary, GPUDirect RDMA over InfiniBand/RoCE replaces NVLink as the medium of the collective.&lt;/li>
&lt;li>&lt;a href="https://blog.lo0.es/en/posts/network-numa-cilium-ebpf-dranet-fourth-leg-pinning/">The kitchen door the maître never looked at: network NUMA, Cilium eBPF and DRANET&lt;/a> — making that GPUDirect RDMA take the NUMA-local path (GPU and NIC on the same PCIe root) is exactly what DRA/DRANET co-schedules; +60% NCCL bus bandwidth when it is aligned.&lt;/li>
&lt;li>&lt;a href="https://blog.lo0.es/en/posts/sms-cuda-streams-cuda-graphs-inference/">SM, CUDA streams and CUDA graphs&lt;/a> — between the compute kernels the TP all-reduces are interleaved; vLLM&amp;rsquo;s custom all-reduce is integrated into the same CUDA graph so as not to break the sequence with a CPU synchronisation.&lt;/li>
&lt;/ul>
&lt;h2 id="references">References&lt;/h2>
&lt;ul>
&lt;li>NVIDIA, &lt;em>NVIDIA Hopper Architecture In-Depth&lt;/em> (NVLink 4, 900 GB/s): &lt;a href="https://developer.nvidia.com/blog/nvidia-hopper-architecture-in-depth/">https://developer.nvidia.com/blog/nvidia-hopper-architecture-in-depth/&lt;/a>.&lt;/li>
&lt;li>NVIDIA, &lt;em>Introducing NVIDIA HGX H100&lt;/em> (4× NVSwitch, all-to-all): &lt;a href="https://developer.nvidia.com/blog/introducing-nvidia-hgx-h100-an-accelerated-server-platform-for-ai-and-high-performance-computing/">https://developer.nvidia.com/blog/introducing-nvidia-hgx-h100-an-accelerated-server-platform-for-ai-and-high-performance-computing/&lt;/a>.&lt;/li>
&lt;li>NVIDIA, &lt;em>NCCL Environment Variables&lt;/em> (all the knobs in this post): &lt;a href="https://docs.nvidia.com/deeplearning/nccl/user-guide/docs/env.html">https://docs.nvidia.com/deeplearning/nccl/user-guide/docs/env.html&lt;/a>.&lt;/li>
&lt;li>NVIDIA, &lt;em>The NVLink-Network Switch&lt;/em> (Hot Chips 2022, NVLink SHARP): &lt;a href="https://hc34.hotchips.org/">https://hc34.hotchips.org/&lt;/a>.&lt;/li>
&lt;li>vLLM, &lt;em>Why does vLLM use a custom all-reduce method?&lt;/em> (discussion #6159) and &lt;code>custom_all_reduce.py&lt;/code>: &lt;a href="https://github.com/vllm-project/vllm/discussions/6159">https://github.com/vllm-project/vllm/discussions/6159&lt;/a>.&lt;/li>
&lt;li>NVIDIA, &lt;em>NCCL Multi-Node NVLink Tuning Guide&lt;/em>: &lt;a href="https://docs.nvidia.com/multi-node-nvlink-systems/multi-node-tuning-guide/nccl.html">https://docs.nvidia.com/multi-node-nvlink-systems/multi-node-tuning-guide/nccl.html&lt;/a>.&lt;/li>
&lt;/ul></description></item><item><title>Batch sizing in vLLM: the two-hour grid search worth weeks of hardware</title><link>https://blog.lo0.es/en/posts/vllm-batch-sizing-two-hour-grid-search/</link><pubDate>Fri, 05 Jun 2026 04:00:00 +0000</pubDate><guid>https://blog.lo0.es/en/posts/vllm-batch-sizing-two-hour-grid-search/</guid><description>&lt;h2 id="tldr">TL;DR&lt;/h2>
&lt;p>&lt;code>max-num-seqs&lt;/code> and &lt;code>max-num-batched-tokens&lt;/code> are the two dials that control how much work vLLM processes on each scheduler iteration. Their default values are calibrated to be safe on any hardware, not to maximise throughput on yours. A systematic grid search over 25 configurations, runnable in two hours, identifies the combination that, for your specific workload and hardware, can double throughput without changing a single line of the model or adding a GPU. The OTel metrics that confirm you found the optimum are &lt;code>vllm:num_waiting_seqs&lt;/code>, &lt;code>vllm:num_preemptions_total&lt;/code> and &lt;code>vllm:time_per_output_token_seconds&lt;/code>.&lt;/p>
&lt;hr>
&lt;h2 id="the-analogy">The analogy&lt;/h2>
&lt;p>An industrial kitchen with one chef and ten stoves. If the maître d&amp;rsquo; only sends one order at a time, the chef works at 10% of capacity. If he sends a hundred simultaneous orders but there are ingredients for only twenty, the chef spends half his time waiting for restocking. The optimum is the point where every stove is lit and the resupply never runs dry.&lt;/p>
&lt;p>&lt;code>max-num-seqs&lt;/code> is how many orders the chef can have in preparation at once. &lt;code>max-num-batched-tokens&lt;/code> is how many ingredients he can process in a single movement of the wok. Getting either of them wrong leaves stoves empty.&lt;/p>
&lt;hr>
&lt;h2 id="the-problem-the-defaults-are-not-for-your-hardware">The problem: the defaults are not for your hardware&lt;/h2>
&lt;p>In vLLM V1 (≥ 0.6), the defaults are:&lt;/p>
&lt;pre tabindex="0">&lt;code>max-num-seqs = 1024 (V1) / 256 (V0)
max-num-batched-tokens = 8192
&lt;/code>&lt;/pre>&lt;p>These values guarantee that vLLM starts on any GPU without OOM. They do not guarantee optimal throughput. The reason: the optimal point depends on three variables vLLM does not know at start-up:&lt;/p>
&lt;ol>
&lt;li>&lt;strong>The length distribution of your real workload&lt;/strong> — a RAG system with 2K-token prompts needs a different budget from a chat with 50-token messages.&lt;/li>
&lt;li>&lt;strong>VRAM available for KV cache&lt;/strong> — determined by the model, the quantisation and &lt;code>--gpu-memory-utilization&lt;/code>.&lt;/li>
&lt;li>&lt;strong>Real expected concurrency&lt;/strong> — how many simultaneous users arrive at the 95th percentile.&lt;/li>
&lt;/ol>
&lt;p>The interaction between these three factors makes it impossible for a universal default to be optimal in specific cases.&lt;/p>
&lt;hr>
&lt;h2 id="the-arithmetic-that-matters">The arithmetic that matters&lt;/h2>
&lt;p>On each iteration, vLLM&amp;rsquo;s scheduler decides which tokens to process. The total budget available per step is &lt;code>max-num-batched-tokens&lt;/code>. That budget is split between:&lt;/p>
&lt;ul>
&lt;li>&lt;strong>Decode tokens&lt;/strong>: 1 for each active request in the generation phase. With 64 requests in decode, 64 tokens of budget are consumed.&lt;/li>
&lt;li>&lt;strong>Prefill tokens&lt;/strong> (in chunks): the rest of the budget goes to processing new prompts.&lt;/li>
&lt;/ul>
$$\text{prefill tokens per step} = \text{max num batched tokens} - \text{num requests decode}$$
&lt;p>If &lt;code>max-num-batched-tokens = 8192&lt;/code> and you have 512 requests in decode, each step can only process &lt;code>8192 - 512 = 7680&lt;/code> prefill tokens. With 2000-token prompts, that is ~3.8 new prompts per iteration.&lt;/p>
&lt;p>The problem appears when &lt;code>max-num-seqs&lt;/code> is very high relative to the available KV cache. Each active request in decode occupies KV cache blocks. If the blocks run out, vLLM performs &lt;strong>preemption&lt;/strong>: it pauses a request, frees its KV cache and re-queues it. Each preemption costs additional latency for the paused request and complexity for the scheduler.&lt;/p>
$$\text{KV budget} = \frac{\text{free VRAM} \times \text{gpu memory utilization}}{\text{bytes per token} \times \text{max model len}}$$
&lt;p>For a Qwen2.5-14B on an RTX 4090 with Q4_K_M (9 GB of model, 15 GB free):&lt;/p>
$$\text{KV budget} = \frac{15 \times 0.92 \times 10^9}{40\,000} \approx 345\,000 \text{ tokens}$$
&lt;p>With &lt;code>max-model-len = 8192&lt;/code>, the maximum number of simultaneous requests with a full context is:&lt;/p>
$$\text{real max seqs} = \frac{345\,000}{8192} \approx 42 \text{ requests}$$
&lt;p>Setting &lt;code>max-num-seqs = 1024&lt;/code> with those numbers guarantees constant preemptions. The optimum is at 40-50.&lt;/p>
&lt;hr>
&lt;h2 id="the-grid-search-methodology">The grid search: methodology&lt;/h2>
&lt;h3 id="step-1-measure-the-real-workload">Step 1: measure the real workload&lt;/h3>
&lt;p>Before hunting for the optimum, you need to know your traffic&amp;rsquo;s percentiles. From Langfuse or the vLLM logs:&lt;/p>
&lt;div class="highlight">&lt;pre tabindex="0" class="chroma">&lt;code class="language-python" data-lang="python">&lt;span class="line">&lt;span class="cl">&lt;span class="c1"># Extract the length distribution from Langfuse&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="kn">import&lt;/span> &lt;span class="nn">langfuse&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="n">client&lt;/span> &lt;span class="o">=&lt;/span> &lt;span class="n">langfuse&lt;/span>&lt;span class="o">.&lt;/span>&lt;span class="n">Langfuse&lt;/span>&lt;span class="p">()&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="n">traces&lt;/span> &lt;span class="o">=&lt;/span> &lt;span class="n">client&lt;/span>&lt;span class="o">.&lt;/span>&lt;span class="n">fetch_traces&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="n">limit&lt;/span>&lt;span class="o">=&lt;/span>&lt;span class="mi">1000&lt;/span>&lt;span class="p">)&lt;/span>&lt;span class="o">.&lt;/span>&lt;span class="n">data&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="n">prompt_lens&lt;/span> &lt;span class="o">=&lt;/span> &lt;span class="p">[&lt;/span>&lt;span class="n">t&lt;/span>&lt;span class="o">.&lt;/span>&lt;span class="n">input_tokens&lt;/span> &lt;span class="k">for&lt;/span> &lt;span class="n">t&lt;/span> &lt;span class="ow">in&lt;/span> &lt;span class="n">traces&lt;/span> &lt;span class="k">if&lt;/span> &lt;span class="n">t&lt;/span>&lt;span class="o">.&lt;/span>&lt;span class="n">input_tokens&lt;/span>&lt;span class="p">]&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="n">output_lens&lt;/span> &lt;span class="o">=&lt;/span> &lt;span class="p">[&lt;/span>&lt;span class="n">t&lt;/span>&lt;span class="o">.&lt;/span>&lt;span class="n">output_tokens&lt;/span> &lt;span class="k">for&lt;/span> &lt;span class="n">t&lt;/span> &lt;span class="ow">in&lt;/span> &lt;span class="n">traces&lt;/span> &lt;span class="k">if&lt;/span> &lt;span class="n">t&lt;/span>&lt;span class="o">.&lt;/span>&lt;span class="n">output_tokens&lt;/span>&lt;span class="p">]&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="kn">import&lt;/span> &lt;span class="nn">numpy&lt;/span> &lt;span class="k">as&lt;/span> &lt;span class="nn">np&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="nb">print&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="sa">f&lt;/span>&lt;span class="s2">&amp;#34;Prompt p50=&lt;/span>&lt;span class="si">{&lt;/span>&lt;span class="n">np&lt;/span>&lt;span class="o">.&lt;/span>&lt;span class="n">percentile&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="n">prompt_lens&lt;/span>&lt;span class="p">,&lt;/span>&lt;span class="mi">50&lt;/span>&lt;span class="p">)&lt;/span>&lt;span class="si">:&lt;/span>&lt;span class="s2">.0f&lt;/span>&lt;span class="si">}&lt;/span>&lt;span class="s2"> p95=&lt;/span>&lt;span class="si">{&lt;/span>&lt;span class="n">np&lt;/span>&lt;span class="o">.&lt;/span>&lt;span class="n">percentile&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="n">prompt_lens&lt;/span>&lt;span class="p">,&lt;/span>&lt;span class="mi">95&lt;/span>&lt;span class="p">)&lt;/span>&lt;span class="si">:&lt;/span>&lt;span class="s2">.0f&lt;/span>&lt;span class="si">}&lt;/span>&lt;span class="s2"> p99=&lt;/span>&lt;span class="si">{&lt;/span>&lt;span class="n">np&lt;/span>&lt;span class="o">.&lt;/span>&lt;span class="n">percentile&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="n">prompt_lens&lt;/span>&lt;span class="p">,&lt;/span>&lt;span class="mi">99&lt;/span>&lt;span class="p">)&lt;/span>&lt;span class="si">:&lt;/span>&lt;span class="s2">.0f&lt;/span>&lt;span class="si">}&lt;/span>&lt;span class="s2">&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="nb">print&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="sa">f&lt;/span>&lt;span class="s2">&amp;#34;Output p50=&lt;/span>&lt;span class="si">{&lt;/span>&lt;span class="n">np&lt;/span>&lt;span class="o">.&lt;/span>&lt;span class="n">percentile&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="n">output_lens&lt;/span>&lt;span class="p">,&lt;/span>&lt;span class="mi">50&lt;/span>&lt;span class="p">)&lt;/span>&lt;span class="si">:&lt;/span>&lt;span class="s2">.0f&lt;/span>&lt;span class="si">}&lt;/span>&lt;span class="s2"> p95=&lt;/span>&lt;span class="si">{&lt;/span>&lt;span class="n">np&lt;/span>&lt;span class="o">.&lt;/span>&lt;span class="n">percentile&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="n">output_lens&lt;/span>&lt;span class="p">,&lt;/span>&lt;span class="mi">95&lt;/span>&lt;span class="p">)&lt;/span>&lt;span class="si">:&lt;/span>&lt;span class="s2">.0f&lt;/span>&lt;span class="si">}&lt;/span>&lt;span class="s2"> p99=&lt;/span>&lt;span class="si">{&lt;/span>&lt;span class="n">np&lt;/span>&lt;span class="o">.&lt;/span>&lt;span class="n">percentile&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="n">output_lens&lt;/span>&lt;span class="p">,&lt;/span>&lt;span class="mi">99&lt;/span>&lt;span class="p">)&lt;/span>&lt;span class="si">:&lt;/span>&lt;span class="s2">.0f&lt;/span>&lt;span class="si">}&lt;/span>&lt;span class="s2">&amp;#34;&lt;/span>&lt;span class="p">)&lt;/span>
&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;h3 id="step-2-work-out-the-kv-budget">Step 2: work out the KV budget&lt;/h3>
&lt;p>Run once with &lt;code>--dry-run&lt;/code>, or read vLLM&amp;rsquo;s start-up log:&lt;/p>
&lt;pre tabindex="0">&lt;code>INFO: # GPU blocks: 4521, # CPU blocks: 512
&lt;/code>&lt;/pre>&lt;p>Each block is 16 tokens. &lt;code>4521 × 16 = 72,336 tokens&lt;/code> of total KV budget.&lt;/p>
&lt;h3 id="step-3-the-grid">Step 3: the grid&lt;/h3>
&lt;p>With the KV budget known and the p95 of prompt/output length:&lt;/p>
&lt;div class="highlight">&lt;pre tabindex="0" class="chroma">&lt;code class="language-python" data-lang="python">&lt;span class="line">&lt;span class="cl">&lt;span class="c1"># grid_search_batch.py&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="kn">import&lt;/span> &lt;span class="nn">subprocess&lt;/span>&lt;span class="o">,&lt;/span> &lt;span class="nn">json&lt;/span>&lt;span class="o">,&lt;/span> &lt;span class="nn">time&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="n">MODEL&lt;/span> &lt;span class="o">=&lt;/span> &lt;span class="s2">&amp;#34;Qwen/Qwen2.5-14B-Instruct-AWQ&amp;#34;&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="n">PROMPT_LEN&lt;/span> &lt;span class="o">=&lt;/span> &lt;span class="mi">512&lt;/span> &lt;span class="c1"># p50 of your workload&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="n">OUTPUT_LEN&lt;/span> &lt;span class="o">=&lt;/span> &lt;span class="mi">256&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="n">CONCURRENCY&lt;/span> &lt;span class="o">=&lt;/span> &lt;span class="mi">32&lt;/span> &lt;span class="c1"># simultaneous users expected at peak&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="n">seqs_values&lt;/span> &lt;span class="o">=&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">64&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="mi">128&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="mi">256&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="mi">512&lt;/span>&lt;span class="p">]&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="n">tokens_values&lt;/span> &lt;span class="o">=&lt;/span> &lt;span class="p">[&lt;/span>&lt;span class="mi">4096&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="mi">8192&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="mi">16384&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="mi">32768&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="mi">65536&lt;/span>&lt;span class="p">]&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="n">results&lt;/span> &lt;span class="o">=&lt;/span> &lt;span class="p">[]&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="k">for&lt;/span> &lt;span class="n">seqs&lt;/span> &lt;span class="ow">in&lt;/span> &lt;span class="n">seqs_values&lt;/span>&lt;span class="p">:&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="k">for&lt;/span> &lt;span class="n">tokens&lt;/span> &lt;span class="ow">in&lt;/span> &lt;span class="n">tokens_values&lt;/span>&lt;span class="p">:&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="n">cmd&lt;/span> &lt;span class="o">=&lt;/span> &lt;span class="p">[&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="s2">&amp;#34;python&amp;#34;&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="s2">&amp;#34;-m&amp;#34;&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="s2">&amp;#34;vllm.entrypoints.benchmark_throughput&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="s2">&amp;#34;--model&amp;#34;&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="n">MODEL&lt;/span>&lt;span class="p">,&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="s2">&amp;#34;--max-num-seqs&amp;#34;&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="nb">str&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="n">seqs&lt;/span>&lt;span class="p">),&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="s2">&amp;#34;--max-num-batched-tokens&amp;#34;&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="nb">str&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="n">tokens&lt;/span>&lt;span class="p">),&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="s2">&amp;#34;--num-prompts&amp;#34;&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="s2">&amp;#34;200&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="s2">&amp;#34;--input-len&amp;#34;&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="nb">str&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="n">PROMPT_LEN&lt;/span>&lt;span class="p">),&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="s2">&amp;#34;--output-len&amp;#34;&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="nb">str&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="n">OUTPUT_LEN&lt;/span>&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;span class="line">&lt;span class="cl"> &lt;span class="n">out&lt;/span> &lt;span class="o">=&lt;/span> &lt;span class="n">subprocess&lt;/span>&lt;span class="o">.&lt;/span>&lt;span class="n">run&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="n">cmd&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="n">capture_output&lt;/span>&lt;span class="o">=&lt;/span>&lt;span class="kc">True&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="n">text&lt;/span>&lt;span class="o">=&lt;/span>&lt;span class="kc">True&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="n">timeout&lt;/span>&lt;span class="o">=&lt;/span>&lt;span class="mi">300&lt;/span>&lt;span class="p">)&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="c1"># Parse throughput from the output&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="k">for&lt;/span> &lt;span class="n">line&lt;/span> &lt;span class="ow">in&lt;/span> &lt;span class="n">out&lt;/span>&lt;span class="o">.&lt;/span>&lt;span class="n">stdout&lt;/span>&lt;span class="o">.&lt;/span>&lt;span class="n">splitlines&lt;/span>&lt;span class="p">():&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="k">if&lt;/span> &lt;span class="s2">&amp;#34;Throughput&amp;#34;&lt;/span> &lt;span class="ow">in&lt;/span> &lt;span class="n">line&lt;/span>&lt;span class="p">:&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="n">tps&lt;/span> &lt;span class="o">=&lt;/span> &lt;span class="nb">float&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="n">line&lt;/span>&lt;span class="o">.&lt;/span>&lt;span class="n">split&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="s2">&amp;#34;:&amp;#34;&lt;/span>&lt;span class="p">)[&lt;/span>&lt;span class="mi">1&lt;/span>&lt;span class="p">]&lt;/span>&lt;span class="o">.&lt;/span>&lt;span class="n">strip&lt;/span>&lt;span class="p">()&lt;/span>&lt;span class="o">.&lt;/span>&lt;span class="n">split&lt;/span>&lt;span class="p">()[&lt;/span>&lt;span class="mi">0&lt;/span>&lt;span class="p">])&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="n">results&lt;/span>&lt;span class="o">.&lt;/span>&lt;span class="n">append&lt;/span>&lt;span class="p">({&lt;/span>&lt;span class="s2">&amp;#34;seqs&amp;#34;&lt;/span>&lt;span class="p">:&lt;/span> &lt;span class="n">seqs&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="s2">&amp;#34;tokens&amp;#34;&lt;/span>&lt;span class="p">:&lt;/span> &lt;span class="n">tokens&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="s2">&amp;#34;tps&amp;#34;&lt;/span>&lt;span class="p">:&lt;/span> &lt;span class="n">tps&lt;/span>&lt;span class="p">})&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="nb">print&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="sa">f&lt;/span>&lt;span class="s2">&amp;#34;seqs=&lt;/span>&lt;span class="si">{&lt;/span>&lt;span class="n">seqs&lt;/span>&lt;span class="si">}&lt;/span>&lt;span class="s2"> tokens=&lt;/span>&lt;span class="si">{&lt;/span>&lt;span class="n">tokens&lt;/span>&lt;span class="si">}&lt;/span>&lt;span class="s2"> → &lt;/span>&lt;span class="si">{&lt;/span>&lt;span class="n">tps&lt;/span>&lt;span class="si">:&lt;/span>&lt;span class="s2">.1f&lt;/span>&lt;span class="si">}&lt;/span>&lt;span class="s2"> tok/s&amp;#34;&lt;/span>&lt;span class="p">)&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="c1"># Save for analysis&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="k">with&lt;/span> &lt;span class="nb">open&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="s2">&amp;#34;grid_results.json&amp;#34;&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="s2">&amp;#34;w&amp;#34;&lt;/span>&lt;span class="p">)&lt;/span> &lt;span class="k">as&lt;/span> &lt;span class="n">f&lt;/span>&lt;span class="p">:&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="n">json&lt;/span>&lt;span class="o">.&lt;/span>&lt;span class="n">dump&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="n">results&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="n">f&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="n">indent&lt;/span>&lt;span class="o">=&lt;/span>&lt;span class="mi">2&lt;/span>&lt;span class="p">)&lt;/span>
&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>25 configurations × ~5 min = &lt;strong>~2 hours&lt;/strong>. Real execution time, not waiting time.&lt;/p>
&lt;h3 id="step-4-read-the-surface">Step 4: read the surface&lt;/h3>
&lt;p>The result is a 5×5 throughput matrix. The typical shape:&lt;/p>
&lt;pre tabindex="0">&lt;code>max-num-batched-tokens → 4K 8K 16K 32K 64K
max-num-seqs ↓
32 180 310 380 390 385 ← max-num-seqs too low
64 185 350 480 510 508 ← optimal point for this workload
128 182 340 450 480 475
256 178 320 400 410 402 ← KV cache runs out, preemptions
512 170 290 360 370 368 ← high preemptions
&lt;/code>&lt;/pre>&lt;p>The optimum in this example: &lt;code>max-num-seqs=64, max-num-batched-tokens=32768&lt;/code>. Above that, preemptions cancel out the concurrency gain.&lt;/p>
&lt;hr>
&lt;h2 id="confirmation-with-otel">Confirmation with OTel&lt;/h2>
&lt;p>Once the optimal configuration is deployed, three Prometheus metrics confirm it is well calibrated:&lt;/p>
&lt;div class="highlight">&lt;pre tabindex="0" class="chroma">&lt;code class="language-promql" data-lang="promql">&lt;span class="line">&lt;span class="cl">&lt;span class="c1"># 1. Queued requests — should stay close to 0&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w">&lt;/span>&lt;span class="c1"># If it grows steadily: max-num-seqs too low or max-num-batched-tokens insufficient&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w">&lt;/span>&lt;span class="nv">vllm&lt;/span>&lt;span class="err">:&lt;/span>&lt;span class="nv">num_waiting_seqs&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w">&lt;/span>&lt;span class="c1"># 2. Preemptions — should be 0 or very occasional (&amp;lt;1/min)&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w">&lt;/span>&lt;span class="c1"># If it grows: max-num-seqs too high for the available KV cache&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w">&lt;/span>&lt;span class="kr">rate&lt;/span>&lt;span class="o">(&lt;/span>&lt;span class="nv">vllm&lt;/span>&lt;span class="err">:&lt;/span>&lt;span class="nv">num_preemptions_total&lt;/span>&lt;span class="p">[&lt;/span>&lt;span class="s">5m&lt;/span>&lt;span class="p">]&lt;/span>&lt;span class="o">)&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="o">*&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="mi">60&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w">&lt;/span>&lt;span class="c1"># 3. ITL (inter-token latency) — should be stable, with no spikes&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w">&lt;/span>&lt;span class="c1"># Bimodality = badly calibrated batch size (some requests outside the CUDA graph bucket)&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w">&lt;/span>&lt;span class="kr">histogram_quantile&lt;/span>&lt;span class="o">(&lt;/span>&lt;span class="mf">0.99&lt;/span>&lt;span class="p">,&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="kr">rate&lt;/span>&lt;span class="o">(&lt;/span>&lt;span class="nv">vllm&lt;/span>&lt;span class="err">:&lt;/span>&lt;span class="nv">time_per_output_token_seconds_bucket&lt;/span>&lt;span class="p">[&lt;/span>&lt;span class="s">5m&lt;/span>&lt;span class="p">]&lt;/span>&lt;span class="o">))&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>The optimal configuration produces:&lt;/p>
&lt;ul>
&lt;li>&lt;code>num_waiting_seqs&lt;/code> ≈ 0 under normal operation&lt;/li>
&lt;li>&lt;code>num_preemptions_total&lt;/code> stable (not growing)&lt;/li>
&lt;li>&lt;code>time_per_output_token&lt;/code> unimodal&lt;/li>
&lt;/ul>
&lt;p>If &lt;code>num_waiting_seqs&lt;/code> is high with &lt;code>gpu_cache_usage_perc&lt;/code> low: raise &lt;code>max-num-batched-tokens&lt;/code> to process prefills faster. If &lt;code>num_preemptions_total&lt;/code> grows: lower &lt;code>max-num-seqs&lt;/code> or enable FP8 KV cache to free up blocks.&lt;/p>
&lt;hr>
&lt;h2 id="reference-configurations-by-profile">Reference configurations by profile&lt;/h2>
&lt;p>Based on the grid search for mid-range hardware (generic 4×H100, 14B-70B model):&lt;/p>
&lt;table>
&lt;thead>
&lt;tr>
&lt;th style="text-align:left">Profile&lt;/th>
&lt;th style="text-align:right">Prompt p50&lt;/th>
&lt;th style="text-align:right">Output p50&lt;/th>
&lt;th style="text-align:right">max-num-seqs&lt;/th>
&lt;th style="text-align:right">max-num-batched-tokens&lt;/th>
&lt;/tr>
&lt;/thead>
&lt;tbody>
&lt;tr>
&lt;td style="text-align:left">Conversational chat&lt;/td>
&lt;td style="text-align:right">150 tok&lt;/td>
&lt;td style="text-align:right">300 tok&lt;/td>
&lt;td style="text-align:right">256&lt;/td>
&lt;td style="text-align:right">16384&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td style="text-align:left">Enterprise RAG&lt;/td>
&lt;td style="text-align:right">1500 tok&lt;/td>
&lt;td style="text-align:right">200 tok&lt;/td>
&lt;td style="text-align:right">64&lt;/td>
&lt;td style="text-align:right">32768&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td style="text-align:left">Coding (completion)&lt;/td>
&lt;td style="text-align:right">800 tok&lt;/td>
&lt;td style="text-align:right">500 tok&lt;/td>
&lt;td style="text-align:right">128&lt;/td>
&lt;td style="text-align:right">32768&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td style="text-align:left">Summarisation&lt;/td>
&lt;td style="text-align:right">2500 tok&lt;/td>
&lt;td style="text-align:right">400 tok&lt;/td>
&lt;td style="text-align:right">32&lt;/td>
&lt;td style="text-align:right">65536&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td style="text-align:left">Batch processing&lt;/td>
&lt;td style="text-align:right">4000 tok&lt;/td>
&lt;td style="text-align:right">800 tok&lt;/td>
&lt;td style="text-align:right">16&lt;/td>
&lt;td style="text-align:right">65536&lt;/td>
&lt;/tr>
&lt;/tbody>
&lt;/table>
&lt;p>None of these is universal. They are starting points for the grid search on your real hardware and workload.&lt;/p>
&lt;hr>
&lt;h2 id="when-not-to-touch-the-defaults">When not to touch the defaults&lt;/h2>
&lt;p>If your system sits below 50% KV cache utilisation (&lt;code>vllm:gpu_cache_usage_perc &amp;lt; 0.50&lt;/code>) under real demand and with no &lt;code>num_waiting_seqs&lt;/code>, the defaults are enough for your current load. The grid search contributes more when you are close to maximum capacity or when you want to extract the full performance of fixed hardware.&lt;/p>
&lt;hr>
&lt;h2 id="see-also">See also&lt;/h2>
&lt;ul>
&lt;li>&lt;a href="https://blog.lo0.es/en/posts/optimising-prefill-vllm-knobs-ttft-will-not-forgive/">&lt;code>max-num-batched-tokens&lt;/code> is the budget chunked prefill uses to interleave decode; this article covers the tuning of that parameter&lt;/a>&lt;/li>
&lt;li>&lt;a href="https://blog.lo0.es/en/posts/optimising-decode-vllm-squeezing-every-token-small-hardware/">&lt;code>max-num-seqs&lt;/code> interacts directly with &lt;code>gpu-memory-utilization&lt;/code> and the KV cache capacity for decode&lt;/a>&lt;/li>
&lt;li>&lt;a href="https://blog.lo0.es/en/posts/instrumenting-vllm-otel-measuring-optimisations/">the &lt;code>num_waiting_seqs&lt;/code>, &lt;code>num_preemptions_total&lt;/code> and &lt;code>time_per_output_token&lt;/code> metrics configured in the full OTel pipeline&lt;/a>&lt;/li>
&lt;li>&lt;a href="https://blog.lo0.es/en/posts/kv-cache-working-memory-llm-inference/">the KV budget formula that determines the real maximum of &lt;code>max-num-seqs&lt;/code> for your hardware&lt;/a>&lt;/li>
&lt;li>&lt;a href="https://blog.lo0.es/en/posts/capacity-planning-on-premise-llm-inference/">hardware sizing starts from the optimal throughput this grid search determines&lt;/a>&lt;/li>
&lt;/ul>
&lt;h3 id="in-this-same-series">In this same series&lt;/h3>
&lt;ul>
&lt;li>&lt;a href="https://blog.lo0.es/en/posts/prefix-cache-hit-rate-engineering/">the second free optimisation: taking the prefix cache hit rate from 15% to 75%&lt;/a>&lt;/li>
&lt;li>&lt;a href="https://blog.lo0.es/en/posts/fp8-end-to-end-enable-measure-quality-decide/">FP8 in weights and KV cache: +40-60% throughput measured before and after with an eval suite&lt;/a>&lt;/li>
&lt;li>&lt;a href="https://blog.lo0.es/en/posts/one-big-replica-or-many-small-ones-platform-decision/">TP=4×1 vs TP=2×2: when the crossover point changes the platform decision&lt;/a>&lt;/li>
&lt;/ul>
&lt;hr>
&lt;h2 id="references">References&lt;/h2>
&lt;ul>
&lt;li>&lt;a href="https://docs.vllm.ai/en/stable/configuration/optimization/">vLLM Optimization and Tuning — documentación oficial&lt;/a>&lt;/li>
&lt;li>&lt;a href="https://medium.com/@mahernaija/tuning-vllm-for-maximum-throughput-a-research-engineers-field-guide-21f341d71248">Tuning vLLM for Maximum Throughput (2026)&lt;/a>&lt;/li>
&lt;li>&lt;a href="https://rocm.docs.amd.com/en/latest/how-to/rocm-for-ai/inference-optimization/vllm-optimization.html">vLLM V1 performance optimization — ROCm&lt;/a>&lt;/li>
&lt;li>&lt;a href="https://docs.anyscale.com/llm/serving/parameter-tuning">Anyscale: Tune parameters for LLMs&lt;/a>&lt;/li>
&lt;/ul></description></item><item><title>FP8 end to end: enable it, measure quality and decide with data</title><link>https://blog.lo0.es/en/posts/fp8-end-to-end-enable-measure-quality-decide/</link><pubDate>Fri, 05 Jun 2026 04:00:00 +0000</pubDate><guid>https://blog.lo0.es/en/posts/fp8-end-to-end-enable-measure-quality-decide/</guid><description>&lt;h2 id="tldr">TL;DR&lt;/h2>
&lt;p>FP8 is the configuration change with the highest impact per unit of effort available on H100 and Ada Lovelace hardware. On an H100 it enables native FP8 tensor cores: +40-60% decode throughput and ×2 the VRAM available for the KV cache. On an RTX 4090 and an L40 the compute benefit is smaller, but the ×2 VRAM is real and translates directly into twice the concurrency. The risk is quality degradation, which on well-calibrated modern models is &amp;lt;0.5% on standard benchmarks but can be larger on formal reasoning. The correct workflow is not to switch it on and pray: it is to switch it on in staging, run the eval suite, correlate quality with throughput in OTel, and decide with data.&lt;/p>
&lt;hr>
&lt;h2 id="the-analogy">The analogy&lt;/h2>
&lt;p>A photographer who works with 35 mm negatives and moves to digital. Digital photos take up less space and are processed faster. But a low-resolution photo of a landscape can be indistinguishable from the high-resolution one to the human eye, while a low-resolution photo of text loses letters. Exactly the same trade-off applies to FP8: for tasks where numerical imprecision is averaged out over thousands of activations (conversation, summarisation, RAG), it is practically invisible. For tasks where a single wrong multiplication propagates into an incorrect answer (formal mathematics, critical code), it can be decisive.&lt;/p>
&lt;hr>
&lt;h2 id="the-three-layers-of-fp8-in-vllm">The three layers of FP8 in vLLM&lt;/h2>
&lt;p>FP8 is not a single flag: it is three independent layers that are enabled separately and have different benefits.&lt;/p>
&lt;p>&lt;strong>Layer 1 — Model weights (&lt;code>--quantization fp8&lt;/code>):&lt;/strong>
The model weights are stored and computed in FP8 E4M3. The models have to be pre-quantised (available on HuggingFace with a &lt;code>-FP8&lt;/code> or &lt;code>-fp8&lt;/code> suffix) or quantised at load time with calibration. The benefit: the model takes up half the VRAM and the weight matmuls are 2× faster on an H100.&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">&lt;span class="c1"># Pre-quantised model (recommended for production)&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">vllm serve neuralmagic/Meta-Llama-3.1-70B-Instruct-FP8 &lt;span class="se">\
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="se">&lt;/span> --quantization fp8
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="c1"># Or on-the-fly quantisation (no extra files, somewhat slower on the first tokens)&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">vllm serve meta-llama/Meta-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> --quantization fp8 &lt;span class="se">\
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="se">&lt;/span> --kv-cache-dtype auto
&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>&lt;strong>Layer 2 — KV cache (&lt;code>--kv-cache-dtype fp8&lt;/code>):&lt;/strong>
The K and V tensors of the KV cache are stored in FP8 instead of BF16. It halves the size of the KV cache, doubling the number of tokens that fit in VRAM. It does not affect the model weights.&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 my-model &lt;span class="se">\
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="se">&lt;/span> --kv-cache-dtype fp8 &lt;span class="se">\
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="se">&lt;/span> --calculate-kv-scales &lt;span class="c1"># dynamic calibration, mandatory to minimise degradation&lt;/span>
&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>&lt;strong>Layer 3 — Activations (automatic on an H100):&lt;/strong>
On Hopper GPUs, vLLM automatically enables FP8 for the intermediate activations when both previous layers are active. No extra flag is required.&lt;/p>
&lt;p>&lt;strong>Full production configuration:&lt;/strong>&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 neuralmagic/Meta-Llama-3.1-70B-Instruct-FP8 &lt;span class="se">\
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="se">&lt;/span> --quantization fp8 &lt;span class="se">\
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="se">&lt;/span> --kv-cache-dtype fp8 &lt;span class="se">\
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="se">&lt;/span> --calculate-kv-scales &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 class="se">\
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="se">&lt;/span> --max-model-len &lt;span class="m">16384&lt;/span>
&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;hr>
&lt;h2 id="the-measurable-impact-by-hardware">The measurable impact by hardware&lt;/h2>
&lt;h3 id="h100-sxm-hopper-native-fp8-tensor-cores">H100 SXM (Hopper, native FP8 tensor cores)&lt;/h3>
&lt;table>
&lt;thead>
&lt;tr>
&lt;th style="text-align:left">Metric&lt;/th>
&lt;th style="text-align:right">BF16 baseline&lt;/th>
&lt;th style="text-align:right">FP8 enabled&lt;/th>
&lt;th style="text-align:right">Delta&lt;/th>
&lt;/tr>
&lt;/thead>
&lt;tbody>
&lt;tr>
&lt;td style="text-align:left">Decode throughput (tok/s, 70B, batch 32)&lt;/td>
&lt;td style="text-align:right">~1,800&lt;/td>
&lt;td style="text-align:right">~2,700&lt;/td>
&lt;td style="text-align:right">+50%&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td style="text-align:left">Model VRAM (70B)&lt;/td>
&lt;td style="text-align:right">140 GB&lt;/td>
&lt;td style="text-align:right">70 GB&lt;/td>
&lt;td style="text-align:right">−50%&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td style="text-align:left">KV cache VRAM available (on 4×H100)&lt;/td>
&lt;td style="text-align:right">180 GB&lt;/td>
&lt;td style="text-align:right">250 GB&lt;/td>
&lt;td style="text-align:right">+39%&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td style="text-align:left">Maximum concurrency (ctx 8K)&lt;/td>
&lt;td style="text-align:right">~22,500 tok&lt;/td>
&lt;td style="text-align:right">~31,250 tok&lt;/td>
&lt;td style="text-align:right">+39%&lt;/td>
&lt;/tr>
&lt;/tbody>
&lt;/table>
&lt;p>This is equivalent to an extra replica for free in terms of KV cache capacity.&lt;/p>
&lt;h3 id="rtx-4090-ada-lovelace-fp8-in-cuda-but-without-dedicated-tensor-cores">RTX 4090 (Ada Lovelace, FP8 in CUDA but without dedicated tensor cores)&lt;/h3>
&lt;table>
&lt;thead>
&lt;tr>
&lt;th style="text-align:left">Metric&lt;/th>
&lt;th style="text-align:right">BF16/Q4 baseline&lt;/th>
&lt;th style="text-align:right">FP8 KV cache added&lt;/th>
&lt;th style="text-align:right">Delta&lt;/th>
&lt;/tr>
&lt;/thead>
&lt;tbody>
&lt;tr>
&lt;td style="text-align:left">Decode throughput (tok/s, 14B Q4)&lt;/td>
&lt;td style="text-align:right">~45&lt;/td>
&lt;td style="text-align:right">~47&lt;/td>
&lt;td style="text-align:right">+4%&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td style="text-align:left">KV cache VRAM available&lt;/td>
&lt;td style="text-align:right">15 GB&lt;/td>
&lt;td style="text-align:right">15 GB (same model)&lt;/td>
&lt;td style="text-align:right">—&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td style="text-align:left">Total cache tokens (ctx 8K)&lt;/td>
&lt;td style="text-align:right">~46,000&lt;/td>
&lt;td style="text-align:right">~92,000&lt;/td>
&lt;td style="text-align:right">+100%&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td style="text-align:left">Maximum concurrency (ctx 8K)&lt;/td>
&lt;td style="text-align:right">~5 users&lt;/td>
&lt;td style="text-align:right">~11 users&lt;/td>
&lt;td style="text-align:right">+120%&lt;/td>
&lt;/tr>
&lt;/tbody>
&lt;/table>
&lt;p>On Ada, the compute benefit is smaller (the FP8 tensor cores do not have the same width as on Hopper), but the ×2 in KV cache capacity is entirely real and translates into twice the possible concurrent users.&lt;/p>
&lt;hr>
&lt;h2 id="the-correct-workflow-enable-measure-decide">The correct workflow: enable, measure, decide&lt;/h2>
&lt;p>Enabling FP8 directly in production without validating quality is inadequate. The correct workflow has four steps.&lt;/p>
&lt;h3 id="step-1-baseline-in-staging">Step 1: baseline in staging&lt;/h3>
&lt;p>Before enabling FP8, record the quality metrics of the current BF16 model. The most reproducible way is to run an eval suite over a fixed dataset and save the results:&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">&lt;span class="c1"># Install lm-evaluation-harness&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">pip install lm-eval
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="c1"># BF16 baseline&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">lm_eval --model vllm &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_args &lt;span class="nv">pretrained&lt;/span>&lt;span class="o">=&lt;/span>meta-llama/Meta-Llama-3.1-70B-Instruct,dtype&lt;span class="o">=&lt;/span>bfloat16 &lt;span class="se">\
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="se">&lt;/span> --tasks mmlu,hellaswag,gsm8k &lt;span class="se">\
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="se">&lt;/span> --num_fewshot &lt;span class="m">5&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> --output_path ./results/baseline_bf16.json
&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;h3 id="step-2-enable-fp8-and-run-the-same-eval-suite">Step 2: enable FP8 and run the same eval suite&lt;/h3>
&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">&lt;span class="c1"># FP8&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">lm_eval --model vllm &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_args &lt;span class="nv">pretrained&lt;/span>&lt;span class="o">=&lt;/span>neuralmagic/Meta-Llama-3.1-70B-Instruct-FP8,quantization&lt;span class="o">=&lt;/span>fp8,kv_cache_dtype&lt;span class="o">=&lt;/span>fp8,calculate_kv_scales&lt;span class="o">=&lt;/span>&lt;span class="nb">true&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> --tasks mmlu,hellaswag,gsm8k &lt;span class="se">\
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="se">&lt;/span> --num_fewshot &lt;span class="m">5&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> --output_path ./results/fp8_full.json
&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;h3 id="step-3-compute-the-degradation">Step 3: compute the degradation&lt;/h3>
&lt;div class="highlight">&lt;pre tabindex="0" class="chroma">&lt;code class="language-python" data-lang="python">&lt;span class="line">&lt;span class="cl">&lt;span class="c1"># compare_eval.py&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="kn">import&lt;/span> &lt;span class="nn">json&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="k">with&lt;/span> &lt;span class="nb">open&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="s2">&amp;#34;results/baseline_bf16.json&amp;#34;&lt;/span>&lt;span class="p">)&lt;/span> &lt;span class="k">as&lt;/span> &lt;span class="n">f&lt;/span>&lt;span class="p">:&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="n">baseline&lt;/span> &lt;span class="o">=&lt;/span> &lt;span class="n">json&lt;/span>&lt;span class="o">.&lt;/span>&lt;span class="n">load&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="n">f&lt;/span>&lt;span class="p">)&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="k">with&lt;/span> &lt;span class="nb">open&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="s2">&amp;#34;results/fp8_full.json&amp;#34;&lt;/span>&lt;span class="p">)&lt;/span> &lt;span class="k">as&lt;/span> &lt;span class="n">f&lt;/span>&lt;span class="p">:&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="n">fp8&lt;/span> &lt;span class="o">=&lt;/span> &lt;span class="n">json&lt;/span>&lt;span class="o">.&lt;/span>&lt;span class="n">load&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="n">f&lt;/span>&lt;span class="p">)&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="n">tasks&lt;/span> &lt;span class="o">=&lt;/span> &lt;span class="p">[&lt;/span>&lt;span class="s2">&amp;#34;mmlu&amp;#34;&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="s2">&amp;#34;hellaswag&amp;#34;&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="s2">&amp;#34;gsm8k&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="nb">print&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="sa">f&lt;/span>&lt;span class="s2">&amp;#34;&lt;/span>&lt;span class="si">{&lt;/span>&lt;span class="s1">&amp;#39;Task&amp;#39;&lt;/span>&lt;span class="si">:&lt;/span>&lt;span class="s2">&amp;lt;15&lt;/span>&lt;span class="si">}&lt;/span>&lt;span class="s2"> &lt;/span>&lt;span class="si">{&lt;/span>&lt;span class="s1">&amp;#39;BF16&amp;#39;&lt;/span>&lt;span class="si">:&lt;/span>&lt;span class="s2">&amp;gt;8&lt;/span>&lt;span class="si">}&lt;/span>&lt;span class="s2"> &lt;/span>&lt;span class="si">{&lt;/span>&lt;span class="s1">&amp;#39;FP8&amp;#39;&lt;/span>&lt;span class="si">:&lt;/span>&lt;span class="s2">&amp;gt;8&lt;/span>&lt;span class="si">}&lt;/span>&lt;span class="s2"> &lt;/span>&lt;span class="si">{&lt;/span>&lt;span class="s1">&amp;#39;Delta&amp;#39;&lt;/span>&lt;span class="si">:&lt;/span>&lt;span class="s2">&amp;gt;8&lt;/span>&lt;span class="si">}&lt;/span>&lt;span class="s2"> &lt;/span>&lt;span class="si">{&lt;/span>&lt;span class="s1">&amp;#39;OK?&amp;#39;&lt;/span>&lt;span class="si">:&lt;/span>&lt;span class="s2">&amp;gt;6&lt;/span>&lt;span class="si">}&lt;/span>&lt;span class="s2">&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="nb">print&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="s2">&amp;#34;-&amp;#34;&lt;/span> &lt;span class="o">*&lt;/span> &lt;span class="mi">50&lt;/span>&lt;span class="p">)&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="k">for&lt;/span> &lt;span class="n">task&lt;/span> &lt;span class="ow">in&lt;/span> &lt;span class="n">tasks&lt;/span>&lt;span class="p">:&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="n">b&lt;/span> &lt;span class="o">=&lt;/span> &lt;span class="n">baseline&lt;/span>&lt;span class="p">[&lt;/span>&lt;span class="s2">&amp;#34;results&amp;#34;&lt;/span>&lt;span class="p">][&lt;/span>&lt;span class="n">task&lt;/span>&lt;span class="p">][&lt;/span>&lt;span class="s2">&amp;#34;acc,none&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="n">f&lt;/span> &lt;span class="o">=&lt;/span> &lt;span class="n">fp8&lt;/span>&lt;span class="p">[&lt;/span>&lt;span class="s2">&amp;#34;results&amp;#34;&lt;/span>&lt;span class="p">][&lt;/span>&lt;span class="n">task&lt;/span>&lt;span class="p">][&lt;/span>&lt;span class="s2">&amp;#34;acc,none&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="n">delta&lt;/span> &lt;span class="o">=&lt;/span> &lt;span class="p">(&lt;/span>&lt;span class="n">f&lt;/span> &lt;span class="o">-&lt;/span> &lt;span class="n">b&lt;/span>&lt;span class="p">)&lt;/span> &lt;span class="o">/&lt;/span> &lt;span class="n">b&lt;/span> &lt;span class="o">*&lt;/span> &lt;span class="mi">100&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="n">ok&lt;/span> &lt;span class="o">=&lt;/span> &lt;span class="s2">&amp;#34;✓&amp;#34;&lt;/span> &lt;span class="k">if&lt;/span> &lt;span class="nb">abs&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="n">delta&lt;/span>&lt;span class="p">)&lt;/span> &lt;span class="o">&amp;lt;&lt;/span> &lt;span class="mf">1.0&lt;/span> &lt;span class="k">else&lt;/span> &lt;span class="s2">&amp;#34;✗ REVIEW&amp;#34;&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="nb">print&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="sa">f&lt;/span>&lt;span class="s2">&amp;#34;&lt;/span>&lt;span class="si">{&lt;/span>&lt;span class="n">task&lt;/span>&lt;span class="si">:&lt;/span>&lt;span class="s2">&amp;lt;15&lt;/span>&lt;span class="si">}&lt;/span>&lt;span class="s2"> &lt;/span>&lt;span class="si">{&lt;/span>&lt;span class="n">b&lt;/span>&lt;span class="si">:&lt;/span>&lt;span class="s2">&amp;gt;8.3f&lt;/span>&lt;span class="si">}&lt;/span>&lt;span class="s2"> &lt;/span>&lt;span class="si">{&lt;/span>&lt;span class="n">f&lt;/span>&lt;span class="si">:&lt;/span>&lt;span class="s2">&amp;gt;8.3f&lt;/span>&lt;span class="si">}&lt;/span>&lt;span class="s2"> &lt;/span>&lt;span class="si">{&lt;/span>&lt;span class="n">delta&lt;/span>&lt;span class="si">:&lt;/span>&lt;span class="s2">&amp;gt;+7.1f&lt;/span>&lt;span class="si">}&lt;/span>&lt;span class="s2">% &lt;/span>&lt;span class="si">{&lt;/span>&lt;span class="n">ok&lt;/span>&lt;span class="si">:&lt;/span>&lt;span class="s2">&amp;gt;6&lt;/span>&lt;span class="si">}&lt;/span>&lt;span class="s2">&amp;#34;&lt;/span>&lt;span class="p">)&lt;/span>
&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>Decision thresholds documented in MLPerf Inference 2025:&lt;/p>
&lt;ul>
&lt;li>&lt;strong>&amp;lt; 0.5% degradation&lt;/strong>: enable in production without restrictions.&lt;/li>
&lt;li>&lt;strong>0.5% – 1.5%&lt;/strong>: enable with active quality monitoring via LLM-as-judge.&lt;/li>
&lt;li>&lt;strong>&amp;gt; 1.5%&lt;/strong>: investigate before enabling — a possible calibration problem or an incompatible model.&lt;/li>
&lt;/ul>
&lt;h3 id="step-4-domain-eval-with-llm-as-judge">Step 4: domain eval with LLM-as-judge&lt;/h3>
&lt;p>Academic benchmarks measure what they measure. Your use case may be different. Adding 200 representative samples from your domain, evaluated by an LLM judge, closes the gap:&lt;/p>
&lt;div class="highlight">&lt;pre tabindex="0" class="chroma">&lt;code class="language-python" data-lang="python">&lt;span class="line">&lt;span class="cl">&lt;span class="c1"># domain_eval.py&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="kn">from&lt;/span> &lt;span class="nn">langfuse&lt;/span> &lt;span class="kn">import&lt;/span> &lt;span class="n">Langfuse&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="kn">from&lt;/span> &lt;span class="nn">openai&lt;/span> &lt;span class="kn">import&lt;/span> &lt;span class="n">OpenAI&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="n">client&lt;/span> &lt;span class="o">=&lt;/span> &lt;span class="n">Langfuse&lt;/span>&lt;span class="p">()&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="n">judge&lt;/span> &lt;span class="o">=&lt;/span> &lt;span class="n">OpenAI&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="n">base_url&lt;/span>&lt;span class="o">=&lt;/span>&lt;span class="s2">&amp;#34;http://judge-llm:8000/v1&amp;#34;&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="n">api_key&lt;/span>&lt;span class="o">=&lt;/span>&lt;span class="s2">&amp;#34;token&amp;#34;&lt;/span>&lt;span class="p">)&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="c1"># Load the 200 curated production samples (prompt + expected answer)&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="n">samples&lt;/span> &lt;span class="o">=&lt;/span> &lt;span class="n">load_domain_samples&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="s2">&amp;#34;eval_dataset_200.json&amp;#34;&lt;/span>&lt;span class="p">)&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="n">scores_bf16&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="n">scores_fp8&lt;/span> &lt;span class="o">=&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="k">for&lt;/span> &lt;span class="n">sample&lt;/span> &lt;span class="ow">in&lt;/span> &lt;span class="n">samples&lt;/span>&lt;span class="p">:&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="k">for&lt;/span> &lt;span class="n">model_type&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="n">endpoint&lt;/span> &lt;span class="ow">in&lt;/span> &lt;span class="p">[(&lt;/span>&lt;span class="s2">&amp;#34;bf16&amp;#34;&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="s2">&amp;#34;http://staging-bf16:8000&amp;#34;&lt;/span>&lt;span class="p">),&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 class="s2">&amp;#34;http://staging-fp8:8000&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="n">response&lt;/span> &lt;span class="o">=&lt;/span> &lt;span class="n">call_model&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="n">endpoint&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="n">sample&lt;/span>&lt;span class="p">[&lt;/span>&lt;span class="s2">&amp;#34;prompt&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="n">score&lt;/span> &lt;span class="o">=&lt;/span> &lt;span class="n">judge&lt;/span>&lt;span class="o">.&lt;/span>&lt;span class="n">chat&lt;/span>&lt;span class="o">.&lt;/span>&lt;span class="n">completions&lt;/span>&lt;span class="o">.&lt;/span>&lt;span class="n">create&lt;/span>&lt;span class="p">(&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="n">model&lt;/span>&lt;span class="o">=&lt;/span>&lt;span class="s2">&amp;#34;Qwen/Qwen2.5-72B-Instruct&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="n">messages&lt;/span>&lt;span class="o">=&lt;/span>&lt;span class="p">[{&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="s2">&amp;#34;role&amp;#34;&lt;/span>&lt;span class="p">:&lt;/span> &lt;span class="s2">&amp;#34;user&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="s2">&amp;#34;content&amp;#34;&lt;/span>&lt;span class="p">:&lt;/span> &lt;span class="sa">f&lt;/span>&lt;span class="s2">&amp;#34;Rate this answer from 1 to 5 on accuracy and completeness.&lt;/span>&lt;span class="se">\n&lt;/span>&lt;span class="s2">Question: &lt;/span>&lt;span class="si">{&lt;/span>&lt;span class="n">sample&lt;/span>&lt;span class="p">[&lt;/span>&lt;span class="s1">&amp;#39;prompt&amp;#39;&lt;/span>&lt;span class="p">]&lt;/span>&lt;span class="si">}&lt;/span>&lt;span class="se">\n&lt;/span>&lt;span class="s2">Expected answer: &lt;/span>&lt;span class="si">{&lt;/span>&lt;span class="n">sample&lt;/span>&lt;span class="p">[&lt;/span>&lt;span class="s1">&amp;#39;expected&amp;#39;&lt;/span>&lt;span class="p">]&lt;/span>&lt;span class="si">}&lt;/span>&lt;span class="se">\n&lt;/span>&lt;span class="s2">Model answer: &lt;/span>&lt;span class="si">{&lt;/span>&lt;span class="n">response&lt;/span>&lt;span class="si">}&lt;/span>&lt;span class="se">\n\n&lt;/span>&lt;span class="s2">Reply with a number from 1 to 5 only.&amp;#34;&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 class="o">.&lt;/span>&lt;span class="n">choices&lt;/span>&lt;span class="p">[&lt;/span>&lt;span class="mi">0&lt;/span>&lt;span class="p">]&lt;/span>&lt;span class="o">.&lt;/span>&lt;span class="n">message&lt;/span>&lt;span class="o">.&lt;/span>&lt;span class="n">content&lt;/span>&lt;span class="o">.&lt;/span>&lt;span class="n">strip&lt;/span>&lt;span class="p">()&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="k">if&lt;/span> &lt;span class="n">model_type&lt;/span> &lt;span class="o">==&lt;/span> &lt;span class="s2">&amp;#34;bf16&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="n">scores_bf16&lt;/span>&lt;span class="o">.&lt;/span>&lt;span class="n">append&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="nb">int&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="n">score&lt;/span>&lt;span class="p">))&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="k">else&lt;/span>&lt;span class="p">:&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="n">scores_fp8&lt;/span>&lt;span class="o">.&lt;/span>&lt;span class="n">append&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="nb">int&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="n">score&lt;/span>&lt;span class="p">))&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="kn">import&lt;/span> &lt;span class="nn">numpy&lt;/span> &lt;span class="k">as&lt;/span> &lt;span class="nn">np&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="nb">print&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="sa">f&lt;/span>&lt;span class="s2">&amp;#34;Mean BF16 score: &lt;/span>&lt;span class="si">{&lt;/span>&lt;span class="n">np&lt;/span>&lt;span class="o">.&lt;/span>&lt;span class="n">mean&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="n">scores_bf16&lt;/span>&lt;span class="p">)&lt;/span>&lt;span class="si">:&lt;/span>&lt;span class="s2">.2f&lt;/span>&lt;span class="si">}&lt;/span>&lt;span class="s2">&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="nb">print&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="sa">f&lt;/span>&lt;span class="s2">&amp;#34;Mean FP8 score: &lt;/span>&lt;span class="si">{&lt;/span>&lt;span class="n">np&lt;/span>&lt;span class="o">.&lt;/span>&lt;span class="n">mean&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="n">scores_fp8&lt;/span>&lt;span class="p">)&lt;/span>&lt;span class="si">:&lt;/span>&lt;span class="s2">.2f&lt;/span>&lt;span class="si">}&lt;/span>&lt;span class="s2">&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="nb">print&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="sa">f&lt;/span>&lt;span class="s2">&amp;#34;Degradation: &lt;/span>&lt;span class="si">{&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="n">np&lt;/span>&lt;span class="o">.&lt;/span>&lt;span class="n">mean&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="n">scores_fp8&lt;/span>&lt;span class="p">)&lt;/span>&lt;span class="o">-&lt;/span>&lt;span class="n">np&lt;/span>&lt;span class="o">.&lt;/span>&lt;span class="n">mean&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="n">scores_bf16&lt;/span>&lt;span class="p">))&lt;/span>&lt;span class="o">/&lt;/span>&lt;span class="n">np&lt;/span>&lt;span class="o">.&lt;/span>&lt;span class="n">mean&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="n">scores_bf16&lt;/span>&lt;span class="p">)&lt;/span>&lt;span class="o">*&lt;/span>&lt;span class="mi">100&lt;/span>&lt;span class="si">:&lt;/span>&lt;span class="s2">.1f&lt;/span>&lt;span class="si">}&lt;/span>&lt;span class="s2">%&amp;#34;&lt;/span>&lt;span class="p">)&lt;/span>
&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;hr>
&lt;h2 id="otel--langfuse-correlation-the-dashboard-that-decides">OTel + Langfuse correlation: the dashboard that decides&lt;/h2>
&lt;p>The moment of decision rests on a single dashboard with two signals on the same time axis:&lt;/p>
&lt;p>&lt;strong>Signal 1 — Throughput (Prometheus):&lt;/strong>&lt;/p>
&lt;div class="highlight">&lt;pre tabindex="0" class="chroma">&lt;code class="language-promql" data-lang="promql">&lt;span class="line">&lt;span class="cl">&lt;span class="kr">rate&lt;/span>&lt;span class="o">(&lt;/span>&lt;span class="nv">vllm&lt;/span>&lt;span class="err">:&lt;/span>&lt;span class="nv">generation_tokens_total&lt;/span>&lt;span class="p">[&lt;/span>&lt;span class="s">5m&lt;/span>&lt;span class="p">]&lt;/span>&lt;span class="o">)&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>&lt;strong>Signal 2 — Mean quality (Langfuse → Prometheus via exporter):&lt;/strong>&lt;/p>
&lt;div class="highlight">&lt;pre tabindex="0" class="chroma">&lt;code class="language-promql" data-lang="promql">&lt;span class="line">&lt;span class="cl">&lt;span class="c1"># If you have configured Langfuse with scores exported via OTel&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w">&lt;/span>&lt;span class="nv">langfuse_score_value&lt;/span>&lt;span class="p">{&lt;/span>&lt;span class="nl">name&lt;/span>&lt;span class="o">=&lt;/span>&lt;span class="p">&amp;#34;&lt;/span>&lt;span class="s">llm_judge_domain&lt;/span>&lt;span class="p">&amp;#34;}&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>The expected pattern after enabling FP8: throughput rises by 40-60% and quality stays within ±0.1 points. If quality drops by more than 0.3 points and stays low, there is a real problem.&lt;/p>
&lt;div class="highlight">&lt;pre tabindex="0" class="chroma">&lt;code class="language-promql" data-lang="promql">&lt;span class="line">&lt;span class="cl">&lt;span class="c1"># Alert: quality drops more than 0.2 points, sustained, after the change&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w">&lt;/span>&lt;span class="nv">ALERT&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="nv">FP8CalidadDegradada&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nv">IF&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="kr">avg_over_time&lt;/span>&lt;span class="o">(&lt;/span>&lt;span class="nv">langfuse_score_value&lt;/span>&lt;span class="p">{&lt;/span>&lt;span class="nl">name&lt;/span>&lt;span class="o">=&lt;/span>&lt;span class="p">&amp;#34;&lt;/span>&lt;span class="s">llm_judge_domain&lt;/span>&lt;span class="p">&amp;#34;}[&lt;/span>&lt;span class="s">30m&lt;/span>&lt;span class="p">]&lt;/span>&lt;span class="o">)&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="o">&amp;lt;&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="o">(&lt;/span>&lt;span class="kr">avg_over_time&lt;/span>&lt;span class="o">(&lt;/span>&lt;span class="nv">langfuse_score_value&lt;/span>&lt;span class="p">{&lt;/span>&lt;span class="nl">name&lt;/span>&lt;span class="o">=&lt;/span>&lt;span class="p">&amp;#34;&lt;/span>&lt;span class="s">llm_judge_domain&lt;/span>&lt;span class="p">&amp;#34;}[&lt;/span>&lt;span class="s">1d&lt;/span>&lt;span class="p">]&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="k">offset&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="s">2h&lt;/span>&lt;span class="o">)&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="o">-&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="mf">0.2&lt;/span>&lt;span class="o">)&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nv">FOR&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="s">15m&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nv">LABELS&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="p">{&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="nl">severity&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="o">=&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="p">&amp;#34;&lt;/span>&lt;span class="s">warning&lt;/span>&lt;span class="p">&amp;#34;&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="p">}&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nv">ANNOTATIONS&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="p">{&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="nl">summary&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="o">=&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="p">&amp;#34;&lt;/span>&lt;span class="s">Possible quality degradation after the FP8 configuration change&lt;/span>&lt;span class="p">&amp;#34;&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="p">}&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;hr>
&lt;h2 id="when-not-to-enable-fp8">When NOT to enable FP8&lt;/h2>
&lt;p>FP8 is not always the right answer. The cases where degradation exceeds the acceptable threshold:&lt;/p>
&lt;p>&lt;strong>Formal mathematical reasoning:&lt;/strong> GSM8K and MATH are the benchmarks most sensitive to FP8. If your use case is solving mathematical problems or precise financial calculation, measure specifically on these benchmarks before enabling it.&lt;/p>
&lt;p>&lt;strong>Critical code with tests:&lt;/strong> numerical precision affects the probability of tokens at key positions in a function. The risk is not that the code &amp;ldquo;looks&amp;rdquo; bad, but that it passes superficial tests while having subtle bugs.&lt;/p>
&lt;p>&lt;strong>Very long contexts without &lt;code>--calculate-kv-scales&lt;/code>:&lt;/strong> without dynamic scale calibration, the accumulated numerical error in the KV cache grows with the context. With &lt;code>--calculate-kv-scales&lt;/code> active, the impact is minimal up to 32K tokens.&lt;/p>
&lt;p>&lt;strong>Small models (&amp;lt;7B):&lt;/strong> the FP8 conversion overhead can exceed the throughput benefit. The break-even point is around 7B parameters.&lt;/p>
&lt;hr>
&lt;h2 id="see-also">See also&lt;/h2>
&lt;ul>
&lt;li>
&lt;p>&lt;a href="https://blog.lo0.es/en/posts/multimodal-on-premise-serving-vlm-vllm/">Multimodal on-premise: serving a VLM with vLLM (vision + language)&lt;/a> — FP8 so that a VLM fits with its heavy budget of visual tokens.&lt;/p>
&lt;/li>
&lt;li>
&lt;p>&lt;a href="https://blog.lo0.es/en/posts/quantization-llm-inference-fp8-int4-gguf/">the maths of FP8 E4M3: what the 4-bit exponent and the 3-bit mantissa are, and why this specific format was chosen over INT8&lt;/a>&lt;/p>
&lt;/li>
&lt;li>
&lt;p>&lt;a href="https://blog.lo0.es/en/posts/kv-cache-working-memory-llm-inference/">the KV cache size formula: why moving to FP8 divides it exactly by two&lt;/a>&lt;/p>
&lt;/li>
&lt;li>
&lt;p>&lt;a href="https://blog.lo0.es/en/posts/optimising-decode-vllm-squeezing-every-token-small-hardware/">&lt;code>--kv-cache-dtype fp8&lt;/code> and &lt;code>--calculate-kv-scales&lt;/code> in the context of the full decode tuning&lt;/a>&lt;/p>
&lt;/li>
&lt;li>
&lt;p>&lt;a href="https://blog.lo0.es/en/posts/instrumenting-vllm-otel-measuring-optimisations/">how to set up the Langfuse + Prometheus correlation on a single dashboard for the FP8 before/after&lt;/a>&lt;/p>
&lt;/li>
&lt;li>
&lt;p>&lt;a href="https://blog.lo0.es/en/posts/evals-llm-layer-after-tracing/">the full eval suite: how to build the 200-sample domain dataset and the LLM judge that verifies quality&lt;/a>&lt;/p>
&lt;/li>
&lt;/ul>
&lt;h3 id="in-this-same-series">In this same series&lt;/h3>
&lt;ul>
&lt;li>&lt;a href="https://blog.lo0.es/en/posts/vllm-batch-sizing-two-hour-grid-search/">the max-num-seqs × max-num-batched-tokens grid search: the free optimisation with the biggest impact before touching quantisation&lt;/a>&lt;/li>
&lt;li>&lt;a href="https://blog.lo0.es/en/posts/prefix-cache-hit-rate-engineering/">prefix cache hit rate engineering: going from 15% to 75% without adding hardware&lt;/a>&lt;/li>
&lt;li>&lt;a href="https://blog.lo0.es/en/posts/one-big-replica-or-many-small-ones-platform-decision/">TP=4×1 vs TP=2×2: the architectural decision that determines how to scale what FP8 frees up&lt;/a>&lt;/li>
&lt;/ul>
&lt;hr>
&lt;h2 id="references">References&lt;/h2>
&lt;ul>
&lt;li>&lt;a href="https://vllm.ai/blog/2026-04-22-fp8-kvcache">The State of FP8 KV-Cache and Attention Quantization in vLLM — vLLM Blog (abril 2026)&lt;/a>&lt;/li>
&lt;li>&lt;a href="https://docs.vllm.ai/en/v0.8.5/features/quantization/fp8.html">FP8 W8A8 — vLLM Documentation&lt;/a>&lt;/li>
&lt;li>&lt;a href="https://mlcommons.org/benchmarks/inference-datacenter/">MLPerf Inference v5.1 — resultados de calidad FP8 (sep 2025)&lt;/a>&lt;/li>
&lt;li>&lt;a href="https://ifactoryapp.com/sap-integration/on-prem-ai/fp4-vs-fp8-vs-fp16-llm-inference">FP4 vs FP8 vs FP16 LLM Inference: Quality and Speed Tradeoffs&lt;/a>&lt;/li>
&lt;li>&lt;a href="https://www.spheron.network/blog/vllm-production-deployment-2026/">vLLM Production Deployment 2026: FP8 Docker Setup on H100&lt;/a>&lt;/li>
&lt;/ul></description></item><item><title>Instrumenting vLLM with OTel: measuring what the optimisations really do</title><link>https://blog.lo0.es/en/posts/instrumenting-vllm-otel-measuring-optimisations/</link><pubDate>Fri, 05 Jun 2026 04:00:00 +0000</pubDate><guid>https://blog.lo0.es/en/posts/instrumenting-vllm-otel-measuring-optimisations/</guid><description>&lt;h2 id="tldr">TL;DR&lt;/h2>
&lt;p>vLLM exposes two independent observability signals: Prometheus metrics (pull, aggregated) and OTel traces (push, per request). To measure whether chunked prefill, prefix caching, speculative decoding, FP8 KV cache and concurrency are really working, you need both. The metrics tell you what is happening in the system; the traces tell you why one specific request was slow. This article configures the complete pipeline and maps each optimisation to its diagnostic metric.&lt;/p>
&lt;hr>
&lt;h2 id="the-analogy">The analogy&lt;/h2>
&lt;p>A Formula 1 driver and his telemetry engineers. The driver feels that the car &amp;ldquo;goes odd&amp;rdquo; in turn 3, but without the sensor data he does not know whether it is the rear tyre, the differential or the fuel. The engineers see exactly what happened in that turn, temperature per sensor, lateral load per millisecond.&lt;/p>
&lt;p>vLLM without OTel is the driver on his own: you notice that the TTFT &amp;ldquo;looks high&amp;rdquo; but you do not know whether it is a long prefill, a prefix cache miss, or a KV cache preemption. With OTel you have the full picture: the metrics are the race summary (aggregated), the traces are the lap-by-lap telemetry (per-request).&lt;/p>
&lt;hr>
&lt;h2 id="architecture-of-the-two-signals">Architecture of the two signals&lt;/h2>
&lt;p>vLLM deliberately separates its two observability channels:&lt;/p>
&lt;pre tabindex="0">&lt;code> ┌─────────────────────────────┐
│ vLLM │
│ │
requests ────────────►│ SchedulerStats │
│ │ │
│ ├─► Prometheus /metrics │◄── scrape (pull)
│ │ (aggregated, ~15s) │
│ │ │
│ └─► OTLP exporter │──► push (per request)
│ (spans, immediate) │
└─────────────────────────────┘
│ OTLP gRPC/HTTP
▼
┌─────────────────────┐
│ OTel Collector │
│ │
│ receivers: │
│ otlp (traces) │
│ prometheus │
│ exporters: │
│ langfuse │
│ prometheus remote│
│ loki (logs) │
└─────────────────────┘
&lt;/code>&lt;/pre>&lt;p>&lt;strong>Prometheus pull&lt;/strong> exposes metrics with the &lt;code>vllm:&lt;/code> prefix at &lt;code>:8000/metrics&lt;/code>. They are histograms, gauges and counters updated on every scheduler iteration. Good for dashboards and alerts about the system as a whole.&lt;/p>
&lt;p>&lt;strong>OTLP push&lt;/strong> sends one span per request, immediately on completion. It contains attributes of the specific request: prompt tokens, generated tokens, TTFT, model. Good for debugging anomalous requests and for Langfuse.&lt;/p>
&lt;hr>
&lt;h2 id="installation-and-basic-configuration">Installation and basic configuration&lt;/h2>
&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">&lt;span class="c1"># vLLM with OTel support&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">pip install &lt;span class="s2">&amp;#34;vllm[otel]&amp;#34;&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="c1"># Installs: opentelemetry-sdk, opentelemetry-api,&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="c1"># opentelemetry-exporter-otlp, opentelemetry-semantic-conventions-ai&lt;/span>
&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&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">&lt;span class="c1"># Start vLLM with OTel enabled&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="nb">export&lt;/span> &lt;span class="nv">OTEL_SERVICE_NAME&lt;/span>&lt;span class="o">=&lt;/span>&lt;span class="s2">&amp;#34;vllm-production&amp;#34;&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="nb">export&lt;/span> &lt;span class="nv">OTEL_EXPORTER_OTLP_TRACES_ENDPOINT&lt;/span>&lt;span class="o">=&lt;/span>&lt;span class="s2">&amp;#34;http://otel-collector:4317&amp;#34;&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="nb">export&lt;/span> &lt;span class="nv">OTEL_EXPORTER_OTLP_TRACES_PROTOCOL&lt;/span>&lt;span class="o">=&lt;/span>&lt;span class="s2">&amp;#34;grpc&amp;#34;&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="nb">export&lt;/span> &lt;span class="nv">OTEL_EXPORTER_OTLP_TRACES_INSECURE&lt;/span>&lt;span class="o">=&lt;/span>&lt;span class="s2">&amp;#34;true&amp;#34;&lt;/span> &lt;span class="c1"># on an internal network without TLS&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">vllm serve Qwen/Qwen2.5-7B-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> --otlp-traces-endpoint http://otel-collector:4317 &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> --kv-cache-dtype fp8 &lt;span class="se">\
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="se">&lt;/span> --speculative-model Qwen/Qwen2.5-0.5B-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> --num-speculative-tokens &lt;span class="m">5&lt;/span>
&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>The Prometheus metrics need no extra configuration: they are always at &lt;code>:8000/metrics&lt;/code>.&lt;/p>
&lt;hr>
&lt;h2 id="otel-collector-minimum-configuration">OTel Collector: minimum configuration&lt;/h2>
&lt;div class="highlight">&lt;pre tabindex="0" class="chroma">&lt;code class="language-yaml" data-lang="yaml">&lt;span class="line">&lt;span class="cl">&lt;span class="c"># otel-collector-config.yaml&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w">&lt;/span>&lt;span class="nt">receivers&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">otlp&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">protocols&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">grpc&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">endpoint&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="m">0.0.0.0&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="m">4317&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">http&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">endpoint&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="m">0.0.0.0&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="m">4318&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">prometheus&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">config&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">scrape_configs&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>- &lt;span class="nt">job_name&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">vllm&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">scrape_interval&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">15s&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">static_configs&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>- &lt;span class="nt">targets&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="p">[&lt;/span>&lt;span class="s2">&amp;#34;vllm:8000&amp;#34;&lt;/span>&lt;span class="p">]&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w">&lt;/span>&lt;span class="nt">processors&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">batch&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">timeout&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">5s&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">resource&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">attributes&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>- &lt;span class="nt">key&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">deployment.environment&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">value&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="s2">&amp;#34;production&amp;#34;&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">action&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">upsert&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w">&lt;/span>&lt;span class="nt">exporters&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">otlphttp/langfuse&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">endpoint&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="s2">&amp;#34;https://cloud.langfuse.com/api/public/otel&amp;#34;&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">headers&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">Authorization&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="s2">&amp;#34;Basic &amp;lt;base64(pk:sk)&amp;gt;&amp;#34;&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">prometheusremotewrite&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">endpoint&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="s2">&amp;#34;http://prometheus:9090/api/v1/write&amp;#34;&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w">&lt;/span>&lt;span class="nt">service&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">pipelines&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">traces&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">receivers&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="p">[&lt;/span>&lt;span class="l">otlp]&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">processors&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="p">[&lt;/span>&lt;span class="l">batch, resource]&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">exporters&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="p">[&lt;/span>&lt;span class="l">otlphttp/langfuse]&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">metrics&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">receivers&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="p">[&lt;/span>&lt;span class="l">prometheus]&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">processors&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="p">[&lt;/span>&lt;span class="l">batch]&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">exporters&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="p">[&lt;/span>&lt;span class="l">prometheusremotewrite]&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;hr>
&lt;h2 id="the-five-metrics-that-matter">The five metrics that matter&lt;/h2>
&lt;p>Every optimisation has a primary diagnostic signal. If the metric does not move as expected after enabling the flag, there is a configuration or load problem.&lt;/p>
&lt;h3 id="1-chunked-prefill--vllmtime_to_first_token_seconds">1. Chunked prefill → &lt;code>vllm:time_to_first_token_seconds&lt;/code>&lt;/h3>
&lt;p>Chunked prefill should reduce the variance of the TTFT, not necessarily the median. Its main goal is for the high percentiles (p99) to come down even if the p50 rises slightly.&lt;/p>
&lt;div class="highlight">&lt;pre tabindex="0" class="chroma">&lt;code class="language-promql" data-lang="promql">&lt;span class="line">&lt;span class="cl">&lt;span class="c1"># TTFT p50 and p99 — expect p99 to drop with chunked prefill enabled&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w">&lt;/span>&lt;span class="kr">histogram_quantile&lt;/span>&lt;span class="o">(&lt;/span>&lt;span class="mf">0.50&lt;/span>&lt;span class="p">,&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="kr">rate&lt;/span>&lt;span class="o">(&lt;/span>&lt;span class="nv">vllm&lt;/span>&lt;span class="err">:&lt;/span>&lt;span class="nv">time_to_first_token_seconds_bucket&lt;/span>&lt;span class="p">[&lt;/span>&lt;span class="s">5m&lt;/span>&lt;span class="p">]&lt;/span>&lt;span class="o">))&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w">&lt;/span>&lt;span class="kr">histogram_quantile&lt;/span>&lt;span class="o">(&lt;/span>&lt;span class="mf">0.99&lt;/span>&lt;span class="p">,&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="kr">rate&lt;/span>&lt;span class="o">(&lt;/span>&lt;span class="nv">vllm&lt;/span>&lt;span class="err">:&lt;/span>&lt;span class="nv">time_to_first_token_seconds_bucket&lt;/span>&lt;span class="p">[&lt;/span>&lt;span class="s">5m&lt;/span>&lt;span class="p">]&lt;/span>&lt;span class="o">))&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>&lt;strong>Sign that it works:&lt;/strong> the p99/p50 ratio approaches 1. Without chunked prefill, one request&amp;rsquo;s long prefill blocks all the others and the p99 rises disproportionately.&lt;/p>
&lt;p>&lt;strong>Sign of a problem:&lt;/strong> p50 and p99 both rise. A &lt;code>--max-num-batched-tokens&lt;/code> that is too low makes the chunks so small that the prefill takes many steps to complete even though the other requests are not blocked. Raise the budget.&lt;/p>
&lt;p>It is also useful to look at the &lt;code>llm.usage.prompt_tokens&lt;/code> attribute per span in the OTel traces: requests with many prompt tokens should have a proportional TTFT, not a blocking one.&lt;/p>
&lt;hr>
&lt;h3 id="2-prefix-caching--vllmgpu_prefix_cache_hit_rate">2. Prefix caching → &lt;code>vllm:gpu_prefix_cache_hit_rate&lt;/code>&lt;/h3>
&lt;div class="highlight">&lt;pre tabindex="0" class="chroma">&lt;code class="language-promql" data-lang="promql">&lt;span class="line">&lt;span class="cl">&lt;span class="c1"># Prefix cache hit rate on the GPU (0.0–1.0)&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w">&lt;/span>&lt;span class="nv">vllm&lt;/span>&lt;span class="err">:&lt;/span>&lt;span class="nv">gpu_prefix_cache_hit_rate&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w">&lt;/span>&lt;span class="c1"># Evolution over a 5-minute window&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w">&lt;/span>&lt;span class="kr">rate&lt;/span>&lt;span class="o">(&lt;/span>&lt;span class="nv">vllm&lt;/span>&lt;span class="err">:&lt;/span>&lt;span class="nv">gpu_prefix_cache_hit_rate&lt;/span>&lt;span class="p">[&lt;/span>&lt;span class="s">5m&lt;/span>&lt;span class="p">]&lt;/span>&lt;span class="o">)&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>&lt;strong>Sign that it works:&lt;/strong> a sustained hit rate &amp;gt; 0.5 in workloads with a shared system prompt. With a hit rate of 0.8, 80% of requests skip the prefix prefill; the TTFT of those requests falls to the cost of the variable suffix alone.&lt;/p>
&lt;p>&lt;strong>Sign of a problem: a hit rate close to zero&lt;/strong> despite system prompts that &amp;ldquo;look&amp;rdquo; identical. The usual causes:&lt;/p>
&lt;div class="highlight">&lt;pre tabindex="0" class="chroma">&lt;code class="language-python" data-lang="python">&lt;span class="line">&lt;span class="cl">&lt;span class="c1"># ❌ This breaks the prefix caching hash:&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="n">system_prompt&lt;/span> &lt;span class="o">=&lt;/span> &lt;span class="sa">f&lt;/span>&lt;span class="s2">&amp;#34;Today is &lt;/span>&lt;span class="si">{&lt;/span>&lt;span class="n">datetime&lt;/span>&lt;span class="o">.&lt;/span>&lt;span class="n">now&lt;/span>&lt;span class="p">()&lt;/span>&lt;span class="si">}&lt;/span>&lt;span class="s2">. You are an assistant...&amp;#34;&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="c1"># ^^^ a different timestamp on every request&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="c1"># ✅ The system prompt has to be identical byte for byte:&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="n">system_prompt&lt;/span> &lt;span class="o">=&lt;/span> &lt;span class="s2">&amp;#34;You are an assistant specialising in infrastructure...&amp;#34;&lt;/span>
&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>Any variation in the system prompt, timestamps, session IDs, interpolated prompt versions, produces a different hash and a cache miss. The OTel traces do not expose the hit/miss per request directly in the current implementation; use them to correlate a high &lt;code>llm.usage.prompt_tokens&lt;/code> with a high TTFT in the same request.&lt;/p>
&lt;hr>
&lt;h3 id="3-speculative-decoding--vllmspec_decode_draft_acceptance_rate">3. Speculative decoding → &lt;code>vllm:spec_decode_draft_acceptance_rate&lt;/code>&lt;/h3>
&lt;div class="highlight">&lt;pre tabindex="0" class="chroma">&lt;code class="language-promql" data-lang="promql">&lt;span class="line">&lt;span class="cl">&lt;span class="c1"># Acceptance rate of the draft model (0.0–1.0)&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w">&lt;/span>&lt;span class="nv">vllm&lt;/span>&lt;span class="err">:&lt;/span>&lt;span class="nv">spec_decode_draft_acceptance_rate&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w">&lt;/span>&lt;span class="c1"># Estimated effective speedup (with k=5 proposed tokens)&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w">&lt;/span>&lt;span class="c1"># speedup ≈ (1 + α·k) / (1 + overhead_draft)&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w">&lt;/span>&lt;span class="c1"># Simplified: if α=0.75 and k=5 → speedup ≈ 1 + 0.75×5×(1 - cost_ratio) &lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>&lt;strong>Sign that it works:&lt;/strong> a sustained acceptance rate &amp;gt; 0.70. Below 0.60, the draft model&amp;rsquo;s overhead outweighs the gain from the accepted tokens and speculative decoding is counterproductive.&lt;/p>
&lt;p>&lt;strong>Sign of a problem:&lt;/strong> an acceptance rate &amp;lt; 0.50. The usual causes:&lt;/p>
&lt;ul>
&lt;li>A drafter from a different family than the verifier (for instance, Mistral 0.5B as the draft for Qwen 7B).&lt;/li>
&lt;li>A high generation temperature (&amp;gt;0.9): the higher the temperature, the more the draft&amp;rsquo;s distribution diverges from the verifier&amp;rsquo;s.&lt;/li>
&lt;li>A very large batch: at high concurrency, the draft can fall outside the domain of the current requests.&lt;/li>
&lt;/ul>
&lt;div class="highlight">&lt;pre tabindex="0" class="chroma">&lt;code class="language-promql" data-lang="promql">&lt;span class="line">&lt;span class="cl">&lt;span class="c1"># Alert: inefficient speculative decoding&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w">&lt;/span>&lt;span class="nv">ALERT&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="nv">SpecDecodeInefficient&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nv">IF&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="nv">vllm&lt;/span>&lt;span class="err">:&lt;/span>&lt;span class="nv">spec_decode_draft_acceptance_rate&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="o">&amp;lt;&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="mf">0.60&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nv">FOR&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="s">5m&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nv">LABELS&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="p">{&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="nl">severity&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="o">=&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="p">&amp;#34;&lt;/span>&lt;span class="s">warning&lt;/span>&lt;span class="p">&amp;#34;&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="p">}&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nv">ANNOTATIONS&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="p">{&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="nl">summary&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="o">=&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="p">&amp;#34;&lt;/span>&lt;span class="s">Draft acceptance rate low: disable spec decode or change the drafter&lt;/span>&lt;span class="p">&amp;#34;&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="p">}&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>In the OTel traces, the complete span of the request includes the total decode time. Without an acceptance rate per span, the way to detect that spec decode is working is to compare the total decode time divided by the tokens generated: if it is significantly lower than the baseline without spec decode, it is helping.&lt;/p>
&lt;hr>
&lt;h3 id="4-fp8-kv-cache-and-concurrency--vllmgpu_cache_usage_perc--vllmnum_preemptions_total">4. FP8 KV cache and concurrency → &lt;code>vllm:gpu_cache_usage_perc&lt;/code> + &lt;code>vllm:num_preemptions_total&lt;/code>&lt;/h3>
&lt;p>These two metrics are the two sides of KV cache management:&lt;/p>
&lt;div class="highlight">&lt;pre tabindex="0" class="chroma">&lt;code class="language-promql" data-lang="promql">&lt;span class="line">&lt;span class="cl">&lt;span class="c1"># KV cache utilisation (0.0–1.0)&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w">&lt;/span>&lt;span class="c1"># With FP8 enabled, the same hardware supports more requests before saturating&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w">&lt;/span>&lt;span class="nv">vllm&lt;/span>&lt;span class="err">:&lt;/span>&lt;span class="nv">gpu_cache_usage_perc&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w">&lt;/span>&lt;span class="c1"># Cumulative preemptions (counter)&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w">&lt;/span>&lt;span class="c1"># Rises when vLLM cannot fit more requests and pauses one&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w">&lt;/span>&lt;span class="kr">rate&lt;/span>&lt;span class="o">(&lt;/span>&lt;span class="nv">vllm&lt;/span>&lt;span class="err">:&lt;/span>&lt;span class="nv">num_preemptions_total&lt;/span>&lt;span class="p">[&lt;/span>&lt;span class="s">5m&lt;/span>&lt;span class="p">]&lt;/span>&lt;span class="o">)&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>&lt;strong>Sign that FP8 works:&lt;/strong> with &lt;code>--kv-cache-dtype fp8&lt;/code> enabled, &lt;code>gpu_cache_usage_perc&lt;/code> should saturate at concurrency levels around 2× higher than the BF16 baseline before &lt;code>num_preemptions_total&lt;/code> starts to grow.&lt;/p>
&lt;p>&lt;strong>Sign of a problem:&lt;/strong> &lt;code>num_preemptions_total&lt;/code> growing at rates &amp;gt; 1/minute with &lt;code>gpu_cache_usage_perc&lt;/code> below 0.90. It indicates that &lt;code>max-num-seqs&lt;/code> is too high for the available KV cache: requests enter the system but there are no free blocks to assign to them. Lower &lt;code>max-num-seqs&lt;/code> or reduce &lt;code>max-model-len&lt;/code>.&lt;/p>
&lt;div class="highlight">&lt;pre tabindex="0" class="chroma">&lt;code class="language-promql" data-lang="promql">&lt;span class="line">&lt;span class="cl">&lt;span class="c1"># Alert: KV cache saturated with preemptions&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w">&lt;/span>&lt;span class="nv">ALERT&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="nv">KVCacheSaturated&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nv">IF&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="kr">rate&lt;/span>&lt;span class="o">(&lt;/span>&lt;span class="nv">vllm&lt;/span>&lt;span class="err">:&lt;/span>&lt;span class="nv">num_preemptions_total&lt;/span>&lt;span class="p">[&lt;/span>&lt;span class="s">2m&lt;/span>&lt;span class="p">]&lt;/span>&lt;span class="o">)&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="o">&amp;gt;&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="mf">0.5&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nv">AND&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="nv">vllm&lt;/span>&lt;span class="err">:&lt;/span>&lt;span class="nv">gpu_cache_usage_perc&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="o">&amp;gt;&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="mf">0.85&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nv">FOR&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="s">3m&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nv">LABELS&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="p">{&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="nl">severity&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="o">=&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="p">&amp;#34;&lt;/span>&lt;span class="s">critical&lt;/span>&lt;span class="p">&amp;#34;&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="p">}&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nv">ANNOTATIONS&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="p">{&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="nl">summary&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="o">=&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="p">&amp;#34;&lt;/span>&lt;span class="s">KV cache saturated: lower max-num-seqs or max-model-len&lt;/span>&lt;span class="p">&amp;#34;&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="p">}&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>The impact of FP8 on capacity can be quantified:&lt;/p>
$$\Delta\text{capacity} = \frac{\text{max tokens FP8}}{\text{max tokens BF16}} \approx 2\times$$
&lt;p>Measure before and after enabling &lt;code>--kv-cache-dtype fp8&lt;/code>: the level of &lt;code>gpu_cache_usage_perc&lt;/code> for a given concurrency should fall by half.&lt;/p>
&lt;hr>
&lt;h3 id="5-effective-concurrency--vllmnum_running_seqs--vllmnum_waiting_seqs">5. Effective concurrency → &lt;code>vllm:num_running_seqs&lt;/code> + &lt;code>vllm:num_waiting_seqs&lt;/code>&lt;/h3>
&lt;div class="highlight">&lt;pre tabindex="0" class="chroma">&lt;code class="language-promql" data-lang="promql">&lt;span class="line">&lt;span class="cl">&lt;span class="c1"># Requests active in the engine (decode + prefill in progress)&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w">&lt;/span>&lt;span class="nv">vllm&lt;/span>&lt;span class="err">:&lt;/span>&lt;span class="nv">num_running_seqs&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w">&lt;/span>&lt;span class="c1"># Requests queued waiting for a slot&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w">&lt;/span>&lt;span class="nv">vllm&lt;/span>&lt;span class="err">:&lt;/span>&lt;span class="nv">num_waiting_seqs&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w">&lt;/span>&lt;span class="c1"># Waiting ratio: if &amp;gt; 0.2 sustained, there is a concurrency bottleneck&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w">&lt;/span>&lt;span class="nv">vllm&lt;/span>&lt;span class="err">:&lt;/span>&lt;span class="nv">num_waiting_seqs&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="o">/&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="o">(&lt;/span>&lt;span class="nv">vllm&lt;/span>&lt;span class="err">:&lt;/span>&lt;span class="nv">num_running_seqs&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="o">+&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="nv">vllm&lt;/span>&lt;span class="err">:&lt;/span>&lt;span class="nv">num_waiting_seqs&lt;/span>&lt;span class="o">)&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>&lt;strong>Healthy sign:&lt;/strong> &lt;code>num_running_seqs&lt;/code> stable close to the configured &lt;code>--max-num-seqs&lt;/code> value, &lt;code>num_waiting_seqs&lt;/code> low (&amp;lt; 10% of running).&lt;/p>
&lt;p>&lt;strong>Sign of a problem:&lt;/strong> a high &lt;code>num_waiting_seqs&lt;/code> with a low &lt;code>gpu_cache_usage_perc&lt;/code>. It indicates that the scheduler is not filling the available slots because &lt;code>max-num-batched-tokens&lt;/code> is too low: the token budget per step does not allow the pending prefills to be processed fast enough. Raise &lt;code>max-num-batched-tokens&lt;/code>.&lt;/p>
&lt;hr>
&lt;h2 id="reference-dashboard-the-5-metrics-in-grafana">Reference dashboard: the 5 metrics in Grafana&lt;/h2>
&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;panels&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="p">{&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="nt">&amp;#34;title&amp;#34;&lt;/span>&lt;span class="p">:&lt;/span> &lt;span class="s2">&amp;#34;TTFT p50 / p99 (chunked prefill)&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;targets&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="p">{&lt;/span>&lt;span class="nt">&amp;#34;expr&amp;#34;&lt;/span>&lt;span class="p">:&lt;/span> &lt;span class="s2">&amp;#34;histogram_quantile(0.50, rate(vllm:time_to_first_token_seconds_bucket[5m]))&amp;#34;&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="nt">&amp;#34;legendFormat&amp;#34;&lt;/span>&lt;span class="p">:&lt;/span> &lt;span class="s2">&amp;#34;p50&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="p">{&lt;/span>&lt;span class="nt">&amp;#34;expr&amp;#34;&lt;/span>&lt;span class="p">:&lt;/span> &lt;span class="s2">&amp;#34;histogram_quantile(0.99, rate(vllm:time_to_first_token_seconds_bucket[5m]))&amp;#34;&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="nt">&amp;#34;legendFormat&amp;#34;&lt;/span>&lt;span class="p">:&lt;/span> &lt;span class="s2">&amp;#34;p99&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="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;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;title&amp;#34;&lt;/span>&lt;span class="p">:&lt;/span> &lt;span class="s2">&amp;#34;Prefix cache hit rate&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;targets&amp;#34;&lt;/span>&lt;span class="p">:&lt;/span> &lt;span class="p">[{&lt;/span>&lt;span class="nt">&amp;#34;expr&amp;#34;&lt;/span>&lt;span class="p">:&lt;/span> &lt;span class="s2">&amp;#34;vllm:gpu_prefix_cache_hit_rate&amp;#34;&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="nt">&amp;#34;legendFormat&amp;#34;&lt;/span>&lt;span class="p">:&lt;/span> &lt;span class="s2">&amp;#34;GPU hit rate&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="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;span class="line">&lt;span class="cl"> &lt;span class="nt">&amp;#34;title&amp;#34;&lt;/span>&lt;span class="p">:&lt;/span> &lt;span class="s2">&amp;#34;Spec decode acceptance rate&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;targets&amp;#34;&lt;/span>&lt;span class="p">:&lt;/span> &lt;span class="p">[{&lt;/span>&lt;span class="nt">&amp;#34;expr&amp;#34;&lt;/span>&lt;span class="p">:&lt;/span> &lt;span class="s2">&amp;#34;vllm:spec_decode_draft_acceptance_rate&amp;#34;&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="nt">&amp;#34;legendFormat&amp;#34;&lt;/span>&lt;span class="p">:&lt;/span> &lt;span class="s2">&amp;#34;acceptance rate&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="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;span class="line">&lt;span class="cl"> &lt;span class="nt">&amp;#34;title&amp;#34;&lt;/span>&lt;span class="p">:&lt;/span> &lt;span class="s2">&amp;#34;KV cache usage + preemptions&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;targets&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="p">{&lt;/span>&lt;span class="nt">&amp;#34;expr&amp;#34;&lt;/span>&lt;span class="p">:&lt;/span> &lt;span class="s2">&amp;#34;vllm:gpu_cache_usage_perc&amp;#34;&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="nt">&amp;#34;legendFormat&amp;#34;&lt;/span>&lt;span class="p">:&lt;/span> &lt;span class="s2">&amp;#34;cache usage&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="p">{&lt;/span>&lt;span class="nt">&amp;#34;expr&amp;#34;&lt;/span>&lt;span class="p">:&lt;/span> &lt;span class="s2">&amp;#34;rate(vllm:num_preemptions_total[2m]) * 60&amp;#34;&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="nt">&amp;#34;legendFormat&amp;#34;&lt;/span>&lt;span class="p">:&lt;/span> &lt;span class="s2">&amp;#34;preemptions/min&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="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;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;title&amp;#34;&lt;/span>&lt;span class="p">:&lt;/span> &lt;span class="s2">&amp;#34;Effective concurrency&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;targets&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="p">{&lt;/span>&lt;span class="nt">&amp;#34;expr&amp;#34;&lt;/span>&lt;span class="p">:&lt;/span> &lt;span class="s2">&amp;#34;vllm:num_running_seqs&amp;#34;&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="nt">&amp;#34;legendFormat&amp;#34;&lt;/span>&lt;span class="p">:&lt;/span> &lt;span class="s2">&amp;#34;running&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="p">{&lt;/span>&lt;span class="nt">&amp;#34;expr&amp;#34;&lt;/span>&lt;span class="p">:&lt;/span> &lt;span class="s2">&amp;#34;vllm:num_waiting_seqs&amp;#34;&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="nt">&amp;#34;legendFormat&amp;#34;&lt;/span>&lt;span class="p">:&lt;/span> &lt;span class="s2">&amp;#34;waiting&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="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;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;hr>
&lt;h2 id="connecting-traces-to-langfuse">Connecting traces to Langfuse&lt;/h2>
&lt;p>vLLM&amp;rsquo;s OTel traces are GenAI semconv compatible spans. Langfuse accepts them directly via OTLP:&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">&lt;span class="c1"># In the OTel Collector (already configured above)&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="c1"># The otlphttp/langfuse exporter sends traces to Langfuse Cloud or self-hosted&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="c1"># For self-hosted Langfuse (ENS/sovereign):&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">exporters:
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> otlphttp/langfuse:
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> endpoint: &lt;span class="s2">&amp;#34;http://langfuse-internal:3000/api/public/otel&amp;#34;&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> headers:
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> Authorization: &lt;span class="s2">&amp;#34;Basic &amp;lt;base64(pk_xxx:sk_xxx)&amp;gt;&amp;#34;&lt;/span>
&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>In Langfuse, each vLLM request appears as a trace with:&lt;/p>
&lt;ul>
&lt;li>&lt;code>gen_ai.system&lt;/code>: the model served&lt;/li>
&lt;li>&lt;code>gen_ai.usage.input_tokens&lt;/code>: prompt tokens&lt;/li>
&lt;li>&lt;code>gen_ai.usage.output_tokens&lt;/code>: generated tokens&lt;/li>
&lt;li>Span duration: end-to-end latency&lt;/li>
&lt;/ul>
&lt;p>What does &lt;strong>not&lt;/strong> appear directly in the span: the speculative decoding acceptance rate, the prefix cache hit/miss, or the number of preemptions. That data is only in Prometheus. The correct workflow is:&lt;/p>
&lt;ol>
&lt;li>Langfuse identifies an anomalous request by latency.&lt;/li>
&lt;li>Prometheus/Grafana shows whether in that interval there were high preemptions, low spec decode, or a prefix cache miss.&lt;/li>
&lt;li>The two are correlated by timestamp.&lt;/li>
&lt;/ol>
&lt;hr>
&lt;h2 id="quick-diagnostic-matrix">Quick diagnostic matrix&lt;/h2>
&lt;table>
&lt;thead>
&lt;tr>
&lt;th>Observable symptom&lt;/th>
&lt;th>Prometheus metric&lt;/th>
&lt;th>Probable cause&lt;/th>
&lt;th>Action&lt;/th>
&lt;/tr>
&lt;/thead>
&lt;tbody>
&lt;tr>
&lt;td>TTFT p99 very high&lt;/td>
&lt;td>&lt;code>ttft p99/p50 &amp;gt;&amp;gt; 2&lt;/code>&lt;/td>
&lt;td>Long blocking prefills&lt;/td>
&lt;td>Raise &lt;code>--max-num-batched-tokens&lt;/code>&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>TTFT p50 high, p99 the same&lt;/td>
&lt;td>&lt;code>ttft p50 &amp;gt; 500ms&lt;/code>&lt;/td>
&lt;td>Prefix cache not working&lt;/td>
&lt;td>Check the system prompt hash&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>Slow decode with no improvement&lt;/td>
&lt;td>&lt;code>spec_decode_acceptance &amp;lt; 0.60&lt;/code>&lt;/td>
&lt;td>Incompatible drafter&lt;/td>
&lt;td>Change the drafter or disable it&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>Sporadic OOM / crash&lt;/td>
&lt;td>&lt;code>gpu_cache_usage_perc = 1.0&lt;/code> + preemptions&lt;/td>
&lt;td>KV cache full&lt;/td>
&lt;td>Lower &lt;code>max-num-seqs&lt;/code> or enable FP8&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>High queue with free cache&lt;/td>
&lt;td>&lt;code>waiting &amp;gt;&amp;gt; 0&lt;/code> + &lt;code>cache &amp;lt; 0.70&lt;/code>&lt;/td>
&lt;td>Low token budget&lt;/td>
&lt;td>Raise &lt;code>--max-num-batched-tokens&lt;/code>&lt;/td>
&lt;/tr>
&lt;/tbody>
&lt;/table>
&lt;hr>
&lt;h2 id="implications-for-sovereign-on-premise-inference">Implications for sovereign on-premise inference&lt;/h2>
&lt;p>In an ENS deployment where you cannot use Langfuse Cloud or DataDog, the complete self-hosted stack is:&lt;/p>
&lt;div class="highlight">&lt;pre tabindex="0" class="chroma">&lt;code class="language-yaml" data-lang="yaml">&lt;span class="line">&lt;span class="cl">&lt;span class="c"># docker-compose.yml (or equivalent K8s manifests)&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w">&lt;/span>&lt;span class="nt">services&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">otel-collector&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">image&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">otel/opentelemetry-collector-contrib:latest&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">volumes&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="p">[&lt;/span>&lt;span class="l">./otel-config.yaml:/etc/otel/config.yaml]&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">langfuse&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">image&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">langfuse/langfuse:latest&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">environment&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">DATABASE_URL&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">postgres://...&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">prometheus&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">image&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">prom/prometheus:latest&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">grafana&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">image&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">grafana/grafana:latest&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>The whole pipeline runs on-premise. The traces never leave the perimeter. ENS compliance does not depend on which observability you choose: it depends on the inference data not going out to third parties. With a local stack, both conditions are met.&lt;/p>
&lt;hr>
&lt;h2 id="see-also">See also&lt;/h2>
&lt;ul>
&lt;li>&lt;a href="https://blog.lo0.es/en/posts/llm-tracing-opentelemetry-genai/">the fundamentals of OTel GenAI semconv: what spans are, the standard attributes and how they flow from the SDK to the collector&lt;/a>&lt;/li>
&lt;li>&lt;a href="https://blog.lo0.es/en/posts/optimising-prefill-vllm-knobs-ttft-will-not-forgive/">the prefill optimisations this article instruments: chunked prefill, prefix caching, FP8 KV&lt;/a>&lt;/li>
&lt;li>&lt;a href="https://blog.lo0.es/en/posts/optimising-decode-vllm-squeezing-every-token-small-hardware/">the decode optimisations: speculative decoding, gpu-memory-utilization, max-num-seqs&lt;/a>&lt;/li>
&lt;li>&lt;a href="https://blog.lo0.es/en/posts/anatomy-dcgm-vllm-metrics-analogies-documented-anomalies/">DCGM for the GPU metrics underneath vLLM: SM utilization, memory bandwidth, temperature; the hardware layer below the application metrics&lt;/a>&lt;/li>
&lt;li>&lt;a href="https://blog.lo0.es/en/posts/gpu-observability-llm-inference-twelve-dcgm-vllm-metrics/">how to correlate GPU metrics (DCGM) with application metrics (vLLM Prometheus) for a complete diagnosis&lt;/a>&lt;/li>
&lt;li>&lt;a href="https://blog.lo0.es/en/posts/continuous-batching-orca-vllm-chunked-prefill-goodput/">the scheduler that produces the num_running_seqs and num_waiting_seqs metrics; without understanding the scheduler, the concurrency metrics have no context&lt;/a>&lt;/li>
&lt;/ul>
&lt;hr>
&lt;h2 id="references">References&lt;/h2>
&lt;ul>
&lt;li>&lt;a href="https://docs.vllm.ai/en/stable/examples/online_serving/opentelemetry/">vLLM OpenTelemetry setup — documentación oficial&lt;/a>&lt;/li>
&lt;li>&lt;a href="https://docs.vllm.ai/en/stable/design/metrics/">vLLM Metrics — diseño y lista completa&lt;/a>&lt;/li>
&lt;li>&lt;a href="https://langfuse.com/integrations/model-providers/vllm">Tracing vLLM with Langfuse via OpenTelemetry&lt;/a>&lt;/li>
&lt;li>&lt;a href="https://www.dash0.com/blog/observing-vllm-with-opentelemetry-and-dash0">Observing vLLM with OpenTelemetry and Dash0&lt;/a>&lt;/li>
&lt;li>&lt;a href="https://opentelemetry.io/blog/2024/llm-observability/">OpenTelemetry GenAI Semantic Conventions&lt;/a>&lt;/li>
&lt;/ul></description></item><item><title>One big replica or many small ones: the decision that defines your platform</title><link>https://blog.lo0.es/en/posts/one-big-replica-or-many-small-ones-platform-decision/</link><pubDate>Fri, 05 Jun 2026 04:00:00 +0000</pubDate><guid>https://blog.lo0.es/en/posts/one-big-replica-or-many-small-ones-platform-decision/</guid><description>&lt;h2 id="tldr">TL;DR&lt;/h2>
&lt;p>With 4 GPUs available you have two basic options: one vLLM instance using all 4 (TP=4) or two independent instances using 2 each (TP=2 × 2 replicas). The first gives lower latency per individual request. The second gives higher aggregate throughput at high concurrency, better fault tolerance and finer-grained scaling. The crossover point, where the second overtakes the first, is typically between 16 and 64 concurrent requests for 70B models, much earlier than most people assume. The metric that decides it: goodput, the tokens generated within the latency SLO divided by the total.&lt;/p>
&lt;hr>
&lt;h2 id="the-analogy">The analogy&lt;/h2>
&lt;p>Two ways to organise a translation service: one senior translator with access to four specialised dictionaries at once (able to resolve any complex query in 30 seconds), or two junior translators with two dictionaries each (they take 45 seconds per complex query, but they can handle two at the same time).&lt;/p>
&lt;p>For a client who arrives alone and expects a fast answer, the senior wins. For a queue of twenty clients arriving at once, the two juniors process twice as many queries per hour even though each one takes longer. The question is not who is better, but what kind of traffic you have.&lt;/p>
&lt;hr>
&lt;h2 id="the-two-architectures-in-vllm">The two architectures in vLLM&lt;/h2>
&lt;h3 id="architecture-a-tp4-one-replica">Architecture A: TP=4, one replica&lt;/h3>
&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">&lt;span class="c1"># A single instance uses all 4 GPUs via tensor parallelism&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">vllm serve meta-llama/Meta-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> --gpu-memory-utilization 0.92 &lt;span class="se">\
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="se">&lt;/span> --port &lt;span class="m">8000&lt;/span>
&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;pre tabindex="0">&lt;code>GPU-0 ─┐
GPU-1 ─┤─ vLLM instance 0 ──► port 8000
GPU-2 ─┤ (TP=4, the model is
GPU-3 ─┘ split across 4 GPUs)
&lt;/code>&lt;/pre>&lt;p>Every attention and FFN operation is split across 4 GPUs. They require an all-reduce after each layer (on NVLink: ~50-200 µs; on PCIe: ~2-8 ms). The complete model is available in the aggregate VRAM.&lt;/p>
&lt;h3 id="architecture-b-tp2--2-replicas">Architecture B: TP=2 × 2 replicas&lt;/h3>
&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">&lt;span class="c1"># Two independent instances, each with 2 GPUs&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="c1"># Instance 0 on GPU 0-1&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="nv">CUDA_VISIBLE_DEVICES&lt;/span>&lt;span class="o">=&lt;/span>0,1 vllm serve meta-llama/Meta-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">2&lt;/span> --port &lt;span class="m">8000&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="c1"># Instance 1 on GPU 2-3&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="nv">CUDA_VISIBLE_DEVICES&lt;/span>&lt;span class="o">=&lt;/span>2,3 vllm serve meta-llama/Meta-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">2&lt;/span> --port &lt;span class="m">8001&lt;/span>
&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;pre tabindex="0">&lt;code>GPU-0 ─┐ ┌─► port 8000
GPU-1 ─┘─ vLLM instance 0 ─┘
← load balancer
GPU-2 ─┐ ┌─► port 8001
GPU-3 ─┘─ vLLM instance 1 ─┘
&lt;/code>&lt;/pre>&lt;p>Each instance holds half the model. Requests are distributed across instances. No communication between instances (they are completely independent).&lt;/p>
&lt;hr>
&lt;h2 id="why-tp4-has-higher-individual-latency-than-tp2">Why TP=4 has higher individual latency than TP=2&lt;/h2>
&lt;p>Tensor parallelism splits every transformer layer. After computing its fraction, each GPU needs to synchronise with the others via all-reduce before moving on to the next layer. The cost of that synchronisation:&lt;/p>
$$\text{overhead TP} = n\_layers \times 2 \times \text{allreduce latency}$$
&lt;p>For Llama 3 70B (80 layers) on 4×H100 NVLink:&lt;/p>
$$\text{overhead TP4} = 80 \times 2 \times 100\,\mu s = 16\,ms$$
&lt;p>On PCIe (without direct NVLink between GPUs):&lt;/p>
$$\text{overhead TP4 PCIe} = 80 \times 2 \times 3\,ms = 480\,ms$$
&lt;p>That overhead is added to every decode step. With TP=2:&lt;/p>
$$\text{overhead TP2} = 80 \times 2 \times 60\,\mu s = 9.6\,ms \text{ (NVLink)}$$
&lt;p>The difference between TP=2 and TP=4 on NVLink is ~6 ms per decode step, which matters for TPOT (inter-token latency) in streaming applications.&lt;/p>
&lt;p>On PCIe without direct NVLink, TP=4 can be &lt;strong>400 ms slower per step&lt;/strong> than TP=2. For an output of 200 tokens, that is 80 extra seconds. In that scenario TP=4 over PCIe should never be used unless the model does not fit in 2 GPUs.&lt;/p>
&lt;hr>
&lt;h2 id="the-crossover-point-when-tp22-beats-tp41">The crossover point: when TP=2×2 beats TP=4×1&lt;/h2>
&lt;p>For a 70B model on 4×H100 SXM (NVLink), aggregate throughput in tokens per second:&lt;/p>
&lt;pre tabindex="0">&lt;code>Concurrency | TP=4 × 1 instance | TP=2 × 2 instances | Winner
──────────────┼──────────────────────┼───────────────────────┼────────
1 | 200 tok/s | 170 tok/s | TP=4 (latency)
4 | 650 tok/s | 620 tok/s | TP=4 (slight)
16 | 1,800 tok/s | 2,100 tok/s | TP=2×2
32 | 2,400 tok/s | 3,600 tok/s | TP=2×2 (+50%)
64 | 2,800 tok/s | 5,200 tok/s | TP=2×2 (+86%)
128 | 2,900 tok/s | 5,800 tok/s | TP=2×2 (+100%)
&lt;/code>&lt;/pre>&lt;p>Why they diverge at high concurrency: with TP=4, the scheduler of a single instance manages every request but the KV cache is shared. With TP=2×2, each instance has its own scheduler and KV cache: less contention, more real parallelism.&lt;/p>
&lt;p>The crossover point on NVLink sits around &lt;strong>16-32 simultaneous requests&lt;/strong> for 70B. For smaller models (14B, 7B) the crossover happens earlier, because the TP communication overhead weighs more in relative terms.&lt;/p>
&lt;hr>
&lt;h2 id="the-three-implications-nobody-mentions">The three implications nobody mentions&lt;/h2>
&lt;h3 id="1-fault-tolerance">1. Fault tolerance&lt;/h3>
&lt;p>With TP=4 × 1 replica: if one GPU fails, the whole instance goes down. The service drops to 0% until the GPU recovers or the pod restarts on another node.&lt;/p>
&lt;p>With TP=2 × 2 replicas: if one GPU fails, one instance goes down. The service keeps running at 50% capacity. For ENS/NIS2, where availability is a contractual requirement, this difference is decisive.&lt;/p>
&lt;h3 id="2-autoscaling-granularity">2. Autoscaling granularity&lt;/h3>
&lt;p>With KEDA or HPA based on &lt;code>vllm:num_waiting_seqs&lt;/code>, autoscaling has to provision in multiples of the deploy unit:&lt;/p>
&lt;ul>
&lt;li>TP=4 × 1: each new node requires 4 GPUs. The minimum scaling granularity is 4 GPUs.&lt;/li>
&lt;li>TP=2 × 2: each new pod requires 2 GPUs. The minimum granularity is 2 GPUs, which is finer and more cost-efficient.&lt;/li>
&lt;/ul>
&lt;h3 id="3-quality-degradation-under-load">3. Quality degradation under load&lt;/h3>
&lt;p>TP=4 with many concurrent requests starts to suffer preemptions when the KV cache fills up. TP=2×2 spreads that pressure across two independent KV cache pools, so the probability of preemption is lower under the same total load.&lt;/p>
&lt;hr>
&lt;h2 id="measuring-the-crossover-point-with-otel">Measuring the crossover point with OTel&lt;/h2>
&lt;p>Goodput is the right metric for comparing the two architectures. Not raw throughput (which ignores the SLO), but the tokens generated within the agreed TPOT SLO:&lt;/p>
&lt;div class="highlight">&lt;pre tabindex="0" class="chroma">&lt;code class="language-promql" data-lang="promql">&lt;span class="line">&lt;span class="cl">&lt;span class="c1"># Goodput: tokens generated with TPOT inside the SLO (e.g. &amp;lt;50ms/token)&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w">&lt;/span>&lt;span class="c1"># For TP=4:&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w">&lt;/span>&lt;span class="kr">rate&lt;/span>&lt;span class="o">(&lt;/span>&lt;span class="nv">vllm&lt;/span>&lt;span class="err">:&lt;/span>&lt;span class="nv">generation_tokens_total&lt;/span>&lt;span class="p">{&lt;/span>&lt;span class="nl">instance&lt;/span>&lt;span class="o">=&lt;/span>&lt;span class="p">&amp;#34;&lt;/span>&lt;span class="s">tp4&lt;/span>&lt;span class="p">&amp;#34;}[&lt;/span>&lt;span class="s">5m&lt;/span>&lt;span class="p">]&lt;/span>&lt;span class="o">)&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="o">*&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="o">(&lt;/span>&lt;span class="mi">1&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="o">-&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="kr">histogram_quantile&lt;/span>&lt;span class="o">(&lt;/span>&lt;span class="mf">0.95&lt;/span>&lt;span class="p">,&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="kr">rate&lt;/span>&lt;span class="o">(&lt;/span>&lt;span class="nv">vllm&lt;/span>&lt;span class="err">:&lt;/span>&lt;span class="nv">time_per_output_token_seconds_bucket&lt;/span>&lt;span class="p">{&lt;/span>&lt;span class="nl">instance&lt;/span>&lt;span class="o">=&lt;/span>&lt;span class="p">&amp;#34;&lt;/span>&lt;span class="s">tp4&lt;/span>&lt;span class="p">&amp;#34;}[&lt;/span>&lt;span class="s">5m&lt;/span>&lt;span class="p">]&lt;/span>&lt;span class="o">))&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="o">&amp;gt;&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="mf">0.050&lt;/span>&lt;span class="o">)&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w">&lt;/span>&lt;span class="c1"># For TP=2×2 (sum of the two instances):&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w">&lt;/span>&lt;span class="k">sum&lt;/span>&lt;span class="o">(&lt;/span>&lt;span class="kr">rate&lt;/span>&lt;span class="o">(&lt;/span>&lt;span class="nv">vllm&lt;/span>&lt;span class="err">:&lt;/span>&lt;span class="nv">generation_tokens_total&lt;/span>&lt;span class="p">{&lt;/span>&lt;span class="nl">instance&lt;/span>&lt;span class="o">=~&lt;/span>&lt;span class="p">&amp;#34;&lt;/span>&lt;span class="s">tp2-.*&lt;/span>&lt;span class="p">&amp;#34;}[&lt;/span>&lt;span class="s">5m&lt;/span>&lt;span class="p">]&lt;/span>&lt;span class="o">))&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="o">*&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="o">(&lt;/span>&lt;span class="mi">1&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="o">-&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="kr">histogram_quantile&lt;/span>&lt;span class="o">(&lt;/span>&lt;span class="mf">0.95&lt;/span>&lt;span class="p">,&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="k">sum&lt;/span>&lt;span class="o">(&lt;/span>&lt;span class="kr">rate&lt;/span>&lt;span class="o">(&lt;/span>&lt;span class="nv">vllm&lt;/span>&lt;span class="err">:&lt;/span>&lt;span class="nv">time_per_output_token_seconds_bucket&lt;/span>&lt;span class="p">{&lt;/span>&lt;span class="nl">instance&lt;/span>&lt;span class="o">=~&lt;/span>&lt;span class="p">&amp;#34;&lt;/span>&lt;span class="s">tp2-.*&lt;/span>&lt;span class="p">&amp;#34;}[&lt;/span>&lt;span class="s">5m&lt;/span>&lt;span class="p">]&lt;/span>&lt;span class="o">)))&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="o">&amp;gt;&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="mf">0.050&lt;/span>&lt;span class="o">)&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>A direct comparison on the same dashboard, with synthetic traffic at different concurrency levels, determines the exact crossover point for your hardware and model.&lt;/p>
&lt;hr>
&lt;h2 id="the-decision-by-workload-profile">The decision by workload profile&lt;/h2>
&lt;table>
&lt;thead>
&lt;tr>
&lt;th style="text-align:left">Profile&lt;/th>
&lt;th style="text-align:left">Recommended architecture&lt;/th>
&lt;th style="text-align:left">Reason&lt;/th>
&lt;/tr>
&lt;/thead>
&lt;tbody>
&lt;tr>
&lt;td style="text-align:left">Single-user chatbot / low concurrency (&amp;lt;10 simultaneous)&lt;/td>
&lt;td style="text-align:left">TP=4 × 1&lt;/td>
&lt;td style="text-align:left">Lower p50 latency, better streaming experience&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td style="text-align:left">Enterprise API (20-100 concurrent)&lt;/td>
&lt;td style="text-align:left">TP=2 × 2&lt;/td>
&lt;td style="text-align:left">Higher goodput, fault tolerance, finer autoscaling&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td style="text-align:left">Batch processing (throughput &amp;gt; latency)&lt;/td>
&lt;td style="text-align:left">TP=2 × 2 (or more replicas)&lt;/td>
&lt;td style="text-align:left">Maximum throughput always lies in replicas&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td style="text-align:left">Very large model (&amp;gt;80B, does not fit in 2 GPUs)&lt;/td>
&lt;td style="text-align:left">TP=4 × 1&lt;/td>
&lt;td style="text-align:left">No structural alternative&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td style="text-align:left">ENS / contractual availability&lt;/td>
&lt;td style="text-align:left">TP=2 × 2 minimum&lt;/td>
&lt;td style="text-align:left">Losing one GPU is not catastrophic&lt;/td>
&lt;/tr>
&lt;/tbody>
&lt;/table>
&lt;hr>
&lt;h2 id="kubernetes-configuration-for-both-architectures">Kubernetes configuration for both architectures&lt;/h2>
&lt;div class="highlight">&lt;pre tabindex="0" class="chroma">&lt;code class="language-yaml" data-lang="yaml">&lt;span class="line">&lt;span class="cl">&lt;span class="c"># Parallel deployments for an A/B test or different topologies&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w">&lt;/span>&lt;span class="c"># TP=2 instances (2 replicas per deployment)&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w">&lt;/span>&lt;span class="nt">apiVersion&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">apps/v1&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w">&lt;/span>&lt;span class="nt">kind&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">Deployment&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w">&lt;/span>&lt;span class="nt">metadata&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">name&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">vllm-tp2&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w">&lt;/span>&lt;span class="nt">spec&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">replicas&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="m">2&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">template&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">spec&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">containers&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>- &lt;span class="nt">name&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">vllm&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">args&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="p">[&lt;/span>&lt;span class="s2">&amp;#34;serve&amp;#34;&lt;/span>&lt;span class="p">,&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="s2">&amp;#34;meta-llama/Meta-Llama-3.1-70B-Instruct&amp;#34;&lt;/span>&lt;span class="p">,&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="s2">&amp;#34;--tensor-parallel-size&amp;#34;&lt;/span>&lt;span class="p">,&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="s2">&amp;#34;2&amp;#34;&lt;/span>&lt;span class="p">,&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="s2">&amp;#34;--gpu-memory-utilization&amp;#34;&lt;/span>&lt;span class="p">,&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="s2">&amp;#34;0.92&amp;#34;&lt;/span>&lt;span class="p">]&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">resources&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">limits&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">nvidia.com/gpu&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="s2">&amp;#34;2&amp;#34;&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="c"># 2 GPUs per pod&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w">&lt;/span>&lt;span class="nn">---&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w">&lt;/span>&lt;span class="c"># Service with load balancing across the 2 replicas&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w">&lt;/span>&lt;span class="nt">apiVersion&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">v1&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w">&lt;/span>&lt;span class="nt">kind&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">Service&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w">&lt;/span>&lt;span class="nt">metadata&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">name&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">vllm-tp2&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w">&lt;/span>&lt;span class="nt">spec&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">selector&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">app&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">vllm-tp2&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">ports&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>- &lt;span class="nt">port&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="m">8000&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">sessionAffinity&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">ClientIP &lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="c"># for prefix cache awareness&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">sessionAffinityConfig&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">clientIP&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">timeoutSeconds&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="m">10800&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="c"># 3 hours of affinity per session&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>The &lt;code>sessionAffinity: ClientIP&lt;/code> setting in the Kubernetes Service is the simplest way to implement session-affinity routing: requests from the same client always go to the same replica, maximising the prefix cache hit rate on the conversation history.&lt;/p>
&lt;hr>
&lt;h2 id="see-also">See also&lt;/h2>
&lt;ul>
&lt;li>
&lt;p>&lt;a href="https://blog.lo0.es/en/posts/sharing-one-gpu-time-slicing-mps-mig/">Sharing a GPU between several workloads: time-slicing, MPS and MIG&lt;/a> — the other half of the decision: how to split one GPU between workloads with time-slicing, MPS or MIG.&lt;/p>
&lt;/li>
&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> — when they are not replicas of the same model but several models rotating on one GPU.&lt;/p>
&lt;/li>
&lt;li>
&lt;p>&lt;a href="https://blog.lo0.es/en/posts/vllm-batch-sizing-two-hour-grid-search/">the max-num-seqs grid search changes with the architecture: one large replica tolerates a higher max-num-seqs than two small ones with the same total KV cache&lt;/a>&lt;/p>
&lt;/li>
&lt;li>
&lt;p>&lt;a href="https://blog.lo0.es/en/posts/prefix-cache-hit-rate-engineering/">session routing (&lt;code>sessionAffinity&lt;/code>) is the K8s implementation of prefix-aware routing: same client, same replica, same cache&lt;/a>&lt;/p>
&lt;/li>
&lt;li>
&lt;p>&lt;a href="https://blog.lo0.es/en/posts/llm-inference-autoscaling-kubernetes-hpa-keda/">KEDA scales in pod units; TP=2×2 gives a granularity of 2 GPUs versus 4 GPUs for TP=4×1, which affects the cost of reactive autoscaling&lt;/a>&lt;/p>
&lt;/li>
&lt;li>
&lt;p>&lt;a href="https://blog.lo0.es/en/posts/instrumenting-vllm-otel-measuring-optimisations/">goodput computed over &lt;code>generation_tokens_total&lt;/code> and &lt;code>time_per_output_token_seconds&lt;/code> are the metrics that compare the two architectures&lt;/a>&lt;/p>
&lt;/li>
&lt;li>
&lt;p>&lt;a href="https://blog.lo0.es/en/posts/disaggregated-serving-prefill-decode-specialised-pods/">the next level of separation when neither TP=4×1 nor TP=2×2 is enough: separating the prefill hardware from the decode hardware&lt;/a>&lt;/p>
&lt;/li>
&lt;li>
&lt;p>&lt;a href="https://blog.lo0.es/en/posts/fp8-end-to-end-enable-measure-quality-decide/">FP8 frees VRAM in every replica; combined with TP=2×2 the impact multiplies: more concurrency per replica and more replicas possible&lt;/a>&lt;/p>
&lt;/li>
&lt;li>
&lt;p>&lt;a href="https://blog.lo0.es/en/posts/nvlink-nvswitch-nccl-cable-every-token-crosses/">NVLink, NVSwitch and NCCL: the cable every token travels through&lt;/a> — &lt;em>why&lt;/em> the NVLink limit draws the boundary of this decision: the per-layer all-reduce that TP pays for is cheap over NVSwitch and extremely expensive over PCIe.&lt;/p>
&lt;/li>
&lt;/ul>
&lt;hr>
&lt;h2 id="references">References&lt;/h2>
&lt;ul>
&lt;li>&lt;a href="https://github.com/vllm-project/vllm/issues/16300">vLLM issue #16300: TP=8 peor que TP=4 en 8×A100&lt;/a>&lt;/li>
&lt;li>&lt;a href="https://docs.jarvislabs.ai/blog/scaling-llm-inference-dp-pp-tp">Scaling LLM Inference: DP, PP &amp;amp; TP en vLLM — Jarvislabs&lt;/a>&lt;/li>
&lt;li>&lt;a href="https://rocm.blogs.amd.com/software-tools-optimization/vllm-moe-guide/README.html">vLLM MoE Playbook: TP, DP, PP and Expert Parallelism — ROCm Blogs&lt;/a>&lt;/li>
&lt;li>&lt;a href="https://www.databasemart.com/blog/vllm-distributed-inference-optimization-guide">vLLM Distributed Inference Optimization Guide — DatabaseMart&lt;/a>&lt;/li>
&lt;/ul></description></item><item><title>Optimising decode in vLLM: squeezing every token out of small hardware</title><link>https://blog.lo0.es/en/posts/optimising-decode-vllm-squeezing-every-token-small-hardware/</link><pubDate>Fri, 05 Jun 2026 04:00:00 +0000</pubDate><guid>https://blog.lo0.es/en/posts/optimising-decode-vllm-squeezing-every-token-small-hardware/</guid><description>&lt;h2 id="tldr">TL;DR&lt;/h2>
&lt;p>Decode is the phase in which vLLM generates output tokens one at a time. It is &lt;strong>memory-bound&lt;/strong>, not compute-bound: the GPU spends more time waiting for the weights to arrive from VRAM than doing arithmetic. On small hardware, an RTX 4090 (24 GB) or an L40 (48 GB), a badly configured decode wastes half the card&amp;rsquo;s capacity. Five vLLM parameters change the equation: &lt;code>gpu-memory-utilization&lt;/code>, &lt;code>max-num-seqs&lt;/code>, speculative decoding, FP8 KV cache and a &lt;code>swap-space&lt;/code> correctly set to zero. Well calibrated, the difference is real: from 15 tokens/s to 35–50 tokens/s on the same hardware.&lt;/p>
&lt;hr>
&lt;h2 id="the-analogy">The analogy&lt;/h2>
&lt;p>An assembly-line worker putting cars together. Every car needs exactly the same process: walk to the store for the part, come back, bolt it on, repeat. The travel time to the store, the VRAM latency, is fixed and cannot be eliminated. But there are ways to make it less painful:&lt;/p>
&lt;ol>
&lt;li>&lt;strong>Have several cars in parallel on the line&lt;/strong> (more concurrency, the same travel time amortised).&lt;/li>
&lt;li>&lt;strong>Have an assistant prefabricate common parts&lt;/strong> (speculative decoding: the draft model proposes, the verifier confirms).&lt;/li>
&lt;li>&lt;strong>Keep only the most-used parts in the workshop&lt;/strong> (quantised KV cache: more contexts fit in the same space).&lt;/li>
&lt;/ol>
&lt;p>Those three strategies are exactly the three axes of decode optimisation in vLLM.&lt;/p>
&lt;hr>
&lt;h2 id="why-decode-is-memory-bound">Why decode is memory-bound&lt;/h2>
&lt;p>During prefill, the GPU processes N tokens in parallel: the attention operation is a large matmul and the compute units are busy. During decode, it processes &lt;strong>1 token per step&lt;/strong>: the matmul becomes a vector-matrix product, an operation that underuses the tensor cores.&lt;/p>
&lt;p>The typical compute utilisation ratio during decode on an RTX 4090:&lt;/p>
$$\text{MFU}_{decode} \approx 5–15\% \quad \text{(vs 40–60\% in prefill)}$$
&lt;p>The bottleneck is not compute power, it is bandwidth. To generate each token, the model has to read its complete weights from VRAM:&lt;/p>
$$\text{time per token} \approx \frac{\text{weight size in bytes}}{\text{VRAM bandwidth}}$$
&lt;p>For Qwen2.5-7B in BF16 (14 GB of weights) on an RTX 4090 (1,008 GB/s):&lt;/p>
$$t \approx \frac{14 \times 10^9}{1.008 \times 10^{12}} \approx 13.9 \text{ ms/token} \approx 72 \text{ tokens/s theoretical maximum}$$
&lt;p>The real value is lower (~30–50 tok/s) because of scheduler overhead, attention over the growing KV cache and other latencies. But the theoretical limit sets the ceiling.&lt;/p>
&lt;p>With Q4_K_M (weights ~4 GB):&lt;/p>
$$t \approx \frac{4 \times 10^9}{1.008 \times 10^{12}} \approx 3.97 \text{ ms/token} \approx 252 \text{ tokens/s theoretical}$$
&lt;p>Quantising the model is the most direct way to improve decode throughput on memory-bound hardware. Everything else optimises on top of that ceiling.&lt;/p>
&lt;hr>
&lt;h2 id="the-five-levers">The five levers&lt;/h2>
&lt;h3 id="1-give-vllm-all-the-vram-you-can----gpu-memory-utilization">1. Give vLLM all the VRAM you can — &lt;code>--gpu-memory-utilization&lt;/code>&lt;/h3>
&lt;p>&lt;code>--gpu-memory-utilization&lt;/code> (short form &lt;code>--gpu-mem-util&lt;/code>) defines the fraction of available VRAM that vLLM may use for the &lt;strong>KV cache&lt;/strong>, once the model weights are loaded. The rest is reserved for activations during the forward pass and for the CUDA context.&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 my-model &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>The default value is &lt;code>0.90&lt;/code>. On bare metal where no other process uses the GPU, &lt;code>0.92–0.95&lt;/code> is safe. Do not go above &lt;code>0.95&lt;/code>: vLLM needs headroom for activations during batch peaks, and running out of VRAM in the middle of an inference results in a process crash, not a clean error.&lt;/p>
&lt;p>&lt;strong>Why it matters:&lt;/strong> more KV cache available = more simultaneous requests in flight = better GPU utilisation during decode. PagedAttention allocates the KV cache in fixed-size blocks (16 tokens per block by default), and vLLM manages them like virtual memory pages. The more blocks available, the more requests it can serve without any of them waiting for space.&lt;/p>
&lt;pre tabindex="0">&lt;code>RTX 4090, Qwen2.5-7B-BF16 (14 GB weights):
Total VRAM: 24 GB
Weights: 14 GB
Available for KV cache: 10 GB
gpu-memory-utilization 0.90 → 0.90 × 10 GB = 9 GB for KV cache
gpu-memory-utilization 0.94 → 0.94 × 10 GB = 9.4 GB → ~4% more tokens in flight
&lt;/code>&lt;/pre>&lt;p>The impact is modest with models that fit comfortably, but it is amplified with models that push VRAM to its limit.&lt;/p>
&lt;hr>
&lt;h3 id="2-real-concurrency----max-num-seqs">2. Real concurrency — &lt;code>--max-num-seqs&lt;/code>&lt;/h3>
&lt;p>&lt;code>--max-num-seqs&lt;/code> is the maximum number of requests vLLM can have in process simultaneously (counting prefill and decode together). It is the parameter that controls the system&amp;rsquo;s effective concurrency.&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 my-model &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">128&lt;/span>
&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>The effect is direct: more requests in simultaneous decode = better amortisation of the fixed cost of reading the weights. When the decode batch grows from 1 to 8, the time to generate 8 tokens is almost the same as generating 1 (the weights are read once for all of them). Aggregate throughput scales almost linearly until the KV cache or the activation VRAM becomes the bottleneck.&lt;/p>
$$\text{aggregate throughput}(B) \approx B \times \text{throughput}(1) \quad \text{for } B \ll B_{max}$$
&lt;p>&lt;strong>Common mistake:&lt;/strong> raising &lt;code>--max-num-seqs&lt;/code> without making sure there is enough KV cache in VRAM for all the requests. If vLLM cannot hold the KV caches of 128 simultaneous requests, it does preemption (pauses some request and frees its KV cache) at a latency cost. Monitor &lt;code>vllm:num_preemptions_total&lt;/code>.&lt;/p>
&lt;p>&lt;strong>Interaction with &lt;code>--max-num-batched-tokens&lt;/code>:&lt;/strong> the vLLM scheduler processes up to &lt;code>max-num-batched-tokens&lt;/code> tokens per step. If you have 128 requests in decode generating 1 token each, that is 128 decode tokens. The decode budget consumes 128 tokens of the total budget; the rest goes to prefill in chunks. Tune both values together.&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">&lt;span class="c1"># For an RTX 4090 serving ~50 concurrent users with responses of up to 512 tokens&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">vllm serve my-model &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">64&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">8192&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="c1"># 64 decode tokens per step + up to 8128 chunked prefill tokens&lt;/span>
&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;hr>
&lt;h3 id="3-speculative-decoding----speculative-model----num-speculative-tokens">3. Speculative decoding — &lt;code>--speculative-model&lt;/code> + &lt;code>--num-speculative-tokens&lt;/code>&lt;/h3>
&lt;p>Speculative decoding is the highest-impact change for decode on small hardware. The idea is simple: a small draft model proposes several tokens at once, and the verifier model validates or rejects them in a single forward pass.&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 Qwen/Qwen2.5-7B-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> --speculative-model Qwen/Qwen2.5-0.5B-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> --num-speculative-tokens &lt;span class="m">5&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> --speculative-draft-tensor-parallel-size &lt;span class="m">1&lt;/span>
&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>&lt;strong>Why it works:&lt;/strong> the 7B verifier has to read 14 GB of weights per step. With 5 proposed tokens, if the acceptance rate is 80%, an average of 4–5 tokens are generated per verifier step instead of 1. Effective throughput goes up without the GPU working any harder.&lt;/p>
&lt;p>The acceptance rate (α) depends on how well the draft predicts the verifier&amp;rsquo;s distribution. Within the same domain, models from the same family usually have α &amp;gt; 0.75:&lt;/p>
$$\text{speedup} \approx \frac{1 + \alpha \cdot k}{1 + \alpha \cdot k / \text{cost ratio}}$$
&lt;p>Where $k$ is the number of proposed tokens and cost_ratio is the draft/verifier cost ratio. For a 0.5B draft and a 7B verifier (ratio ~14×):&lt;/p>
$$\text{speedup} \approx 1 + 0.8 \times 5 \approx 5 \text{ (theoretical maximum, not reachable)}$$
&lt;p>In practice, with α = 0.75 and k = 5 on hardware without NVLink: &lt;strong>1.8–2.5× more tokens/s&lt;/strong> compared with decode alone.&lt;/p>
&lt;p>&lt;strong>EAGLE-3 in 2026:&lt;/strong> the best drafters today are not small versions of the same model, but networks specialised in predicting the verifier&amp;rsquo;s distribution. EAGLE-3 reports a 3–6.5× speedup over vanilla decode in public benchmarks. In production with mixed batches the real speedup is more conservative (1.5–3×). vLLM supports EAGLE/EAGLE-2 via &lt;code>--speculative-model&lt;/code>:&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">&lt;span class="c1"># With an EAGLE drafter (requires a drafter trained specifically for the base model)&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">vllm serve meta-llama/Llama-3.1-8B-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> --speculative-model yuhuili/EAGLE3-LLaMA3.1-Instruct-8B &lt;span class="se">\
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="se">&lt;/span> --num-speculative-tokens &lt;span class="m">6&lt;/span>
&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>&lt;strong>When speculative decoding does NOT help:&lt;/strong>&lt;/p>
&lt;ul>
&lt;li>Very large batches (&amp;gt;32 requests): the acceptance rate varies between requests and the batch spends more time re-drafting than accepting.&lt;/li>
&lt;li>High-entropy tasks (brainstorming, very creative code): the draft predicts worse, α falls below 0.5 and the draft overhead outweighs the gain.&lt;/li>
&lt;li>If the draft model does not fit in the available VRAM alongside the verifier.&lt;/li>
&lt;/ul>
&lt;p>On an RTX 4090 with a 7B verifier and a 0.5B draft (BF16): 14 + 1 GB = 15 GB. That leaves 9 GB for the KV cache. It works.&lt;/p>
&lt;hr>
&lt;h3 id="4-quantised-kv-cache----kv-cache-dtype-fp8">4. Quantised KV cache — &lt;code>--kv-cache-dtype fp8&lt;/code>&lt;/h3>
&lt;p>This was already covered in the prefill article for its effect on context capacity. From the decode point of view, the benefit is different: more tokens fit in the KV cache → more simultaneous requests without preemption → better aggregate throughput.&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 my-model &lt;span class="se">\
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="se">&lt;/span> --kv-cache-dtype fp8 &lt;span class="se">\
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="se">&lt;/span> --calculate-kv-scales
&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>&lt;strong>Precision warning in decode:&lt;/strong> the KV cache is read on every attention step of the decode. Quantisation introduces noise into the attention activations. For long texts (&amp;gt;4K tokens of context) it can accumulate. In quality benchmarks (MMLU, HellaSwag) the degradation with FP8 KV and &lt;code>--calculate-kv-scales&lt;/code> is &amp;lt;0.5% on modern models. Without &lt;code>--calculate-kv-scales&lt;/code>, the degradation can be larger because the scales are fixed statically.&lt;/p>
&lt;p>&lt;strong>Optimal combination for RTX 4090:&lt;/strong>&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 Qwen/Qwen2.5-7B-Instruct-AWQ &lt;span class="se">\
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="se">&lt;/span> --quantization awq &lt;span class="se">\ &lt;/span> &lt;span class="c1"># weights in INT4: 4 GB model&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> --kv-cache-dtype fp8 &lt;span class="se">\ &lt;/span> &lt;span class="c1"># KV cache at half the size&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> --calculate-kv-scales &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.94
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="c1"># Available VRAM: 24 - 4 = 20 GB for KV cache&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="c1"># With FP8: ~40 KB/token (vs 80 KB BF16) → 20 GB / 40 KB = 500,000 tokens of total context&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="c1"># With max-num-seqs 64 and a 4K ctx: 64 × 4096 × 40KB = 10 GB → fits with room to spare&lt;/span>
&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;hr>
&lt;h3 id="5-remove-the-swap----swap-space-0">5. Remove the swap — &lt;code>--swap-space 0&lt;/code>&lt;/h3>
&lt;p>&lt;code>--swap-space&lt;/code> defines how much system RAM (not VRAM) vLLM may use to preempt KV caches. When vLLM has more active requests than fit in VRAM, it can &amp;ldquo;pause&amp;rdquo; some by moving their KV cache to RAM and reactivating them later.&lt;/p>
&lt;p>The problem: moving a 4K-token KV cache from VRAM to RAM and back has a latency of tens of milliseconds over PCIe. For a system where you want predictable latency, the swap introduces unacceptable jitter.&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 my-model &lt;span class="se">\
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="se">&lt;/span> --swap-space &lt;span class="m">0&lt;/span>
&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>With &lt;code>--swap-space 0&lt;/code>, when vLLM cannot hold any more requests in VRAM, it simply queues them instead of doing preemption. The queue adds waiting latency, but it is predictable and it does not interrupt the requests already in flight.&lt;/p>
&lt;p>&lt;strong>When should you have swap?&lt;/strong> If your workload has short demand peaks and you can tolerate occasional jitter in exchange for not rejecting requests, a swap of 4–8 GB can be useful. In ENS deployments where latency is a contractual SLA, &lt;code>--swap-space 0&lt;/code> is the right option.&lt;/p>
&lt;hr>
&lt;h2 id="the-reference-configuration-per-hardware">The reference configuration per hardware&lt;/h2>
&lt;h3 id="rtx-4090-24-gb--7b-model-internal-use">RTX 4090 (24 GB) — 7B model, internal use&lt;/h3>
&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 Qwen/Qwen2.5-7B-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> --gpu-memory-utilization 0.92 &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-model-len &lt;span class="m">8192&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">64&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">8192&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> --kv-cache-dtype fp8 &lt;span class="se">\
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="se">&lt;/span> --calculate-kv-scales &lt;span class="se">\
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="se">&lt;/span> --swap-space &lt;span class="m">0&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> --speculative-model Qwen/Qwen2.5-0.5B-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> --num-speculative-tokens &lt;span class="m">5&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> --speculative-draft-tensor-parallel-size &lt;span class="m">1&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> --dtype bfloat16
&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>Expected throughput: &lt;strong>35–55 tokens/s per user&lt;/strong>, up to 64 simultaneous, TTFT &amp;lt;500ms for prompts &amp;lt;1K tokens.&lt;/p>
&lt;h3 id="l40-48-gb--14b-model-multi-user">L40 (48 GB) — 14B model, multi-user&lt;/h3>
&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 Qwen/Qwen2.5-14B-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> --gpu-memory-utilization 0.90 &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-model-len &lt;span class="m">16384&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">128&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">16384&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> --kv-cache-dtype fp8 &lt;span class="se">\
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="se">&lt;/span> --calculate-kv-scales &lt;span class="se">\
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="se">&lt;/span> --swap-space &lt;span class="m">0&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> --speculative-model Qwen/Qwen2.5-1.5B-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> --num-speculative-tokens &lt;span class="m">5&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> --dtype bfloat16
&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>Expected throughput: &lt;strong>25–40 tokens/s per user&lt;/strong>, up to 128 simultaneous with speculative decoding active, TTFT &amp;lt;800ms for prompts &amp;lt;2K tokens.&lt;/p>
&lt;hr>
&lt;h2 id="how-to-measure-that-decode-is-optimised">How to measure that decode is optimised&lt;/h2>
&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">&lt;span class="c1"># Key metrics at vllm:8000/metrics&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">vllm:generation_tokens_total &lt;span class="c1"># total tokens generated → trend&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">vllm:e2e_request_latency_seconds_* &lt;span class="c1"># end-to-end latency per percentile&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">vllm:time_per_output_token_seconds_* &lt;span class="c1"># ITL (inter-token latency)&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">vllm:num_preemptions_total &lt;span class="c1"># if it rises, the KV cache is filling up&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">vllm:spec_decode_draft_acceptance_rate &lt;span class="c1"># speculative decoding hit rate&lt;/span>
&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>If &lt;code>spec_decode_draft_acceptance_rate&lt;/code> &amp;lt; 0.6, the drafter is not helping: turn speculative decoding off or find a drafter better trained for your model or domain.&lt;/p>
&lt;p>If &lt;code>num_preemptions_total&lt;/code> grows, you have too many simultaneous requests for the available KV cache. Options: lower &lt;code>max-num-seqs&lt;/code>, enable FP8 KV cache, lower &lt;code>max-model-len&lt;/code>, or quantise the model further.&lt;/p>
&lt;hr>
&lt;h2 id="implications-for-on-premise-inference">Implications for on-premise inference&lt;/h2>
&lt;p>In a sovereign deployment with fixed hardware, you cannot buy more GPUs at will. Every well-calibrated tenth of &lt;code>gpu-memory-utilization&lt;/code>, every point of speculative decoding acceptance rate and every MB of KV cache freed by FP8 is real capacity you do not have to provision with another node.&lt;/p>
&lt;p>The combination of quantised weights (AWQ/GPTQ), FP8 KV cache and speculative decoding lets a 14B serve on one L40 what without optimisations would need two L40s in tensor parallel. That is the economic argument for investing time in these parameters.&lt;/p>
&lt;p>Decode cannot be accelerated indefinitely on memory-bound hardware: the theoretical limit is set by VRAM bandwidth. But the difference between the minimum and the maximum achievable on that hardware can be 3–4× with the right levers.&lt;/p>
&lt;hr>
&lt;h2 id="see-also">See also&lt;/h2>
&lt;ul>
&lt;li>&lt;a href="https://blog.lo0.es/en/posts/optimising-prefill-vllm-knobs-ttft-will-not-forgive/">the prefill-phase optimisations: chunked prefill, prefix caching and FP8 KV&lt;/a>&lt;/li>
&lt;li>&lt;a href="https://blog.lo0.es/en/posts/speculative-decoding-fundamentals-maths-state-of-play/">how speculative decoding works inside&lt;/a>&lt;/li>
&lt;li>&lt;a href="https://blog.lo0.es/en/posts/kv-cache-working-memory-llm-inference/">the structure decode consults on every token&lt;/a>&lt;/li>
&lt;li>&lt;a href="https://blog.lo0.es/en/posts/quantization-llm-inference-fp8-int4-gguf/">why quantising weights changes the speed ceiling of decode&lt;/a>&lt;/li>
&lt;li>&lt;a href="https://blog.lo0.es/en/posts/continuous-batching-orca-vllm-chunked-prefill-goodput/">the foundation of concurrency management in vLLM&lt;/a>&lt;/li>
&lt;li>&lt;a href="https://blog.lo0.es/en/posts/instrumenting-vllm-otel-measuring-optimisations/">how to measure with Prometheus whether speculative decoding, gpu-memory-utilization and max-num-seqs are working: &lt;code>spec_decode_draft_acceptance_rate&lt;/code>, &lt;code>num_preemptions_total&lt;/code> and the diagnostic matrix&lt;/a>&lt;/li>
&lt;/ul>
&lt;hr>
&lt;h2 id="references">References&lt;/h2>
&lt;ul>
&lt;li>&lt;a href="https://arxiv.org/abs/2309.06180">Efficient Memory Management for Large Language Model Serving with PagedAttention&lt;/a> — Kwon et al., 2023&lt;/li>
&lt;li>&lt;a href="https://arxiv.org/abs/2406.16858">EAGLE-2: Faster Inference of Language Models with Dynamic Draft Trees&lt;/a> — Li et al., 2024&lt;/li>
&lt;li>&lt;a href="https://arxiv.org/abs/2211.17192">Fast Inference from Transformers via Speculative Decoding&lt;/a> — Leviathan et al., 2022&lt;/li>
&lt;li>&lt;a href="https://docs.vllm.ai/en/stable/configuration/optimization/">vLLM Optimization and Tuning — documentación oficial&lt;/a>&lt;/li>
&lt;li>&lt;a href="https://docs.vllm.ai/en/stable/features/spec_decode.html">vLLM Speculative Decoding&lt;/a>&lt;/li>
&lt;li>&lt;a href="https://docs.vllm.ai/en/latest/features/quantization/quantized_kvcache/">FP8 KV Cache en vLLM&lt;/a>&lt;/li>
&lt;/ul></description></item><item><title>Optimising prefill in vLLM: the knobs your TTFT will not forgive</title><link>https://blog.lo0.es/en/posts/optimising-prefill-vllm-knobs-ttft-will-not-forgive/</link><pubDate>Fri, 05 Jun 2026 04:00:00 +0000</pubDate><guid>https://blog.lo0.es/en/posts/optimising-prefill-vllm-knobs-ttft-will-not-forgive/</guid><description>&lt;h2 id="tldr">TL;DR&lt;/h2>
&lt;p>Prefill is the phase in which vLLM processes your input prompt and produces the initial KV cache. It is compute-bound (unlike decode, which is memory-bound), it takes longer the longer the prompt is, and it blocks the decode of every other queued request. There are four levers in vLLM that radically change its behaviour: chunked prefill, prefix caching, FP8 KV cache and the per-batch token budget. With modest hardware, a 24 GB RTX 4090 or a 48 GB L40, the difference between ignoring them and using them well can be a 3× lower TTFT and 40% more aggregate throughput.&lt;/p>
&lt;hr>
&lt;h2 id="the-analogy">The analogy&lt;/h2>
&lt;p>Picture a printing shop from the early twentieth century. Setting the lead type (preparing the forme) is slow and blocks the press. Printing the pages already set is fast, but it needs the forme ready before it can start.&lt;/p>
&lt;p>Prefill is setting the type. Decode is printing. A press that can only do one thing at a time, either set or print, leaves the machinery idle half the time. The historical solution was to have one worker setting the next page while the previous one was already on the press. That is, exactly, chunked prefill.&lt;/p>
&lt;hr>
&lt;h2 id="what-prefill-really-is">What prefill really is&lt;/h2>
&lt;p>When a request arrives at vLLM, the engine has to process every token of the prompt at once before it can emit the first token of the response. During that processing it computes, for each prompt token, its attention &lt;strong>Key&lt;/strong> and &lt;strong>Value&lt;/strong> vectors. The result, the initial KV cache, is stored in VRAM and used throughout the subsequent decode.&lt;/p>
&lt;p>Unlike decode, where the model processes &lt;strong>one new token&lt;/strong> per step, in prefill it processes &lt;strong>N tokens in one go&lt;/strong>. That makes it far more efficient in FLOPs/token (GPUs are good at large matmuls), but it carries a quadratic cost in attention:&lt;/p>
$$\text{prefill attention FLOPs} \approx 4 \cdot N^2 \cdot d_{model}$$
&lt;p>With a prompt of 1,000 tokens and $d_{model} = 4096$ (Qwen2.5-7B): $4 \cdot 10^6 \cdot 4096 \approx 16 \times 10^9$ FLOPs in attention alone. With 4,000 tokens, &lt;strong>256× more&lt;/strong>, because of the quadratic nature.&lt;/p>
&lt;pre tabindex="0">&lt;code>Prefill (compute-bound):
prompt tokens → [attention O(N²)] → [FFN] → initial KV cache
Decode (memory-bound):
1 new token → [cross-attention over KV cache] → next token
&lt;/code>&lt;/pre>&lt;hr>
&lt;h2 id="why-prefill-is-a-problem-on-small-hardware">Why prefill is a problem on small hardware&lt;/h2>
&lt;p>On an H100 with 3.35 TB/s of bandwidth, a long prefill amortises quickly. On an RTX 4090 (1.008 TB/s) or an L40 (864 GB/s), the bottleneck shows up sooner and has concrete consequences:&lt;/p>
&lt;p>&lt;strong>The head-of-line blocking problem.&lt;/strong> By default, vLLM processes a complete prefill before doing any decode. If you have 10 requests queued, 9 in decode and 1 with an 8,000-token prompt, those 9 requests stall while the GPU chews through the long prefill. Their users see the streaming freeze. This is called &lt;strong>head-of-line blocking&lt;/strong> and it is the number one enemy of TTFT in production.&lt;/p>
&lt;hr>
&lt;h2 id="the-four-levers">The four levers&lt;/h2>
&lt;h3 id="1-chunked-prefill----enable-chunked-prefill----max-num-batched-tokens">1. Chunked prefill — &lt;code>--enable-chunked-prefill&lt;/code> + &lt;code>--max-num-batched-tokens&lt;/code>&lt;/h3>
&lt;p>Chunked prefill splits a long prefill into pieces (&lt;em>chunks&lt;/em>) and interleaves them with decode steps in the same batch. In vLLM V1 (≥ 0.6) it is on by default.&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 my-model &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> --max-num-batched-tokens &lt;span class="m">4096&lt;/span>
&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>&lt;code>--max-num-batched-tokens&lt;/code> is the total token budget vLLM can process in a single engine step, counting prefill and decode together. It is the most important parameter for controlling the trade-off:&lt;/p>
&lt;table>
&lt;thead>
&lt;tr>
&lt;th style="text-align:left">&lt;code>max-num-batched-tokens&lt;/code>&lt;/th>
&lt;th style="text-align:left">Effect&lt;/th>
&lt;/tr>
&lt;/thead>
&lt;tbody>
&lt;tr>
&lt;td style="text-align:left">Low (512–2048)&lt;/td>
&lt;td style="text-align:left">More decode steps per cycle → better ITL, worse TTFT&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td style="text-align:left">High (8192–32768)&lt;/td>
&lt;td style="text-align:left">Large prefill chunks → better TTFT and throughput, worse ITL&lt;/td>
&lt;/tr>
&lt;/tbody>
&lt;/table>
&lt;p>For an RTX 4090 serving 7B–13B models with mixed contexts (256–4096 tokens):&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">--max-num-batched-tokens &lt;span class="m">8192&lt;/span> &lt;span class="c1"># a reasonable balance point&lt;/span>
&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>For an L40 (48 GB) with larger models and longer prompts:&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">--max-num-batched-tokens &lt;span class="m">16384&lt;/span>
&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>&lt;strong>How it works internally:&lt;/strong> with a budget of 4,096 tokens and a prefill of 10,000, vLLM splits it into 3 chunks (4,096 + 4,096 + 1,808). Between chunks, it processes the pending decode steps. The requests in decode keep advancing; the long prefill takes longer to finish, but it freezes nothing.&lt;/p>
&lt;pre tabindex="0">&lt;code>Without chunked prefill:
t=0 [prefill 10k tokens]─────────────────────────────┐
t=1 └─[decode r1,r2...r9]
With chunked prefill (budget 4096):
t=0 [prefill chunk 4096][decode r1..r9]
t=1 [prefill chunk 4096][decode r1..r9]
t=2 [prefill chunk 1808][decode r1..r9]
t=3 [decode all, including the new one]
&lt;/code>&lt;/pre>&lt;p>The TTFT of the long request rises slightly (3 steps instead of 1), but the ITL of the other 9 requests is not interrupted.&lt;/p>
&lt;hr>
&lt;h3 id="2-prefix-caching----enable-prefix-caching">2. Prefix caching — &lt;code>--enable-prefix-caching&lt;/code>&lt;/h3>
&lt;p>If several requests share the same prefix, a system prompt, a few-shot block, a context document, vLLM can compute the KV cache of that prefix once and reuse it.&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 my-model &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>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>This is called &lt;strong>Automatic Prefix Caching (APC)&lt;/strong>. Internally, vLLM splits the KV cache into fixed-size blocks (16 tokens per block by default) and assigns them a content-based SHA hash. When a new request arrives, it checks whether any initial block is already in the cache. On a hit, that prefill is skipped.&lt;/p>
&lt;p>&lt;strong>The numerical impact:&lt;/strong> suppose a 512-token system prompt that appears in 80% of your requests, and a rate of 100 req/min:&lt;/p>
&lt;ul>
&lt;li>Without APC: 80 req/min × 512 tokens × prefill_cost = 41,000 tokens/min of redundant prefill&lt;/li>
&lt;li>With APC (80% hit rate): 20 req/min × 512 = 10,240 tokens/min of prefill → &lt;strong>a 75% reduction&lt;/strong>&lt;/li>
&lt;/ul>
&lt;p>The TTFT of those 80 requests falls to whatever it costs to process only the new suffix.&lt;/p>
&lt;p>&lt;strong>Limitation with chunked prefill:&lt;/strong> when chunked prefill is active, only the first chunk of the prefill benefits from APC in the current vLLM implementation. For workloads where the cache hit rate is very high and the suffixes are short, consider lowering &lt;code>--max-num-batched-tokens&lt;/code> so that the first chunk covers more of the shared prefix.&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">&lt;span class="c1"># Configuration optimised for a high prefix cache hit rate&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">vllm serve my-model &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> --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> --max-num-batched-tokens &lt;span class="m">4096&lt;/span> &lt;span class="c1"># smaller chunks = prefix fits in chunk 1&lt;/span>
&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;hr>
&lt;h3 id="3-fp8-kv-cache----kv-cache-dtype-fp8">3. FP8 KV cache — &lt;code>--kv-cache-dtype fp8&lt;/code>&lt;/h3>
&lt;p>The KV cache takes up VRAM. The more VRAM it consumes, the fewer concurrent requests you can keep in flight. On a 24 GB RTX 4090, the Qwen2.5-14B model in BF16 already takes ~28 GB, so it does not fit. In Q4 it takes ~9 GB, leaving ~14 GB for the KV cache.&lt;/p>
&lt;p>How many context tokens fit in 14 GB of BF16 KV cache for a 14B with GQA?&lt;/p>
$$\text{KV size per token} = 2 \cdot n_{kv\_heads} \cdot d_{head} \cdot n_{layers} \cdot 2 \text{ bytes}$$
&lt;p>For Qwen2.5-14B: $n_{kv\_heads}=8$, $d_{head}=128$, $n_{layers}=40$, BF16 → $2 \cdot 8 \cdot 128 \cdot 40 \cdot 2 = 163{,}840$ bytes ≈ 160 KB/token.&lt;/p>
&lt;p>14 GB / 160 KB ≈ &lt;strong>87,500 tokens&lt;/strong> of total context. With 8 users in parallel and 4,096 tokens of context each: 32,768 tokens used out of 87,500. There is headroom, but it is finite.&lt;/p>
&lt;p>Moving to FP8 (1 byte instead of 2):&lt;/p>
$$\text{KV FP8} = 80 \text{ KB/token} \implies 14 \text{ GB} / 80 \text{ KB} = 175{,}000 \text{ tokens}$$
&lt;p>Twice the context capacity with the same VRAM. That buys either more concurrency or longer contexts.&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 my-model &lt;span class="se">\
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="se">&lt;/span> --kv-cache-dtype fp8 &lt;span class="se">\
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="se">&lt;/span> --calculate-kv-scales &lt;span class="c1"># calibrates the scales dynamically; without this there is degradation&lt;/span>
&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>&lt;strong>Warning for RTX 4090 and L40:&lt;/strong> Ada Lovelace has FP8 instructions at the CUDA level but without Hopper&amp;rsquo;s (H100&amp;rsquo;s) dedicated scaling hardware. The memory reduction is real; the compute speedup is smaller than on an H100. Do not expect the same speedup as in a Hopper datacenter. On the L40S (the variant with optimised FP8 tensor cores) the benefit is larger than on an RTX 4090.&lt;/p>
&lt;hr>
&lt;h3 id="4-context-budget----max-model-len">4. Context budget — &lt;code>--max-model-len&lt;/code>&lt;/h3>
&lt;p>&lt;code>--max-model-len&lt;/code> defines the maximum number of tokens vLLM can handle in a single request (prompt plus generation). It is the hard limit that determines how much VRAM is reserved for the KV cache in the worst case.&lt;/p>
&lt;p>On small hardware, lowering it frees VRAM for more concurrency:&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">&lt;span class="c1"># 7B model on RTX 4090, typical context of 4K but the model supports 128K&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">vllm serve my-model &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-model-len &lt;span class="m">8192&lt;/span> &lt;span class="se">\ &lt;/span> &lt;span class="c1"># instead of 131072&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> --gpu-memory-utilization 0.92
&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>With the context cut to 8,192 tokens, vLLM does not reserve KV cache for 131,072 potential tokens and can fit more simultaneous requests. The risk is obvious: requests above 8,192 tokens fail with an error. Tune it to the P99 of your real length distribution.&lt;/p>
&lt;hr>
&lt;h2 id="interaction-between-parameters">Interaction between parameters&lt;/h2>
&lt;p>The four parameters are not independent. A common mistake is enabling prefix caching without tuning the block size, or raising &lt;code>max-num-batched-tokens&lt;/code> without checking that &lt;code>max-num-seqs&lt;/code> allows it to be filled:&lt;/p>
&lt;pre tabindex="0">&lt;code>max-num-batched-tokens = 8192
max-num-seqs = 4
average prompt = 512 tokens → 4 × 512 = 2048 prefill tokens &amp;lt; 8192
The 8192 budget is never filled because max-num-seqs caps it first.
Fix: raise max-num-seqs or lower max-num-batched-tokens.
&lt;/code>&lt;/pre>&lt;p>A balanced configuration for RTX 4090 + a 7B model:&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 Qwen/Qwen2.5-7B-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> --gpu-memory-utilization 0.92 &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-model-len &lt;span class="m">8192&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> --max-num-batched-tokens &lt;span class="m">8192&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">64&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-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> --kv-cache-dtype fp8 &lt;span class="se">\
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="se">&lt;/span> --calculate-kv-scales
&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>Configuration for an L40 (48 GB) + a 14B model:&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 Qwen/Qwen2.5-14B-Instruct-AWQ &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.90 &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-model-len &lt;span class="m">16384&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> --max-num-batched-tokens &lt;span class="m">16384&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">128&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-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> --kv-cache-dtype fp8 &lt;span class="se">\
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="se">&lt;/span> --calculate-kv-scales
&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;hr>
&lt;h2 id="how-to-measure-that-it-is-working">How to measure that it is working&lt;/h2>
&lt;p>The metrics that confirm prefill is optimised:&lt;/p>
&lt;pre tabindex="0">&lt;code># In vLLM&amp;#39;s Prometheus metrics (port 8000/metrics):
vllm:time_to_first_token_seconds_bucket → TTFT distribution
vllm:gpu_cache_usage_perc → KV cache utilisation
vllm:prefix_cache_hit_rate → APC hit rate (if enabled)
vllm:num_running_seqs → simultaneous in-flight requests
&lt;/code>&lt;/pre>&lt;p>A &lt;code>prefix_cache_hit_rate&lt;/code> below 30% on workloads with a fixed system prompt means something in the hash is not working (a system prompt that varies by timestamp, a date format in the prompt, and so on).&lt;/p>
&lt;hr>
&lt;h2 id="implications-for-on-premise-inference">Implications for on-premise inference&lt;/h2>
&lt;p>Chunked prefill and prefix caching are &lt;strong>zero cost&lt;/strong>: they are enabled with flags and require no extra hardware. FP8 KV cache requires the model to be compatible (almost every modern transformer is) and that you are on Ada Lovelace or newer.&lt;/p>
&lt;p>For sovereign ENS deployments where the hardware is fixed and you cannot scale horizontally on demand, a well-configured prefill is the difference between needing 4 nodes and needing 2 for the same load.&lt;/p>
&lt;p>The second article in this series covers the &lt;strong>decode&lt;/strong> optimisations: speculative decoding, KV cache tuning to maximise concurrency and how to configure &lt;code>gpu-memory-utilization&lt;/code> without vLLM running out of VRAM at midnight.&lt;/p>
&lt;hr>
&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/">how the KV cache works inside&lt;/a>&lt;/li>
&lt;li>&lt;a href="https://blog.lo0.es/en/posts/flashattention-librarian-io-awareness-async-blackwell-asymmetry/">why FlashAttention changes memory consumption in prefill&lt;/a>&lt;/li>
&lt;li>&lt;a href="https://blog.lo0.es/en/posts/continuous-batching-orca-vllm-chunked-prefill-goodput/">the foundation chunked prefill builds on&lt;/a>&lt;/li>
&lt;li>&lt;a href="https://blog.lo0.es/en/posts/disaggregated-serving-prefill-decode-specialised-pods/">when scaling a single node is no longer enough&lt;/a>&lt;/li>
&lt;li>&lt;a href="https://blog.lo0.es/en/posts/instrumenting-vllm-otel-measuring-optimisations/">how to measure with Prometheus and OTel whether chunked prefill and prefix caching are working: &lt;code>ttft p99&lt;/code>, &lt;code>gpu_prefix_cache_hit_rate&lt;/code> and the concrete alerts&lt;/a>&lt;/li>
&lt;/ul>
&lt;hr>
&lt;h2 id="references">References&lt;/h2>
&lt;ul>
&lt;li>&lt;a href="https://arxiv.org/abs/2309.06180">Efficient Memory Management for Large Language Model Serving with PagedAttention&lt;/a> — Kwon et al., 2023&lt;/li>
&lt;li>&lt;a href="https://arxiv.org/abs/2403.02310">Sarathi-Serve: Chunked Prefill and Stall-Free Scheduling&lt;/a> — Agrawal et al., 2024&lt;/li>
&lt;li>&lt;a href="https://docs.vllm.ai/en/stable/configuration/optimization/">vLLM Optimization and Tuning — documentación oficial&lt;/a>&lt;/li>
&lt;li>&lt;a href="https://docs.vllm.ai/en/latest/features/quantization/quantized_kvcache/">Quantized KV Cache en vLLM&lt;/a>&lt;/li>
&lt;/ul></description></item><item><title>Prefix cache: hit rate engineering to go from 15% to 75%</title><link>https://blog.lo0.es/en/posts/prefix-cache-hit-rate-engineering/</link><pubDate>Fri, 05 Jun 2026 04:00:00 +0000</pubDate><guid>https://blog.lo0.es/en/posts/prefix-cache-hit-rate-engineering/</guid><description>&lt;h2 id="tldr">TL;DR&lt;/h2>
&lt;p>vLLM&amp;rsquo;s prefix cache stores the KV cache blocks of shared prefixes and reuses them in later requests. A hit avoids recomputing that prefix: TTFT drops to the cost of the variable suffix only. In enterprise workloads with fixed system prompts (RAG, domain chatbots, assistants with long instructions) the hit rate should be &amp;gt;70%. In practice it is 10-20% for entirely avoidable reasons. This article identifies them, fixes them, and gives the OTel queries to confirm the result.&lt;/p>
&lt;hr>
&lt;h2 id="the-analogy">The analogy&lt;/h2>
&lt;p>A simultaneous conference interpreter who has to translate the speeches of twenty speakers. All of them open with the same two-page protocol preamble: the conference declaration, the rules of conduct, the day&amp;rsquo;s programme. An interpreter with no memory rereads the two pages for each speaker before starting on their specific speech. An interpreter with good notes reads them once, files them, and when the second speaker starts goes straight to the speech.&lt;/p>
&lt;p>The prefix cache is that file. The prefix hash is the reference that lets you skip to the new part. But if the preamble changes by even one word, because someone drops in the day&amp;rsquo;s date, the interpreter has to reread everything from the beginning.&lt;/p>
&lt;hr>
&lt;h2 id="how-the-prefix-cache-hash-works">How the prefix cache hash works&lt;/h2>
&lt;p>vLLM splits the KV cache into blocks of 16 tokens. Each block has a hash computed over its exact content. When a new request arrives, vLLM checks whether any initial block of the prompt is already in cache by comparing hashes.&lt;/p>
&lt;p>The hash is computed over &lt;strong>the byte-by-byte content of the tokens&lt;/strong>. Any difference, a space, a different character, one extra token, produces a completely different hash. There is no partial matching inside a block.&lt;/p>
&lt;p>Direct consequence: if your system prompt has 512 tokens and token number 3 changes between requests (because you interpolate a date, an ID, a version number), &lt;strong>no block hits&lt;/strong> even though 99% of the text is identical.&lt;/p>
&lt;pre tabindex="0">&lt;code>Block 0 (tokens 0-15): hash = a3f7... ← in cache?
Block 1 (tokens 16-31): hash = 9d2c... ← in cache?
...
Block 31 (tokens 496-511): hash = 7e1a... ← in cache?
&lt;/code>&lt;/pre>&lt;p>If block 0 does not hit (because its content changed), blocks 1-31 are not even checked although they are identical. The prefix cache is sequential.&lt;/p>
&lt;hr>
&lt;h2 id="audit-why-your-real-hit-rate-is-low">Audit: why your real hit rate is low&lt;/h2>
&lt;p>Before changing anything, you need to know &lt;em>what&lt;/em> is breaking the hash. The most direct method: pull the last 1000 production prompts and work out what fraction of the prefix varies.&lt;/p>
&lt;div class="highlight">&lt;pre tabindex="0" class="chroma">&lt;code class="language-python" data-lang="python">&lt;span class="line">&lt;span class="cl">&lt;span class="c1"># audit_prefix_cache.py&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="kn">import&lt;/span> &lt;span class="nn">langfuse&lt;/span>&lt;span class="o">,&lt;/span> &lt;span class="nn">hashlib&lt;/span>&lt;span class="o">,&lt;/span> &lt;span class="nn">collections&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="kn">from&lt;/span> &lt;span class="nn">transformers&lt;/span> &lt;span class="kn">import&lt;/span> &lt;span class="n">AutoTokenizer&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="n">client&lt;/span> &lt;span class="o">=&lt;/span> &lt;span class="n">langfuse&lt;/span>&lt;span class="o">.&lt;/span>&lt;span class="n">Langfuse&lt;/span>&lt;span class="p">()&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="n">tokenizer&lt;/span> &lt;span class="o">=&lt;/span> &lt;span class="n">AutoTokenizer&lt;/span>&lt;span class="o">.&lt;/span>&lt;span class="n">from_pretrained&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="s2">&amp;#34;Qwen/Qwen2.5-14B-Instruct&amp;#34;&lt;/span>&lt;span class="p">)&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="n">traces&lt;/span> &lt;span class="o">=&lt;/span> &lt;span class="n">client&lt;/span>&lt;span class="o">.&lt;/span>&lt;span class="n">fetch_traces&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="n">limit&lt;/span>&lt;span class="o">=&lt;/span>&lt;span class="mi">1000&lt;/span>&lt;span class="p">)&lt;/span>&lt;span class="o">.&lt;/span>&lt;span class="n">data&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="n">prompts&lt;/span> &lt;span class="o">=&lt;/span> &lt;span class="p">[&lt;/span>&lt;span class="n">t&lt;/span>&lt;span class="o">.&lt;/span>&lt;span class="n">input&lt;/span> &lt;span class="k">for&lt;/span> &lt;span class="n">t&lt;/span> &lt;span class="ow">in&lt;/span> &lt;span class="n">traces&lt;/span> &lt;span class="k">if&lt;/span> &lt;span class="n">t&lt;/span>&lt;span class="o">.&lt;/span>&lt;span class="n">input&lt;/span>&lt;span class="p">]&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="c1"># Tokenise and extract the first 512 tokens (the typical system prompt)&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="n">prefixes&lt;/span> &lt;span class="o">=&lt;/span> &lt;span class="p">[]&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="k">for&lt;/span> &lt;span class="n">prompt&lt;/span> &lt;span class="ow">in&lt;/span> &lt;span class="n">prompts&lt;/span>&lt;span class="p">:&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="n">tokens&lt;/span> &lt;span class="o">=&lt;/span> &lt;span class="n">tokenizer&lt;/span>&lt;span class="o">.&lt;/span>&lt;span class="n">encode&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="n">prompt&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="n">add_special_tokens&lt;/span>&lt;span class="o">=&lt;/span>&lt;span class="kc">False&lt;/span>&lt;span class="p">)&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="n">prefix_tokens&lt;/span> &lt;span class="o">=&lt;/span> &lt;span class="nb">tuple&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="n">tokens&lt;/span>&lt;span class="p">[:&lt;/span>&lt;span class="mi">512&lt;/span>&lt;span class="p">])&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="n">prefixes&lt;/span>&lt;span class="o">.&lt;/span>&lt;span class="n">append&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="n">prefix_tokens&lt;/span>&lt;span class="p">)&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="c1"># How many unique prefixes are there?&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="n">unique&lt;/span> &lt;span class="o">=&lt;/span> &lt;span class="nb">len&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="nb">set&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="n">prefixes&lt;/span>&lt;span class="p">))&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="n">total&lt;/span> &lt;span class="o">=&lt;/span> &lt;span class="nb">len&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="n">prefixes&lt;/span>&lt;span class="p">)&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="nb">print&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="sa">f&lt;/span>&lt;span class="s2">&amp;#34;Unique prefixes: &lt;/span>&lt;span class="si">{&lt;/span>&lt;span class="n">unique&lt;/span>&lt;span class="si">}&lt;/span>&lt;span class="s2">/&lt;/span>&lt;span class="si">{&lt;/span>&lt;span class="n">total&lt;/span>&lt;span class="si">}&lt;/span>&lt;span class="s2"> (&lt;/span>&lt;span class="si">{&lt;/span>&lt;span class="n">unique&lt;/span>&lt;span class="o">/&lt;/span>&lt;span class="n">total&lt;/span>&lt;span class="o">*&lt;/span>&lt;span class="mi">100&lt;/span>&lt;span class="si">:&lt;/span>&lt;span class="s2">.1f&lt;/span>&lt;span class="si">}&lt;/span>&lt;span class="s2">%)&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="nb">print&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="sa">f&lt;/span>&lt;span class="s2">&amp;#34;Theoretical hit rate if they were all equal: &lt;/span>&lt;span class="si">{&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="mi">1&lt;/span> &lt;span class="o">-&lt;/span> &lt;span class="n">unique&lt;/span>&lt;span class="o">/&lt;/span>&lt;span class="n">total&lt;/span>&lt;span class="p">)&lt;/span>&lt;span class="o">*&lt;/span>&lt;span class="mi">100&lt;/span>&lt;span class="si">:&lt;/span>&lt;span class="s2">.1f&lt;/span>&lt;span class="si">}&lt;/span>&lt;span class="s2">%&amp;#34;&lt;/span>&lt;span class="p">)&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="c1"># Find which token differs between the most common prefix and the rest&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="kn">from&lt;/span> &lt;span class="nn">collections&lt;/span> &lt;span class="kn">import&lt;/span> &lt;span class="n">Counter&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="n">most_common_prefix&lt;/span> &lt;span class="o">=&lt;/span> &lt;span class="n">Counter&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="n">prefixes&lt;/span>&lt;span class="p">)&lt;/span>&lt;span class="o">.&lt;/span>&lt;span class="n">most_common&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="mi">1&lt;/span>&lt;span class="p">)[&lt;/span>&lt;span class="mi">0&lt;/span>&lt;span class="p">][&lt;/span>&lt;span class="mi">0&lt;/span>&lt;span class="p">]&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="n">divergence_positions&lt;/span> &lt;span class="o">=&lt;/span> &lt;span class="p">[]&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="k">for&lt;/span> &lt;span class="n">prefix&lt;/span> &lt;span class="ow">in&lt;/span> &lt;span class="n">prefixes&lt;/span>&lt;span class="p">:&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="k">if&lt;/span> &lt;span class="n">prefix&lt;/span> &lt;span class="o">==&lt;/span> &lt;span class="n">most_common_prefix&lt;/span>&lt;span class="p">:&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="k">continue&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="k">for&lt;/span> &lt;span class="n">i&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="p">(&lt;/span>&lt;span class="n">a&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="n">b&lt;/span>&lt;span class="p">)&lt;/span> &lt;span class="ow">in&lt;/span> &lt;span class="nb">enumerate&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="nb">zip&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="n">most_common_prefix&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="n">prefix&lt;/span>&lt;span class="p">)):&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="k">if&lt;/span> &lt;span class="n">a&lt;/span> &lt;span class="o">!=&lt;/span> &lt;span class="n">b&lt;/span>&lt;span class="p">:&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="n">divergence_positions&lt;/span>&lt;span class="o">.&lt;/span>&lt;span class="n">append&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="n">i&lt;/span>&lt;span class="p">)&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="k">break&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="k">if&lt;/span> &lt;span class="n">divergence_positions&lt;/span>&lt;span class="p">:&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="n">pos&lt;/span> &lt;span class="o">=&lt;/span> &lt;span class="n">Counter&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="n">divergence_positions&lt;/span>&lt;span class="p">)&lt;/span>&lt;span class="o">.&lt;/span>&lt;span class="n">most_common&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="mi">1&lt;/span>&lt;span class="p">)[&lt;/span>&lt;span class="mi">0&lt;/span>&lt;span class="p">][&lt;/span>&lt;span class="mi">0&lt;/span>&lt;span class="p">]&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="n">token_text&lt;/span> &lt;span class="o">=&lt;/span> &lt;span class="n">tokenizer&lt;/span>&lt;span class="o">.&lt;/span>&lt;span class="n">decode&lt;/span>&lt;span class="p">([&lt;/span>&lt;span class="n">most_common_prefix&lt;/span>&lt;span class="p">[&lt;/span>&lt;span class="n">pos&lt;/span>&lt;span class="p">]])&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="nb">print&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="sa">f&lt;/span>&lt;span class="s2">&amp;#34;&lt;/span>&lt;span class="se">\n&lt;/span>&lt;span class="s2">Most frequent divergence at position &lt;/span>&lt;span class="si">{&lt;/span>&lt;span class="n">pos&lt;/span>&lt;span class="si">}&lt;/span>&lt;span class="s2">: &amp;#39;&lt;/span>&lt;span class="si">{&lt;/span>&lt;span class="n">token_text&lt;/span>&lt;span class="si">}&lt;/span>&lt;span class="s2">&amp;#39;&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="nb">print&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="s2">&amp;#34;→ The token at that position varies between requests&amp;#34;&lt;/span>&lt;span class="p">)&lt;/span>
&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>The most common culprits, in order of frequency:&lt;/p>
&lt;p>&lt;strong>1. Timestamps and dates:&lt;/strong>&lt;/p>
&lt;div class="highlight">&lt;pre tabindex="0" class="chroma">&lt;code class="language-python" data-lang="python">&lt;span class="line">&lt;span class="cl">&lt;span class="c1"># ❌ Breaks the hash on every request&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="n">system&lt;/span> &lt;span class="o">=&lt;/span> &lt;span class="sa">f&lt;/span>&lt;span class="s2">&amp;#34;Current date: &lt;/span>&lt;span class="si">{&lt;/span>&lt;span class="n">datetime&lt;/span>&lt;span class="o">.&lt;/span>&lt;span class="n">now&lt;/span>&lt;span class="p">()&lt;/span>&lt;span class="o">.&lt;/span>&lt;span class="n">strftime&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="s1">&amp;#39;%Y-%m-&lt;/span>&lt;span class="si">%d&lt;/span>&lt;span class="s1"> %H:%M&amp;#39;&lt;/span>&lt;span class="p">)&lt;/span>&lt;span class="si">}&lt;/span>&lt;span class="s2">. You are an assistant...&amp;#34;&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="c1"># ✅ Take the date out of the system prompt&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="n">system&lt;/span> &lt;span class="o">=&lt;/span> &lt;span class="s2">&amp;#34;You are an assistant specialising in cloud infrastructure.&amp;#34;&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="c1"># Pass the date as part of the user message if it is needed&lt;/span>
&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>&lt;strong>2. Session and user IDs:&lt;/strong>&lt;/p>
&lt;div class="highlight">&lt;pre tabindex="0" class="chroma">&lt;code class="language-python" data-lang="python">&lt;span class="line">&lt;span class="cl">&lt;span class="c1"># ❌&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="n">system&lt;/span> &lt;span class="o">=&lt;/span> &lt;span class="sa">f&lt;/span>&lt;span class="s2">&amp;#34;User ID: &lt;/span>&lt;span class="si">{&lt;/span>&lt;span class="n">user_id&lt;/span>&lt;span class="si">}&lt;/span>&lt;span class="s2">. Preferences: &lt;/span>&lt;span class="si">{&lt;/span>&lt;span class="n">user_prefs&lt;/span>&lt;span class="si">}&lt;/span>&lt;span class="s2">. You are an assistant...&amp;#34;&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="c1"># ✅ Separate the static from the contextual&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="n">system&lt;/span> &lt;span class="o">=&lt;/span> &lt;span class="s2">&amp;#34;You are a specialised assistant.&amp;#34;&lt;/span> &lt;span class="c1"># always the same&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="c1"># Add the user context as the first message of the history&lt;/span>
&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>&lt;strong>3. Interpolated prompt versions:&lt;/strong>&lt;/p>
&lt;div class="highlight">&lt;pre tabindex="0" class="chroma">&lt;code class="language-python" data-lang="python">&lt;span class="line">&lt;span class="cl">&lt;span class="c1"># ❌&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="n">system&lt;/span> &lt;span class="o">=&lt;/span> &lt;span class="sa">f&lt;/span>&lt;span class="s2">&amp;#34;[v&lt;/span>&lt;span class="si">{&lt;/span>&lt;span class="n">PROMPT_VERSION&lt;/span>&lt;span class="si">}&lt;/span>&lt;span class="s2">] You are an assistant...&amp;#34;&lt;/span> &lt;span class="c1"># changes with every deploy&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="c1"># ✅ Do not version in the text, version in the prompt name in Langfuse&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="n">system&lt;/span> &lt;span class="o">=&lt;/span> &lt;span class="s2">&amp;#34;You are an assistant...&amp;#34;&lt;/span>
&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>&lt;strong>4. Dynamic few-shots:&lt;/strong>&lt;/p>
&lt;div class="highlight">&lt;pre tabindex="0" class="chroma">&lt;code class="language-python" data-lang="python">&lt;span class="line">&lt;span class="cl">&lt;span class="c1"># ❌ Examples retrieved at random from a pool&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="n">examples&lt;/span> &lt;span class="o">=&lt;/span> &lt;span class="n">random&lt;/span>&lt;span class="o">.&lt;/span>&lt;span class="n">sample&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="n">example_pool&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="n">k&lt;/span>&lt;span class="o">=&lt;/span>&lt;span class="mi">3&lt;/span>&lt;span class="p">)&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="n">system&lt;/span> &lt;span class="o">=&lt;/span> &lt;span class="sa">f&lt;/span>&lt;span class="s2">&amp;#34;Examples:&lt;/span>&lt;span class="se">\n&lt;/span>&lt;span class="si">{&lt;/span>&lt;span class="n">format_examples&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="n">examples&lt;/span>&lt;span class="p">)&lt;/span>&lt;span class="si">}&lt;/span>&lt;span class="se">\n\n&lt;/span>&lt;span class="s2">You are an assistant...&amp;#34;&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="c1"># ✅ Fixed few-shots always in the same order&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="n">FIXED_EXAMPLES&lt;/span> &lt;span class="o">=&lt;/span> &lt;span class="p">[&lt;/span>&lt;span class="n">example_pool&lt;/span>&lt;span class="p">[&lt;/span>&lt;span class="mi">0&lt;/span>&lt;span class="p">],&lt;/span> &lt;span class="n">example_pool&lt;/span>&lt;span class="p">[&lt;/span>&lt;span class="mi">1&lt;/span>&lt;span class="p">],&lt;/span> &lt;span class="n">example_pool&lt;/span>&lt;span class="p">[&lt;/span>&lt;span class="mi">2&lt;/span>&lt;span class="p">]]&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="n">system&lt;/span> &lt;span class="o">=&lt;/span> &lt;span class="sa">f&lt;/span>&lt;span class="s2">&amp;#34;Examples:&lt;/span>&lt;span class="se">\n&lt;/span>&lt;span class="si">{&lt;/span>&lt;span class="n">format_examples&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="n">FIXED_EXAMPLES&lt;/span>&lt;span class="p">)&lt;/span>&lt;span class="si">}&lt;/span>&lt;span class="se">\n\n&lt;/span>&lt;span class="s2">You are an assistant...&amp;#34;&lt;/span>
&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;hr>
&lt;h2 id="template-engineering-the-structure-that-maximises-hits">Template engineering: the structure that maximises hits&lt;/h2>
&lt;p>The principle is simple: &lt;strong>everything static goes first, everything dynamic goes after&lt;/strong>. The prefix cache is sequential. Once a block fails to hit, the rest are not looked up either.&lt;/p>
&lt;pre tabindex="0">&lt;code>OPTIMAL STRUCTURE for maximising prefix cache:
┌──────────────────────────────────────────────┐
│ STATIC BLOCK (tokens 0-511) │ ← hit rate ~100%
│ Invariant system prompt │
│ Fixed instructions │
│ Few-shots always in the same order │
├──────────────────────────────────────────────┤
│ SEMI-STATIC BLOCK (tokens 512-1023) │ ← hit rate ~60-80%
│ RAG documents for this session │
│ Conversation history so far │
├──────────────────────────────────────────────┤
│ DYNAMIC BLOCK (tokens 1024+) │ ← hit rate ~0% (expected)
│ Current user message │
│ Context specific to this request │
└──────────────────────────────────────────────┘
&lt;/code>&lt;/pre>&lt;p>For RAG specifically: if the retrieved documents are the same across a set of similar queries (very frequent in RAG over fixed corporate documents), ordering them &lt;strong>always in the same order&lt;/strong> (by ID, by a fixed score, not by a variable score) multiplies the hit rate of the semi-static block.&lt;/p>
&lt;hr>
&lt;h2 id="prefix-aware-routing-the-next-level">Prefix-aware routing: the next level&lt;/h2>
&lt;p>With a single vLLM instance, the prefix cache works automatically. The problem shows up with multiple replicas: the load balancer distributes requests round-robin, and the prefix cached on replica A is no use at all when the request lands on replica B.&lt;/p>
&lt;p>The solution is &lt;strong>prefix-aware routing&lt;/strong>: send requests with the same prefix to the same node.&lt;/p>
&lt;p>&lt;strong>With Ray Serve (native integration):&lt;/strong>&lt;/p>
&lt;div class="highlight">&lt;pre tabindex="0" class="chroma">&lt;code class="language-python" data-lang="python">&lt;span class="line">&lt;span class="cl">&lt;span class="c1"># ray_serve_prefix_router.py&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="kn">from&lt;/span> &lt;span class="nn">ray&lt;/span> &lt;span class="kn">import&lt;/span> &lt;span class="n">serve&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="kn">from&lt;/span> &lt;span class="nn">ray.serve.llm&lt;/span> &lt;span class="kn">import&lt;/span> &lt;span class="n">LLMConfig&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="n">build_llm_deployment&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="nd">@serve.deployment&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="k">class&lt;/span> &lt;span class="nc">PrefixAwareRouter&lt;/span>&lt;span class="p">:&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="k">def&lt;/span> &lt;span class="fm">__init__&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="bp">self&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="n">replicas&lt;/span>&lt;span class="p">):&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="bp">self&lt;/span>&lt;span class="o">.&lt;/span>&lt;span class="n">replicas&lt;/span> &lt;span class="o">=&lt;/span> &lt;span class="n">replicas&lt;/span> &lt;span class="c1"># list of vLLM handles&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="k">async&lt;/span> &lt;span class="k">def&lt;/span> &lt;span class="fm">__call__&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="bp">self&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="n">request&lt;/span>&lt;span class="p">):&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="n">body&lt;/span> &lt;span class="o">=&lt;/span> &lt;span class="k">await&lt;/span> &lt;span class="n">request&lt;/span>&lt;span class="o">.&lt;/span>&lt;span class="n">json&lt;/span>&lt;span class="p">()&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="n">messages&lt;/span> &lt;span class="o">=&lt;/span> &lt;span class="n">body&lt;/span>&lt;span class="o">.&lt;/span>&lt;span class="n">get&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="s2">&amp;#34;messages&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>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="c1"># Compute the hash of the system prompt (static prefix)&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="n">system_content&lt;/span> &lt;span class="o">=&lt;/span> &lt;span class="s2">&amp;#34;&amp;#34;&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="k">for&lt;/span> &lt;span class="n">msg&lt;/span> &lt;span class="ow">in&lt;/span> &lt;span class="n">messages&lt;/span>&lt;span class="p">:&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="k">if&lt;/span> &lt;span class="n">msg&lt;/span>&lt;span class="p">[&lt;/span>&lt;span class="s2">&amp;#34;role&amp;#34;&lt;/span>&lt;span class="p">]&lt;/span> &lt;span class="o">==&lt;/span> &lt;span class="s2">&amp;#34;system&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="n">system_content&lt;/span> &lt;span class="o">=&lt;/span> &lt;span class="n">msg&lt;/span>&lt;span class="p">[&lt;/span>&lt;span class="s2">&amp;#34;content&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="k">break&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="n">prefix_hash&lt;/span> &lt;span class="o">=&lt;/span> &lt;span class="nb">hash&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="n">system_content&lt;/span>&lt;span class="p">)&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="c1"># Deterministic routing: same hash → same node&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="n">replica_idx&lt;/span> &lt;span class="o">=&lt;/span> &lt;span class="n">prefix_hash&lt;/span> &lt;span class="o">%&lt;/span> &lt;span class="nb">len&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="bp">self&lt;/span>&lt;span class="o">.&lt;/span>&lt;span class="n">replicas&lt;/span>&lt;span class="p">)&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="k">return&lt;/span> &lt;span class="k">await&lt;/span> &lt;span class="bp">self&lt;/span>&lt;span class="o">.&lt;/span>&lt;span class="n">replicas&lt;/span>&lt;span class="p">[&lt;/span>&lt;span class="n">replica_idx&lt;/span>&lt;span class="p">]&lt;/span>&lt;span class="o">.&lt;/span>&lt;span class="n">remote&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="n">request&lt;/span>&lt;span class="p">)&lt;/span>
&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>&lt;strong>With an L7 gateway (Nginx/Traefik):&lt;/strong>&lt;/p>
&lt;div class="highlight">&lt;pre tabindex="0" class="chroma">&lt;code class="language-nginx" data-lang="nginx">&lt;span class="line">&lt;span class="cl">&lt;span class="c1"># nginx.conf — routing by X-Prefix-Hash header
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="c1">&lt;/span>&lt;span class="k">upstream&lt;/span> &lt;span class="s">vllm_backends&lt;/span> &lt;span class="p">{&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="kn">hash&lt;/span> &lt;span class="nv">$http_x_prefix_hash&lt;/span> &lt;span class="s">consistent&lt;/span>&lt;span class="p">;&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="kn">server&lt;/span> &lt;span class="n">vllm-0&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="mi">8000&lt;/span>&lt;span class="p">;&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="kn">server&lt;/span> &lt;span class="n">vllm-1&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="mi">8000&lt;/span>&lt;span class="p">;&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="kn">server&lt;/span> &lt;span class="n">vllm-2&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="mi">8000&lt;/span>&lt;span class="p">;&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="kn">server&lt;/span> &lt;span class="n">vllm-3&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="mi">8000&lt;/span>&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>The client computes the hash of the static prefix and includes it as a header:&lt;/p>
&lt;div class="highlight">&lt;pre tabindex="0" class="chroma">&lt;code class="language-python" data-lang="python">&lt;span class="line">&lt;span class="cl">&lt;span class="kn">import&lt;/span> &lt;span class="nn">hashlib&lt;/span>&lt;span class="o">,&lt;/span> &lt;span class="nn">requests&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="k">def&lt;/span> &lt;span class="nf">llm_request&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="n">messages&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="n">base_url&lt;/span>&lt;span class="p">):&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="n">system_msg&lt;/span> &lt;span class="o">=&lt;/span> &lt;span class="nb">next&lt;/span>&lt;span class="p">((&lt;/span>&lt;span class="n">m&lt;/span>&lt;span class="p">[&lt;/span>&lt;span class="s2">&amp;#34;content&amp;#34;&lt;/span>&lt;span class="p">]&lt;/span> &lt;span class="k">for&lt;/span> &lt;span class="n">m&lt;/span> &lt;span class="ow">in&lt;/span> &lt;span class="n">messages&lt;/span> &lt;span class="k">if&lt;/span> &lt;span class="n">m&lt;/span>&lt;span class="p">[&lt;/span>&lt;span class="s2">&amp;#34;role&amp;#34;&lt;/span>&lt;span class="p">]&lt;/span> &lt;span class="o">==&lt;/span> &lt;span class="s2">&amp;#34;system&amp;#34;&lt;/span>&lt;span class="p">),&lt;/span> &lt;span class="s2">&amp;#34;&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="n">prefix_hash&lt;/span> &lt;span class="o">=&lt;/span> &lt;span class="n">hashlib&lt;/span>&lt;span class="o">.&lt;/span>&lt;span class="n">sha256&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="n">system_msg&lt;/span>&lt;span class="o">.&lt;/span>&lt;span class="n">encode&lt;/span>&lt;span class="p">())&lt;/span>&lt;span class="o">.&lt;/span>&lt;span class="n">hexdigest&lt;/span>&lt;span class="p">()[:&lt;/span>&lt;span class="mi">16&lt;/span>&lt;span class="p">]&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="k">return&lt;/span> &lt;span class="n">requests&lt;/span>&lt;span class="o">.&lt;/span>&lt;span class="n">post&lt;/span>&lt;span class="p">(&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="sa">f&lt;/span>&lt;span class="s2">&amp;#34;&lt;/span>&lt;span class="si">{&lt;/span>&lt;span class="n">base_url&lt;/span>&lt;span class="si">}&lt;/span>&lt;span class="s2">/v1/chat/completions&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="n">json&lt;/span>&lt;span class="o">=&lt;/span>&lt;span class="p">{&lt;/span>&lt;span class="s2">&amp;#34;messages&amp;#34;&lt;/span>&lt;span class="p">:&lt;/span> &lt;span class="n">messages&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="s2">&amp;#34;model&amp;#34;&lt;/span>&lt;span class="p">:&lt;/span> &lt;span class="s2">&amp;#34;my-model&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="n">headers&lt;/span>&lt;span class="o">=&lt;/span>&lt;span class="p">{&lt;/span>&lt;span class="s2">&amp;#34;X-Prefix-Hash&amp;#34;&lt;/span>&lt;span class="p">:&lt;/span> &lt;span class="n">prefix_hash&lt;/span>&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;hr>
&lt;h2 id="measuring-the-impact-with-otel">Measuring the impact with OTel&lt;/h2>
&lt;div class="highlight">&lt;pre tabindex="0" class="chroma">&lt;code class="language-promql" data-lang="promql">&lt;span class="line">&lt;span class="cl">&lt;span class="c1"># Current hit rate (0.0 to 1.0) — target &amp;gt; 0.70 with enterprise workloads&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w">&lt;/span>&lt;span class="nv">vllm&lt;/span>&lt;span class="err">:&lt;/span>&lt;span class="nv">gpu_prefix_cache_hit_rate&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w">&lt;/span>&lt;span class="c1"># TTFT by percentile — should fall when the hit rate rises&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w">&lt;/span>&lt;span class="kr">histogram_quantile&lt;/span>&lt;span class="o">(&lt;/span>&lt;span class="mf">0.50&lt;/span>&lt;span class="p">,&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="kr">rate&lt;/span>&lt;span class="o">(&lt;/span>&lt;span class="nv">vllm&lt;/span>&lt;span class="err">:&lt;/span>&lt;span class="nv">time_to_first_token_seconds_bucket&lt;/span>&lt;span class="p">[&lt;/span>&lt;span class="s">5m&lt;/span>&lt;span class="p">]&lt;/span>&lt;span class="o">))&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w">&lt;/span>&lt;span class="kr">histogram_quantile&lt;/span>&lt;span class="o">(&lt;/span>&lt;span class="mf">0.95&lt;/span>&lt;span class="p">,&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="kr">rate&lt;/span>&lt;span class="o">(&lt;/span>&lt;span class="nv">vllm&lt;/span>&lt;span class="err">:&lt;/span>&lt;span class="nv">time_to_first_token_seconds_bucket&lt;/span>&lt;span class="p">[&lt;/span>&lt;span class="s">5m&lt;/span>&lt;span class="p">]&lt;/span>&lt;span class="o">))&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>The inverse correlation between hit rate and TTFT is the proof that the cache is working. If the hit rate goes from 15% to 70% and the p50 TTFT does not change, there is a configuration problem: the cache may be disabled, or the routing may not be sending requests to the right node.&lt;/p>
&lt;p>&lt;strong>Correlation query in Grafana&lt;/strong> (two-axis panel):&lt;/p>
&lt;div class="highlight">&lt;pre tabindex="0" class="chroma">&lt;code class="language-promql" data-lang="promql">&lt;span class="line">&lt;span class="cl">&lt;span class="c1"># Left Y axis: hit rate&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w">&lt;/span>&lt;span class="nv">vllm&lt;/span>&lt;span class="err">:&lt;/span>&lt;span class="nv">gpu_prefix_cache_hit_rate&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w">&lt;/span>&lt;span class="c1"># Right Y axis: TTFT p50 (inverted)&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w">&lt;/span>&lt;span class="kr">histogram_quantile&lt;/span>&lt;span class="o">(&lt;/span>&lt;span class="mf">0.50&lt;/span>&lt;span class="p">,&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="kr">rate&lt;/span>&lt;span class="o">(&lt;/span>&lt;span class="nv">vllm&lt;/span>&lt;span class="err">:&lt;/span>&lt;span class="nv">time_to_first_token_seconds_bucket&lt;/span>&lt;span class="p">[&lt;/span>&lt;span class="s">5m&lt;/span>&lt;span class="p">]&lt;/span>&lt;span class="o">))&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>The inverse slope should be visible: when the hit rate drops (a spike of requests with new prompts), TTFT rises. When the hit rate stabilises (users repeating the same flow), TTFT falls.&lt;/p>
&lt;hr>
&lt;h2 id="the-impact-in-numbers">The impact in numbers&lt;/h2>
&lt;p>For a system with 100 req/min, a 512-token system prompt and a before/after hit rate:&lt;/p>
&lt;table>
&lt;thead>
&lt;tr>
&lt;th style="text-align:left">Metric&lt;/th>
&lt;th style="text-align:right">Hit rate 15%&lt;/th>
&lt;th style="text-align:right">Hit rate 75%&lt;/th>
&lt;th style="text-align:right">Difference&lt;/th>
&lt;/tr>
&lt;/thead>
&lt;tbody>
&lt;tr>
&lt;td style="text-align:left">Prefill tokens per minute&lt;/td>
&lt;td style="text-align:right">5,100&lt;/td>
&lt;td style="text-align:right">12,800 — 50% cached → 6,400 effective&lt;/td>
&lt;td style="text-align:right">−37% load&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td style="text-align:left">TTFT p50 (512 prompt + 100 suffix)&lt;/td>
&lt;td style="text-align:right">~820 ms&lt;/td>
&lt;td style="text-align:right">~180 ms (suffix only)&lt;/td>
&lt;td style="text-align:right">−78%&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td style="text-align:left">Prefill capacity freed&lt;/td>
&lt;td style="text-align:right">—&lt;/td>
&lt;td style="text-align:right">+1,200 tok/min&lt;/td>
&lt;td style="text-align:right">available for more requests&lt;/td>
&lt;/tr>
&lt;/tbody>
&lt;/table>
&lt;p>The 75% hit rate in this example is equivalent to being able to serve 37% more requests on the same hardware, because the prefill work of 3 out of every 4 requests is already done.&lt;/p>
&lt;hr>
&lt;h2 id="when-the-prefix-cache-does-not-help">When the prefix cache does not help&lt;/h2>
&lt;p>The prefix cache is ineffective in workloads where every request has a completely unique prompt: translating a different document every time, code analysis with always-different context, creative generation with no system prompt. In these cases the hit rate structurally cannot exceed 5-10% and the template engineering effort does not pay off.&lt;/p>
&lt;p>The signal: if your p99 input length is greater than the p50, you have high prompt variance and the prefix cache contributes little. If the p50 and the p99 are similar (consistent prompts), the prefix cache is the cheapest lever available.&lt;/p>
&lt;hr>
&lt;h2 id="see-also">See also&lt;/h2>
&lt;ul>
&lt;li>
&lt;p>&lt;a href="https://blog.lo0.es/en/posts/vllm-attention-backend-flashattention-flashinfer-prefill-decode-asymmetry/">FlashInfer&amp;rsquo;s cascade attention: the &lt;em>compute&lt;/em> side of the shared prefix that the prefix cache solves in &lt;em>memory&lt;/em> — attending the common prefix once instead of R times&lt;/a>&lt;/p>
&lt;/li>
&lt;li>
&lt;p>&lt;a href="https://blog.lo0.es/en/posts/optimising-prefill-vllm-knobs-ttft-will-not-forgive/">&lt;code>--enable-prefix-caching&lt;/code> and the interaction with chunked prefill: only the first chunk benefits from the cache, which affects the optimal budget for &lt;code>max-num-batched-tokens&lt;/code>&lt;/a>&lt;/p>
&lt;/li>
&lt;li>
&lt;p>&lt;a href="https://blog.lo0.es/en/posts/kv-cache-working-memory-llm-inference/">the block structure the prefix cache operates on: why the 16-token granularity matters for template design&lt;/a>&lt;/p>
&lt;/li>
&lt;li>
&lt;p>&lt;a href="https://blog.lo0.es/en/posts/vllm-batch-sizing-two-hour-grid-search/">the grid search that determines the optimal &lt;code>max-num-seqs&lt;/code>, which interacts with the number of blocks available for the cache&lt;/a>&lt;/p>
&lt;/li>
&lt;li>
&lt;p>&lt;a href="https://blog.lo0.es/en/posts/llm-inference-router-l7-switchboard/">the L7 gateway where prefix-aware routing via header is implemented&lt;/a>&lt;/p>
&lt;/li>
&lt;li>
&lt;p>&lt;a href="https://blog.lo0.es/en/posts/instrumenting-vllm-otel-measuring-optimisations/">how to configure &lt;code>gpu_prefix_cache_hit_rate&lt;/code> on the Grafana dashboard and the alert for when it falls below the target threshold&lt;/a>&lt;/p>
&lt;/li>
&lt;li>
&lt;p>&lt;a href="https://blog.lo0.es/en/posts/prefix-routing-what-litellm-does-not-do/">Prefix routing: what LiteLLM does not do&lt;/a> — the real state of KV-aware routers in September 2026, and how much they genuinely improve.&lt;/p>
&lt;/li>
&lt;/ul>
&lt;h3 id="in-this-same-series">In this same series&lt;/h3>
&lt;ul>
&lt;li>&lt;a href="https://blog.lo0.es/en/posts/vllm-batch-sizing-two-hour-grid-search/">the first optimisation in the series: the grid search of max-num-seqs × max-num-batched-tokens&lt;/a>&lt;/li>
&lt;li>&lt;a href="https://blog.lo0.es/en/posts/fp8-end-to-end-enable-measure-quality-decide/">FP8 in weights and KV cache: doubling the VRAM available for cache and measuring the quality degradation before going to production&lt;/a>&lt;/li>
&lt;li>&lt;a href="https://blog.lo0.es/en/posts/one-big-replica-or-many-small-ones-platform-decision/">TP=4×1 vs TP=2×2: the per-session routing that complements the prefix-aware routing in this article&lt;/a>&lt;/li>
&lt;/ul>
&lt;hr>
&lt;h2 id="references">References&lt;/h2>
&lt;ul>
&lt;li>&lt;a href="https://docs.vllm.ai/en/stable/design/prefix_caching/">vLLM Automatic Prefix Caching — official documentation&lt;/a>&lt;/li>
&lt;li>&lt;a href="https://docs.ray.io/en/latest/serve/llm/user-guides/prefix-aware-routing.html">Prefix-aware routing — Ray Serve&lt;/a>&lt;/li>
&lt;li>&lt;a href="https://llm-d.ai/blog/kvcache-wins-you-can-see">KV-Cache Wins You Can See — llm-d blog&lt;/a>&lt;/li>
&lt;li>&lt;a href="https://www.digitalocean.com/blog/reduce-llm-inference-costs-prefix-caching">The Inference Tax: Prefix-Aware Routing — DigitalOcean&lt;/a>&lt;/li>
&lt;li>&lt;a href="https://github.com/vllm-project/vllm/issues/24394">vLLM issue #24394: Improve Prefix Cache Hit Rate&lt;/a>&lt;/li>
&lt;/ul></description></item><item><title>Function calling and tool-augmented retrieval: the detective who knows which file to ask for</title><link>https://blog.lo0.es/en/posts/function-calling-tool-augmented-retrieval-detective-knows-which-file/</link><pubDate>Thu, 04 Jun 2026 10:00:00 +0200</pubDate><guid>https://blog.lo0.es/en/posts/function-calling-tool-augmented-retrieval-detective-knows-which-file/</guid><description>&lt;blockquote>
&lt;p>This post goes deeper into the agentic reasoning mechanism that extends the RAG described in &lt;a href="https://blog.lo0.es/en/posts/reranker-hybrid-retrieval-five-chunks-llm-reads/">RAG with reranker and hybrid retrieval&lt;/a>. The retriever invoked when the LLM chooses &lt;code>vector_search&lt;/code> is exactly the pipeline in that article. The JSON Schema that defines each tool call is &lt;a href="https://blog.lo0.es/en/posts/structured-output-bitmask-outlines-xgrammar-llguidance/">structured output&lt;/a> applied to the tool interface. And the agent&amp;rsquo;s requests pass through the &lt;a href="https://blog.lo0.es/en/posts/llm-inference-router-l7-switchboard/">L7 inference gateway&lt;/a> before reaching the model.&lt;/p>
&lt;/blockquote>
&lt;h2 id="tldr">TL;DR&lt;/h2>
&lt;p>A naive RAG always queries the same source. Function calling gives the LLM the ability to decide which tool to invoke (vector store, SQL, web search) according to what the query actually needs. The ReAct pattern chains those invocations in a reasoned loop until there is enough evidence. A 3-iteration pipeline with Llama-3.1-70B on on-premise hardware takes ≈ 1.1 s against the ≈ 300 ms of a single-step RAG; the gain is not in speed but in queries that a naive RAG simply cannot answer. The critical eval metric is &lt;strong>tool selection accuracy&lt;/strong>: the percentage of turns in which the model picks the right tool, measured over a synthetic eval set.&lt;/p>
&lt;h2 id="the-analogy-the-detective-who-knows-which-file-to-ask-for">The analogy: the detective who knows which file to ask for&lt;/h2>
&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="The detective and his sources of evidence">
&lt;style>
.db{fill:#f8f8f8;stroke:#444;stroke-width:1.4}
.dh{fill:#7aafff;stroke:#444;stroke-width:1.4}
.ds{fill:#ffd76b;stroke:#444;stroke-width:1.4}
.dg{fill:#b2e8b2;stroke:#444;stroke-width:1.4}
.dr{fill:#ffb3b3;stroke:#444;stroke-width:1.4}
.dl{font:600 13px sans-serif;fill:#222}
.dm{font:400 11px sans-serif;fill:#555}
.da{stroke:#666;stroke-width:1.5;fill:none;marker-end:url(#mda)}
.dq{stroke:#666;stroke-width:1.5;fill:none;stroke-dasharray:5 3;marker-end:url(#mda)}
&lt;/style>
&lt;defs>&lt;marker id="mda" 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;!-- Detective (LLM) in the centre -->
&lt;rect x="290" y="130" width="200" height="80" rx="8" class="dh"/>
&lt;text x="390" y="158" text-anchor="middle" class="dl">Detective (LLM)&lt;/text>
&lt;text x="390" y="176" text-anchor="middle" class="dm">reasons what evidence&lt;/text>
&lt;text x="390" y="192" text-anchor="middle" class="dm">it needs and asks for it&lt;/text>
&lt;!-- Case / Query -->
&lt;rect x="310" y="20" width="160" height="50" rx="8" class="db"/>
&lt;text x="390" y="42" text-anchor="middle" class="dl">Case (Query)&lt;/text>
&lt;text x="390" y="60" text-anchor="middle" class="dm">"how many EU contracts &amp;gt; 100k€?"&lt;/text>
&lt;path class="da" d="M390,70 L390,128"/>
&lt;!-- Vector store -->
&lt;rect x="20" y="240" width="160" height="70" rx="8" class="ds"/>
&lt;text x="100" y="263" text-anchor="middle" class="dl">Witness files&lt;/text>
&lt;text x="100" y="281" text-anchor="middle" class="dm">vector_search&lt;/text>
&lt;text x="100" y="297" text-anchor="middle" class="dm">Qdrant · 5-50 ms&lt;/text>
&lt;!-- SQL -->
&lt;rect x="310" y="240" width="160" height="70" rx="8" class="dg"/>
&lt;text x="390" y="263" text-anchor="middle" class="dl">Accounting ledger&lt;/text>
&lt;text x="390" y="281" text-anchor="middle" class="dm">sql_query&lt;/text>
&lt;text x="390" y="297" text-anchor="middle" class="dm">PostgreSQL · 10-200 ms&lt;/text>
&lt;!-- Web search -->
&lt;rect x="600" y="240" width="160" height="70" rx="8" class="dr"/>
&lt;text x="680" y="263" text-anchor="middle" class="dl">Press archive&lt;/text>
&lt;text x="680" y="281" text-anchor="middle" class="dm">web_search&lt;/text>
&lt;text x="680" y="297" text-anchor="middle" class="dm">public · 200-2000 ms&lt;/text>
&lt;!-- Arrows from the detective to the sources -->
&lt;path class="da" d="M310,190 L180,238"/>
&lt;path class="da" d="M390,210 L390,238"/>
&lt;path class="da" d="M470,190 L600,238"/>
&lt;!-- Return arrows (observations) -->
&lt;path class="dq" d="M140,240 Q200,220 300,195"/>
&lt;path class="dq" d="M390,240 L390,212"/>
&lt;path class="dq" d="M640,240 Q560,220 480,195"/>
&lt;!-- Final answer -->
&lt;rect x="310" y="20" width="160" height="50" rx="8" class="db"/>
&lt;text x="390" y="42" text-anchor="middle" class="dl">Case (Query)&lt;/text>
&lt;text x="390" y="60" text-anchor="middle" class="dm">"how many EU contracts &amp;gt; 100k€?"&lt;/text>
&lt;/svg>
&lt;/div>
&lt;p>A detective in a novel does not go to the same filing cabinet regardless of the case that lands on his desk. When he takes a case, he reasons first: what kind of evidence do I need? If there are witnesses, he asks for the statements (vector search over unstructured documents). If there are financial transactions, he asks the bank for the accounting records (SQL over the structured database). If the suspect has recent activity that the company cannot possibly have indexed, he goes to the press archive (web search). He does not consult all three sources at once on every case: he picks the one the evidence requires, receives the result, reasons again if he needs more, and only writes the report when he has enough evidence.&lt;/p>
&lt;p>A bad detective always goes to the same filing cabinet. A naive RAG is that bad detective: it vectorises the query, goes to the vector store, and returns whatever it finds even if the question was &amp;ldquo;how many contracts?&amp;rdquo;, something no PDF chunk can answer better than a &lt;code>COUNT(*)&lt;/code> in SQL.&lt;/p>
&lt;p>&lt;strong>Function calling is giving the LLM the ability to reason about which source to ask for, and to invoke it in a structured way.&lt;/strong> The analogy has three edges worth keeping in mind:&lt;/p>
&lt;ol>
&lt;li>The detective does not improvise the file he asks for: there is a catalogue of available sources with a description of what each one contains. The tool description in the system prompt does that job.&lt;/li>
&lt;li>The detective can ask for several pieces of evidence at once if they are independent (parallel tool calling).&lt;/li>
&lt;li>The detective knows when to stop: if after N rounds he reaches no conclusion, he declares that he does not have enough evidence. The agent has an iteration limit for the same reason.&lt;/li>
&lt;/ol>
&lt;h2 id="what-function-calling-is-the-anatomy-of-a-tool-call">What function calling is: the anatomy of a tool call&lt;/h2>
&lt;p>Function calling, also called tool use, is a mechanism by which the LLM, instead of generating free text as its answer, generates a structured JSON object representing a tool invocation. The system intercepts that JSON, executes the real tool, and returns the result as a message with the &lt;code>tool&lt;/code> role in the conversation.&lt;/p>
&lt;h3 id="defining-tools-in-the-system-prompt">Defining tools in the system prompt&lt;/h3>
&lt;p>Each tool is defined through a JSON Schema that specifies name, description and parameters. This JSON Schema is exactly the same mechanism described in &lt;a href="https://blog.lo0.es/en/posts/structured-output-bitmask-outlines-xgrammar-llguidance/">structured output&lt;/a>, applied here to the tool interface:&lt;/p>
&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;tools&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="p">{&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="nt">&amp;#34;name&amp;#34;&lt;/span>&lt;span class="p">:&lt;/span> &lt;span class="s2">&amp;#34;vector_search&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;description&amp;#34;&lt;/span>&lt;span class="p">:&lt;/span> &lt;span class="s2">&amp;#34;Search internal company documents about policies, contracts and procedures. Use when the query requires unstructured text, document context or semantic similarity.&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;parameters&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;type&amp;#34;&lt;/span>&lt;span class="p">:&lt;/span> &lt;span class="s2">&amp;#34;object&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;properties&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;query&amp;#34;&lt;/span>&lt;span class="p">:&lt;/span> &lt;span class="p">{&lt;/span>&lt;span class="nt">&amp;#34;type&amp;#34;&lt;/span>&lt;span class="p">:&lt;/span> &lt;span class="s2">&amp;#34;string&amp;#34;&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="nt">&amp;#34;description&amp;#34;&lt;/span>&lt;span class="p">:&lt;/span> &lt;span class="s2">&amp;#34;Natural language search query&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;top_k&amp;#34;&lt;/span>&lt;span class="p">:&lt;/span> &lt;span class="p">{&lt;/span>&lt;span class="nt">&amp;#34;type&amp;#34;&lt;/span>&lt;span class="p">:&lt;/span> &lt;span class="s2">&amp;#34;integer&amp;#34;&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="nt">&amp;#34;default&amp;#34;&lt;/span>&lt;span class="p">:&lt;/span> &lt;span class="mi">5&lt;/span>&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;span class="line">&lt;span class="cl"> &lt;span class="nt">&amp;#34;required&amp;#34;&lt;/span>&lt;span class="p">:&lt;/span> &lt;span class="p">[&lt;/span>&lt;span class="s2">&amp;#34;query&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="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;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;name&amp;#34;&lt;/span>&lt;span class="p">:&lt;/span> &lt;span class="s2">&amp;#34;sql_query&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;description&amp;#34;&lt;/span>&lt;span class="p">:&lt;/span> &lt;span class="s2">&amp;#34;Query the SQL database for structured metrics, counts, aggregations and financial data. Use when the query requires exact numbers, filters, sums or joins over structured records.&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;parameters&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;type&amp;#34;&lt;/span>&lt;span class="p">:&lt;/span> &lt;span class="s2">&amp;#34;object&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;properties&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;query&amp;#34;&lt;/span>&lt;span class="p">:&lt;/span> &lt;span class="p">{&lt;/span>&lt;span class="nt">&amp;#34;type&amp;#34;&lt;/span>&lt;span class="p">:&lt;/span> &lt;span class="s2">&amp;#34;string&amp;#34;&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="nt">&amp;#34;description&amp;#34;&lt;/span>&lt;span class="p">:&lt;/span> &lt;span class="s2">&amp;#34;Parameterized SQL query with $1, $2 placeholders&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;params&amp;#34;&lt;/span>&lt;span class="p">:&lt;/span> &lt;span class="p">{&lt;/span>&lt;span class="nt">&amp;#34;type&amp;#34;&lt;/span>&lt;span class="p">:&lt;/span> &lt;span class="s2">&amp;#34;array&amp;#34;&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="nt">&amp;#34;items&amp;#34;&lt;/span>&lt;span class="p">:&lt;/span> &lt;span class="p">{},&lt;/span> &lt;span class="nt">&amp;#34;description&amp;#34;&lt;/span>&lt;span class="p">:&lt;/span> &lt;span class="s2">&amp;#34;Parameter values for the placeholders&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="p">},&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="nt">&amp;#34;required&amp;#34;&lt;/span>&lt;span class="p">:&lt;/span> &lt;span class="p">[&lt;/span>&lt;span class="s2">&amp;#34;query&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="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;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;name&amp;#34;&lt;/span>&lt;span class="p">:&lt;/span> &lt;span class="s2">&amp;#34;web_search&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;description&amp;#34;&lt;/span>&lt;span class="p">:&lt;/span> &lt;span class="s2">&amp;#34;Search public web for real-time information, recent news or current prices. Use only when data is public and not covered by internal sources.&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;parameters&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;type&amp;#34;&lt;/span>&lt;span class="p">:&lt;/span> &lt;span class="s2">&amp;#34;object&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;properties&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;query&amp;#34;&lt;/span>&lt;span class="p">:&lt;/span> &lt;span class="p">{&lt;/span>&lt;span class="nt">&amp;#34;type&amp;#34;&lt;/span>&lt;span class="p">:&lt;/span> &lt;span class="s2">&amp;#34;string&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="p">},&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="nt">&amp;#34;required&amp;#34;&lt;/span>&lt;span class="p">:&lt;/span> &lt;span class="p">[&lt;/span>&lt;span class="s2">&amp;#34;query&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="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;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;h3 id="the-life-cycle-of-a-tool-call">The life cycle of a tool call&lt;/h3>
&lt;p>When the LLM decides to invoke a tool, the message it generates in place of free text has this structure (OpenAI-compatible format, the same one vLLM supports):&lt;/p>
&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;role&amp;#34;&lt;/span>&lt;span class="p">:&lt;/span> &lt;span class="s2">&amp;#34;assistant&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;tool_calls&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="p">{&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="nt">&amp;#34;id&amp;#34;&lt;/span>&lt;span class="p">:&lt;/span> &lt;span class="s2">&amp;#34;call_01&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;type&amp;#34;&lt;/span>&lt;span class="p">:&lt;/span> &lt;span class="s2">&amp;#34;function&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;function&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;name&amp;#34;&lt;/span>&lt;span class="p">:&lt;/span> &lt;span class="s2">&amp;#34;sql_query&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;arguments&amp;#34;&lt;/span>&lt;span class="p">:&lt;/span> &lt;span class="s2">&amp;#34;{\&amp;#34;query\&amp;#34;: \&amp;#34;SELECT COUNT(*), SUM(amount) FROM contracts WHERE amount &amp;gt; $1 AND year = $2 AND provider_region = $3\&amp;#34;, \&amp;#34;params\&amp;#34;: [100000, 2025, \&amp;#34;EU\&amp;#34;]}&amp;#34;&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;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>The system executes the tool and returns:&lt;/p>
&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 class="nt">&amp;#34;role&amp;#34;&lt;/span>&lt;span class="p">:&lt;/span> &lt;span class="s2">&amp;#34;tool&amp;#34;&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="nt">&amp;#34;tool_call_id&amp;#34;&lt;/span>&lt;span class="p">:&lt;/span> &lt;span class="s2">&amp;#34;call_01&amp;#34;&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="nt">&amp;#34;content&amp;#34;&lt;/span>&lt;span class="p">:&lt;/span> &lt;span class="s2">&amp;#34;{\&amp;#34;count\&amp;#34;: 47, \&amp;#34;total\&amp;#34;: 8300000}&amp;#34;&lt;/span>&lt;span class="p">}&lt;/span>
&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>The LLM receives that message as a continuation of the conversation and decides whether it needs more information or can generate the final answer.&lt;/p>
&lt;h3 id="support-in-oss-models">Support in OSS models&lt;/h3>
&lt;p>In 2026, native function calling support (not emulated via system prompt) is available in:&lt;/p>
&lt;ul>
&lt;li>&lt;strong>Llama 3.1 / 3.3+&lt;/strong>: native tool call format, supported in vLLM with &lt;code>--enable-auto-tool-choice --tool-call-parser llama3_json&lt;/code>&lt;/li>
&lt;li>&lt;strong>Qwen 2.5+&lt;/strong>: native support with &lt;code>--tool-call-parser hermes&lt;/code>&lt;/li>
&lt;li>&lt;strong>Mistral NeMo / Mistral 7B Instruct v0.3+&lt;/strong>: native format with &lt;code>--tool-call-parser mistral&lt;/code>&lt;/li>
&lt;li>&lt;strong>Qwen3 (2025+)&lt;/strong>: extended native support with parallel tool calling&lt;/li>
&lt;/ul>
&lt;p>Without &lt;code>--enable-auto-tool-choice&lt;/code>, vLLM can force the tool call format via guided decoding (JSON Schema), but the ability to &lt;em>decide when to invoke&lt;/em> a tool versus generating free text requires specific model training.&lt;/p>
&lt;h2 id="the-react-pattern-reasoning--acting">The ReAct pattern: Reasoning + Acting&lt;/h2>
&lt;p>ReAct (Yao et al., 2022) is the loop that turns an LLM with tools into an agent with reasoning. The central idea is that the model interleaves &lt;strong>thoughts&lt;/strong> (explicit reasoning about what to do) with &lt;strong>actions&lt;/strong> (tool invocations) and &lt;strong>observations&lt;/strong> (results received). This loop repeats until the model reckons it has enough information to answer, or until the iteration limit is reached.&lt;/p>
&lt;pre tabindex="0">&lt;code>Thought → Action → Observation → Thought → Action → Observation → ... → Response
&lt;/code>&lt;/pre>&lt;h3 id="a-full-react-trace-a-real-production-query">A full ReAct trace: a real production query&lt;/h3>
&lt;p>Query: &lt;em>&amp;ldquo;How many contracts above 100k€ did we sign in 2025 with EU providers, and which special conditions recur?&amp;rdquo;&lt;/em>&lt;/p>
&lt;p>This query requires two different sources: the exact numbers live in SQL, and the context of special conditions lives in the contract PDFs indexed in the vector store.&lt;/p>
&lt;hr>
&lt;p>&lt;strong>Iteration 1&lt;/strong>&lt;/p>
&lt;p>&lt;em>Thought:&lt;/em> The query asks for aggregated data (count and sum) with exact filters over structured fields (amount, year, region). This is a SQL query, not a semantic search.&lt;/p>
&lt;p>&lt;em>Action:&lt;/em> &lt;code>sql_query&lt;/code>&lt;/p>
&lt;div class="highlight">&lt;pre tabindex="0" class="chroma">&lt;code class="language-sql" data-lang="sql">&lt;span class="line">&lt;span class="cl">&lt;span class="k">SELECT&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="k">COUNT&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="o">*&lt;/span>&lt;span class="p">)&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="k">AS&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="n">num_contratos&lt;/span>&lt;span class="p">,&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="k">SUM&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="n">amount&lt;/span>&lt;span class="p">)&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="k">AS&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="n">total_eur&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w">&lt;/span>&lt;span class="k">FROM&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="n">contracts&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w">&lt;/span>&lt;span class="k">WHERE&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="n">amount&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="o">&amp;gt;&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="err">$&lt;/span>&lt;span class="mi">1&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="k">AND&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="k">year&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="o">=&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="err">$&lt;/span>&lt;span class="mi">2&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="k">AND&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="n">provider_region&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="o">=&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="err">$&lt;/span>&lt;span class="mi">3&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>params: &lt;code>[100000, 2025, &amp;quot;EU&amp;quot;]&lt;/code>&lt;/p>
&lt;p>&lt;em>Observation:&lt;/em> &lt;code>{&amp;quot;num_contratos&amp;quot;: 47, &amp;quot;total_eur&amp;quot;: 8300000}&lt;/code>&lt;/p>
&lt;hr>
&lt;p>&lt;strong>Iteration 2&lt;/strong>&lt;/p>
&lt;p>&lt;em>Thought:&lt;/em> I have the numbers. Now I need the special conditions that recur in these contracts. That kind of information is in the free text of the PDFs, not in the structured table. I go to the vector store.&lt;/p>
&lt;p>&lt;em>Action:&lt;/em> &lt;code>vector_search&lt;/code>&lt;/p>
&lt;pre tabindex="0">&lt;code>&amp;#34;EU provider contracts 2025 amount above 100k special conditions recurring clauses&amp;#34;
&lt;/code>&lt;/pre>&lt;p>top_k: 5&lt;/p>
&lt;p>&lt;em>Observation:&lt;/em>&lt;/p>
&lt;pre tabindex="0">&lt;code>[chunk_1] Provider A contract — clause 8.3: late-delivery penalty of 2% per month on the invoiced amount...
[chunk_2] Provider C contract — clause 7.1: price revision linked to the euro area CPI capped at 4% per year...
[chunk_3] Provider B contract — clause 8.3: late-delivery penalty of 2% per month...
&lt;/code>&lt;/pre>&lt;hr>
&lt;p>&lt;em>Thought:&lt;/em> I have enough data from both sources. Clause 8.3 on late-delivery penalties appears in at least two of the retrieved contracts. I can synthesise the answer.&lt;/p>
&lt;p>&lt;em>Response:&lt;/em> &amp;ldquo;In 2025 you signed &lt;strong>47 contracts&lt;/strong> with EU providers above 100,000 €, for a cumulative total of &lt;strong>8.3 M€&lt;/strong>. The special condition that appears most often in the higher-value contracts is the late-delivery penalty clause (8.3 in the contract templates of Provider A and B): 2% per month on the invoiced amount. The price revision linked to the euro area CPI, capped at 4% per year (clause 7.1), also recurs.&amp;rdquo;&lt;/p>
&lt;hr>
&lt;p>This trace has &lt;strong>2 iterations&lt;/strong>. A more complex query (comparing with the previous year, cross-referencing with payment status) would add further iterations. Common practice is to set a limit of &lt;strong>5–10 iterations&lt;/strong> maximum to avoid loops.&lt;/p>
&lt;h2 id="the-three-sources-and-when-to-use-each-one">The three sources and when to use each one&lt;/h2>
&lt;p>The choice of source is neither arbitrary nor left solely to the LLM: the architecture defines which tools exist and how they are described. The table below summarises the selection criteria:&lt;/p>
&lt;table>
&lt;thead>
&lt;tr>
&lt;th>Characteristic&lt;/th>
&lt;th>Vector store (Qdrant)&lt;/th>
&lt;th>SQL / structured (PostgreSQL)&lt;/th>
&lt;th>Web search&lt;/th>
&lt;/tr>
&lt;/thead>
&lt;tbody>
&lt;tr>
&lt;td>&lt;strong>Data type&lt;/strong>&lt;/td>
&lt;td>Free text, documents, PDFs&lt;/td>
&lt;td>Tables with a fixed schema&lt;/td>
&lt;td>Public pages, news&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>&lt;strong>Structure&lt;/strong>&lt;/td>
&lt;td>Unstructured&lt;/td>
&lt;td>Highly structured&lt;/td>
&lt;td>Semi-structured&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>&lt;strong>Temporality&lt;/strong>&lt;/td>
&lt;td>Static index (periodic refresh)&lt;/td>
&lt;td>Real time (transactional)&lt;/td>
&lt;td>Real time (crawl)&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>&lt;strong>Typical latency&lt;/strong>&lt;/td>
&lt;td>5–50 ms&lt;/td>
&lt;td>10–200 ms&lt;/td>
&lt;td>200–2,000 ms&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>&lt;strong>Privacy&lt;/strong>&lt;/td>
&lt;td>Internal data, full sovereignty&lt;/td>
&lt;td>Internal data, full sovereignty&lt;/td>
&lt;td>Public data only&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>&lt;strong>Natural query&lt;/strong>&lt;/td>
&lt;td>Yes (natural language → embedding)&lt;/td>
&lt;td>No (parameterised SQL)&lt;/td>
&lt;td>Yes&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>&lt;strong>Exact aggregations&lt;/strong>&lt;/td>
&lt;td>No&lt;/td>
&lt;td>Yes (&lt;code>COUNT&lt;/code>, &lt;code>SUM&lt;/code>, &lt;code>GROUP BY&lt;/code>)&lt;/td>
&lt;td>No&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>&lt;strong>When to use&lt;/strong>&lt;/td>
&lt;td>Document context, semantics, PDFs&lt;/td>
&lt;td>Metrics, counts, exact filters, joins&lt;/td>
&lt;td>Data that does not exist internally and is public&lt;/td>
&lt;/tr>
&lt;/tbody>
&lt;/table>
&lt;p>The most important rule of thumb: if the question contains words like &amp;ldquo;how many&amp;rdquo;, &amp;ldquo;sum&amp;rdquo;, &amp;ldquo;total&amp;rdquo;, &amp;ldquo;more than X&amp;rdquo;, &amp;ldquo;in year Y&amp;rdquo; and the data is in a structured table, the right answer is &lt;code>sql_query&lt;/code>. If the question asks for context, explanations, clauses, procedures or examples from documents, the answer is &lt;code>vector_search&lt;/code>. If it asks for the current price of something or recent news about a third party, &lt;code>web_search&lt;/code>, but only if no data sovereignty is involved.&lt;/p>
&lt;h2 id="tool-routing-how-the-llm-picks-the-right-tool">Tool routing: how the LLM picks the right tool&lt;/h2>
&lt;p>The description of each tool in the system prompt is &lt;strong>the most critical factor&lt;/strong> for routing accuracy. An LLM with good function calling capabilities can still choose badly if the descriptions are ambiguous or overlap.&lt;/p>
&lt;h3 id="descriptions-that-work-versus-those-that-do-not">Descriptions that work versus those that do not&lt;/h3>
&lt;p>&lt;strong>Weak description&lt;/strong> (leads the LLM to use the wrong tool):&lt;/p>
&lt;pre tabindex="0">&lt;code>&amp;#34;search_docs&amp;#34; — Searches for information in the available sources.
&amp;#34;query_data&amp;#34; — Gets data from the system.
&lt;/code>&lt;/pre>&lt;p>&lt;strong>Strong description&lt;/strong> (delimits precisely when to use each one):&lt;/p>
&lt;pre tabindex="0">&lt;code>&amp;#34;vector_search&amp;#34; — Search internal company documents about policies, contracts and procedures.
Use when the query requires unstructured text, document context or semantic
similarity. NOT for counts, sums or exact filters.
&amp;#34;sql_query&amp;#34; — Query the SQL database for structured metrics, counts, aggregations and
financial data. Use when the query requires exact numbers, filters, sums or
joins over structured records. NOT for finding document context.
&lt;/code>&lt;/pre>&lt;p>The difference lies in two elements: (1) positive use-case examples, and (2) explicit exclusions with &lt;code>NOT for&lt;/code>. Both reduce the semantic overlap between tools and improve tool selection accuracy.&lt;/p>
&lt;h3 id="parallel-tool-calling">Parallel tool calling&lt;/h3>
&lt;p>When two tools are independent of each other, that is, when the result of one does not affect the query of the other, the LLM can invoke them simultaneously in the same turn:&lt;/p>
&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;tool_calls&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="p">{&lt;/span>&lt;span class="nt">&amp;#34;id&amp;#34;&lt;/span>&lt;span class="p">:&lt;/span> &lt;span class="s2">&amp;#34;call_01&amp;#34;&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="nt">&amp;#34;function&amp;#34;&lt;/span>&lt;span class="p">:&lt;/span> &lt;span class="p">{&lt;/span>&lt;span class="nt">&amp;#34;name&amp;#34;&lt;/span>&lt;span class="p">:&lt;/span> &lt;span class="s2">&amp;#34;sql_query&amp;#34;&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="nt">&amp;#34;arguments&amp;#34;&lt;/span>&lt;span class="p">:&lt;/span> &lt;span class="s2">&amp;#34;...&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="p">{&lt;/span>&lt;span class="nt">&amp;#34;id&amp;#34;&lt;/span>&lt;span class="p">:&lt;/span> &lt;span class="s2">&amp;#34;call_02&amp;#34;&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="nt">&amp;#34;function&amp;#34;&lt;/span>&lt;span class="p">:&lt;/span> &lt;span class="p">{&lt;/span>&lt;span class="nt">&amp;#34;name&amp;#34;&lt;/span>&lt;span class="p">:&lt;/span> &lt;span class="s2">&amp;#34;vector_search&amp;#34;&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="nt">&amp;#34;arguments&amp;#34;&lt;/span>&lt;span class="p">:&lt;/span> &lt;span class="s2">&amp;#34;...&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="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>The system executes both in parallel and returns both observations before the LLM&amp;rsquo;s next turn. This cuts total latency when the queries are independent: instead of 2 sequential iterations (2 × tool_latency), you pay 1 × max(sql_latency, vector_latency). For the detective example: if he needs both the accounting records and the witness statements to answer, he can ask for them at the same time.&lt;/p>
&lt;h3 id="tool-selection-accuracy-the-eval-metric">Tool selection accuracy: the eval metric&lt;/h3>
&lt;p>&lt;strong>Tool selection accuracy&lt;/strong> is the percentage of turns in which the LLM picks the right tool given a set of evaluated queries:&lt;/p>
\[
\text{TSA} = \frac{\text{turns with correct tool chosen}}{\text{total turns with expected tool call}}
\]
&lt;p>It is measured over a synthetic eval set built with &lt;code>(query, expected_tool, expected_args)&lt;/code> triples. An example of a minimal eval set:&lt;/p>
&lt;table>
&lt;thead>
&lt;tr>
&lt;th>Query&lt;/th>
&lt;th>Expected tool&lt;/th>
&lt;th>Failure indicator&lt;/th>
&lt;/tr>
&lt;/thead>
&lt;tbody>
&lt;tr>
&lt;td>&amp;ldquo;How many orders in March?&amp;rdquo;&lt;/td>
&lt;td>&lt;code>sql_query&lt;/code>&lt;/td>
&lt;td>LLM uses &lt;code>vector_search&lt;/code>&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>&amp;ldquo;What does the returns policy say?&amp;rdquo;&lt;/td>
&lt;td>&lt;code>vector_search&lt;/code>&lt;/td>
&lt;td>LLM uses &lt;code>sql_query&lt;/code>&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>&amp;ldquo;What is the price of copper today?&amp;rdquo;&lt;/td>
&lt;td>&lt;code>web_search&lt;/code>&lt;/td>
&lt;td>LLM uses &lt;code>vector_search&lt;/code>&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>&amp;ldquo;Sum the Q3 contracts&amp;rdquo;&lt;/td>
&lt;td>&lt;code>sql_query&lt;/code>&lt;/td>
&lt;td>LLM uses &lt;code>vector_search&lt;/code>&lt;/td>
&lt;/tr>
&lt;/tbody>
&lt;/table>
&lt;p>A TSA &amp;lt; 0.85 in a production agent is a sign that the tool descriptions need revising before the model does. For more detail on how to build these evals, see &lt;a href="https://blog.lo0.es/en/posts/evals-llm-layer-after-tracing/">LLM evals&lt;/a>.&lt;/p>
&lt;h2 id="error-handling-in-tool-calls">Error handling in tool calls&lt;/h2>
&lt;h3 id="sql-injection-via-prompt">SQL injection via prompt&lt;/h3>
&lt;p>The most serious risk in tool-augmented retrieval is that the LLM generates malicious SQL, either because a user induced it via prompt injection, or because the model hallucinated a destructive query. This attack vector is covered in detail in &lt;a href="https://blog.lo0.es/en/posts/llm-guard-anatomy-scanners-langfuse-vllm-litellm/">LLM Guard&lt;/a>, but the minimum rules on the tool side are:&lt;/p>
&lt;p>&lt;strong>Rule 1: parameterised queries always, never direct interpolation.&lt;/strong>&lt;/p>
&lt;div class="highlight">&lt;pre tabindex="0" class="chroma">&lt;code class="language-python" data-lang="python">&lt;span class="line">&lt;span class="cl">&lt;span class="c1"># NEVER this:&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="n">cursor&lt;/span>&lt;span class="o">.&lt;/span>&lt;span class="n">execute&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="sa">f&lt;/span>&lt;span class="s2">&amp;#34;SELECT * FROM contracts WHERE provider = &amp;#39;&lt;/span>&lt;span class="si">{&lt;/span>&lt;span class="n">llm_output&lt;/span>&lt;span class="si">}&lt;/span>&lt;span class="s2">&amp;#39;&amp;#34;&lt;/span>&lt;span class="p">)&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="c1"># Always this:&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="n">cursor&lt;/span>&lt;span class="o">.&lt;/span>&lt;span class="n">execute&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="s2">&amp;#34;SELECT * FROM contracts WHERE provider = $1&amp;#34;&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="p">[&lt;/span>&lt;span class="n">llm_output&lt;/span>&lt;span class="p">])&lt;/span>
&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>&lt;strong>Rule 2: DB user with minimum permissions.&lt;/strong> The user the agent runs SQL as must have &lt;code>SELECT&lt;/code> on the necessary tables and nothing else. No &lt;code>DROP&lt;/code>, &lt;code>INSERT&lt;/code>, &lt;code>UPDATE&lt;/code> or &lt;code>DELETE&lt;/code>.&lt;/p>
&lt;p>&lt;strong>Rule 3: table allowlist.&lt;/strong> The system validates that the query generated by the LLM only references tables on an allowlist before executing it.&lt;/p>
&lt;p>&lt;strong>Rule 4: per-query timeout.&lt;/strong> Queries that block for too long, potentially induced to DoS the database, are cancelled with a configured timeout.&lt;/p>
&lt;h3 id="rate-limits-timeouts-and-tool-errors">Rate limits, timeouts and tool errors&lt;/h3>
&lt;p>When a tool fails, the error is returned to the LLM as an observation:&lt;/p>
&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 class="nt">&amp;#34;role&amp;#34;&lt;/span>&lt;span class="p">:&lt;/span> &lt;span class="s2">&amp;#34;tool&amp;#34;&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="nt">&amp;#34;tool_call_id&amp;#34;&lt;/span>&lt;span class="p">:&lt;/span> &lt;span class="s2">&amp;#34;call_01&amp;#34;&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="nt">&amp;#34;content&amp;#34;&lt;/span>&lt;span class="p">:&lt;/span> &lt;span class="s2">&amp;#34;{\&amp;#34;error\&amp;#34;: \&amp;#34;timeout after 5s\&amp;#34;, \&amp;#34;tool\&amp;#34;: \&amp;#34;web_search\&amp;#34;}&amp;#34;&lt;/span>&lt;span class="p">}&lt;/span>
&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>The system prompt must instruct the LLM on what to do in this case:&lt;/p>
&lt;pre tabindex="0">&lt;code>If a tool returns an error or is unavailable, acknowledge the limitation in your response.
Do not retry more than once. If web_search is unavailable, state that real-time data
is not accessible at this moment and answer with available internal sources only.
&lt;/code>&lt;/pre>&lt;p>This stops the agent from entering retry loops and handles graceful degradation: if &lt;code>web_search&lt;/code> is unavailable, it answers with what it has in the internal sources.&lt;/p>
&lt;h2 id="diagram-of-the-react-loop-with-the-three-sources">Diagram of the ReAct loop with the three sources&lt;/h2>
&lt;div class="diagram" style="max-width:820px;margin:1.5rem auto;">
&lt;svg viewBox="0 0 820 480" xmlns="http://www.w3.org/2000/svg" role="img" aria-label="ReAct loop with three data sources">
&lt;style>
.rb{fill:#f8f8f8;stroke:#444;stroke-width:1.4}
.rh{fill:#7aafff;stroke:#444;stroke-width:1.4}
.ry{fill:#ffd76b;stroke:#444;stroke-width:1.4}
.rg{fill:#b2e8b2;stroke:#444;stroke-width:1.4}
.rr{fill:#ffb3b3;stroke:#444;stroke-width:1.4}
.rp{fill:#e0c8ff;stroke:#444;stroke-width:1.4}
.rl{font:600 13px sans-serif;fill:#222}
.rs{font:400 11px sans-serif;fill:#555}
.ri{font:italic 11px sans-serif;fill:#555}
.ra{stroke:#555;stroke-width:1.5;fill:none;marker-end:url(#mra)}
.rloop{stroke:#999;stroke-width:1.2;fill:none;stroke-dasharray:5 3;marker-end:url(#mra)}
&lt;/style>
&lt;defs>&lt;marker id="mra" 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="#555"/>&lt;/marker>&lt;/defs>
&lt;!-- Query input -->
&lt;rect x="310" y="10" width="200" height="50" rx="8" class="rb"/>
&lt;text x="410" y="32" text-anchor="middle" class="rl">User query&lt;/text>
&lt;text x="410" y="50" text-anchor="middle" class="rs">message with user role&lt;/text>
&lt;!-- LLM: Thought -->
&lt;rect x="290" y="90" width="240" height="60" rx="8" class="rh"/>
&lt;text x="410" y="114" text-anchor="middle" class="rl">LLM — Thought&lt;/text>
&lt;text x="410" y="132" text-anchor="middle" class="rs">reasons what it needs&lt;/text>
&lt;text x="410" y="146" text-anchor="middle" class="rs">and which tool to invoke&lt;/text>
&lt;!-- Action -->
&lt;rect x="290" y="185" width="240" height="50" rx="8" class="ry"/>
&lt;text x="410" y="207" text-anchor="middle" class="rl">Action&lt;/text>
&lt;text x="410" y="223" text-anchor="middle" class="rs">generates tool_call JSON&lt;/text>
&lt;!-- Tool router -->
&lt;rect x="290" y="265" width="240" height="50" rx="8" class="rp"/>
&lt;text x="410" y="287" text-anchor="middle" class="rl">Tool router (system)&lt;/text>
&lt;text x="410" y="303" text-anchor="middle" class="rs">dispatches the call&lt;/text>
&lt;!-- Three sources -->
&lt;rect x="30" y="355" width="160" height="70" rx="8" class="ry"/>
&lt;text x="110" y="378" text-anchor="middle" class="rl">vector_search&lt;/text>
&lt;text x="110" y="396" text-anchor="middle" class="rs">Qdrant&lt;/text>
&lt;text x="110" y="412" text-anchor="middle" class="rs">5–50 ms&lt;/text>
&lt;rect x="330" y="355" width="160" height="70" rx="8" class="rg"/>
&lt;text x="410" y="378" text-anchor="middle" class="rl">sql_query&lt;/text>
&lt;text x="410" y="396" text-anchor="middle" class="rs">PostgreSQL&lt;/text>
&lt;text x="410" y="412" text-anchor="middle" class="rs">10–200 ms&lt;/text>
&lt;rect x="630" y="355" width="160" height="70" rx="8" class="rr"/>
&lt;text x="710" y="378" text-anchor="middle" class="rl">web_search&lt;/text>
&lt;text x="710" y="396" text-anchor="middle" class="rs">external API&lt;/text>
&lt;text x="710" y="412" text-anchor="middle" class="rs">200–2,000 ms&lt;/text>
&lt;!-- Observation -->
&lt;rect x="290" y="355" width="0" height="0" rx="8"/>
&lt;!-- Main arrows -->
&lt;path class="ra" d="M410,60 L410,88"/>
&lt;path class="ra" d="M410,150 L410,183"/>
&lt;path class="ra" d="M410,235 L410,263"/>
&lt;path class="ra" d="M370,315 L190,353"/>
&lt;path class="ra" d="M410,315 L410,353"/>
&lt;path class="ra" d="M450,315 L630,353"/>
&lt;!-- Observation arrows back to the LLM (dashed lines) -->
&lt;path class="rloop" d="M110,355 Q60,260 280,145"/>
&lt;path class="rloop" d="M410,355 Q510,310 530,150"/>
&lt;path class="rloop" d="M710,355 Q780,260 540,145"/>
&lt;!-- Observation labels -->
&lt;text x="60" y="255" class="ri">Observation&lt;/text>
&lt;text x="548" y="255" class="ri">Observation&lt;/text>
&lt;text x="726" y="255" class="ri">Observation&lt;/text>
&lt;!-- Final answer -->
&lt;rect x="600" y="90" width="190" height="60" rx="8" class="rg"/>
&lt;text x="695" y="114" text-anchor="middle" class="rl">Response&lt;/text>
&lt;text x="695" y="132" text-anchor="middle" class="rs">when the LLM has&lt;/text>
&lt;text x="695" y="148" text-anchor="middle" class="rs">enough evidence&lt;/text>
&lt;!-- Arrow to the answer -->
&lt;path class="ra" d="M530,120 L598,120"/>
&lt;!-- Iteration limit -->
&lt;rect x="0" y="185" width="240" height="50" rx="8" class="rb"/>
&lt;text x="120" y="207" text-anchor="middle" class="rl">Iteration limit&lt;/text>
&lt;text x="120" y="223" text-anchor="middle" class="rs">max. 5–10 turns&lt;/text>
&lt;path class="rloop" d="M290,210 L242,210"/>
&lt;/svg>
&lt;/div>
&lt;h2 id="latency-maths-of-the-react-pipeline">Latency maths of the ReAct pipeline&lt;/h2>
&lt;p>Each iteration of the ReAct loop has three latency components:&lt;/p>
\[
T_{\text{iter}} = \text{TTFT}_{\text{LLM}} + T_{\text{tool}} + \Delta_{\text{context}}
\]
&lt;p>where:&lt;/p>
&lt;ul>
&lt;li>&lt;strong>TTFT_LLM&lt;/strong>: time to the LLM&amp;rsquo;s first token (dominated by the prefill of the accumulated context)&lt;/li>
&lt;li>&lt;strong>T_tool&lt;/strong>: tool execution time&lt;/li>
&lt;li>&lt;strong>Δ_context&lt;/strong>: overhead of the growing context window (each iteration adds the previous output to the context)&lt;/li>
&lt;/ul>
&lt;h3 id="reference-values-llama-31-70b-on-4h100-sxm-320-gb-nvlink">Reference values: Llama-3.1-70B on 4×H100 SXM (320 GB, NVLink)&lt;/h3>
&lt;p>With Llama-3.1-70B in FP8 on a node with 4×H100 SXM (320 GB HBM3, NVLink 900 GB/s), the typical values in production are:&lt;/p>
&lt;table>
&lt;thead>
&lt;tr>
&lt;th>Component&lt;/th>
&lt;th>Value&lt;/th>
&lt;/tr>
&lt;/thead>
&lt;tbody>
&lt;tr>
&lt;td>TTFT with context &amp;lt; 4k tokens&lt;/td>
&lt;td>≈ 150 ms&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>TTFT with context 8k tokens&lt;/td>
&lt;td>≈ 220 ms&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>&lt;code>sql_query&lt;/code> (simple query, indexed)&lt;/td>
&lt;td>≈ 50 ms&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>&lt;code>vector_search&lt;/code> (top-5, Qdrant in RAM)&lt;/td>
&lt;td>≈ 20 ms&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>&lt;code>web_search&lt;/code> (external API)&lt;/td>
&lt;td>≈ 600 ms&lt;/td>
&lt;/tr>
&lt;/tbody>
&lt;/table>
&lt;h3 id="total-latency-by-number-of-iterations">Total latency by number of iterations&lt;/h3>
&lt;p>&lt;strong>1-iteration pipeline&lt;/strong> (simple query, a single tool):&lt;/p>
\[
T_1 = 150 + 50 = 200 \text{ ms} + \text{final synthesis} \approx 200 + 300 = 500 \text{ ms}
\]
&lt;p>&lt;strong>2-iteration pipeline&lt;/strong> (SQL + vector_search in sequence):&lt;/p>
\[
T_2 = (150 + 50) + (180 + 20) + 400 = 800 \text{ ms}
\]
&lt;p>The context in the second iteration already includes the result of the first, so TTFT rises slightly to ≈ 180 ms.&lt;/p>
&lt;p>&lt;strong>3-iteration pipeline&lt;/strong> (the most common case in complex queries):&lt;/p>
\[
T_3 = (150 + 50) + (180 + 20) + (200 + 50) + 450 \approx 1,100 \text{ ms}
\]
&lt;p>&lt;strong>Parallel tool calling&lt;/strong> (SQL + vector_search in parallel, a single iteration):&lt;/p>
\[
T_{\text{parallel}} = 150 + \max(50, 20) + 400 = 600 \text{ ms}
\]
&lt;p>When the two queries are independent, parallel tool calling trims latency from ≈ 800 ms to ≈ 600 ms: a 25% improvement over the 2 sequential iterations case.&lt;/p>
&lt;h3 id="comparison-with-a-naive-rag">Comparison with a naive RAG&lt;/h3>
&lt;table>
&lt;thead>
&lt;tr>
&lt;th>Configuration&lt;/th>
&lt;th>Latency&lt;/th>
&lt;th>Queries it can answer&lt;/th>
&lt;/tr>
&lt;/thead>
&lt;tbody>
&lt;tr>
&lt;td>Naive RAG (1 retriever, 1 step)&lt;/td>
&lt;td>≈ 300 ms&lt;/td>
&lt;td>Document context queries&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>ReAct 1 iteration (SQL)&lt;/td>
&lt;td>≈ 500 ms&lt;/td>
&lt;td>Structured aggregation queries&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>ReAct 2 iterations (SQL + vector)&lt;/td>
&lt;td>≈ 800 ms&lt;/td>
&lt;td>Hybrid numeric + context queries&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>ReAct 3 iterations&lt;/td>
&lt;td>≈ 1,100 ms&lt;/td>
&lt;td>Complex multi-source queries&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>ReAct with web_search&lt;/td>
&lt;td>≈ 1,500 ms&lt;/td>
&lt;td>Queries requiring real-time data&lt;/td>
&lt;/tr>
&lt;/tbody>
&lt;/table>
&lt;p>The practical conclusion: multi-hop ReAct is &lt;strong>3–5× slower than a single-step naive RAG&lt;/strong>. The gain is not in speed but in &lt;strong>query coverage&lt;/strong>: a naive RAG cannot answer &amp;ldquo;how many contracts?&amp;rdquo; because that answer is in no text chunk at all. For applications with a strict latency SLO (&amp;lt; 500 ms), you have to work out whether the use case really needs ReAct or whether a well-configured RAG with hybrid retrieval covers 90% of the queries.&lt;/p>
&lt;h2 id="on-premise-hardware-for-react-agents">On-premise hardware for ReAct agents&lt;/h2>
&lt;p>A ReAct agent with Llama-3.1-70B in production has different requirements from a naive RAG, because the context grows with each iteration and prefill throughput is more critical.&lt;/p>
&lt;p>&lt;strong>Recommended configuration: 4×H100 SXM (320 GB HBM3, NVLink 900 GB/s)&lt;/strong>&lt;/p>
&lt;ul>
&lt;li>Llama-3.1-70B in FP8: fits in 2×H100 (70B params × 1 byte/param ≈ 70 GB + KV cache). With 4×H100 you can serve it with tensor parallelism TP=4, cutting prefill TTFT by ≈ 2×.&lt;/li>
&lt;li>Qdrant instance: it can sit on the same node (if the collection fits in RAM) or on a dedicated node. For collections &amp;lt; 50M vectors of 768 dims: ≈ 150 GB, which fits in the RAM of a dual-socket server.&lt;/li>
&lt;li>PostgreSQL: separate node or managed instance. The agent adds no unusual load to SQL; the queries are simple and bounded by timeout.&lt;/li>
&lt;li>vLLM with &lt;code>--enable-auto-tool-choice --tool-call-parser llama3_json --max-model-len 16384&lt;/code>: a 16k token context comfortably covers the 5–10 turns of a ReAct pipeline.&lt;/li>
&lt;/ul>
&lt;p>&lt;strong>Minimum viable configuration: 2×H100 SXM (160 GB)&lt;/strong>&lt;/p>
&lt;ul>
&lt;li>Llama-3.1-70B in FP8 with TP=2. TTFT ≈ 250–300 ms for 4k token contexts (a 60–100% increase over TP=4).&lt;/li>
&lt;li>Good enough for internal workloads with &amp;lt; 20 concurrent requests.&lt;/li>
&lt;li>Not advisable for an SLO &amp;lt; 1 s with more than 5 concurrent users and a long context.&lt;/li>
&lt;/ul>
&lt;h2 id="what-we-have-not-covered">What we have not covered&lt;/h2>
&lt;p>&lt;strong>Agentic retrieval loops with planning.&lt;/strong> ReAct is the simplest agent pattern. When a query requires decomposition into sub-tasks with dependencies, you need orchestration frameworks such as LangGraph (state graphs), smolagents (Hugging Face, agents with Python code as actions) or llama-index Agents (planning + retrieval pipeline). These introduce a planning step before the execution loop.&lt;/p>
&lt;p>&lt;strong>MCP (Model Context Protocol).&lt;/strong> Anthropic&amp;rsquo;s emerging standard, with OSS implementations, for defining tools portably across frameworks and hosts. Instead of defining each tool&amp;rsquo;s JSON Schema separately in every application, MCP centralises those definitions in an MCP server that any compatible client can discover and invoke. Adoption in 2025–2026 is fast among OSS frameworks (LangChain, smolagents, OpenWebUI).&lt;/p>
&lt;p>&lt;strong>Tool caching.&lt;/strong> If the same tool call (same arguments, same tool) is going to be invoked several times within the same context or in very similar contexts, the result can be cached. The mechanism is analogous to the semantic cache described for RAG: before executing the tool, the hash of the arguments (or its embedding, for semantic matching) is compared against a cache with a TTL. Especially valuable for &lt;code>sql_query&lt;/code> with frequent queries and data that changes little.&lt;/p>
&lt;p>&lt;strong>Multi-agent.&lt;/strong> When an orchestrating agent delegates sub-tasks to specialised agents, one for SQL, one for document retrieval, one for code generation, you enter multi-agent system territory. Each sub-agent can have its own tool set and its own LLM (possibly smaller and more specialised). Coordination between agents introduces additional tracing and observability complexity.&lt;/p>
&lt;h2 id="see-also">See also&lt;/h2>
&lt;ul>
&lt;li>&lt;a href="https://blog.lo0.es/en/posts/reranker-hybrid-retrieval-five-chunks-llm-reads/">RAG with reranker and hybrid retrieval&lt;/a> — the retriever invoked when the LLM chooses &lt;code>vector_search&lt;/code> is exactly the pipeline described there: dense + sparse + cross reranker&lt;/li>
&lt;li>&lt;a href="https://blog.lo0.es/en/posts/structured-output-bitmask-outlines-xgrammar-llguidance/">Structured output: fundamentals&lt;/a> — the JSON Schema that defines the contract of each tool call is exactly structured output applied to the tool interface&lt;/li>
&lt;li>&lt;a href="https://blog.lo0.es/en/posts/llm-inference-router-l7-switchboard/">Inference router and L7 LLM gateway&lt;/a> — the L7 gateway that receives the ReAct agent&amp;rsquo;s requests and routes them to the right LLM; it also applies rate limiting per user and tenant&lt;/li>
&lt;li>&lt;a href="https://blog.lo0.es/en/posts/llm-guard-anatomy-scanners-langfuse-vllm-litellm/">LLM Guard: fundamentals&lt;/a> — SQL injection via prompt is a real attack vector in tool-augmented retrieval; LLM Guard covers prompt injection detection before the request reaches the LLM&lt;/li>
&lt;li>&lt;a href="https://blog.lo0.es/en/posts/six-stage-llmops-pipeline-architecture-and-deep-dive/">Six-stage LLMOps pipeline&lt;/a> — tool-augmented retrieval lives at the intersection of the pipeline&amp;rsquo;s Deploy and Observe stages: it is deployed as part of the inference system and observed via tracing of each agent turn&lt;/li>
&lt;li>&lt;a href="https://blog.lo0.es/en/posts/evals-llm-layer-after-tracing/">LLM evals: the layer after tracing&lt;/a> — tool selection accuracy is the critical eval metric for a ReAct agent; the golden eval dataset must include (query, expected tool, expected args) triples&lt;/li>
&lt;/ul>
&lt;h2 id="references">References&lt;/h2>
&lt;ul>
&lt;li>Yao, S. et al. (2022). &lt;em>ReAct: Synergizing Reasoning and Acting in Language Models&lt;/em>. arXiv:2210.03629. &lt;a href="https://arxiv.org/abs/2210.03629">https://arxiv.org/abs/2210.03629&lt;/a>&lt;/li>
&lt;li>vLLM documentation. &lt;em>Tool calling&lt;/em>. &lt;a href="https://docs.vllm.ai/en/stable/features/tool_calling.html">https://docs.vllm.ai/en/stable/features/tool_calling.html&lt;/a>&lt;/li>
&lt;li>Qdrant documentation. &lt;em>Search&lt;/em>. &lt;a href="https://qdrant.tech/documentation/concepts/search/">https://qdrant.tech/documentation/concepts/search/&lt;/a>&lt;/li>
&lt;li>OpenAI. &lt;em>Function calling&lt;/em>. &lt;a href="https://platform.openai.com/docs/guides/function-calling">https://platform.openai.com/docs/guides/function-calling&lt;/a>&lt;/li>
&lt;li>Meta AI. &lt;em>Llama 3.1 Model Card&lt;/em>. &lt;a href="https://github.com/meta-llama/llama-models/blob/main/models/llama3_1/MODEL_CARD.md">https://github.com/meta-llama/llama-models/blob/main/models/llama3_1/MODEL_CARD.md&lt;/a>&lt;/li>
&lt;li>Qwen Team (Alibaba). &lt;em>Qwen2.5 Technical Report&lt;/em>. arXiv:2412.15115. &lt;a href="https://arxiv.org/abs/2412.15115">https://arxiv.org/abs/2412.15115&lt;/a>&lt;/li>
&lt;li>Anthropic. &lt;em>Model Context Protocol&lt;/em>. &lt;a href="https://modelcontextprotocol.io">https://modelcontextprotocol.io&lt;/a>&lt;/li>
&lt;li>OWASP. &lt;em>LLM Top 10 for Large Language Model Applications&lt;/em>. LLM01: Prompt Injection. &lt;a href="https://owasp.org/www-project-top-10-for-large-language-model-applications/">https://owasp.org/www-project-top-10-for-large-language-model-applications/&lt;/a>&lt;/li>
&lt;/ul></description></item><item><title>Embeddings in 2026: the three families (dense, sparse, multi-vector), the model zoo and the decision that matters in production</title><link>https://blog.lo0.es/en/posts/embeddings-2026-dense-sparse-multivector-families/</link><pubDate>Wed, 03 Jun 2026 03:00:00 +0200</pubDate><guid>https://blog.lo0.es/en/posts/embeddings-2026-dense-sparse-multivector-families/</guid><description>&lt;blockquote>
&lt;p>This post opens the data sub-saga inside the &lt;a href="https://blog.lo0.es/en/posts/six-stage-llmops-pipeline-architecture-and-deep-dive/">six-stage LLMOps pipeline&lt;/a> by going into the piece that holds up &lt;a href="https://blog.lo0.es/en/posts/reranker-hybrid-retrieval-five-chunks-llm-reads/">three-layer retrieval&lt;/a>: &lt;strong>the embedder&lt;/strong>. If the &lt;a href="https://blog.lo0.es/en/posts/rag-corpus-curation-active-librarian/">librarian of curation&lt;/a> decided what enters the index and the &lt;a href="https://blog.lo0.es/en/posts/reranker-hybrid-retrieval-five-chunks-llm-reads/">faculty committee&lt;/a> decided what reaches the model&amp;rsquo;s face, this post looks at the one in the middle: &lt;strong>the cartographer who draws the map you search over&lt;/strong>.&lt;/p>
&lt;/blockquote>
&lt;h2 id="tldr">TL;DR&lt;/h2>
&lt;p>The conversation about embeddings has been simplified in production to the point where &amp;ldquo;which embedder do you use&amp;rdquo; always gets the same answer: &lt;em>OpenAI text-embedding-3-large&lt;/em> in the demo, &lt;em>bge-m3&lt;/em> in the &amp;ldquo;sovereign-ready&amp;rdquo; version. That simplification hides the fact that an embedder is &lt;strong>three different models at once&lt;/strong>, dense single-vector, learned sparse (SPLADE) and multi-vector late-interaction (ColBERT), and that in 2026 the leading models do not compete within the same family: &lt;code>gte-Qwen2-7B-instruct&lt;/code> and &lt;code>NV-Embed-v2&lt;/code> break MTEB in dense single-vector, &lt;code>SPLADE-v3&lt;/code> and the sparse head of &lt;code>bge-m3&lt;/code> dominate the learned lexical descriptor, &lt;code>Jina-ColBERT-v2&lt;/code> and &lt;code>ColNomic-7B&lt;/code> are the strongest in multilingual multi-vector, and &lt;code>Snowflake Arctic Embed L 2.0&lt;/code> has slipped in as the small multilingual favourite with decent Matryoshka. This post takes apart the three families with their maths (InfoNCE with &lt;code>τ&lt;/code>, MaxSim, SPLADE&amp;rsquo;s FLOPS regularization, Matryoshka Representation Learning), goes through the open source model zoo with the dimension, licence and niche of each one, poses the specific problem of &lt;strong>multilingual Spanish&lt;/strong>, which cuts the list of viable models to fewer than six, gives the real storage cost per million chunks with &lt;code>int8&lt;/code> / binary / TurboQuant, describes how they are served on-premise with &lt;code>TEI&lt;/code>, &lt;code>Infinity&lt;/code> and &lt;code>vLLM --task embed&lt;/code>, sets the minimum hardware at an RTX 4090 and the good hardware at a 4×H100 cluster, lists the seven operational traps that sink quality without warning (corpus drift, forgotten normalisation, badly chosen Matryoshka dimension, missing hard negatives, chat template slipped into the embedder, tokenizer drift, MTEB overfit) and closes with a license-clean stack for sovereign production.&lt;/p>
&lt;h2 id="the-analogy-three-librarians-cataloguing-the-same-book">The analogy: three librarians cataloguing the same book&lt;/h2>
&lt;p>A technical library receives a new book and, before putting it on the shelves, has to generate a searchable identifier for it. Three librarians with three different trades live in the library, and all three catalogue the same book at once:&lt;/p>
&lt;p>&lt;strong>Librarian A, the thematic one.&lt;/strong> He reads the whole book and gives it &lt;em>a single&lt;/em> rich RFID tag. That tag is a vector of 1,024 numbers where each coordinate encodes a latent semantic axis (one the librarian never verbalises: he has learned it by reading a hundred million previous books). Two books about Kubernetes in production will end up with RFID tags very close together in the space even if one talks about Linkerd and the other about Cilium, because they share thematic axes. To search, you compare the question&amp;rsquo;s tag with the book&amp;rsquo;s and return the nearest ones by cosine. It is fast, it scales to millions, and it loses fine nuance. This is the &lt;strong>dense single-vector embedder&lt;/strong>: &lt;code>bge-m3&lt;/code> in dense mode, &lt;code>gte-Qwen2-7B-instruct&lt;/code>, &lt;code>Snowflake Arctic Embed L 2.0&lt;/code>, &lt;code>multilingual-e5-large-instruct&lt;/code>.&lt;/p>
&lt;p>&lt;strong>Librarian B, the lexical one.&lt;/strong> He summarises nothing. What he does is write on a card the &lt;em>weighted list of relevant terms&lt;/em> from the book, &lt;strong>expanded with synonyms from the field&lt;/strong>. The Kubernetes book carries on its card &amp;ldquo;kubernetes 4.2, linkerd 3.8, cilium 3.7, service-mesh 4.1, sidecar 3.2, mtls 2.9, ebpf 2.6, k8s 4.0…&amp;rdquo;, each term with a weight. The trick is that the model does the expansion: if your question says &amp;ldquo;service mesh&amp;rdquo; and the original book only said &amp;ldquo;Linkerd&amp;rdquo;, librarian B&amp;rsquo;s card did record &amp;ldquo;service-mesh 4.1&amp;rdquo; because he understood the relationship. To search, you intersect the question&amp;rsquo;s card with the book&amp;rsquo;s the old-fashioned way: inverted index, posting lists. It is decisive when the reader writes few, very specific words (product names, errors, jargon). This is the &lt;strong>learned sparse embedder&lt;/strong>: &lt;code>SPLADE-v3&lt;/code> or the sparse head of &lt;code>bge-m3&lt;/code>. It is the modern successor to BM25, not its rival; we will see why.&lt;/p>
&lt;p>&lt;strong>Librarian C, the copyist.&lt;/strong> He gives up on summarising. He takes every word on every page of the book and gives it a 128-dimension mini-RFID. He ends up with a 30,000-token book turned into 30,000 mini-RFIDs. When you search for something, the librarian compares &lt;em>every word of your question with every word of the book&lt;/em> and keeps the maximum for each word of the question, adding them up. It captures nuance the other two lose by construction (proper nouns, numbers, specific phrasings), but its filing system is an order of magnitude larger in disk space. This is ColBERT-v2 / Jina-ColBERT-v2 / ColNomic-7B: late interaction, MaxSim.&lt;/p>
&lt;p>And then there is &lt;strong>the all-rounder librarian who does all three jobs in a single pass&lt;/strong>: &lt;code>bge-m3&lt;/code>. A single 568 M parameter model that simultaneously returns the thematic RFID tag (1,024-d), the weighted list of terms (sparse) and the set of per-token mini-RFIDs (128-d). That is why it has settled in as the standard embedder for multilingual on-premise RAG: a single &lt;code>model.encode(chunk)&lt;/code> produces the three outputs that feed &lt;a href="https://blog.lo0.es/en/posts/reranker-hybrid-retrieval-five-chunks-llm-reads/">hybrid retrieval&lt;/a> without orchestrating three different models.&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="The three librarians: dense, sparse and multi-vector">
&lt;style>
.ebox{fill:#f8f8f8;stroke:#444;stroke-width:1.4;rx:8}
.ehead{fill:#ff8a4c;stroke:#444;stroke-width:1.4;rx:8}
.eden{fill:#7aafff;stroke:#444;stroke-width:1.4;rx:8}
.espa{fill:#a8d5a8;stroke:#444;stroke-width:1.4;rx:8}
.emul{fill:#ffb86b;stroke:#444;stroke-width:1.4;rx:8}
.epol{fill:#d8a8ff;stroke:#444;stroke-width:1.4;rx:8}
.eout{fill:#ffe18a;stroke:#444;stroke-width:1.4;rx:8}
.eblt{font:600 13px sans-serif;fill:#222}
.esub{font:400 11px sans-serif;fill:#444}
.earr{stroke:#666;stroke-width:1.5;fill:none;marker-end:url(#me1)}
.elbl{font:600 11px sans-serif;fill:#555}
&lt;/style>
&lt;defs>&lt;marker id="me1" 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;rect x="20" y="160" width="170" height="60" class="ehead"/>
&lt;text x="105" y="186" text-anchor="middle" class="eblt">Corpus chunk&lt;/text>
&lt;text x="105" y="204" text-anchor="middle" class="esub">"Linkerd 3.8 brings mTLS by default…"&lt;/text>
&lt;rect x="240" y="20" width="190" height="80" class="eden"/>
&lt;text x="335" y="44" text-anchor="middle" class="eblt">Dense (single-vector)&lt;/text>
&lt;text x="335" y="62" text-anchor="middle" class="esub">1 vector × 1024 d (fp16)&lt;/text>
&lt;text x="335" y="78" text-anchor="middle" class="esub">cosine similarity, HNSW&lt;/text>
&lt;text x="335" y="92" text-anchor="middle" class="esub">bge-m3 · gte-Qwen2 · e5&lt;/text>
&lt;rect x="240" y="140" width="190" height="80" class="espa"/>
&lt;text x="335" y="164" text-anchor="middle" class="eblt">Learned sparse&lt;/text>
&lt;text x="335" y="182" text-anchor="middle" class="esub">~80 weighted terms ⊂ vocab 30k&lt;/text>
&lt;text x="335" y="198" text-anchor="middle" class="esub">inverted index, posting lists&lt;/text>
&lt;text x="335" y="212" text-anchor="middle" class="esub">SPLADE-v3 · bge-m3 sparse&lt;/text>
&lt;rect x="240" y="260" width="190" height="80" class="emul"/>
&lt;text x="335" y="284" text-anchor="middle" class="eblt">Multi-vector (late int.)&lt;/text>
&lt;text x="335" y="302" text-anchor="middle" class="esub">N tokens × 128 d (fp16)&lt;/text>
&lt;text x="335" y="318" text-anchor="middle" class="esub">MaxSim per query token&lt;/text>
&lt;text x="335" y="332" text-anchor="middle" class="esub">ColBERT-v2 · Jina-ColBERT-v2&lt;/text>
&lt;rect x="490" y="20" width="280" height="80" class="eout"/>
&lt;text x="630" y="44" text-anchor="middle" class="eblt">Storage / 1 M docs (d=1024, fp16)&lt;/text>
&lt;text x="630" y="62" text-anchor="middle" class="esub">dense fp16: 2 GB · int8: 1 GB · binary: 128 MB&lt;/text>
&lt;text x="630" y="78" text-anchor="middle" class="esub">SPLADE: ~25 MB (posting lists)&lt;/text>
&lt;text x="630" y="94" text-anchor="middle" class="esub">ColBERT fp16 (256 tok×128 d): 64 GB&lt;/text>
&lt;rect x="490" y="140" width="280" height="80" class="epol"/>
&lt;text x="630" y="164" text-anchor="middle" class="eblt">bge-m3 (3-in-1)&lt;/text>
&lt;text x="630" y="182" text-anchor="middle" class="esub">one forward → dense + sparse + colbert&lt;/text>
&lt;text x="630" y="198" text-anchor="middle" class="esub">568 M params · XLM-RoBERTa-large&lt;/text>
&lt;text x="630" y="214" text-anchor="middle" class="esub">100+ languages · 8192 tokens · MIT&lt;/text>
&lt;rect x="490" y="260" width="280" height="80" class="ebox"/>
&lt;text x="630" y="284" text-anchor="middle" class="eblt">The 2026 open source zoo&lt;/text>
&lt;text x="630" y="302" text-anchor="middle" class="esub">jina-v3/v4 · nomic-v2 MoE · Snowflake Arctic L 2.0&lt;/text>
&lt;text x="630" y="318" text-anchor="middle" class="esub">gte-Qwen2-7B · multilingual-e5-large-instruct&lt;/text>
&lt;text x="630" y="334" text-anchor="middle" class="esub">SPLADE-v3 · Jina-ColBERT-v2 · ColNomic-7B&lt;/text>
&lt;path class="earr" d="M190 188 L240 60"/>
&lt;path class="earr" d="M190 190 L240 180"/>
&lt;path class="earr" d="M190 192 L240 300"/>
&lt;/svg>
&lt;p style="text-align:center;font-size:0.9rem;color:#555;margin-top:0.5rem;">The same chunk indexed by three librarians. The all-rounder &lt;code>bge-m3&lt;/code> runs all three in a single 568 M parameter pass.&lt;/p>
&lt;/div>
&lt;h2 id="what-an-embedding-really-is">What an embedding really is&lt;/h2>
&lt;p>A text embedding is a function &lt;code>f : text → ℝᵈ&lt;/code> trained so that two &amp;ldquo;semantically similar&amp;rdquo; texts produce nearby vectors in that space. The important part is not &amp;ldquo;vector&amp;rdquo;, any fixed-length hash is one, but &lt;strong>what closeness means&lt;/strong>. Closeness is defined implicitly by the loss the model is trained with.&lt;/p>
&lt;p>Almost every modern embedder is trained with InfoNCE (also called &lt;em>Multiple-Negatives Ranking Loss&lt;/em> in &lt;code>sentence-transformers&lt;/code>):&lt;/p>
&lt;pre tabindex="0">&lt;code class="language-math" data-lang="math">\mathcal{L}_{\text{InfoNCE}} = -\log \frac{\exp(\text{sim}(q, d^+)/\tau)}{\sum_{d \in \mathcal{B}} \exp(\text{sim}(q, d)/\tau)}
&lt;/code>&lt;/pre>&lt;p>For each &lt;code>(query, doc⁺)&lt;/code> pair the model has to assign the positive more similarity than &lt;em>all the other documents in the batch&lt;/em> &lt;code>B&lt;/code>, where the other documents act as free in-batch negatives. The temperature &lt;code>τ&lt;/code> (typically &lt;code>0.02&lt;/code>–&lt;code>0.07&lt;/code>, almost always &lt;code>0.05&lt;/code>) controls how sharp the distribution gets: lower &lt;code>τ&lt;/code> means the model becomes more demanding about the positive but less stable. A large batch size &lt;code>|B|&lt;/code> means many more negatives per gradient, so a better informed model. That is why embedders are trained with &lt;code>batch ≥ 1,024&lt;/code> on H100 clusters with AllGather across nodes to stack every negative in the cluster into a single effective batch.&lt;/p>
&lt;p>On top of the in-batch negatives comes hard negatives mining: documents deliberately picked because they are &amp;ldquo;almost in the answer&amp;rdquo; (typically the next 10-100 neighbours from a previous BM25 / dense retrieval). Without hard negatives the model learns to discriminate the trivial and real quality on BEIR / MTEB drops 5-10 points.&lt;/p>
&lt;p>This matters because &lt;strong>the embedder family depends on what you feed into &lt;code>sim(·,·)&lt;/code>&lt;/strong>:&lt;/p>
&lt;table>
&lt;thead>
&lt;tr>
&lt;th>Family&lt;/th>
&lt;th>&lt;code>sim(q, d)&lt;/code>&lt;/th>
&lt;th>Model output&lt;/th>
&lt;/tr>
&lt;/thead>
&lt;tbody>
&lt;tr>
&lt;td>Dense single-vector&lt;/td>
&lt;td>dot product of two normalised 1024-d vectors&lt;/td>
&lt;td>&lt;code>f(q), f(d) ∈ ℝ^d&lt;/code>&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>Learned sparse&lt;/td>
&lt;td>dot product of two 30,522-d vectors (≈80 non-zero each)&lt;/td>
&lt;td>&lt;code>f(q), f(d) ∈ ℝ^V&lt;/code>, `V =&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>Multi-vector&lt;/td>
&lt;td>&lt;code>Σᵢ maxⱼ ⟨qᵢ, dⱼ⟩&lt;/code> (MaxSim)&lt;/td>
&lt;td>`f(q) ∈ ℝ^{&lt;/td>
&lt;/tr>
&lt;/tbody>
&lt;/table>
&lt;p>The loss is the same in all three cases, InfoNCE, but the geometry of the space changes, and with it quality, storage cost and search latency.&lt;/p>
&lt;h2 id="the-three-families-in-detail">The three families in detail&lt;/h2>
&lt;h3 id="dense-single-vector-the-cartographer">Dense single-vector, the cartographer&lt;/h3>
&lt;p>The embedder reads the full chunk, passes it through an encoder transformer (XLM-RoBERTa, BERT, a Mistral decoder with a prompt) and &lt;strong>aggregates the token representations into a single vector&lt;/strong> by:&lt;/p>
&lt;ul>
&lt;li>&lt;strong>CLS pooling&lt;/strong>: uses the embedding of the &lt;code>[CLS]&lt;/code> token. Standard in BERT-base.&lt;/li>
&lt;li>&lt;strong>Mean pooling&lt;/strong>: plain average of the embeddings of all tokens. Standard in &lt;code>multilingual-e5-large-instruct&lt;/code>, &lt;code>bge-m3&lt;/code> dense, &lt;code>Snowflake Arctic Embed L 2.0&lt;/code>.&lt;/li>
&lt;li>&lt;strong>Last-token pooling&lt;/strong>: for decoder-LLM based embedders (&lt;code>e5-mistral-7b-instruct&lt;/code>, &lt;code>gte-Qwen2-7B-instruct&lt;/code>, &lt;code>NV-Embed-v2&lt;/code>) that take the final token as the aggregate. It works because the model is causal and the last token &amp;ldquo;has seen&amp;rdquo; the whole context.&lt;/li>
&lt;li>&lt;strong>Latent-attention pooling&lt;/strong>: new in &lt;code>NV-Embed-v2&lt;/code>. A learnable attention layer that weights tokens instead of averaging them. +2-3 MTEB points over mean pooling.&lt;/li>
&lt;/ul>
&lt;p>After pooling the vector is normalised to norm 1: &lt;code>v ← v / ‖v‖₂&lt;/code>. With normalised vectors, &lt;strong>cosine and dot product coincide&lt;/strong>:&lt;/p>
&lt;pre tabindex="0">&lt;code class="language-math" data-lang="math">\cos(q, d) = \frac{q \cdot d}{\|q\|\|d\|} = q \cdot d, \qquad \|q - d\|^2 = 2 - 2\,(q \cdot d)
&lt;/code>&lt;/pre>&lt;p>That is why almost every vector DB indexes by &lt;em>inner product&lt;/em> and leaves it to the user to normalise beforehand (Qdrant, Faiss IP, Milvus). If you forget to normalise the query vector but the corpus vectors are normalised, &lt;strong>retrieval degrades silently&lt;/strong>: the query&amp;rsquo;s magnitude distorts the ranking. It is the number one bug in RAG in production.&lt;/p>
&lt;p>A minimal numerical example, two normalised 4-d vectors:&lt;/p>
&lt;div class="highlight">&lt;pre tabindex="0" class="chroma">&lt;code class="language-text" data-lang="text">&lt;span class="line">&lt;span class="cl">q = [0.5, 0.5, 0.5, 0.5] ‖q‖ = 1
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">d₁ = [0.6, 0.4, 0.5, 0.5] ‖d₁‖ = 1.005, normalised [0.597, 0.398, 0.498, 0.498]
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">q · d₁ = 0.5·0.597 + 0.5·0.398 + 0.5·0.498 + 0.5·0.498 = 0.995
&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>Closeness of nearly 1, as expected. If &lt;code>d₁&lt;/code> had not been normalised, &lt;code>q · d₁ = 1.0&lt;/code> and it would appear closer than any &lt;code>d&lt;/code> that is perfectly aligned but with a norm &lt;code>&amp;lt; 1.005&lt;/code>. &lt;strong>Normalisation is not a detail: it is the contract of the space.&lt;/strong>&lt;/p>
&lt;h3 id="learned-sparse-the-lexical-descriptor">Learned sparse, the lexical descriptor&lt;/h3>
&lt;p>&lt;code>SPLADE-v3&lt;/code> (Naver, March 2024) has consolidated the modern version of the lexical librarian. Internally it is a small BERT (~110 M parameters, DistilBERT/BERT base) that produces, for each input token, a distribution over the whole vocabulary (30,522 dimensions in BERT WordPiece), and then does &lt;code>max-pool&lt;/code> over the tokens:&lt;/p>
&lt;pre tabindex="0">&lt;code class="language-math" data-lang="math">w_j = \max_{i \in \text{seq}} \log\bigl(1 + \text{ReLU}(W_{ij})\bigr)
&lt;/code>&lt;/pre>&lt;p>where &lt;code>Wᵢⱼ&lt;/code> is the logit of input token &lt;code>i&lt;/code> for vocabulary term &lt;code>j&lt;/code>. The &lt;code>log(1+ReLU)&lt;/code> saturates high logits (it stops a single word from dominating the vector) and the &lt;code>ReLU&lt;/code> cuts the negatives. The result is a 30,522-dimension vector of which typically 50-200 entries stay non-zero.&lt;/p>
&lt;p>The key part is the &lt;strong>FLOPS regularization&lt;/strong> added to the loss during training:&lt;/p>
&lt;pre tabindex="0">&lt;code class="language-math" data-lang="math">\mathcal{L}_{\text{FLOPS}} = \lambda \cdot \sum_{j=1}^{V} \bar{w}_j^2, \qquad \bar{w}_j = \frac{1}{|B|}\sum_{i \in B} w_{ij}
&lt;/code>&lt;/pre>&lt;p>It penalises the expected cost of the posting lists: if a vocabulary word appears on average in many documents, adding it to a new document costs double. The model learns to generate sparse vectors by construction.&lt;/p>
&lt;p>What does this do to the text &amp;ldquo;Linkerd 3.8 brings mTLS by default&amp;rdquo;? The model does not only write the literal terms, it also writes, with a smaller but non-zero weight, &amp;ldquo;service-mesh&amp;rdquo;, &amp;ldquo;kubernetes&amp;rdquo;, &amp;ldquo;tls&amp;rdquo;, &amp;ldquo;sidecar&amp;rdquo;, &amp;ldquo;envoy&amp;rdquo;, &amp;ldquo;istio&amp;rdquo; (its competitor, also semantically related), &amp;ldquo;encryption&amp;rdquo;, &amp;ldquo;k8s&amp;rdquo;. That &lt;strong>semantic expansion of the document is what sets SPLADE apart from BM25&lt;/strong>. BM25 only knows what was literally in the text; SPLADE knows what an expert would add as a descriptor.&lt;/p>
&lt;p>In practice SPLADE-v3 beats BM25 by 3-6 MRR@10 points on MS MARCO and dominates BEIR zero-shot. The cost is ~2-4× the query latency of BM25 over the same inverted index, which static pruning can mitigate.&lt;/p>
&lt;p>For the multilingual case, &lt;strong>&lt;code>bge-m3&lt;/code> with its sparse head is the only maintainable option&lt;/strong>: SPLADE-v3 is trained in English and the multilingual ports are in an experimental state.&lt;/p>
&lt;h3 id="multi-vector-the-copyist">Multi-vector, the copyist&lt;/h3>
&lt;p>&lt;code>ColBERT-v2&lt;/code> (Stanford, NAACL 2022) introduced the &lt;strong>late interaction&lt;/strong> paradigm. Instead of compressing the document to a single vector, it leaves it as a matrix &lt;code>(|d|, k)&lt;/code> with a &lt;code>k&lt;/code>-dimension vector per token. The similarity between query and document is computed token by token and aggregated with MaxSim:&lt;/p>
&lt;pre tabindex="0">&lt;code class="language-math" data-lang="math">s(q, d) = \sum_{i \in q} \max_{j \in d} \langle q_i, d_j \rangle
&lt;/code>&lt;/pre>&lt;p>What is being computed: for each word of the query, find the document token that fits it best and add that similarity. The sum is over the query, not over the document. This lets a 30,000-token document compete fairly with a 200-token one, because the query always adds &lt;code>|q|&lt;/code> terms.&lt;/p>
&lt;p>Why does it give more quality than dense single-vector? Because compressing to one vector loses information about where each idea was. If the query is &amp;ldquo;which version brought mTLS by default in Linkerd&amp;rdquo;, the dense summary of the document only knows that the chunk is about &amp;ldquo;Linkerd and mTLS&amp;rdquo;; ColBERT&amp;rsquo;s copyist can match &amp;ldquo;which version&amp;rdquo; with &amp;ldquo;3.8&amp;rdquo; because it stores the embedding of the &lt;code>3.8&lt;/code> token separately. On BEIR / out-of-domain, late interaction beats single-vector by between +2 and +6 nDCG@10 with the same backbone.&lt;/p>
&lt;p>The price is storage. Per document, a 1024-dimension dense single-vector in fp16 takes &lt;code>1024 × 2 = 2 KB&lt;/code>. ColBERT-v2 with 128-dimension tokens for a 256-token chunk takes &lt;code>256 × 128 × 2 = 65,536 B ≈ 64 KB&lt;/code>: &lt;strong>32× more space&lt;/strong>. With ColBERT-v2&amp;rsquo;s &lt;code>nbits=2&lt;/code> residual compression it drops to ~16 KB (8×). &lt;code>Jina-ColBERT-v2&lt;/code> adds &lt;strong>Matryoshka over the token dimensions&lt;/strong> (truncatable to 128 / 96 / 64), cutting another 50%.&lt;/p>
&lt;p>For 1 million chunks:&lt;/p>
&lt;table>
&lt;thead>
&lt;tr>
&lt;th>Family&lt;/th>
&lt;th>Per doc&lt;/th>
&lt;th>Total&lt;/th>
&lt;/tr>
&lt;/thead>
&lt;tbody>
&lt;tr>
&lt;td>Dense fp32 (1024-d)&lt;/td>
&lt;td>4,096 B&lt;/td>
&lt;td>4.0 GB&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>Dense fp16 / halfvec&lt;/td>
&lt;td>2,048 B&lt;/td>
&lt;td>2.0 GB&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>Dense int8 (SQ)&lt;/td>
&lt;td>1,024 B&lt;/td>
&lt;td>1.0 GB&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>Dense binary (1 bit/d)&lt;/td>
&lt;td>128 B&lt;/td>
&lt;td>&lt;strong>128 MB&lt;/strong>&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>SPLADE (≈ 80 terms × 8 B)&lt;/td>
&lt;td>~640 B&lt;/td>
&lt;td>~640 MB&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>ColBERT fp16 (256 tok × 128 d)&lt;/td>
&lt;td>65,536 B&lt;/td>
&lt;td>&lt;strong>64 GB&lt;/strong>&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>ColBERT residual &lt;code>nbits=2&lt;/code>&lt;/td>
&lt;td>~16,000 B&lt;/td>
&lt;td>~16 GB&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>Jina-ColBERT-v2 (MRL token 64)&lt;/td>
&lt;td>~8,000 B&lt;/td>
&lt;td>~8 GB&lt;/td>
&lt;/tr>
&lt;/tbody>
&lt;/table>
&lt;p>ColBERT in &lt;strong>on-premise&lt;/strong> production is reserved for corpora of up to a few million chunks, or applied only as a reranker over the top-100 from the first committee (dense + sparse), as described in the &lt;a href="https://blog.lo0.es/en/posts/reranker-hybrid-retrieval-five-chunks-llm-reads/">reranker post&lt;/a>.&lt;/p>
&lt;h2 id="matryoshka-the-truncatable-dimension">Matryoshka, the truncatable dimension&lt;/h2>
&lt;p>An operational lever that changed the conversation about embeddings between 2024 and 2026 is &lt;strong>Matryoshka Representation Learning&lt;/strong> (Kusupati et al., NeurIPS 2022). The trick: during training, on top of the loss over the full &lt;code>D&lt;/code>-dimension vector, the &lt;strong>same loss is computed over prefixes&lt;/strong> of the vector:&lt;/p>
&lt;pre tabindex="0">&lt;code class="language-math" data-lang="math">\mathcal{L}_{\text{MRL}} = \sum_{k \in \{64, 128, 256, 512, 1024\}} \alpha_k \cdot \mathcal{L}_{\text{InfoNCE}}\bigl(\text{emb}[:k]\bigr)
&lt;/code>&lt;/pre>&lt;p>The first &lt;code>k&lt;/code> dimensions of the embedding are trained to be, on their own, a valid embedding. At inference time, if you want a cheaper embedding, &lt;strong>you truncate the vector&lt;/strong>: the first quarter is already a usable embedding. Without Matryoshka, truncating wrecks the geometry: the first 256 dimensions of an embedding trained only at 1024-d encode nothing coherent.&lt;/p>
&lt;p>Typical degradation in MTEB nDCG@10 when truncating an MRL embedder:&lt;/p>
&lt;table>
&lt;thead>
&lt;tr>
&lt;th>Truncation&lt;/th>
&lt;th>Average loss&lt;/th>
&lt;/tr>
&lt;/thead>
&lt;tbody>
&lt;tr>
&lt;td>1024 → 512&lt;/td>
&lt;td>-1 to -2 points&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>1024 → 256&lt;/td>
&lt;td>-3 to -5 points&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>1024 → 128&lt;/td>
&lt;td>-5 to -8 points&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>1024 → 64&lt;/td>
&lt;td>-8 to -12 points&lt;/td>
&lt;/tr>
&lt;/tbody>
&lt;/table>
&lt;p>Native MRL models in 2026 (the ones that let you pick the dimension at runtime without retraining):&lt;/p>
&lt;ul>
&lt;li>&lt;code>jina-embeddings-v3&lt;/code> (1024 → 32, fine steps, CC-BY-NC-4.0)&lt;/li>
&lt;li>&lt;code>jina-embeddings-v4&lt;/code> (2048 → 128, multimodal text+image, CC-BY-NC-4.0)&lt;/li>
&lt;li>&lt;code>nomic-embed-text-v2-moe&lt;/code> (768 → 256, Apache 2.0)&lt;/li>
&lt;li>&lt;code>Snowflake-arctic-embed-l-v2.0&lt;/code> (1024 → 256, Apache 2.0)&lt;/li>
&lt;li>&lt;code>mxbai-embed-large-v1&lt;/code> and &lt;code>mxbai-embed-2d-large-v1&lt;/code> (the latter also truncatable in layer depth)&lt;/li>
&lt;li>&lt;code>Stella_en_1.5B_v5&lt;/code> (multiple steps 512 / 768 / 1024 / 2048 / 4096 / 6144 / 8192, MIT, English only)&lt;/li>
&lt;li>&lt;code>text-embedding-3-large&lt;/code> (OpenAI, 3072 → 256, API only)&lt;/li>
&lt;li>&lt;code>voyage-3&lt;/code> family (1024 → 256 / 512 / 1024 / 2048, API only)&lt;/li>
&lt;/ul>
&lt;p>&lt;strong>Production recommendation&lt;/strong>: always use an MRL model even if you do not truncate at the start, because it simplifies the future quantisation decision. And above all, &lt;strong>evaluate truncation on your real corpus&lt;/strong>: the average MTEB degradation of &amp;ldquo;-3 points&amp;rdquo; becomes &amp;ldquo;-12 points&amp;rdquo; in a niche domain.&lt;/p>
&lt;h2 id="the-2026-open-source-model-zoo">The 2026 open source model zoo&lt;/h2>
&lt;p>What follows is a spec sheet per model. Verified as of 2026-06: HuggingFace cards + reference papers + MTEB / MMTEB leaderboard.&lt;/p>
&lt;h3 id="dense-single-vector">Dense single-vector&lt;/h3>
&lt;table>
&lt;thead>
&lt;tr>
&lt;th>Model&lt;/th>
&lt;th>Params&lt;/th>
&lt;th>Dim&lt;/th>
&lt;th>Tokens&lt;/th>
&lt;th>Languages&lt;/th>
&lt;th>Licence&lt;/th>
&lt;th>Distinctive&lt;/th>
&lt;/tr>
&lt;/thead>
&lt;tbody>
&lt;tr>
&lt;td>&lt;code>BAAI/bge-m3&lt;/code> (dense)&lt;/td>
&lt;td>568 M&lt;/td>
&lt;td>1024&lt;/td>
&lt;td>8192&lt;/td>
&lt;td>100+&lt;/td>
&lt;td>MIT&lt;/td>
&lt;td>Tri-mode (dense + sparse + colbert) in one forward. De facto standard for multilingual on-prem.&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>&lt;code>Snowflake/snowflake-arctic-embed-l-v2.0&lt;/code>&lt;/td>
&lt;td>568 M&lt;/td>
&lt;td>1024 (MRL → 256)&lt;/td>
&lt;td>8192&lt;/td>
&lt;td>~100&lt;/td>
&lt;td>Apache 2.0&lt;/td>
&lt;td>Trained for multilingual + English without degrading either. MIRACL 55.8.&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>&lt;code>intfloat/multilingual-e5-large-instruct&lt;/code>&lt;/td>
&lt;td>560 M&lt;/td>
&lt;td>1024&lt;/td>
&lt;td>512&lt;/td>
&lt;td>~100&lt;/td>
&lt;td>MIT&lt;/td>
&lt;td>Veteran multilingual baseline. Short window.&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>&lt;code>intfloat/e5-mistral-7b-instruct&lt;/code>&lt;/td>
&lt;td>7.1 B&lt;/td>
&lt;td>4096&lt;/td>
&lt;td>4096&lt;/td>
&lt;td>English&lt;/td>
&lt;td>MIT&lt;/td>
&lt;td>First decoder-as-embedder to break MTEB. English.&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>&lt;code>Alibaba-NLP/gte-Qwen2-7B-instruct&lt;/code>&lt;/td>
&lt;td>7 B&lt;/td>
&lt;td>3584&lt;/td>
&lt;td>32,768&lt;/td>
&lt;td>100+&lt;/td>
&lt;td>Apache 2.0&lt;/td>
&lt;td>The only one with 32k context. Top MTEB-en, strong multilingual.&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>&lt;code>nvidia/NV-Embed-v2&lt;/code>&lt;/td>
&lt;td>7.85 B&lt;/td>
&lt;td>4096&lt;/td>
&lt;td>32,768&lt;/td>
&lt;td>English&lt;/td>
&lt;td>CC-BY-NC-4.0&lt;/td>
&lt;td>Latent-attention pooling. Top quality. &lt;strong>Licence blocker in prod&lt;/strong>.&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>&lt;code>Linq-AI-Research/Linq-Embed-Mistral&lt;/code>&lt;/td>
&lt;td>7 B&lt;/td>
&lt;td>4096&lt;/td>
&lt;td>4096&lt;/td>
&lt;td>English&lt;/td>
&lt;td>CC-BY-NC-4.0&lt;/td>
&lt;td>Top MTEB retrieval Aug 2024. &lt;strong>Non-commercial&lt;/strong>.&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>&lt;code>NovaSearch/stella_en_1.5B_v5&lt;/code>&lt;/td>
&lt;td>1.54 B&lt;/td>
&lt;td>8192 (multiple MRL)&lt;/td>
&lt;td>8192&lt;/td>
&lt;td>English&lt;/td>
&lt;td>MIT&lt;/td>
&lt;td>Small + rich MRL. English.&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>&lt;code>BAAI/bge-multilingual-gemma2&lt;/code>&lt;/td>
&lt;td>9 B&lt;/td>
&lt;td>3584&lt;/td>
&lt;td>8192&lt;/td>
&lt;td>100+&lt;/td>
&lt;td>Gemma&lt;/td>
&lt;td>High quality, &lt;strong>the Gemma licence restricts redistribution&lt;/strong>.&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>&lt;code>BAAI/bge-en-icl&lt;/code>&lt;/td>
&lt;td>7 B&lt;/td>
&lt;td>4096&lt;/td>
&lt;td>8192&lt;/td>
&lt;td>English&lt;/td>
&lt;td>MIT-style&lt;/td>
&lt;td>In-context learning from examples in the prompt.&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>&lt;code>mixedbread-ai/mxbai-embed-large-v1&lt;/code>&lt;/td>
&lt;td>335 M&lt;/td>
&lt;td>1024 (MRL)&lt;/td>
&lt;td>512&lt;/td>
&lt;td>English&lt;/td>
&lt;td>Apache 2.0&lt;/td>
&lt;td>MRL + native binary. Short window.&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>&lt;code>jinaai/jina-embeddings-v3&lt;/code>&lt;/td>
&lt;td>570 M&lt;/td>
&lt;td>1024 (MRL → 32)&lt;/td>
&lt;td>8192&lt;/td>
&lt;td>89&lt;/td>
&lt;td>CC-BY-NC-4.0&lt;/td>
&lt;td>Per-task LoRA. &lt;strong>Non-commercial without a licence&lt;/strong>.&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>&lt;code>nomic-ai/nomic-embed-text-v2-moe&lt;/code>&lt;/td>
&lt;td>475 M / 305 M active&lt;/td>
&lt;td>768 (MRL → 256)&lt;/td>
&lt;td>512&lt;/td>
&lt;td>~100&lt;/td>
&lt;td>Apache 2.0&lt;/td>
&lt;td>First general-purpose MoE in embeddings.&lt;/td>
&lt;/tr>
&lt;/tbody>
&lt;/table>
&lt;h3 id="learned-sparse">Learned sparse&lt;/h3>
&lt;table>
&lt;thead>
&lt;tr>
&lt;th>Model&lt;/th>
&lt;th>Params&lt;/th>
&lt;th>Vocab&lt;/th>
&lt;th>Tokens&lt;/th>
&lt;th>Languages&lt;/th>
&lt;th>Licence&lt;/th>
&lt;th>Distinctive&lt;/th>
&lt;/tr>
&lt;/thead>
&lt;tbody>
&lt;tr>
&lt;td>&lt;code>naver/splade-v3&lt;/code>&lt;/td>
&lt;td>110 M&lt;/td>
&lt;td>30,522&lt;/td>
&lt;td>512&lt;/td>
&lt;td>English&lt;/td>
&lt;td>CC-BY-NC-SA-4.0&lt;/td>
&lt;td>SOTA learned sparse. &lt;strong>Non-commercial&lt;/strong>.&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>&lt;code>BAAI/bge-m3&lt;/code> (sparse head)&lt;/td>
&lt;td>568 M&lt;/td>
&lt;td>XLM-R vocab&lt;/td>
&lt;td>8192&lt;/td>
&lt;td>100+&lt;/td>
&lt;td>MIT&lt;/td>
&lt;td>The only license-clean multilingual option.&lt;/td>
&lt;/tr>
&lt;/tbody>
&lt;/table>
&lt;h3 id="multi-vector-late-interaction">Multi-vector (late interaction)&lt;/h3>
&lt;table>
&lt;thead>
&lt;tr>
&lt;th>Model&lt;/th>
&lt;th>Params&lt;/th>
&lt;th>Dim/token&lt;/th>
&lt;th>Tokens&lt;/th>
&lt;th>Languages&lt;/th>
&lt;th>Licence&lt;/th>
&lt;th>Distinctive&lt;/th>
&lt;/tr>
&lt;/thead>
&lt;tbody>
&lt;tr>
&lt;td>&lt;code>colbert-ir/colbertv2.0&lt;/code>&lt;/td>
&lt;td>110 M&lt;/td>
&lt;td>128&lt;/td>
&lt;td>512&lt;/td>
&lt;td>English&lt;/td>
&lt;td>MIT&lt;/td>
&lt;td>The original paper, the basis for everything.&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>&lt;code>jinaai/jina-colbert-v2&lt;/code>&lt;/td>
&lt;td>560 M&lt;/td>
&lt;td>128 / 96 / 64 (MRL)&lt;/td>
&lt;td>8192&lt;/td>
&lt;td>89&lt;/td>
&lt;td>Apache 2.0&lt;/td>
&lt;td>&lt;strong>The license-clean multilingual multi-vector&lt;/strong>.&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>&lt;code>nomic-ai/colnomic-embed-multimodal-7b&lt;/code>&lt;/td>
&lt;td>7 B&lt;/td>
&lt;td>3584 (Qwen2-VL)&lt;/td>
&lt;td>—&lt;/td>
&lt;td>~100&lt;/td>
&lt;td>Apache 2.0&lt;/td>
&lt;td>Multimodal text+image multi-vector. Vidore-v2 open SOTA.&lt;/td>
&lt;/tr>
&lt;/tbody>
&lt;/table>
&lt;h3 id="the-leaderboard-with-caution">The leaderboard, with caution&lt;/h3>
&lt;p>MTEB / MMTEB (Massive Multilingual Text Embedding Benchmark, Enevoldsen et al., arxiv 2502.13595) is the standard thermometer. Top retrieval on MMTEB in mid-2026 is dominated by &lt;code>Qwen3-Embedding-8B&lt;/code> (~70.6 multilingual avg) and &lt;code>Llama-Embed-Nemotron-8B&lt;/code>. Below them, the 7B models (&lt;code>gte-Qwen2-7B&lt;/code>, &lt;code>NV-Embed-v2&lt;/code>) and the 568M ones (&lt;code>bge-m3&lt;/code>, &lt;code>Snowflake-Arctic-L-2.0&lt;/code>) compete task by task.&lt;/p>
&lt;p>&lt;strong>Trap&lt;/strong>: MTEB has started to saturate through &lt;strong>dataset contamination&lt;/strong>. The higher the ranking, the more likely it is that the model saw subsets of the evaluation datasets during training. The rule in production: the leaderboard is for &lt;strong>discarding&lt;/strong> bad models, not for picking the best one. The final decision is made on your own domain eval set, generated with the &lt;a href="https://blog.lo0.es/en/posts/llm-as-judge-exam-marker-not-oracle/">LLM-as-judge&lt;/a> or &lt;a href="https://blog.lo0.es/en/posts/evals-llm-layer-after-tracing/">evals&lt;/a> recipe.&lt;/p>
&lt;h2 id="the-specific-problem-of-multilingual-spanish">The specific problem of multilingual Spanish&lt;/h2>
&lt;p>For a Spanish customer serving corporate, legal or support documentation in Spanish (and often with Catalan / Portuguese / English mixed in), the embedder zoo narrows to &lt;strong>fewer than six viable models&lt;/strong>. The operational exclusions:&lt;/p>
&lt;ol>
&lt;li>English-only models: &lt;code>e5-mistral-7b-instruct&lt;/code>, &lt;code>stella-en-1.5B-v5&lt;/code>, &lt;code>Linq-Embed-Mistral&lt;/code>, &lt;code>mxbai-embed-large-v1&lt;/code>, &lt;code>NV-Embed-v2&lt;/code>, &lt;code>SPLADE-v3&lt;/code>. They cut Spanish performance below an acceptable level: translating the query into English before searching is one route, but it introduces latency, tokenisation drift and another model dependency.&lt;/li>
&lt;li>Models with a non-commercial licence: &lt;code>jina-embeddings-v3&lt;/code>, &lt;code>jina-embeddings-v4&lt;/code>, &lt;code>NV-Embed-v2&lt;/code>, &lt;code>Linq-Embed-Mistral&lt;/code>. They work for a PoC, but commercial production needs an explicit agreement with the vendor. Unless you have the licence signed, they have to be excluded.&lt;/li>
&lt;li>Models with a Gemma licence: &lt;code>bge-multilingual-gemma2&lt;/code>. Allowed for internal use, awkward to redistribute weights to a customer.&lt;/li>
&lt;/ol>
&lt;p>The ones left, ordered by practical choice in sovereign production:&lt;/p>
&lt;ol>
&lt;li>&lt;strong>&lt;code>BAAI/bge-m3&lt;/code>&lt;/strong> — MIT, 568 M, 100+ languages (including Spanish and Catalan, explicitly trained), 8,192 tokens, tri-mode dense+sparse+colbert. &lt;strong>A reasonable default&lt;/strong>. Fits in an RTX 4090. &lt;code>TEI&lt;/code> and &lt;code>Infinity&lt;/code> serve it natively.&lt;/li>
&lt;li>&lt;strong>&lt;code>Snowflake/snowflake-arctic-embed-l-v2.0&lt;/code>&lt;/strong> — Apache 2.0, same size, explicit Matryoshka, better MIRACL/CLEF than bge-m3 on some multilingual tasks, no sparse head. &lt;strong>If the priority is pure MMTEB in Spanish&lt;/strong>.&lt;/li>
&lt;li>&lt;strong>&lt;code>intfloat/multilingual-e5-large-instruct&lt;/code>&lt;/strong> — MIT, 560 M, veteran baseline. Its 512-token window is the big limitation: long documents have to be split first. &lt;strong>If what you already have in production works, do not migrate out of fashion&lt;/strong>.&lt;/li>
&lt;li>&lt;strong>&lt;code>Alibaba-NLP/gte-Qwen2-7B-instruct&lt;/code>&lt;/strong> — Apache 2.0, 32k context, high quality in Spanish (Qwen2 is well trained on Spanish). &lt;strong>If the chunks are long&lt;/strong> (more than 4k tokens) and you have the GPU to serve it (it does not fit in a 4090; it does in an H100). It fits alongside an LLM on an H100 80GB with care.&lt;/li>
&lt;li>&lt;strong>&lt;code>nomic-ai/nomic-embed-text-v2-moe&lt;/code>&lt;/strong> — Apache 2.0, 305 M active, MRL, ~100 languages. &lt;strong>If latency and cost per token rule&lt;/strong>: the MoE gives it disproportionate throughput for its quality.&lt;/li>
&lt;li>&lt;strong>&lt;code>jinaai/jina-colbert-v2&lt;/code>&lt;/strong> — Apache 2.0, multilingual multi-vector, &lt;strong>as a reranker&lt;/strong> or as the main retrieval on a small corpus (&amp;lt; 1 M chunks). The only license-clean multi-vector in Spanish.&lt;/li>
&lt;/ol>
&lt;p>The rule of thumb: &lt;strong>&lt;code>bge-m3&lt;/code> as dense+sparse on the front line, &lt;code>jina-colbert-v2&lt;/code> as a third reranking layer when the use case warrants it&lt;/strong>, and &lt;code>Snowflake Arctic L 2.0&lt;/code> as the alternative if the corpus-specific eval prefers its geometry.&lt;/p>
&lt;h2 id="serving-embeddings-on-premise">Serving embeddings on-premise&lt;/h2>
&lt;p>Three engines split the on-prem embedding serving landscape in 2026, with different profiles.&lt;/p>
&lt;h3 id="text-embeddings-inference-tei-the-standard">Text Embeddings Inference (TEI), the standard&lt;/h3>
&lt;p>&lt;code>huggingface/text-embeddings-inference&lt;/code> is a server written in Rust with a Candle / ONNX backend, integrated FlashAttention and &lt;em>dynamic batching by tokens&lt;/em>. It exposes an OpenAI-compatible &lt;code>/v1/embeddings&lt;/code> API and supports the three &lt;code>bge-m3&lt;/code> modes simultaneously from version 1.5 onwards. For multilingual production it is the obvious default.&lt;/p>
&lt;div class="highlight">&lt;pre tabindex="0" class="chroma">&lt;code class="language-yaml" data-lang="yaml">&lt;span class="line">&lt;span class="cl">&lt;span class="c"># values.yaml — TEI serving multilingual bge-m3 on an RTX 4090&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w">&lt;/span>&lt;span class="nt">image&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">ghcr.io/huggingface/text-embeddings-inference:1.5&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w">&lt;/span>&lt;span class="nt">args&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>- --&lt;span class="l">model-id=BAAI/bge-m3&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>- --&lt;span class="l">pooling=cls&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>- --&lt;span class="l">max-batch-tokens=16384&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>- --&lt;span class="l">max-concurrent-requests=512&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>- --&lt;span class="l">dtype=float16&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w">&lt;/span>&lt;span class="nt">resources&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">limits&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">nvidia.com/gpu&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="m">1&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>Indicative throughput with &lt;code>bge-m3&lt;/code>, &lt;code>fp16&lt;/code>, 512-token sequence, batch 32:&lt;/p>
&lt;ul>
&lt;li>RTX 4090 (24 GB): ~8–15 k tokens/s&lt;/li>
&lt;li>A100 80 GB: ~60 k tokens/s sustained&lt;/li>
&lt;li>H100 80 GB: ~40–80 k tokens/s, with &lt;code>fp8&lt;/code> about 50% more&lt;/li>
&lt;/ul>
&lt;p>(The ranges are approximate and depend on the real batch, the average sequence length and compilation with FA2/FA3.)&lt;/p>
&lt;h3 id="infinity-the-flexible-one">Infinity, the flexible one&lt;/h3>
&lt;p>&lt;code>michaelfeil/infinity&lt;/code> (MIT) is a multi-model FastAPI server able to load &lt;code>bge-m3&lt;/code>, &lt;code>Snowflake Arctic&lt;/code>, &lt;code>Jina-v3&lt;/code>, &lt;code>Nomic&lt;/code>, &lt;code>ColPali&lt;/code>, &lt;code>CLAP&lt;/code> and rerankers simultaneously behind the same OpenAI-style API. Backend &lt;code>PyTorch + Optimum (ONNX/TensorRT)&lt;/code> or &lt;code>CTranslate2&lt;/code>. Useful when you need to serve several different embedders (one for text, another for code, another for images) behind a single endpoint, or when the model does not yet have TEI support.&lt;/p>
&lt;h3 id="vllm---task-embed-for-the-7b-embedders">vLLM &lt;code>--task embed&lt;/code>, for the 7B embedders&lt;/h3>
&lt;p>When the embedder is really an LLM decoder turned into an embedder (&lt;code>e5-mistral-7b-instruct&lt;/code>, &lt;code>gte-Qwen2-7B-instruct&lt;/code>, &lt;code>NV-Embed-v2&lt;/code>, &lt;code>Stella-1.5B&lt;/code>), the natural place to serve it is &lt;code>vLLM&lt;/code>, which already has the &lt;a href="https://blog.lo0.es/en/posts/kv-cache-working-memory-llm-inference/">PagedAttention&lt;/a> and &lt;a href="https://blog.lo0.es/en/posts/continuous-batching-orca-vllm-chunked-prefill-goodput/">continuous batching&lt;/a> stack in production:&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 Alibaba-NLP/gte-Qwen2-7B-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> --task embed &lt;span class="se">\
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="se">&lt;/span> --dtype bfloat16 &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-model-len &lt;span class="m">32768&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> --trust-remote-code
&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>&lt;code>vLLM&lt;/code> detects the correct pooling (last-token on the Qwen / Mistral based ones) and exposes an OpenAI-compatible &lt;code>/v1/embeddings&lt;/code>. For inference clusters already running vLLM with an LLM on another port, &lt;strong>it is the natural way to serve the embedder without standing up another stack&lt;/strong>.&lt;/p>
&lt;h3 id="fastembed-the-lightweight-one">fastembed, the lightweight one&lt;/h3>
&lt;p>&lt;code>qdrant/fastembed&lt;/code> loads &lt;code>bge-small&lt;/code>, &lt;code>MiniLM&lt;/code>, &lt;code>ColBERT&lt;/code> and &lt;code>BM25/SPLADE&lt;/code> sparse in ONNX-CPU. It is not competitive on throughput against TEI/Infinity with a GPU, but it is the right option when you have to serve embeddings on a NUC node without a GPU (see &lt;a href="https://blog.lo0.es/en/posts/mixed-nvidia-intel-environments-llm-inference/">mixed NVIDIA + Intel environments&lt;/a>) or when the embedder is part of the client (a preview in a UI, prior scoring at the edge).&lt;/p>
&lt;h2 id="storage-quantisation-and-the-corpus-arithmetic">Storage, quantisation and the corpus arithmetic&lt;/h2>
&lt;p>The embedding does not stay in the embedder&amp;rsquo;s memory: it lives in the vector DB index and is materialised every time you ingest a new chunk. &lt;strong>The storage cost calculation is what decides the final dimension&lt;/strong>, not MTEB quality. For 1 million chunks with a dense embedder at 1024-d:&lt;/p>
&lt;div class="highlight">&lt;pre tabindex="0" class="chroma">&lt;code class="language-text" data-lang="text">&lt;span class="line">&lt;span class="cl">fp32 : 1,024 dims × 4 B × 1 M = 4,096 MB ≈ 4.0 GB
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">fp16/halfvec: 1,024 dims × 2 B × 1 M = 2,048 MB ≈ 2.0 GB
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">int8 (SQ) : 1,024 dims × 1 B × 1 M = 1,024 MB ≈ 1.0 GB
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">binary : 128 B × 1 M ≈ 128 MB
&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>The quantisation options in order of real use (mid-2026):&lt;/p>
&lt;ol>
&lt;li>&lt;strong>halfvec (&lt;code>fp16&lt;/code>)&lt;/strong>: the default in pgvector 0.7+ and in any serious vector DB. Zero MTEB loss, 2× compression. &lt;strong>Always turn it on.&lt;/strong>&lt;/li>
&lt;li>&lt;strong>Scalar Quantization int8 (SQ)&lt;/strong>: each vector component is mapped to &lt;code>int8&lt;/code> with a global min/max. Typical recall@10 loss: 0–1%. 4× compression. &lt;strong>The Qdrant default&lt;/strong>, supported in Milvus and Weaviate.&lt;/li>
&lt;li>&lt;strong>Binary quantisation&lt;/strong>: &lt;code>bit = sign(v_i - μ_i)&lt;/code>. 32× raw compression. Cold loss 5–15%. Mitigated with &lt;strong>a Hadamard / TurboQuant pre-rotation&lt;/strong> (Qdrant 1.18, Dec 2025): it pre-multiplies by a random orthogonal matrix that spreads the energy across dimensions before binarising. After TurboQuant the loss drops to 1–3%. It also combines with rescoring over the original &lt;code>fp16&lt;/code> for the top-100 candidates.&lt;/li>
&lt;li>&lt;strong>Product Quantization (PQ)&lt;/strong>: the FAISS classic. Up to 64× compression, 2–5% loss. More complex to operate (it needs a trained codebook); in 2026 it has given ground to binary + rescoring.&lt;/li>
&lt;/ol>
&lt;p>A corpus of 100 million chunks (a real figure for a large corporate RAG) with dense &lt;code>bge-m3&lt;/code>:&lt;/p>
&lt;table>
&lt;thead>
&lt;tr>
&lt;th>Format&lt;/th>
&lt;th>Total&lt;/th>
&lt;/tr>
&lt;/thead>
&lt;tbody>
&lt;tr>
&lt;td>fp32&lt;/td>
&lt;td>400 GB&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>fp16&lt;/td>
&lt;td>200 GB&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>int8&lt;/td>
&lt;td>100 GB&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>binary + Hadamard&lt;/td>
&lt;td>&lt;strong>12.5 GB&lt;/strong>&lt;/td>
&lt;/tr>
&lt;/tbody>
&lt;/table>
&lt;p>The difference between 200 GB and 12.5 GB &lt;strong>is the difference between needing a dedicated vector DB node with 8 NVMe in RAID and fitting in the RAM of a single node&lt;/strong>. For large corpora, quantisation is no longer an optimisation: it is the only way to operate.&lt;/p>
&lt;h2 id="the-vector-db-integration">The vector DB integration&lt;/h2>
&lt;p>The vector DBs of 2026 have become hybrid DBs that index all three types at once. The quick map:&lt;/p>
&lt;table>
&lt;thead>
&lt;tr>
&lt;th>Vector DB&lt;/th>
&lt;th>Native hybrid&lt;/th>
&lt;th>Multi-vector / ColBERT&lt;/th>
&lt;th>Quantisation&lt;/th>
&lt;/tr>
&lt;/thead>
&lt;tbody>
&lt;tr>
&lt;td>Qdrant ≥1.10&lt;/td>
&lt;td>RRF/DBSF in &lt;code>query_points&lt;/code> with dense + sparse + colbert in one collection&lt;/td>
&lt;td>Yes, native (one-shot MaxSim)&lt;/td>
&lt;td>SQ int8, binary, &lt;strong>TurboQuant&lt;/strong> 1.18&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>Weaviate&lt;/td>
&lt;td>&lt;code>hybrid(alpha=0.75)&lt;/code> BM25 + dense, multi-target &lt;em>named vectors&lt;/em>&lt;/td>
&lt;td>Yes, as a multi-vector named vector&lt;/td>
&lt;td>PQ, SQ, rotational 8-bit BBQ&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>Milvus ≥2.4&lt;/td>
&lt;td>Multi-vector + sparse in the schema; 2.5 adds native BM25 full-text&lt;/td>
&lt;td>Multi-vector field, MaxSim orchestrated from the client&lt;/td>
&lt;td>SQ, PQ, CAGRA GPU&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>pgvector 0.7+/0.8&lt;/td>
&lt;td>&lt;code>halfvec&lt;/code>, &lt;code>sparsevec&lt;/code>, &lt;code>bit&lt;/code>; HNSW for all three&lt;/td>
&lt;td>Not native (separate-table workaround)&lt;/td>
&lt;td>&lt;code>binary_quantize()&lt;/code>, halfvec, rescoring with exact &lt;code>&amp;lt;#&amp;gt;&lt;/code>&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>Elasticsearch / OpenSearch&lt;/td>
&lt;td>&lt;code>sparse_vector&lt;/code> (ELSER, SPLADE) + &lt;code>dense_vector&lt;/code> HNSW; RRF&lt;/td>
&lt;td>OpenSearch 3.x yes&lt;/td>
&lt;td>ES 9: &lt;code>int8_hnsw&lt;/code> by default, &lt;strong>BBQ&lt;/strong> binary quantization&lt;/td>
&lt;/tr>
&lt;/tbody>
&lt;/table>
&lt;p>For sovereign &lt;strong>on-prem production in Spanish&lt;/strong>, the easiest combination to operate in 2026 is &lt;strong>Qdrant + &lt;code>bge-m3&lt;/code>&lt;/strong>: a single collection indexes the three modes of the same model, the hybrid query with RRF is done in one call, TurboQuant quantisation brings the corpus down to manageable levels, and the operator is a Go binary with simple backups to S3/MinIO. &lt;strong>pgvector + &lt;code>bge-m3&lt;/code>&lt;/strong> is the other reasonable option when you already have Postgres with HA and do not want to add a second DB to the operational inventory; you lose native multi-vector, but you gain cross-cutting SQL over the chunks.&lt;/p>
&lt;p>The HNSW parameters you absolutely have to touch:&lt;/p>
&lt;ul>
&lt;li>&lt;code>M&lt;/code>: connections per node in the graph. 16–32 typical. Higher means more recall, more RAM. For small corpora (&amp;lt;1 M) &lt;code>M=16&lt;/code>; for medium corpora (&lt;code>10 M&lt;/code>) &lt;code>M=24&lt;/code>; for large corpora &lt;code>M=32+IVF-PQ&lt;/code> or &lt;code>M=32+binary&lt;/code>.&lt;/li>
&lt;li>&lt;code>ef_construction&lt;/code>: search width during construction. 100–400. Higher means a better graph, slower construction. &lt;strong>Build with &lt;code>ef_construction=400&lt;/code> even if it is slow; you pay it once.&lt;/strong>&lt;/li>
&lt;li>&lt;code>ef_search&lt;/code>: width during the query. 50–200. &lt;strong>The main knob for the recall/latency trade-off at runtime.&lt;/strong> Start at 64 and measure.&lt;/li>
&lt;/ul>
&lt;h2 id="implications-for-on-premise-inference">Implications for on-premise inference&lt;/h2>
&lt;p>The embedder does not share hardware with the LLM as comfortably as it might seem. The numbers:&lt;/p>
&lt;ul>
&lt;li>&lt;strong>&lt;code>bge-m3&lt;/code> (568 M)&lt;/strong> takes about &lt;code>568 × 2 = 1,136 MB&lt;/code> in &lt;code>fp16&lt;/code> for the weights, plus the batch KV cache, plus temporary activations. In practice it is served comfortably in &lt;strong>6–8 GB of VRAM&lt;/strong> even at a high batch. It fits &lt;strong>alongside a 7B-Q4 LLM&lt;/strong> on an RTX 4090.&lt;/li>
&lt;li>&lt;strong>&lt;code>gte-Qwen2-7B-instruct&lt;/code>&lt;/strong> needs &lt;code>~14 GB fp16&lt;/code> for weights alone. It does not fit next to a 7B LLM on a 4090; on an H100 80 GB it does, with care over simultaneous batching.&lt;/li>
&lt;li>&lt;strong>&lt;code>jina-colbert-v2&lt;/code> (560 M)&lt;/strong> takes &lt;code>~1.1 GB&lt;/code> of weights, but the storage of the multi-vector index is the real cost: 8 GB per million chunks even with Matryoshka and compression.&lt;/li>
&lt;/ul>
&lt;h3 id="on-the-rtx-4090-24-gb">On the RTX 4090 (24 GB)&lt;/h3>
&lt;p>A realistic minimum stack for a Spanish RAG with a corpus under 1 M chunks:&lt;/p>
&lt;div class="highlight">&lt;pre tabindex="0" class="chroma">&lt;code class="language-text" data-lang="text">&lt;span class="line">&lt;span class="cl">GPU 24 GB ┐
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> ├─ TEI bge-m3 (dense + sparse + colbert) │ ~6 GB VRAM, ~12 k tok/s
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> └─ vLLM Qwen2.5-7B-Instruct AWQ Q4 │ ~8 GB VRAM, ~80 tok/s
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">CPU/RAM ┐
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> ├─ Qdrant with bge-m3 dense + sparse + colbert │ ~3 GB RAM per M chunks
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> └─ FastAPI gateway (LiteLLM)
&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>It serves a few tens of RAG QPS with decent multilingual quality. It is the PoC configuration and the deployment for a small site.&lt;/p>
&lt;h3 id="on-the-4h100-80-gb-cluster">On the 4×H100 80 GB cluster&lt;/h3>
&lt;p>For the production case with several million chunks and an SLO of p99 &amp;lt; 500 ms:&lt;/p>
&lt;div class="highlight">&lt;pre tabindex="0" class="chroma">&lt;code class="language-text" data-lang="text">&lt;span class="line">&lt;span class="cl">H100 #1 (80 GB) ── vLLM Qwen3-72B-Instruct AWQ + Qwen2.5-7B speculative ┐
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">H100 #2 (80 GB) ── vLLM gte-Qwen2-7B-instruct (embedding 32k ctx) │ LLM + large embed
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">H100 #3 (80 GB) ── TEI bge-m3 multi-tenant + jina-colbert-v2 reranker │ medium embed
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">H100 #4 (80 GB) ── Hold-out for canary / shadow │ see canary post
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> ┘
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">Qdrant cluster (3 CPU nodes + NVMe) ── 100 M chunks indexed (binary + TurboQuant + rescoring)
&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>This configuration &lt;strong>separates the large LLM from the large embedder&lt;/strong> (they share the Qwen2 architecture but compete for VRAM if put on the same GPU) and leaves a whole H100 for variants in &lt;a href="https://blog.lo0.es/en/posts/canary-blue-green-shadow-llm-models/">canary&lt;/a>. &lt;code>bge-m3&lt;/code> fits with room to spare alongside the reranker on a single H100, serving tens of thousands of requests/min.&lt;/p>
&lt;h2 id="the-seven-operational-traps-of-the-embedder">The seven operational traps of the embedder&lt;/h2>
&lt;ol>
&lt;li>
&lt;p>&lt;strong>Not normalising the query vector.&lt;/strong> Cosine and dot product coincide only when both vectors are unit vectors. If you forget &lt;code>v ← v / ‖v‖₂&lt;/code> in the client, the results are &amp;ldquo;almost right&amp;rdquo;, the top-1 is still correct on trivial queries, the top-10 no longer is, and nobody notices until RAG quality drops 8 points. &lt;strong>Fix&lt;/strong>: bake normalisation into the embedder adapter, not into the client.&lt;/p>
&lt;/li>
&lt;li>
&lt;p>&lt;strong>Chat template slipped into the embedder.&lt;/strong> Some LLM-based embedders (&lt;code>e5-mistral-7b-instruct&lt;/code>, &lt;code>gte-Qwen2-7B&lt;/code>) expect a specific instruction prompt before the text to embed (&lt;code>&amp;quot;Instruct: Retrieve relevant passages\nQuery: ...&amp;quot;&lt;/code>). Forgetting it leaves performance ~5 MTEB points lower. &lt;strong>Fix&lt;/strong>: read the &lt;code>usage_template&lt;/code> in the model card and put it into the embedder wrapper.&lt;/p>
&lt;/li>
&lt;li>
&lt;p>&lt;strong>Badly chosen Matryoshka dimension.&lt;/strong> The default in many Qdrant / pgvector clients is &lt;code>dim=768&lt;/code>. If your embedder is natively MRL at 1024 → 768, fine. If it is 1024 without MRL, truncating to 768 wrecks the space (typical loss -8 MTEB points). &lt;strong>Fix&lt;/strong>: use the model&amp;rsquo;s native &lt;code>dim&lt;/code> and truncate only when storage demands it, and only on MRL models.&lt;/p>
&lt;/li>
&lt;li>
&lt;p>&lt;strong>Missing hard negatives in fine-tuning.&lt;/strong> When the embedder is fine-tuned with your own data (which should be standard practice for corporate RAG), if the mini-batch only carries positives and in-batch negatives &lt;em>from the same domain&lt;/em>, the model learns that anything outside the domain is negative, but inside the domain it does not discriminate. &lt;strong>Fix&lt;/strong>: mine hard negatives with BM25 / dense from your own corpus before fine-tuning.&lt;/p>
&lt;/li>
&lt;li>
&lt;p>&lt;strong>Corpus drift without reindexing.&lt;/strong> When you retrain or replace the embedder but only apply the new model to new chunks, &lt;strong>you end up with the index mixing two incompatible geometries&lt;/strong>. Chunks from the old and the new model are not comparable by cosine. &lt;strong>Fix&lt;/strong>: every change of embedder is a full reindexing of the corpus, planned as an operational &lt;a href="https://blog.lo0.es/en/posts/retrain-closing-the-loop-feedback-dataset-adapter/">retrain&lt;/a>.&lt;/p>
&lt;/li>
&lt;li>
&lt;p>&lt;strong>Tokenizer drift between client and model.&lt;/strong> The Python client that prepares the queries uses its own tokenizer (sometimes &lt;code>tiktoken&lt;/code> by default) and truncates at 8,192 tokens. The embedder uses XLM-R with sentencepiece and truncates at 8,192 &lt;em>of its own tokenizer&lt;/em>. Long queries are truncated differently; the corpus embeddings are consistent but the query ones are not. &lt;strong>Fix&lt;/strong>: use the model&amp;rsquo;s tokenizer in the client or in the wrapper.&lt;/p>
&lt;/li>
&lt;li>
&lt;p>&lt;strong>MTEB overfit as a selection guide.&lt;/strong> The MTEB leaderboard has become a contaminated metric: there is evidence that leading models saw subsets of the evaluation datasets during training. The model that is +0.5 points above the second is not necessarily better for your domain. &lt;strong>Fix&lt;/strong>: your own domain eval set (100-300 labelled query-doc pairs) run with the &lt;a href="https://blog.lo0.es/en/posts/evals-llm-layer-after-tracing/">evals&lt;/a> recipe decides.&lt;/p>
&lt;/li>
&lt;/ol>
&lt;h2 id="license-clean-stack-for-sovereign-production">License-clean stack for sovereign production&lt;/h2>
&lt;p>Let us write down the recommended ending. For a Spanish organisation serving corporate RAG on-prem under &lt;a href="https://blog.lo0.es/en/posts/technical-controls-ens-iso-42001-eu-ai-act-cross-mapping/">ENS / ISO 42001 / EU AI Act&lt;/a>, with a corpus of 1-50 M chunks in Spanish + English + Catalan:&lt;/p>
&lt;table>
&lt;thead>
&lt;tr>
&lt;th>Layer&lt;/th>
&lt;th>Component&lt;/th>
&lt;th>Licence&lt;/th>
&lt;th>Rationale&lt;/th>
&lt;/tr>
&lt;/thead>
&lt;tbody>
&lt;tr>
&lt;td>Dense embedder&lt;/td>
&lt;td>&lt;code>BAAI/bge-m3&lt;/code>&lt;/td>
&lt;td>MIT&lt;/td>
&lt;td>Robust multilingual, 8k tokens, license-clean, served by TEI&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>Sparse embedder&lt;/td>
&lt;td>&lt;code>bge-m3&lt;/code> sparse head&lt;/td>
&lt;td>MIT&lt;/td>
&lt;td>Same pass as the dense one, no second model needed&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>Layer 2 reranker&lt;/td>
&lt;td>&lt;code>BAAI/bge-reranker-v2-m3&lt;/code>&lt;/td>
&lt;td>MIT&lt;/td>
&lt;td>Multilingual cross-encoder from the same team&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>Layer 3 reranker (optional)&lt;/td>
&lt;td>&lt;code>jinaai/jina-colbert-v2&lt;/code>&lt;/td>
&lt;td>Apache 2.0&lt;/td>
&lt;td>License-clean multilingual multi-vector&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>Embed server&lt;/td>
&lt;td>&lt;code>TEI&lt;/code> + &lt;code>Infinity&lt;/code> for multi-model&lt;/td>
&lt;td>Apache 2.0 / MIT&lt;/td>
&lt;td>Supported stack&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>Vector DB&lt;/td>
&lt;td>&lt;code>Qdrant&lt;/code> (preferred) or &lt;code>pgvector 0.8&lt;/code>&lt;/td>
&lt;td>Apache 2.0 / PostgreSQL&lt;/td>
&lt;td>Native hybrid + quantisation&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>Quantisation&lt;/td>
&lt;td>&lt;code>int8 SQ&lt;/code> + &lt;code>binary + TurboQuant + rescoring&lt;/code>&lt;/td>
&lt;td>Apache 2.0&lt;/td>
&lt;td>Cuts the corpus 16×–32× with &amp;lt; 3% loss&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>Minimum hardware&lt;/td>
&lt;td>RTX 4090 24 GB&lt;/td>
&lt;td>—&lt;/td>
&lt;td>For PoC and small sites&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>Production hardware&lt;/td>
&lt;td>4×H100 80 GB cluster&lt;/td>
&lt;td>—&lt;/td>
&lt;td>For RAG with an SLO of p99 &amp;lt; 500 ms&lt;/td>
&lt;/tr>
&lt;/tbody>
&lt;/table>
&lt;p>The alternative stack, if explicit MMTEB in Spanish weighs more than the tri-modality of &lt;code>bge-m3&lt;/code>: replace &lt;code>bge-m3&lt;/code> with &lt;strong>&lt;code>Snowflake/snowflake-arctic-embed-l-v2.0&lt;/code>&lt;/strong> (Apache 2.0, MRL → 256) and explicitly add &lt;code>SPLADE-v3&lt;/code> or plain BM25 for the sparse layer. It loses the elegance of the single forward and gains 1-2 points on Spanish MIRACL.&lt;/p>
&lt;h2 id="conclusion">Conclusion&lt;/h2>
&lt;p>The embedder is the easiest piece of a RAG to oversimplify and the one that decides real quality the most once everything else is in place. The three families (dense, sparse, multi-vector) are not three options to choose between but three trades that &lt;strong>&lt;code>bge-m3&lt;/code> performs in a single pass&lt;/strong> and that &lt;a href="https://blog.lo0.es/en/posts/reranker-hybrid-retrieval-five-chunks-llm-reads/">hybrid retrieval&lt;/a> consumes in parallel. The maths that matters is modest, InfoNCE with &lt;code>τ&lt;/code>, MaxSim, FLOPS regularization, MRL, but the operational traps are many and quiet: forgotten normalisation, missing chat template, badly chosen Matryoshka dimension, tokenizer drift. For sovereign production in Spanish the list of viable models fits in fewer than ten, and the real decision comes down to &amp;ldquo;&lt;strong>&lt;code>bge-m3&lt;/code> or &lt;code>Snowflake Arctic L 2.0&lt;/code>&lt;/strong>&amp;rdquo;, with &lt;code>jina-colbert-v2&lt;/code> added as layer three when the fine-grained quality justifies the cost. The license-clean stack fits on an RTX 4090 for a PoC and scales to a 4×H100 cluster for real production.&lt;/p>
&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-embeddings-rerankers-tei-production/">Serving embeddings and rerankers with TEI in production&lt;/a> — how to serve these models in production with batching and an OpenAI-compatible API.&lt;/p>
&lt;/li>
&lt;li>
&lt;p>&lt;a href="https://blog.lo0.es/en/posts/rag-cpu-data-plane-generation-plane/">Taking RAG to the CPU: separating the data plane from the generation plane&lt;/a> — why these encoders (~500M) are the ideal case for running on CPU and freeing up the GPU.&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> — where the data / retrieval piece fits.&lt;/p>
&lt;/li>
&lt;li>
&lt;p>&lt;a href="https://blog.lo0.es/en/posts/rag-corpus-curation-active-librarian/">RAG corpus curation&lt;/a> — what enters the index before the embedder sees it.&lt;/p>
&lt;/li>
&lt;li>
&lt;p>&lt;a href="https://blog.lo0.es/en/posts/reranker-hybrid-retrieval-five-chunks-llm-reads/">Reranker and hybrid retrieval&lt;/a> — what the committee that consumes the embeddings does.&lt;/p>
&lt;/li>
&lt;li>
&lt;p>&lt;a href="https://blog.lo0.es/en/posts/ontologies-knowledge-graphs-llmops-linnaean-nomenclature/">Ontologies and knowledge graphs in LLMOps&lt;/a> — the type layer that enriches the embedding with queryable metadata; chunks are not just vectors but typed instances against a TBox.&lt;/p>
&lt;/li>
&lt;li>
&lt;p>&lt;a href="https://blog.lo0.es/en/posts/evals-llm-layer-after-tracing/">Evals for LLMs&lt;/a> — how you decide whether an embedder is really better than the current one.&lt;/p>
&lt;/li>
&lt;li>
&lt;p>&lt;a href="https://blog.lo0.es/en/posts/capacity-planning-on-premise-llm-inference/">Capacity planning for LLM inference&lt;/a> — sizing the GPU to serve embedder + LLM on the same cluster.&lt;/p>
&lt;/li>
&lt;/ul>
&lt;h2 id="references">References&lt;/h2>
&lt;ul>
&lt;li>Chen et al. &lt;em>M3-Embedding: Multi-Linguality, Multi-Functionality, Multi-Granularity Text Embeddings Through Self-Knowledge Distillation&lt;/em>. arXiv:2402.03216. &lt;a href="https://arxiv.org/abs/2402.03216">https://arxiv.org/abs/2402.03216&lt;/a>&lt;/li>
&lt;li>Sturua et al. &lt;em>Jina Embeddings v3: Multilingual Embeddings With Task LoRA&lt;/em>. arXiv:2409.10173. &lt;a href="https://arxiv.org/abs/2409.10173">https://arxiv.org/abs/2409.10173&lt;/a>&lt;/li>
&lt;li>Günther et al. &lt;em>Jina Embeddings v4: Universal Embeddings for Multimodal Multilingual Retrieval&lt;/em>. arXiv:2506.18902. &lt;a href="https://arxiv.org/abs/2506.18902">https://arxiv.org/abs/2506.18902&lt;/a>&lt;/li>
&lt;li>Nussbaum et al. &lt;em>Nomic Embed v2: Multilingual Mixture of Experts&lt;/em>. arXiv:2502.07972. &lt;a href="https://arxiv.org/abs/2502.07972">https://arxiv.org/abs/2502.07972&lt;/a>&lt;/li>
&lt;li>Wang et al. &lt;em>Improving Text Embeddings with Large Language Models (E5-Mistral)&lt;/em>. arXiv:2401.00368.&lt;/li>
&lt;li>Yu et al. &lt;em>Arctic-Embed 2.0: Multilingual Retrieval Without Compromise&lt;/em>. Snowflake, 2024-12. &lt;a href="https://www.snowflake.com/blog/arctic-embed-2-multilingual/">https://www.snowflake.com/blog/arctic-embed-2-multilingual/&lt;/a>&lt;/li>
&lt;li>Lee et al. &lt;em>NV-Embed: Improved Techniques for Training LLMs as Generalist Embedding Models&lt;/em>. arXiv:2405.17428.&lt;/li>
&lt;li>Khattab y Zaharia. &lt;em>ColBERT: Efficient and Effective Passage Search via Contextualized Late Interaction over BERT&lt;/em>. SIGIR 2020.&lt;/li>
&lt;li>Santhanam et al. &lt;em>ColBERTv2: Effective and Efficient Retrieval via Lightweight Late Interaction&lt;/em>. arXiv:2112.01488.&lt;/li>
&lt;li>Jha et al. &lt;em>Jina-ColBERT-v2: A General-Purpose Multilingual Late Interaction Retriever&lt;/em>. arXiv:2408.16672.&lt;/li>
&lt;li>Lassance et al. &lt;em>SPLADE-v3&lt;/em>. arXiv:2403.06789.&lt;/li>
&lt;li>Kusupati et al. &lt;em>Matryoshka Representation Learning&lt;/em>. NeurIPS 2022, arXiv:2205.13147.&lt;/li>
&lt;li>Enevoldsen et al. &lt;em>MMTEB: Massive Multilingual Text Embedding Benchmark&lt;/em>. arXiv:2502.13595.&lt;/li>
&lt;li>Hugging Face Text Embeddings Inference. &lt;a href="https://github.com/huggingface/text-embeddings-inference">https://github.com/huggingface/text-embeddings-inference&lt;/a>&lt;/li>
&lt;li>Michael Feil. &lt;em>Infinity&lt;/em>. &lt;a href="https://github.com/michaelfeil/infinity">https://github.com/michaelfeil/infinity&lt;/a>&lt;/li>
&lt;li>Qdrant. &lt;em>TurboQuant 1.18 release notes&lt;/em>. &lt;a href="https://qdrant.tech/articles/turboquant-quantization/">https://qdrant.tech/articles/turboquant-quantization/&lt;/a>&lt;/li>
&lt;li>pgvector. Release notes 0.7 / 0.8. &lt;a href="https://github.com/pgvector/pgvector">https://github.com/pgvector/pgvector&lt;/a>&lt;/li>
&lt;li>Hugging Face. &lt;em>Embedding Quantization&lt;/em>. &lt;a href="https://huggingface.co/blog/embedding-quantization">https://huggingface.co/blog/embedding-quantization&lt;/a>&lt;/li>
&lt;/ul></description></item><item><title>Anatomy of the twelve DCGM and five vLLM metrics: analogies, documented anomalies and real cases 2024-2026</title><link>https://blog.lo0.es/en/posts/anatomy-dcgm-vllm-metrics-analogies-documented-anomalies/</link><pubDate>Tue, 02 Jun 2026 04:00:00 +0200</pubDate><guid>https://blog.lo0.es/en/posts/anatomy-dcgm-vllm-metrics-analogies-documented-anomalies/</guid><description>&lt;blockquote>
&lt;p>This post goes deeper into the list of metrics presented in &lt;a href="https://blog.lo0.es/en/posts/gpu-observability-llm-inference-twelve-dcgm-vllm-metrics/">GPU observability for LLM inference&lt;/a>. There each metric got its G/A/R threshold and PromQL query; here each one gets its explanatory analogy and the anomaly documented in production with a referenced public case. It is the post worth having open when an alert fires and you still do not know what to do with it; the &lt;a href="https://blog.lo0.es/en/posts/incident-response-runbooks-llm-inference-kafka-keep/">next post on runbooks&lt;/a> translates each anomaly into concrete action.&lt;/p>
&lt;/blockquote>
&lt;h2 id="tldr">TL;DR&lt;/h2>
&lt;p>The twelve DCGM metrics (compute, memory, thermal-power, health) and the five vLLM engine metrics (concurrency, KV pool, SLO latencies) covered in the previous post paint the cluster&amp;rsquo;s cockpit, but a list without context does not teach you to diagnose. Each metric has a &lt;strong>recurring anomalous pattern&lt;/strong> documented in the public literature (academic papers, GitHub issues, OEM KBs, operator blogs) that the veteran operator recognises instantly and the junior one does not. This post develops each metric with an &lt;strong>analogy of its own&lt;/strong> that pins down which question it answers, and with the &lt;strong>statistically relevant anomaly&lt;/strong> backed by figures from documented incidents. Three examples of the calibre involved: &lt;strong>Meta&lt;/strong> published that during the training of Llama 3 405B on 16,384 H100s there were &lt;strong>419 unplanned failures in 54 days&lt;/strong>, one every 3 hours, with GPU + HBM3 accounting for 47 % of the total; the paper &lt;em>Story of Two GPUs&lt;/em> (arXiv 2503.11901) quantifies that &lt;strong>the H100 has 3.2× worse MTBE from uncorrectable ECC than the A100&lt;/strong>, attributable to the higher density of HBM3; issue &lt;strong>vllm#16300&lt;/strong> documents that on a cluster of 8×A100 80 GB &lt;strong>TP=8 delivers worse throughput than TP=4&lt;/strong> because NVLink saturation kills the partition speedup. The KBs &lt;strong>Dell 000220508&lt;/strong> and &lt;strong>Lenovo HT514380&lt;/strong> formalise the recurring &lt;em>HW Power Brake&lt;/em> case in H100 racks overcommitted at PDU level. Issue &lt;strong>vllm#25677&lt;/strong> showed &lt;em>chunked prefill&lt;/em> 10× slower than without it on Qwen3-30B-A3B (bad calibration of &lt;code>max_num_batched_tokens&lt;/code>). Issue &lt;strong>vllm#11912&lt;/strong> documents a TPOT regression from 15.7 ms to 25.7 ms crossing version 0.6.4. Each case includes a verifiable URL. The operational rule: when an alert arrives, look first at the anomalous pattern associated with the metric that fired, &lt;strong>before&lt;/strong> opening the request&amp;rsquo;s trace; 80 % of degradations match one of the documented patterns.&lt;/p>
&lt;h2 id="you-are-here-observe--the-diagnosis-layer">You are here: OBSERVE — the diagnosis layer&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: Observe, diagnosis layer">
&lt;style>.box{stroke:#444;stroke-width:1.4;rx:6}.active{fill:#c9a8e9;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(#anm)}.cyc{stroke:#888;stroke-width:1.2;fill:none;stroke-dasharray:4 2;marker-end:url(#anm)}&lt;/style>
&lt;defs>&lt;marker id="anm" 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: OBSERVE · each metric is a question with a typical anomaly attached&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 idle"/>&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 active"/>&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="family-1--compute">Family 1 — Compute&lt;/h2>
&lt;h3 id="dcgm_fi_prof_sm_occupancy--is-there-parallel-work-in-the-engines">&lt;code>DCGM_FI_PROF_SM_OCCUPANCY&lt;/code> — is there parallel work in the engines?&lt;/h3>
&lt;p>&lt;strong>The analogy.&lt;/strong> An industrial kitchen with 32 hobs and a single chef. The metric answers &lt;em>&amp;ldquo;how many hobs have a pan on them right now?&amp;rdquo;&lt;/em>. If half are empty, the kitchen is under-used: the orders come out one after another because the chef does not open up parallelism. If all of them are occupied but the chef is standing still watching a stopwatch, the hobs are on but nothing is being cooked (a pathological kernel saturating SMs without doing useful work).&lt;/p>
&lt;p>&lt;strong>The documented anomaly.&lt;/strong> The best-known trap: &lt;strong>high SM occupancy does not imply real throughput&lt;/strong>. The article &lt;em>&amp;ldquo;GPU Utilization Is a Counter, Not a Cause&amp;rdquo;&lt;/em> (Ingero, May 2026) put it in one exact sentence: &lt;em>&amp;ldquo;a kernel running at 5 % of peak FLOPS for 100 ms still reads 100 % on SM_ACTIVE&amp;rdquo;&lt;/em>. In MoE workloads the effect turns pathological: overloaded experts produce the &lt;strong>Straggler Effect&lt;/strong> (paper arXiv 2503.05066). The SMs look busy while waiting for the saturated expert, and the utilisation dashboard paints green while latency goes through the roof.&lt;/p>
&lt;p>&lt;strong>Operational implication.&lt;/strong> Do not trust sizing or autoscaling to SM occupancy alone. Always combine it with &lt;code>PIPE_TENSOR_ACTIVE&lt;/code> (is there useful compute?) and &lt;code>DRAM_ACTIVE&lt;/code> (is memory the bottleneck?). The normal LLM decode regime is 30–55 %, not 99 %; seeing a sustained 99 % with high TPOT is a symptom of a kernel bug or an MoE straggler.&lt;/p>
&lt;h3 id="dcgm_fi_prof_pipe_tensor_active--are-the-tensor-cores-producing">&lt;code>DCGM_FI_PROF_PIPE_TENSOR_ACTIVE&lt;/code> — are the tensor cores producing?&lt;/h3>
&lt;p>&lt;strong>The analogy.&lt;/strong> A factory with two lines: the manual one (CUDA cores) and the automated one (tensor cores). The metric answers &lt;em>&amp;ldquo;what percentage of the time is the automated line active?&amp;rdquo;&lt;/em>. If you buy an H100 for its tensor cores and the automated line is at 5 %, you have paid for a Ferrari to do bicycle courier work.&lt;/p>
&lt;p>&lt;strong>The documented anomaly.&lt;/strong> Issue &lt;strong>vllm#20783&lt;/strong> (July 2025) was titled literally &lt;em>&amp;ldquo;Performance Anomaly: compressed-tensors shows no speedup over BF16 on H100&amp;rdquo;&lt;/em>. The operator expected 1.5–2× with FP8 quantisation and got parity with BF16; the &lt;code>PIPE_TENSOR_ACTIVE&lt;/code> metric revealed that the FP8 path was not running on the HMMA units (the tensor unit for FP16/BF16/FP8) and was falling back to CUDA cores. Issue &lt;strong>vllm#31475&lt;/strong> documented the parallel case on MI300X: FP8 slower than BF16 because of a regression in the ROCm path. DCGM exposes separate counters per unit (&lt;code>HMMA&lt;/code> for FP16/BF16/FP8, &lt;code>IMMA&lt;/code> for INT8, &lt;code>DMMA&lt;/code> for TF32/FP32); if &lt;code>HMMA&lt;/code> is low even though the model is BF16, the engine is not using tensor cores.&lt;/p>
&lt;p>&lt;strong>Operational implication.&lt;/strong> Check &lt;code>PIPE_TENSOR_ACTIVE&lt;/code> after every change of quantisation or engine version; a supposedly neutral change may have disabled the optimised path. For prefill expect 50–80 %; for decode 15–30 % is normal (decode is memory-bound, not compute-bound). A figure below 5 % in prefill means the engine is not using tensor cores.&lt;/p>
&lt;h3 id="dcgm_fi_prof_dram_active--is-the-hbm-saturated">&lt;code>DCGM_FI_PROF_DRAM_ACTIVE&lt;/code> — is the HBM saturated?&lt;/h3>
&lt;p>&lt;strong>The analogy.&lt;/strong> A motorway with N lanes. The metric answers &lt;em>&amp;ldquo;what percentage of the time are all the lanes occupied moving cars?&amp;rdquo;&lt;/em>. When the tensor cores ask for data faster than the HBM delivers it, the motorway is at 95 % and the engines wait. In decode, this is the normal regime: you walk the model weights and the KV cache through for every token.&lt;/p>
&lt;p>&lt;strong>The documented anomaly.&lt;/strong> The paper &lt;em>&amp;ldquo;Mind the Memory Gap: Unveiling GPU Bottlenecks in Large-Batch LLM Inference&amp;rdquo;&lt;/em> (arXiv 2503.08311) quantifies that at contexts ≥ 128k, reading the KV cache &lt;strong>dominates the total decode time&lt;/strong> and saturates the HBM3 (3.35 TB/s on the H100). The distinctive pattern: &lt;code>DRAM_ACTIVE&lt;/code> &amp;gt; 80 % with &lt;code>PIPE_TENSOR_ACTIVE&lt;/code> around 10–20 %. Raising the batch no longer helps: the bottleneck is not FLOPS, it is bandwidth. The useful lever is compressing the KV, see &lt;a href="https://blog.lo0.es/en/posts/quantization-llm-inference-fp8-int4-gguf/">Quantisation&lt;/a> for &lt;code>--kv-cache-dtype=fp8&lt;/code>, which cuts the KV footprint by roughly 50 %.&lt;/p>
&lt;p>&lt;strong>Operational implication.&lt;/strong> If &lt;code>DRAM_ACTIVE &amp;gt; 95 %&lt;/code> is sustained and &lt;code>gpu_cache_usage_perc &amp;lt; 70 %&lt;/code>, &lt;strong>something other than your engine is asking for HBM&lt;/strong> (a leak in a library, another process sharing the GPU without MIG). Investigate immediately with &lt;code>nvidia-smi&lt;/code> and &lt;code>fuser /dev/nvidia*&lt;/code>.&lt;/p>
&lt;h2 id="family-2--memory">Family 2 — Memory&lt;/h2>
&lt;h3 id="dcgm_fi_dev_fb_used--how-much-vram-has-been-consumed">&lt;code>DCGM_FI_DEV_FB_USED&lt;/code> — how much VRAM has been consumed?&lt;/h3>
&lt;p>&lt;strong>The analogy.&lt;/strong> The fuel level in an aircraft&amp;rsquo;s tank in flight: the pilot needs to know how much is left &lt;strong>and at what rate it is being consumed&lt;/strong>, not just the instantaneous figure. An H100 at 88 % FB used and &lt;strong>stable&lt;/strong> can operate calmly; the same figure &lt;strong>rising 2 %/min&lt;/strong> announces OOM in 7 minutes.&lt;/p>
&lt;p>&lt;strong>The documented anomaly.&lt;/strong> Issue &lt;strong>dcgm-exporter#512&lt;/strong> documents a surprise relevant to MIG clusters: &lt;strong>&lt;code>DCGM_FI_DEV_FB_USED&lt;/code> and &lt;code>DCGM_FI_DEV_FB_FREE&lt;/code> are absent on H100 GPU instances with MIG enabled&lt;/strong>, present on A100 and B200, but an exporter bug hides them on H100-MIG. Operators who assume the dashboard covers everything discover the blind spot on the day of the first OOM. Issue &lt;strong>dcgm-exporter#271&lt;/strong> documents another detail: &lt;code>FB_USED + FB_FREE&lt;/code> &lt;strong>does not always add up to a constant&lt;/strong> because there is driver-reserved overhead that shows up in the delta. The original &lt;strong>PagedAttention/vLLM&lt;/strong> paper estimated that pre-PagedAttention serving frameworks wasted &lt;strong>60–80 %&lt;/strong> of the KV cache to fragmentation; PagedAttention brought it down to under 4 %.&lt;/p>
&lt;p>&lt;strong>Operational implication.&lt;/strong> On H100 MIG clusters, check that &lt;code>DCGM_FI_DEV_FB_USED&lt;/code> appears per instance before trusting alerts; if it is absent, monitor through &lt;code>nvidia-smi --query-gpu=memory.used&lt;/code> directly. Operational rule: alert on the &lt;strong>delta&lt;/strong> (a sustained rise), not just an absolute threshold.&lt;/p>
&lt;h3 id="dcgm_fi_dev_fb_free--the-absolute-complement">&lt;code>DCGM_FI_DEV_FB_FREE&lt;/code> — the absolute complement&lt;/h3>
&lt;p>&lt;strong>The analogy.&lt;/strong> The &amp;ldquo;range remaining&amp;rdquo; indicator in a modern car: it complements the percentage with an absolute figure that is directly actionable.&lt;/p>
&lt;p>&lt;strong>The documented anomaly.&lt;/strong> When an aggressive PagedAttention pool leaves &lt;code>FB_FREE&lt;/code> at small absolute values (&amp;lt; 2 GiB), any normal allocation of transient buffers (the activations of a large prefill) can push you into OOM. The classic pattern: a &amp;ldquo;green&amp;rdquo; percentage (87 %) but a &amp;ldquo;red&amp;rdquo; absolute (&amp;lt; 4 GiB free on an 80 GB H100).&lt;/p>
&lt;p>&lt;strong>Operational implication.&lt;/strong> A complementary alert with an absolute threshold: &lt;code>DCGM_FI_DEV_FB_FREE &amp;lt; 4096&lt;/code> (MiB). It is the safety net for the cases where the percentage misleads because the engine is configured with a very high &lt;code>gpu_memory_utilization&lt;/code>.&lt;/p>
&lt;h3 id="dcgm_fi_dev_nvlink_bandwidth_total--does-the-internal-bus-hold-up">&lt;code>DCGM_FI_DEV_NVLINK_BANDWIDTH_TOTAL&lt;/code> — does the internal bus hold up?&lt;/h3>
&lt;p>&lt;strong>The analogy.&lt;/strong> An interstate motorway between four cities. Every car that crosses to do a tensor parallel all-reduce pays a toll and consumes width. When there are more cars than the motorway supports, the latency to reach the destination shoots up, even if each individual car is fast.&lt;/p>
&lt;p>&lt;strong>The documented anomaly.&lt;/strong> Issue &lt;strong>vllm#16300&lt;/strong> (April 2025) was titled &lt;em>&amp;ldquo;Performance degradation with tp=8 compared to tp=4 on 8×A100(80G)&amp;rdquo;&lt;/em> and documented &lt;strong>TP=8 delivering worse throughput than TP=4&lt;/strong> on the same cluster, same model, same quantisation. Root cause: tensor parallelism requires an all-reduce after each attention and MLP block; at TP=8, the communication cost between 8 GPUs (even over NVSwitch) grows faster than the speedup of partitioning the compute. The practical rule that emerges: &lt;strong>TP=4 + 2 replicas&lt;/strong> usually delivers better latency/throughput than &lt;strong>TP=8 + 1 replica&lt;/strong>, except for extremely long contexts (≥128k) where you need the aggregate VRAM. Theoretical NVLink 4.0 capacity on the H100 SXM: around 450 GB/s per GPU; typical sustained TP=4 regime: 50–150 GB/s.&lt;/p>
&lt;p>&lt;strong>Operational implication.&lt;/strong> If &lt;code>NVLINK_BANDWIDTH_TOTAL &amp;gt; 90 %&lt;/code> of capacity is sustained, this is not a problem &lt;em>solvable by raising parallelism&lt;/em>. The opposite: &lt;strong>lower TP&lt;/strong>. The metric is orthogonal to the sizing from &lt;a href="https://blog.lo0.es/en/posts/capacity-planning-on-premise-llm-inference/">capacity planning&lt;/a>: the ceiling is not only VRAM/time, it is also the bus.&lt;/p>
&lt;h2 id="family-3--thermal-and-power">Family 3 — Thermal and power&lt;/h2>
&lt;h3 id="dcgm_fi_dev_gpu_temp--is-the-gpu-breathing">&lt;code>DCGM_FI_DEV_GPU_TEMP&lt;/code> — is the GPU breathing?&lt;/h3>
&lt;p>&lt;strong>The analogy.&lt;/strong> The body temperature of an elite athlete under full effort. 36–37 °C is normal; 38 °C is sustainable stress; above 39 °C the body activates protective mechanisms (sweating, slowing down) that &lt;strong>degrade performance&lt;/strong>. The GPU does the same: above a thermal threshold, it reduces its clock automatically. If it did not, it would break.&lt;/p>
&lt;p>&lt;strong>The documented anomaly.&lt;/strong> The H100 SXM5 with its 700 W TDP has thermal thresholds that are not entirely public (NVIDIA does not publish them exhaustively in the datasheet), but the behaviour is well known: above roughly 85 °C edge or roughly 95 °C HBM, the bit &lt;code>0x40 HW_THERMAL&lt;/code> appears in the clock throttle reasons. Operators on the NVIDIA developer forum report that with a &lt;strong>rack inlet temperature above 27 °C&lt;/strong>, throttling is routine. The NVIDIA HGX Platform paper states that the minimum recommended air flow is &lt;strong>&amp;gt; 1000 CFM/kW&lt;/strong>; densities above 30 kW/rack at 700 W TDP demand mandatory liquid cooling because forced air does not get there.&lt;/p>
&lt;p>&lt;strong>Operational implication.&lt;/strong> If &lt;code>GPU_TEMP &amp;gt; 83 °C&lt;/code> is sustained, look first at &lt;code>CLOCK_THROTTLE_REASONS&lt;/code> (bit 0x40) and at the rack inlet temperature: this is not an engine problem, it is an air flow one. For legacy air-cooled racks, consider redistributing thermal load or installing a rear-door HX.&lt;/p>
&lt;h3 id="dcgm_fi_dev_power_usage--how-much-is-it-asking-of-the-socket">&lt;code>DCGM_FI_DEV_POWER_USAGE&lt;/code> — how much is it asking of the socket?&lt;/h3>
&lt;p>&lt;strong>The analogy.&lt;/strong> The instantaneous draw of an industrial appliance plugged into a three-phase outlet with a sized breaker. If the washing machine starts at 9 kW and the breaker is 10 kW, you live on the edge; if the washing machine &amp;ldquo;gets along&amp;rdquo; with the breaker it is because somebody sized it consciously.&lt;/p>
&lt;p>&lt;strong>The documented anomaly.&lt;/strong> A published real measurement: an H100 SXM5 with vLLM running Llama 3.1 405B at batch=4 draws &lt;strong>around 697 W at-wall&lt;/strong> sustained (NVIDIA TDP 700 W). Now the interesting operational lever: &lt;strong>lowering &lt;code>nvidia-smi -pl&lt;/code> from 700 W to 500 W&lt;/strong> delivers around 30 % energy savings with only around 20 % loss of throughput. A cluster of 4 nodes × 8 H100 at 700 W equals around 22 kW of GPU alone; at 500 W, around 16 kW. The difference pays an entire quarter&amp;rsquo;s electricity bill in clusters operated around the clock. A three-phase 415 VAC PDU branch at 60–80 A supports around 32 kW, roughly 4 DGX H100. Legacy 208 V does not support H100 density; reference: NVIDIA DGX SuperPOD Electrical Specifications.&lt;/p>
&lt;p>&lt;strong>Operational implication.&lt;/strong> A useful metric for three things: (1) detecting anomalously low workloads (unexpected idle), (2) computing per-tenant showback of real energy cost (not estimates), (3) alerting if the draw approaches the PDU branch limit. Keep &lt;strong>GPU → PDU branch → breaker&lt;/strong> mapped in the CMDB.&lt;/p>
&lt;h3 id="dcgm_fi_dev_clock_throttle_reasons--who-is-stepping-on-the-brake">&lt;code>DCGM_FI_DEV_CLOCK_THROTTLE_REASONS&lt;/code> — who is stepping on the brake?&lt;/h3>
&lt;p>&lt;strong>The analogy.&lt;/strong> The &amp;ldquo;limited mode&amp;rdquo; warning light on the dashboard of a modern car. When it lights up, the car reduces its performance automatically, but &lt;strong>it does not tell you why&lt;/strong> unless you know how to read the combination of letters. The bits of the bitmap are those letters.&lt;/p>
&lt;p>&lt;strong>The documented anomaly.&lt;/strong> A public case formally acknowledged by two different OEMs: &lt;strong>Dell KB 000220508&lt;/strong> and &lt;strong>Lenovo HT514380&lt;/strong> address the same phenomenon: &lt;em>HW Power Brake Slowdown active&lt;/em> (bit &lt;code>0x80&lt;/code>) on the H100 SXM. The cause is not the GPU: it is the chassis PDU sending an electrical power-brake signal because the rack branch is close to the breaker limit. The operator sees throughput down 30–50 % with no XID and no ECC, and the inference engine &amp;ldquo;is healthy&amp;rdquo;; the problem is in the electrics. The NVIDIA developer forum thread on &lt;em>&amp;ldquo;HW Power Brake Slowdown&amp;rdquo;&lt;/em> corroborates the pattern. Bit &lt;code>0x40 HW_THERMAL&lt;/code> appears in badly ventilated racks; bit &lt;code>0x04 SW_POWER_CAP&lt;/code> appears if somebody left &lt;code>nvidia-smi -pl 500&lt;/code> set and nobody reverts it.&lt;/p>
&lt;p>&lt;strong>Operational implication.&lt;/strong> Any bit other than 0 or &lt;code>Idle&lt;/code> (bit 0x01) sustained &lt;strong>is an immediate alert&lt;/strong>. The recommended decoding: record the complete bitmap value in the log plus the attribute &lt;code>throttle.reasons.decoded=[&amp;quot;HW_THERMAL&amp;quot;, &amp;quot;HW_POWER_BRAKE&amp;quot;]&lt;/code> on the OTel span. Without that, incident response does not know what to do.&lt;/p>
&lt;h2 id="family-4--health-the-catastrophic-reports">Family 4 — Health (the catastrophic reports)&lt;/h2>
&lt;h3 id="dcgm_fi_dev_xid_errors--the-drivers-red-codes">&lt;code>DCGM_FI_DEV_XID_ERRORS&lt;/code> — the driver&amp;rsquo;s red codes&lt;/h3>
&lt;p>&lt;strong>The analogy.&lt;/strong> The critical alarm lights in a nuclear control room. They do not climb gradually; they either appear or they do not. Each XID is a predefined code (XID 13 = graphics engine exception; XID 31 = MMU fault; XID 43 = stopped channel; XID 79 = GPU fallen off the bus; XID 95 = uncontained ECC), and each one has its documented procedure.&lt;/p>
&lt;p>&lt;strong>The documented anomaly.&lt;/strong> The most studied public case: &lt;strong>Meta&lt;/strong> published that during the training of Llama 3 405B on &lt;strong>16,384 H100s over 54 days&lt;/strong> there were &lt;strong>419 unplanned failures&lt;/strong>, one every 3 hours at cluster scale. GPUs accounted for 148 (35 %) plus HBM3 for 72 (17 %), almost half of all failures. The paper &lt;em>&amp;ldquo;Story of Two GPUs: Characterizing the Resilience of Hopper H100 and Ampere A100&amp;rdquo;&lt;/em> (arXiv 2503.11901) quantifies with a different dataset (2.1M GPU-hours) that the H100 has &lt;strong>3.2× worse MTBE for uncorrectable ECC than the A100&lt;/strong>. The ByteDance MegaScale paper reports that XID 79 (&amp;ldquo;GPU fallen off the bus&amp;rdquo;) co-occurs with PCIe errors in &lt;strong>43 % of cases&lt;/strong>. The NVIDIA developer forum documents persistent cases of XID 31 (MMU fault) that &lt;strong>follow the GPU when it is moved to a different PCIe slot&lt;/strong>, a hardware bug in the module, not in the backplane.&lt;/p>
&lt;p>&lt;strong>Operational implication.&lt;/strong> &lt;strong>Any increment of the counter is an immediate alert&lt;/strong>: many XIDs demand a node reset or an RMA of the GPU. The XID-by-XID distinction matters: XID 13/43 is usually a software bug if it coincides with a recent change; XID 31/48/79/94/95 is usually hardware. Keep a canonical &lt;code>xid → procedure&lt;/code> table. See &lt;a href="https://blog.lo0.es/en/posts/incident-response-runbooks-llm-inference-kafka-keep/">the runbooks&lt;/a> for the translation into concrete action.&lt;/p>
&lt;h3 id="dcgm_fi_dev_ecc_dbe_vol_total--the-errors-that-corrupt-data">&lt;code>DCGM_FI_DEV_ECC_DBE_VOL_TOTAL&lt;/code> — the errors that corrupt data&lt;/h3>
&lt;p>&lt;strong>The analogy.&lt;/strong> An accounting ledger where sometimes someone erases an entry and rewrites it (corrected single-bit ECC: note a change in the margin and carry on) and sometimes someone burns two pages at once (double-bit: the information is lost, the audit has to stop).&lt;/p>
&lt;p>&lt;strong>The documented anomaly.&lt;/strong> The paper &lt;em>&amp;ldquo;Characterizing GPU Resilience&amp;rdquo;&lt;/em> quantifies for the H100 that when XID 48 (DBE) appears, &lt;strong>the job in flight dies with 100 % probability&lt;/strong> (5 out of 5 in the dataset studied). The documented recovery: drain the node, reset, complete the row remap, equalling &lt;strong>around 19 hours of node downtime&lt;/strong>. HBM3 density explains the worse MTBE against HBM2e: there are more cells per unit area, and therefore a higher statistical probability of degradation. In Llama 3, HBM3 caused 72 of the 419 interruptions (17 %).&lt;/p>
&lt;p>&lt;strong>Operational implication.&lt;/strong> Any value &amp;gt; 0 is a critical alert. The GPU must be &lt;strong>drained immediately&lt;/strong>, removed from the scheduler, fully reset, validated for row remap with &lt;code>nvidia-smi -q -d ROW_REMAPPER&lt;/code> (&lt;code>Pending: No&lt;/code>), and given an extensive smoke test before returning to the pool. If the row remap uses more than 4–8 spare pages on one GPU, plan a replacement in the next window: the degradation is progressive.&lt;/p>
&lt;h3 id="dcgm_fi_dev_retired_dbe--the-pages-marked-for-retirement">&lt;code>DCGM_FI_DEV_RETIRED_DBE&lt;/code> — the pages marked for retirement&lt;/h3>
&lt;p>&lt;strong>The analogy.&lt;/strong> The tiles a museum restorer marks with yellow tape because they are damaged. They pose no immediate danger (the room stays open), but the accumulation says the floor is degrading structurally and the full replacement has to be planned.&lt;/p>
&lt;p>&lt;strong>The documented anomaly.&lt;/strong> NVIDIA documents up to &lt;strong>512 spare pages per HBM bank&lt;/strong> on the H100; the &lt;code>RETIRED_DBE&lt;/code> counter indicates how many have been used. Operators on NVIDIA forums report that above &lt;strong>4–8 retired pages on a specific GPU&lt;/strong>, the frequency of XID 48 rises. The pattern: a GPU with 6 retired pages today becomes 12 in a month, a first XID 48 two months later, and a forced drain.&lt;/p>
&lt;p>&lt;strong>Operational implication.&lt;/strong> A trend metric, not an immediate alert. Record the value per GPU and review it monthly; GPUs with rising values go into the proactive replacement plan before the catastrophic failure.&lt;/p>
&lt;h2 id="the-five-vllm-engine-metrics">The five vLLM engine metrics&lt;/h2>
&lt;h3 id="vllmnum_requests_running--how-many-requests-fit-in-the-batch">&lt;code>vllm:num_requests_running&lt;/code> — how many requests fit in the batch?&lt;/h3>
&lt;p>&lt;strong>The analogy.&lt;/strong> The number of cars a toll booth lets through at once. If the barrier opens for N at a time, car N+1 waits in the queue. Saturation shows up because the line does not get shorter.&lt;/p>
&lt;p>&lt;strong>The documented anomaly.&lt;/strong> Reaching the configured &lt;code>--max-num-seqs&lt;/code> and staying there is the typical symptom of a &lt;strong>cluster below its sizing&lt;/strong>; the engine admits up to the ceiling and no further. The query &lt;code>vllm:num_requests_running == max_num_seqs&lt;/code> for more than 5 minutes indicates firm saturation.&lt;/p>
&lt;p>&lt;strong>Operational implication.&lt;/strong> Combine it with &lt;code>num_requests_waiting&lt;/code>: if running is at the ceiling AND waiting &amp;gt; 0, you have to scale. If running is at the ceiling and waiting is 0, you are in the optimal regime (cluster used to the maximum with no queue).&lt;/p>
&lt;h3 id="vllmnum_requests_waiting--the-primary-saturation-indicator">&lt;code>vllm:num_requests_waiting&lt;/code> — the primary saturation indicator&lt;/h3>
&lt;p>&lt;strong>The analogy.&lt;/strong> The visible queue in front of the toll booth. As long as it is empty, the system flows; the moment a sustained queue forms, drivers start arriving late at their destination, and TTFT goes through the roof.&lt;/p>
&lt;p>&lt;strong>The documented anomaly.&lt;/strong> A public case in &lt;em>&amp;ldquo;11-Second Time to First Token on a Healthy vLLM Server&amp;rdquo;&lt;/em> (Medium, Ingero, 2026): a server with no XIDs, no preemption, DCGM metrics all green, but &lt;code>num_requests_waiting&lt;/code> sustained above 0 and a TTFT of &lt;strong>11 seconds&lt;/strong>. Issue &lt;strong>vllm#16985&lt;/strong> documents progressive degradation in long sessions: the queue grows slowly over hours without any other indicator moving. The root cause is not hardware, it is &lt;strong>admission control&lt;/strong>: the arrival rate exceeds the completion rate and the system does not enqueue any more, it leaves requests in &lt;code>WAITING&lt;/code>. Red Hat designates it as &lt;strong>the primary saturation metric&lt;/strong> in its tutorial &lt;em>&amp;ldquo;5 steps to triage vLLM performance&amp;rdquo;&lt;/em>.&lt;/p>
&lt;p>&lt;strong>Operational implication.&lt;/strong> The primary HPA metric in KEDA, see &lt;a href="https://blog.lo0.es/en/posts/llm-inference-autoscaling-kubernetes-hpa-keda/">Autoscaling LLM on Kubernetes&lt;/a>. Typical threshold: alert if &lt;code>avg_over_time(vllm:num_requests_waiting[5m]) &amp;gt; 5&lt;/code>. For canaries: if the queue only forms in the v2 pool, it is a regression of the new model, not cluster load.&lt;/p>
&lt;h3 id="vllmgpu_cache_usage_perc--the-kv-cache-pool">&lt;code>vllm:gpu_cache_usage_perc&lt;/code> — the KV cache pool&lt;/h3>
&lt;p>&lt;strong>The analogy.&lt;/strong> The capacity of an events hall where each guest takes up a variable amount of space. The maître d&amp;rsquo; admits guests up to capacity; when a new guest arrives and there is no room, he &lt;strong>throws out the guest who has been there longest&lt;/strong> to make space for the newcomer. That is vLLM&amp;rsquo;s preempt-on-OOM.&lt;/p>
&lt;p>&lt;strong>The documented anomaly.&lt;/strong> Issue &lt;strong>vllm#5051&lt;/strong> &lt;em>&amp;ldquo;Add num_requests_preempted metric&amp;rdquo;&lt;/em> was born exactly out of operators observing degradation with no direct metric telling them how many requests were being thrown out. The official vLLM documentation confirms: &lt;em>&amp;ldquo;sustained &lt;code>gpu_cache_usage_perc&lt;/code> above 90 % indicates the server is approaching its KV cache limit and will begin preempting sequences&amp;rdquo;&lt;/em> (oldest-first). The distinctive visual pattern: a &lt;strong>sawtooth near 100 %&lt;/strong> with preemption spikes. In &lt;code>swap&lt;/code> mode, the latency of the preempted request explodes because there is a host↔device PCIe copy; in &lt;code>recompute&lt;/code> mode (the default in V1), the preempted request redoes its prefill from scratch, which pushes its TTFT to double or triple.&lt;/p>
&lt;p>&lt;strong>Operational implication.&lt;/strong> If &lt;code>gpu_cache_usage_perc &amp;gt; 92 %&lt;/code> is sustained, there are &lt;strong>two levers&lt;/strong>: lower &lt;code>max_num_seqs&lt;/code> (admit less concurrency, but none of it gets preempted) or raise &lt;code>gpu_memory_utilization&lt;/code> (more pool, less VRAM for transient activations, a different risk). The choice depends on the workload. The metric that is directly missing, a preempted counter, is exported from vLLM v1.0 onwards as &lt;code>vllm:num_preemptions_total&lt;/code> (see issue #5051).&lt;/p>
&lt;h3 id="vllmtime_to_first_token_seconds--the-latency-the-client-sees">&lt;code>vllm:time_to_first_token_seconds&lt;/code> — the latency the client sees&lt;/h3>
&lt;p>&lt;strong>The analogy.&lt;/strong> The time from a customer walking into a restaurant to receiving the first piece of bread at the table. Too long and the customer thinks they have been forgotten, even if the main course is going to arrive perfect.&lt;/p>
&lt;p>&lt;strong>The documented anomaly.&lt;/strong> Three documented patterns of recurring TTFT spikes:&lt;/p>
&lt;ol>
&lt;li>&lt;strong>Badly calibrated chunked prefill.&lt;/strong> Issue &lt;strong>vllm#25677&lt;/strong> (Qwen3-30B-A3B) reported &lt;strong>prefill 10–11× slower with chunked prefill enabled&lt;/strong> than without it. Cause: a very low &lt;code>max_num_batched_tokens&lt;/code> forces small chunks that do not fill the kernels. Issue &lt;strong>vllm#7604&lt;/strong> documents an equivalent regression on Llama-3-70B v0.5.4. The lever: raise &lt;code>max_num_batched_tokens&lt;/code> to 4096–8192 for typical prompts above 2k.&lt;/li>
&lt;li>&lt;strong>Regression between engine versions.&lt;/strong> Issue &lt;strong>vllm#8819&lt;/strong> documents a regression of &lt;code>vllm:time_to_first_token_seconds_sum&lt;/code> between minor versions. Issue &lt;strong>vllm#11912&lt;/strong> reports that with a prompt of around 8000 tokens, TPOT rose from &lt;strong>15.7 ms → 25.7 ms&lt;/strong> from v0.6.4.post1 onwards with no config change, a regression confirmed and trackable only with the metric.&lt;/li>
&lt;li>&lt;strong>Long-context prefill blocking decodes.&lt;/strong> The &lt;em>&amp;ldquo;11s TTFT on healthy server&amp;rdquo;&lt;/em> case cited above: a 30k-token prefill monopolises the GPU for several seconds and the active decodes freeze. Fix: well-calibrated chunked prefill, or disaggregated serving (see &lt;a href="https://blog.lo0.es/en/posts/disaggregated-serving-prefill-decode-specialised-pods/">Disaggregated serving&lt;/a>).&lt;/li>
&lt;/ol>
&lt;p>&lt;strong>Operational implication.&lt;/strong> Do not alert only on the absolute P95; alert also on the &lt;strong>v2/v1 ratio&lt;/strong> when there is a canary (&lt;code>histogram_quantile(0.95, ..., version=&amp;quot;v2&amp;quot;) / histogram_quantile(0.95, ..., version=&amp;quot;v1&amp;quot;) &amp;gt; 1.10&lt;/code>). If TTFT grows and the queue is stable, the bottleneck is prefill: not solvable by adding replicas, but there are levers in quantisation or chunked prefill.&lt;/p>
&lt;h3 id="vllmtime_per_output_token_seconds--streaming-smoothness">&lt;code>vllm:time_per_output_token_seconds&lt;/code> — streaming smoothness&lt;/h3>
&lt;p>&lt;strong>The analogy.&lt;/strong> The speed at which the waiter brings the courses one after another after the first. If the next one takes a while, the diner notices something is wrong even though the first course arrived on time.&lt;/p>
&lt;p>&lt;strong>The documented anomaly.&lt;/strong> The distinctive pattern is the &lt;strong>abrupt step when &lt;code>gpu_cache_usage_perc&lt;/code> crosses roughly 85 %&lt;/strong>: TPOT jumps from 35 ms to 80 ms in a few seconds because the engine starts competing for the HBM with its own evictions. Issue &lt;strong>vllm#35387&lt;/strong> documents another anomalous case: &lt;strong>MTP (speculative decoding) causing a 76 % latency regression&lt;/strong> on Qwen3-Next-80B-A3B-Instruct-FP8. The TPOT metric caught it before any client complaints were filed.&lt;/p>
&lt;p>&lt;strong>Operational implication.&lt;/strong> The difference from TTFT: if TTFT grows and Queue Time is stable, you are prefill bound; if TPOT grows at a steady rate, there is pressure on the HBM (KV cache pool or swap enabled). A secondary alert on the TPOT SLO, but also watch the &lt;strong>derivative&lt;/strong>: TPOT rising 1 ms every 10 minutes is a latent regression that does not break the SLO yet but will.&lt;/p>
&lt;h2 id="the-operational-rule-read-metrics-by-family-not-in-isolation">The operational rule: read metrics by family, not in isolation&lt;/h2>
&lt;div class="diagram" style="max-width:820px;margin:1.5rem auto;">
&lt;svg viewBox="0 0 820 280" xmlns="http://www.w3.org/2000/svg" role="img" aria-label="combined reading of metrics by family">
&lt;style>.b{stroke:#333;stroke-width:1.4;rx:6}.c{fill:#dfe9f5;stroke:#356}.m{fill:#eef0d0;stroke:#7a3}.t{fill:#f4e3cf;stroke:#a63}.s{fill:#f6e2e2;stroke:#a33}.title{font:600 13px sans-serif;fill:#222}.h{font:700 12px sans-serif;fill:#222}.l{font:11px sans-serif;fill:#222}.n{font:italic 10px sans-serif;fill:#444}&lt;/style>
&lt;text x="410" y="20" text-anchor="middle" class="title">Combinations that diagnose (each family on its own misleads)&lt;/text>
&lt;rect x="20" y="40" width="380" height="100" class="b c"/>
&lt;text x="30" y="62" class="h">COMPUTE saturated BUT memory free&lt;/text>
&lt;text x="30" y="80" class="l">SM_OCCUPANCY 95% + TENSOR_ACTIVE 75% + DRAM_ACTIVE 50%&lt;/text>
&lt;text x="30" y="98" class="l">+ FB_USED 60%&lt;/text>
&lt;text x="30" y="120" class="n">→ Prefill bound. Lever: speculative decoding,&lt;/text>
&lt;text x="30" y="134" class="n"> chunked prefill, disaggregated serving.&lt;/text>
&lt;rect x="420" y="40" width="380" height="100" class="b m"/>
&lt;text x="430" y="62" class="h">MEMORY saturated BUT compute slack&lt;/text>
&lt;text x="430" y="80" class="l">SM_OCCUPANCY 35% + TENSOR_ACTIVE 18% + DRAM_ACTIVE 92%&lt;/text>
&lt;text x="430" y="98" class="l">+ gpu_cache_usage_perc 88%&lt;/text>
&lt;text x="430" y="120" class="n">→ Decode bound + KV cache under pressure.&lt;/text>
&lt;text x="430" y="134" class="n"> Lever: FP8 KV cache, shorter context.&lt;/text>
&lt;rect x="20" y="150" width="380" height="120" class="b t"/>
&lt;text x="30" y="172" class="h">High TPOT WITHOUT saturating compute or memory&lt;/text>
&lt;text x="30" y="190" class="l">DRAM_ACTIVE 65% + FB_USED 70% + temp 78°C&lt;/text>
&lt;text x="30" y="208" class="l">+ THROTTLE_REASONS = 0x40 (HW_THERMAL)&lt;/text>
&lt;text x="30" y="230" class="n">→ Silent thermal throttle.&lt;/text>
&lt;text x="30" y="244" class="n"> Lever: check rack ventilation, not the engine.&lt;/text>
&lt;text x="30" y="258" class="n"> Classic Dell/Lenovo KB case.&lt;/text>
&lt;rect x="420" y="150" width="380" height="120" class="b s"/>
&lt;text x="430" y="172" class="h">High TTFT P95 WITHOUT throttle or queue&lt;/text>
&lt;text x="430" y="190" class="l">num_requests_waiting 0 + throttle 0 + DRAM_ACTIVE 70%&lt;/text>
&lt;text x="430" y="208" class="l">+ v2/v1 ratio = 1.4 (canary live)&lt;/text>
&lt;text x="430" y="230" class="n">→ v2 model regression in prefill.&lt;/text>
&lt;text x="430" y="244" class="n"> Lever: roll back the canary,&lt;/text>
&lt;text x="430" y="258" class="n"> review the v2 engine config.&lt;/text>
&lt;/svg>
&lt;/div>
&lt;h2 id="three-anti-patterns-of-the-novice-operator">Three anti-patterns of the novice operator&lt;/h2>
&lt;p>&lt;strong>Anti-pattern 1 — alerting only on absolute thresholds.&lt;/strong> An H100 at 87 % FB is not necessarily an alarm; an H100 at 87 % rising 2 %/min is. Alerts that fire on a fixed threshold without looking at the derivative produce twice the noise and half the value. Rule: for metrics with known dynamics (KV cache, FB, queue), alert on a &lt;strong>sustained delta&lt;/strong>, not just on the level.&lt;/p>
&lt;p>&lt;strong>Anti-pattern 2 — confusing SBE with DBE.&lt;/strong> The &lt;code>DCGM_FI_DEV_ECC_SBE_VOL_TOTAL&lt;/code> counter (single-bit, correctable) grows &lt;strong>continuously&lt;/strong> on any HBM under load; it is not an alarm, it is physics. The one that matters is &lt;code>DCGM_FI_DEV_ECC_DBE_VOL_TOTAL&lt;/code> (double-bit, uncorrectable). Confusing them yields false negatives (not alerting on a real DBE) or false positives (alerting on a harmless SBE).&lt;/p>
&lt;p>&lt;strong>Anti-pattern 3 — treating SM_OCCUPANCY at 99 % as &amp;ldquo;saturated&amp;rdquo;.&lt;/strong> The LLM decode regime is memory-bound, not compute-bound; high SM occupancy with low TENSOR_ACTIVE and high DRAM_ACTIVE &lt;strong>is normal&lt;/strong>. Sizing for &amp;ldquo;GPU at 60 %&amp;rdquo; and asking for more hardware when the cluster is saturated in HBM (not in SMs) means buying twice the GPUs without gaining throughput. Rule: always read SM_OCCUPANCY together with TENSOR_ACTIVE and DRAM_ACTIVE; on its own it means nothing.&lt;/p>
&lt;h2 id="applied-to-typical-on-premise-hardware">Applied to typical on-premise hardware&lt;/h2>
&lt;p>For a generic cluster of &lt;strong>4 nodes × 4×H100 SXM 80 GB with intra-node NVLink&lt;/strong>:&lt;/p>
&lt;ul>
&lt;li>&lt;strong>DCGM Exporter&lt;/strong> per node (a GPU Operator DaemonSet) emitting every 15 s; cardinality per GPU is around 80 series. A 16-GPU cluster gives roughly 1.3k base series, around 85k samples/min with a 15 s scrape.&lt;/li>
&lt;li>&lt;strong>vLLM /metrics&lt;/strong> per inference pod; each replica emits around 50 base series. For 16 replicas, around 800 additional series, around 3k samples/min.&lt;/li>
&lt;li>&lt;strong>Prometheus retention&lt;/strong>: 30 days at high resolution plus 1 year downsampled through a Thanos sidecar or Mimir. Estimated volume: 25–35 GB/day.&lt;/li>
&lt;li>&lt;strong>Alertmanager&lt;/strong>: the 6 critical alerts from the &lt;a href="https://blog.lo0.es/en/posts/gpu-observability-llm-inference-twelve-dcgm-vllm-metrics/">previous post&lt;/a> plus derived alerts (delta, v2/v1 ratio, decoded throttle bitmap).&lt;/li>
&lt;/ul>
&lt;p>Each metric is worth exposing also as an &lt;strong>OTel attribute&lt;/strong> on the spans of the &lt;a href="https://blog.lo0.es/en/posts/llm-tracing-opentelemetry-genai/">GenAI tracing&lt;/a>: &lt;code>gpu.fb_used_pct&lt;/code>, &lt;code>gpu.dram_active&lt;/code>, &lt;code>gpu.throttle_reasons.decoded&lt;/code>. That lets you correlate a slow request with the state of the GPU at that instant, without jumping between dashboards.&lt;/p>
&lt;h2 id="what-we-have-not-covered-upcoming-posts">What we have not covered (upcoming posts)&lt;/h2>
&lt;ul>
&lt;li>&lt;strong>Per-alert runbooks&lt;/strong> — the translation of each anomalous metric into concrete action (drain, reset, RMA, scaling, rollback) in the next post: &lt;a href="https://blog.lo0.es/en/posts/incident-response-runbooks-llm-inference-kafka-keep/">Incident response runbooks&lt;/a>.&lt;/li>
&lt;li>&lt;strong>Tail sampling for metric ↔ trace correlation&lt;/strong> — what is preserved when an alert fires, for post-mortem investigation.&lt;/li>
&lt;li>&lt;strong>Per-tenant showback&lt;/strong> combining &lt;code>vllm:request_success_total&lt;/code> × &lt;code>gen_ai.usage.*&lt;/code> × &lt;code>DCGM_FI_DEV_POWER_USAGE&lt;/code> to bill real energy cost.&lt;/li>
&lt;li>&lt;strong>Multi-tenant fairness metrics&lt;/strong> — when a tenant hogs the KV cache pool and how to detect it.&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/gpu-observability-llm-inference-twelve-dcgm-vllm-metrics/">GPU observability for LLM inference&lt;/a> — the compact list that this post goes deeper into.&lt;/li>
&lt;li>&lt;a href="https://blog.lo0.es/en/posts/incident-response-runbooks-llm-inference-kafka-keep/">Incident response runbooks for LLM with Keep + Kafka&lt;/a> — the translation of each anomaly into action.&lt;/li>
&lt;li>&lt;a href="https://blog.lo0.es/en/posts/llm-tracing-opentelemetry-genai/">LLM tracing with OpenTelemetry GenAI&lt;/a> — the other half of observability.&lt;/li>
&lt;li>&lt;a href="https://blog.lo0.es/en/posts/llm-inference-autoscaling-kubernetes-hpa-keda/">Autoscaling LLM on Kubernetes&lt;/a> — &lt;code>num_requests_waiting&lt;/code> and &lt;code>gpu_cache_usage_perc&lt;/code> as primary HPA metrics.&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> — how the thresholds relate to the sizing.&lt;/li>
&lt;li>&lt;a href="https://blog.lo0.es/en/posts/canary-blue-green-shadow-llm-models/">Canary, blue-green and shadow&lt;/a> — the v2/v1 TTFT ratio as a gate.&lt;/li>
&lt;li>&lt;a href="https://blog.lo0.es/en/posts/continuous-batching-orca-vllm-chunked-prefill-goodput/">Continuous batching&lt;/a> — explains preempt-on-OOM and the KV pool sawtooth.&lt;/li>
&lt;li>&lt;a href="https://blog.lo0.es/en/posts/kv-cache-working-memory-llm-inference/">KV cache&lt;/a> — underpins the calculation of &lt;code>gpu_cache_usage_perc&lt;/code>.&lt;/li>
&lt;/ul>
&lt;h2 id="references">References&lt;/h2>
&lt;ul>
&lt;li>Meta — &lt;em>Faulty Nvidia H100 GPUs and HBM3 memory caused half of failures during Llama 3 training&lt;/em> (Tom&amp;rsquo;s Hardware, 2024). &lt;a href="https://www.tomshardware.com/tech-industry/artificial-intelligence/faulty-nvidia-h100-gpus-and-hbm3-memory-caused-half-of-the-failures-during-llama-3-training-one-failure-every-three-hours-for-metas-16384-gpu-training-cluster">tomshardware.com&lt;/a>&lt;/li>
&lt;li>&lt;em>Story of Two GPUs: Characterizing the Resilience of Hopper H100 and Ampere A100&lt;/em>. arXiv 2503.11901. &lt;a href="https://arxiv.org/html/2503.11901v3">https://arxiv.org/html/2503.11901v3&lt;/a>&lt;/li>
&lt;li>ByteDance — &lt;em>Robust LLM Training Infrastructure at ByteDance&lt;/em>. arXiv 2509.16293. &lt;a href="https://arxiv.org/pdf/2509.16293">https://arxiv.org/pdf/2509.16293&lt;/a>&lt;/li>
&lt;li>&lt;em>Mind the Memory Gap: Unveiling GPU Bottlenecks in Large-Batch LLM Inference&lt;/em>. arXiv 2503.08311.&lt;/li>
&lt;li>&lt;em>Capacity-Aware Inference: Mitigating the Straggler Effect in Mixture of Experts&lt;/em>. arXiv 2503.05066.&lt;/li>
&lt;li>NVIDIA — &lt;em>Analyzing Xid Errors with the Xid Catalog&lt;/em> and &lt;em>Memory Error Management&lt;/em> (docs.nvidia.com/deploy).&lt;/li>
&lt;li>Dell — &lt;em>PowerEdge XE8640 with H100 - GPU Performance Issue HW Power Brake Slowdown - Active&lt;/em> (KB 000220508).&lt;/li>
&lt;li>Lenovo — &lt;em>Power brake reporting on H100 GPU&lt;/em> (HT514380).&lt;/li>
&lt;li>vLLM project — issues #5051 (preempted metric), #7604 and #25677 (chunked prefill regression), #11912 (long-prompt regression), #16300 (TP=8 worse than TP=4), #16985 (long-running degradation), #20783 (compressed-tensors no speedup), #35387 (MTP regression).&lt;/li>
&lt;li>Red Hat — &lt;em>5 steps to triage vLLM performance&lt;/em>. &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>AI21 — &lt;em>Go big or go OOM: the art of scaling vLLM&lt;/em>. &lt;a href="https://www.ai21.com/blog/scaling-vllm-without-oom/">https://www.ai21.com/blog/scaling-vllm-without-oom/&lt;/a>&lt;/li>
&lt;li>&lt;em>11-Second Time to First Token on a Healthy vLLM Server&lt;/em> (Medium, Ingero, 2026).&lt;/li>
&lt;li>NVIDIA — &lt;em>DGX SuperPOD Electrical Specifications&lt;/em> (docs.nvidia.com/dgx-superpod).&lt;/li>
&lt;/ul>
&lt;p>Sources: the full URLs are linked inline on each reference.&lt;/p></description></item><item><title>Canary, blue-green and shadow for LLM models: how to deploy a new version without breaking the SLO</title><link>https://blog.lo0.es/en/posts/canary-blue-green-shadow-llm-models/</link><pubDate>Mon, 01 Jun 2026 16:30:00 +0200</pubDate><guid>https://blog.lo0.es/en/posts/canary-blue-green-shadow-llm-models/</guid><description>&lt;blockquote>
&lt;p>This post complements those on &lt;a href="https://blog.lo0.es/en/posts/llm-inference-autoscaling-kubernetes-hpa-keda/">LLM autoscaling on Kubernetes&lt;/a> (the autoscaler coexists with the rollout and must respect it), &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 metrics that act as gates come from there), &lt;a href="https://blog.lo0.es/en/posts/evals-llm-layer-after-tracing/">Evals for LLMs&lt;/a> (the eval that decides whether the new model is ready), &lt;a href="https://blog.lo0.es/en/posts/llm-as-judge-exam-marker-not-oracle/">LLM-as-judge&lt;/a> (the technique that puts the &amp;ldquo;quality&amp;rdquo; into the canary gate) and &lt;a href="https://blog.lo0.es/en/posts/retrain-closing-the-loop-feedback-dataset-adapter/">Retrain: closing the loop&lt;/a> (the previous step that the new model comes out of).&lt;/p>
&lt;/blockquote>
&lt;h2 id="tldr">TL;DR&lt;/h2>
&lt;p>Promoting a new version of an LLM model to the production cluster without cutting traffic or breaking the SLO demands progressive deployment. The three canonical strategies, &lt;strong>blue-green&lt;/strong>, &lt;strong>canary&lt;/strong> and &lt;strong>shadow&lt;/strong>, answer different questions and have different costs. &lt;strong>Blue-green&lt;/strong>: a complete new pool brought up in parallel, atomic load balancer switch. Instant rollback (point back at the old pool); it demands twice the GPUs during the window. &lt;strong>Canary&lt;/strong>: traffic is shared progressively between the old version and the new one (1 % → 5 % → 25 % → 100 %), measuring regression gates at each step; it consumes incrementally less hardware but exposes real users to the new model from the first percentage point. &lt;strong>Shadow / mirror&lt;/strong>: the old model serves 100 % of the real traffic to the client and, in parallel, a copy of every request goes to the new model without returning its response to the user; it isolates you from quality risk but spends the new model&amp;rsquo;s GPU on responses nobody consumes, and it does not work well with long SSE streaming. The choice depends on three factors: available GPU budget, service criticality and the availability of a fast automatic eval. The &lt;strong>five regression metrics&lt;/strong> any LLM canary should gate on are: TTFT P95, error rate (HTTP 5xx + premature &lt;code>finish_reason=&amp;quot;length&amp;quot;&lt;/code>), quality score with LLM-as-judge over a golden set, statistical drift of output embeddings (Wasserstein or KL against the baseline distribution) and cost per request (tokens/s and kW/request). On Kubernetes, &lt;strong>Argo Rollouts&lt;/strong> manages traffic and the &lt;code>AnalysisTemplate&lt;/code>s as automatic gates; &lt;strong>Flagger&lt;/strong> is the more opinionated alternative. vLLM v1 &lt;strong>does not support hot model swap&lt;/strong> robustly as of May 2026, so the rollout unit is the &lt;strong>whole replica&lt;/strong> (deployment v2 alongside deployment v1). The three specific pitfalls: LB sticky sessions break the statistical comparability of the canary (a client A always lands on the new one, B on the old one, so the populations are not equivalent); semantic eval with LLM-as-judge takes 2–8 seconds per sample and is no use as a real-time gate (it is used in post-analysis or offline before promotion); SSE streaming complicates shadowing because the new model&amp;rsquo;s response has to be discarded without affecting the old one&amp;rsquo;s. This post includes a minimal Argo Rollouts manifest applicable to a generic cluster with the NVIDIA GPU Operator.&lt;/p>
&lt;h2 id="you-are-here-deploy-and-the-transition-to-retrain">You are here: DEPLOY (and the transition to RETRAIN)&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 with transition to Retrain">
&lt;style>.box{stroke:#444;stroke-width:1.4;rx:6}.active{fill:#7ad88f;stroke-width:3}.semiactive{fill:#cfead0;stroke-width:2}.idle{fill:#f4f4f4}.lbl{font:600 12px sans-serif;fill:#222}.arr{stroke:#666;stroke-width:1.4;fill:none;marker-end:url(#crm)}.cyc{stroke:#888;stroke-width:1.2;fill:none;stroke-dasharray:4 2;marker-end:url(#crm)}&lt;/style>
&lt;defs>&lt;marker id="crm" 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 · canary closes the circle opened in RETRAIN&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 semiactive"/>&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;p>A new model does not appear in the cluster by magic: it comes from the &lt;a href="https://blog.lo0.es/en/posts/retrain-closing-the-loop-feedback-dataset-adapter/">retrain&lt;/a> loop or from a weights provider update. The step between &amp;ldquo;I have an artifact that passed offline eval&amp;rdquo; and &amp;ldquo;it is serving 100 % of the traffic&amp;rdquo; is exactly this post.&lt;/p>
&lt;h2 id="the-analogy-opening-night-of-a-play">The analogy: opening night of a play&lt;/h2>
&lt;p>A theatre company is about to premiere a new version of a play that has been running successfully for a year. The company knows several hard facts: the current audience pays for a consistent experience; a bad opening damages the business for months; but premiering nothing leaves the company obsolete against the competition.&lt;/p>
&lt;p>The three opening routes the management can choose are the same three of the LLM rollout.&lt;/p>
&lt;p>&lt;strong>Dress rehearsal behind closed doors (shadow / mirror).&lt;/strong> The new actors perform the whole play to an empty theatre. There is no audience; nobody buys a ticket. Three full run-throughs are enough to check continuity, timing and the chemistry of the cast. It is expensive because there are salaries and theatre rent, but it does not expose the audience to risk. Useful when the new cast is untested and the director wants to see how it holds up over a complete performance before selling it. In LLM terms: the new model processes every real request in parallel with the old one but its responses are discarded; you spend the new model&amp;rsquo;s GPU on responses nobody sees.&lt;/p>
&lt;p>&lt;strong>Cast by performance, alternating (canary).&lt;/strong> Instead of changing the whole cast at once, Thursday performances belong to the new cast, Friday ones to the old one, Saturday ones half and half. The management reads the visitors&amp;rsquo; book comments and the audience numbers performance by performance, deciding after two weeks whether to promote the new cast to permanent or withdraw it. Cheaper than the dress rehearsal because the performances still sell tickets, but it exposes a real audience to risk from the first Thursday. In LLM terms: traffic is shared progressively between the old version and the new one, measuring gates at each step.&lt;/p>
&lt;p>&lt;strong>Double company with an atomic switch (blue-green).&lt;/strong> The company hires the new cast, prepares it for a month behind closed doors, and one Saturday announces: &amp;ldquo;from the next opening night every performance is with the new cast&amp;rdquo;. If the first performance goes badly, they go back to the old cast in 24 hours, but during that month of preparation &lt;strong>double salaries&lt;/strong> are paid to both companies. In LLM terms: two complete pools of the same size, instant LB switch from one to the other, rollback in seconds if the metrics break.&lt;/p>
&lt;p>The analogy also supports the decision: the choice depends on how critical the play is for the business (criticality of the LLM service), how much budget there is to sustain two casts at once (GPU budget), and how much confidence there is in the new cast from the chamber rehearsals (offline eval prior to the canary).&lt;/p>
&lt;h2 id="the-three-strategies-in-detail">The three strategies in detail&lt;/h2>
&lt;div class="diagram" style="max-width:820px;margin:1.5rem auto;">
&lt;svg viewBox="0 0 820 340" xmlns="http://www.w3.org/2000/svg" role="img" aria-label="three LLM rollout strategies compared">
&lt;style>.b{stroke:#333;stroke-width:1.4;rx:6}.bg{fill:#d8eecf;stroke:#373}.cn{fill:#dfe9f5;stroke:#356}.sh{fill:#ead8f5;stroke:#634}.title{font:600 13px sans-serif;fill:#222}.h{font:700 12px sans-serif;fill:#222}.l{font:11px sans-serif;fill:#222}.n{font:italic 10px sans-serif;fill:#444}&lt;/style>
&lt;text x="410" y="20" text-anchor="middle" class="title">Three LLM rollout strategies with their tradeoffs&lt;/text>
&lt;rect x="20" y="40" width="250" height="280" class="b bg"/>
&lt;text x="145" y="62" text-anchor="middle" class="h">BLUE-GREEN&lt;/text>
&lt;text x="30" y="90" class="l">Pool v1 (blue) serves 100%&lt;/text>
&lt;text x="30" y="106" class="l">Pool v2 (green) up and idle&lt;/text>
&lt;text x="30" y="122" class="l">LB switch: v1 → v2 instant&lt;/text>
&lt;text x="30" y="138" class="l">Rollback: switch back to v1&lt;/text>
&lt;text x="30" y="170" class="h">+ Instant rollback&lt;/text>
&lt;text x="30" y="190" class="h">+ E2E test on a real pool&lt;/text>
&lt;text x="30" y="216" class="h">− Double GPU during window&lt;/text>
&lt;text x="30" y="236" class="h">− Big switch = total risk&lt;/text>
&lt;text x="30" y="270" class="n">Typical case: minor provider&lt;/text>
&lt;text x="30" y="284" class="n">update (FP8 → FP4 new version&lt;/text>
&lt;text x="30" y="298" class="n">of the same model).&lt;/text>
&lt;rect x="285" y="40" width="250" height="280" class="b cn"/>
&lt;text x="410" y="62" text-anchor="middle" class="h">CANARY&lt;/text>
&lt;text x="295" y="90" class="l">v1 serves most · v2 a fraction&lt;/text>
&lt;text x="295" y="106" class="l">Progressive split: 1→5→25→100%&lt;/text>
&lt;text x="295" y="122" class="l">Regression gate between steps&lt;/text>
&lt;text x="295" y="138" class="l">Auto-rollback if gate fails&lt;/text>
&lt;text x="295" y="170" class="h">+ Controlled exposure&lt;/text>
&lt;text x="295" y="190" class="h">+ Incremental GPU, not double&lt;/text>
&lt;text x="295" y="216" class="h">− Real users in the sample&lt;/text>
&lt;text x="295" y="236" class="h">− Sticky sessions break sampling&lt;/text>
&lt;text x="295" y="270" class="n">Typical case: model change&lt;/text>
&lt;text x="295" y="284" class="n">(Llama 70B → Llama 3.3 70B&lt;/text>
&lt;text x="295" y="298" class="n">fine-tuned by domain).&lt;/text>
&lt;rect x="550" y="40" width="250" height="280" class="b sh"/>
&lt;text x="675" y="62" text-anchor="middle" class="h">SHADOW · MIRROR&lt;/text>
&lt;text x="560" y="90" class="l">v1 serves 100% to the user&lt;/text>
&lt;text x="560" y="106" class="l">v2 gets a copy of every request&lt;/text>
&lt;text x="560" y="122" class="l">v2 response is discarded&lt;/text>
&lt;text x="560" y="138" class="l">Offline comparison v1 vs v2&lt;/text>
&lt;text x="560" y="170" class="h">+ Zero risk exposure&lt;/text>
&lt;text x="560" y="190" class="h">+ Real traffic on v2, no harm&lt;/text>
&lt;text x="560" y="216" class="h">− v2 GPU 100% with no user value&lt;/text>
&lt;text x="560" y="236" class="h">− Poor fit with long SSE streaming&lt;/text>
&lt;text x="560" y="270" class="n">Typical case: pre-canary validation&lt;/text>
&lt;text x="560" y="284" class="n">of a model with a different&lt;/text>
&lt;text x="560" y="298" class="n">architecture (dense → MoE).&lt;/text>
&lt;/svg>
&lt;/div>
&lt;h3 id="blue-green">Blue-green&lt;/h3>
&lt;p>The operator keeps two replica pools identical in size: the &lt;strong>blue&lt;/strong> one (production version v1) and the &lt;strong>green&lt;/strong> one (candidate version v2). When v2 is validated offline (eval passed, smoke tests), the LoadBalancer switch redirects 100 % of the traffic from blue to green &lt;strong>in a single step&lt;/strong>. If the SLO metrics break, the switch goes back in seconds.&lt;/p>
&lt;p>Cost: &lt;strong>2× GPUs&lt;/strong> for the whole window (preparing v2 + the post-switch observation window). For a 16-GPU cluster serving Llama 70B with TP=4 (4 replicas), preparing the blue-green needs 16 extra GPUs for 1–3 days.&lt;/p>
&lt;p>Risk: the switch is &lt;strong>atomic&lt;/strong>. If v2 has a problem that did not show up in offline eval but does show up at scale (for example, edge cases that only appear at 200 RPS), 100 % of users notice it at once. Rollback is instant, but the requests of the first minute after the switch were already affected. So blue-green is preferable when you have &lt;strong>high confidence&lt;/strong> in v2 (a minor change: same architecture, same format, just a new version of the weights) and you prioritise &lt;strong>immediate rollback&lt;/strong> over gradual exposure.&lt;/p>
&lt;h3 id="canary">Canary&lt;/h3>
&lt;p>The operator deploys v2 with a small number of replicas (typically 1) next to the v1 pool. The LoadBalancer shares the traffic out progressively on a schedule: 1 % for 30 minutes → 5 % for 1 hour → 25 % for 2 hours → 50 % for 4 hours → 100 %. Between each step, an &lt;strong>analysis gate&lt;/strong> evaluates regression metrics over the traffic that is already landing on v2. If the gate fails, the rollback withdraws traffic from v2 automatically and leaves v1 serving everything.&lt;/p>
&lt;p>Cost: &lt;strong>incremental&lt;/strong>. At the start (1 % of traffic) one v2 replica is enough; at 50 % you need half as many v2 replicas as v1 has in total. Peak extra GPU during the canary: about 30–50 % above baseline.&lt;/p>
&lt;p>Risk: real users are seeing v2 from the first 1 %. If v2 produces responses with degraded quality but normal TTFT and error rate, the affected users perceive the degradation without the gate detecting it (unless the gate includes quality drift, which is slow). So canary is preferable when you have &lt;strong>medium confidence&lt;/strong> in v2 (a significant change: different architecture or training) and you accept that a low percentage of users will be guinea pigs.&lt;/p>
&lt;h3 id="shadow--mirror">Shadow / mirror&lt;/h3>
&lt;p>The LoadBalancer sends 100 % of the real traffic to v1 (which answers the client) &lt;strong>and duplicates every request&lt;/strong> towards v2 (whose response is discarded or stored for offline analysis). The client never sees v2; it is never exposed to the risk.&lt;/p>
&lt;p>Cost: &lt;strong>100 % extra compute for v2&lt;/strong> with no direct user value for the whole shadow window. For a 16-GPU cluster serving Llama 70B with TP=4 (4 replicas), a shadow of the same size consumes 16 extra GPUs full time.&lt;/p>
&lt;p>Risk: shadow is the safest for the user. But it has two serious limitations: (a) if v2 has a bottleneck that makes the copied request to the shadow take a long time, the shadowing proxy can consume LB connections; it must be &lt;strong>out-of-band&lt;/strong> (asynchronous); (b) &lt;strong>long SSE streaming&lt;/strong> complicates mirroring because you have to keep two parallel streams and discard one while the other flows to the client. Common pattern: shadow only non-streaming requests (short completions, classification), manual offline eval of the streaming requests.&lt;/p>
&lt;h2 id="the-five-regression-metrics-that-act-as-a-gate">The five regression metrics that act as a gate&lt;/h2>
&lt;p>Without automatic gates, the &amp;ldquo;canary&amp;rdquo; is just a nice name for &amp;ldquo;manual rollout with a variable percentage&amp;rdquo;. The gates are the piece that turns the canary into a defensible operation.&lt;/p>
&lt;p>&lt;strong>Metric 1 — TTFT P95.&lt;/strong> Comparison of the new model&amp;rsquo;s P95 against the baseline (v1) P95 in 5-minute windows. Gate: &lt;code>ttft_p95(v2) / ttft_p95(v1) &amp;lt; 1.10&lt;/code>. It detects prefill latency regressions (a slower new model) or engine problems (suboptimal config). Source: &lt;code>vllm:time_to_first_token_seconds_bucket&lt;/code> (see &lt;a href="https://blog.lo0.es/en/posts/gpu-observability-llm-inference-twelve-dcgm-vllm-metrics/">GPU observability for LLM inference&lt;/a>).&lt;/p>
&lt;p>&lt;strong>Metric 2 — Error rate.&lt;/strong> Sum of HTTP 5xx + unexpected 4xx + rate of premature &lt;code>finish_reason=&amp;quot;length&amp;quot;&lt;/code> (responses cut short because the new model does not generate EOS). Gate: &lt;code>error_rate(v2) - error_rate(v1) &amp;lt; 0.01&lt;/code> (1 percentage point). It detects engine crashes, a broken tokenizer, generation problems. Source: &lt;code>vllm:request_success_total{status=...}&lt;/code>.&lt;/p>
&lt;p>&lt;strong>Metric 3 — Quality score (LLM-as-judge).&lt;/strong> Over a &lt;strong>golden set&lt;/strong> of 200–1,000 representative prompts, v1 and v2 are run offline and a judge model (typically larger: GPT-4 class, Claude, local Llama 405B) scores each pair. Typical gate: &lt;code>mean_score(v2) &amp;gt;= mean_score(v1) - 0.05&lt;/code>. This metric &lt;strong>is not measured in real time during the canary&lt;/strong>: judge inference takes 2–8 seconds per sample and does not scale as an inline gate. It is used as an &lt;strong>offline pre-promotion gate&lt;/strong> (before starting the canary) and as a post-mortem over a sample of real traffic captured during the canary. See &lt;a href="https://blog.lo0.es/en/posts/llm-as-judge-exam-marker-not-oracle/">LLM-as-judge&lt;/a> for the mechanics.&lt;/p>
&lt;p>&lt;strong>Metric 4 — Statistical output drift.&lt;/strong> For every request that lands on v2 during the canary, embed the response with a lightweight embedding model (e5, BGE) and compare the distribution of v2 embeddings against the distribution of the v1 baseline over the same window. Usable metrics: &lt;strong>Wasserstein distance&lt;/strong>, KL divergence, or more simply, comparing means and variances per dimension. Gate: normalised distance &amp;lt; a calibrated threshold (typically Wasserstein &amp;lt; 0.15). It detects subtle changes in style, length and vocabulary that LLM-as-judge does not capture without going through it too. It is fast: the lightweight embedding takes around 50 ms per response.&lt;/p>
&lt;p>&lt;strong>Metric 5 — Cost per request.&lt;/strong> Output tokens per request and kW per request. Gate: &lt;code>cost_per_request(v2) / cost_per_request(v1) &amp;lt; 1.20&lt;/code>. It detects new models that generate significantly longer responses or that consume more energy for the same load (quantisation degradation, optimisations failing). Without this gate, an &amp;ldquo;update&amp;rdquo; can silently double the bill.&lt;/p>
&lt;table>
&lt;thead>
&lt;tr>
&lt;th>Metric&lt;/th>
&lt;th>Type&lt;/th>
&lt;th>Measurement latency&lt;/th>
&lt;th>Typical gate&lt;/th>
&lt;th>Detection&lt;/th>
&lt;/tr>
&lt;/thead>
&lt;tbody>
&lt;tr>
&lt;td>TTFT P95&lt;/td>
&lt;td>Quantitative&lt;/td>
&lt;td>5 min&lt;/td>
&lt;td>&lt;code>&amp;lt; 110% baseline&lt;/code>&lt;/td>
&lt;td>Latency regression&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>Error rate&lt;/td>
&lt;td>Quantitative&lt;/td>
&lt;td>1 min&lt;/td>
&lt;td>&lt;code>&amp;lt; 1pp over baseline&lt;/code>&lt;/td>
&lt;td>Crashes, generation broken&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>Quality (LLM-judge)&lt;/td>
&lt;td>Offline semantic&lt;/td>
&lt;td>hours, over golden set&lt;/td>
&lt;td>&lt;code>&amp;gt; baseline − 0.05&lt;/code>&lt;/td>
&lt;td>Functional quality&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>Statistical drift&lt;/td>
&lt;td>Statistical&lt;/td>
&lt;td>~5 min&lt;/td>
&lt;td>Wasserstein &amp;lt; 0.15&lt;/td>
&lt;td>Style, length, vocabulary&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>Cost per request&lt;/td>
&lt;td>Quantitative&lt;/td>
&lt;td>5 min&lt;/td>
&lt;td>&lt;code>&amp;lt; 120% baseline&lt;/code>&lt;/td>
&lt;td>Economic/energy efficiency&lt;/td>
&lt;/tr>
&lt;/tbody>
&lt;/table>
&lt;h2 id="the-mechanics-on-kubernetes-argo-rollouts">The mechanics on Kubernetes: Argo Rollouts&lt;/h2>
&lt;p>Argo Rollouts extends the standard Kubernetes &lt;code>Deployment&lt;/code> with a new &lt;code>Rollout&lt;/code> resource that orchestrates traffic progression and automatic analyses. It integrates with any service mesh (Istio, Linkerd) or ingress controller that supports traffic splitting (NGINX, Traefik, Gateway API).&lt;/p>
&lt;p>A minimal example of a 1 → 5 → 25 → 100 % canary with TTFT and error rate gates:&lt;/p>
&lt;div class="highlight">&lt;pre tabindex="0" class="chroma">&lt;code class="language-yaml" data-lang="yaml">&lt;span class="line">&lt;span class="cl">&lt;span class="nt">apiVersion&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">argoproj.io/v1alpha1&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w">&lt;/span>&lt;span class="nt">kind&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">Rollout&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w">&lt;/span>&lt;span class="nt">metadata&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>{&lt;span class="w"> &lt;/span>&lt;span class="nt">name&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">vllm-llama70b }&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w">&lt;/span>&lt;span class="nt">spec&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">replicas&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="m">4&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">strategy&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">canary&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">canaryService&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">vllm-llama70b-canary&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">stableService&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">vllm-llama70b-stable&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">trafficRouting&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">nginx&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">stableIngress&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">vllm-llama70b-ingress&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">steps&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>- &lt;span class="nt">setWeight&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="m">1&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>- &lt;span class="nt">pause&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>{&lt;span class="w"> &lt;/span>&lt;span class="nt">duration&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">30m }&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>- &lt;span class="nt">analysis&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>{&lt;span class="w"> &lt;/span>&lt;span class="nt">templates&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="p">[&lt;/span>{&lt;span class="w"> &lt;/span>&lt;span class="nt">templateName&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">ttft-error-gate }] }&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>- &lt;span class="nt">setWeight&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="m">5&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>- &lt;span class="nt">pause&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>{&lt;span class="w"> &lt;/span>&lt;span class="nt">duration&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">1h }&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>- &lt;span class="nt">analysis&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>{&lt;span class="w"> &lt;/span>&lt;span class="nt">templates&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="p">[&lt;/span>{&lt;span class="w"> &lt;/span>&lt;span class="nt">templateName&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">ttft-error-gate }] }&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>- &lt;span class="nt">setWeight&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="m">25&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>- &lt;span class="nt">pause&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>{&lt;span class="w"> &lt;/span>&lt;span class="nt">duration&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">2h }&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>- &lt;span class="nt">analysis&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>{&lt;span class="w"> &lt;/span>&lt;span class="nt">templates&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="p">[&lt;/span>{&lt;span class="w"> &lt;/span>&lt;span class="nt">templateName&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">ttft-error-gate }, { templateName: drift-gate }] }&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>- &lt;span class="nt">setWeight&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="m">50&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>- &lt;span class="nt">pause&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>{&lt;span class="w"> &lt;/span>&lt;span class="nt">duration&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">4h }&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>- &lt;span class="nt">analysis&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>{&lt;span class="w"> &lt;/span>&lt;span class="nt">templates&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="p">[&lt;/span>{&lt;span class="w"> &lt;/span>&lt;span class="nt">templateName&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">ttft-error-gate }, { templateName: drift-gate }] }&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>- &lt;span class="nt">setWeight&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="m">100&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">selector&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>{&lt;span class="w"> &lt;/span>&lt;span class="nt">matchLabels&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>{&lt;span class="w"> &lt;/span>&lt;span class="nt">app&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">vllm-llama70b } }&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">template&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">metadata&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>{&lt;span class="w"> &lt;/span>&lt;span class="nt">labels&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>{&lt;span class="w"> &lt;/span>&lt;span class="nt">app&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">vllm-llama70b } }&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">spec&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">containers&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>- &lt;span class="nt">name&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">vllm&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">image&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">vllm/vllm-openai:v0.10.0&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">args&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="p">[&lt;/span>&lt;span class="w"> &lt;/span>--&lt;span class="l">model=/models/llama-70b-fp8-v2 ] &lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="c"># new version&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w">&lt;/span>&lt;span class="nn">---&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w">&lt;/span>&lt;span class="nt">apiVersion&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">argoproj.io/v1alpha1&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w">&lt;/span>&lt;span class="nt">kind&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">AnalysisTemplate&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w">&lt;/span>&lt;span class="nt">metadata&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>{&lt;span class="w"> &lt;/span>&lt;span class="nt">name&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">ttft-error-gate }&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w">&lt;/span>&lt;span class="nt">spec&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">metrics&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>- &lt;span class="nt">name&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">ttft-p95-ratio&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">interval&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">1m&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">count&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="m">5&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">failureLimit&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="m">1&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">successCondition&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">result &amp;lt; 1.10&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">provider&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">prometheus&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">address&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">http://prometheus.observability.svc:9090&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">query&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="p">|&lt;/span>&lt;span class="sd">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="sd"> histogram_quantile(0.95, sum by(le)(rate(vllm:time_to_first_token_seconds_bucket{version=&amp;#34;v2&amp;#34;}[5m])))
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="sd"> /
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="sd"> histogram_quantile(0.95, sum by(le)(rate(vllm:time_to_first_token_seconds_bucket{version=&amp;#34;v1&amp;#34;}[5m])))&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>- &lt;span class="nt">name&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">error-rate-diff&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">interval&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">1m&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">count&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="m">5&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">failureLimit&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="m">1&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">successCondition&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">result &amp;lt; 0.01&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">provider&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">prometheus&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">address&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">http://prometheus.observability.svc:9090&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">query&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="p">|&lt;/span>&lt;span class="sd">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="sd"> sum(rate(vllm:request_total{version=&amp;#34;v2&amp;#34;,status=~&amp;#34;5..&amp;#34;}[5m])) / sum(rate(vllm:request_total{version=&amp;#34;v2&amp;#34;}[5m]))
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="sd"> -
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="sd"> sum(rate(vllm:request_total{version=&amp;#34;v1&amp;#34;,status=~&amp;#34;5..&amp;#34;}[5m])) / sum(rate(vllm:request_total{version=&amp;#34;v1&amp;#34;}[5m]))&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>If any of the &lt;code>AnalysisTemplate&lt;/code>s fails, Argo Rollouts backs out automatically: it sets weight=0 on the canary, alerts the operator and keeps v1 serving 100 %. Human operation is reduced to investigating the failure and deciding whether to relaunch or abort.&lt;/p>
&lt;p>&lt;strong>Flagger&lt;/strong> offers a more opinionated alternative: weight progression is automatic as a function of metric success instead of a fixed pause; the operator defines a target (&lt;code>maxWeight: 100&lt;/code>, &lt;code>stepWeight: 10&lt;/code>, &lt;code>metrics: [...]&lt;/code>) and Flagger raises or lowers it according to behaviour. Both are mature as of May 2026; the choice is usually dictated by which service mesh is already in the cluster.&lt;/p>
&lt;h2 id="the-vllm-detail-why-there-is-no-hot-swap-of-the-model">The vLLM detail: why there is no &amp;ldquo;hot swap&amp;rdquo; of the model&lt;/h2>
&lt;p>As of May 2026, vLLM v1 &lt;strong>does not support hot swapping&lt;/strong> the model inside the same replica without restarting the engine. The &lt;code>--model&lt;/code> argument is evaluated at start-up; changing it requires re-instantiating the &lt;code>LLMEngine&lt;/code>, which restarts connections and discards the KV cache. So the &lt;strong>rollout unit is the whole replica&lt;/strong>: you do not do &amp;ldquo;v1 loads the new model on one of its GPUs&amp;rdquo; but &amp;ldquo;a v2 replica is brought up next to a v1 replica and traffic is shared out via the LB&amp;rdquo;.&lt;/p>
&lt;p>TensorRT-LLM with Triton has a similar mechanism: changing the model requires a reload of the Triton backend. SGLang does not support robust hot swap either. The operational consequence: LLM rollout is always going to cost extra GPUs during the window, and the choice between blue-green, canary and shadow is exactly the question of how many extra and for how long.&lt;/p>
&lt;h2 id="the-three-pitfalls-specific-to-llm-rollout">The three pitfalls specific to LLM rollout&lt;/h2>
&lt;p>&lt;strong>Pitfall 1 — sticky sessions break canary comparability.&lt;/strong> If the LoadBalancer does session affinity by client IP (common in NGINX and Traefik with &lt;code>loadbalancer.kubernetes.io/session-affinity: ClientIP&lt;/code>), a user A always lands on v2 while B always lands on v1. Load distributions, prompt profiles and client behaviour &lt;strong>are not random&lt;/strong> between the two pools, which statistically invalidates any gate comparison. Solution: for a canary, disable session affinity (&lt;code>sessionAffinity: None&lt;/code>) or use affinity by random request-id. If the client app requires stickiness for functional reasons (conversational memory persisted in cache), canary is not the right strategy; use blue-green or shadow.&lt;/p>
&lt;p>&lt;strong>Pitfall 2 — LLM-as-judge is not an inline real-time gate.&lt;/strong> The temptation to use quality score as a live gate is strong, but the judge&amp;rsquo;s latency (2–8 s per sample) makes it unfeasible to evaluate more than a 1–2 % sampling of the traffic, and the results arrive minutes late. Operational solutions: (a) &lt;strong>offline pre-canary eval&lt;/strong> over a golden set as a prerequisite for starting (if it fails, the canary does not even begin); (b) during the canary, &lt;strong>capture requests + responses&lt;/strong> from v2 in real time and run the judge asynchronously in a batch job that finishes before the next step; (c) use statistical embedding drift as a fast proxy for inline quality, and reserve the judge for intermediate gates between steps.&lt;/p>
&lt;p>&lt;strong>Pitfall 3 — SSE streaming complicates shadowing.&lt;/strong> Classic traffic mirroring (NGINX &lt;code>mirror&lt;/code>, Istio &lt;code>MirrorPolicy&lt;/code>) is designed for request/response HTTP: it copies the request, lets the primary server answer the client, and duplicates the request to the secondary, discarding the response. With SSE, the secondary&amp;rsquo;s response is a continuous stream of several seconds, and keeping two streams in parallel doubles the load on the proxy. Solutions: (a) shadow &lt;strong>only non-streaming requests&lt;/strong> (chat without streaming, embeddings, classification, batch eval), (b) shadow the streaming traffic but with a short timeout on the secondary (discard the shadow if it takes longer than 30 s), (c) replace the shadow with a small-weight canary (1 %), which does handle streaming well.&lt;/p>
&lt;h2 id="applied-to-typical-on-premise-hardware">Applied to typical on-premise hardware&lt;/h2>
&lt;p>For a generic cluster of &lt;strong>4 nodes × 4×H100 SXM 80 GB = 16 GPUs&lt;/strong>, serving Llama 70B FP8 with TP=4 (4 possible replicas, one per node):&lt;/p>
&lt;ul>
&lt;li>&lt;strong>Blue-green&lt;/strong>: impossible to keep two complete pools of 4 replicas without extra GPUs. Practical solution: blue-green with reduced pools (2 v1 replicas + 2 v2 replicas) during the window, with &lt;strong>accepted capacity degradation&lt;/strong> (half the sustained RPS SLO), or having a parallel cluster (another node) reserved for rollouts.&lt;/li>
&lt;li>&lt;strong>Canary&lt;/strong>: feasible. Start with 3 v1 replicas + 1 v2 replica (25 % nominal weight, though the traffic weight also varies). Move to 2 v1 + 2 v2 at 50 %, then 1 v1 + 3 v2, finally 0 v1 + 4 v2.&lt;/li>
&lt;li>&lt;strong>Shadow&lt;/strong>: awkward because of the GPU cost. Reserve it for pre-canary validation of major changes, during a short window (4–8 hours) with shadowed traffic limited to a sample (10–20 % of requests, not 100 %).&lt;/li>
&lt;/ul>
&lt;p>For clusters of &lt;strong>8 GPU nodes&lt;/strong>, the three patterns are all sustainable. The operational rule: the rollout budget is typically &lt;strong>25–30 % of the cluster&amp;rsquo;s sustained capacity&lt;/strong>, and buying for the peak plus that head-room makes the &lt;a href="https://blog.lo0.es/en/posts/capacity-planning-on-premise-llm-inference/">capacity planning&lt;/a> numbers add up.&lt;/p>
&lt;h2 id="what-we-have-not-covered-upcoming-articles">What we have not covered (upcoming articles)&lt;/h2>
&lt;ul>
&lt;li>&lt;strong>Multi-region rollouts&lt;/strong>: how to coordinate a canary when the cluster is geographically distributed.&lt;/li>
&lt;li>&lt;strong>A/B testing of prompts&lt;/strong> (not of models): the same model with two different system prompts, measuring conversion.&lt;/li>
&lt;li>&lt;strong>Embedding rollback&lt;/strong>: changing the embedding model of a RAG system means re-embedding the whole corpus, so the canary mechanics are different. See &lt;a href="https://blog.lo0.es/en/posts/rag-corpus-curation-active-librarian/">RAG corpus curation&lt;/a>.&lt;/li>
&lt;li>&lt;strong>Feature flags for LLM&lt;/strong>: granularity per tenant or per feature within the same model.&lt;/li>
&lt;li>&lt;strong>End-to-end continuous deployment&lt;/strong>: integration with the retrain pipeline so a new adapter is promoted automatically after passing evals.&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/gitops-inference-stack-flux/">GitOps for the inference stack with Flux: operating the assistant as code&lt;/a> — promotion and rollback as a git revert inside the GitOps flow.&lt;/p>
&lt;/li>
&lt;li>
&lt;p>&lt;a href="https://blog.lo0.es/en/posts/llm-inference-autoscaling-kubernetes-hpa-keda/">LLM autoscaling on Kubernetes&lt;/a> — the autoscaler coexists with the canary and must respect the traffic splits.&lt;/p>
&lt;/li>
&lt;li>
&lt;p>&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 metrics that act as gates come from here.&lt;/p>
&lt;/li>
&lt;li>
&lt;p>&lt;a href="https://blog.lo0.es/en/posts/capacity-planning-on-premise-llm-inference/">Capacity planning&lt;/a> — defines the head-room needed for rollouts.&lt;/p>
&lt;/li>
&lt;li>
&lt;p>&lt;a href="https://blog.lo0.es/en/posts/evals-llm-layer-after-tracing/">Evals for LLMs&lt;/a> — the offline eval that validates v2 before starting the canary.&lt;/p>
&lt;/li>
&lt;li>
&lt;p>&lt;a href="https://blog.lo0.es/en/posts/llm-as-judge-exam-marker-not-oracle/">LLM-as-judge&lt;/a> — the quality score technique as an offline gate.&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 loop&lt;/a> — where the new model that enters the canary comes from.&lt;/p>
&lt;/li>
&lt;li>
&lt;p>&lt;a href="https://blog.lo0.es/en/posts/five-maturity-levels-platform-under-llm/">Five maturity levels&lt;/a> — Argo Rollouts is a piece of level 4–5.&lt;/p>
&lt;/li>
&lt;li>
&lt;p>&lt;a href="https://blog.lo0.es/en/posts/llm-inference-router-l7-switchboard/">The LLM inference router&lt;/a> — the piece we call &amp;ldquo;LoadBalancer&amp;rdquo; in this post taken apart as a layer in its own right: model catalogue, L7 traffic splitting, cross-cutting policy, failover and prefix-aware routing. The 1 % → 5 % → 25 % → 100 % split materialises there.&lt;/p>
&lt;/li>
&lt;li>
&lt;p>&lt;a href="https://blog.lo0.es/en/posts/incident-response-runbooks-llm-inference-kafka-keep/">Incident response runbooks for LLM with Keep + Kafka&lt;/a> — the automatic canary rollback when &lt;code>ttft_p95(v2)/ttft_p95(v1) &amp;gt; 1.30&lt;/code> is runbook RB-06; the full Keep YAML workflow and the compliance fit are there.&lt;/p>
&lt;/li>
&lt;/ul>
&lt;h2 id="references">References&lt;/h2>
&lt;ul>
&lt;li>Argo Rollouts project — &lt;code>argoproj.io/argo-rollouts&lt;/code> (&lt;code>Rollout&lt;/code> and &lt;code>AnalysisTemplate&lt;/code> CRDs).&lt;/li>
&lt;li>Flagger project — &lt;code>fluxcd.io/flagger&lt;/code> (alternative with automatic progression).&lt;/li>
&lt;li>Istio — &lt;em>Traffic Mirroring&lt;/em> (mirror configurable at &lt;code>VirtualService&lt;/code> level).&lt;/li>
&lt;li>NGINX Ingress — &lt;em>Canary annotations&lt;/em> (&lt;code>nginx.ingress.kubernetes.io/canary-*&lt;/code>).&lt;/li>
&lt;li>vLLM project — issue tracker on hot model swap (status as of May 2026: in design, not production-ready).&lt;/li>
&lt;li>Hou et al. — &lt;em>DistServe: Disaggregating Prefill and Decoding for Goodput-optimized LLM Serving&lt;/em> (OSDI 2024) — reference on goodput metrics applicable to canary gates.&lt;/li>
&lt;li>Bürkner et al. — &lt;em>Statistical methods for detecting model drift in production&lt;/em> (various articles on Wasserstein and KL in ML monitoring).&lt;/li>
&lt;/ul></description></item><item><title>Autoscaling LLM inference on Kubernetes: HPA with custom metrics and KEDA for vLLM</title><link>https://blog.lo0.es/en/posts/llm-inference-autoscaling-kubernetes-hpa-keda/</link><pubDate>Mon, 01 Jun 2026 16:00:00 +0200</pubDate><guid>https://blog.lo0.es/en/posts/llm-inference-autoscaling-kubernetes-hpa-keda/</guid><description>&lt;blockquote>
&lt;p>This post complements those on &lt;a href="https://blog.lo0.es/en/posts/gpu-observability-llm-inference-twelve-dcgm-vllm-metrics/">GPU observability for LLM inference&lt;/a> (where the metrics that feed the HPA come from), &lt;a href="https://blog.lo0.es/en/posts/capacity-planning-on-premise-llm-inference/">Capacity planning&lt;/a> (what ceiling and what head-room the autoscaler assumes) and &lt;a href="https://blog.lo0.es/en/posts/continuous-batching-orca-vllm-chunked-prefill-goodput/">Continuous batching&lt;/a> (which explains why &lt;code>num_requests_waiting&lt;/code> is the primary metric).&lt;/p>
&lt;/blockquote>
&lt;h2 id="tldr">TL;DR&lt;/h2>
&lt;p>Classic Kubernetes autoscaling, HPA on &lt;code>cpu&lt;/code> or &lt;code>memory&lt;/code>, &lt;strong>is no use for LLM inference&lt;/strong>. The reason: the vLLM pod consumes little CPU (the GPU does the work) and the process RSS is flat; both metrics can sit at 30 % while the GPU is saturated and the request queue grows unchecked. The four viable signals that do respond to the real load are: &lt;strong>&lt;code>vllm:num_requests_waiting&lt;/code>&lt;/strong> (the queue, the primary metric), &lt;strong>&lt;code>vllm:gpu_cache_usage_perc&lt;/code>&lt;/strong> (pressure on the KV cache pool), &lt;strong>TTFT P95&lt;/strong> via the &lt;code>vllm:time_to_first_token_seconds_bucket&lt;/code> histogram (the SLO guarantee) and the &lt;strong>batch fill ratio&lt;/strong> &lt;code>num_requests_running / max_num_seqs&lt;/code> (utilisation of the concurrency ceiling). For an HPA to consume Prometheus metrics you need an adapter; as of May 2026 there are two mature options: &lt;strong>&lt;code>prometheus-adapter&lt;/code>&lt;/strong> (cluster-wide sigma, static configuration, &lt;code>external.metrics.k8s.io&lt;/code> output) and &lt;strong>KEDA&lt;/strong> (&lt;code>ScaledObject&lt;/code> with a Prometheus trigger, configurable polling, optional scale to zero, cron integration). KEDA is the dominant option for LLM on a generic cluster because it solves the &amp;ldquo;warm pool + cron + engine metric&amp;rdquo; pattern in a single CRD. The dominant operational challenge is not the scaling logic but the &lt;strong>cold start&lt;/strong>: a vLLM pod with Llama 70B BF16 (140 GB) takes between &lt;strong>90 seconds&lt;/strong> (model pre-cached on a local PV) and &lt;strong>6 minutes&lt;/strong> (image pull + model download from object store) to serve the first token. The five levers that cut it down are an image pre-pulled via DaemonSet, the model cached on a regional PV or tmpfs, a &lt;strong>warm pool&lt;/strong> with &lt;code>minReplicaCount &amp;gt; 0&lt;/code>, &lt;strong>predictive scaling&lt;/strong> via KEDA cron when the traffic pattern is predictable (offices 9–18 h), and parallel model download. The three specific LLM scale-down pitfalls: cutting streaming SSE connections halfway through a response (graceful drain with &lt;code>terminationGracePeriodSeconds&lt;/code> ≥ 60 s), scale-out/in oscillation from a badly calibrated stabilization window, and forgetting that the HPA only scales pods; &lt;strong>GPU nodes&lt;/strong> are scaled with cluster-autoscaler over labelled nodepools. This post includes the minimal YAML manifests.&lt;/p>
&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(#asm)}.cyc{stroke:#888;stroke-width:1.2;fill:none;stroke-dasharray:4 2;marker-end:url(#asm)}&lt;/style>
&lt;defs>&lt;marker id="asm" 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 · autoscaling on the metrics OBSERVE measures&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="the-analogy-the-bakery-with-wood-fired-ovens">The analogy: the bakery with wood-fired ovens&lt;/h2>
&lt;p>An artisan bakery has three wood-fired ovens. Each oven takes 25 minutes to reach temperature from cold. Once hot, it bakes bread continuously with a run of 18 minutes per batch. The manager wants to maximise bread sold per day without burning wood for nothing, and she knows three things: that there is a demand peak at 7:30 every morning, that Mondays sell almost nothing, and that when the bread on the counter runs out the customers go to the supermarket next door.&lt;/p>
&lt;p>The cheap strategy, lighting ovens when there is a queue in the shop, &lt;strong>does not work&lt;/strong>. By the time the queue grows and the manager lights the second oven, that oven will not be ready until 25 minutes later; the customers in that window are lost. The &amp;ldquo;queue at the counter&amp;rdquo; signal arrives late.&lt;/p>
&lt;p>The smart strategy: light the second oven at 6:55, ahead of the predictable 7:30 peak, and leave it running until 10:00 even though the queue drops at 8:15. Keep the third oven off from Monday to Wednesday because demand does not arrive; light it proactively on Thursdays at 12:00 because historically it goes up. Keep a stock of pre-proved raw dough in the chiller so that when the oven is ready the bread goes in within 30 seconds and there is no two-hour proving wait.&lt;/p>
&lt;p>Autoscaling an LLM inference cluster works the same way:&lt;/p>
&lt;ul>
&lt;li>&lt;strong>Lighting ovens from cold&lt;/strong> = reactive scale-out when the queue grows (slow, loses customers).&lt;/li>
&lt;li>&lt;strong>Proactive cron&lt;/strong> = predictive scaling when the pattern is known (working hours, expected peaks).&lt;/li>
&lt;li>&lt;strong>Pre-proved dough&lt;/strong> = warm pool of replicas with the model loaded but under 0 load.&lt;/li>
&lt;li>&lt;strong>Switching off ovens with no bread inside&lt;/strong> = scale-down respecting the active streams (you do not shut the oven with bread in it).&lt;/li>
&lt;/ul>
&lt;p>The key metric, &amp;ldquo;how many customers are in the queue&amp;rdquo;, is called &lt;code>num_requests_waiting&lt;/code>. The metric that says &amp;ldquo;the oven is about to run out of dough for new loaves&amp;rdquo; is called &lt;code>gpu_cache_usage_perc&lt;/code>. And the quality-of-service metric, &amp;ldquo;how long the first loaf takes to come out when a new customer walks in&amp;rdquo;, is called TTFT.&lt;/p>
&lt;h2 id="why-hpa-on-cpu-is-no-use">Why HPA on CPU is no use&lt;/h2>
&lt;p>The classic Kubernetes HPA looks at the pod&amp;rsquo;s &lt;code>resource.cpu&lt;/code>. For a conventional HTTP service, Node.js or a REST API, CPU moves linearly with traffic and the HPA scales with reasonable accuracy. For a vLLM or SGLang pod on GPU, the pod&amp;rsquo;s CPU typically lives between 5 % and 15 % regardless of whether the GPU is at 30 % or 99 % load: the real work is done by the device, not the process. Result: the CPU-based HPA &lt;strong>never&lt;/strong> triggers scale-out even though the GPU is bursting, and clients pile up in the queue until TTFT P95 crosses the SLO. The operator discovers the problem through the TTFT alert, not through the HPA.&lt;/p>
&lt;p>&lt;code>memory&lt;/code> is no use either: the vLLM process RSS is flat after startup (model plus buffers loaded in one go); it does not reflect the real pressure on the GPU. The only things that rise and fall with the useful inference load are metrics the engine publishes explicitly: request queue, KV cache pool, SLO latencies. Without an adapter that makes them visible to the HPA, autoscaling is blind.&lt;/p>
&lt;h2 id="the-four-viable-signals">The four viable signals&lt;/h2>
&lt;div class="diagram" style="max-width:820px;margin:1.5rem auto;">
&lt;svg viewBox="0 0 820 320" xmlns="http://www.w3.org/2000/svg" role="img" aria-label="four LLM autoscaling signals">
&lt;style>.b{stroke:#333;stroke-width:1.4;rx:6}.p{fill:#dfe9f5;stroke:#356}.s{fill:#eef0d0;stroke:#7a3}.t{fill:#f4e3cf;stroke:#a63}.f{fill:#ead8f5;stroke:#634}.title{font:600 13px sans-serif;fill:#222}.h{font:700 12px sans-serif;fill:#222}.m{font:11px monospace;fill:#222}.n{font:italic 11px sans-serif;fill:#444}&lt;/style>
&lt;text x="410" y="20" text-anchor="middle" class="title">The four metrics that feed the LLM HPA&lt;/text>
&lt;rect x="30" y="40" width="370" height="120" class="b p"/>
&lt;text x="40" y="62" class="h">1 · QUEUE (PRIMARY)&lt;/text>
&lt;text x="40" y="82" class="m">vllm:num_requests_waiting&lt;/text>
&lt;text x="40" y="105" class="n">Any requests waiting to enter the batch?&lt;/text>
&lt;text x="40" y="125" class="n">Reacts instantly. Robust to model changes.&lt;/text>
&lt;text x="40" y="145" class="n">Typical HPA threshold: target = 5 (scale-out if &amp;gt; 5 sustained).&lt;/text>
&lt;rect x="420" y="40" width="370" height="120" class="b s"/>
&lt;text x="430" y="62" class="h">2 · KV CACHE POOL&lt;/text>
&lt;text x="430" y="82" class="m">vllm:gpu_cache_usage_perc&lt;/text>
&lt;text x="430" y="105" class="n">How much KV cache VRAM is in use?&lt;/text>
&lt;text x="430" y="125" class="n">Predictive: warns before the queue starts.&lt;/text>
&lt;text x="430" y="145" class="n">Typical threshold: target = 0.85 (scale-out if &amp;gt; 0.85).&lt;/text>
&lt;rect x="30" y="170" width="370" height="120" class="b t"/>
&lt;text x="40" y="192" class="h">3 · TTFT P95 (SLO)&lt;/text>
&lt;text x="40" y="212" class="m">histogram_quantile(0.95,&lt;/text>
&lt;text x="40" y="226" class="m"> rate(vllm:time_to_first_token_seconds_bucket[5m]))&lt;/text>
&lt;text x="40" y="246" class="n">The contractual guarantee to the customer.&lt;/text>
&lt;text x="40" y="266" class="n">Backup for the other two; reacts late but defends the SLO.&lt;/text>
&lt;rect x="420" y="170" width="370" height="120" class="b f"/>
&lt;text x="430" y="192" class="h">4 · BATCH FILL RATIO&lt;/text>
&lt;text x="430" y="212" class="m">vllm:num_requests_running&lt;/text>
&lt;text x="430" y="226" class="m"> / max_num_seqs (config)&lt;/text>
&lt;text x="430" y="246" class="n">Utilisation of the engine's concurrency ceiling.&lt;/text>
&lt;text x="430" y="266" class="n">Useful for scale-down: if ratio &amp;lt; 0.4 sustained, a replica is spare.&lt;/text>
&lt;text x="410" y="310" text-anchor="middle" class="n">Recommended policy: queue as primary, KV cache as secondary, TTFT as guardrail&lt;/text>
&lt;/svg>
&lt;/div>
&lt;p>&lt;strong>Signal 1, &lt;code>vllm:num_requests_waiting&lt;/code> (queue).&lt;/strong> It is the most direct metric: how many requests are waiting to enter the batch. It reacts the instant the target concurrency saturates. It is robust against model changes (the number of requests is the same concept whether it is Llama 7B or 70B). It is the &lt;strong>primary metric&lt;/strong> of the LLM HPA. Typical threshold: &lt;code>target = 5&lt;/code> requests waiting on average; if the queue grows above 5 sustained for 2 minutes, scale out.&lt;/p>
&lt;p>&lt;strong>Signal 2, &lt;code>vllm:gpu_cache_usage_perc&lt;/code> (KV pool).&lt;/strong> It moves &lt;strong>before&lt;/strong> the queue does: the KV pool fills up while the batch slots are still free, until the engine starts rejecting new requests for OOM prevention and the queue forms. It is therefore &lt;strong>predictive&lt;/strong>: it triggers scale-out before the client notices degradation. Typical threshold: &lt;code>target = 0.85&lt;/code> (85 % of pool used).&lt;/p>
&lt;p>&lt;strong>Signal 3, TTFT P95.&lt;/strong> The contractual guarantee. If TTFT P95 leaves the SLO, scale out even though the queue and the KV pool look reasonable (there may be a spike of long prompts). It is &lt;strong>reactive&lt;/strong>, it leaves the SLO before your HPA reacts, but it works as a final guardrail.&lt;/p>
&lt;p>&lt;strong>Signal 4, batch fill ratio.&lt;/strong> The &lt;code>num_requests_running / max_num_seqs&lt;/code> quotient (the latter is engine config, not a metric). Useful for &lt;strong>scale-down&lt;/strong>: if the ratio stays below 0.4 for 10 minutes, there is spare capacity and replicas can be reduced safely.&lt;/p>
&lt;p>The recommended policy combines all four: the queue and the KV pool trigger scale-out (whichever arrives first), TTFT confirms it as a guardrail, and the batch fill ratio manages scale-down. Implementing that in a single HPA demands external metrics; KEDA makes this manageable.&lt;/p>
&lt;h2 id="the-wiring-keda-as-the-prometheus-adapter">The wiring: KEDA as the Prometheus adapter&lt;/h2>
&lt;p>KEDA introduces two main CRDs: &lt;code>TriggerAuthentication&lt;/code> (how to authenticate against the source) and &lt;code>ScaledObject&lt;/code> (which deployment to scale with which triggers). For a vLLM deployment with Prometheus as the source:&lt;/p>
&lt;div class="highlight">&lt;pre tabindex="0" class="chroma">&lt;code class="language-yaml" data-lang="yaml">&lt;span class="line">&lt;span class="cl">&lt;span class="nt">apiVersion&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">keda.sh/v1alpha1&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w">&lt;/span>&lt;span class="nt">kind&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">ScaledObject&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w">&lt;/span>&lt;span class="nt">metadata&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">name&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">vllm-llama70b-scaler&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">namespace&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">inference&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w">&lt;/span>&lt;span class="nt">spec&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">scaleTargetRef&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">name&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">vllm-llama70b&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">minReplicaCount&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="m">2&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="c"># warm pool&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">maxReplicaCount&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="m">20&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">pollingInterval&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="m">15&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">cooldownPeriod&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="m">300&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="c"># 5 min before scale-down&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">advanced&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">horizontalPodAutoscalerConfig&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">behavior&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">scaleDown&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">stabilizationWindowSeconds&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="m">600&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="c"># large window to avoid oscillation&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">policies&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>- &lt;span class="nt">type&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">Pods&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">value&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="m">1&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">periodSeconds&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="m">120&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">scaleUp&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">stabilizationWindowSeconds&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="m">30&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">policies&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>- &lt;span class="nt">type&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">Pods&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">value&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="m">2&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">periodSeconds&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="m">60&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">triggers&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>- &lt;span class="nt">type&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">prometheus&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">metadata&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">serverAddress&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">http://prometheus.observability.svc:9090&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">metricName&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">vllm_queue_depth&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">threshold&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="s2">&amp;#34;5&amp;#34;&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">query&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="p">|&lt;/span>&lt;span class="sd">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="sd"> avg(vllm:num_requests_waiting{deployment=&amp;#34;vllm-llama70b&amp;#34;})&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>- &lt;span class="nt">type&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">prometheus&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">metadata&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">serverAddress&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">http://prometheus.observability.svc:9090&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">metricName&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">vllm_kv_cache&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">threshold&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="s2">&amp;#34;0.85&amp;#34;&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">query&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="p">|&lt;/span>&lt;span class="sd">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="sd"> avg(vllm:gpu_cache_usage_perc{deployment=&amp;#34;vllm-llama70b&amp;#34;})&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>- &lt;span class="nt">type&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">prometheus&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">metadata&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">serverAddress&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">http://prometheus.observability.svc:9090&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">metricName&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">vllm_ttft_p95&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">threshold&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="s2">&amp;#34;1.5&amp;#34;&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">query&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="p">|&lt;/span>&lt;span class="sd">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="sd"> histogram_quantile(0.95,
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="sd"> sum by(le)(rate(vllm:time_to_first_token_seconds_bucket{deployment=&amp;#34;vllm-llama70b&amp;#34;}[5m])))&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>Three non-obvious operational details:&lt;/p>
&lt;p>&lt;strong>&lt;code>minReplicaCount: 2&lt;/code>.&lt;/strong> This is the warm pool. Keeping at least two replicas guarantees availability if a node is lost and absorbs spikes without waiting for the cold start of the first scale-up. Dropping it to 0 saves GPU off-peak but introduces 90 s–6 min of latency for the first new client.&lt;/p>
&lt;p>&lt;strong>&lt;code>stabilizationWindowSeconds: 600&lt;/code> on scale-down.&lt;/strong> Ten minutes. Models are not &lt;code>nginx&lt;/code>: if a replica closes prematurely and two minutes later there is another peak, the cold start of a new pod is what the client waits. Better to keep extra replicas around for twice as long as you would for a normal web service.&lt;/p>
&lt;p>&lt;strong>&lt;code>scaleUp: stabilizationWindowSeconds: 30&lt;/code>.&lt;/strong> Thirty seconds. Scale-out has to be fast: the new pod&amp;rsquo;s cold start adds its own delay, and if on top of that the HPA waits several more minutes before triggering, the SLO is already broken.&lt;/p>
&lt;h2 id="the-big-operational-problem-cold-start">The big operational problem: cold start&lt;/h2>
&lt;p>A vLLM pod loading Llama 70B goes through these phases before serving the first token:&lt;/p>
&lt;table>
&lt;thead>
&lt;tr>
&lt;th>Phase&lt;/th>
&lt;th>Typical time&lt;/th>
&lt;th>Can be accelerated with&lt;/th>
&lt;/tr>
&lt;/thead>
&lt;tbody>
&lt;tr>
&lt;td>Image pull (4–6 GB)&lt;/td>
&lt;td>30–90 s&lt;/td>
&lt;td>DaemonSet pre-pull&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>Model download (140 GB BF16)&lt;/td>
&lt;td>60–300 s&lt;/td>
&lt;td>Cached regional PV, S3 + multi-thread&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>Loading the model into HBM&lt;/td>
&lt;td>30–90 s&lt;/td>
&lt;td>tmpfs or local NVMe&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>CUDA graph capture&lt;/td>
&lt;td>20–60 s&lt;/td>
&lt;td>&lt;code>--enforce-eager&lt;/code> (slower at runtime but fast startup)&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>PagedAttention warmup&lt;/td>
&lt;td>5–15 s&lt;/td>
&lt;td>—&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>Health check ready&lt;/td>
&lt;td>10–30 s&lt;/td>
&lt;td>probe tuning&lt;/td>
&lt;/tr>
&lt;/tbody>
&lt;/table>
&lt;p>&lt;strong>Total without optimisation: 4–10 minutes.&lt;/strong> That is how long a new replica takes to absorb traffic. With all the levers combined: &lt;strong>45–90 seconds&lt;/strong>. The difference between those two numbers is the main platform work for LLM autoscaling.&lt;/p>
&lt;h3 id="the-five-levers">The five levers&lt;/h3>
&lt;p>&lt;strong>Lever 1, pre-pulled image.&lt;/strong> A trivial DaemonSet runs &lt;code>ctr image pull&lt;/code> (or &lt;code>crictl pull&lt;/code>) on the GPU nodes as soon as they join the cluster. The inference engine image stays on disk; new pods skip the 30–90 s of pull. Cost: around 6 GB of disk per node.&lt;/p>
&lt;div class="highlight">&lt;pre tabindex="0" class="chroma">&lt;code class="language-yaml" data-lang="yaml">&lt;span class="line">&lt;span class="cl">&lt;span class="nt">apiVersion&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">apps/v1&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w">&lt;/span>&lt;span class="nt">kind&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">DaemonSet&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w">&lt;/span>&lt;span class="nt">metadata&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>{&lt;span class="w"> &lt;/span>&lt;span class="nt">name&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">vllm-image-warmer }&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w">&lt;/span>&lt;span class="nt">spec&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">selector&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>{&lt;span class="w"> &lt;/span>&lt;span class="nt">matchLabels&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>{&lt;span class="w"> &lt;/span>&lt;span class="nt">app&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">vllm-warmer } }&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">template&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">metadata&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>{&lt;span class="w"> &lt;/span>&lt;span class="nt">labels&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>{&lt;span class="w"> &lt;/span>&lt;span class="nt">app&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">vllm-warmer } }&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">spec&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">nodeSelector&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>{&lt;span class="w"> &lt;/span>&lt;span class="nt">workload&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">gpu }&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">initContainers&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>- &lt;span class="nt">name&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">pull&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">image&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">vllm/vllm-openai:v0.10.0&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">command&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="p">[&lt;/span>&lt;span class="s2">&amp;#34;/bin/true&amp;#34;&lt;/span>&lt;span class="p">]&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">containers&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>- &lt;span class="nt">name&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">pause&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">image&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">registry.k8s.io/pause:3.10&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>&lt;strong>Lever 2, model on a regional PV.&lt;/strong> The model download (140 GB BF16 or 35 GB FP8) from central object storage is the dominant component of the cold start. Caching the model on a &lt;strong>zone or rack PV&lt;/strong>, Rook-Ceph RBD or local NVMe provisioned by the operator, cuts 60–300 s down to 5–15 s. The antipattern: downloading the model on every startup from external S3.&lt;/p>
&lt;div class="highlight">&lt;pre tabindex="0" class="chroma">&lt;code class="language-yaml" data-lang="yaml">&lt;span class="line">&lt;span class="cl">&lt;span class="nt">volumeMounts&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>- &lt;span class="nt">name&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">model-cache&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">mountPath&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">/models&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">readOnly&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="kc">true&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w">&lt;/span>&lt;span class="nt">volumes&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>- &lt;span class="nt">name&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">model-cache&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">persistentVolumeClaim&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">claimName&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">llama70b-fp8-pvc &lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="c"># RWX shared, filled offline&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>&lt;strong>Lever 3, warm pool.&lt;/strong> &lt;code>minReplicaCount &amp;gt; 0&lt;/code> keeps pre-loaded replicas idle. The cost is idle GPU; the benefit is 0 s of cold start for the first client of a peak. For production clusters with continuous traffic: a warm pool of 2–3 replicas. For nightly batch clusters with 0 traffic: warm pool 0 and accept the cold start, or KEDA with a cron that switches on 10 minutes earlier.&lt;/p>
&lt;p>&lt;strong>Lever 4, predictive scaling with cron.&lt;/strong> When the pattern is predictable (offices 9–18 h):&lt;/p>
&lt;div class="highlight">&lt;pre tabindex="0" class="chroma">&lt;code class="language-yaml" data-lang="yaml">&lt;span class="line">&lt;span class="cl">&lt;span class="nt">triggers&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>- &lt;span class="nt">type&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">cron&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">metadata&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">timezone&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">Europe/Madrid&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">start&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="s2">&amp;#34;30 8 * * 1-5&amp;#34;&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="c"># 8:30 Monday–Friday&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">end&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="s2">&amp;#34;0 19 * * 1-5&amp;#34;&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="c"># 19:00&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">desiredReplicas&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="s2">&amp;#34;6&amp;#34;&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>Combined with reactive triggers. The HPA scales according to the maximum of the signals: if the cron asks for 6 and the queue asks for 10, the result is 10.&lt;/p>
&lt;p>&lt;strong>Lever 5, parallel download and an efficient format.&lt;/strong> For PVs that are not pre-loaded, tools such as &lt;code>nvidia-modelmanager&lt;/code>, &lt;code>s5cmd&lt;/code> or &lt;code>aria2c&lt;/code> parallelise the model download. Going from serial download (~150 MB/s) to parallel with 8 threads (~1.2 GB/s) divides the time by 8. And formats such as &lt;strong>safetensors&lt;/strong> load into HBM faster than the original &lt;strong>PyTorch pickle&lt;/strong>.&lt;/p>
&lt;h2 id="when-to-scale-nodes-not-just-pods">When to scale nodes, not just pods&lt;/h2>
&lt;p>The HPA scales &lt;strong>pods&lt;/strong>. If the cluster has no free GPU nodes, the new pod stays &lt;code>Pending&lt;/code> for lack of resources. To scale &lt;strong>nodes&lt;/strong>, you need cluster-autoscaler with a specific GPU nodepool, labelled:&lt;/p>
&lt;div class="highlight">&lt;pre tabindex="0" class="chroma">&lt;code class="language-yaml" data-lang="yaml">&lt;span class="line">&lt;span class="cl">&lt;span class="c"># nodepool config (Karpenter or cluster-autoscaler equivalent)&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w">&lt;/span>&lt;span class="nt">labels&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">workload&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">gpu&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">gpu-model&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">h100-sxm-80gb&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w">&lt;/span>&lt;span class="nt">taints&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>- &lt;span class="nt">key&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">nvidia.com/gpu&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">effect&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">NoSchedule&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w">&lt;/span>&lt;span class="nt">limits&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">min&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="m">2&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">nodes&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">max&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="m">8&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">nodes&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>Without this, the HPA can ask for 10 replicas but the cluster only delivers those that fit on nodes already up. The cold start of a new node (bare metal or cloud provisioning, PXE, OS boot, NVIDIA drivers, cluster join) is &lt;strong>much&lt;/strong> longer than the cold start of a pod: typically 5–15 minutes on preconfigured bare metal, 30–60 minutes on real provisioning. For on-premise clusters, the nodepool must be &lt;strong>always sized to the expected maximum&lt;/strong>, and the &amp;ldquo;scaling&amp;rdquo; is only on the pod side. The concept of reactive node scale-out only applies to clouds; on-premise you have to buy for the peak.&lt;/p>
&lt;h2 id="three-pitfalls-specific-to-llm-scale-down">Three pitfalls specific to LLM scale-down&lt;/h2>
&lt;p>&lt;strong>Pitfall 1, cutting streaming SSE connections.&lt;/strong> When a replica enters &lt;code>Terminating&lt;/code>, Kubernetes sends SIGTERM to the pod and, by default, kills it 30 seconds later. For vLLM that means &lt;strong>cutting streaming SSE connections halfway through the response&lt;/strong>. The client gets a 502 error with the partial output lost. Solution: &lt;code>terminationGracePeriodSeconds: 120&lt;/code> plus a preStop hook that tells the engine not to accept new requests but to finish those in flight:&lt;/p>
&lt;div class="highlight">&lt;pre tabindex="0" class="chroma">&lt;code class="language-yaml" data-lang="yaml">&lt;span class="line">&lt;span class="cl">&lt;span class="nt">spec&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">terminationGracePeriodSeconds&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="m">120&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">containers&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>- &lt;span class="nt">name&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">vllm&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">lifecycle&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">preStop&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">httpGet&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">path&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">/shutdown&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">port&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="m">8000&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>This requires the engine to expose a graceful shutdown endpoint; vLLM v1 supports it via &lt;code>--enable-graceful-shutdown&lt;/code>. Without it, scale-down breaks the SLO even though the metrics do not capture it (cut requests never enter the TTFT histogram).&lt;/p>
&lt;p>&lt;strong>Pitfall 2, scale-up/scale-down oscillation.&lt;/strong> If the scale-down &lt;code>stabilizationWindowSeconds&lt;/code> is short (~60 s default), the next drop in the queue triggers scale-down, and two minutes later the next peak triggers scale-up. The system oscillates, pays repeated cold starts, and never reaches a stable regime. Solution: scale-down with a window of 10 minutes minimum and conservative policies (&lt;code>type: Pods, value: 1, periodSeconds: 120&lt;/code>, at most one replica fewer every 2 minutes).&lt;/p>
&lt;p>&lt;strong>Pitfall 3, &lt;code>vllm:num_requests_waiting&lt;/code> with &lt;code>avg&lt;/code> when there is rebalancing.&lt;/strong> If two replicas are unbalanced (one with queue 20, the other with queue 0), &lt;code>avg&lt;/code> gives 10 and the HPA triggers scale-out when the right move would be to rebalance via the load balancer. To detect it: add an alert on &lt;code>stddev(vllm:num_requests_waiting)&lt;/code> per deployment. If the dispersion is high, the problem is not capacity but routing.&lt;/p>
&lt;h2 id="full-example-manifest">Full example manifest&lt;/h2>
&lt;p>For a vLLM deployment with Llama 70B FP8 on 4×H100 SXM per replica, KEDA with warm pool 2:&lt;/p>
&lt;div class="highlight">&lt;pre tabindex="0" class="chroma">&lt;code class="language-yaml" data-lang="yaml">&lt;span class="line">&lt;span class="cl">&lt;span class="nt">apiVersion&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">apps/v1&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w">&lt;/span>&lt;span class="nt">kind&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">Deployment&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w">&lt;/span>&lt;span class="nt">metadata&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">name&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">vllm-llama70b&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">namespace&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">inference&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w">&lt;/span>&lt;span class="nt">spec&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">replicas&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="m">2&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="c"># managed by KEDA afterwards&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">selector&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>{&lt;span class="w"> &lt;/span>&lt;span class="nt">matchLabels&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>{&lt;span class="w"> &lt;/span>&lt;span class="nt">app&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">vllm-llama70b } }&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">template&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">metadata&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">labels&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>{&lt;span class="w"> &lt;/span>&lt;span class="nt">app: vllm-llama70b, deployment&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">vllm-llama70b }&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">spec&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">terminationGracePeriodSeconds&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="m">120&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">nodeSelector&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>{&lt;span class="w"> &lt;/span>&lt;span class="nt">workload: gpu, gpu-model&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">h100-sxm-80gb }&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">tolerations&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>- &lt;span class="nt">key&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">nvidia.com/gpu&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">operator&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">Exists&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">effect&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">NoSchedule&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">containers&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>- &lt;span class="nt">name&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">vllm&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">image&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">vllm/vllm-openai:v0.10.0&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">args&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>- --&lt;span class="l">model=/models/llama-3.3-70b-fp8&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>- --&lt;span class="l">tensor-parallel-size=4&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>- --&lt;span class="l">max-num-seqs=64&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>- --&lt;span class="l">enable-prefix-caching&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>- --&lt;span class="l">enable-graceful-shutdown&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">ports&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>- {&lt;span class="w"> &lt;/span>&lt;span class="nt">name: http, containerPort&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="m">8000&lt;/span>&lt;span class="w"> &lt;/span>}&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>- {&lt;span class="w"> &lt;/span>&lt;span class="nt">name: metrics, containerPort&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="m">8000&lt;/span>&lt;span class="w"> &lt;/span>}&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">resources&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">limits&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">nvidia.com/gpu&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="s2">&amp;#34;4&amp;#34;&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">memory&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">200Gi&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">readinessProbe&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">httpGet&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>{&lt;span class="w"> &lt;/span>&lt;span class="nt">path: /health, port&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="m">8000&lt;/span>&lt;span class="w"> &lt;/span>}&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">initialDelaySeconds&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="m">60&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">periodSeconds&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="m">10&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">failureThreshold&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="m">30&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="c"># tolerates the warmup&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">lifecycle&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">preStop&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">httpGet&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>{&lt;span class="w"> &lt;/span>&lt;span class="nt">path: /shutdown, port&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="m">8000&lt;/span>&lt;span class="w"> &lt;/span>}&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">volumeMounts&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>- {&lt;span class="w"> &lt;/span>&lt;span class="nt">name: model-cache, mountPath: /models, readOnly&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="kc">true&lt;/span>&lt;span class="w"> &lt;/span>}&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>- {&lt;span class="w"> &lt;/span>&lt;span class="nt">name: dshm, mountPath&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">/dev/shm }&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">volumes&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>- &lt;span class="nt">name&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">model-cache&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">persistentVolumeClaim&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>{&lt;span class="w"> &lt;/span>&lt;span class="nt">claimName&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">llama70b-fp8-pvc }&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>- &lt;span class="nt">name&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">dshm&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">emptyDir&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>{&lt;span class="w"> &lt;/span>&lt;span class="nt">medium: Memory, sizeLimit&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">16Gi }&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w">&lt;/span>&lt;span class="nn">---&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w">&lt;/span>&lt;span class="nt">apiVersion&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">monitoring.coreos.com/v1&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w">&lt;/span>&lt;span class="nt">kind&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">PodMonitor&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w">&lt;/span>&lt;span class="nt">metadata&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>{&lt;span class="w"> &lt;/span>&lt;span class="nt">name: vllm-llama70b-metrics, namespace&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">inference }&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w">&lt;/span>&lt;span class="nt">spec&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">selector&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>{&lt;span class="w"> &lt;/span>&lt;span class="nt">matchLabels&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>{&lt;span class="w"> &lt;/span>&lt;span class="nt">app&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">vllm-llama70b } }&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">podMetricsEndpoints&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>- &lt;span class="nt">port&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">metrics&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">path&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">/metrics&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">interval&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">15s&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w">&lt;/span>&lt;span class="nn">---&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w">&lt;/span>&lt;span class="nt">apiVersion&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">keda.sh/v1alpha1&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w">&lt;/span>&lt;span class="nt">kind&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">ScaledObject&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w">&lt;/span>&lt;span class="nt">metadata&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>{&lt;span class="w"> &lt;/span>&lt;span class="nt">name: vllm-llama70b-scaler, namespace&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">inference }&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w">&lt;/span>&lt;span class="nt">spec&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">scaleTargetRef&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>{&lt;span class="w"> &lt;/span>&lt;span class="nt">name&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">vllm-llama70b }&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">minReplicaCount&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="m">2&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">maxReplicaCount&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="m">20&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">pollingInterval&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="m">15&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">cooldownPeriod&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="m">300&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">advanced&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">horizontalPodAutoscalerConfig&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">behavior&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">scaleDown&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">stabilizationWindowSeconds&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="m">600&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">policies&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>- {&lt;span class="w"> &lt;/span>&lt;span class="nt">type: Pods, value: 1, periodSeconds&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="m">120&lt;/span>&lt;span class="w"> &lt;/span>}&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">scaleUp&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">stabilizationWindowSeconds&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="m">30&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">policies&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>- {&lt;span class="w"> &lt;/span>&lt;span class="nt">type: Pods, value: 2, periodSeconds&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="m">60&lt;/span>&lt;span class="w"> &lt;/span>}&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">triggers&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>- &lt;span class="nt">type&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">prometheus&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">metadata&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">serverAddress&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">http://prometheus.observability.svc:9090&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">metricName&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">vllm_queue&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">threshold&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="s2">&amp;#34;5&amp;#34;&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">query&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">avg(vllm:num_requests_waiting{deployment=&amp;#34;vllm-llama70b&amp;#34;})&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>- &lt;span class="nt">type&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">prometheus&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">metadata&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">serverAddress&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">http://prometheus.observability.svc:9090&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">metricName&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">vllm_kv&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">threshold&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="s2">&amp;#34;0.85&amp;#34;&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">query&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">avg(vllm:gpu_cache_usage_perc{deployment=&amp;#34;vllm-llama70b&amp;#34;})&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>- &lt;span class="nt">type&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">cron&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">metadata&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">timezone&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">Europe/Madrid&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">start&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="s2">&amp;#34;30 8 * * 1-5&amp;#34;&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">end&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="s2">&amp;#34;0 19 * * 1-5&amp;#34;&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">desiredReplicas&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="s2">&amp;#34;6&amp;#34;&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>This set is the minimum viable one for LLM autoscaling on a generic cluster with the NVIDIA GPU Operator. Each team adapts it to its own SLO.&lt;/p>
&lt;h2 id="applied-to-typical-on-premise-hardware">Applied to typical on-premise hardware&lt;/h2>
&lt;p>For a generic cluster of &lt;strong>4×H100 SXM 80 GB per node, 4 GPU nodes&lt;/strong>:&lt;/p>
&lt;ul>
&lt;li>Each node hosts one vLLM replica with TP=4 and Llama 70B FP8 (one model per node, they are not shared).&lt;/li>
&lt;li>Warm pool of 2 replicas off-peak; the KEDA cron raises it to 4 during working hours.&lt;/li>
&lt;li>Cluster-autoscaler &lt;strong>does not apply&lt;/strong> (4 physical nodes bought; scaling is pods only). The number of concurrent replicas is at most the number of available nodes (if each replica uses all 4 GPUs of the whole node).&lt;/li>
&lt;li>If the sizing requires more simultaneous replicas than nodes, there are two routes: (a) lower the TP of each replica so two fit per node, (b) expand the physical nodepool. The decision is dictated by capacity planning, see &lt;a href="https://blog.lo0.es/en/posts/capacity-planning-on-premise-llm-inference/">Capacity planning for on-premise LLM inference&lt;/a>.&lt;/li>
&lt;/ul>
&lt;p>KEDA event volume: around 5 evaluations/min per ScaledObject. For 10 models served in parallel, 3,000 evaluations/h. Manageable with one KEDA operator per cluster.&lt;/p>
&lt;h2 id="what-we-have-not-covered-upcoming-articles">What we have not covered (upcoming articles)&lt;/h2>
&lt;ul>
&lt;li>&lt;strong>Cluster-autoscaler for on-premise GPU nodes&lt;/strong>: how to orchestrate bare metal provisioning (Tinkerbell, Metal³) as a function of demand.&lt;/li>
&lt;li>&lt;strong>Multi-cluster autoscaling&lt;/strong>: scaling across clusters in different DCs for geographic resilience.&lt;/li>
&lt;li>&lt;strong>Cost-aware autoscaling&lt;/strong>: prioritising nodes by hourly energy cost (on clusters with indexed tariffs).&lt;/li>
&lt;li>&lt;strong>Predictive ML-based scaling&lt;/strong>: instead of a static cron, training a model that predicts demand 30 minutes ahead.&lt;/li>
&lt;li>&lt;strong>Multi-tenant quotas and fairness&lt;/strong>: KEDA with namespace quotas so that one tenant does not monopolise the HPA.&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/speeding-up-model-cold-start-minutes-to-seconds/">Speeding up model cold start: from minutes to seconds&lt;/a> — the cold start that makes scale-to-zero viable, or not.&lt;/p>
&lt;/li>
&lt;li>
&lt;p>&lt;a href="https://blog.lo0.es/en/posts/gitops-inference-stack-flux/">GitOps for the inference stack with Flux: operating the assistant as code&lt;/a> — how autoscaling is managed declaratively under GitOps.&lt;/p>
&lt;/li>
&lt;li>
&lt;p>&lt;a href="https://blog.lo0.es/en/posts/gpu-observability-llm-inference-twelve-dcgm-vllm-metrics/">GPU observability for LLM inference&lt;/a> — source of the metrics that feed the HPA.&lt;/p>
&lt;/li>
&lt;li>
&lt;p>&lt;a href="https://blog.lo0.es/en/posts/capacity-planning-on-premise-llm-inference/">Capacity planning for on-premise LLM inference&lt;/a> — what ceiling and what head-room the autoscaler assumes.&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> — explains &lt;code>num_requests_running&lt;/code>, &lt;code>num_requests_waiting&lt;/code> and &lt;code>gpu_cache_usage_perc&lt;/code>.&lt;/p>
&lt;/li>
&lt;li>
&lt;p>&lt;a href="https://blog.lo0.es/en/posts/kv-cache-working-memory-llm-inference/">KV cache&lt;/a> — governs the KV pool and therefore the thresholds.&lt;/p>
&lt;/li>
&lt;li>
&lt;p>&lt;a href="https://blog.lo0.es/en/posts/five-maturity-levels-platform-under-llm/">Five maturity levels&lt;/a> — KEDA is a piece of level 4.&lt;/p>
&lt;/li>
&lt;li>
&lt;p>&lt;a href="https://blog.lo0.es/en/posts/canary-blue-green-shadow-llm-models/">Canary, blue-green and shadow&lt;/a> — the autoscaler coexists with the deployment strategy.&lt;/p>
&lt;/li>
&lt;li>
&lt;p>&lt;a href="https://blog.lo0.es/en/posts/llm-inference-router-l7-switchboard/">The LLM inference router&lt;/a> — the router consumes &lt;code>vllm:num_requests_running&lt;/code> and &lt;code>vllm:gpu_cache_usage_perc&lt;/code> (the same metrics as the autoscaler) to pick a replica with token-aware LB and prefix-aware routing; the two components share a cockpit but decide different things.&lt;/p>
&lt;/li>
&lt;li>
&lt;p>&lt;a href="https://blog.lo0.es/en/posts/incident-response-runbooks-llm-inference-kafka-keep/">Incident response runbooks for LLM with Keep + Kafka&lt;/a> — runbooks RB-01 (&lt;code>GpuHbmNearOom&lt;/code>) and RB-05 (&lt;code>VllmKvCachePoolNearFull&lt;/code>) use the autoscaler as an immediate mitigation lever.&lt;/p>
&lt;/li>
&lt;li>
&lt;p>&lt;a href="https://blog.lo0.es/en/posts/cpu-memory-topology-manager-rke2/">RKE2 resource managers: CPU, Memory and Topology Manager&lt;/a> — every replica the autoscaler creates goes through Topology Manager admission; if the node has no free NUMA &amp;ldquo;table&amp;rdquo;, the pod stays pending. Autoscaling has to account for NUMA granularity, not just aggregate CPU and memory.&lt;/p>
&lt;/li>
&lt;li>
&lt;p>&lt;a href="https://blog.lo0.es/en/posts/pantry-lift-from-disk-to-hbm-why-the-kitchen-opens-late/">From disk to HBM: cold start and model loading&lt;/a> — the real ceiling on elasticity is not available GPU, it is how long each new replica takes to load the model: 40 s of cold start make scale-to-zero unviable under a latency SLO.&lt;/p>
&lt;/li>
&lt;/ul>
&lt;h2 id="references">References&lt;/h2>
&lt;ul>
&lt;li>KEDA project — &lt;code>keda.sh&lt;/code> (documentación oficial de triggers Prometheus y cron).&lt;/li>
&lt;li>Kubernetes — &lt;em>Horizontal Pod Autoscaler walkthrough&lt;/em> (kubernetes.io/docs/tasks/run-application/horizontal-pod-autoscale).&lt;/li>
&lt;li>NVIDIA — &lt;em>GPU Operator on Kubernetes&lt;/em> (Helm chart oficial con DaemonSet de drivers y DCGM).&lt;/li>
&lt;li>vLLM project — &lt;code>production_monitoring/&lt;/code> (métricas Prometheus expuestas por el servidor).&lt;/li>
&lt;li>Karpenter — &lt;em>NodePool spec&lt;/em> (etiquetado y taints para nodepools GPU).&lt;/li>
&lt;li>Cluster Autoscaler — &lt;em>Scaling GPU nodes&lt;/em> (caveats de descubrimiento de recursos GPU).&lt;/li>
&lt;li>Kubernetes — &lt;em>Pod lifecycle and termination&lt;/em> (preStop, terminationGracePeriodSeconds).&lt;/li>
&lt;/ul></description></item><item><title>GPU observability for LLM inference: the twelve DCGM and vLLM metrics that dictate the health of your production</title><link>https://blog.lo0.es/en/posts/gpu-observability-llm-inference-twelve-dcgm-vllm-metrics/</link><pubDate>Mon, 01 Jun 2026 15:30:00 +0200</pubDate><guid>https://blog.lo0.es/en/posts/gpu-observability-llm-inference-twelve-dcgm-vllm-metrics/</guid><description>&lt;blockquote>
&lt;p>This post complements those on &lt;a href="https://blog.lo0.es/en/posts/llm-tracing-opentelemetry-genai/">LLM tracing with OpenTelemetry GenAI&lt;/a> (the tracing layer above the metrics), &lt;a href="https://blog.lo0.es/en/posts/capacity-planning-on-premise-llm-inference/">Capacity planning&lt;/a> (what was sized and what must be watched) and &lt;a href="https://blog.lo0.es/en/posts/continuous-batching-orca-vllm-chunked-prefill-goodput/">Continuous batching&lt;/a> (the mechanism that explains several of the engine metrics).&lt;/p>
&lt;/blockquote>
&lt;h2 id="tldr">TL;DR&lt;/h2>
&lt;p>Observability for an LLM inference cluster is built on &lt;strong>two complementary sources&lt;/strong>: the GPU hardware metrics exposed by the &lt;strong>DCGM (Data Center GPU Manager) Exporter&lt;/strong>, part of the NVIDIA GPU Operator, and the metrics from the &lt;strong>inference engine&lt;/strong> (vLLM, SGLang, TensorRT-LLM) exposed on a Prometheus-compatible &lt;code>/metrics&lt;/code>. Neither is enough on its own. The classic &lt;code>nvidia-smi&lt;/code> metric called &lt;em>GPU utilization&lt;/em> is misleading for LLMs: it reads high whenever &lt;strong>any kernel&lt;/strong> is running, without distinguishing tensor cores burning from SMs waiting on HBM. The full cockpit has &lt;strong>twelve DCGM metrics in four families&lt;/strong> (compute: &lt;code>DCGM_FI_PROF_SM_OCCUPANCY&lt;/code>, &lt;code>DCGM_FI_PROF_PIPE_TENSOR_ACTIVE&lt;/code>, &lt;code>DCGM_FI_PROF_DRAM_ACTIVE&lt;/code>; memory: &lt;code>DCGM_FI_DEV_FB_USED&lt;/code>, &lt;code>DCGM_FI_DEV_FB_FREE&lt;/code>, &lt;code>DCGM_FI_DEV_NVLINK_BANDWIDTH_TOTAL&lt;/code>; thermal-power: &lt;code>DCGM_FI_DEV_GPU_TEMP&lt;/code>, &lt;code>DCGM_FI_DEV_POWER_USAGE&lt;/code>, &lt;code>DCGM_FI_DEV_CLOCK_THROTTLE_REASONS&lt;/code>; health: &lt;code>DCGM_FI_DEV_XID_ERRORS&lt;/code>, &lt;code>DCGM_FI_DEV_ECC_DBE_VOL_TOTAL&lt;/code>, &lt;code>DCGM_FI_DEV_RETIRED_DBE&lt;/code>) and &lt;strong>five vLLM engine metrics&lt;/strong> (&lt;code>vllm:num_requests_running&lt;/code>, &lt;code>vllm:num_requests_waiting&lt;/code>, &lt;code>vllm:gpu_cache_usage_perc&lt;/code>, &lt;code>vllm:time_to_first_token_seconds&lt;/code>, &lt;code>vllm:time_per_output_token_seconds&lt;/code>). Each one has a defensible green/amber/red threshold, a PromQL query for alerting, and at least one common false reading that confuses the junior operator. The &lt;strong>six critical alerts&lt;/strong> that any production cluster must fire are: HBM &amp;gt; 92 %, thermal or power throttle, XID error, ECC double-bit, KV cache pool &amp;gt; 95 %, and TTFT P95 outside SLO for 5 minutes. The point of having this panel: that the operator on shift diagnoses the origin of a degradation in &lt;strong>under five minutes&lt;/strong>, without opening an SSH console to the GPUs. When that holds, the cluster has moved to professional operation; until then, it is operated on intuition.&lt;/p>
&lt;h2 id="you-are-here-observe-the-other-half-of-tracing">You are here: OBSERVE (the other half of tracing)&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: Observe">
&lt;style>.box{stroke:#444;stroke-width:1.4;rx:6}.active{fill:#c9a8e9;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(#obm)}.cyc{stroke:#888;stroke-width:1.2;fill:none;stroke-dasharray:4 2;marker-end:url(#obm)}&lt;/style>
&lt;defs>&lt;marker id="obm" 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: OBSERVE · metrics (DCGM + engine) complement tracing&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 idle"/>&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 active"/>&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;p>Tracing, already covered in &lt;a href="https://blog.lo0.es/en/posts/llm-tracing-opentelemetry-genai/">LLM tracing with OpenTelemetry GenAI&lt;/a>, answers &lt;em>what happened in this specific request&lt;/em>. Metrics answer &lt;em>what is happening across the cluster in aggregate&lt;/em>. They are complementary: an alert on the metrics side tells you &amp;ldquo;the cluster is degrading&amp;rdquo;, tracing tells you &amp;ldquo;and here is the specific trace that proves it&amp;rdquo;. A cluster without tracing but with metrics operates; a cluster without metrics but with tracing &lt;strong>does not operate, it debugs&lt;/strong>.&lt;/p>
&lt;h2 id="the-analogy-the-cockpit-of-a-modern-aircraft">The analogy: the cockpit of a modern aircraft&lt;/h2>
&lt;p>In a modern commercial aircraft, the pilot&amp;rsquo;s instrument panel has more than 70 active indicators. If there were only one, the altimeter, say, the aircraft would fly into the ground at the first moment of low visibility. You need the altimeter &lt;strong>and&lt;/strong> the attitude indicator, &lt;strong>and&lt;/strong> the airspeed indicator, &lt;strong>and&lt;/strong> the turn indicator, &lt;strong>and&lt;/strong> the fuel gauge, &lt;strong>and&lt;/strong> the oil pressure gauges for each engine, &lt;strong>and&lt;/strong> the turbine exit temperatures. Each answers a different question. And together they cover the operational question: &lt;em>is the aircraft healthy, is it where it should be, and is it going where we want?&lt;/em>&lt;/p>
&lt;p>Observability for an LLM inference cluster works the same way. A single metric, &amp;ldquo;GPU utilization 99 %&amp;rdquo;, answers nothing useful. It is like looking only at the car&amp;rsquo;s odometer to diagnose why the engine is making a noise. The full cockpit is &lt;strong>twelve instruments on the hardware side plus five on the inference engine side&lt;/strong>, organised into families that answer different questions:&lt;/p>
&lt;ul>
&lt;li>&lt;strong>Compute and efficiency&lt;/strong>: &lt;em>are the tensor cores doing the work we expect, or are they waiting?&lt;/em>&lt;/li>
&lt;li>&lt;strong>Memory&lt;/strong>: &lt;em>is there VRAM left for new requests, or are we on the edge of OOM?&lt;/em>&lt;/li>
&lt;li>&lt;strong>Thermal and power&lt;/strong>: &lt;em>is the hardware healthy, or is it silently limiting throughput?&lt;/em>&lt;/li>
&lt;li>&lt;strong>Health and errors&lt;/strong>: &lt;em>is there hardware degradation under way (ECC, XID, NVLink)?&lt;/em>&lt;/li>
&lt;li>&lt;strong>Inference engine&lt;/strong>: &lt;em>is the queue growing, is the KV pool saturated, is the SLO being met?&lt;/em>&lt;/li>
&lt;/ul>
&lt;p>The first four answer &amp;ldquo;is the GPU fine?&amp;rdquo;. The fifth answers &amp;ldquo;is it delivering the service we promised?&amp;rdquo;. The two questions are different and both must have an answer at a glance.&lt;/p>
&lt;h2 id="why-nvidia-smi-gpu-util-misleads-with-llms">Why &lt;code>nvidia-smi&lt;/code> &lt;code>GPU-Util&lt;/code> misleads with LLMs&lt;/h2>
&lt;p>The classic metric that appears in &lt;code>nvidia-smi&lt;/code> as &lt;code>GPU-Util&lt;/code> corresponds to &lt;code>DCGM_FI_DEV_GPU_UTIL&lt;/code>. Its official definition: &amp;ldquo;percentage of time during which one or more kernels were executing on the GPU&amp;rdquo;. The problem with LLMs: the decode phase is &lt;strong>memory-bound&lt;/strong>, not compute-bound. When the inference engine decodes token by token, the GPU spends 90 % of the time waiting for the HBM to finish delivering the model weights and the KV cache. There is a kernel running (the HBM read), so &lt;code>GPU-Util&lt;/code> reports values close to 100 %. But the tensor cores are idle: the bottleneck is memory, not compute.&lt;/p>
&lt;p>Practical result: the operator sees &amp;ldquo;GPU-Util 99 %&amp;rdquo; in Grafana and assumes &amp;ldquo;GPU saturated, no more load can go in&amp;rdquo;. But the reality may be &amp;ldquo;compute at 25 %, HBM saturated at 95 %&amp;rdquo;, which changes the operational decisions (quantisation, batch size, parallelism). The classic metric lies by oversimplifying.&lt;/p>
&lt;p>The right move is to look at the &lt;strong>three DCGM profiling metrics&lt;/strong> of the &lt;code>_FI_PROF_*&lt;/code> subsystem:&lt;/p>
&lt;ul>
&lt;li>&lt;code>DCGM_FI_PROF_SM_OCCUPANCY&lt;/code>: ratio of active warps over the maximum per SM. &lt;em>Is there parallel work?&lt;/em>&lt;/li>
&lt;li>&lt;code>DCGM_FI_PROF_PIPE_TENSOR_ACTIVE&lt;/code>: % of cycles with tensor cores effectively active. &lt;em>Is compute working?&lt;/em>&lt;/li>
&lt;li>&lt;code>DCGM_FI_PROF_DRAM_ACTIVE&lt;/code>: % of cycles with HBM transferring. &lt;em>Is memory saturated?&lt;/em>&lt;/li>
&lt;/ul>
&lt;p>A typical decode-bound GPU running Llama 70B on an H100 shows: SM occupancy 35–55 %, tensor active 15–30 %, DRAM active 80–95 %. That is the real &amp;ldquo;saturated GPU&amp;rdquo; for LLMs. The three together tell the regimes apart; none of them alone says anything actionable.&lt;/p>
&lt;h2 id="how-they-are-wired-up-in-production">How they are wired up in production&lt;/h2>
&lt;p>The platform side is covered in &lt;a href="https://blog.lo0.es/en/posts/five-maturity-levels-platform-under-llm/">Five maturity levels&lt;/a> (level 4, the GPU plane) and &lt;a href="https://blog.lo0.es/en/posts/greenfield-deployment-phases-on-premise-llm-platform/">Seven deployment phases&lt;/a> (phase F5). For the observer, the key pieces are:&lt;/p>
&lt;p>&lt;strong>NVIDIA GPU Operator.&lt;/strong> Helm manifests that deploy on each GPU node: drivers, container toolkit, MIG manager and &lt;strong>DCGM Exporter&lt;/strong>. The last one exposes &lt;code>/metrics&lt;/code> in Prometheus format with all the &lt;code>DCGM_FI_*&lt;/code> listed above. It is scraped from the cluster&amp;rsquo;s internal Prometheus.&lt;/p>
&lt;p>&lt;strong>Inference engine.&lt;/strong> vLLM exposes &lt;code>/metrics&lt;/code> on port 8000 (default) with &lt;code>vllm:*&lt;/code> metrics. SGLang exposes it too, with the &lt;code>sglang:&lt;/code> prefix. TensorRT-LLM exposes it through Triton Inference Server with the &lt;code>nv_inference:&lt;/code> prefix. The basic naming convention is similar across the three engines; the thresholds and queries in this post assume vLLM, but they translate.&lt;/p>
&lt;p>&lt;strong>ServiceMonitor / PodMonitor.&lt;/strong> The Prometheus operator resource that says what to scrape. Minimal example:&lt;/p>
&lt;div class="highlight">&lt;pre tabindex="0" class="chroma">&lt;code class="language-yaml" data-lang="yaml">&lt;span class="line">&lt;span class="cl">&lt;span class="nt">apiVersion&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">monitoring.coreos.com/v1&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w">&lt;/span>&lt;span class="nt">kind&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">PodMonitor&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w">&lt;/span>&lt;span class="nt">metadata&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">name&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">vllm-inference&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w">&lt;/span>&lt;span class="nt">spec&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">selector&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">matchLabels&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>{&lt;span class="w"> &lt;/span>&lt;span class="nt">app&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">vllm }&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">podMetricsEndpoints&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>- &lt;span class="nt">port&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">metrics&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">interval&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">15s&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>&lt;strong>Dashboards.&lt;/strong> The NVIDIA operator publishes reference Grafana dashboards for DCGM in &lt;code>nvidia/dcgm-exporter&lt;/code> (the official repo). vLLM publishes one in &lt;code>vllm-project/vllm&lt;/code> (the &lt;code>examples/&lt;/code> folder). Both work as a base; each team adds the panels specific to its own SLO.&lt;/p>
&lt;h2 id="the-twelve-dcgm-metrics-organised-by-family">The twelve DCGM metrics organised by family&lt;/h2>
&lt;div class="diagram" style="max-width:820px;margin:1.5rem auto;">
&lt;svg viewBox="0 0 820 360" xmlns="http://www.w3.org/2000/svg" role="img" aria-label="twelve DCGM metrics in four families">
&lt;style>.b{stroke:#333;stroke-width:1.4;rx:6}.fc{fill:#dfe9f5;stroke:#356}.fm{fill:#eef0d0;stroke:#7a3}.ft{fill:#f4e3cf;stroke:#a63}.fs{fill:#f6e2e2;stroke:#a33}.title{font:600 13px sans-serif;fill:#222}.fam{font:700 11px sans-serif;fill:#222}.met{font:10px monospace;fill:#222}.note{font:italic 10px sans-serif;fill:#555}&lt;/style>
&lt;text x="410" y="20" text-anchor="middle" class="title">DCGM cockpit: 12 metrics in 4 families&lt;/text>
&lt;rect x="20" y="40" width="195" height="290" class="b fc"/>
&lt;text x="117" y="60" text-anchor="middle" class="fam">COMPUTE&lt;/text>
&lt;text x="30" y="90" class="met">DCGM_FI_PROF_&lt;/text>&lt;text x="30" y="105" class="met">SM_OCCUPANCY&lt;/text>
&lt;text x="30" y="135" class="met">DCGM_FI_PROF_&lt;/text>&lt;text x="30" y="150" class="met">PIPE_TENSOR_ACTIVE&lt;/text>
&lt;text x="30" y="180" class="met">DCGM_FI_PROF_&lt;/text>&lt;text x="30" y="195" class="met">DRAM_ACTIVE&lt;/text>
&lt;text x="30" y="240" text-anchor="start" class="note">Is compute working or&lt;/text>
&lt;text x="30" y="254" text-anchor="start" class="note">waiting on HBM?&lt;/text>
&lt;rect x="220" y="40" width="195" height="290" class="b fm"/>
&lt;text x="317" y="60" text-anchor="middle" class="fam">MEMORY&lt;/text>
&lt;text x="230" y="90" class="met">DCGM_FI_DEV_&lt;/text>&lt;text x="230" y="105" class="met">FB_USED&lt;/text>
&lt;text x="230" y="135" class="met">DCGM_FI_DEV_&lt;/text>&lt;text x="230" y="150" class="met">FB_FREE&lt;/text>
&lt;text x="230" y="180" class="met">DCGM_FI_DEV_NVLINK_&lt;/text>&lt;text x="230" y="195" class="met">BANDWIDTH_TOTAL&lt;/text>
&lt;text x="230" y="240" class="note">Is there VRAM left for&lt;/text>
&lt;text x="230" y="254" class="note">new requests?&lt;/text>
&lt;rect x="420" y="40" width="195" height="290" class="b ft"/>
&lt;text x="517" y="60" text-anchor="middle" class="fam">THERMAL · POWER&lt;/text>
&lt;text x="430" y="90" class="met">DCGM_FI_DEV_&lt;/text>&lt;text x="430" y="105" class="met">GPU_TEMP&lt;/text>
&lt;text x="430" y="135" class="met">DCGM_FI_DEV_&lt;/text>&lt;text x="430" y="150" class="met">POWER_USAGE&lt;/text>
&lt;text x="430" y="180" class="met">DCGM_FI_DEV_CLOCK_&lt;/text>&lt;text x="430" y="195" class="met">THROTTLE_REASONS&lt;/text>
&lt;text x="430" y="240" class="note">Healthy hardware or&lt;/text>
&lt;text x="430" y="254" class="note">silently limiting?&lt;/text>
&lt;rect x="620" y="40" width="180" height="290" class="b fs"/>
&lt;text x="710" y="60" text-anchor="middle" class="fam">HEALTH&lt;/text>
&lt;text x="630" y="90" class="met">DCGM_FI_DEV_&lt;/text>&lt;text x="630" y="105" class="met">XID_ERRORS&lt;/text>
&lt;text x="630" y="135" class="met">DCGM_FI_DEV_&lt;/text>&lt;text x="630" y="150" class="met">ECC_DBE_VOL_TOTAL&lt;/text>
&lt;text x="630" y="180" class="met">DCGM_FI_DEV_&lt;/text>&lt;text x="630" y="195" class="met">RETIRED_DBE&lt;/text>
&lt;text x="630" y="240" class="note">Is the silicon&lt;/text>
&lt;text x="630" y="254" class="note">degrading right now?&lt;/text>
&lt;text x="410" y="350" text-anchor="middle" class="note">Each family answers a different question · none suffices alone&lt;/text>
&lt;/svg>
&lt;/div>
&lt;h3 id="family-1--compute">Family 1 — Compute&lt;/h3>
&lt;p>&lt;strong>&lt;code>DCGM_FI_PROF_SM_OCCUPANCY&lt;/code>&lt;/strong>: ratio of active warps per SM over the maximum possible. Value between 0 and 1.&lt;/p>
&lt;ul>
&lt;li>&lt;strong>Green&lt;/strong>: 0.30–0.70 (typical LLM decode regime).&lt;/li>
&lt;li>&lt;strong>Amber&lt;/strong>: &amp;lt; 0.20 sustained (batch too small, GPU under-used in parallelism).&lt;/li>
&lt;li>&lt;strong>Red&lt;/strong>: 0.95 sustained with low DRAM_ACTIVE (pathological kernel saturating the SMs).&lt;/li>
&lt;/ul>
&lt;p>&lt;strong>&lt;code>DCGM_FI_PROF_PIPE_TENSOR_ACTIVE&lt;/code>&lt;/strong>: % of cycles with tensor cores executing. The key metric for &amp;ldquo;is compute producing?&amp;rdquo;.&lt;/p>
&lt;ul>
&lt;li>&lt;strong>Green in prefill&lt;/strong>: 50–80 %.&lt;/li>
&lt;li>&lt;strong>Green in decode&lt;/strong>: 15–30 % (decode is memory-bound, this is not a symptom of a problem).&lt;/li>
&lt;li>&lt;strong>Red&lt;/strong>: &amp;lt; 5 % sustained in prefill, or the engine is not using the tensor cores at all (bad config, incompatible format).&lt;/li>
&lt;/ul>
&lt;p>&lt;strong>&lt;code>DCGM_FI_PROF_DRAM_ACTIVE&lt;/code>&lt;/strong>: % of cycles with HBM transferring data. The key metric for detecting memory saturation.&lt;/p>
&lt;ul>
&lt;li>&lt;strong>Green in decode&lt;/strong>: 60–85 %.&lt;/li>
&lt;li>&lt;strong>Amber&lt;/strong>: &amp;gt; 90 % sustained (HBM is a firm bottleneck, which explains high TPOT).&lt;/li>
&lt;li>&lt;strong>Red&lt;/strong>: &amp;gt; 95 % sustained with KV cache pool &amp;lt; 70 % (something other than the engine is asking for HBM; investigate leaks).&lt;/li>
&lt;/ul>
&lt;h3 id="family-2--memory">Family 2 — Memory&lt;/h3>
&lt;p>&lt;strong>&lt;code>DCGM_FI_DEV_FB_USED&lt;/code>&lt;/strong>: Frame Buffer (HBM) used, in MiB.&lt;/p>
&lt;ul>
&lt;li>&lt;strong>Green&lt;/strong>: 70–85 % of total.&lt;/li>
&lt;li>&lt;strong>Amber&lt;/strong>: 86–92 %.&lt;/li>
&lt;li>&lt;strong>Red&lt;/strong>: &amp;gt; 92 % (risk of OOM on the next paged-attention allocation).&lt;/li>
&lt;/ul>
&lt;p>PromQL for the cluster-wide percentage: &lt;code>100 * sum(DCGM_FI_DEV_FB_USED) / sum(DCGM_FI_DEV_FB_TOTAL)&lt;/code>.&lt;/p>
&lt;p>&lt;strong>&lt;code>DCGM_FI_DEV_FB_FREE&lt;/code>&lt;/strong>: free Frame Buffer. Complementary to the previous one; useful for absolute alerts (&lt;code>&amp;lt; 4096 MiB free&lt;/code>).&lt;/p>
&lt;p>&lt;strong>&lt;code>DCGM_FI_DEV_NVLINK_BANDWIDTH_TOTAL&lt;/code>&lt;/strong>: aggregate NVLink bandwidth in MB/s. For TP (tensor parallel) topologies that cross GPUs over NVLink, this metric reveals whether the parallelism split is saturating the bus.&lt;/p>
&lt;ul>
&lt;li>&lt;strong>Green&lt;/strong>: varies with topology. On 4×H100 SXM with NVLink 4.0, theoretical capacity is 450 GB/s per GPU. A typical TP=4 regime: 50–150 GB/s sustained.&lt;/li>
&lt;li>&lt;strong>Red&lt;/strong>: &amp;gt; 90 % of capacity sustained (check whether the model would fit with lower TP or with pipeline parallel).&lt;/li>
&lt;/ul>
&lt;h3 id="family-3--thermal-and-power">Family 3 — Thermal and power&lt;/h3>
&lt;p>&lt;strong>&lt;code>DCGM_FI_DEV_GPU_TEMP&lt;/code>&lt;/strong>: die temperature in °C.&lt;/p>
&lt;ul>
&lt;li>&lt;strong>Green&lt;/strong>: &amp;lt; 75 °C.&lt;/li>
&lt;li>&lt;strong>Amber&lt;/strong>: 75–82 °C.&lt;/li>
&lt;li>&lt;strong>Red&lt;/strong>: &amp;gt; 83 °C (close to the H100&amp;rsquo;s automatic thermal throttle; check ventilation, air flow, rack inlet temperature).&lt;/li>
&lt;/ul>
&lt;p>&lt;strong>&lt;code>DCGM_FI_DEV_POWER_USAGE&lt;/code>&lt;/strong>: draw in watts. For the H100 SXM, nominal TDP is 700 W. Useful for three things: spotting an unusually low workload (suspect idle or a stall), billing real energy cost, and firing alerts if the draw approaches the PDU limit.&lt;/p>
&lt;p>&lt;strong>&lt;code>DCGM_FI_DEV_CLOCK_THROTTLE_REASONS&lt;/code>&lt;/strong>: encoded bitmap with the active throttle reasons. This is the metric that &lt;strong>silently explains&lt;/strong> TPOT degradations.&lt;/p>
&lt;p>Relevant bits:&lt;/p>
&lt;ul>
&lt;li>&lt;code>0x0000000000000001&lt;/code>: Idle (not a problem).&lt;/li>
&lt;li>&lt;code>0x0000000000000002&lt;/code>: App clocks setting.&lt;/li>
&lt;li>&lt;code>0x0000000000000004&lt;/code>: SW Power Cap (software limit, e.g. from &lt;code>nvidia-smi -pl&lt;/code>).&lt;/li>
&lt;li>&lt;code>0x0000000000000008&lt;/code>: HW Slowdown.&lt;/li>
&lt;li>&lt;code>0x0000000000000010&lt;/code>: Sync Boost (NVIDIA Sync).&lt;/li>
&lt;li>&lt;code>0x0000000000000020&lt;/code>: SW Thermal Slowdown (software thermal limit).&lt;/li>
&lt;li>&lt;code>0x0000000000000040&lt;/code>: HW Thermal Slowdown (hardware thermal limit, an emergency).&lt;/li>
&lt;li>&lt;code>0x0000000000000080&lt;/code>: HW Power Brake Slowdown (PSU voltage drop).&lt;/li>
&lt;li>&lt;code>0x0000000000000100&lt;/code>: Display Clock Setting.&lt;/li>
&lt;/ul>
&lt;p>Any throttle other than &lt;code>Idle&lt;/code> with a sustained value &amp;gt; 0 &lt;strong>is an alert&lt;/strong>. TPOT degradation with &lt;code>DRAM_ACTIVE&lt;/code> already high and thermal throttle active is the classic &amp;ldquo;the rack is badly ventilated, it is not the engine&amp;rsquo;s fault&amp;rdquo;.&lt;/p>
&lt;h3 id="family-4--health">Family 4 — Health&lt;/h3>
&lt;p>&lt;strong>&lt;code>DCGM_FI_DEV_XID_ERRORS&lt;/code>&lt;/strong>: cumulative counter of driver XID errors. XIDs are critical event codes that NVIDIA documents exhaustively (XID 13: graphics engine exception; XID 31: GPU memory page fault; XID 43: reset channel verif error; XID 79: GPU has fallen off the bus; XID 95: uncontained ECC error; and so on). &lt;strong>Any increment is an immediate alert&lt;/strong>: many XIDs require a node reset or an RMA of the GPU.&lt;/p>
&lt;p>&lt;strong>&lt;code>DCGM_FI_DEV_ECC_DBE_VOL_TOTAL&lt;/code>&lt;/strong>: volatile double-bit ECC errors (uncorrectable). Unlike single-bit ones (which ECC corrects silently and which are counted in &lt;code>DCGM_FI_DEV_ECC_SBE_*&lt;/code>), double-bit errors &lt;strong>corrupt data&lt;/strong>. Any value &amp;gt; 0 is a critical alert: the GPU must be drained and inspected.&lt;/p>
&lt;p>&lt;strong>&lt;code>DCGM_FI_DEV_RETIRED_DBE&lt;/code>&lt;/strong>: physical HBM pages retired because of accumulated double-bit errors. NVIDIA retires faulty pages automatically to prevent future corruption. More than 4–8 retired pages on one GPU suggests silicon degradation: document it and plan a replacement in the next maintenance window.&lt;/p>
&lt;h2 id="the-five-inference-engine-metrics-vllm">The five inference engine metrics (vLLM)&lt;/h2>
&lt;p>The DCGM metrics answer &amp;ldquo;is the GPU healthy?&amp;rdquo;. The engine metrics answer &amp;ldquo;is the service meeting the SLO?&amp;rdquo;. Without them, you know the hardware works but you do not know whether the clients are happy.&lt;/p>
&lt;p>&lt;strong>&lt;code>vllm:num_requests_running&lt;/code>&lt;/strong>: requests currently in the batch. If it reaches the configured &lt;code>--max-num-seqs&lt;/code> and does not come down, the engine is saturated on concurrency (check VRAM and rebalance through the autoscaler, see &lt;a href="https://blog.lo0.es/en/posts/llm-inference-autoscaling-kubernetes-hpa-keda/">Autoscaling LLM on Kubernetes&lt;/a>).&lt;/p>
&lt;p>&lt;strong>&lt;code>vllm:num_requests_waiting&lt;/code>&lt;/strong>: requests queued, not yet in the batch. Any value &amp;gt; 0 sustained over minutes means the cluster is not scaling with the load. &lt;strong>This is the primary metric for HPA&lt;/strong>.&lt;/p>
&lt;p>&lt;strong>&lt;code>vllm:gpu_cache_usage_perc&lt;/code>&lt;/strong>: % of the KV cache pool in use.&lt;/p>
&lt;ul>
&lt;li>&lt;strong>Green&lt;/strong>: 50–80 %.&lt;/li>
&lt;li>&lt;strong>Amber&lt;/strong>: 80–92 %.&lt;/li>
&lt;li>&lt;strong>Red&lt;/strong>: &amp;gt; 92 % (risk of &lt;strong>preempt-on-OOM&lt;/strong>: vLLM will drop requests to free memory, which raises TTFT visibly).&lt;/li>
&lt;/ul>
&lt;p>&lt;strong>&lt;code>vllm:time_to_first_token_seconds&lt;/code>&lt;/strong>: histogram of TTFT per request. Consumed as &lt;code>histogram_quantile(0.95, sum by(le)(rate(vllm:time_to_first_token_seconds_bucket[5m])))&lt;/code>. Compared against the TTFT P95 SLO, it fires the primary service alert.&lt;/p>
&lt;p>&lt;strong>&lt;code>vllm:time_per_output_token_seconds&lt;/code>&lt;/strong>: histogram of TPOT. Equivalent to the previous one but for streaming smoothness. Compared against the TPOT P95 SLO, it fires the secondary alert.&lt;/p>
&lt;h2 id="the-six-alerts-that-must-page-in-production">The six alerts that must page in production&lt;/h2>
&lt;p>Any serious production cluster fires these six alerts to a channel with an on-call rotation. Without them, the SLO is met by luck, not by process.&lt;/p>
&lt;div class="highlight">&lt;pre tabindex="0" class="chroma">&lt;code class="language-yaml" data-lang="yaml">&lt;span class="line">&lt;span class="cl">&lt;span class="nt">groups&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>- &lt;span class="nt">name&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">gpu-llm-critical&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">rules&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>- &lt;span class="nt">alert&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">GpuHbmNearOom&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">expr&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="m">100&lt;/span>&lt;span class="w"> &lt;/span>*&lt;span class="w"> &lt;/span>&lt;span class="l">(DCGM_FI_DEV_FB_USED / DCGM_FI_DEV_FB_TOTAL) &amp;gt; 92&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">for&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">2m&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">labels&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>{&lt;span class="w"> &lt;/span>&lt;span class="nt">severity&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">critical }&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">annotations&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">summary&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="s2">&amp;#34;HBM on {{ $labels.gpu }} at {{ $value }}% — OOM risk&amp;#34;&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>- &lt;span class="nt">alert&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">GpuThermalOrPowerThrottle&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">expr&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">(DCGM_FI_DEV_CLOCK_THROTTLE_REASONS != 0) and ignoring(reason) (DCGM_FI_DEV_CLOCK_THROTTLE_REASONS != 1)&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">for&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">1m&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">labels&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>{&lt;span class="w"> &lt;/span>&lt;span class="nt">severity&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">warning }&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">annotations&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">summary&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="s2">&amp;#34;GPU {{ $labels.gpu }} throttling (reasons={{ $value }})&amp;#34;&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>- &lt;span class="nt">alert&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">GpuXidErrorDetected&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">expr&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">increase(DCGM_FI_DEV_XID_ERRORS[5m]) &amp;gt; 0&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">labels&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>{&lt;span class="w"> &lt;/span>&lt;span class="nt">severity&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">critical }&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">annotations&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">summary&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="s2">&amp;#34;XID error on GPU {{ $labels.gpu }} — investigate immediately&amp;#34;&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>- &lt;span class="nt">alert&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">GpuEccDoubleBit&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">expr&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">DCGM_FI_DEV_ECC_DBE_VOL_TOTAL &amp;gt; 0&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">labels&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>{&lt;span class="w"> &lt;/span>&lt;span class="nt">severity&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">critical }&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">annotations&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">summary&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="s2">&amp;#34;ECC double-bit on GPU {{ $labels.gpu }} — drain node&amp;#34;&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>- &lt;span class="nt">alert&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">VllmKvCachePoolNearFull&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">expr&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">vllm:gpu_cache_usage_perc &amp;gt; 0.95&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">for&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">3m&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">labels&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>{&lt;span class="w"> &lt;/span>&lt;span class="nt">severity&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">warning }&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">annotations&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">summary&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="s2">&amp;#34;KV cache pool &amp;gt; 95% on {{ $labels.instance }}&amp;#34;&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>- &lt;span class="nt">alert&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">VllmTtftP95OutOfSlo&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">expr&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">histogram_quantile(0.95, sum by(le, instance)(rate(vllm:time_to_first_token_seconds_bucket[5m]))) &amp;gt; 1.5&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">for&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">5m&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">labels&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>{&lt;span class="w"> &lt;/span>&lt;span class="nt">severity&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">warning }&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">annotations&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">summary&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="s2">&amp;#34;TTFT P95 over SLO ({{ $value }}s &amp;gt; 1.5s)&amp;#34;&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>These six cover 80 % of the incidents that affect the SLO. The remaining 20 % demand investigation with tracing (see &lt;a href="https://blog.lo0.es/en/posts/llm-tracing-opentelemetry-genai/">LLM tracing with OpenTelemetry GenAI&lt;/a>).&lt;/p>
&lt;h2 id="master-table-thresholds-and-queries">Master table: thresholds and queries&lt;/h2>
&lt;table>
&lt;thead>
&lt;tr>
&lt;th>Metric&lt;/th>
&lt;th>Green&lt;/th>
&lt;th>Amber&lt;/th>
&lt;th>Red&lt;/th>
&lt;th>Base query (PromQL)&lt;/th>
&lt;/tr>
&lt;/thead>
&lt;tbody>
&lt;tr>
&lt;td>SM occupancy&lt;/td>
&lt;td>0.30–0.70&lt;/td>
&lt;td>0.15–0.30&lt;/td>
&lt;td>&amp;lt; 0.10 sustained&lt;/td>
&lt;td>&lt;code>DCGM_FI_PROF_SM_OCCUPANCY&lt;/code>&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>Tensor active (decode)&lt;/td>
&lt;td>15–30 %&lt;/td>
&lt;td>&amp;lt; 10 %&lt;/td>
&lt;td>&amp;lt; 3 %&lt;/td>
&lt;td>&lt;code>DCGM_FI_PROF_PIPE_TENSOR_ACTIVE&lt;/code>&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>DRAM active&lt;/td>
&lt;td>60–85 %&lt;/td>
&lt;td>85–95 %&lt;/td>
&lt;td>&amp;gt; 95 % with low KV&lt;/td>
&lt;td>&lt;code>DCGM_FI_PROF_DRAM_ACTIVE&lt;/code>&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>FB used&lt;/td>
&lt;td>70–85 %&lt;/td>
&lt;td>86–92 %&lt;/td>
&lt;td>&amp;gt; 92 %&lt;/td>
&lt;td>&lt;code>100 * DCGM_FI_DEV_FB_USED / DCGM_FI_DEV_FB_TOTAL&lt;/code>&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>NVLink BW&lt;/td>
&lt;td>&amp;lt; 70 % cap&lt;/td>
&lt;td>70–90 % cap&lt;/td>
&lt;td>&amp;gt; 90 % cap&lt;/td>
&lt;td>&lt;code>DCGM_FI_DEV_NVLINK_BANDWIDTH_TOTAL&lt;/code>&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>GPU temp&lt;/td>
&lt;td>&amp;lt; 75 °C&lt;/td>
&lt;td>75–82 °C&lt;/td>
&lt;td>&amp;gt; 83 °C&lt;/td>
&lt;td>&lt;code>DCGM_FI_DEV_GPU_TEMP&lt;/code>&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>Power usage&lt;/td>
&lt;td>&amp;lt; 90 % TDP&lt;/td>
&lt;td>90–98 % TDP&lt;/td>
&lt;td>&amp;gt; 98 % TDP&lt;/td>
&lt;td>&lt;code>DCGM_FI_DEV_POWER_USAGE&lt;/code>&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>Throttle reasons&lt;/td>
&lt;td>0 or Idle&lt;/td>
&lt;td>App/SW&lt;/td>
&lt;td>HW Therm/Power&lt;/td>
&lt;td>&lt;code>DCGM_FI_DEV_CLOCK_THROTTLE_REASONS&lt;/code>&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>XID errors&lt;/td>
&lt;td>no change&lt;/td>
&lt;td>—&lt;/td>
&lt;td>any delta&lt;/td>
&lt;td>&lt;code>increase(DCGM_FI_DEV_XID_ERRORS[5m])&lt;/code>&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>ECC DBE&lt;/td>
&lt;td>0&lt;/td>
&lt;td>—&lt;/td>
&lt;td>&amp;gt; 0&lt;/td>
&lt;td>&lt;code>DCGM_FI_DEV_ECC_DBE_VOL_TOTAL&lt;/code>&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>Retired pages&lt;/td>
&lt;td>&amp;lt; 4&lt;/td>
&lt;td>4–8&lt;/td>
&lt;td>&amp;gt; 8&lt;/td>
&lt;td>&lt;code>DCGM_FI_DEV_RETIRED_DBE&lt;/code>&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>KV cache used&lt;/td>
&lt;td>50–80 %&lt;/td>
&lt;td>80–92 %&lt;/td>
&lt;td>&amp;gt; 92 %&lt;/td>
&lt;td>&lt;code>vllm:gpu_cache_usage_perc&lt;/code>&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>Requests waiting&lt;/td>
&lt;td>0&lt;/td>
&lt;td>1–5 sustained&lt;/td>
&lt;td>&amp;gt; 10 sustained&lt;/td>
&lt;td>&lt;code>vllm:num_requests_waiting&lt;/code>&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>TTFT P95&lt;/td>
&lt;td>&amp;lt; SLO&lt;/td>
&lt;td>80–100 % SLO&lt;/td>
&lt;td>&amp;gt; SLO&lt;/td>
&lt;td>see alert query above&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>TPOT P95&lt;/td>
&lt;td>&amp;lt; SLO&lt;/td>
&lt;td>80–100 % SLO&lt;/td>
&lt;td>&amp;gt; SLO&lt;/td>
&lt;td>&lt;code>histogram_quantile(0.95, sum by(le)(rate(vllm:time_per_output_token_seconds_bucket[5m])))&lt;/code>&lt;/td>
&lt;/tr>
&lt;/tbody>
&lt;/table>
&lt;h2 id="three-pitfalls-that-confuse-the-junior-operator">Three pitfalls that confuse the junior operator&lt;/h2>
&lt;p>&lt;strong>Pitfall 1 — &amp;ldquo;GPU-Util at 99 % = saturated&amp;rdquo;.&lt;/strong> As explained at the start, &lt;code>DCGM_FI_DEV_GPU_UTIL&lt;/code> lights up with any kernel. The right move is to look at the three &lt;code>_PROF_*&lt;/code> metrics (SM occupancy, tensor active, DRAM active) together. &lt;em>GPU util 99 % + tensor active 8 % + DRAM active 92 %&lt;/em> means &amp;ldquo;saturated by memory, not compute&amp;rdquo;; &lt;em>GPU util 99 % + tensor active 75 % + DRAM active 50 %&lt;/em> means &amp;ldquo;saturated by compute, prefill heavy&amp;rdquo;. The two situations call for different levers.&lt;/p>
&lt;p>&lt;strong>Pitfall 2 — confusing single-bit ECC (SBE) with double-bit (DBE).&lt;/strong> Single-bit errors are corrected silently and are &lt;strong>unavoidable&lt;/strong> in any HBM under load (cosmic radiation, voltage fluctuations). An SBE counter growing slowly is not an alert, it is physics. DBE is: it corrupts data. Telling the two metrics apart avoids false alarms and false negatives in equal measure.&lt;/p>
&lt;p>&lt;strong>Pitfall 3 — alerting on &lt;code>num_requests_waiting &amp;gt; 0&lt;/code> without context.&lt;/strong> An instantaneous value of 1 or 2 during a spike is normal. What matters is a &lt;strong>sustained&lt;/strong> queue: use &lt;code>for: 5m&lt;/code> with a threshold of 3–5. Without that window, the system floods the alert channel with noise.&lt;/p>
&lt;h2 id="applied-to-typical-on-premise-hardware">Applied to typical on-premise hardware&lt;/h2>
&lt;p>For a generic cluster of &lt;strong>4×H100 SXM 80 GB with intra-node NVLink&lt;/strong>:&lt;/p>
&lt;ul>
&lt;li>DCGM Exporter deployed through the NVIDIA GPU Operator, one DaemonSet per GPU node.&lt;/li>
&lt;li>Internal Prometheus with 30-day retention for high-frequency metrics, 1 year for downsampled ones (Thanos/Mimir if the volume justifies it).&lt;/li>
&lt;li>Grafana with three standard dashboards: GPU hardware (DCGM), engine (vLLM), SLO (TTFT/TPOT/RPS against written targets).&lt;/li>
&lt;li>Alertmanager with an on-call rotation and rate limiting through silences grouped by node.&lt;/li>
&lt;li>Cardinality kept under control: &lt;code>gpu&lt;/code> (local id), &lt;code>node&lt;/code>, &lt;code>pod&lt;/code>, &lt;code>model&lt;/code>. Do not add &lt;code>request_id&lt;/code> or other high-cardinality labels to metrics; that is tracing&amp;rsquo;s job.&lt;/li>
&lt;/ul>
&lt;p>Estimated volume for a 16-GPU cluster scraped every 15 s: around 2 million samples/min, around 25 GB/day of raw Prometheus. Manageable with one Prometheus per cluster plus retention; if the team scales beyond 64 GPUs, consider a Thanos sidecar or VictoriaMetrics. See &lt;a href="https://blog.lo0.es/en/posts/oss-catalogue-llmops-six-stages-tool-by-tool/">OSS LLMOps tooling catalogue&lt;/a> for equivalent alternatives.&lt;/p>
&lt;h2 id="what-we-have-not-covered-upcoming-articles">What we have not covered (upcoming articles)&lt;/h2>
&lt;ul>
&lt;li>&lt;strong>Tracing of LLM workloads&lt;/strong>: already covered in &lt;a href="https://blog.lo0.es/en/posts/llm-tracing-opentelemetry-genai/">LLM tracing with OpenTelemetry GenAI&lt;/a>.&lt;/li>
&lt;li>&lt;strong>Autoscaling&lt;/strong> based on these metrics: see &lt;a href="https://blog.lo0.es/en/posts/llm-inference-autoscaling-kubernetes-hpa-keda/">Autoscaling LLM on Kubernetes&lt;/a>.&lt;/li>
&lt;li>&lt;strong>Incident response runbooks&lt;/strong>: how each of these alerts translates into concrete action (drain, restart, RMA, scaling, rollback).&lt;/li>
&lt;li>&lt;strong>Cost accounting&lt;/strong>: using &lt;code>DCGM_FI_DEV_POWER_USAGE&lt;/code> and &lt;code>vllm:request_success_total&lt;/code> for per-tenant cost showback.&lt;/li>
&lt;li>&lt;strong>Multi-tenant fairness monitoring&lt;/strong>: when several tenants share a cluster, which metrics detect that one of them is hogging the KV cache.&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/finops-multi-tenancy-gpu-cluster-who-pays/">FinOps and multi-tenancy on the GPU cluster: who pays for what&lt;/a> — DCGM utilisation as the cluster&amp;rsquo;s FinOps governance metric.&lt;/p>
&lt;/li>
&lt;li>
&lt;p>&lt;a href="https://blog.lo0.es/en/posts/llm-tracing-opentelemetry-genai/">LLM tracing with OpenTelemetry GenAI&lt;/a> — the other half of observability.&lt;/p>
&lt;/li>
&lt;li>
&lt;p>&lt;a href="https://blog.lo0.es/en/posts/capacity-planning-on-premise-llm-inference/">Capacity planning for on-premise LLM inference&lt;/a> — what was sized and, therefore, which thresholds are defensible here.&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> — explains why &lt;code>num_requests_running&lt;/code>, &lt;code>num_requests_waiting&lt;/code> and &lt;code>gpu_cache_usage_perc&lt;/code> are the engine&amp;rsquo;s operational metrics.&lt;/p>
&lt;/li>
&lt;li>
&lt;p>&lt;a href="https://blog.lo0.es/en/posts/five-maturity-levels-platform-under-llm/">Five maturity levels&lt;/a> — LLM-aware observability lives at level 4.&lt;/p>
&lt;/li>
&lt;li>
&lt;p>&lt;a href="https://blog.lo0.es/en/posts/anatomy-on-premise-llm-inference-stack-seven-layers/">Seven layers of the on-premise LLM inference stack&lt;/a> — DCGM Exporter is a piece of the platform layer.&lt;/p>
&lt;/li>
&lt;li>
&lt;p>&lt;a href="https://blog.lo0.es/en/posts/llm-inference-autoscaling-kubernetes-hpa-keda/">Autoscaling LLM on Kubernetes&lt;/a> — uses these metrics as input.&lt;/p>
&lt;/li>
&lt;li>
&lt;p>&lt;a href="https://blog.lo0.es/en/posts/anatomy-dcgm-vllm-metrics-analogies-documented-anomalies/">Anatomy of the twelve DCGM and five vLLM metrics&lt;/a> — a deeper dive with an analogy and a documented production anomaly for each metric, with figures from public incidents (Meta Llama 3, &lt;em>Story of Two GPUs&lt;/em>, vLLM issues, Dell/Lenovo KBs).&lt;/p>
&lt;/li>
&lt;li>
&lt;p>&lt;a href="https://blog.lo0.es/en/posts/incident-response-runbooks-llm-inference-kafka-keep/">Incident response runbooks for LLM with Keep + Kafka&lt;/a> — the translation of each critical alert into concrete action (drain, reset, RMA, rollback) with workflow YAML, a WORM Kafka schema and the fit with ISO 27035, ENS, NIS2 and EU AI Act art. 73.&lt;/p>
&lt;/li>
&lt;li>
&lt;p>&lt;a href="https://blog.lo0.es/en/posts/nvlink-nvswitch-nccl-cable-every-token-crosses/">NVLink, NVSwitch and NCCL: the cable every token travels through&lt;/a> — the NVLink counters (&lt;code>nvidia-smi nvlink -e&lt;/code>, TX/RX bytes per link, CRC errors) that these DCGM metrics expose: a slow all-reduce shows up in a cable error counter before it shows up in API latency.&lt;/p>
&lt;/li>
&lt;li>
&lt;p>&lt;a href="https://blog.lo0.es/en/posts/gpu-idle-cost-not-on-any-invoice/">GPU utilisation as a FinOps lever&lt;/a> — from the DCGM metrics (GPU_UTIL vs SM_ACTIVE, MFU) to the cost of idle and the cost per token.&lt;/p>
&lt;/li>
&lt;/ul>
&lt;h2 id="references">References&lt;/h2>
&lt;ul>
&lt;li>NVIDIA — &lt;em>DCGM Exporter&lt;/em> (repo &lt;code>nvidia/dcgm-exporter&lt;/code>, documented metrics and units).&lt;/li>
&lt;li>NVIDIA — &lt;em>DCGM Field Identifiers reference&lt;/em> (complete list of &lt;code>DCGM_FI_*&lt;/code>).&lt;/li>
&lt;li>NVIDIA — &lt;em>XID Errors documentation&lt;/em> (catalogue of XID codes and remediation procedures).&lt;/li>
&lt;li>NVIDIA — &lt;em>NVIDIA GPU Operator&lt;/em> (official Helm chart).&lt;/li>
&lt;li>vLLM project — &lt;code>examples/production_monitoring/&lt;/code> (reference PromQL and Grafana dashboards).&lt;/li>
&lt;li>Prometheus — &lt;em>Histogram and summary best practices&lt;/em> (for building defensible percentile queries).&lt;/li>
&lt;li>NVIDIA — &lt;em>H100 Tensor Core GPU datasheet&lt;/em> (TDP, HBM bandwidth, NVLink capacities).&lt;/li>
&lt;/ul></description></item><item><title>Capacity planning for on-premise LLM inference: how to size GPUs from an SLO</title><link>https://blog.lo0.es/en/posts/capacity-planning-on-premise-llm-inference/</link><pubDate>Mon, 01 Jun 2026 15:00:00 +0200</pubDate><guid>https://blog.lo0.es/en/posts/capacity-planning-on-premise-llm-inference/</guid><description>&lt;blockquote>
&lt;p>This post complements the ones on &lt;a href="https://blog.lo0.es/en/posts/kv-cache-working-memory-llm-inference/">KV cache&lt;/a> (the piece that dominates the VRAM budget), &lt;a href="https://blog.lo0.es/en/posts/continuous-batching-orca-vllm-chunked-prefill-goodput/">Continuous batching&lt;/a> (what defines the effective utilisation of the compute) and &lt;a href="https://blog.lo0.es/en/posts/anatomy-on-premise-llm-inference-stack-seven-layers/">Seven layers of the stack&lt;/a> (the pieces the sizing takes for granted). Before reading this one, make sure your team has written down the SLOs it is going to chase; without that input the calculation is not defensible.&lt;/p>
&lt;/blockquote>
&lt;h2 id="tldr">TL;DR&lt;/h2>
&lt;p>LLM inference capacity planning does not answer &amp;ldquo;how many tokens per second does a GPU give&amp;rdquo; — that question has no universal answer, because throughput depends on concurrency, the prefill/decode split, context length, the inference engine and quantisation. The right question has three inputs (&lt;strong>SLO&lt;/strong>: TTFT P95, TPOT P95, sustained RPS), a hardware reference (GPU model, VRAM, HBM bandwidth, effective FLOPs) and a model (parameters, GQA/MHA/MoE architecture, weight format). The calculation resolves into two coupled budgets that cross each other. &lt;strong>VRAM budget&lt;/strong>: from the GPU&amp;rsquo;s total you subtract model weights and activations, what remains is the &lt;strong>KV cache budget&lt;/strong>, and from there you derive the &lt;strong>maximum concurrency&lt;/strong> possible at the average context you expect. &lt;strong>Time budget&lt;/strong>: the engine (vLLM, SGLang, TensorRT-LLM) has a tokens/s ceiling in decode given by HBM bandwidth and another in prefill given by useful FLOPs; from there you derive the &lt;strong>expected TPOT&lt;/strong> and, dividing prefill_tokens by prefill throughput, the &lt;strong>expected TTFT&lt;/strong>. Both budgets must meet the SLO &lt;strong>simultaneously&lt;/strong>: whichever is tighter dictates the sizing. On the Llama 70B BF16 example with tensor parallel 4 on 4×H100 SXM, a single replica saturates at ~28 concurrent requests and delivers ~3,200 tokens/s of aggregate decode with a median TPOT of 35 ms; for 200 sustained RPS at a profile of 800 prompt tokens + 250 of output, you need between 4 and 5 replicas with a 25 % cushion over the observed peak. Quantisation (FP8 → INT4) divides VRAM cost and decode time by between 1.5 and 4×, but it degrades quality measurably — it is not assumed free, it is validated with evals. The five habitual traps: confusing the mean with P95, ignoring the prefill/decode split of the real workload, sizing without head-room for retraining or rollback, forgetting that a GPU at 100 % SM util means nothing if the HBM is saturated, and not documenting the assumptions behind the calculation (a sizing with no written assumptions is a throwaway calculation).&lt;/p>
&lt;h2 id="you-are-here-deploy-with-one-foot-in-observe">You are here: DEPLOY (with one foot in OBSERVE)&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 with one foot in Observe">
&lt;style>.box{stroke:#444;stroke-width:1.4;rx:6}.active{fill:#ffb347;stroke-width:3}.semiactive{fill:#ffe1b3;stroke-width:2}.idle{fill:#f4f4f4}.lbl{font:600 12px sans-serif;fill:#222}.arr{stroke:#666;stroke-width:1.4;fill:none;marker-end:url(#cpm)}.cyc{stroke:#888;stroke-width:1.2;fill:none;stroke-dasharray:4 2;marker-end:url(#cpm)}&lt;/style>
&lt;defs>&lt;marker id="cpm" 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: capacity planning · closes DEPLOY and opens the conversation with OBSERVE&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 semiactive"/>&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;p>Capacity planning is a piece with dual residency. It lives in &lt;strong>DEPLOY&lt;/strong> because without a valid sizing you do not buy hardware or configure the inference engine. But its &lt;strong>input is real observations&lt;/strong>: the distribution of prompt and output lengths, the prefill/decode mix of the workload, the real P95s already being seen in pre-production. Without that data the calculation is a napkin sketch, defensible only until the first customer arrives who does not fit the assumed average.&lt;/p>
&lt;h2 id="the-analogy-the-hotel-with-variable-sized-rooms">The analogy: the hotel with variable-sized rooms&lt;/h2>
&lt;p>Imagine a hotel where the rooms have no fixed size: each guest pays for the square metres they need, and the building&amp;rsquo;s floor plan reorganises itself dynamically to accommodate whoever arrives. Management wants to maximise occupancy, but it has two real constraints and one quality metric.&lt;/p>
&lt;p>&lt;strong>Constraint 1 — physical space.&lt;/strong> The floor has 1,000 m² in total. If a family comes in needing 200 m², that family occupies that surface and it cannot be handed to the next guest. The largest room limits how many simultaneous guests fit.&lt;/p>
&lt;p>&lt;strong>Constraint 2 — service staff.&lt;/strong> There are 10 receptionists. Each can handle the check-in of one guest every two minutes. When 60 guests arrive within an hour, the last ones wait in a queue; the time from walking into reception to receiving their key depends on how many are ahead of them.&lt;/p>
&lt;p>&lt;strong>Quality metric — a promise about time.&lt;/strong> The brochure says &amp;ldquo;check-in in under 15 minutes&amp;rdquo;. If too many guests arrive at once, that promise breaks even if there is physical space free.&lt;/p>
&lt;p>The physical space is the GPU&amp;rsquo;s VRAM. Each room is a request with its KV cache (larger the longer the conversation). The receptionists are the compute units (Streaming Multiprocessors + Tensor Cores). The check-in is the prefill phase; the nights the guest spends afterwards are the decode steps. The 15-minute promise is the TTFT P95 SLO.&lt;/p>
&lt;p>The hotel&amp;rsquo;s capacity planning is exactly this: given the expected guest profile (how many arrive per hour, how much space they ask for on average, how many minutes of waiting they tolerate), work out how many floors and how many receptionists are needed. It is not done by estimating &amp;ldquo;rooms per hour&amp;rdquo; in the abstract; it is done by crossing the two budgets with the promise about time. The analogy holds up the calculation all the way to the end.&lt;/p>
&lt;h2 id="the-three-slo-inputs">The three SLO inputs&lt;/h2>
&lt;p>Before putting a single number on the sheet, you have to write down the three dimensions of the SLO. Without this, the calculation is aesthetics, not engineering.&lt;/p>
&lt;p>&lt;strong>TTFT P95 (Time-To-First-Token).&lt;/strong> The time from the client sending the request until it receives the first token. It is dominated by the prefill phase (processing the whole prompt in one go) plus the scheduler queue. For conversational chat, a reasonable target sits between 0.5 and 2 seconds P95. For coding assistants with large prompts (5–10 K tokens of context), between 2 and 4 s P95. Below 500 ms you enter UX territory for voice-style conversations, but that demands serious architectural compromises.&lt;/p>
&lt;p>&lt;strong>TPOT P95 (Time-Per-Output-Token).&lt;/strong> The time between consecutive tokens during decode. It dominates the &amp;ldquo;perceived fluency&amp;rdquo; of streaming. Above 80 ms/token the human reader perceives pauses; below 30 ms/token the output flows faster than it can be read. The usual industry target: 40–60 ms P95.&lt;/p>
&lt;p>&lt;strong>Sustained RPS meeting the SLO.&lt;/strong> The throughput the system must support &lt;strong>without violating&lt;/strong> TTFT or TPOT. This is DistServe&amp;rsquo;s key metric, called &lt;strong>goodput&lt;/strong> (see &lt;a href="https://blog.lo0.es/en/posts/continuous-batching-orca-vllm-chunked-prefill-goodput/">Continuous batching&lt;/a>). &amp;ldquo;200 RPS peak&amp;rdquo; is not the same as &amp;ldquo;200 RPS with TTFT P95 ≤ 1.5 s&amp;rdquo;. Without the SLO condition, the RPS number means nothing.&lt;/p>
&lt;p>These three dimensions come with a &lt;strong>workload profile&lt;/strong>: the distribution of prompt and output lengths. Medians are not enough; you need P50, P95, P99. A badly measured profile is the main reason sizings fail.&lt;/p>
&lt;h2 id="the-central-formula-two-budgets-that-cross">The central formula: two budgets that cross&lt;/h2>
&lt;p>The calculation resolves into two independent sums that are then crossed. The smaller of the two rules.&lt;/p>
&lt;h3 id="vram-budget">VRAM budget&lt;/h3>
&lt;p>For a GPU with total VRAM $V$, the space available for KV cache is:&lt;/p>
$$V_{\text{kv}} = V - V_{\text{model}} - V_{\text{activations}} - V_{\text{overhead}}$$
&lt;p>where:&lt;/p>
&lt;ul>
&lt;li>$V_{\text{model}}$ is the size of the weights: for a model of $P$ parameters in a format of $b$ bytes/parameter, $V_{\text{model}} = P \cdot b$. Llama 70B BF16 = $70 \times 10^9 \times 2 = 140$ GB. Under tensor parallel TP=4, each GPU carries $140 / 4 = 35$ GB.&lt;/li>
&lt;li>$V_{\text{activations}}$ are the intermediate buffers of the forward pass. For vLLM with a reasonable batch, between 2 and 6 GB per GPU depending on batch size and maximum length.&lt;/li>
&lt;li>$V_{\text{overhead}}$ is the CUDA context, NCCL buffers, the PagedAttention pool, reserved paged blocks. 2–4 GB typical.&lt;/li>
&lt;/ul>
&lt;p>The KV cache budget per GPU is what is left over. For an H100 SXM 80 GB with Llama 70B TP=4 BF16:&lt;/p>
$$V_{\text{kv}} = 80 - 35 - 4 - 3 = 38 \text{ GB per GPU} = 152 \text{ GB aggregated over TP=4}$$
&lt;p>The KV cache cost per token for a model with $L$ layers, $H_{\text{kv}}$ KV heads (GQA), per-head dimension $d_h$, in a format of $b$ bytes:&lt;/p>
$$\text{kv per token} = 2 \cdot L \cdot H_{\text{kv}} \cdot d_h \cdot b$$
&lt;p>The factor of 2 is because both K and V are stored. For Llama 70B (L=80, $H_{\text{kv}}$=8 with GQA, $d_h$=128, BF16 = 2 bytes):&lt;/p>
$$\text{kv per token} = 2 \cdot 80 \cdot 8 \cdot 128 \cdot 2 = 327,680 \text{ bytes} = 320 \text{ KB/token}$$
&lt;p>And the maximum concurrency at average context $C$:&lt;/p>
$$N_{\text{max}} = \frac{V_{\text{kv}}}{C \cdot \text{kv per token}}$$
&lt;p>With an aggregated $V_{\text{kv}}$ of 152 GB and an average context of 1,500 tokens (800 prompt + 700 generated at the worst moment of the conversation):&lt;/p>
$$N_{\text{max}} = \frac{152 \times 10^9}{1,500 \cdot 320 \times 10^3} \approx 316 \text{ concurrent requests}$$
&lt;p>This is the &lt;strong>physical ceiling&lt;/strong> of concurrency for that replica. It is not what you are going to use; it is what you &lt;strong>cannot exceed&lt;/strong> without OOM. The operational number sits well below it (head-room for spikes).&lt;/p>
&lt;h3 id="time-budget">Time budget&lt;/h3>
&lt;p>Two sub-calculations come in here: decode (memory-bound) and prefill (compute-bound).&lt;/p>
&lt;p>&lt;strong>Decode TPOT.&lt;/strong> For every token generated, the model weights (the ones relevant to that request) have to be walked through and the accumulated KV cache has to be read. The bottleneck is HBM bandwidth. For a GPU with bandwidth $B$ GB/s and a model of $V_{\text{model per gpu}}$ GB of weights:&lt;/p>
$$\text{tpot}_{\text{theoretical}} \approx \frac{V_{\text{model per gpu}}}{B}$$
&lt;p>For an H100 SXM with HBM3 at 3.35 TB/s and Llama 70B TP=4 BF16 (35 GB/GPU):&lt;/p>
$$\text{tpot}_{\text{theoretical}} \approx \frac{35}{3,350} \approx 10.4 \text{ ms/token}$$
&lt;p>This is the &lt;strong>best theoretical case&lt;/strong> with batch=1 and 100 % HBM efficiency. In practice vLLM on H100 with Llama 70B TP=4 reaches 12–18 ms/token at low batch and 30–45 ms/token at high batch (at concurrency 32, tokens compete for the shared HBM). The defensible operational number: &lt;strong>35 ms/token&lt;/strong> at concurrency 24–32.&lt;/p>
&lt;p>&lt;strong>Prefill throughput.&lt;/strong> Prefill processes N prompt tokens in a single forward pass. It is compute-bound: the bottleneck is FLOPs. For an H100 SXM with 989 sustained BF16 TFLOPs and Llama 70B (each forward pass costs roughly $2 \cdot P \cdot N$ FLOPs per sequence of length N):&lt;/p>
$$\text{prefill tps} = \frac{4 \cdot \text{TFLOPs} \cdot \eta}{2 \cdot P} = \frac{4 \cdot 989 \times 10^{12} \cdot 0.5}{2 \cdot 70 \times 10^9} \approx 14,000 \text{ tokens/s}$$
&lt;p>(the factor of 4 is the GPUs in TP, $\eta$ is real efficiency between 0.4 and 0.6 on H100). An 800-token prompt takes this long in prefill:&lt;/p>
$$\text{prefill time} = \frac{800}{14,000} \approx 57 \text{ ms}$$
&lt;p>Adding a typical queue of 100–300 ms at high concurrency, &lt;strong>TTFT P95 ≈ 350–500 ms&lt;/strong> for that profile. Well below the 1.5 s target; there is margin.&lt;/p>
&lt;h3 id="the-crossing">The crossing&lt;/h3>
&lt;p>The real operational concurrency $N_{\text{op}}$ is the minimum of &lt;strong>the VRAM ceiling&lt;/strong>, the concurrency at which &lt;strong>TPOT starts to degrade&lt;/strong> past the SLO, and the concurrency at which &lt;strong>TTFT starts to degrade&lt;/strong> past the SLO (prefill queue). For the example:&lt;/p>
&lt;ul>
&lt;li>VRAM ceiling: 316.&lt;/li>
&lt;li>TPOT degrades to 80 ms (SLO) at around concurrency ~80–100 (measured empirically with a benchmark, not a closed formula).&lt;/li>
&lt;li>TTFT degrades to 1.5 s at around concurrency ~40–60 because of the prefill queue.&lt;/li>
&lt;/ul>
&lt;p>The replica&amp;rsquo;s operational concurrency is ~50. Applying 25 % head-room for spikes and rebalancing, &lt;strong>target concurrency per replica ≈ 35–40&lt;/strong>.&lt;/p>
&lt;h2 id="step-by-step-worksheet-llama-70b-bf16-on-4h100-sxm">Step-by-step worksheet: Llama 70B BF16 on 4×H100 SXM&lt;/h2>
&lt;p>The exercise&amp;rsquo;s input:&lt;/p>
&lt;ul>
&lt;li>&lt;strong>SLO&lt;/strong>: TTFT P95 ≤ 1.5 s; TPOT P95 ≤ 60 ms; &lt;strong>200 sustained RPS&lt;/strong>.&lt;/li>
&lt;li>&lt;strong>Workload&lt;/strong>: prompt P50=600, P95=1,200, P99=2,500; output P50=180, P95=500, P99=900. Average prompt 800, output 250.&lt;/li>
&lt;li>&lt;strong>Generic hardware&lt;/strong>: 4×H100 SXM 80 GB with NVLink, vLLM v1 engine, tensor parallel 4, BF16.&lt;/li>
&lt;/ul>
&lt;p>&lt;strong>Step 1 — VRAM per GPU.&lt;/strong> Weights 35 GB, activations 4 GB, overhead 3 GB → KV budget 38 GB/GPU = 152 GB aggregated. KV/token for Llama 70B GQA = 320 KB. Ceiling of live tokens in cache: $152 \times 10^9 / 320 \times 10^3 \approx 475,000$ tokens. At the operational average context (800 prompt + 200 already generated = 1,000 live tokens per request), concurrency ceiling $\approx 475$.&lt;/p>
&lt;p>&lt;strong>Step 2 — average duration of a request.&lt;/strong> Prefill 800 tokens / 14,000 tps = 57 ms. Decode 250 tokens × 35 ms/token = 8,750 ms. Total $\approx 8.8$ s per request.&lt;/p>
&lt;p>&lt;strong>Step 3 — replica throughput.&lt;/strong> If the replica sustains operational concurrency 40 and each request lasts 8.8 s, the replica delivers roughly $40 / 8.8 \approx 4.5$ requests/s in steady state.&lt;/p>
&lt;p>&lt;strong>Step 4 — number of replicas.&lt;/strong> For a 200 RPS target: $200 / 4.5 \approx 45$ replicas. That is 45 × 4 = 180 GPUs. Too much: this sizing does not work because the cost per request is high.&lt;/p>
&lt;p>&lt;strong>Step 5 — review the levers.&lt;/strong> Before buying more hardware, there are three levers to explore, in this order:&lt;/p>
&lt;ol>
&lt;li>&lt;strong>Quantisation.&lt;/strong> Dropping to FP8 reduces weights to 17.5 GB/GPU (leaving more VRAM for KV cache → more concurrency), roughly doubles decode tokens/s (HBM saturated by half), and degrades MMLU quality typically by 0.5–1.5 points on models like Llama 70B. Rewriting the calculation in FP8: TPOT drops to ~18 ms, total time per request to 4.7 s, RPS per replica rises to ~8.5, &lt;strong>replicas needed ≈ 24, equivalent to 96 GPUs&lt;/strong>.&lt;/li>
&lt;li>&lt;strong>Speculative decoding.&lt;/strong> With a small drafter and 60–70 % acceptance, effective TPOT falls 30–40 %. RPS per replica rises to ~12, &lt;strong>replicas ≈ 17 = 68 GPUs&lt;/strong>.&lt;/li>
&lt;li>&lt;strong>Disaggregated serving.&lt;/strong> Separating prefill workers and decode workers lets each be scaled to the real mix of the workload (see &lt;a href="https://blog.lo0.es/en/posts/disaggregated-serving-prefill-decode-specialised-pods/">Disaggregated serving&lt;/a>). It usually shaves off another 20–40 % under asymmetric workloads.&lt;/li>
&lt;/ol>
&lt;p>&lt;strong>Step 6 — recommended sizing.&lt;/strong> For the example, with FP8 + speculative decoding and 25 % head-room: &lt;strong>20 vLLM replicas at TP=4 over 80 H100 SXM&lt;/strong>. If the team does not want to depend on aggressive quantisation (pure BF16 for maximum fidelity), the calculation rises to &lt;strong>30 replicas = 120 GPUs&lt;/strong> and forces a renegotiation of the SLO or the budget.&lt;/p>
&lt;p>&lt;strong>Step 7 — write down the assumptions.&lt;/strong> This is the part no valid sizing skips. In the team&amp;rsquo;s repo, next to the calculation:&lt;/p>
&lt;div class="highlight">&lt;pre tabindex="0" class="chroma">&lt;code class="language-yaml" data-lang="yaml">&lt;span class="line">&lt;span class="cl">&lt;span class="c"># sizing/llama70b-prod.yaml&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w">&lt;/span>&lt;span class="nt">fecha&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="ld">2026-06-01&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w">&lt;/span>&lt;span class="nt">slo&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">ttft_p95_ms&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="m">1500&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">tpot_p95_ms&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="m">60&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">rps_target&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="m">200&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w">&lt;/span>&lt;span class="nt">workload&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">prompt_tokens_p50&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="m">600&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">prompt_tokens_p95&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="m">1200&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">output_tokens_p50&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="m">180&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">output_tokens_p95&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="m">500&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">asunto&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">chat productivo con RAG ligero&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w">&lt;/span>&lt;span class="nt">modelo&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">arquitectura&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">llama-70b-instruct&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">formato_pesos&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">fp8&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">motor&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">vllm-v1&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w">&lt;/span>&lt;span class="nt">hardware&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">gpu&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">H100-SXM-80GB&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">topologia&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">TP=4 con NVLink intra-nodo&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">red_inter_replica&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="m">25&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">GbE&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w">&lt;/span>&lt;span class="nt">optimizaciones&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>- &lt;span class="l">paged_attention&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>- &lt;span class="l">chunked_prefill&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>- &lt;span class="l">speculative_decoding (drafter llama-1.1b, aceptación esperada 65%)&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w">&lt;/span>&lt;span class="nt">asunciones_criticas&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>- &lt;span class="nt">utilizacion_hbm_eficiente&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="m">0.55&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>- &lt;span class="nt">head_room_pico_sobre_p95&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="m">0.25&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>- &lt;span class="nt">aceptacion_speculative_min&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="m">0.55&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w">&lt;/span>&lt;span class="nt">plan_validacion&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>- &lt;span class="l">benchmark vllm bench serve antes de procurement&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>- &lt;span class="l">canary 10% durante 7 días post-deploy&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>Without this YAML, the calculation is not reproducible a month later.&lt;/p>
&lt;h2 id="moe-case-mixtral-822b-141-b-total-39-b-active">MoE case: Mixtral 8×22B (~141 B total, 39 B active)&lt;/h2>
&lt;p>MoEs change the calculation along one key dimension: the total weights are large but the active weights per token are small. For Mixtral 8×22B with top-2 routing:&lt;/p>
&lt;ul>
&lt;li>Weight VRAM: $141 \times 2 = 282$ GB BF16. With TP=4 → 70 GB/GPU. It does not fit in an H100 80 GB alongside KV cache + activations. You need TP=8 (~35 GB/GPU) or FP8 with TP=4 (~35 GB/GPU).&lt;/li>
&lt;li>Decode TPOT: dominated by the weights read per token, which are $\sim 39 / 8 \cdot 2 \approx 9.75$ GB/GPU with TP=4 (one top-2 expert per token, divided across 4 GPUs). On an H100 with HBM at 3.35 TB/s, &lt;strong>theoretical TPOT ≈ 3 ms/token&lt;/strong>. In practice, 10–20 ms at reasonable concurrency.&lt;/li>
&lt;li>Prefill: similar to the dense model of the active weights, ~39 B FLOPs/token.&lt;/li>
&lt;/ul>
&lt;p>MoE sizing usually delivers more RPS per GPU than an equivalent dense model: the low cost per token compensates for the extra VRAM. See &lt;a href="https://blog.lo0.es/en/posts/moe-inference-fundamentals-expert-parallel-deepseek-economics/">MoE inference&lt;/a> for the detail of routing and why a high batch is decisive so that each expert sees enough tokens.&lt;/p>
&lt;h2 id="sensitivity-table-context-and-quantisation">Sensitivity table: context and quantisation&lt;/h2>
&lt;p>For Llama 70B on 4×H100 SXM (TP=4), operational concurrency per replica with an SLO of TTFT 1.5 s / TPOT 60 ms:&lt;/p>
&lt;table>
&lt;thead>
&lt;tr>
&lt;th>Average context&lt;/th>
&lt;th>BF16&lt;/th>
&lt;th>FP8&lt;/th>
&lt;th>INT4 (AWQ)&lt;/th>
&lt;/tr>
&lt;/thead>
&lt;tbody>
&lt;tr>
&lt;td>500 tokens&lt;/td>
&lt;td>55&lt;/td>
&lt;td>110&lt;/td>
&lt;td>180&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>1,000 tokens&lt;/td>
&lt;td>40&lt;/td>
&lt;td>80&lt;/td>
&lt;td>130&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>2,000 tokens&lt;/td>
&lt;td>24&lt;/td>
&lt;td>50&lt;/td>
&lt;td>85&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>4,000 tokens&lt;/td>
&lt;td>12&lt;/td>
&lt;td>26&lt;/td>
&lt;td>48&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>8,000 tokens&lt;/td>
&lt;td>6&lt;/td>
&lt;td>13&lt;/td>
&lt;td>25&lt;/td>
&lt;/tr>
&lt;/tbody>
&lt;/table>
&lt;p>Approximate numbers from public vLLM benchmarks as of June 2026, with ±20 % variation depending on engine version and the headroom adopted. To validate on your own hardware: &lt;code>vllm bench serve&lt;/code> with your profile of real prompts.&lt;/p>
&lt;h2 id="the-five-habitual-traps">The five habitual traps&lt;/h2>
&lt;p>&lt;strong>Trap 1 — confusing the mean with P95.&lt;/strong> Average throughput over an hour may be 50 RPS while the 5-minute peak reaches 180 RPS. Sizing against the mean guarantees breaking the SLO at every peak. Rule: size against the hourly P95, with 20–30 % head-room over P95.&lt;/p>
&lt;p>&lt;strong>Trap 2 — not measuring the real prefill/decode split.&lt;/strong> A &amp;ldquo;RAG with short answers&amp;rdquo; workload spends 70–80 % of GPU time in prefill; a &amp;ldquo;writing assistant generating essays&amp;rdquo; spends 80 % in decode. The useful optimisations (chunked prefill vs speculative decoding) change radically. Without measuring it, you buy badly balanced hardware.&lt;/p>
&lt;p>&lt;strong>Trap 3 — sizing with no head-room for retraining or rollback.&lt;/strong> The production cluster is not just the inference engine: there is a re-embedding batch when the embeddings model changes, continuous canary evals (see &lt;a href="https://blog.lo0.es/en/posts/canary-blue-green-shadow-llm-models/">Canary, blue-green and shadow&lt;/a>), light fine-tuning, hot stand-by for rollback. Reserve 15–25 % of capacity for those non-negotiable workloads.&lt;/p>
&lt;p>&lt;strong>Trap 4 — &amp;ldquo;GPU at 100 % SM utilization&amp;rdquo; as a target.&lt;/strong> SM occupancy of 95 % with saturated HBM produces the same throughput as SM at 60 % with saturated HBM. The bottleneck in decode is the HBM. Optimising for &amp;ldquo;GPU usage 100 %&amp;rdquo; without looking at HBM utilisation and arithmetic intensity makes you spend more on GPU without gaining throughput. See &lt;a href="https://blog.lo0.es/en/posts/gpu-observability-llm-inference-twelve-dcgm-vllm-metrics/">GPU observability for LLM inference&lt;/a> for which metrics to actually watch.&lt;/p>
&lt;p>&lt;strong>Trap 5 — not documenting the assumptions.&lt;/strong> A sizing with no reproducible YAML (workload, model, engine, head-room, critical assumptions) leaves the team with no way of knowing what changed when the cluster stops meeting the SLO six months later. Documenting is cheap; losing a quarter to debugging is not.&lt;/p>
&lt;h2 id="applied-to-typical-on-premise-hardware">Applied to typical on-premise hardware&lt;/h2>
&lt;p>For a generic cluster of &lt;strong>4×H100 SXM 80 GB with intra-node NVLink and 25 GbE between nodes&lt;/strong>, the recurring configurations in May 2026 are:&lt;/p>
&lt;table>
&lt;thead>
&lt;tr>
&lt;th>Model&lt;/th>
&lt;th>Format&lt;/th>
&lt;th>TP&lt;/th>
&lt;th>Replicas that fit&lt;/th>
&lt;th>Typical RPS per node (ctx 1K)&lt;/th>
&lt;/tr>
&lt;/thead>
&lt;tbody>
&lt;tr>
&lt;td>Llama 8B&lt;/td>
&lt;td>BF16&lt;/td>
&lt;td>1&lt;/td>
&lt;td>4 (one per GPU)&lt;/td>
&lt;td>240–320&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>Llama 8B&lt;/td>
&lt;td>FP8&lt;/td>
&lt;td>1&lt;/td>
&lt;td>4&lt;/td>
&lt;td>450–600&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>Llama 70B&lt;/td>
&lt;td>BF16&lt;/td>
&lt;td>4&lt;/td>
&lt;td>1&lt;/td>
&lt;td>30–45&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>Llama 70B&lt;/td>
&lt;td>FP8&lt;/td>
&lt;td>4&lt;/td>
&lt;td>1&lt;/td>
&lt;td>60–90&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>Llama 70B&lt;/td>
&lt;td>INT4 AWQ&lt;/td>
&lt;td>2&lt;/td>
&lt;td>2&lt;/td>
&lt;td>90–130&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>Mixtral 8×22B&lt;/td>
&lt;td>FP8&lt;/td>
&lt;td>4&lt;/td>
&lt;td>1&lt;/td>
&lt;td>90–140&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>Qwen 72B&lt;/td>
&lt;td>BF16&lt;/td>
&lt;td>4&lt;/td>
&lt;td>1&lt;/td>
&lt;td>28–42&lt;/td>
&lt;/tr>
&lt;/tbody>
&lt;/table>
&lt;p>These numbers are &lt;strong>orders of magnitude to start the conversation&lt;/strong>, not commitments. The definitive sizing is validated with &lt;code>vllm bench serve&lt;/code> or &lt;code>genai-perf&lt;/code> (NVIDIA) using the customer&amp;rsquo;s real prompt/output profile. The prefill/decode asymmetry of each case&amp;rsquo;s workload can move these numbers 30–50 % up or down.&lt;/p>
&lt;p>For clusters of 8×H100 SXM (typical of DGX servers or equivalent replicas), the options open up to TP=8 for 405B-class models or multi-replica TP=2 for 70B models with greater density. The metric that decides is always the same: tokens meeting SLO per kW and per euro of amortised hardware.&lt;/p>
&lt;h2 id="how-the-sizing-is-validated-before-buying">How the sizing is validated before buying&lt;/h2>
&lt;p>The spreadsheet sizing is the first half. The second is the validation benchmark.&lt;/p>
&lt;p>&lt;strong>Stage 1 — back-of-the-envelope sizing.&lt;/strong> This post&amp;rsquo;s formulas applied to the SLO and the expected workload. Output: an approximate number of replicas and a topology.&lt;/p>
&lt;p>&lt;strong>Stage 2 — synthetic micro-benchmark.&lt;/strong> On a borrowed GPU or one rented by the day, bring up the engine with the chosen model and run &lt;code>vllm bench serve&lt;/code> with prompts of representative lengths. Validate TPOT, prefill TPS and the concurrency ceiling. Calibrate the HBM efficiency factor ($\eta$) used in the formulas.&lt;/p>
&lt;p>&lt;strong>Stage 3 — load test with realistic traffic.&lt;/strong> Generate traffic following the real distribution of the customer&amp;rsquo;s workload (not Poisson, not constant: the real trace). Measure P50/P95/P99 of TTFT, TPOT, throughput. Confirm the head-room.&lt;/p>
&lt;p>&lt;strong>Stage 4 — canary in production.&lt;/strong> With the cluster sized, route 5–10 % of real traffic for 7–14 days before closing the procurement of additional hardware. See &lt;a href="https://blog.lo0.es/en/posts/canary-blue-green-shadow-llm-models/">Canary, blue-green and shadow&lt;/a> for the mechanics.&lt;/p>
&lt;p>Jumping from Stage 1 to full procurement is the most frequent cause of a cluster that is 40 % oversized and 60 % undersized at the same time, in different regions of the workload. Four weeks of validation done well save four months of refactoring.&lt;/p>
&lt;h2 id="what-we-have-not-covered-upcoming-articles">What we have not covered (upcoming articles)&lt;/h2>
&lt;ul>
&lt;li>&lt;strong>The observability metrics&lt;/strong> that close the sizing loop in production, see &lt;a href="https://blog.lo0.es/en/posts/gpu-observability-llm-inference-twelve-dcgm-vllm-metrics/">GPU observability for LLM inference&lt;/a>.&lt;/li>
&lt;li>Autoscaling that adjusts replicas to the real traffic curve, see &lt;a href="https://blog.lo0.es/en/posts/llm-inference-autoscaling-kubernetes-hpa-keda/">Autoscaling LLM on Kubernetes&lt;/a>.&lt;/li>
&lt;li>&lt;strong>Detailed cost accounting&lt;/strong> per tenant (showback / chargeback) over the sized hardware.&lt;/li>
&lt;li>&lt;strong>Sizing for continuous fine-tuning&lt;/strong> (PEFT and light training) that shares a cluster with inference.&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/finops-multi-tenancy-gpu-cluster-who-pays/">FinOps and multi-tenancy of the GPU cluster: who pays for what&lt;/a> — translating the sizing into €/token and attributing cost per team.&lt;/p>
&lt;/li>
&lt;li>
&lt;p>&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> — the component that dominates the VRAM budget.&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> — what defines the effective utilisation of the compute and the goodput metric.&lt;/p>
&lt;/li>
&lt;li>
&lt;p>&lt;a href="https://blog.lo0.es/en/posts/disaggregated-serving-prefill-decode-specialised-pods/">Disaggregated prefill/decode serving&lt;/a> — an advanced lever for asymmetric workloads.&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&lt;/a> — how the sums change with MoE models.&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 inference&lt;/a> — what each format costs and what it saves.&lt;/p>
&lt;/li>
&lt;li>
&lt;p>&lt;a href="https://blog.lo0.es/en/posts/anatomy-on-premise-llm-inference-stack-seven-layers/">Seven layers of the on-premise LLM inference stack&lt;/a> — the pieces the sizing takes for granted.&lt;/p>
&lt;/li>
&lt;li>
&lt;p>&lt;a href="https://blog.lo0.es/en/posts/mixed-nvidia-intel-environments-llm-inference/">Mixed NVIDIA + Intel environments for LLM inference&lt;/a> — the sizing closes better once heterogeneity is accepted: embeddings and reranker on Intel Xeon AMX free up H100s for the large LLM, without buying more GPU.&lt;/p>
&lt;/li>
&lt;li>
&lt;p>&lt;a href="https://blog.lo0.es/en/posts/numa-hugepages-cpu-isolation-gpu-waits-kernel/">NUMA, hugepages and CPU isolation&lt;/a> and &lt;a href="https://blog.lo0.es/en/posts/cpu-memory-topology-manager-rke2/">RKE2 resource managers&lt;/a> — why the sizing comes to be reasoned per NUMA node rather than per node: you have to discount the reserved housekeeping cores and check that the pod fits on a single NUMA &amp;ldquo;table&amp;rdquo;.&lt;/p>
&lt;/li>
&lt;li>
&lt;p>&lt;a href="https://blog.lo0.es/en/posts/full-tco-on-premise-gpu-cluster-capex-gpu-hour-break-even/">TCO of the on-premise GPU cluster: amortisation, energy and infrastructure&lt;/a> — the €/GPU-hour that closes the sizing&amp;rsquo;s economic model: once you know how many GPUs you need, here is the real cost of owning them.&lt;/p>
&lt;/li>
&lt;li>
&lt;p>&lt;a href="https://blog.lo0.es/en/posts/slo-to-number-of-gpus-sizing-justifying-inference-hardware/">Sizing and justifying the investment in GPU&lt;/a> — the full roadmap for turning this sizing into an investment proposal defensible before management: ROI, break-even and sensitivity analysis.&lt;/p>
&lt;/li>
&lt;li>
&lt;p>&lt;a href="https://blog.lo0.es/en/posts/sizing-agents-decode-bottleneck/">Sizing for agents&lt;/a> — the same method with the inputs changed, when the caller is an agent and not a person.&lt;/p>
&lt;/li>
&lt;/ul>
&lt;h2 id="references">References&lt;/h2>
&lt;ul>
&lt;li>Kwon et al. — &lt;em>vLLM: Easy, Fast, and Cheap LLM Serving with PagedAttention&lt;/em> (SOSP 2023).&lt;/li>
&lt;li>Zhong et al. — &lt;em>DistServe: Disaggregating Prefill and Decoding for Goodput-optimized LLM Serving&lt;/em> (OSDI 2024).&lt;/li>
&lt;li>Agrawal et al. — &lt;em>Taming Throughput-Latency Tradeoff in LLM Inference with Sarathi-Serve&lt;/em> (OSDI 2024).&lt;/li>
&lt;li>NVIDIA — &lt;em>H100 Tensor Core GPU Architecture Whitepaper&lt;/em> (HBM3 memory, bandwidth, sustained FLOPs).&lt;/li>
&lt;li>vLLM project — &lt;code>vllm bench serve&lt;/code> reference (benchmarking CLI included in the repo).&lt;/li>
&lt;li>NVIDIA — &lt;code>genai-perf&lt;/code> (official tool for benchmarking LLM services).&lt;/li>
&lt;/ul></description></item><item><title>LLM Guard: the sworn translator with a notebook of equivalences — anatomy, scanners and its integration with Langfuse, vLLM and LiteLLM</title><link>https://blog.lo0.es/en/posts/llm-guard-anatomy-scanners-langfuse-vllm-litellm/</link><pubDate>Mon, 01 Jun 2026 05:00:00 +0200</pubDate><guid>https://blog.lo0.es/en/posts/llm-guard-anatomy-scanners-langfuse-vllm-litellm/</guid><description>&lt;blockquote>
&lt;p>This post is a &lt;strong>deep dive into a single piece&lt;/strong> inside the layer covered in the &lt;a href="https://blog.lo0.es/en/posts/llm-guardrails-safety-four-lines-of-defence/">post on guardrails and LLM safety&lt;/a>. That one maps the four lines of defence (input, retrieval, tool, output) and the 2026 OSS catalogue from a bird&amp;rsquo;s-eye view; this one gets down to ground level on &lt;strong>LLM Guard&lt;/strong>, because its Anonymize/Deanonymize pattern, its composable scanner model and its four deployment modes deserve their own treatment. The analogies built up there (HACCP kitchen, four CCPs) still hold: this post zooms in on the tool that occupies the PII belt and the individual scanners within that architecture.&lt;/p>
&lt;/blockquote>
&lt;h2 id="tldr">TL;DR&lt;/h2>
&lt;p>LLM Guard is the OSS tool (MIT, Protect AI) that materialises the LLM guardrail layer with a model radically different from that of NeMo Guardrails and Guardrails AI: instead of a declarative DSL (Colang) or a validator framework with external LLM-as-judge calls, it offers a &lt;strong>catalogue of specialised compact detectors&lt;/strong>, 15 input scanners and 21 output scanners, composable as a Python pipeline, with one distinctive mechanism: the &lt;strong>Anonymize → LLM → Deanonymize with Vault&lt;/strong> pattern. The Vault is a centralised store of the mapping between real entities (&lt;code>John Doe&lt;/code>, &lt;code>12345678X&lt;/code>) and placeholders (&lt;code>[REDACTED_PERSON_1]&lt;/code>, &lt;code>[REDACTED_DNI_1]&lt;/code>); on input, entities are redacted and the mapping is saved; the LLM never sees real personal data; on output, the Deanonymize scanner restores the originals before returning the answer to the user. This post takes apart: the internal anatomy (Vault + scanners + orchestrator with &lt;code>fail_fast&lt;/code> and TTL cache), the four deployment patterns with their mathematics (in-process library, FastAPI API, OTel sidecar over vLLM, AI Gateway plugin — LiteLLM, Envoy AI Gateway, Kong AI Gateway), the integration diagrams with Langfuse (via LLM Guard&amp;rsquo;s OTel HTTP exporter + &lt;code>langfuse.score()&lt;/code> from the AI Gateway), the mathematics with the project&amp;rsquo;s benchmarks (Anonymize at 177 ms CPU → 128 ms ONNX-CPU → 125 ms GPU FP16 → 38 ms GPU+ONNX, scaling ×4.6 when you combine ONNX + GPU), the ONNX pattern as the default acceleration without a dedicated GPU, the comparison with NeMo Guardrails (declarative Colang DSL oriented to conversational flow) and Guardrails AI (JSON contract-style validators with external judges), the application to on-premise hardware (which scanners hold up on CPU, which need a shared GPU) and the seven operational pitfalls specific to the tool.&lt;/p>
&lt;h2 id="the-analogy-the-sworn-translator-with-a-notebook-of-equivalences">The analogy: the sworn translator with a notebook of equivalences&lt;/h2>
&lt;div class="diagram" style="max-width:820px;margin:1.5rem auto;">
&lt;svg viewBox="0 0 820 380" xmlns="http://www.w3.org/2000/svg" role="img" aria-label="LLM Guard as a sworn translator with a notebook of equivalences">
&lt;style>
.t-user{fill:#7aafff;stroke:#444;stroke-width:1.4;rx:8}
.t-trad{fill:#ffd76b;stroke:#444;stroke-width:1.4;rx:8}
.t-model{fill:#ff8a4c;stroke:#444;stroke-width:1.4;rx:8}
.t-vault{fill:#c8b8ff;stroke:#444;stroke-width:1.4;rx:8}
.tl{font:600 13px sans-serif;fill:#222}
.ts{font:400 11px sans-serif;fill:#555}
.tn{font:italic 11px sans-serif;fill:#555}
.tar{stroke:#666;stroke-width:1.6;fill:none;marker-end:url(#mt1)}
.tcb{stroke:#7a5;stroke-width:1.4;fill:none;stroke-dasharray:5 3;marker-end:url(#mt2)}
&lt;/style>
&lt;defs>
&lt;marker id="mt1" 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;marker id="mt2" 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="#7a5"/>&lt;/marker>
&lt;/defs>
&lt;rect x="20" y="40" width="120" height="60" class="t-user"/>
&lt;text x="80" y="64" text-anchor="middle" class="tl">Customer&lt;/text>
&lt;text x="80" y="82" text-anchor="middle" class="ts">"My ID is 12345678X,&lt;/text>
&lt;text x="80" y="96" text-anchor="middle" class="ts">is VAT due?"&lt;/text>
&lt;rect x="180" y="40" width="160" height="60" class="t-trad"/>
&lt;text x="260" y="64" text-anchor="middle" class="tl">Translator (Anonymize)&lt;/text>
&lt;text x="260" y="82" text-anchor="middle" class="ts">redacts sensitive entities&lt;/text>
&lt;text x="260" y="96" text-anchor="middle" class="ts">+ writes them in the notebook&lt;/text>
&lt;rect x="380" y="40" width="160" height="60" class="t-model"/>
&lt;text x="460" y="64" text-anchor="middle" class="tl">LLM&lt;/text>
&lt;text x="460" y="82" text-anchor="middle" class="ts">receives sanitised text:&lt;/text>
&lt;text x="460" y="96" text-anchor="middle" class="ts">"My ID is [DNI_1], is VAT due?"&lt;/text>
&lt;rect x="580" y="40" width="160" height="60" class="t-trad"/>
&lt;text x="660" y="64" text-anchor="middle" class="tl">Translator (Deanonymize)&lt;/text>
&lt;text x="660" y="82" text-anchor="middle" class="ts">restores the originals&lt;/text>
&lt;text x="660" y="96" text-anchor="middle" class="ts">from the notebook&lt;/text>
&lt;rect x="680" y="40" width="120" height="60" class="t-user" transform="translate(-20 130)"/>
&lt;text x="720" y="194" text-anchor="middle" class="tl" transform="translate(-20 0)">Customer receives&lt;/text>
&lt;text x="720" y="212" text-anchor="middle" class="ts" transform="translate(-20 0)">an answer with&lt;/text>
&lt;text x="720" y="226" text-anchor="middle" class="ts" transform="translate(-20 0)">"12345678X" restored&lt;/text>
&lt;path class="tar" d="M140,70 L180,70"/>
&lt;path class="tar" d="M340,70 L380,70"/>
&lt;path class="tar" d="M540,70 L580,70"/>
&lt;path class="tar" d="M660,100 Q660,150 700,170"/>
&lt;rect x="280" y="220" width="220" height="80" class="t-vault"/>
&lt;text x="390" y="244" text-anchor="middle" class="tl">Vault (shared notebook)&lt;/text>
&lt;text x="390" y="262" text-anchor="middle" class="ts">[PERSON_1] = "Marta García"&lt;/text>
&lt;text x="390" y="276" text-anchor="middle" class="ts">[DNI_1] = "12345678X"&lt;/text>
&lt;text x="390" y="290" text-anchor="middle" class="ts">[IBAN_1] = "ES91 2100 0418..."&lt;/text>
&lt;path class="tcb" d="M260,100 L320,218"/>
&lt;path class="tcb" d="M460,218 L660,100"/>
&lt;text x="200" y="160" class="tn">stores mapping&lt;/text>
&lt;text x="500" y="160" class="tn">looks up to restore&lt;/text>
&lt;text x="410" y="340" text-anchor="middle" class="tn">The LLM never sees the original PII. The notebook (Vault) is the only point that knows the equivalence.&lt;/text>
&lt;/svg>
&lt;/div>
&lt;p>A serious sworn translator working with sensitive documents, an employment contract, a medical record, a tax return, does not send the raw text to the machine translation service in the cloud. They keep a &lt;strong>notebook of equivalences&lt;/strong> open on the desk. When the original document arrives, they open the notebook and start writing down: &amp;ldquo;Marta García&amp;rdquo; → &lt;code>[PERSON-1]&lt;/code>, &amp;ldquo;12345678X&amp;rdquo; → &lt;code>[DNI-1]&lt;/code>, &amp;ldquo;ES91 2100 0418&amp;hellip;&amp;rdquo; → &lt;code>[IBAN-1]&lt;/code>. They substitute each occurrence in the text with its label and pass the &lt;strong>anonymised&lt;/strong> text to the translation service. The service returns a translation that still contains the labels. The translator opens the notebook again, restores each label with its original value, and hands the client the final translation with the PII intact. As far as the translation service is concerned, those personal data &lt;strong>never existed&lt;/strong>: it only saw placeholders.&lt;/p>
&lt;p>This is the exact operation that defines LLM Guard&amp;rsquo;s character against the rest of the ecosystem. NeMo Guardrails solves safety with a &lt;strong>declarative graph&lt;/strong> of rules in Colang; Guardrails AI with &lt;strong>validators&lt;/strong> that invoke an LLM-as-judge to verify contracts; LLM Guard with a &lt;strong>catalogue of specialised compact detectors&lt;/strong> + the Vault pattern. All three are valid in different scenarios. The choice is not a matter of taste: it is structural, according to how the system is built and where the bottleneck is.&lt;/p>
&lt;p>The translator also checks, of course, that the text contains no other problems besides PII: insults, instructions to reprogram itself, links to hostile pages, code that should not be there. That is what the rest of the scanner catalogue is for. But the house signature, what sets it apart, is that notebook.&lt;/p>
&lt;h2 id="internal-anatomy-of-llm-guard">Internal anatomy of LLM Guard&lt;/h2>
&lt;div class="diagram" style="max-width:820px;margin:1.5rem auto;">
&lt;svg viewBox="0 0 820 460" xmlns="http://www.w3.org/2000/svg" role="img" aria-label="Internal anatomy of LLM Guard">
&lt;style>
.a-orch{fill:#7aafff;stroke:#444;stroke-width:1.4;rx:8}
.a-in{fill:#a8e6a3;stroke:#444;stroke-width:1.4;rx:6}
.a-out{fill:#ffd76b;stroke:#444;stroke-width:1.4;rx:6}
.a-vault{fill:#c8b8ff;stroke:#444;stroke-width:1.4;rx:6}
.a-obs{fill:#f8a8d8;stroke:#444;stroke-width:1.4;rx:6}
.al{font:600 12px sans-serif;fill:#222}
.as{font:400 10px sans-serif;fill:#444}
.an{font:italic 10px sans-serif;fill:#555}
.aar{stroke:#666;stroke-width:1.4;fill:none;marker-end:url(#ma1)}
&lt;/style>
&lt;defs>&lt;marker id="ma1" 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;rect x="20" y="20" width="780" height="40" class="a-orch"/>
&lt;text x="410" y="42" text-anchor="middle" class="al">Orchestrator: scan_prompt() / scan_output() · fail_fast · TTL cache · timeout · OTel spans&lt;/text>
&lt;text x="410" y="56" text-anchor="middle" class="as">Iterates scanners in order, aggregates is_valid and risk_score, emits traces and Prometheus metrics&lt;/text>
&lt;text x="50" y="90" class="al">Input scanners (15)&lt;/text>
&lt;rect x="30" y="100" width="120" height="22" class="a-in"/>&lt;text x="90" y="115" text-anchor="middle" class="as">Anonymize ⓥ&lt;/text>
&lt;rect x="30" y="125" width="120" height="22" class="a-in"/>&lt;text x="90" y="140" text-anchor="middle" class="as">PromptInjection&lt;/text>
&lt;rect x="30" y="150" width="120" height="22" class="a-in"/>&lt;text x="90" y="165" text-anchor="middle" class="as">Toxicity&lt;/text>
&lt;rect x="30" y="175" width="120" height="22" class="a-in"/>&lt;text x="90" y="190" text-anchor="middle" class="as">Secrets&lt;/text>
&lt;rect x="30" y="200" width="120" height="22" class="a-in"/>&lt;text x="90" y="215" text-anchor="middle" class="as">TokenLimit&lt;/text>
&lt;rect x="30" y="225" width="120" height="22" class="a-in"/>&lt;text x="90" y="240" text-anchor="middle" class="as">BanTopics&lt;/text>
&lt;rect x="30" y="250" width="120" height="22" class="a-in"/>&lt;text x="90" y="265" text-anchor="middle" class="as">BanCompetitors&lt;/text>
&lt;rect x="30" y="275" width="120" height="22" class="a-in"/>&lt;text x="90" y="290" text-anchor="middle" class="as">BanCode / Code&lt;/text>
&lt;rect x="30" y="300" width="120" height="22" class="a-in"/>&lt;text x="90" y="315" text-anchor="middle" class="as">Sentiment&lt;/text>
&lt;rect x="30" y="325" width="120" height="22" class="a-in"/>&lt;text x="90" y="340" text-anchor="middle" class="as">Gibberish&lt;/text>
&lt;rect x="30" y="350" width="120" height="22" class="a-in"/>&lt;text x="90" y="365" text-anchor="middle" class="as">Language&lt;/text>
&lt;rect x="30" y="375" width="120" height="22" class="a-in"/>&lt;text x="90" y="390" text-anchor="middle" class="as">InvisibleText&lt;/text>
&lt;rect x="30" y="400" width="120" height="22" class="a-in"/>&lt;text x="90" y="415" text-anchor="middle" class="as">Regex · BanSubstrings&lt;/text>
&lt;rect x="180" y="120" width="160" height="170" class="a-vault"/>
&lt;text x="260" y="142" text-anchor="middle" class="al">Vault&lt;/text>
&lt;text x="260" y="160" text-anchor="middle" class="as">In-memory dictionary&lt;/text>
&lt;text x="260" y="174" text-anchor="middle" class="as">per session / request&lt;/text>
&lt;text x="260" y="200" text-anchor="middle" class="as">[PERSON_1]→"Marta García"&lt;/text>
&lt;text x="260" y="214" text-anchor="middle" class="as">[DNI_1]→"12345678X"&lt;/text>
&lt;text x="260" y="228" text-anchor="middle" class="as">[IBAN_1]→"ES91..."&lt;/text>
&lt;text x="260" y="252" text-anchor="middle" class="as">.placeholder() / .get()&lt;/text>
&lt;text x="260" y="266" text-anchor="middle" class="as">optional: persistence&lt;/text>
&lt;text x="260" y="280" text-anchor="middle" class="as">Redis / sticky client&lt;/text>
&lt;path class="aar" d="M150,110 L186,140"/>
&lt;text x="360" y="90" class="al">Output scanners (21)&lt;/text>
&lt;rect x="350" y="100" width="120" height="22" class="a-out"/>&lt;text x="410" y="115" text-anchor="middle" class="as">Deanonymize ⓥ&lt;/text>
&lt;rect x="350" y="125" width="120" height="22" class="a-out"/>&lt;text x="410" y="140" text-anchor="middle" class="as">Sensitive (PII out)&lt;/text>
&lt;rect x="350" y="150" width="120" height="22" class="a-out"/>&lt;text x="410" y="165" text-anchor="middle" class="as">Toxicity · Bias&lt;/text>
&lt;rect x="350" y="175" width="120" height="22" class="a-out"/>&lt;text x="410" y="190" text-anchor="middle" class="as">NoRefusal&lt;/text>
&lt;rect x="350" y="200" width="120" height="22" class="a-out"/>&lt;text x="410" y="215" text-anchor="middle" class="as">Relevance&lt;/text>
&lt;rect x="350" y="225" width="120" height="22" class="a-out"/>&lt;text x="410" y="240" text-anchor="middle" class="as">FactualConsistency&lt;/text>
&lt;rect x="350" y="250" width="120" height="22" class="a-out"/>&lt;text x="410" y="265" text-anchor="middle" class="as">JSON validator&lt;/text>
&lt;rect x="350" y="275" width="120" height="22" class="a-out"/>&lt;text x="410" y="290" text-anchor="middle" class="as">MaliciousURLs&lt;/text>
&lt;rect x="350" y="300" width="120" height="22" class="a-out"/>&lt;text x="410" y="315" text-anchor="middle" class="as">URLReachability&lt;/text>
&lt;rect x="350" y="325" width="120" height="22" class="a-out"/>&lt;text x="410" y="340" text-anchor="middle" class="as">LanguageSame&lt;/text>
&lt;rect x="350" y="350" width="120" height="22" class="a-out"/>&lt;text x="410" y="365" text-anchor="middle" class="as">ReadingTime&lt;/text>
&lt;rect x="350" y="375" width="120" height="22" class="a-out"/>&lt;text x="410" y="390" text-anchor="middle" class="as">BanCompetitors&lt;/text>
&lt;rect x="350" y="400" width="120" height="22" class="a-out"/>&lt;text x="410" y="415" text-anchor="middle" class="as">Regex · BanSubstrings&lt;/text>
&lt;path class="aar" d="M340,140 L350,140"/>
&lt;text x="540" y="90" class="al">Backend models&lt;/text>
&lt;rect x="510" y="100" width="160" height="60" class="a-obs"/>
&lt;text x="590" y="120" text-anchor="middle" class="al">ONNX runtime&lt;/text>
&lt;text x="590" y="136" text-anchor="middle" class="as">quantised models&lt;/text>
&lt;text x="590" y="152" text-anchor="middle" class="as">CPU + GPU compatible&lt;/text>
&lt;rect x="510" y="170" width="160" height="60" class="a-obs"/>
&lt;text x="590" y="190" text-anchor="middle" class="al">Transformers (HF)&lt;/text>
&lt;text x="590" y="206" text-anchor="middle" class="as">BERT NER, distilbert&lt;/text>
&lt;text x="590" y="222" text-anchor="middle" class="as">deberta, bge, etc.&lt;/text>
&lt;rect x="510" y="240" width="160" height="60" class="a-obs"/>
&lt;text x="590" y="260" text-anchor="middle" class="al">Presidio Analyzer&lt;/text>
&lt;text x="590" y="276" text-anchor="middle" class="as">spaCy / flair / regex&lt;/text>
&lt;text x="590" y="292" text-anchor="middle" class="as">~50 base entities&lt;/text>
&lt;rect x="510" y="310" width="160" height="60" class="a-obs"/>
&lt;text x="590" y="330" text-anchor="middle" class="al">Pure validators&lt;/text>
&lt;text x="590" y="346" text-anchor="middle" class="as">regex, JSON schema,&lt;/text>
&lt;text x="590" y="362" text-anchor="middle" class="as">stdlib URL parsing&lt;/text>
&lt;text x="700" y="90" class="al">Telemetry&lt;/text>
&lt;rect x="690" y="100" width="115" height="60" class="a-obs"/>
&lt;text x="747" y="120" text-anchor="middle" class="al">OTel exporter&lt;/text>
&lt;text x="747" y="136" text-anchor="middle" class="as">traces (HTTP)&lt;/text>
&lt;text x="747" y="152" text-anchor="middle" class="as">metrics (HTTP)&lt;/text>
&lt;rect x="690" y="170" width="115" height="60" class="a-obs"/>
&lt;text x="747" y="190" text-anchor="middle" class="al">Prometheus&lt;/text>
&lt;text x="747" y="206" text-anchor="middle" class="as">/metrics endpoint&lt;/text>
&lt;text x="747" y="222" text-anchor="middle" class="as">counters + histograms&lt;/text>
&lt;rect x="690" y="240" width="115" height="60" class="a-obs"/>
&lt;text x="747" y="260" text-anchor="middle" class="al">structured logs&lt;/text>
&lt;text x="747" y="276" text-anchor="middle" class="as">stdout JSON,&lt;/text>
&lt;text x="747" y="292" text-anchor="middle" class="as">Loki/ELK parseable&lt;/text>
&lt;rect x="690" y="310" width="115" height="60" class="a-obs"/>
&lt;text x="747" y="330" text-anchor="middle" class="al">FastAPI&lt;/text>
&lt;text x="747" y="346" text-anchor="middle" class="as">/analyze/prompt&lt;/text>
&lt;text x="747" y="362" text-anchor="middle" class="as">/analyze/output&lt;/text>
&lt;text x="410" y="445" text-anchor="middle" class="an">The Vault is the unique piece: Anonymize (input) and Deanonymize (output) share it in the same request or session. Without it, PII would leak to the LLM.&lt;/text>
&lt;/svg>
&lt;/div>
&lt;p>The three structural pieces are:&lt;/p>
&lt;p>&lt;strong>1. The orchestrator&lt;/strong> (&lt;code>scan_prompt&lt;/code>, &lt;code>scan_output&lt;/code>). It receives an ordered list of scanners and runs them sequentially over the text. It returns the triple &lt;code>(sanitized_text, results_valid, results_score)&lt;/code> where:&lt;/p>
&lt;ul>
&lt;li>&lt;code>sanitized_text&lt;/code> is the text transformed by the scanners that mutate it (Anonymize, BanSubstrings with redaction).&lt;/li>
&lt;li>&lt;code>results_valid&lt;/code> is a &lt;code>{scanner_name: bool}&lt;/code> dict indicating which scanners passed.&lt;/li>
&lt;li>&lt;code>results_score&lt;/code> is a &lt;code>{scanner_name: float}&lt;/code> dict with the reported risk score (0 clean, 1 maximum violation).&lt;/li>
&lt;/ul>
&lt;p>It supports &lt;code>fail_fast=True&lt;/code> to stop after the first failure. It supports a per-scanner &lt;code>timeout&lt;/code> so it does not block on a slow detector. When exposed as a FastAPI API, it supports a TTL cache to avoid rescanning repeated prompts (the case of bots with identical questions).&lt;/p>
&lt;p>&lt;strong>2. The scanner catalogue.&lt;/strong> Fifteen input scanners and twenty-one output scanners, each with its own backend model and its configurable threshold:&lt;/p>
&lt;table>
&lt;thead>
&lt;tr>
&lt;th>Family&lt;/th>
&lt;th>Input&lt;/th>
&lt;th>Output&lt;/th>
&lt;th>Dominant backend&lt;/th>
&lt;/tr>
&lt;/thead>
&lt;tbody>
&lt;tr>
&lt;td>&lt;strong>PII&lt;/strong>&lt;/td>
&lt;td>Anonymize&lt;/td>
&lt;td>Deanonymize, Sensitive&lt;/td>
&lt;td>Presidio + BERT-NER&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>&lt;strong>Injection and jailbreak&lt;/strong>&lt;/td>
&lt;td>PromptInjection&lt;/td>
&lt;td>—&lt;/td>
&lt;td>Fine-tuned DeBERTa (Protect AI&amp;rsquo;s own)&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>&lt;strong>Toxicity and bias&lt;/strong>&lt;/td>
&lt;td>Toxicity, Sentiment&lt;/td>
&lt;td>Toxicity, Bias, Sentiment&lt;/td>
&lt;td>Fine-tuned RoBERTa / BERT&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>&lt;strong>Banned topics&lt;/strong>&lt;/td>
&lt;td>BanTopics, BanCompetitors&lt;/td>
&lt;td>BanTopics, BanCompetitors&lt;/td>
&lt;td>Zero-shot BART-MNLI classifier&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>&lt;strong>Substrings and regex&lt;/strong>&lt;/td>
&lt;td>BanSubstrings, Regex&lt;/td>
&lt;td>BanSubstrings, Regex&lt;/td>
&lt;td>string matching + regex&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>&lt;strong>Secrets&lt;/strong>&lt;/td>
&lt;td>Secrets&lt;/td>
&lt;td>—&lt;/td>
&lt;td>detect-secrets (Yelp) + regex&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>&lt;strong>Structure&lt;/strong>&lt;/td>
&lt;td>TokenLimit, Language, InvisibleText, Gibberish&lt;/td>
&lt;td>JSON, Language, LanguageSame, Gibberish, ReadingTime&lt;/td>
&lt;td>tokeniser, lang-detect, JSON schema&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>&lt;strong>Code&lt;/strong>&lt;/td>
&lt;td>BanCode, Code&lt;/td>
&lt;td>BanCode, Code&lt;/td>
&lt;td>language classifier + regex&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>&lt;strong>URLs&lt;/strong>&lt;/td>
&lt;td>—&lt;/td>
&lt;td>MaliciousURLs, URLReachability&lt;/td>
&lt;td>block list + DNS lookup&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>&lt;strong>Answer quality&lt;/strong>&lt;/td>
&lt;td>—&lt;/td>
&lt;td>NoRefusal, Relevance, FactualConsistency&lt;/td>
&lt;td>NLI cross-encoder + cosine similarity&lt;/td>
&lt;/tr>
&lt;/tbody>
&lt;/table>
&lt;p>Each scanner is imported and instantiated individually, with its own threshold:&lt;/p>
&lt;div class="highlight">&lt;pre tabindex="0" class="chroma">&lt;code class="language-python" data-lang="python">&lt;span class="line">&lt;span class="cl">&lt;span class="kn">from&lt;/span> &lt;span class="nn">llm_guard.input_scanners&lt;/span> &lt;span class="kn">import&lt;/span> &lt;span class="n">Anonymize&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="n">PromptInjection&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="n">Toxicity&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="n">Secrets&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="kn">from&lt;/span> &lt;span class="nn">llm_guard.vault&lt;/span> &lt;span class="kn">import&lt;/span> &lt;span class="n">Vault&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="n">vault&lt;/span> &lt;span class="o">=&lt;/span> &lt;span class="n">Vault&lt;/span>&lt;span class="p">()&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="n">scanners&lt;/span> &lt;span class="o">=&lt;/span> &lt;span class="p">[&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="n">Anonymize&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="n">vault&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="n">threshold&lt;/span>&lt;span class="o">=&lt;/span>&lt;span class="mf">0.5&lt;/span>&lt;span class="p">),&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="n">PromptInjection&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="n">threshold&lt;/span>&lt;span class="o">=&lt;/span>&lt;span class="mf">0.85&lt;/span>&lt;span class="p">),&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="n">Toxicity&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="n">threshold&lt;/span>&lt;span class="o">=&lt;/span>&lt;span class="mf">0.7&lt;/span>&lt;span class="p">),&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="n">Secrets&lt;/span>&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>&lt;strong>3. The Vault.&lt;/strong> A unique piece not found in NeMo Guardrails or Guardrails AI with the same model. It is an in-memory dictionary per session or request that stores the &lt;code>placeholder → original_value&lt;/code> mapping. The &lt;code>Anonymize&lt;/code> scanner writes it on input and the &lt;code>Deanonymize&lt;/code> scanner reads it on output. If the Vault is shared across multiple requests from the same user, the mapping persists (useful for multi-turn conversations). If it is per request, it is discarded after the answer.&lt;/p>
&lt;p>The basic Vault is a Python &lt;code>dict&lt;/code>; for distributed environments with multiple pods, it is replaced by a sticky Redis (same user → same pod) or by a custom Vault that reads from and writes to an external Redis, discarded after a TTL. This is operational, not part of the core library.&lt;/p>
&lt;h2 id="the-anonymize--llm--deanonymize-flow-in-detail">The Anonymize → LLM → Deanonymize flow in detail&lt;/h2>
&lt;p>The canonical usage pattern of LLM Guard breaks down into six exact steps:&lt;/p>
&lt;pre tabindex="0">&lt;code>1. Receive the user&amp;#39;s prompt:
&amp;#34;My name is Marta García and my IBAN is ES9121000418450200051332,
can you review the charge from 14 March?&amp;#34;
2. scan_prompt() with [Anonymize(vault), PromptInjection(), Toxicity()]
→ Anonymize redacts entities and stores them in the vault:
vault[&amp;#34;[REDACTED_PERSON_1]&amp;#34;] = &amp;#34;Marta García&amp;#34;
vault[&amp;#34;[REDACTED_IBAN_1]&amp;#34;] = &amp;#34;ES9121000418450200051332&amp;#34;
→ PromptInjection checks there is no jailbreak (there is none)
→ Toxicity checks there are no insults (there are none)
→ results_valid = {Anonymize: True, PromptInjection: True, Toxicity: True}
→ sanitized_prompt:
&amp;#34;My name is [REDACTED_PERSON_1] and my IBAN is [REDACTED_IBAN_1],
can you review the charge from 14 March?&amp;#34;
3. Call the LLM with sanitized_prompt:
→ vLLM receives the prompt with no real PII
→ generates the answer:
&amp;#34;Yes, [REDACTED_PERSON_1], I am going to review the charge on account
[REDACTED_IBAN_1]. Can you confirm the amount?&amp;#34;
4. scan_output() with [Deanonymize(vault), Toxicity(), Relevance(), Sensitive()]
→ Deanonymize replaces placeholders with values from the vault:
[REDACTED_PERSON_1] → &amp;#34;Marta García&amp;#34;
[REDACTED_IBAN_1] → &amp;#34;ES9121000418450200051332&amp;#34;
→ Toxicity checks the answer is not offensive
→ Relevance checks it answers the prompt
→ Sensitive checks no unauthorised PII appears
(in this case, the restored PII is authorised because the user
brought it themselves and the Vault signs it → the rule applies
only to new PII invented by the LLM)
→ sanitized_response:
&amp;#34;Yes, Marta García, I am going to review the charge on account
ES9121000418450200051332. Can you confirm the amount?&amp;#34;
5. Return sanitized_response to the user.
6. If the session continues, the vault persists and the next turns reuse
the same placeholders. When the session ends, the vault is discarded.
&lt;/code>&lt;/pre>&lt;p>Three details that matter operationally:&lt;/p>
&lt;ul>
&lt;li>&lt;strong>Persistent entities&lt;/strong> (&lt;code>[REDACTED_PERSON_1]&lt;/code> for &amp;ldquo;Marta García&amp;rdquo;) stay constant throughout the session. If the user mentions another person (&amp;ldquo;I spoke to Juan Pérez&amp;rdquo;), Anonymize will assign &lt;code>[REDACTED_PERSON_2]&lt;/code>. Cross-turn coherence is guaranteed by the Vault.&lt;/li>
&lt;li>&lt;strong>The LLM never sees the original data&lt;/strong> during the session. This is the key property for cases where the LLM is served from a cloud model or when the prompt is logged (Langfuse, OTel) without confidential access.&lt;/li>
&lt;li>&lt;strong>LLM Guard&amp;rsquo;s logging records the placeholders&lt;/strong>, not the original values. For audit with original values, an additional layer is needed (access to the Vault with privileged permissions), and this is by design, not by default.&lt;/li>
&lt;/ul>
&lt;h2 id="four-deployment-modes">Four deployment modes&lt;/h2>
&lt;h3 id="mode-1--in-process-python-library">Mode 1 — In-process Python library&lt;/h3>
&lt;p>The simplest: &lt;code>pip install llm-guard&lt;/code>, import the scanners in the application code, call &lt;code>scan_prompt&lt;/code>/&lt;code>scan_output&lt;/code> directly. The models load into the process. The advantage is minimum latency; the disadvantage is that each application replica loads its own models into memory.&lt;/p>
&lt;div class="highlight">&lt;pre tabindex="0" class="chroma">&lt;code class="language-python" data-lang="python">&lt;span class="line">&lt;span class="cl">&lt;span class="c1"># on the app server&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="kn">from&lt;/span> &lt;span class="nn">llm_guard&lt;/span> &lt;span class="kn">import&lt;/span> &lt;span class="n">scan_prompt&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="n">scan_output&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="kn">from&lt;/span> &lt;span class="nn">llm_guard.input_scanners&lt;/span> &lt;span class="kn">import&lt;/span> &lt;span class="n">Anonymize&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="n">PromptInjection&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="n">Toxicity&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="kn">from&lt;/span> &lt;span class="nn">llm_guard.output_scanners&lt;/span> &lt;span class="kn">import&lt;/span> &lt;span class="n">Deanonymize&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="n">Toxicity&lt;/span> &lt;span class="k">as&lt;/span> &lt;span class="n">OutToxicity&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="n">Relevance&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="kn">from&lt;/span> &lt;span class="nn">llm_guard.vault&lt;/span> &lt;span class="kn">import&lt;/span> &lt;span class="n">Vault&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="n">vault&lt;/span> &lt;span class="o">=&lt;/span> &lt;span class="n">Vault&lt;/span>&lt;span class="p">()&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="n">input_scanners&lt;/span> &lt;span class="o">=&lt;/span> &lt;span class="p">[&lt;/span>&lt;span class="n">Anonymize&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="n">vault&lt;/span>&lt;span class="p">),&lt;/span> &lt;span class="n">PromptInjection&lt;/span>&lt;span class="p">(),&lt;/span> &lt;span class="n">Toxicity&lt;/span>&lt;span class="p">()]&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="n">output_scanners&lt;/span> &lt;span class="o">=&lt;/span> &lt;span class="p">[&lt;/span>&lt;span class="n">Deanonymize&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="n">vault&lt;/span>&lt;span class="p">),&lt;/span> &lt;span class="n">OutToxicity&lt;/span>&lt;span class="p">(),&lt;/span> &lt;span class="n">Relevance&lt;/span>&lt;span class="p">()]&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="c1"># in the request handler&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="n">sanitized_prompt&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="n">valid_in&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="n">score_in&lt;/span> &lt;span class="o">=&lt;/span> &lt;span class="n">scan_prompt&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="n">input_scanners&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="n">user_prompt&lt;/span>&lt;span class="p">)&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="k">if&lt;/span> &lt;span class="ow">not&lt;/span> &lt;span class="nb">all&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="n">valid_in&lt;/span>&lt;span class="o">.&lt;/span>&lt;span class="n">values&lt;/span>&lt;span class="p">()):&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="k">return&lt;/span> &lt;span class="n">error_response&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="n">score_in&lt;/span>&lt;span class="p">)&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="n">response&lt;/span> &lt;span class="o">=&lt;/span> &lt;span class="n">vllm_client&lt;/span>&lt;span class="o">.&lt;/span>&lt;span class="n">complete&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="n">sanitized_prompt&lt;/span>&lt;span class="p">)&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="n">sanitized_resp&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="n">valid_out&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="n">score_out&lt;/span> &lt;span class="o">=&lt;/span> &lt;span class="n">scan_output&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="n">output_scanners&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="n">sanitized_prompt&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="n">response&lt;/span>&lt;span class="p">)&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="k">if&lt;/span> &lt;span class="ow">not&lt;/span> &lt;span class="nb">all&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="n">valid_out&lt;/span>&lt;span class="o">.&lt;/span>&lt;span class="n">values&lt;/span>&lt;span class="p">()):&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="k">return&lt;/span> &lt;span class="n">error_response&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="n">score_out&lt;/span>&lt;span class="p">)&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="k">return&lt;/span> &lt;span class="n">sanitized_resp&lt;/span>
&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>It fits &lt;strong>pattern A (sidecar)&lt;/strong> from the &lt;a href="https://blog.lo0.es/en/posts/llm-guardrails-safety-four-lines-of-defence/">guardrails post&lt;/a> when the app and the sidecar share a process. And &lt;strong>pattern C (in-process)&lt;/strong> if the app is itself the inference layer.&lt;/p>
&lt;h3 id="mode-2--its-own-fastapi-api">Mode 2 — Its own FastAPI API&lt;/h3>
&lt;p>The project includes a ready-made FastAPI server (&lt;code>llm-guard-api&lt;/code>) that exposes the scanners behind two REST endpoints:&lt;/p>
&lt;pre tabindex="0">&lt;code>POST /analyze/prompt
body: {&amp;#34;prompt&amp;#34;: &amp;#34;...&amp;#34;, &amp;#34;scanners&amp;#34;: [...] (optional)}
response: {&amp;#34;sanitized_prompt&amp;#34;: &amp;#34;...&amp;#34;, &amp;#34;is_valid&amp;#34;: bool, &amp;#34;scanners&amp;#34;: {scanner: {is_valid, risk_score}}}
POST /analyze/output
body: {&amp;#34;prompt&amp;#34;: &amp;#34;...&amp;#34;, &amp;#34;output&amp;#34;: &amp;#34;...&amp;#34;, &amp;#34;scanners&amp;#34;: [...]}
response: analogous
&lt;/code>&lt;/pre>&lt;p>Configuration through &lt;code>config/scanners.yml&lt;/code> with environment variables (&lt;code>SCAN_FAIL_FAST&lt;/code>, &lt;code>CACHE_MAX_SIZE&lt;/code>, &lt;code>CACHE_TTL&lt;/code>, &lt;code>SCAN_PROMPT_TIMEOUT&lt;/code>…). It carries Prometheus metrics at &lt;code>/metrics&lt;/code> and OTel HTTP exporter traces by default.&lt;/p>
&lt;p>It fits &lt;strong>pattern B (centralised service behind an AI gateway)&lt;/strong> from the guardrails post.&lt;/p>
&lt;h3 id="mode-3--otel-sidecar-on-the-inference-engine-pod">Mode 3 — OTel sidecar on the inference engine pod&lt;/h3>
&lt;p>For vLLM deployments on Kubernetes, a variant of mode 2 is to deploy the LLM Guard API as a &lt;strong>sidecar container&lt;/strong> in the same vLLM pod, talking over localhost. The AI gateway in front invokes the sidecar before and after inference. The node&amp;rsquo;s OTel collector aggregates vLLM&amp;rsquo;s spans with LLM Guard&amp;rsquo;s &lt;code>gen_ai.guardrail.*&lt;/code> spans automatically because they share a &lt;code>trace_id&lt;/code> propagated via HTTP baggage.&lt;/p>
&lt;p>This fits &lt;strong>pattern A (sidecar)&lt;/strong> from the guardrails post, but with the discipline of a REST API so as not to couple languages (the AI gateway can be Envoy in C++, LLM Guard in Python).&lt;/p>
&lt;h3 id="mode-4--plugin-inside-an-ai-gateway">Mode 4 — Plugin inside an AI gateway&lt;/h3>
&lt;p>Three AI gateways support LLM Guard as a native plugin in 2026:&lt;/p>
&lt;ul>
&lt;li>&lt;strong>LiteLLM Proxy&lt;/strong> (MIT, BerriAI) — &lt;code>llm_guard&lt;/code> plugin, enabled in config with &lt;code>guardrails: [&amp;quot;llm_guard&amp;quot;]&lt;/code>. It calls the API internally.&lt;/li>
&lt;li>&lt;strong>Envoy AI Gateway&lt;/strong> (CNCF, Apache 2.0) — &lt;code>ai-guardrails&lt;/code> filter with a pluggable backend pointing at the LLM Guard service.&lt;/li>
&lt;li>&lt;strong>Kong AI Gateway&lt;/strong> (Apache 2.0) — &lt;code>ai-proxy&lt;/code> plugin with a post-processor that invokes LLM Guard.&lt;/li>
&lt;/ul>
&lt;p>In all three cases, the AI gateway is the single entry point from the client app to the LLM; the gateway calls LLM Guard before and after passing to the inference engine. Advantage: zero lock-in in the application code; switching from LLM Guard to NeMo Guardrails means changing the gateway plugin, not rewriting the app. Disadvantage: the extra hop adds latency (typically 5-15 ms intra-cluster).&lt;/p>
&lt;h2 id="graphical-integration-with-langfuse-vllm-and-the-otel-stack">Graphical integration with Langfuse, vLLM and the OTel stack&lt;/h2>
&lt;div class="diagram" style="max-width:820px;margin:1.5rem auto;">
&lt;svg viewBox="0 0 820 460" xmlns="http://www.w3.org/2000/svg" role="img" aria-label="Integration of LLM Guard with Langfuse, vLLM and the OTel stack">
&lt;style>
.b-app{fill:#7aafff;stroke:#444;stroke-width:1.4;rx:8}
.b-gw{fill:#a8e6a3;stroke:#444;stroke-width:1.4;rx:8}
.b-lg{fill:#ffd76b;stroke:#444;stroke-width:1.4;rx:8}
.b-vllm{fill:#ff8a4c;stroke:#444;stroke-width:1.4;rx:8}
.b-otel{fill:#c8b8ff;stroke:#444;stroke-width:1.4;rx:8}
.b-langfuse{fill:#f8a8d8;stroke:#444;stroke-width:1.4;rx:8}
.b-storage{fill:#f0e8c0;stroke:#444;stroke-width:1.4;rx:8}
.bl{font:600 13px sans-serif;fill:#222}
.bs{font:400 11px sans-serif;fill:#444}
.bn{font:italic 10px sans-serif;fill:#555}
.bar{stroke:#666;stroke-width:1.6;fill:none;marker-end:url(#mb1)}
.bart{stroke:#5a5;stroke-width:1.4;fill:none;stroke-dasharray:5 3;marker-end:url(#mb2)}
&lt;/style>
&lt;defs>
&lt;marker id="mb1" 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;marker id="mb2" 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="#5a5"/>&lt;/marker>
&lt;/defs>
&lt;rect x="20" y="40" width="140" height="50" class="b-app"/>
&lt;text x="90" y="60" text-anchor="middle" class="bl">Client app&lt;/text>
&lt;text x="90" y="78" text-anchor="middle" class="bs">chatbot · backend · agent&lt;/text>
&lt;rect x="200" y="40" width="180" height="50" class="b-gw"/>
&lt;text x="290" y="60" text-anchor="middle" class="bl">AI Gateway&lt;/text>
&lt;text x="290" y="78" text-anchor="middle" class="bs">LiteLLM · Envoy AI · Kong AI&lt;/text>
&lt;rect x="430" y="20" width="160" height="40" class="b-lg"/>
&lt;text x="510" y="38" text-anchor="middle" class="bl">LLM Guard API&lt;/text>
&lt;text x="510" y="54" text-anchor="middle" class="bs">scan_prompt + scan_output&lt;/text>
&lt;rect x="430" y="70" width="160" height="40" class="b-vllm"/>
&lt;text x="510" y="88" text-anchor="middle" class="bl">vLLM&lt;/text>
&lt;text x="510" y="104" text-anchor="middle" class="bs">inference engine + adapter&lt;/text>
&lt;rect x="640" y="40" width="160" height="50" class="b-storage"/>
&lt;text x="720" y="60" text-anchor="middle" class="bl">Vault Redis&lt;/text>
&lt;text x="720" y="78" text-anchor="middle" class="bs">placeholder→PII mapping&lt;/text>
&lt;path class="bar" d="M160,65 L200,65"/>
&lt;path class="bar" d="M380,55 L430,40"/>
&lt;path class="bar" d="M380,75 L430,90"/>
&lt;path class="bar" d="M510,60 L640,65"/>
&lt;text x="170" y="55" class="bn">1: prompt&lt;/text>
&lt;text x="390" y="35" class="bn">2: pre-scan&lt;/text>
&lt;text x="390" y="105" class="bn">3: inference&lt;/text>
&lt;text x="555" y="55" class="bn">vault R/W&lt;/text>
&lt;rect x="20" y="180" width="240" height="80" class="b-otel"/>
&lt;text x="140" y="202" text-anchor="middle" class="bl">OTel Collector (DaemonSet)&lt;/text>
&lt;text x="140" y="220" text-anchor="middle" class="bs">receives gen_ai.* and&lt;/text>
&lt;text x="140" y="234" text-anchor="middle" class="bs">gen_ai.guardrail.* spans from:&lt;/text>
&lt;text x="140" y="250" text-anchor="middle" class="bs">vLLM, LLM Guard, AI Gateway&lt;/text>
&lt;path class="bart" d="M510,110 Q260,140 140,178"/>
&lt;path class="bart" d="M510,60 Q330,140 200,178"/>
&lt;path class="bart" d="M290,90 Q230,140 140,178"/>
&lt;text x="320" y="135" class="bn">OTel HTTP spans&lt;/text>
&lt;rect x="300" y="180" width="200" height="80" class="b-langfuse"/>
&lt;text x="400" y="202" text-anchor="middle" class="bl">Langfuse&lt;/text>
&lt;text x="400" y="220" text-anchor="middle" class="bs">/api/public/otel ingestion&lt;/text>
&lt;text x="400" y="236" text-anchor="middle" class="bs">+ /api/public/scores&lt;/text>
&lt;text x="400" y="252" text-anchor="middle" class="bs">+ datasets + sessions&lt;/text>
&lt;path class="bar" d="M260,220 L300,220"/>
&lt;text x="270" y="215" class="bn">OTLP&lt;/text>
&lt;rect x="540" y="180" width="120" height="40" class="b-otel"/>
&lt;text x="600" y="200" text-anchor="middle" class="bl">Tempo / Jaeger&lt;/text>
&lt;text x="600" y="216" text-anchor="middle" class="bs">trace storage&lt;/text>
&lt;rect x="540" y="225" width="120" height="40" class="b-otel"/>
&lt;text x="600" y="245" text-anchor="middle" class="bl">VictoriaMetrics&lt;/text>
&lt;text x="600" y="261" text-anchor="middle" class="bs">Prom metrics&lt;/text>
&lt;path class="bar" d="M260,210 L540,200"/>
&lt;path class="bar" d="M260,235 L540,240"/>
&lt;rect x="700" y="180" width="100" height="80" class="b-storage"/>
&lt;text x="750" y="202" text-anchor="middle" class="bl">Grafana&lt;/text>
&lt;text x="750" y="220" text-anchor="middle" class="bs">datasource&lt;/text>
&lt;text x="750" y="234" text-anchor="middle" class="bs">Tempo + VM&lt;/text>
&lt;text x="750" y="252" text-anchor="middle" class="bs">+ Langfuse&lt;/text>
&lt;path class="bar" d="M660,220 L700,220"/>
&lt;rect x="20" y="320" width="780" height="50" class="b-gw"/>
&lt;text x="410" y="340" text-anchor="middle" class="bl">Langfuse scoring plane: the AI Gateway posts langfuse.score(trace_id, name="guardrail.PromptInjection", value=risk_score)&lt;/text>
&lt;text x="410" y="356" text-anchor="middle" class="bs">for each scanner run; that lets Langfuse build "% blocks per category" dashboards and time series&lt;/text>
&lt;path class="bar" d="M290,90 Q290,290 400,320"/>
&lt;text x="305" y="200" class="bn">HTTP scores&lt;/text>
&lt;text x="410" y="400" text-anchor="middle" class="bn">Three telemetry planes mix: traces (OTel → Tempo + Langfuse), metrics (Prometheus → VictoriaMetrics), scores (Langfuse SDK).&lt;/text>
&lt;text x="410" y="418" text-anchor="middle" class="bn">Grafana joins them by trace_id; Langfuse joins them by session_id + propagated trace_id.&lt;/text>
&lt;text x="410" y="438" text-anchor="middle" class="bn">The Vault Redis has its own data plane and is NOT exported to observability — the original PII never leaves it.&lt;/text>
&lt;/svg>
&lt;/div>
&lt;p>The &lt;strong>three integration routes with Langfuse&lt;/strong> that matter operationally:&lt;/p>
&lt;p>&lt;strong>Route A — LLM Guard&amp;rsquo;s OTel HTTP exporter.&lt;/strong> LLM Guard has a native OTel HTTP exporter. By setting &lt;code>OTEL_EXPORTER_OTLP_TRACES_ENDPOINT=https://langfuse.cluster/api/public/otel&lt;/code>, the &lt;code>gen_ai.guardrail.*&lt;/code> spans emitted by each scanner arrive directly at Langfuse and appear as child spans of the main LLM span (provided the &lt;code>trace_id&lt;/code> is propagated via HTTP baggage from the AI gateway). This is the canonical route in 2026.&lt;/p>
&lt;p>&lt;strong>Route B — Langfuse scoring API from the AI gateway.&lt;/strong> On receiving LLM Guard&amp;rsquo;s response with the per-scanner &lt;code>risk_score&lt;/code>, the AI gateway (LiteLLM, Envoy AI, Kong AI) issues a &lt;code>langfuse.score(trace_id, name=&amp;quot;guardrail.PromptInjection&amp;quot;, value=0.87, comment=&amp;quot;blocked&amp;quot;)&lt;/code> call for each scanner. In Langfuse it appears as scores attached to the same trace as the inference. It enables &amp;ldquo;blocks per category&amp;rdquo; dashboards and per-scanner time series. It is &lt;strong>complementary&lt;/strong> to route A: A brings the spans, B brings the numeric score that is easy to aggregate in SQL.&lt;/p>
&lt;p>&lt;strong>Route C — Langfuse sessions + Vault metadata.&lt;/strong> In conversational mode, the AI gateway propagates &lt;code>langfuse_session_id&lt;/code> to the Vault as its key. When a user has a multi-turn session, Langfuse shows the complete trace of the session, with the placeholders reused turn after turn. The original PII still never travels to Langfuse, only the placeholders and their categories.&lt;/p>
&lt;p>The node&amp;rsquo;s &lt;strong>OTel Collector&lt;/strong> is the glue: it receives spans from vLLM (via OpenLLMetry or native instrumentation), from LLM Guard (via its OTel exporter) and from the AI gateway (standard HTTP instrumentation), &lt;strong>joins them by trace_id&lt;/strong>, and sends them in parallel to Langfuse (via OTLP HTTP) and to Tempo/Jaeger. LLM Guard&amp;rsquo;s Prometheus metrics go to VictoriaMetrics via normal scraping. Grafana offers the unified view for cross-trace investigation; Langfuse offers the LLM-centric view with sessions and scores. The &lt;a href="https://blog.lo0.es/en/posts/llm-tracing-opentelemetry-genai/">OTel GenAI tracing post&lt;/a> details the complete mechanics of the Collector.&lt;/p>
&lt;h2 id="the-mathematics-that-matter">The mathematics that matter&lt;/h2>
&lt;h3 id="latency-per-scanner--the-real-numbers">Latency per scanner — the real numbers&lt;/h3>
&lt;p>The project publishes reproducible benchmarks. For the Anonymize scanner (input length 317 chars, batch 5), the reference data are:&lt;/p>
&lt;table>
&lt;thead>
&lt;tr>
&lt;th>Platform&lt;/th>
&lt;th>Backend&lt;/th>
&lt;th>Avg latency&lt;/th>
&lt;th>p99&lt;/th>
&lt;th>QPS&lt;/th>
&lt;/tr>
&lt;/thead>
&lt;tbody>
&lt;tr>
&lt;td>AWS m5.xlarge (CPU)&lt;/td>
&lt;td>Transformers&lt;/td>
&lt;td>177 ms&lt;/td>
&lt;td>326 ms&lt;/td>
&lt;td>1,789&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>AWS m5.xlarge (CPU)&lt;/td>
&lt;td>&lt;strong>ONNX runtime&lt;/strong>&lt;/td>
&lt;td>&lt;strong>128 ms&lt;/strong>&lt;/td>
&lt;td>180 ms&lt;/td>
&lt;td>2,464&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>AWS r6a.xlarge (AMD CPU)&lt;/td>
&lt;td>Transformers&lt;/td>
&lt;td>244 ms&lt;/td>
&lt;td>284 ms&lt;/td>
&lt;td>1,298&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>AWS g5.xlarge (NVIDIA A10G)&lt;/td>
&lt;td>Transformers FP16&lt;/td>
&lt;td>125 ms&lt;/td>
&lt;td>498 ms&lt;/td>
&lt;td>2,532&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>AWS g5.xlarge (A10G)&lt;/td>
&lt;td>&lt;strong>ONNX + GPU&lt;/strong>&lt;/td>
&lt;td>&lt;strong>38 ms&lt;/strong>&lt;/td>
&lt;td>99 ms&lt;/td>
&lt;td>8,317&lt;/td>
&lt;/tr>
&lt;/tbody>
&lt;/table>
&lt;p>Three operational observations:&lt;/p>
&lt;ol>
&lt;li>&lt;strong>ONNX always wins.&lt;/strong> Even on CPU, ONNX brings the average down from 177 to 128 ms (a factor of 1.4×). On GPU with ONNX, it drops from 177 to 38 ms (a factor of 4.6×). The practical rule: &lt;strong>always export the scanner&amp;rsquo;s model to ONNX before production&lt;/strong>. The official SaaS preview uses it by default.&lt;/li>
&lt;li>&lt;strong>A GPU without ONNX does not perform as well as you might expect.&lt;/strong> An A10G without ONNX (125 ms) is comparable to an m5.xlarge with ONNX (128 ms). The GPU alone does not compensate if the inference graph is not optimised. The relevant pairing is ONNX + GPU.&lt;/li>
&lt;li>&lt;strong>p99 latency without ONNX explodes.&lt;/strong> On GPU without ONNX, the p99 of 498 ms triples the average of 125 ms — queues and batching produce high tail latencies. With ONNX, the p99/avg ratio drops to 2.6× (99/38), far more predictable.&lt;/li>
&lt;/ol>
&lt;p>For a guardrail layer with five scanners run sequentially (Anonymize, PromptInjection, Toxicity, Secrets, BanTopics), the sum of the p99s is what determines the budget for line 1 (input). Five scanners at ~100 ms p99 each = 500 ms accumulated p99, outside the budget for interactive chat. With ONNX we get down to ~50 ms each = 250 ms p99, which is manageable. &lt;strong>With &lt;code>fail_fast=True&lt;/code>&lt;/strong>, the expected time is lower (the most likely case is that the cheap ones pass and the expensive ones only fail if they run at all).&lt;/p>
&lt;p>For a finer calculation, the expected latency of the pipeline with &lt;code>fail_fast&lt;/code> is:&lt;/p>
\[
\mathbb{E}[L] = \sum_{i=1}^{N} L_i \cdot \prod_{j=1}^{i-1} p_j
\]
&lt;p>where \(L_i\) is the latency of scanner \(i\) and \(p_j\) the probability that scanner \(j\) returns valid. On well-behaved traffic (most prompts pass every scanner), \(\prod p_j \approx 1\) and the formula collapses to the direct sum. On adversarial traffic, the faster scanners at the start of the pipeline cut in earlier and the expected latency drops sharply.&lt;/p>
&lt;h3 id="computational-cost-per-scanner">Computational cost per scanner&lt;/h3>
&lt;p>The size of the backend model determines the cost and the possibility of running on CPU vs requiring a GPU:&lt;/p>
&lt;table>
&lt;thead>
&lt;tr>
&lt;th>Scanner&lt;/th>
&lt;th>Typical backend model&lt;/th>
&lt;th>Parameters&lt;/th>
&lt;th>VRAM FP16 / ONNX-INT8&lt;/th>
&lt;th>CPU viable&lt;/th>
&lt;/tr>
&lt;/thead>
&lt;tbody>
&lt;tr>
&lt;td>Anonymize (BERT-NER)&lt;/td>
&lt;td>dslim/bert-base-NER&lt;/td>
&lt;td>110 M&lt;/td>
&lt;td>220 MB / 55 MB&lt;/td>
&lt;td>Yes (with ONNX)&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>Anonymize (BERT-large)&lt;/td>
&lt;td>dslim/bert-large-NER&lt;/td>
&lt;td>335 M&lt;/td>
&lt;td>670 MB / 170 MB&lt;/td>
&lt;td>Yes but slow (~500 ms CPU)&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>PromptInjection&lt;/td>
&lt;td>Fine-tuned DeBERTa-v3-base&lt;/td>
&lt;td>184 M&lt;/td>
&lt;td>370 MB / 90 MB&lt;/td>
&lt;td>Yes (with ONNX)&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>Toxicity&lt;/td>
&lt;td>unitary/toxic-bert&lt;/td>
&lt;td>110 M&lt;/td>
&lt;td>220 MB / 55 MB&lt;/td>
&lt;td>Yes&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>Sentiment&lt;/td>
&lt;td>distilbert-sst2&lt;/td>
&lt;td>67 M&lt;/td>
&lt;td>130 MB / 35 MB&lt;/td>
&lt;td>Yes&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>Gibberish&lt;/td>
&lt;td>small distilbert&lt;/td>
&lt;td>67 M&lt;/td>
&lt;td>130 MB / 35 MB&lt;/td>
&lt;td>Yes&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>BanTopics&lt;/td>
&lt;td>BART-MNLI zero-shot&lt;/td>
&lt;td>407 M&lt;/td>
&lt;td>815 MB / 200 MB&lt;/td>
&lt;td>Slow on CPU (~400 ms)&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>Bias (output)&lt;/td>
&lt;td>RoBERTa-bias&lt;/td>
&lt;td>125 M&lt;/td>
&lt;td>250 MB / 65 MB&lt;/td>
&lt;td>Yes&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>FactualConsistency&lt;/td>
&lt;td>cross-encoder/nli-deberta&lt;/td>
&lt;td>184 M&lt;/td>
&lt;td>370 MB / 90 MB&lt;/td>
&lt;td>Yes&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>Relevance&lt;/td>
&lt;td>sentence-transformers&lt;/td>
&lt;td>110 M&lt;/td>
&lt;td>220 MB / 55 MB&lt;/td>
&lt;td>Yes&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>TokenLimit, Regex, JSON, BanSubstrings, Secrets&lt;/td>
&lt;td>(no model)&lt;/td>
&lt;td>—&lt;/td>
&lt;td>0&lt;/td>
&lt;td>Trivial&lt;/td>
&lt;/tr>
&lt;/tbody>
&lt;/table>
&lt;p>&lt;strong>Sensible on-premise pattern&lt;/strong>: model-free scanners (TokenLimit, Regex, BanSubstrings, Secrets) run on CPU without blinking. Anonymize, PromptInjection, Toxicity, Sentiment and Relevance run comfortably on CPU with ONNX-INT8 at ~50-150 ms p99. BanTopics and the ones based on large cross-encoders (FactualConsistency) are the candidates to live on a shared GPU if you want p99 &amp;lt; 100 ms.&lt;/p>
&lt;h3 id="api-throughput-on-a-cluster">API throughput on a cluster&lt;/h3>
&lt;p>One instance of the FastAPI API with 4 Uvicorn workers on a node with 8 vCPUs reaches ~600-1,200 RPS over a typical 5-scanner pipeline on CPU + ONNX. To scale:&lt;/p>
&lt;ul>
&lt;li>&lt;strong>Horizontally&lt;/strong>: replicate pods behind a ClusterIP Service — linear scaling, because the scanners are stateless (except the Vault, which is per session and is externalised to Redis if you want it sticky or shared).&lt;/li>
&lt;li>&lt;strong>Vertically with GPU&lt;/strong>: 1 H100 serves ~5,000-10,000 RPS with all the scanners in ONNX-GPU. It is overkill for most deployments except multi-tenant ones with thousands of sustained QPS.&lt;/li>
&lt;/ul>
&lt;p>The practical rule from the &lt;a href="https://blog.lo0.es/en/posts/llm-guardrails-safety-four-lines-of-defence/">guardrails post&lt;/a> (1 guardrail GPU per 4-8 LLM GPUs) holds here: with a 4×H100 SXM cluster serving Llama 70B at TP=4, an L4 or RTX 4090 dedicated to the LLM Guard service covers the load.&lt;/p>
&lt;h2 id="comparison-with-nemo-guardrails-and-guardrails-ai">Comparison with NeMo Guardrails and Guardrails AI&lt;/h2>
&lt;p>The three tools solve the same problem from three different architectural models. The choice between them is not about quality, since all three are mature, it is about &lt;strong>fit with the rest of the stack&lt;/strong>:&lt;/p>
&lt;table>
&lt;thead>
&lt;tr>
&lt;th>Dimension&lt;/th>
&lt;th>LLM Guard&lt;/th>
&lt;th>NeMo Guardrails&lt;/th>
&lt;th>Guardrails AI&lt;/th>
&lt;/tr>
&lt;/thead>
&lt;tbody>
&lt;tr>
&lt;td>&lt;strong>Conceptual model&lt;/strong>&lt;/td>
&lt;td>Pipeline of compact scanners&lt;/td>
&lt;td>Declarative Colang graph (conversational flow)&lt;/td>
&lt;td>JSON contract-style validators&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>&lt;strong>Dominant detection&lt;/strong>&lt;/td>
&lt;td>Specialised ML models (BERT, DeBERTa) per category&lt;/td>
&lt;td>Rules + LLM-as-judge&lt;/td>
&lt;td>Heuristic validators + external LLM-as-judge&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>&lt;strong>PII workflow&lt;/strong>&lt;/td>
&lt;td>Anonymize + Vault + Deanonymize&lt;/td>
&lt;td>Via integrated Presidio, no built-in Vault&lt;/td>
&lt;td>PII validators, no automatic restitution&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>&lt;strong>Licence&lt;/strong>&lt;/td>
&lt;td>MIT&lt;/td>
&lt;td>Apache 2.0&lt;/td>
&lt;td>Apache 2.0 (+ paid Hub)&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>&lt;strong>Language&lt;/strong>&lt;/td>
&lt;td>Python&lt;/td>
&lt;td>Python + Colang DSL&lt;/td>
&lt;td>Python&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>&lt;strong>API maturity&lt;/strong>&lt;/td>
&lt;td>Built-in FastAPI API, built-in OTel&lt;/td>
&lt;td>Built-in FastAPI server, partial OTel&lt;/td>
&lt;td>External API server&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>&lt;strong>Cluster deployment&lt;/strong>&lt;/td>
&lt;td>Lib + API + sidecar + gateway plugins&lt;/td>
&lt;td>Lib + server&lt;/td>
&lt;td>Lib + server + SaaS Hub&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>&lt;strong>Typical latency (5 ONNX-GPU scanners)&lt;/strong>&lt;/td>
&lt;td>50-200 ms&lt;/td>
&lt;td>100-500 ms (more if there is an LLM judge)&lt;/td>
&lt;td>100-300 ms (depends on the validator)&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>&lt;strong>When it shines&lt;/strong>&lt;/td>
&lt;td>Apps with heavy PII, multi-tenant with sessions, GDPR/HIPAA requirements&lt;/td>
&lt;td>Conversational systems with defined flows, agents with a dialogue policy&lt;/td>
&lt;td>Apps with strict JSON contracts, structured output with additional validation&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>&lt;strong>When it does not fit&lt;/strong>&lt;/td>
&lt;td>If you need a declarative dialogue policy&lt;/td>
&lt;td>If you want compact detectors with no LLM judge&lt;/td>
&lt;td>If you want an automatic Vault and Deanonymize&lt;/td>
&lt;/tr>
&lt;/tbody>
&lt;/table>
&lt;p>The three are &lt;strong>complementary in large deployments&lt;/strong>. A mature pattern in 2026 is:&lt;/p>
&lt;ul>
&lt;li>&lt;strong>NeMo Guardrails&lt;/strong> orchestrates the dialogue flow (which tools the agent can invoke, in what order, with what cooldowns).&lt;/li>
&lt;li>&lt;strong>LLM Guard&lt;/strong> occupies the PII line + compact scanners on input and output, with its Vault doing the dirty work of anonymisation.&lt;/li>
&lt;li>&lt;strong>Guardrails AI&lt;/strong> validates structured outputs (JSON Schema, function calling) with its validators.&lt;/li>
&lt;/ul>
&lt;p>The separation of responsibilities avoids overlap and allows pieces to be swapped without rewriting everything. All three expose a FastAPI API and emit OTel spans; the AI gateway orchestrates them sequentially.&lt;/p>
&lt;h2 id="applied-to-on-premise-hardware">Applied to on-premise hardware&lt;/h2>
&lt;h3 id="on-the-rtx-4090-24-gb">On the RTX 4090 (24 GB)&lt;/h3>
&lt;p>A 4090 dedicated to the LLM Guard service pod comfortably serves the complete pipeline in medium-scale production:&lt;/p>
&lt;ul>
&lt;li>Anonymize (BERT-NER ONNX-INT8): ~50 MB VRAM.&lt;/li>
&lt;li>PromptInjection (DeBERTa ONNX-INT8): ~90 MB.&lt;/li>
&lt;li>Toxicity, Sentiment, Gibberish: ~150 MB total.&lt;/li>
&lt;li>BanTopics (BART-MNLI ONNX-INT8): ~200 MB.&lt;/li>
&lt;li>Bias, Relevance, FactualConsistency (output): ~250 MB total.&lt;/li>
&lt;/ul>
&lt;p>Total ~750 MB. The rest of the VRAM is idle or usable for aggressive batching. Sustained throughput of 3,000-6,000 RPS over the complete pipeline. For deployments with &amp;lt; 500 sustained RPS, the 4090 is under-used and can be shared with another workload (RAG embeddings, BGE reranker).&lt;/p>
&lt;h3 id="on-the-4h100-sxm-cluster-320-gb-total-nvlink">On the 4×H100 SXM cluster (320 GB total, NVLink)&lt;/h3>
&lt;p>There is capacity to spare by an order of magnitude. Sensible pattern:&lt;/p>
&lt;ul>
&lt;li>&lt;strong>3 H100s&lt;/strong> serving the main LLM at TP=3 (Llama 70B FP8).&lt;/li>
&lt;li>&lt;strong>1 H100 split into MIG instances&lt;/strong> (1g.10gb or similar) — one slice for LLM Guard (~10 GB MIG is more than enough), another for the reranker, another for embeddings.&lt;/li>
&lt;/ul>
&lt;p>Aggregate throughput for LLM Guard at that scale: 15,000-30,000 RPS. Plenty for a large multi-tenant setup with long sessions.&lt;/p>
&lt;h2 id="the-specific-operational-pitfalls">The specific operational pitfalls&lt;/h2>
&lt;p>&lt;strong>Pitfall 1 — Vault with no TTL.&lt;/strong> The Vault grows without limit if it is not cleaned. In in-process lib mode per request there is no problem (the object is destroyed). In centralised service mode with Redis, &lt;strong>the TTL is missing&lt;/strong> and Redis fills up. A silent trap discovered when the Redis pod gets OOM-killed in production after six weeks.&lt;/p>
&lt;p>&lt;strong>Pitfall 2 — Vault not shared between pods + AI gateway with no sticky session.&lt;/strong> If the AI gateway distributes round-robin across multiple LLM Guard pods, one pod&amp;rsquo;s local Vault knows nothing of the mapping created by another. Result: on turn 2 of a session, Deanonymize does not find the placeholders from turn 1 and leaves a literal &lt;code>[REDACTED_PERSON_1]&lt;/code> in the answer. Solution: a shared Redis Vault &lt;strong>or&lt;/strong> a sticky session by user_id.&lt;/p>
&lt;p>&lt;strong>Pitfall 3 — Models not exported to ONNX in production.&lt;/strong> It is deployed with the default config (Transformers) and latency is 3-5× worse than the benchmarks report. The team assumes LLM Guard &amp;ldquo;is slow&amp;rdquo;. The solution is to export to ONNX (built into the project) and configure &lt;code>recognizer_conf&lt;/code> with the path to the model&amp;rsquo;s &lt;code>.onnx&lt;/code>.&lt;/p>
&lt;p>&lt;strong>Pitfall 4 — &lt;code>fail_fast=False&lt;/code> with many scanners.&lt;/strong> Without &lt;code>fail_fast&lt;/code>, every scanner always runs, even if the first one already blocked. Latency is 3-5× worse on adversarial traffic. For production, barring an explicit reason (wanting complete per-scanner metrics even when blocking), &lt;code>fail_fast=True&lt;/code> is the reasonable default.&lt;/p>
&lt;p>&lt;strong>Pitfall 5 — Infinite &lt;code>cache_ttl&lt;/code> + prompts with variable PII.&lt;/strong> If the API&amp;rsquo;s cache stores the &lt;code>sanitized_prompt&lt;/code> indefinitely, two different sessions with the same prompt structure but different PII can collide if the cache key does not include the Vault hash. You have to verify that the cache key includes either the complete content (without PII) or a hash of the original prompt.&lt;/p>
&lt;p>&lt;strong>Pitfall 6 — Structured logs with original PII.&lt;/strong> LLM Guard&amp;rsquo;s JSON stdout logs record only placeholders by default. But if custom hooks are added for debugging, it is easy to leak the original PII to the log. A regulatory audit (GDPR, ENS) detects this and it is a breach. Discipline: never add hooks that read from the Vault without explicit permission.&lt;/p>
&lt;p>&lt;strong>Pitfall 7 — &lt;code>scan_output&lt;/code> without the original &lt;code>prompt&lt;/code>.&lt;/strong> The &lt;code>scan_output&lt;/code> method expects (&lt;code>prompt&lt;/code>, &lt;code>output&lt;/code>) for validators that compare both (Relevance, LanguageSame, FactualConsistency). If only the output is passed, those scanners fail silently or return &lt;code>is_valid=True&lt;/code> by default. You have to keep the &lt;code>sanitized_prompt&lt;/code> in the AI gateway and pass it to scan_output.&lt;/p>
&lt;h2 id="when-to-choose-llm-guard-and-when-not-to">When to choose LLM Guard (and when not to)&lt;/h2>
&lt;p>&lt;strong>Choose LLM Guard when&lt;/strong>:&lt;/p>
&lt;ul>
&lt;li>The requirement for &lt;strong>PII anonymisation with automatic restitution&lt;/strong> is on the list. It is the number one reason to use it. Banking, healthcare, legal advice, HR — any case with heavy PII that must not reach the LLM even when it is local.&lt;/li>
&lt;li>You want a &lt;strong>Pythonic pipeline&lt;/strong> with no new DSL. If the team is pure Python and prefers composing scanners as objects to learning Colang.&lt;/li>
&lt;li>The stack already has an &lt;strong>AI gateway&lt;/strong> (LiteLLM, Envoy AI, Kong AI) and it integrates as a plugin without touching the app.&lt;/li>
&lt;li>You need &lt;strong>built-in OTel and Prometheus&lt;/strong> with no additional instrumentation.&lt;/li>
&lt;/ul>
&lt;p>&lt;strong>Do not choose LLM Guard when&lt;/strong>:&lt;/p>
&lt;ul>
&lt;li>The system is a &lt;strong>conversational agent with complex dialogue flows&lt;/strong> (policies, fallbacks, escalation to a human). There NeMo Guardrails with Colang is structurally better.&lt;/li>
&lt;li>The safety layer reduces to &lt;strong>validating structured outputs&lt;/strong> (JSON, function calling). Guardrails AI with its validators is more natural.&lt;/li>
&lt;li>Your &lt;strong>latency budget is ultra-aggressive&lt;/strong> (&amp;lt; 30 ms for the whole layer). You will have to cut scanners and accept lower coverage; perhaps a single PromptGuard 2 + Presidio in a sidecar (the pattern from the &lt;a href="https://blog.lo0.es/en/posts/llm-guardrails-safety-four-lines-of-defence/">guardrails post&lt;/a>) is simpler.&lt;/li>
&lt;li>You do not want to carry &lt;strong>the operational weight of a distributed Vault&lt;/strong> (Redis, TTL, sticky session). For small systems with no strong PII requirement, it is over-engineered.&lt;/li>
&lt;/ul>
&lt;h2 id="what-we-have-not-covered-upcoming-posts">What we have not covered (upcoming posts)&lt;/h2>
&lt;ul>
&lt;li>&lt;strong>Custom scanners&lt;/strong>: how to write your own scanner when nothing in the catalogue fits (a complex domain regex, your own fine-tuned classifier). The project supports custom scanners by inheriting from &lt;code>InputScanner&lt;/code> / &lt;code>OutputScanner&lt;/code> with three methods.&lt;/li>
&lt;li>&lt;strong>Integration with SLSA / supply chain&lt;/strong>: how to sign the LLM Guard container with cosign, SLSA attestations, and verification in the cluster before admitting it. An operational &lt;a href="https://blog.lo0.es/en/posts/llm-guardrails-safety-four-lines-of-defence/">supply chain security&lt;/a> topic (OWASP LLM03).&lt;/li>
&lt;li>&lt;strong>Red teaming against LLM Guard&lt;/strong>: known techniques that evade detectors (homoglyphs, Unicode confusables, base64 encoding inside the prompt). The project publishes an adversarial test suite for doing your own benchmarking. How it is set up as a continuous gate in CI.&lt;/li>
&lt;li>&lt;strong>Comparative benchmark against Bedrock Guardrails and Azure AI Content Safety&lt;/strong>: F1 per category over real traffic across three different deployments. The &lt;a href="https://blog.lo0.es/en/posts/parallel-catalogue-llmops-stages-open-source-hyperscalers/">OSS vs hyperscalers post&lt;/a> has the strategic comparison; the technical detection comparison is still missing.&lt;/li>
&lt;/ul>
&lt;h2 id="references">References&lt;/h2>
&lt;ul>
&lt;li>&lt;strong>LLM Guard (Protect AI)&lt;/strong>: &lt;a href="https://llm-guard.com">https://llm-guard.com&lt;/a> — official documentation, scanner list, benchmarks.&lt;/li>
&lt;li>&lt;strong>Repository&lt;/strong>: &lt;a href="https://github.com/protectai/llm-guard">https://github.com/protectai/llm-guard&lt;/a>.&lt;/li>
&lt;li>&lt;strong>LLM Guard API&lt;/strong>: &lt;a href="https://github.com/protectai/llm-guard/tree/main/llm_guard_api">https://github.com/protectai/llm-guard/tree/main/llm_guard_api&lt;/a>.&lt;/li>
&lt;li>&lt;strong>Presidio (Microsoft)&lt;/strong>: &lt;a href="https://microsoft.github.io/presidio/">https://microsoft.github.io/presidio/&lt;/a> — basis of the Anonymize scanner.&lt;/li>
&lt;li>&lt;strong>detect-secrets (Yelp)&lt;/strong>: &lt;a href="https://github.com/Yelp/detect-secrets">https://github.com/Yelp/detect-secrets&lt;/a> — basis of the Secrets scanner.&lt;/li>
&lt;li>&lt;strong>Langfuse OTel ingestion&lt;/strong>: &lt;a href="https://langfuse.com/docs/opentelemetry/get-started">https://langfuse.com/docs/opentelemetry/get-started&lt;/a>.&lt;/li>
&lt;li>&lt;strong>LiteLLM guardrails&lt;/strong>: &lt;a href="https://docs.litellm.ai/docs/proxy/guardrails">https://docs.litellm.ai/docs/proxy/guardrails&lt;/a>.&lt;/li>
&lt;li>&lt;strong>Envoy AI Gateway&lt;/strong>: &lt;a href="https://aigateway.envoyproxy.io">https://aigateway.envoyproxy.io&lt;/a>.&lt;/li>
&lt;li>&lt;strong>Kong AI Gateway&lt;/strong>: &lt;a href="https://docs.konghq.com/hub/kong-inc/ai-prompt-guard/">https://docs.konghq.com/hub/kong-inc/ai-prompt-guard/&lt;/a>.&lt;/li>
&lt;li>&lt;strong>OWASP Top 10 for LLM Applications 2025&lt;/strong>: &lt;a href="https://owasp.org/www-project-top-10-for-large-language-model-applications/">https://owasp.org/www-project-top-10-for-large-language-model-applications/&lt;/a>.&lt;/li>
&lt;li>&lt;strong>ONNX Runtime&lt;/strong>: &lt;a href="https://onnxruntime.ai">https://onnxruntime.ai&lt;/a> — exporting HF models to ONNX for acceleration.&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/hardening-secrets-sovereign-llm-stack-defence-in-depth/">Hardening and secrets of the sovereign LLM stack: defence in depth&lt;/a> — guardrails as one more layer of the stack&amp;rsquo;s defence in depth.&lt;/p>
&lt;/li>
&lt;li>
&lt;p>&lt;a href="https://blog.lo0.es/en/posts/llm-guardrails-safety-four-lines-of-defence/">Guardrails and safety in LLMs: the four lines of defence&lt;/a> — the framework that places LLM Guard as one of the tools within the layer. That post explains the four lines (input, retrieval, tool, output), the OWASP LLM Top 10 and compares NeMo Guardrails, Llama Guard 4, ShieldGemma, Granite Guardian, PromptGuard 2 and LLM Guard from a bird&amp;rsquo;s-eye view.&lt;/p>
&lt;/li>
&lt;li>
&lt;p>&lt;a href="https://blog.lo0.es/en/posts/oss-catalogue-llmops-six-stages-tool-by-tool/">The OSS catalogue for LLMOps in six stages&lt;/a> — an extended write-up of LLM Guard among the rest of the OSS tools by pipeline stage.&lt;/p>
&lt;/li>
&lt;li>
&lt;p>&lt;a href="https://blog.lo0.es/en/posts/rag-corpus-curation-active-librarian/">RAG corpus curation: the active librarian&lt;/a> — prevention at ingest shares the Presidio PII detector with LLM Guard; the Vault pattern is the new piece added at runtime.&lt;/p>
&lt;/li>
&lt;li>
&lt;p>&lt;a href="https://blog.lo0.es/en/posts/llm-tracing-opentelemetry-genai/">LLM tracing with OpenTelemetry GenAI&lt;/a> — the OTel plane over which LLM Guard emits the &lt;code>gen_ai.guardrail.*&lt;/code> spans that Langfuse and Tempo consume.&lt;/p>
&lt;/li>
&lt;li>
&lt;p>&lt;a href="https://blog.lo0.es/en/posts/prompt-versioning-contract-five-word-change/">Prompt versioning with Langfuse and MLflow&lt;/a> — the &lt;code>prompt_id+version&lt;/code> travels as a span attribute even when the prompt content is anonymised; it complements this post&amp;rsquo;s PII shielding.&lt;/p>
&lt;/li>
&lt;li>
&lt;p>&lt;a href="https://blog.lo0.es/en/posts/evals-llm-layer-after-tracing/">Evals for LLMs: the layer after tracing&lt;/a> — LLM Guard&amp;rsquo;s offline counterpart. When a scanner reports a high FP rate over real traffic, the offline exercise against an annotated golden set identifies whether to tune the threshold or change the backend model.&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> — the HIGH severity incidents that LLM Guard emits with &lt;code>risk_score &amp;gt; threshold&lt;/code> feed the incident-driven retrain loop.&lt;/p>
&lt;/li>
&lt;li>
&lt;p>&lt;a href="https://blog.lo0.es/en/posts/parallel-catalogue-llmops-stages-open-source-hyperscalers/">OSS vs hyperscalers in LLMOps&lt;/a> — the OSS column of the &amp;ldquo;Guardrails&amp;rdquo; row (NeMo + Presidio + Llama Guard 4 + &lt;strong>LLM Guard&lt;/strong>) against Bedrock Guardrails, Azure AI Content Safety and Vertex Model Armor.&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: function calling and constrained decoding&lt;/a> — LLM Guard&amp;rsquo;s JSON scanner validates the output structure as a safety net when the inference engine has already done constrained decoding.&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 Guardrails (this post included) is the online counterpart of the Eval stage.&lt;/p>
&lt;/li>
&lt;/ul></description></item><item><title>Structured output: the form with dropdowns that strikes out invalid answers before the model picks one — Outlines, XGrammar, LLGuidance and the maths of the bitmask</title><link>https://blog.lo0.es/en/posts/structured-output-bitmask-outlines-xgrammar-llguidance/</link><pubDate>Sat, 30 May 2026 16:30:00 +0200</pubDate><guid>https://blog.lo0.es/en/posts/structured-output-bitmask-outlines-xgrammar-llguidance/</guid><description>&lt;blockquote>
&lt;p>This post complements those on &lt;a href="https://blog.lo0.es/en/posts/continuous-batching-orca-vllm-chunked-prefill-goodput/">Continuous batching&lt;/a> (where the scheduler lives) and &lt;a href="https://blog.lo0.es/en/posts/speculative-decoding-fundamentals-maths-state-of-play/">Speculative decoding&lt;/a> (another technique operating on the last mile of the sampler). Structured output is the LLM&amp;rsquo;s output contract towards the code that consumes it; without it, the integration between an LLM and applications is fragile by default.&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(#som)}.cyc{stroke:#888;stroke-width:1.2;fill:none;stroke-dasharray:4 2;marker-end:url(#som)}&lt;/style>
&lt;defs>&lt;marker id="som" 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 · constraint on the logits, the sampler's last layer&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>An LLM produces free text, but many applications (function calling, entity extraction, routing, text-to-SQL, config generation) need to parse it as JSON, as a tool call with typed args, as a valid SQL statement, or as one option from an enum. The naive solutions fail: prompt engineering (&amp;ldquo;answer in JSON&amp;rdquo;) leaves &lt;strong>25 %&lt;/strong> of outputs unparseable in many models; post-hoc validation plus retry costs latency and does not guarantee termination; json-repair libraries are heuristic patches. &lt;strong>Constrained decoding&lt;/strong> guarantees conformance 100 %, by construction: at every generation step, before sampling from the softmax over the &lt;code>V&lt;/code> vocabulary tokens, the tokens that would break the target structure are masked to -∞. The output is valid by mathematical contract, not by luck. The four dominant families in May 2026 are &lt;strong>Outlines&lt;/strong> (Willard &amp;amp; Louf, 2023; FSM + token trie precomputed from regex/JSON Schema/CFG), &lt;strong>XGrammar&lt;/strong> (Dong et al., 2024-25, CMU+MLC; byte-level pushdown automaton with an adaptive cache of &lt;em>context-independent&lt;/em> tokens, the default in vLLM v1, SGLang, TensorRT-LLM, NIM and MLC-LLM), &lt;strong>LLGuidance&lt;/strong> (Microsoft Research; Earley parser + regex derivatives, ~50 µs of CPU per token, underneath OpenAI Structured Outputs) and &lt;strong>LM Format Enforcer&lt;/strong> (noamgat; aimed at JSON Schema, integrated in many engines as a fallback). &lt;strong>XGrammar-2&lt;/strong> (May 2026) introduces Structural Tag and a cross-grammar cache for dynamic agentic tool calling. The real cost when well integrated: &lt;strong>&amp;lt;5 %&lt;/strong> in TPOT and ~40 µs of CPU per token of mask computation, partially overlappable with the model&amp;rsquo;s forward pass. The open operational question: &lt;em>does it degrade reasoning?&lt;/em> The paper &lt;em>Let me Speak Freely?&lt;/em> (Tam et al., EMNLP 2024) reported significant degradation in reasoning under format constraints; the dottxt rebuttal showed that the effect came from prompts that were not equivalent between experiments. The emerging consensus in May 2026: use &lt;strong>two-pass&lt;/strong> (free reasoning with CoT in text plus structured output in a second call) for tasks that require multi-step reasoning; use single-pass constrained for extraction, classification and function calling, where forced structure improves exactness and reduces hallucinations. This post takes apart the mechanism, the maths (bitmask size = V/8 bytes, per-step latency), the comparison table of backends, the pitfalls (compile time, tokenizer-specific FSM, streaming SSE) and the production deployment pattern.&lt;/p>
&lt;h2 id="the-analogy-the-form-with-dropdowns-instead-of-free-text-fields">The analogy: the form with dropdowns instead of free-text fields&lt;/h2>
&lt;p>Imagine two ways of asking someone to fill in a form.&lt;/p>
&lt;p>The &lt;strong>first way&lt;/strong> is to hand them the paper with blank fields and say &amp;ldquo;fill it in with this exact format: the name goes here, then the ID number without spaces, then the date as YYYY-MM-DD, then mark one of these five possible reasons separated by semicolons&amp;rdquo;. You explain the format in exhaustive detail, but the person is still free to write whatever they like in each field. If they are in a hurry they may drop a zero from the ID number, put the date in the American format, tick two reasons when only one was asked for. When you receive the form, you often have to send it back: field X badly formatted, reason Y invalid, date Z impossible. This is exactly what an LLM does when you ask it to &amp;ldquo;answer in JSON with this schema&amp;rdquo;: it works most of the time, it fails a non-negligible percentage, and you have no formal guarantee of anything.&lt;/p>
&lt;p>The &lt;strong>second way&lt;/strong> is to give them an electronic form where the fields &lt;strong>are not free text&lt;/strong>. The name accepts any text, but the ID number has a mask that only lets you type eight digits followed by a letter; the date is a picker that only lets you choose valid dates; the reasons are a dropdown with five closed options. The person can type whatever they want, but the form does not accept invalid characters at any point. The result is parseable by construction: when you receive the completed form, the ID number has exactly the expected format, the date is a real date, the reason is one of the five. This is &lt;strong>constrained decoding&lt;/strong>.&lt;/p>
&lt;p>The analogy holds on four mappings:&lt;/p>
&lt;ul>
&lt;li>&lt;strong>The person filling it in&lt;/strong> = the LLM producing logits over the vocabulary.&lt;/li>
&lt;li>&lt;strong>The characters the form lets you type in each field&lt;/strong> = the bitmask applied to the logits before sampling.&lt;/li>
&lt;li>&lt;strong>How the form knows which characters to allow depending on which field you are in&lt;/strong> = the automaton (FSM or PDA) that holds the current state of the grammar.&lt;/li>
&lt;li>&lt;strong>The fact that the dropdown is precomputed when the page loads rather than recomputed each time&lt;/strong> = the precomputed table of valid tokens per FSM state, which makes the per-step cost amortised O(1).&lt;/li>
&lt;/ul>
&lt;h2 id="the-problem-structured-output-solves">The problem structured output solves&lt;/h2>
&lt;p>The operational problem is the contract between the LLM and the code that consumes its output. There are three naive approaches, all with documented failures:&lt;/p>
&lt;p>&lt;strong>Pure prompt engineering.&lt;/strong> &amp;ldquo;Answer only with valid JSON, no comments or prose&amp;rdquo;. It works most of the time for good models; it fails between 5 % and 25 % of the time depending on the model, the temperature, the complexity of the schema and the length of the output. The model adds a trailing comma, escapes a quote badly, wraps the JSON in a &lt;code>```json&lt;/code> markdown block, hallucinates a field that was not in the schema, ignores a required field. SqueezeBits measures a ≤72 % correct rate without constraining for some models on JSON Schemas of moderate complexity.&lt;/p>
&lt;p>&lt;strong>Post-hoc validation plus retry.&lt;/strong> The server receives the output, tries to parse it, and if that fails returns the error to the model and asks it to try again. Cost: 2-3× latency in the worst case (typically 2-3 retries before giving up), no guarantee of termination, noise in the logs, hard to test deterministically.&lt;/p>
&lt;p>&lt;strong>JSON repair libraries&lt;/strong> (&lt;code>json_repair&lt;/code>, the &lt;code>fast-json-repair&lt;/code> Rust port). Heuristic patches for common errors: trailing commas, missing quotes, prose interleaved with JSON. Useful as a &lt;strong>fallback&lt;/strong> for approach 2; they are not a contract.&lt;/p>
&lt;p>The accumulated operational cost: latency inflated by retries, noise in production, painful debugging of intermittent parse errors, broken contracts with downstream clients that assumed clean parsing.&lt;/p>
&lt;h2 id="constrained-decoding-the-principle">Constrained decoding: the principle&lt;/h2>
&lt;p>At each decode step, the model produces a vector &lt;code>logits ∈ R^V&lt;/code> where &lt;code>V&lt;/code> is the vocabulary size (Llama 3: 128,256, GPT-4o: ~200,000, Qwen 3: 152,064). The conventional sampler applies softmax plus a sampling strategy (greedy, top-k, top-p, temperature) over the &lt;code>V&lt;/code> tokens.&lt;/p>
&lt;p>Constrained decoding interposes one operation before the softmax: it applies a bitmask that sets to -∞ the logits of the tokens that would violate the target grammar at this step. The result: the softmax only assigns probability mass to admissible tokens; sampling, whatever its strategy, can only pick a valid one.&lt;/p>
&lt;p>The two operational questions are always the same:&lt;/p>
&lt;ol>
&lt;li>&lt;strong>How do you know which tokens are valid at each step?&lt;/strong> → hold the current state of an automaton (FSM/PDA) built from the grammar; look up the table &lt;code>(state → set of valid tokens)&lt;/code>.&lt;/li>
&lt;li>&lt;strong>How much does it cost to compute and apply the mask?&lt;/strong> → this is where Outlines, XGrammar, LLGuidance and LM Format Enforcer compete.&lt;/li>
&lt;/ol>
&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="Constrained decoding flow">
&lt;style>
.box{fill:#fff;stroke:#444;stroke-width:1.4;rx:6}
.logits{fill:#fff4d6;stroke:#a48000;stroke-width:1.4;rx:6}
.mask{fill:#f6caca;stroke:#a52a2a;stroke-width:1.4;rx:6}
.fsm{fill:#e6d0ff;stroke:#5a2db0;stroke-width:1.4;rx:6}
.smpl{fill:#cdebd0;stroke:#2a7a40;stroke-width:1.4;rx:6}
.tok{fill:#d4ecff;stroke:#1f5fa8;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(#som1)}
.dotted{stroke:#999;stroke-width:1.2;fill:none;stroke-dasharray:4 2;marker-end:url(#som1)}
&lt;/style>
&lt;defs>&lt;marker id="som1" 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. The model produces logits over the V vocabulary tokens&lt;/text>
&lt;rect x="20" y="30" width="200" height="50" class="logits"/>
&lt;text x="120" y="50" text-anchor="middle" class="lbl">logits ∈ R^V&lt;/text>
&lt;text x="120" y="68" text-anchor="middle" class="sub">[2.1, 5.3, -1.2, 8.7, 0.4, ..., 3.1]&lt;/text>
&lt;text x="260" y="22" class="lbl">2. The automaton knows what state we are in (in this JSON)&lt;/text>
&lt;rect x="260" y="30" width="240" height="50" class="fsm"/>
&lt;text x="380" y="46" text-anchor="middle" class="lbl">FSM / PDA state&lt;/text>
&lt;text x="380" y="62" text-anchor="middle" class="sub">waiting: opening quote of a string&lt;/text>
&lt;text x="380" y="76" text-anchor="middle" class="sub">(after the "name": field)&lt;/text>
&lt;path class="arr" d="M500,55 L540,55"/>
&lt;text x="540" y="22" class="lbl">3. Precomputed table&lt;/text>
&lt;rect x="540" y="30" width="220" height="50" class="box"/>
&lt;text x="650" y="46" text-anchor="middle" class="lbl">cache: state → valid tokens&lt;/text>
&lt;text x="650" y="62" text-anchor="middle" class="sub">{ '"', ' ', '\t', '\n' }&lt;/text>
&lt;text x="650" y="76" text-anchor="middle" class="sub">rest of vocab → -∞&lt;/text>
&lt;path class="arr" d="M650,80 L650,120 L380,120"/>
&lt;text x="260" y="140" class="lbl">4. Resulting bitmask (16 KB for Llama 3, V=128256)&lt;/text>
&lt;rect x="260" y="148" width="240" height="40" class="mask"/>
&lt;text x="380" y="170" text-anchor="middle" class="lbl">bitmask: 0..010..010..0..0&lt;/text>
&lt;text x="380" y="184" text-anchor="middle" class="sub">1 = allowed, 0 = set to -∞&lt;/text>
&lt;path class="arr" d="M120,80 L120,200"/>
&lt;path class="arr" d="M380,188 L380,200"/>
&lt;text x="20" y="222" class="lbl">5. logits + bitmask → constrained logits → softmax → sampling&lt;/text>
&lt;rect x="20" y="230" width="500" height="50" class="smpl"/>
&lt;text x="270" y="252" text-anchor="middle" class="lbl">apply_token_bitmask_inplace(logits, bitmask)&lt;/text>
&lt;text x="270" y="268" text-anchor="middle" class="sub">softmax → top-k / top-p / greedy → sampled token&lt;/text>
&lt;path class="arr" d="M520,255 L560,255"/>
&lt;rect x="560" y="230" width="180" height="50" class="tok"/>
&lt;text x="650" y="252" text-anchor="middle" class="lbl">token = '"'&lt;/text>
&lt;text x="650" y="268" text-anchor="middle" class="sub">(valid by construction)&lt;/text>
&lt;path class="dotted" d="M650,280 L650,300 L380,300 L380,82"/>
&lt;text x="395" y="297" class="sub">6. update FSM/PDA state with the chosen token → next step&lt;/text>
&lt;/svg>
&lt;/div>
&lt;h2 id="the-four-families-of-backends">The four families of backends&lt;/h2>
&lt;table>
&lt;thead>
&lt;tr>
&lt;th>Backend&lt;/th>
&lt;th>Origin&lt;/th>
&lt;th>Algorithm&lt;/th>
&lt;th>Grammar formats&lt;/th>
&lt;th>Default in&lt;/th>
&lt;th>Notes&lt;/th>
&lt;/tr>
&lt;/thead>
&lt;tbody>
&lt;tr>
&lt;td>Outlines&lt;/td>
&lt;td>dottxt (2023)&lt;/td>
&lt;td>FSM + precomputed token trie&lt;/td>
&lt;td>regex, JSON Schema, Lark CFG&lt;/td>
&lt;td>HF TGI&lt;/td>
&lt;td>The Rust version (outlines-core) closes part of the gap with XGrammar&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>XGrammar&lt;/td>
&lt;td>CMU + MLC (2024-25)&lt;/td>
&lt;td>Byte-level PDA + adaptive ctx-indep/dep cache&lt;/td>
&lt;td>regex, JSON Schema, EBNF/CFG&lt;/td>
&lt;td>vLLM v1, SGLang, TensorRT-LLM, NIM, MLC-LLM&lt;/td>
&lt;td>Speedup vs naive: up to 100× CFG, 3× JSON; &amp;lt;40 µs/token JSON&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>LLGuidance&lt;/td>
&lt;td>Microsoft Research&lt;/td>
&lt;td>Earley parser + regex derivatives&lt;/td>
&lt;td>regex, JSON Schema, Lark&lt;/td>
&lt;td>OpenAI Structured Outputs (internally), Chromium&lt;/td>
&lt;td>~50 µs CPU/token; underneath Guidance, llama.cpp, mistral.rs&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>LM Format Enforcer&lt;/td>
&lt;td>noamgat&lt;/td>
&lt;td>char-level parser + tokenizer prefix tree&lt;/td>
&lt;td>JSON Schema, regex&lt;/td>
&lt;td>(deprecated default in many engines, fallback in NIM)&lt;/td>
&lt;td>Slower than XGrammar (3.5× worse JSON, 10× worse CFG)&lt;/td>
&lt;/tr>
&lt;/tbody>
&lt;/table>
&lt;p>Three operational observations:&lt;/p>
&lt;ol>
&lt;li>&lt;strong>XGrammar is the de facto default in May 2026&lt;/strong> across the open-source ecosystem (vLLM v1, SGLang, TensorRT-LLM, NIM, MLC-LLM). Its byte-level PDA with an adaptive cache gives it almost zero overhead when well integrated.&lt;/li>
&lt;li>&lt;strong>LLGuidance is the least known but most used piece on the market&lt;/strong>, because it sits underneath OpenAI Structured Outputs (confirmed in the README of the repo itself). 50 µs of CPU per token for 128k tokenizers.&lt;/li>
&lt;li>&lt;strong>Outlines was the first&lt;/strong>, it still holds the conceptual mindshare (papers, canonical blog posts), but it lost operational ground to XGrammar. The Rust version (outlines-core) closes part of the gap.&lt;/li>
&lt;/ol>
&lt;h2 id="xgrammar-in-detail-what-sits-under-vllm-and-sglang">XGrammar in detail (what sits under vLLM and SGLang)&lt;/h2>
&lt;p>The paper by Dong, Yin, Ruan and Chen (arXiv:2411.15100, November 2024) introduces a partitioning technique that is key to understanding why XGrammar is 3-100× faster than the alternatives.&lt;/p>
&lt;p>&lt;strong>Partitioning the vocabulary at each PDA state&lt;/strong>:&lt;/p>
&lt;ul>
&lt;li>&lt;em>Context-independent&lt;/em> tokens (~99 % of the vocab in a typical JSON Schema): their validity is decided &lt;strong>only&lt;/strong> by the current PDA position, without needing to inspect the full stack. These are precomputable and stored as bitmasks in a cache.&lt;/li>
&lt;li>&lt;em>Context-dependent&lt;/em> tokens (~1 %): they require inspecting the PDA stack at runtime. They are handled case by case at a higher cost.&lt;/li>
&lt;/ul>
&lt;p>That partition is the fundamental reason why XGrammar works in production at low TPOT. 99 % of the lookups go to a precomputed table in O(1); only the residual 1 % pays the real cost.&lt;/p>
&lt;p>&lt;strong>Other combined techniques&lt;/strong>: a pushdown automaton for full CFGs (not just regex), a persistent stack for fast branching/rollback, JIT compilation plus an Earley parser in XGrammar-2.&lt;/p>
&lt;p>&lt;strong>Reported speedups (paper)&lt;/strong>:&lt;/p>
&lt;ul>
&lt;li>Up to &lt;strong>100×&lt;/strong> over previous solutions on CFG.&lt;/li>
&lt;li>&lt;strong>3×&lt;/strong> on JSON Schema versus Outlines.&lt;/li>
&lt;li>Latency &amp;lt;40 µs per token on JSON Schema; &amp;lt;200 µs on XML/Python DSL.&lt;/li>
&lt;li>When well integrated in vLLM/SGLang/TRT-LLM: near-zero end-to-end overhead.&lt;/li>
&lt;/ul>
&lt;p>&lt;strong>XGrammar-2&lt;/strong> (May 2026, arXiv:2601.04426) adds two pieces that matter for agents:&lt;/p>
&lt;ul>
&lt;li>Structural Tag: a composable JSON protocol that unifies OpenAI harmony, tool calling and reasoning channels.&lt;/li>
&lt;li>Cross-Grammar Cache for reuse at sub-structure level → it allows dynamic switching between sub-grammars in agentic loops without recompiling.&lt;/li>
&lt;li>6× faster compile time than XGrammar-1.&lt;/li>
&lt;/ul>
&lt;h2 id="the-maths-of-the-bitmask">The maths of the bitmask&lt;/h2>
&lt;p>Three numbers drive the operational decision.&lt;/p>
&lt;p>&lt;strong>Bitmask size per step.&lt;/strong> If the vocabulary has &lt;code>V&lt;/code> tokens, the bit-packed bitmask takes &lt;code>V/8&lt;/code> bytes:&lt;/p>
$$\text{bitmask size} = \frac{V}{8} \text{ bytes}$$
&lt;ul>
&lt;li>Llama 3 (V=128,256): 16 KB per bitmask per request per step.&lt;/li>
&lt;li>GPT-4o tokenizer o200k (V≈200,000): 25 KB.&lt;/li>
&lt;li>Llama 2 (V=32,000): 4 KB.&lt;/li>
&lt;/ul>
&lt;p>For a batch of 32 requests with structured output active on an H100, we are talking about ~512 KB of bitmasks per step, trivial against the GBs the forward pass moves.&lt;/p>
&lt;p>&lt;strong>Cost of applying the bitmask to the logits.&lt;/strong> A simple CUDA kernel, &lt;code>O(V)&lt;/code> complexity, ~5-10 µs of latency on an H100. Negligible.&lt;/p>
&lt;p>&lt;strong>Cost of computing the bitmask (CPU-side).&lt;/strong> This is where backends differ:&lt;/p>
&lt;table>
&lt;thead>
&lt;tr>
&lt;th>Backend&lt;/th>
&lt;th>CPU latency per token (Llama 3, V=128k)&lt;/th>
&lt;/tr>
&lt;/thead>
&lt;tbody>
&lt;tr>
&lt;td>LLGuidance&lt;/td>
&lt;td>~50 µs&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>XGrammar&lt;/td>
&lt;td>~40 µs (JSON Schema), ~200 µs (XML/DSL)&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>outlines-core&lt;/td>
&lt;td>comparable to XGrammar after 2024&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>Outlines Python (legacy)&lt;/td>
&lt;td>200-1000 µs&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>LM Format Enforcer&lt;/td>
&lt;td>intermediate, degrades with a large vocab&lt;/td>
&lt;/tr>
&lt;/tbody>
&lt;/table>
&lt;p>With a model forward pass on the order of 10-50 ms per token in decode, a 40-50 µs mask is &lt;strong>&amp;lt;0.5 % overhead&lt;/strong>, invisible. The operational key is that the CPU-side mask computation can be overlapped with the GPU-side forward pass: while the GPU computes the logits for token &lt;em>t&lt;/em>, the CPU precomputes the mask for token &lt;em>t+1&lt;/em> based on the FSM state after token &lt;em>t-1&lt;/em>. SGLang does this explicitly; vLLM v1 improved substantially over v0.&lt;/p>
&lt;p>&lt;strong>Compile-time cost.&lt;/strong> Precomputing the FSM/PDA and the cache:&lt;/p>
&lt;ul>
&lt;li>Simple schemas (1-5 fields): &amp;lt;100 ms.&lt;/li>
&lt;li>Deep JSON Schemas with many &lt;code>$defs&lt;/code>: seconds.&lt;/li>
&lt;li>OpenAI structured outputs: &amp;ldquo;10s typical, up to 1 minute for complex schemas&amp;rdquo;, cached after the first call.&lt;/li>
&lt;li>XGrammar-2: 6× faster than XGrammar-1.&lt;/li>
&lt;li>LLGuidance: ~2 ms startup.&lt;/li>
&lt;/ul>
&lt;p>&lt;strong>Operational best practice&lt;/strong>: pre-cache the known schemas when the server starts, to avoid latency spikes on the first request for each new schema.&lt;/p>
&lt;h2 id="does-it-degrade-the-models-reasoning">Does it degrade the model&amp;rsquo;s reasoning?&lt;/h2>
&lt;p>It is the most interesting question in the field and the answer is not obvious.&lt;/p>
&lt;p>&lt;strong>The theoretical argument for degradation&lt;/strong>: forcing the structure changes the model&amp;rsquo;s probability distribution; if the structured path pushes towards low-probability paths, the model gets &amp;ldquo;stuck&amp;rdquo; in a suboptimal branch with no room to explore.&lt;/p>
&lt;p>&lt;strong>The paper &lt;em>Let me Speak Freely?&lt;/em>&lt;/strong> (Tam et al., arXiv:2408.02442, EMNLP 2024 Industry Track) reported significant degradation on reasoning tasks under format constraints (JSON/XML/YAML). The stricter the format, the greater the degradation. The paper itself, however, acknowledged an improvement on classification tasks with forced structure.&lt;/p>
&lt;p>&lt;strong>The dottxt rebuttal, &lt;em>Say What You Mean&lt;/em>&lt;/strong> (official blog post): a methodological critique. The prompts in the original paper were different between structured and unstructured (not apples to apples). The JSON prompts in the original experiment gave less information than the unstructured ones. Re-running with equivalent prompts (Llama-3-8B-Instruct), dottxt does not reproduce the degradation. The conclusion: the paper confused the &lt;em>format constraint&lt;/em> with the &lt;em>prompt engineering&lt;/em> of that constraint.&lt;/p>
&lt;p>&lt;strong>Emerging consensus in May 2026&lt;/strong> (gathered from technical blogs and community empiricism):&lt;/p>
&lt;ul>
&lt;li>For extraction, classification, function calling and routing: constrained decoding improves exactness and reduces hallucinations. It is the right tool.&lt;/li>
&lt;li>For multi-step reasoning (maths, logic, code review): use &lt;strong>two-pass&lt;/strong>:
&lt;ol>
&lt;li>First call: free reasoning with Chain-of-Thought in natural text (generous &lt;code>max_tokens&lt;/code>, no constraint).&lt;/li>
&lt;li>Second call: pass it the reasoning plus the schema, and ask it to produce structured output with constrained decoding.&lt;/li>
&lt;/ol>
&lt;/li>
&lt;/ul>
&lt;p>The &amp;ldquo;reason then structure&amp;rdquo; pattern gives the best of both worlds: reasoning without a straitjacket plus an output guaranteed to be parseable.&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>Default backend&lt;/th>
&lt;th>Others available&lt;/th>
&lt;th>API&lt;/th>
&lt;/tr>
&lt;/thead>
&lt;tbody>
&lt;tr>
&lt;td>vLLM v1&lt;/td>
&lt;td>XGrammar (auto)&lt;/td>
&lt;td>Outlines, Guidance (llguidance), LM Format Enforcer&lt;/td>
&lt;td>&lt;code>guided_json&lt;/code>, &lt;code>guided_regex&lt;/code>, &lt;code>guided_choice&lt;/code>, &lt;code>guided_grammar&lt;/code> in the request&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>SGLang&lt;/td>
&lt;td>XGrammar&lt;/td>
&lt;td>Outlines, LLGuidance&lt;/td>
&lt;td>&lt;code>response_format.json_schema&lt;/code>, &lt;code>extra_body.regex&lt;/code>, &lt;code>extra_body.ebnf&lt;/code>&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>TensorRT-LLM&lt;/td>
&lt;td>XGrammar&lt;/td>
&lt;td>LLGTRT (Rust llguidance)&lt;/td>
&lt;td>Official integration since Jan 2025&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>NVIDIA NIM&lt;/td>
&lt;td>XGrammar (switched from Outlines in 2025)&lt;/td>
&lt;td>LM Format Enforcer (requires &lt;code>NIM_ENABLE_KV_CACHE_REUSE=0&lt;/code>)&lt;/td>
&lt;td>Pluggable multi-backend&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>llama.cpp&lt;/td>
&lt;td>Native GBNF&lt;/td>
&lt;td>LLGuidance&lt;/td>
&lt;td>Each candidate token is tested against the parse state&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>HF TGI&lt;/td>
&lt;td>Outlines&lt;/td>
&lt;td>XGrammar (experimental)&lt;/td>
&lt;td>&amp;ldquo;Guidance&amp;rdquo; feature with &lt;code>/generate&lt;/code> and &lt;code>/chat/completion&lt;/code> with &lt;code>tools&lt;/code>&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>MLC-LLM&lt;/td>
&lt;td>XGrammar (native, same team)&lt;/td>
&lt;td>—&lt;/td>
&lt;td>Its own API&lt;/td>
&lt;/tr>
&lt;/tbody>
&lt;/table>
&lt;p>vLLM example:&lt;/p>
&lt;div class="highlight">&lt;pre tabindex="0" class="chroma">&lt;code class="language-python" data-lang="python">&lt;span class="line">&lt;span class="cl">&lt;span class="kn">from&lt;/span> &lt;span class="nn">openai&lt;/span> &lt;span class="kn">import&lt;/span> &lt;span class="n">OpenAI&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="n">client&lt;/span> &lt;span class="o">=&lt;/span> &lt;span class="n">OpenAI&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="n">base_url&lt;/span>&lt;span class="o">=&lt;/span>&lt;span class="s2">&amp;#34;http://localhost:8000/v1&amp;#34;&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="n">api_key&lt;/span>&lt;span class="o">=&lt;/span>&lt;span class="s2">&amp;#34;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>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="n">response&lt;/span> &lt;span class="o">=&lt;/span> &lt;span class="n">client&lt;/span>&lt;span class="o">.&lt;/span>&lt;span class="n">chat&lt;/span>&lt;span class="o">.&lt;/span>&lt;span class="n">completions&lt;/span>&lt;span class="o">.&lt;/span>&lt;span class="n">create&lt;/span>&lt;span class="p">(&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="n">model&lt;/span>&lt;span class="o">=&lt;/span>&lt;span class="s2">&amp;#34;meta-llama/Llama-3.1-70B-Instruct&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="n">messages&lt;/span>&lt;span class="o">=&lt;/span>&lt;span class="p">[{&lt;/span>&lt;span class="s2">&amp;#34;role&amp;#34;&lt;/span>&lt;span class="p">:&lt;/span> &lt;span class="s2">&amp;#34;user&amp;#34;&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="s2">&amp;#34;content&amp;#34;&lt;/span>&lt;span class="p">:&lt;/span> &lt;span class="s2">&amp;#34;Extract name and age from: María is 34 years old&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="n">extra_body&lt;/span>&lt;span class="o">=&lt;/span>&lt;span class="p">{&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="s2">&amp;#34;guided_json&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="s2">&amp;#34;type&amp;#34;&lt;/span>&lt;span class="p">:&lt;/span> &lt;span class="s2">&amp;#34;object&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="s2">&amp;#34;properties&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="s2">&amp;#34;name&amp;#34;&lt;/span>&lt;span class="p">:&lt;/span> &lt;span class="p">{&lt;/span>&lt;span class="s2">&amp;#34;type&amp;#34;&lt;/span>&lt;span class="p">:&lt;/span> &lt;span class="s2">&amp;#34;string&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="s2">&amp;#34;age&amp;#34;&lt;/span>&lt;span class="p">:&lt;/span> &lt;span class="p">{&lt;/span>&lt;span class="s2">&amp;#34;type&amp;#34;&lt;/span>&lt;span class="p">:&lt;/span> &lt;span class="s2">&amp;#34;integer&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="p">},&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="s2">&amp;#34;required&amp;#34;&lt;/span>&lt;span class="p">:&lt;/span> &lt;span class="p">[&lt;/span>&lt;span class="s2">&amp;#34;name&amp;#34;&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="s2">&amp;#34;age&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="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;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>SGLang equivalent:&lt;/p>
&lt;div class="highlight">&lt;pre tabindex="0" class="chroma">&lt;code class="language-python" data-lang="python">&lt;span class="line">&lt;span class="cl">&lt;span class="n">response&lt;/span> &lt;span class="o">=&lt;/span> &lt;span class="n">client&lt;/span>&lt;span class="o">.&lt;/span>&lt;span class="n">chat&lt;/span>&lt;span class="o">.&lt;/span>&lt;span class="n">completions&lt;/span>&lt;span class="o">.&lt;/span>&lt;span class="n">create&lt;/span>&lt;span class="p">(&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="n">model&lt;/span>&lt;span class="o">=&lt;/span>&lt;span class="s2">&amp;#34;meta-llama/Llama-3.1-70B-Instruct&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="n">messages&lt;/span>&lt;span class="o">=&lt;/span>&lt;span class="p">[&lt;/span>&lt;span class="o">...&lt;/span>&lt;span class="p">],&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="n">response_format&lt;/span>&lt;span class="o">=&lt;/span>&lt;span class="p">{&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="s2">&amp;#34;type&amp;#34;&lt;/span>&lt;span class="p">:&lt;/span> &lt;span class="s2">&amp;#34;json_schema&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="s2">&amp;#34;json_schema&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="s2">&amp;#34;name&amp;#34;&lt;/span>&lt;span class="p">:&lt;/span> &lt;span class="s2">&amp;#34;person&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="s2">&amp;#34;strict&amp;#34;&lt;/span>&lt;span class="p">:&lt;/span> &lt;span class="kc">True&lt;/span>&lt;span class="p">,&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="s2">&amp;#34;schema&amp;#34;&lt;/span>&lt;span class="p">:&lt;/span> &lt;span class="p">{&lt;/span>&lt;span class="o">...&lt;/span>&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;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;h2 id="usage-patterns-in-production">Usage patterns in production&lt;/h2>
&lt;p>&lt;strong>Function calling / tool use.&lt;/strong> The LLM produces &lt;code>{tool_name: enum, arguments: object}&lt;/code> according to a schema. The dominant case today (OpenAI, Anthropic, open models). The schema guarantees that &lt;code>tool_name&lt;/code> is in the set of available tools and that &lt;code>arguments&lt;/code> has the right types.&lt;/p>
&lt;p>&lt;strong>Entity extraction.&lt;/strong> A schema with &lt;code>{name, address, phone, ...}&lt;/code> from unstructured text. It moves schema adherence from 76% to 98% in vendor benchmarks.&lt;/p>
&lt;p>&lt;strong>Routing / classification.&lt;/strong> The LLM picks between N options (&lt;code>enum&lt;/code> in the schema). The bitmask shrinks to a handful of valid tokens → almost zero overhead, maximum reliability.&lt;/p>
&lt;p>&lt;strong>Constrained SQL.&lt;/strong> An SQL grammar as GBNF or Lark → it avoids injection and syntax errors. Useful in text-to-SQL agents.&lt;/p>
&lt;p>&lt;strong>Code generation with a valid AST.&lt;/strong> A grammar for the target language (Python, Rust, a custom DSL). It guarantees that the output is compilable or parseable code.&lt;/p>
&lt;p>&lt;strong>Agentic loops.&lt;/strong> XGrammar-2 Structural Tag for dynamic switching between formats (tool call → reasoning channel → tool result) without recompiling the grammar.&lt;/p>
&lt;h2 id="operational-pitfalls">Operational pitfalls&lt;/h2>
&lt;p>&lt;strong>Compile time for large schemas.&lt;/strong> JSON Schemas with many &lt;code>$defs&lt;/code> can take seconds to compile. This causes slow startup if they are loaded at boot, or latency spikes on the first request if they are loaded on demand. Mitigation: warm the cache at boot with the fleet&amp;rsquo;s known schemas.&lt;/p>
&lt;p>&lt;strong>Tokenizer-specific FSM/PDA.&lt;/strong> A schema precompiled for Llama 3 (128k tokenizer) does not work for Qwen (152k). The schema cache must be keyed by &lt;code>(tokenizer_hash, schema_hash)&lt;/code>. Changing model = invalidating the cache.&lt;/p>
&lt;p>&lt;strong>Schema changes.&lt;/strong> Version schemas explicitly. Breaking changes → rebuild plus warm cache. Do not silence compile errors in production.&lt;/p>
&lt;p>&lt;strong>Streaming SSE.&lt;/strong> Structured output with streaming requires parsers tolerant of partial output (&lt;code>partial-json-parser&lt;/code>, &lt;code>json-stream&lt;/code>). Strict Pydantic v1 fails; v2 with &lt;code>partial validation&lt;/code> works. Older clients may not handle incremental validation, so test with the real client.&lt;/p>
&lt;p>&lt;strong>Token healing.&lt;/strong> Tokens that cross boundaries (&lt;code>://&lt;/code> as a single token versus &lt;code>:&lt;/code> + &lt;code>//&lt;/code>) can break pure constraints. Outlines and XGrammar mitigate this internally; llama.cpp and others require explicit token healing. If the model sneaks a &amp;ldquo;strange&amp;rdquo; character in after the structure, this is probably why.&lt;/p>
&lt;p>&lt;strong>Real known bugs in vLLM 2025-26&lt;/strong>:&lt;/p>
&lt;ul>
&lt;li>vLLM 0.8.4: XGrammar rejects &lt;code>minItems&lt;/code> in JSON Schema (issue #16880).&lt;/li>
&lt;li>vLLM with Qwen 2.5 VL: Outlines/XGrammar does not respect the schema in some cases (issue #13038).&lt;/li>
&lt;li>The vLLM &lt;code>apply_token_bitmask_inplace&lt;/code> bitmask backend is always &lt;code>auto&lt;/code>, not exposed to the user.&lt;/li>
&lt;/ul>
&lt;p>&lt;strong>OpenAI subset limitations&lt;/strong>: if you are going to port a schema validated on OpenAI structured outputs to another backend, check that you are not using constructs that OpenAI rejects but others allow (deep &lt;code>$ref&lt;/code>, &lt;code>pattern&lt;/code>, &lt;code>default&lt;/code>, depth &amp;gt;5, &lt;code>anyOf&lt;/code> as root).&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> Any model you serve with vLLM or llama.cpp can carry structured output at practically negligible cost. The CPU-side mask latency (~40 µs) is trivial compared with typical TPOT (30-100 ms on consumer hardware). The interesting case: function calling over Llama 3 8B or Qwen 3 14B INT4 → reliable tool use without retries, with no need for a hosted API.&lt;/p>
&lt;p>&lt;strong>On a generic 4×H100 SXM cluster (320 GB, NVLink, native FP8).&lt;/strong> Here XGrammar as the vLLM v1 / SGLang default is the way:&lt;/p>
&lt;ul>
&lt;li>&lt;strong>Llama 3 70B FP8 + XGrammar JSON Schema&lt;/strong>: TPOT P95 stable under 60 ms even with structured output active across the whole batch. It supports hundreds of distinct cached schemas.&lt;/li>
&lt;li>&lt;strong>DeepSeek-V3 + XGrammar Structural Tag&lt;/strong>: agentic tool calling with native MTP and constrained decoding combined; the mask cost overlaps with the MoE forward pass.&lt;/li>
&lt;li>&lt;strong>Multi-tenant function calling&lt;/strong>: each client can have its own set of tools (that is, its own schemas); compile time is amortised through caching, runtime is invariant.&lt;/li>
&lt;/ul>
&lt;p>The rule of thumb for May 2026: XGrammar by default, pre-cache the fleet&amp;rsquo;s schemas at boot, two-pass for reasoning tasks.&lt;/p>
&lt;h2 id="what-we-have-not-covered">What we have not covered&lt;/h2>
&lt;ul>
&lt;li>&lt;strong>Dynamic tool routing&lt;/strong> with XGrammar-2 Structural Tag: the detail of how TagDispatch picks sub-grammars at runtime.&lt;/li>
&lt;li>&lt;strong>Constrained beam search&lt;/strong> and its interaction with grammar: theoretical quality degradation versus greedy.&lt;/li>
&lt;li>&lt;strong>Grammars for code generation with a full AST&lt;/strong>: production-grade Python/Rust grammars, performance.&lt;/li>
&lt;li>&lt;strong>JSON Schema → Pydantic → grammar pipelines&lt;/strong>: tooling to reduce human error.&lt;/li>
&lt;li>&lt;strong>Inhibition decoding&lt;/strong> (the Inhibition Decoding paper, 2025): a variant that penalises but does not forbid certain tokens, useful for soft safety constraints.&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/continuous-batching-orca-vllm-chunked-prefill-goodput/">Continuous batching: the hairdresser with 8 chairs&lt;/a> — the scheduler where structured output is applied request by request; the CPU-side mask computation can overlap with the GPU-side forward pass.&lt;/li>
&lt;li>&lt;a href="https://blog.lo0.es/en/posts/speculative-decoding-fundamentals-maths-state-of-play/">Speculative decoding&lt;/a> — another technique operating on the sampler; speculative plus structured can be combined but requires care (the acceptance rule has to respect the bitmask).&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> — an adapter may be trained specifically for function calling, complementing the structured output guarantee with the model&amp;rsquo;s affinity for the task.&lt;/li>
&lt;li>&lt;a href="https://blog.lo0.es/en/posts/llm-as-judge-exam-marker-not-oracle/">LLM-as-judge: the exam marker&lt;/a> — the judge produces a structured verdict (&lt;code>{score, reasoning, decision}&lt;/code>) that can be secured with structured output to avoid manual parsing.&lt;/li>
&lt;li>&lt;a href="https://blog.lo0.es/en/posts/evals-llm-layer-after-tracing/">Evals for LLMs&lt;/a> — evals with LLM-as-judge benefit enormously from guaranteed structured output.&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/ontologies-knowledge-graphs-llmops-linnaean-nomenclature/">Ontologies and knowledge graphs in LLMOps&lt;/a> — the JSON Schemas imposed on the sampler here usually derive from a SHACL shape of the corporate ontology; structured output is the mechanism by which the LLM populates the KG&amp;rsquo;s ABox in line with the declared TBox.&lt;/li>
&lt;li>&lt;a href="https://blog.lo0.es/en/posts/function-calling-tool-augmented-retrieval-detective-knows-which-file/">Function calling and tool-augmented retrieval: the detective who knows which file to ask for&lt;/a> — the JSON Schema defining each tool call is structured output applied to the tool interface; the constrained decoding guarantee from this post is what makes the LLM generate calls that parse 100 % of the time.&lt;/li>
&lt;/ul>
&lt;h2 id="references">References&lt;/h2>
&lt;ul>
&lt;li>Willard, B., Louf, R. &lt;em>Efficient Guided Generation for Large Language Models&lt;/em> (Outlines). 2023. &lt;a href="https://arxiv.org/abs/2307.09702">https://arxiv.org/abs/2307.09702&lt;/a>&lt;/li>
&lt;li>Dong, Y., Yin, X., Ruan, F., Chen, T. &lt;em>XGrammar: Flexible and Efficient Structured Generation Engine for Large Language Models&lt;/em>. 2024. &lt;a href="https://arxiv.org/abs/2411.15100">https://arxiv.org/abs/2411.15100&lt;/a>&lt;/li>
&lt;li>&lt;em>XGrammar-2: Dynamic Structured Generation for Agentic LLMs&lt;/em>. 2026. &lt;a href="https://arxiv.org/abs/2601.04426">https://arxiv.org/abs/2601.04426&lt;/a>&lt;/li>
&lt;li>Tam, Z. et al. &lt;em>Let Me Speak Freely? A Study on the Impact of Format Restrictions on Performance of Large Language Models&lt;/em>. EMNLP 2024 Industry. &lt;a href="https://arxiv.org/abs/2408.02442">https://arxiv.org/abs/2408.02442&lt;/a>&lt;/li>
&lt;li>dottxt blog, &lt;em>Say What You Mean&lt;/em>: &lt;a href="https://blog.dottxt.ai/say-what-you-mean.html">https://blog.dottxt.ai/say-what-you-mean.html&lt;/a>&lt;/li>
&lt;li>OpenAI, &lt;em>Introducing Structured Outputs in the API&lt;/em>: &lt;a href="https://openai.com/index/introducing-structured-outputs-in-the-api/">https://openai.com/index/introducing-structured-outputs-in-the-api/&lt;/a>&lt;/li>
&lt;li>Outlines repo: &lt;a href="https://github.com/dottxt-ai/outlines">https://github.com/dottxt-ai/outlines&lt;/a>&lt;/li>
&lt;li>outlines-core (Rust): &lt;a href="https://github.com/dottxt-ai/outlines-core">https://github.com/dottxt-ai/outlines-core&lt;/a>&lt;/li>
&lt;li>XGrammar repo: &lt;a href="https://github.com/mlc-ai/xgrammar">https://github.com/mlc-ai/xgrammar&lt;/a>&lt;/li>
&lt;li>LLGuidance (Microsoft Research) repo: &lt;a href="https://github.com/guidance-ai/llguidance">https://github.com/guidance-ai/llguidance&lt;/a>&lt;/li>
&lt;li>LM Format Enforcer repo: &lt;a href="https://github.com/noamgat/lm-format-enforcer">https://github.com/noamgat/lm-format-enforcer&lt;/a>&lt;/li>
&lt;li>llama.cpp grammars: &lt;a href="https://github.com/ggml-org/llama.cpp/blob/master/grammars/README.md">https://github.com/ggml-org/llama.cpp/blob/master/grammars/README.md&lt;/a>&lt;/li>
&lt;li>vLLM Structured Outputs docs: &lt;a href="https://docs.vllm.ai/en/stable/features/structured_outputs/">https://docs.vllm.ai/en/stable/features/structured_outputs/&lt;/a>&lt;/li>
&lt;li>SGLang Structured Outputs docs: &lt;a href="https://docs.sglang.io/advanced_features/structured_outputs.html">https://docs.sglang.io/advanced_features/structured_outputs.html&lt;/a>&lt;/li>
&lt;li>TensorRT-LLM guided decoding (Triton): &lt;a href="https://docs.nvidia.com/deeplearning/triton-inference-server/user-guide/docs/tensorrtllm_backend/docs/guided_decoding.html">https://docs.nvidia.com/deeplearning/triton-inference-server/user-guide/docs/tensorrtllm_backend/docs/guided_decoding.html&lt;/a>&lt;/li>
&lt;li>NIM Structured Generation: &lt;a href="https://docs.nvidia.com/nim/large-language-models/1.12.0/structured-generation.html">https://docs.nvidia.com/nim/large-language-models/1.12.0/structured-generation.html&lt;/a>&lt;/li>
&lt;li>MLC blog, &lt;em>XGrammar&lt;/em>: &lt;a href="https://blog.mlc.ai/2024/11/22/achieving-efficient-flexible-portable-structured-generation-with-xgrammar">https://blog.mlc.ai/2024/11/22/achieving-efficient-flexible-portable-structured-generation-with-xgrammar&lt;/a>&lt;/li>
&lt;li>MLC blog, &lt;em>XGrammar-2&lt;/em>: &lt;a href="https://blog.mlc.ai/2026/05/04/xgrammar-2-fast-customizable-structured-generation">https://blog.mlc.ai/2026/05/04/xgrammar-2-fast-customizable-structured-generation&lt;/a>&lt;/li>
&lt;li>SqueezeBits, &lt;em>Guided decoding performance: vLLM vs SGLang&lt;/em>: &lt;a href="https://blog.squeezebits.com/guided-decoding-performance-vllm-sglang">https://blog.squeezebits.com/guided-decoding-performance-vllm-sglang&lt;/a>&lt;/li>
&lt;li>Red Hat, &lt;em>Structured outputs in vLLM&lt;/em>: &lt;a href="https://developers.redhat.com/articles/2025/06/03/structured-outputs-vllm-guiding-ai-responses">https://developers.redhat.com/articles/2025/06/03/structured-outputs-vllm-guiding-ai-responses&lt;/a>&lt;/li>
&lt;li>Aidan Cooper, &lt;em>Constrained Decoding&lt;/em> guide: &lt;a href="https://www.aidancooper.co.uk/constrained-decoding/">https://www.aidancooper.co.uk/constrained-decoding/&lt;/a>&lt;/li>
&lt;/ul></description></item><item><title>Anatomy of an on-premise LLM inference stack: the seven layers that have to hold each other up</title><link>https://blog.lo0.es/en/posts/anatomy-on-premise-llm-inference-stack-seven-layers/</link><pubDate>Sat, 30 May 2026 16:00:00 +0200</pubDate><guid>https://blog.lo0.es/en/posts/anatomy-on-premise-llm-inference-stack-seven-layers/</guid><description>&lt;h2 id="tldr">TL;DR&lt;/h2>
&lt;p>The &lt;strong>on-premise LLM inference stack is not a model server&lt;/strong>: it is a building of &lt;strong>seven layers&lt;/strong> that hold each other up. The inference layer (vLLM / SGLang) serves tokens, but without a dedicated embeddings layer RAG does not work; without the gateway layer the client couples its SDK to the specific engine; without an LLM-aware observability layer (Langfuse + OpenTelemetry GenAI) any quality degradation goes unnoticed; without a GitOps control plane layer (Flux + Forgejo) any manual change leaves invisible debt; and without a &lt;strong>dependency tracking&lt;/strong> layer (Hubble + intent-based policies) decommissioning a Service silently breaks applications nobody remembers were using it. This post is born from a concrete incident: a pipeline that reported &lt;code>status: completed&lt;/code> and &lt;code>matched_jobs: 0&lt;/code> for days because it kept calling an Ollama that had already been scaled to zero, while a badly written &lt;code>except&lt;/code> labelled the generic &lt;code>ConnectError&lt;/code> as &amp;ldquo;ChromaDB indexing error&amp;rdquo; and the vector store, innocent, took the blame. Each of the three symptoms was the cry of a layer that was missing or badly designed. The body of the post walks the seven layers with their canonical OSS piece, the design decisions that break them, the sizing maths on a generic &lt;strong>4×H100 SXM cluster (320 GB of VRAM, NVLink)&lt;/strong> and a final diagram of the connected stack. The thesis: a stack that passes the incident test is not measured by its peak throughput, it is measured by &lt;strong>how long it takes to shout when something drifts from the design&lt;/strong>.&lt;/p>
&lt;h2 id="you-are-here-the-seven-layers-seen-from-above">You are here: the seven layers seen from above&lt;/h2>
&lt;p>Before going into detail, the map. The seven layers are not seven servers: they are seven responsibilities the stack has to cover. A layer can collapse into one pod (gateway) or spread across several components (observability = traces + metrics + flow logs). What it cannot do is &lt;strong>be missing&lt;/strong>.&lt;/p>
&lt;div class="diagram" style="max-width:820px;margin:1rem auto;">
&lt;svg viewBox="0 0 820 360" xmlns="http://www.w3.org/2000/svg" role="img" aria-label="the seven layers of the on-premise LLM inference stack">&lt;style>
.layer{stroke:#333;stroke-width:1.4;rx:6}
.l1{fill:#ffd9b8;stroke:#a44}
.l2{fill:#ffe6c2;stroke:#a55}
.l3{fill:#fff0d0;stroke:#a66}
.l4{fill:#dfe9f5;stroke:#356}
.l5{fill:#d8eecf;stroke:#373}
.l6{fill:#f5e3d8;stroke:#763}
.l7{fill:#ead8f5;stroke:#634}
.title{font:600 13px sans-serif;fill:#222}
.sm{font:11px sans-serif;fill:#222}
.tiny{font:600 10px sans-serif;fill:#222}
.note{font:italic 10px sans-serif;fill:#555}
.arr{stroke:#666;stroke-width:1.4;fill:none;marker-end:url(#a)}
&lt;/style>
&lt;defs>&lt;marker id="a" 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="410" y="22" text-anchor="middle" class="title">On-premise LLM inference stack — seven layers, one responsibility each&lt;/text>
&lt;rect x="40" y="40" width="740" height="38" class="layer l1"/>
&lt;text x="60" y="64" class="sm">&lt;tspan font-weight="700">1 · Gateway&lt;/tspan> — Envoy AI Gateway · Cilium Gateway API · LiteLLM &lt;tspan class="note">OpenAI-compatible · JWT auth · routing by model · rate limit&lt;/tspan>&lt;/text>
&lt;rect x="40" y="86" width="365" height="50" class="layer l2"/>
&lt;text x="60" y="106" class="sm">&lt;tspan font-weight="700">2 · LLM inference&lt;/tspan>&lt;/text>
&lt;text x="60" y="122" class="tiny">vLLM · SGLang · TensorRT-LLM &lt;tspan class="note" fill="#555">multi-model · multi-LoRA · FP8 KV&lt;/tspan>&lt;/text>
&lt;rect x="415" y="86" width="365" height="50" class="layer l3"/>
&lt;text x="435" y="106" class="sm">&lt;tspan font-weight="700">3 · Embeddings&lt;/tspan>&lt;/text>
&lt;text x="435" y="122" class="tiny">Infinity · TEI · OVMS · sentence-transformers &lt;tspan class="note" fill="#555">fixed dim · separate from the LLM&lt;/tspan>&lt;/text>
&lt;rect x="40" y="144" width="740" height="50" class="layer l4"/>
&lt;text x="60" y="164" class="sm">&lt;tspan font-weight="700">4 · Vector store + relational data&lt;/tspan>&lt;/text>
&lt;text x="60" y="180" class="tiny">Qdrant · pgvector · MinIO (weights · adapters · corpus) &lt;tspan class="note" fill="#555">collection versioned by dim · bucket per tenant&lt;/tspan>&lt;/text>
&lt;rect x="40" y="202" width="740" height="50" class="layer l5"/>
&lt;text x="60" y="222" class="sm">&lt;tspan font-weight="700">5 · LLM-aware observability + infrastructure + network&lt;/tspan>&lt;/text>
&lt;text x="60" y="238" class="tiny">Langfuse · OTel GenAI · VictoriaMetrics · Grafana · Hubble · DCGM &lt;tspan class="note" fill="#555">end-to-end trace_id&lt;/tspan>&lt;/text>
&lt;rect x="40" y="260" width="740" height="40" class="layer l6"/>
&lt;text x="60" y="284" class="sm">&lt;tspan font-weight="700">6 · GitOps control plane&lt;/tspan> — Flux · Forgejo · cert-manager · External Secrets &lt;tspan class="note" fill="#555">binding drift detection&lt;/tspan>&lt;/text>
&lt;rect x="40" y="308" width="740" height="40" class="layer l7"/>
&lt;text x="60" y="332" class="sm">&lt;tspan font-weight="700">7 · Dependency tracking&lt;/tspan> — Hubble flow logs · Otterize · NetworkPolicy as code &lt;tspan class="note" fill="#555">who calls what, observed and declared&lt;/tspan>&lt;/text>
&lt;/svg>
&lt;/div>
&lt;p>Layers 1–4 sit on the &lt;strong>request path&lt;/strong>: if they fall, the client notices in seconds. Layers 5–7 sit on the path of &lt;strong>design and operations&lt;/strong>: if they fall, there is no immediately visible error, and that is why they are the ones that produce silent incidents, which is how most serious incidents end up. This post argues that &lt;strong>the quality of the stack is measured in layers 5, 6 and 7&lt;/strong>, because 1–4 are commodities where everyone picks roughly the same pieces.&lt;/p>
&lt;h2 id="the-analogy-the-office-building-with-shared-services">The analogy: the office building with shared services&lt;/h2>
&lt;p>Picture a twelve-storey office building with several tenants. It has a &lt;strong>concierge desk&lt;/strong> (gateway: filters who comes in), it has &lt;strong>lifts&lt;/strong> (LLM inference: they move the heavy load), it has &lt;strong>stairs and a goods lift&lt;/strong> (embeddings: light but constant traffic, hardly anyone notices them until they break), it has &lt;strong>plumbing and water tanks&lt;/strong> (vector store: what actually holds the state), it has a &lt;strong>distribution board and sensors&lt;/strong> (observability: what warns you when something is drawing more than planned), it has a &lt;strong>building manager&lt;/strong> (GitOps control plane: the only legitimate authority to move anything), and it has a &lt;strong>tenant register&lt;/strong> (dependency tracking: who is connected to which shared service).&lt;/p>
&lt;p>When a new client moves into the third floor and asks to install a server that needs more amperage than planned, the problem is not electrical: it is &lt;strong>administrative&lt;/strong>. If the client can go straight to the board and plug in whatever they like, the building survives for a while and then a breaker trips at three in the morning. If the client has to go through the manager, the manager consults the tenant register (is anyone else hanging off that same circuit?), reviews the electrical planning (are we at the limit?) and either authorises or redirects. The building stays standing not because of its electrical installation but because of the &lt;strong>discipline of going through the manager&lt;/strong>.&lt;/p>
&lt;p>The LLM inference stack works identically. The physical layers (1–4) are the visible ones, the ones marketing people put on the slide. The governance layers (5–7) are the ones that distinguish a platform from a pile of pods with good luck.&lt;/p>
&lt;p>Now to the incident that motivates the whole post.&lt;/p>
&lt;h2 id="the-hook-the-log-that-lied-for-six-days">The hook: the log that lied for six days&lt;/h2>
&lt;p>The application is called &lt;em>jobhunter&lt;/em> internally. It is a cron pipeline that every six hours sweeps public job-ad sources, filters by EU geography, embeds the new ads, indexes them in a vector store and matches them against search profiles. The last step fires notifications.&lt;/p>
&lt;p>For six days the pipeline reported the same thing on every run:&lt;/p>
&lt;pre tabindex="0">&lt;code>status: completed
total_found: 756
new: 23
matched_jobs: 0 ← zero, run after run
&lt;/code>&lt;/pre>&lt;p>And in the logs:&lt;/p>
&lt;pre tabindex="0">&lt;code>[INFO] httpx: POST chromadb:8000/api/v2/.../collections/&amp;lt;id&amp;gt;/delete → 200 OK
[INFO] matcher: Purged 12 expired jobs from ChromaDB
[ERROR] pipeline: ChromaDB indexing error: All connection attempts failed
[INFO] httpx: POST chromadb:8000/api/v2/.../collections/&amp;lt;id&amp;gt;/get → 200 OK
[ERROR] pipeline: Matching error: All connection attempts failed
&lt;/code>&lt;/pre>&lt;p>This is a log that invites you to blame ChromaDB. And in fact the first post-mortem written internally pointed at a version incompatibility between the Python client and the v2 server. A reasonable hypothesis, technically plausible, completely false.&lt;/p>
&lt;p>The real cause: weeks earlier the general LLM on the platform had been migrated from &lt;strong>Ollama&lt;/strong> to &lt;strong>vLLM&lt;/strong>. The migration was clean for the two applications that depended directly on the large model, their manifests were pointed at the new endpoint. What nobody did was look at who else was calling the Service &lt;code>ollama.ollama.svc:11434&lt;/code>. &lt;em>jobhunter&lt;/em> called it to generate the embeddings for the ads. When the Ollama deployment was scaled to zero, the Service was left empty, and any outbound connection received a generic &lt;code>ConnectError(&amp;quot;All connection attempts failed&amp;quot;)&lt;/code> from &lt;code>httpcore&lt;/code>. The &lt;code>try/except&lt;/code> wrapping the whole matching stage caught the exception and labelled it &lt;em>&amp;ldquo;ChromaDB indexing error&amp;rdquo;&lt;/em>, because that was the lexical wrapper of the block, not because ChromaDB had anything to do with it. ChromaDB was answering 200 to &lt;em>delete&lt;/em> and &lt;em>get&lt;/em> in those same logs.&lt;/p>
&lt;p>Three factors kept the incident alive for six days:&lt;/p>
&lt;ol>
&lt;li>The &lt;strong>&lt;code>except&lt;/code> named the wrapper rather than the stage that failed&lt;/strong>. The log said &lt;em>&amp;ldquo;ChromaDB indexing error&amp;rdquo;&lt;/em> when the error was an embeddings call against a nonexistent Service.&lt;/li>
&lt;li>The pipeline returned &lt;strong>&lt;code>status: completed&lt;/code>&lt;/strong> even when there were errors. No alerting based on &lt;code>status&lt;/code> fired. The metric that would have fired (matched_jobs stuck at zero) was not instrumented.&lt;/li>
&lt;li>The pipeline image ran as &lt;strong>&lt;code>:latest&lt;/code> with no versioning&lt;/strong>, no SBOM, no reproducibility. When it started failing, it was impossible to know which Ollama client it had been built with.&lt;/li>
&lt;/ol>
&lt;p>Each of the three symptoms is the cry of a layer that was missing. The first asks for &lt;strong>LLM-aware observability&lt;/strong> that distinguishes stages (layer 5). The second asks for the &lt;strong>pipeline logic&lt;/strong> and the Prometheus metrics to behave like contracts (layer 5, SLI/SLO dimension). The third asks for &lt;strong>GitOps with pinned images&lt;/strong> and an auditable SBOM (layer 6). And the whole incident, decommissioning a Service without knowing who consumes it, shouts &lt;strong>dependency tracking&lt;/strong> (layer 7).&lt;/p>
&lt;p>The rest of the post walks the seven layers through that lens: what each one solves, which OSS piece implements it in 2026, and which typical design decision breaks it.&lt;/p>
&lt;h2 id="layer-1--gateway-the-client-sdk-must-not-couple-to-the-engine">Layer 1 — Gateway: the client SDK must not couple to the engine&lt;/h2>
&lt;p>&lt;strong>What it has to solve.&lt;/strong> That the client sends &lt;code>POST /v1/chat/completions&lt;/code> with the standard OpenAI SDK and never learns which engine (vLLM, SGLang, TensorRT-LLM), which specific model, which LoRA adapter or even which GPU pool is serving the request. Authentication, rate limit, routing by &lt;code>body.model&lt;/code> and by tenant, header injection for tracing.&lt;/p>
&lt;p>&lt;strong>Canonical OSS piece in 2026.&lt;/strong> &lt;strong>Envoy AI Gateway&lt;/strong> (Envoy with GenAI extensions: routing by model, token-based rate limit, fallback chains) or &lt;strong>Cilium Gateway API&lt;/strong> with your own filters. For cases where the client wants multi-provider without distinguishing on-prem from SaaS, &lt;strong>LiteLLM Proxy&lt;/strong> is the lightweight equivalent.&lt;/p>
&lt;p>&lt;strong>What breaks the layer.&lt;/strong> Exposing the inference engine endpoint directly. If clients call &lt;code>http://vllm-prod.svc:8000&lt;/code>, any change of engine, model or pool forces you to touch the code of every app. The rule: &lt;strong>the engine changes, the contract does not&lt;/strong>. The OpenAI SDK is standard; the routing behind the gateway is where design freedom lives.&lt;/p>
&lt;p>&lt;strong>Where &lt;em>jobhunter&lt;/em> failed.&lt;/strong> There was no gateway. The app called &lt;code>ollama.ollama.svc:11434&lt;/code> directly. When Ollama died, there was no intermediate layer that could answer with a &lt;em>fallback&lt;/em>, a &lt;em>retry against another pool&lt;/em>, or at least a &lt;em>503 error with a descriptive body&lt;/em>.&lt;/p>
&lt;h2 id="layer-2--llm-inference-vllm-as-the-default-choice-sglang-when-prefix-caching-rules">Layer 2 — LLM inference: vLLM as the default choice, SGLang when prefix caching rules&lt;/h2>
&lt;p>&lt;strong>What it has to solve.&lt;/strong> Serving tokens with throughput and latency under control: continuous batching, PagedAttention so the KV cache does not fragment the VRAM, FP8 so a 32B model fits with room to spare, multi-LoRA for per-tenant customisation without replicating the base, structured output for function calling with schema guarantees.&lt;/p>
&lt;p>&lt;strong>Canonical OSS piece in 2026.&lt;/strong> &lt;strong>vLLM&lt;/strong> is the default choice: it covers the state of the art (&lt;a href="https://blog.lo0.es/en/posts/kv-cache-working-memory-llm-inference/">PagedAttention&lt;/a>, &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/flashattention-librarian-io-awareness-async-blackwell-asymmetry/">FlashAttention&lt;/a>, &lt;a href="https://blog.lo0.es/en/posts/quantization-llm-inference-fp8-int4-gguf/">FP8 quantisation&lt;/a>, &lt;a href="https://blog.lo0.es/en/posts/multi-lora-serving-shared-base-concurrent-adapters-sgmv-kernel/">multi-LoRA&lt;/a>, &lt;a href="https://blog.lo0.es/en/posts/structured-output-bitmask-outlines-xgrammar-llguidance/">structured output&lt;/a>, &lt;a href="https://blog.lo0.es/en/posts/speculative-decoding-fundamentals-maths-state-of-play/">speculative decoding&lt;/a>). &lt;strong>SGLang&lt;/strong> comes in when the workload has high prefix caching (long chat with large system prompts, agents with repeated instructions), because its RadixAttention composes better than standard prefix caching. For very specialised inference with proprietary NVIDIA kernels, &lt;strong>TensorRT-LLM&lt;/strong>, accepting the hardware lock-in.&lt;/p>
&lt;p>&lt;strong>What breaks the layer.&lt;/strong> Assuming a single model &amp;ldquo;does everything&amp;rdquo;. A 32B chat LLM does not serve &lt;code>/v1/embeddings&lt;/code>; if you try, vLLM answers &lt;code>BadRequestError: &amp;quot;The model does not support Embeddings API&amp;quot;&lt;/code>. Assuming it did was one of the concrete wounds of the incident: the app expected an endpoint the model did not implement.&lt;/p>
&lt;p>&lt;strong>The design decision that costs more later.&lt;/strong> Serving the LLM with aggressive quantisation (INT4) without a quality eval calibrated for your corpus. INT4 with AWQ or GPTQ saves VRAM, but it degrades answers in technical or legal Spanish in a measurable way. The rule: any quantisation change goes through the same golden eval as a model change.&lt;/p>
&lt;h2 id="layer-3--embeddings-separate-from-the-llm-fixed-dimension-a-life-of-its-own">Layer 3 — Embeddings: separate from the LLM, fixed dimension, a life of its own&lt;/h2>
&lt;p>&lt;strong>What it has to solve.&lt;/strong> Generating dense vectors for the RAG corpus and for retrieval queries. The embeddings model is &lt;strong>a different thing&lt;/strong> from the chat LLM: different architecture (encoder, not decoder), different size (hundreds of millions of parameters, not tens of thousands of millions), different API (an &lt;code>/embeddings&lt;/code> endpoint that takes text and returns a fixed-dimension vector).&lt;/p>
&lt;p>&lt;strong>Canonical OSS piece in 2026.&lt;/strong> &lt;strong>Infinity&lt;/strong> or &lt;strong>Hugging Face Text Embeddings Inference (TEI)&lt;/strong> to serve models of the &lt;code>bge-*&lt;/code>, &lt;code>multilingual-e5-*&lt;/code>, &lt;code>nomic-embed-*&lt;/code> families with high throughput and multi-model support. &lt;strong>OpenVINO Model Server&lt;/strong> when Intel hardware is available. &lt;strong>sentence-transformers&lt;/strong> as a fallback embedded in the application itself when the corpus is small and the deployment is constrained.&lt;/p>
&lt;p>&lt;strong>What breaks the layer.&lt;/strong> Treating it as &amp;ldquo;the LLM does that too&amp;rdquo;. It does not if it is chat-only; and even when it does (some models have a dual endpoint), mixing chat and embeddings serving in the same process punishes the throughput of both. The physical separation &lt;strong>is&lt;/strong> the design.&lt;/p>
&lt;p>&lt;strong>The technical fact people forget.&lt;/strong> The &lt;strong>vector store and the embeddings model form an indivisible unit&lt;/strong>. Changing the model from &lt;code>multilingual-e5-large&lt;/code> (1024 dim) to &lt;code>multilingual-e5-small&lt;/code> (384 dim) is &lt;strong>not a substitution&lt;/strong>: it is creating a new collection (&lt;code>my_corpus_v2&lt;/code>) and re-embedding the whole corpus. If you &lt;code>upsert&lt;/code> into the old collection, you hit a dim mismatch at runtime that takes the pod down. This seems obvious and is violated constantly, because the embeddings model is chosen once and then forgotten.&lt;/p>
&lt;h2 id="layer-4--vector-store--relational-data--storage-what-actually-holds-the-state">Layer 4 — Vector store + relational data + storage: what actually holds the state&lt;/h2>
&lt;p>&lt;strong>What it has to solve.&lt;/strong> Persisting the vectors with efficient filters (&lt;code>tenant_id&lt;/code>, &lt;code>created_at&lt;/code>, &lt;code>source&lt;/code>), persisting the relational metadata (users, configs, versioned prompts, traces), and persisting the model weights, the LoRA adapters, the datasets and the original corpora.&lt;/p>
&lt;p>&lt;strong>Canonical OSS pieces in 2026.&lt;/strong>&lt;/p>
&lt;ul>
&lt;li>&lt;strong>Qdrant&lt;/strong> for large collections (&amp;gt;200 k vectors), payload-aware filters, multi-tenancy via collection or via field.&lt;/li>
&lt;li>&lt;strong>pgvector&lt;/strong> for small collections with mandatory relational joins (wanting to do &lt;code>WHERE doc.author = ... AND vector &amp;lt;=&amp;gt; $1&lt;/code> in the same SQL).&lt;/li>
&lt;li>&lt;strong>PostgreSQL operated by CloudNativePG (CNPG)&lt;/strong> for the relational side: Barman Cloud backups, replication, connection via pooler.&lt;/li>
&lt;li>&lt;strong>MinIO&lt;/strong> for S3-compatible objects: bucket per tenant, cross-site replication, weights and adapters versioned by sha256.&lt;/li>
&lt;li>&lt;strong>Redis&lt;/strong> for queues, rate-limit counters and a cache of frequent retrievals.&lt;/li>
&lt;/ul>
&lt;p>&lt;strong>What breaks the layer.&lt;/strong> Assuming the vector store is &lt;em>stateless ephemeral&lt;/em>. It is exactly the opposite: it is the component where losing state costs the most. Without verified backups of the vector store, an index corruption forces you to re-embed the whole corpus, and on a corpus of millions of documents that is hours or days of GPU.&lt;/p>
&lt;p>&lt;strong>The design decision that pays later.&lt;/strong> Forgetting to version the collection by &lt;strong>dimension&lt;/strong> and &lt;strong>embeddings model&lt;/strong>. Suggested convention: &lt;code>my_corpus__embed-multie5l__1024d__v3&lt;/code>. The name carries metadata; any change in any of the three attributes forces a new collection. It is ugly but it protects against an accidental &lt;code>upsert&lt;/code> with the wrong dim.&lt;/p>
&lt;h2 id="layer-5--observability-llm-aware-traces--infrastructure-metrics--flow-logs">Layer 5 — Observability: LLM-aware traces + infrastructure metrics + flow logs&lt;/h2>
&lt;p>&lt;strong>What it has to solve.&lt;/strong> That any inference can be recovered from its &lt;code>trace_id&lt;/code>, with all the &lt;code>gen_ai.*&lt;/code> attributes (&lt;a href="https://blog.lo0.es/en/posts/llm-tracing-opentelemetry-genai/">semantic conventions&lt;/a>) plus your own attributes (&lt;code>tenant_id&lt;/code>, &lt;code>adapter_id&lt;/code>, &lt;code>priority_tier&lt;/code>), a breakdown of latency (queue → prefill → decode → network), tokens consumed, tools invoked, exact model and adapter. And in parallel: Prometheus metrics from vLLM (&lt;code>vllm:num_requests_running&lt;/code>, &lt;code>vllm:gpu_cache_usage_perc&lt;/code>, &lt;code>vllm:prefix_cache_hit_rate&lt;/code>), from the GPU (DCGM Exporter), from the network (Hubble flow logs with drops and NetworkPolicy enforcement).&lt;/p>
&lt;p>&lt;strong>Canonical OSS pieces in 2026.&lt;/strong>&lt;/p>
&lt;ul>
&lt;li>&lt;strong>OpenTelemetry Collector&lt;/strong> as the single transport for traces, metrics and logs, with OTLP receivers and separate exporters per destination.&lt;/li>
&lt;li>&lt;strong>Langfuse&lt;/strong> self-hosted for the LLM-aware side: tracing, prompt versioning, evals with LLM-as-judge (&lt;a href="https://blog.lo0.es/en/posts/evals-llm-layer-after-tracing/">post&lt;/a> and &lt;a href="https://blog.lo0.es/en/posts/llm-as-judge-exam-marker-not-oracle/">post&lt;/a>).&lt;/li>
&lt;li>&lt;strong>VictoriaMetrics + Grafana&lt;/strong> for high-throughput TSDB metrics with long retention.&lt;/li>
&lt;li>&lt;strong>Hubble&lt;/strong> (Cilium) for L3/L4/L7 flow logs and NetworkPolicy visualisation.&lt;/li>
&lt;li>&lt;strong>DCGM Exporter&lt;/strong> for GPU metrics.&lt;/li>
&lt;/ul>
&lt;p>&lt;strong>What breaks the layer.&lt;/strong> Wrapping the whole pipeline stage in a single &lt;code>try/except&lt;/code> that labels the exception with the wrapper&amp;rsquo;s name. The operational rule: &lt;strong>one &lt;code>try/except&lt;/code> per stage&lt;/strong>, with the stage label in the message and a Prometheus metric with &lt;code>labels={&amp;quot;stage&amp;quot;: &amp;quot;&amp;lt;name&amp;gt;&amp;quot;}&lt;/code>. That way &lt;em>&amp;ldquo;ChromaDB indexing error&amp;rdquo;&lt;/em> would never have been the log for an embeddings failure; it would have been &lt;em>&amp;ldquo;Embeddings call failed: ConnectError(ollama:11434)&amp;rdquo;&lt;/em>.&lt;/p>
&lt;p>&lt;strong>Complementary rule.&lt;/strong> The pipeline returns &lt;code>status: completed&lt;/code> if and only if &lt;strong>there were no errors&lt;/strong>. With errors it returns &lt;code>completed_with_errors&lt;/code> or &lt;code>failed&lt;/code>, and the metric &lt;code>pipeline_errors_total{stage}&lt;/code> is incremented. An alert based on &lt;code>increase(pipeline_errors_total[1h]) &amp;gt; 0&lt;/code> fires before the second failed run. Without this discipline, observability exists but does not warn.&lt;/p>
&lt;h2 id="layer-6--gitops-control-plane-the-only-legitimate-authority">Layer 6 — GitOps control plane: the only legitimate authority&lt;/h2>
&lt;p>&lt;strong>What it has to solve.&lt;/strong> That the cluster state is the state declared in git. That any divergence between git and the cluster is visible and, in critical components, &lt;strong>auto-reconciled or auto-alerted&lt;/strong>. That every deployed image has an immutable tag (sha digest or semver pin), an SBOM (Trivy) and traceability back to the commit that introduced it.&lt;/p>
&lt;p>&lt;strong>Canonical OSS pieces in 2026.&lt;/strong>&lt;/p>
&lt;ul>
&lt;li>&lt;strong>Flux&lt;/strong> (or ArgoCD) as the reconciler.&lt;/li>
&lt;li>&lt;strong>Forgejo&lt;/strong> (or Gitea, GitLab CE) as the self-hosted OSS forge.&lt;/li>
&lt;li>&lt;strong>cert-manager + Trust Manager&lt;/strong> for internal PKI.&lt;/li>
&lt;li>&lt;strong>External Secrets Operator + SOPS&lt;/strong> for encrypted versioned secrets.&lt;/li>
&lt;li>&lt;strong>Kyverno&lt;/strong> (or OPA Gatekeeper) for binding policies: deny &lt;code>:latest&lt;/code> images, deny pods without a NetworkPolicy, deny Services without an owner label.&lt;/li>
&lt;li>&lt;strong>Trivy&lt;/strong> for SBOM and vulnerability scanning of images in the CI pipeline.&lt;/li>
&lt;/ul>
&lt;p>&lt;strong>What breaks the layer.&lt;/strong> Images with a mutable tag (&lt;code>:latest&lt;/code>, &lt;code>:main&lt;/code>). Any &lt;code>kubectl edit&lt;/code> in production that is not reflected in git. &lt;code>main&lt;/code> branches with write permissions for humans without review. The rule: &lt;strong>if a human can mutate the cluster without going through a signed commit, you do not have GitOps, you have somebody&amp;rsquo;s whiteboard&lt;/strong>.&lt;/p>
&lt;p>&lt;strong>How it applies to the incident.&lt;/strong> If the pipeline image had been pinned to a sha digest (&lt;code>registry.interno.local/jobhunter@sha256:9af2...&lt;/code>), the team could have audited immediately which Ollama client it carried. With &lt;code>:latest&lt;/code>, not even that.&lt;/p>
&lt;h2 id="layer-7--dependency-tracking-the-layer-the-incident-exposed">Layer 7 — Dependency tracking: the layer the incident exposed&lt;/h2>
&lt;p>&lt;strong>What it has to solve.&lt;/strong> Knowing who calls which Service, both &lt;strong>declaratively&lt;/strong> (what the gitops repo says) and &lt;strong>observed&lt;/strong> (what has been seen crossing the network over the last N days). And, on mature platforms, propagating that information as &lt;strong>policy&lt;/strong>: if nobody declares and nobody observes traffic to the Service &lt;code>ollama.ollama.svc&lt;/code>, decommissioning it is safe; if somebody declares it or uses it, decommissioning it opens a ticket.&lt;/p>
&lt;p>&lt;strong>Canonical OSS pieces in 2026.&lt;/strong>&lt;/p>
&lt;ul>
&lt;li>&lt;strong>Hubble&lt;/strong> (Cilium) for observed flow logs: &lt;code>hubble observe --to-namespace ollama --since 14d&lt;/code> gives the list of source namespaces that have talked to Ollama in the last two weeks.&lt;/li>
&lt;li>&lt;strong>Otterize&lt;/strong> for intent-based policy: each Deployment declares &lt;em>&amp;ldquo;I need to talk to &lt;code>ollama-svc&lt;/code>&amp;rdquo;&lt;/em>, and the operator generates the corresponding NetworkPolicy and maintains a browsable catalogue of who is trying to talk to what.&lt;/li>
&lt;li>&lt;strong>manual kubectl-grep&lt;/strong> as a fallback: &lt;code>kubectl get deployments,cronjobs,statefulsets -A -o yaml | grep -E 'ollama[.-]'&lt;/code> produces the declarative list.&lt;/li>
&lt;li>&lt;strong>NetworkPolicy as code&lt;/strong> reviewed in CI: every PR that touches a Service requires the associated policy to be kept or explicitly updated.&lt;/li>
&lt;/ul>
&lt;p>&lt;strong>A pre-decom checklist&lt;/strong> that the incident suggests codifying as a CI hook:&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">&lt;span class="nv">SVC&lt;/span>&lt;span class="o">=&lt;/span>&lt;span class="s2">&amp;#34;ollama.ollama.svc.cluster.local&amp;#34;&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="c1"># (a) declarative grep in the gitops repo&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">git -C &lt;span class="nv">$GITOPS_REPO&lt;/span> grep -l &lt;span class="s2">&amp;#34;&lt;/span>&lt;span class="nv">$SVC&lt;/span>&lt;span class="s2">&amp;#34;&lt;/span> &lt;span class="o">||&lt;/span> &lt;span class="nb">echo&lt;/span> &lt;span class="s2">&amp;#34;OK declarative&amp;#34;&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="c1"># (b) observed grep in Hubble (last 14 days)&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">hubble observe --to-fqdn &lt;span class="s2">&amp;#34;&lt;/span>&lt;span class="nv">$SVC&lt;/span>&lt;span class="s2">&amp;#34;&lt;/span> --since 336h --output json &lt;span class="se">\
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="se">&lt;/span> &lt;span class="p">|&lt;/span> jq -r &lt;span class="s1">&amp;#39;.source.namespace&amp;#39;&lt;/span> &lt;span class="p">|&lt;/span> sort -u &lt;span class="o">||&lt;/span> &lt;span class="nb">echo&lt;/span> &lt;span class="s2">&amp;#34;OK observed&amp;#34;&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="c1"># (c) live grep in the cluster&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">kubectl get all -A -o yaml &lt;span class="p">|&lt;/span> grep -E &lt;span class="s2">&amp;#34;&lt;/span>&lt;span class="nv">$SVC&lt;/span>&lt;span class="s2">&amp;#34;&lt;/span> &lt;span class="o">||&lt;/span> &lt;span class="nb">echo&lt;/span> &lt;span class="s2">&amp;#34;OK live&amp;#34;&lt;/span>
&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>If all three return empty, the decom is safe. If any of them has content, there is downstream debt still open. The &lt;em>jobhunter&lt;/em> incident is exactly what happens when this check does not exist: the team that decommissioned Ollama looked at the list of applications it knew depended on it directly; nobody looked at the list of those that depended on it silently.&lt;/p>
&lt;h2 id="the-maths-that-matter-sizing-the-stack-on-4h100-sxm-320-gb">The maths that matter: sizing the stack on 4×H100 SXM (320 GB)&lt;/h2>
&lt;p>The generic reference cluster for everything that follows: &lt;strong>4×H100 SXM 80 GB&lt;/strong>, &lt;strong>NVLink&lt;/strong> between the four, &lt;strong>640 GB&lt;/strong> of system RAM, &lt;strong>2×NVMe NVMe-oF&lt;/strong> for local storage of weights and caches, &lt;strong>redundant 25/100 GbE&lt;/strong> to the switch. Aggregate VRAM: &lt;strong>320 GB&lt;/strong>.&lt;/p>
&lt;p>The VRAM budget is not free. A first reasonable split for a stack that serves one large general LLM, a medium code-specialised model, embeddings and a reranker, with room for multi-LoRA and for the KV cache:&lt;/p>
&lt;table>
&lt;thead>
&lt;tr>
&lt;th>Component&lt;/th>
&lt;th>Reference model&lt;/th>
&lt;th>Quant&lt;/th>
&lt;th>Model weights&lt;/th>
&lt;th>Reserved KV cache&lt;/th>
&lt;th>Total VRAM&lt;/th>
&lt;/tr>
&lt;/thead>
&lt;tbody>
&lt;tr>
&lt;td>General LLM (TP=4)&lt;/td>
&lt;td>70B-instruct&lt;/td>
&lt;td>FP8 W8A8&lt;/td>
&lt;td>70 GB&lt;/td>
&lt;td>60 GB&lt;/td>
&lt;td>&lt;strong>130 GB&lt;/strong>&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>Code LLM (TP=2)&lt;/td>
&lt;td>32B-coder&lt;/td>
&lt;td>FP8 W8A8&lt;/td>
&lt;td>32 GB&lt;/td>
&lt;td>28 GB&lt;/td>
&lt;td>&lt;strong>60 GB&lt;/strong>&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>Embeddings&lt;/td>
&lt;td>multilingual-e5-large&lt;/td>
&lt;td>FP16&lt;/td>
&lt;td>1.3 GB&lt;/td>
&lt;td>n/a&lt;/td>
&lt;td>&lt;strong>8 GB&lt;/strong> (×2 replicas)&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>Reranker&lt;/td>
&lt;td>bge-reranker-v2-m3&lt;/td>
&lt;td>FP16&lt;/td>
&lt;td>0.6 GB&lt;/td>
&lt;td>n/a&lt;/td>
&lt;td>&lt;strong>4 GB&lt;/strong>&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>Multi-LoRA pool (over the general LLM)&lt;/td>
&lt;td>up to 16 adapters&lt;/td>
&lt;td>bf16&lt;/td>
&lt;td>16 × 0.4 GB ≈ 6 GB&lt;/td>
&lt;td>reuses the LLM&amp;rsquo;s KV&lt;/td>
&lt;td>&lt;strong>6 GB&lt;/strong>&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>Reserved for fragmentation + overhead&lt;/td>
&lt;td>&lt;/td>
&lt;td>&lt;/td>
&lt;td>&lt;/td>
&lt;td>&lt;/td>
&lt;td>&lt;strong>~30 GB&lt;/strong>&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>&lt;strong>Total committed&lt;/strong>&lt;/td>
&lt;td>&lt;/td>
&lt;td>&lt;/td>
&lt;td>&lt;/td>
&lt;td>&lt;/td>
&lt;td>&lt;strong>~238 GB / 320 GB&lt;/strong>&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>&lt;strong>Free headroom&lt;/strong>&lt;/td>
&lt;td>&lt;/td>
&lt;td>&lt;/td>
&lt;td>&lt;/td>
&lt;td>&lt;/td>
&lt;td>&lt;strong>~82 GB&lt;/strong>&lt;/td>
&lt;/tr>
&lt;/tbody>
&lt;/table>
&lt;p>The 26 % of free headroom is not waste: it is what lets the vLLM scheduler avoid preempting requests under moderate pressure, lets continuous batching group large batches without aborting, and lets a failover from the other site promote a standby without OOM.&lt;/p>
&lt;p>&lt;strong>Expected throughput&lt;/strong>, with the 70B general LLM in FP8 and tensor parallel 4, on an H100 SXM with continuous batching active and prefix caching at 35–55 % (typical in multi-turn chat with shared system prompts):&lt;/p>
$$
\text{aggregate tokens/second} \approx 1500 \text{ to } 2500
$$
&lt;p>for concurrency between 32 and 64 requests, with sub-second TTFT P95 on short prompts (&amp;lt;2k tokens) and TPOT P95 around &lt;strong>40–60 ms/token&lt;/strong> as perceived by the client. These numbers are &lt;strong>reasonable orders of magnitude&lt;/strong>, not guarantees: real throughput depends on the prompt mix, on whether speculative decoding (EAGLE-3) is active and burnt-in, and on the network cost between gateway and inference pods.&lt;/p>
&lt;p>&lt;strong>Embeddings throughput&lt;/strong> on two replicas of &lt;code>multilingual-e5-large&lt;/code> with dynamic batching:&lt;/p>
$$
\text{embeddings/second} \approx 3000 \text{ to } 6000 \quad (\text{optimal batch} \sim 64)
$$
&lt;p>Enough to reindex a corpus of &lt;strong>1 million documents&lt;/strong> in roughly an hour, assuming chunks of 512 tokens and two chunks per document on average. For corpora of tens of millions of documents, re-embedding is done by delta via CDC over the source (covered in &lt;a href="https://blog.lo0.es/en/posts/rag-corpus-curation-active-librarian/">RAG corpus curation&lt;/a>), not by full sweep.&lt;/p>
&lt;p>&lt;strong>Retrieval latency&lt;/strong> on Qdrant with HNSW (M=16, ef_construct=200) over a collection of 5 million 1024-dim vectors filtered by &lt;code>tenant_id&lt;/code>:&lt;/p>
$$
\text{P95 latency retrieve top-50} \approx 8 \text{ to } 25 \text{ ms}
$$
&lt;p>Below the cost of cross-encoder reranking (&lt;code>bge-reranker-v2-m3&lt;/code> over top-50 = roughly 30–60 ms more), and below any call to the LLM. The bottleneck in a well sized RAG pipeline is never the vector store: it is the LLM&amp;rsquo;s decoding.&lt;/p>
&lt;h2 id="final-diagram-the-complete-connected-stack">Final diagram: the complete connected stack&lt;/h2>
&lt;div class="diagram" style="max-width:820px;margin:1rem auto;">
&lt;svg viewBox="0 0 820 540" xmlns="http://www.w3.org/2000/svg" role="img" aria-label="complete diagram of the on-premise LLM inference stack with its seven layers connected">&lt;style>
.b{stroke:#333;stroke-width:1.4;rx:6}
.gw{fill:#ffd9b8;stroke:#a44}
.llm{fill:#ffe6c2;stroke:#a55}
.emb{fill:#fff0d0;stroke:#a66}
.data{fill:#dfe9f5;stroke:#356}
.obs{fill:#d8eecf;stroke:#373}
.ctrl{fill:#f5e3d8;stroke:#763}
.dep{fill:#ead8f5;stroke:#634}
.bg{fill:#fafafa;stroke:#bbb;rx:8}
.lbl{font:600 12px sans-serif;fill:#222}
.sm{font:11px sans-serif;fill:#222}
.tiny{font:600 10px sans-serif;fill:#222}
.arr{stroke:#666;stroke-width:1.4;fill:none;marker-end:url(#a)}
.otel{stroke:#1a73e8;stroke-width:1.4;fill:none;stroke-dasharray:3 2;marker-end:url(#ab)}
.ctrlarr{stroke:#c66;stroke-width:1.2;fill:none;stroke-dasharray:5 3;marker-end:url(#ac)}
&lt;/style>
&lt;defs>
&lt;marker id="a" 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;marker id="ab" 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="#1a73e8"/>&lt;/marker>
&lt;marker id="ac" 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="#c66"/>&lt;/marker>
&lt;/defs>
&lt;text x="410" y="22" text-anchor="middle" class="lbl">On-premise LLM inference stack — seven layers, stable contracts between them&lt;/text>
&lt;!-- Client -->
&lt;rect x="340" y="36" width="140" height="32" class="b gw"/>
&lt;text x="410" y="56" text-anchor="middle" class="sm">Client · OpenAI SDK&lt;/text>
&lt;path class="arr" d="M410,68 L410,82"/>
&lt;!-- Gateway -->
&lt;rect x="80" y="86" width="660" height="40" class="b gw"/>
&lt;text x="100" y="103" class="tiny">LAYER 1 · GATEWAY&lt;/text>
&lt;text x="410" y="120" text-anchor="middle" class="sm">Envoy AI Gateway · routing by body.model · JWT (Defguard) · rate-limit per tenant · trace_id injection&lt;/text>
&lt;!-- Inference + Embeddings + Reranker -->
&lt;rect x="80" y="146" width="320" height="100" class="b llm"/>
&lt;text x="100" y="163" class="tiny">LAYER 2 · LLM INFERENCE&lt;/text>
&lt;rect x="100" y="174" width="280" height="28" class="b" fill="#fff4e0"/>
&lt;text x="240" y="192" text-anchor="middle" class="sm">vLLM · general LLM 70B FP8 · TP=4 · multi-LoRA&lt;/text>
&lt;rect x="100" y="208" width="280" height="28" class="b" fill="#fff4e0"/>
&lt;text x="240" y="226" text-anchor="middle" class="sm">vLLM · code LLM 32B FP8 · TP=2 · structured out.&lt;/text>
&lt;rect x="420" y="146" width="320" height="100" class="b emb"/>
&lt;text x="440" y="163" class="tiny">LAYER 3 · EMBEDDINGS + RERANKER&lt;/text>
&lt;rect x="440" y="174" width="280" height="28" class="b" fill="#fffae8"/>
&lt;text x="580" y="192" text-anchor="middle" class="sm">Infinity · multilingual-e5-large 1024d · ×2&lt;/text>
&lt;rect x="440" y="208" width="280" height="28" class="b" fill="#fffae8"/>
&lt;text x="580" y="226" text-anchor="middle" class="sm">TEI · bge-reranker-v2-m3 (top-50 rerank)&lt;/text>
&lt;path class="arr" d="M250,126 L250,146"/>
&lt;path class="arr" d="M570,126 L570,146"/>
&lt;!-- Vector store + storage -->
&lt;rect x="80" y="266" width="660" height="80" class="b data"/>
&lt;text x="100" y="283" class="tiny">LAYER 4 · VECTOR STORE + RELATIONAL DATA + STORAGE&lt;/text>
&lt;rect x="100" y="294" width="160" height="40" class="b" fill="#eef3fb"/>
&lt;text x="180" y="319" text-anchor="middle" class="sm">Qdrant (HNSW)&lt;/text>
&lt;rect x="280" y="294" width="160" height="40" class="b" fill="#eef3fb"/>
&lt;text x="360" y="319" text-anchor="middle" class="sm">PostgreSQL (CNPG)&lt;/text>
&lt;rect x="460" y="294" width="160" height="40" class="b" fill="#eef3fb"/>
&lt;text x="540" y="319" text-anchor="middle" class="sm">MinIO (weights · adapters)&lt;/text>
&lt;rect x="640" y="294" width="100" height="40" class="b" fill="#eef3fb"/>
&lt;text x="690" y="319" text-anchor="middle" class="sm">Redis&lt;/text>
&lt;path class="arr" d="M580,246 L580,266"/>
&lt;!-- Observability -->
&lt;rect x="80" y="366" width="660" height="60" class="b obs"/>
&lt;text x="100" y="383" class="tiny">LAYER 5 · OBSERVABILITY (LLM-aware traces · metrics · flow logs)&lt;/text>
&lt;rect x="100" y="394" width="150" height="24" class="b" fill="#eaf5e2"/>
&lt;text x="175" y="411" text-anchor="middle" class="sm">Langfuse&lt;/text>
&lt;rect x="260" y="394" width="150" height="24" class="b" fill="#eaf5e2"/>
&lt;text x="335" y="411" text-anchor="middle" class="sm">OTel Collector&lt;/text>
&lt;rect x="420" y="394" width="150" height="24" class="b" fill="#eaf5e2"/>
&lt;text x="495" y="411" text-anchor="middle" class="sm">VictoriaMetrics + Grafana&lt;/text>
&lt;rect x="580" y="394" width="160" height="24" class="b" fill="#eaf5e2"/>
&lt;text x="660" y="411" text-anchor="middle" class="sm">Hubble + DCGM Exporter&lt;/text>
&lt;!-- OTel lines from layers to observability -->
&lt;path class="otel" d="M740,106 L770,106 L770,396 L740,396"/>
&lt;path class="otel" d="M400,196 L770,196" opacity="0.6"/>
&lt;path class="otel" d="M400,310 L770,310" opacity="0.6"/>
&lt;!-- GitOps control plane -->
&lt;rect x="80" y="446" width="380" height="40" class="b ctrl"/>
&lt;text x="100" y="463" class="tiny">LAYER 6 · GITOPS CONTROL PLANE&lt;/text>
&lt;text x="270" y="479" text-anchor="middle" class="sm">Forgejo → Flux → cert-manager · External Secrets · Kyverno&lt;/text>
&lt;!-- Dependency tracking -->
&lt;rect x="480" y="446" width="260" height="40" class="b dep"/>
&lt;text x="500" y="463" class="tiny">LAYER 7 · DEPENDENCY TRACKING&lt;/text>
&lt;text x="610" y="479" text-anchor="middle" class="sm">Hubble flows · Otterize intents&lt;/text>
&lt;!-- Control plane reconciliation → all layers -->
&lt;path class="ctrlarr" d="M270,446 L270,430 L40,430 L40,106 L80,106"/>
&lt;path class="ctrlarr" d="M610,446 L610,430 L780,430 L780,106 L740,106"/>
&lt;text x="60" y="505" class="tiny">Request flow&lt;/text>
&lt;line x1="155" y1="502" x2="180" y2="502" stroke="#666" stroke-width="1.4"/>
&lt;text x="240" y="505" class="tiny">OTel telemetry&lt;/text>
&lt;line x1="335" y1="502" x2="360" y2="502" stroke="#1a73e8" stroke-width="1.4" stroke-dasharray="3 2"/>
&lt;text x="430" y="505" class="tiny">GitOps reconciliation&lt;/text>
&lt;line x1="555" y1="502" x2="580" y2="502" stroke="#c66" stroke-width="1.2" stroke-dasharray="5 3"/>
&lt;/svg>
&lt;/div>
&lt;p>The solid lines are the request path: client → gateway → inference engine → vector store/embeddings → response. The dashed blue lines are telemetry: each component emits OTel to the collector, which routes traces to Langfuse, metrics to VictoriaMetrics and logs to Loki. The dashed red lines are reconciliation: the GitOps control plane keeps any layer in its declared state and warns of divergence.&lt;/p>
&lt;p>The diagram is not decorative: every arrow is a &lt;strong>stable contract&lt;/strong> between two layers. If one layer changes (vLLM → SGLang, multilingual-e5 → bge-m3, Qdrant → pgvector), the arrows hold. That stability of contracts is the architectural property that lets a team migrate components without breaking downstream apps.&lt;/p>
&lt;h2 id="typical-design-decisions-that-break-the-stack">Typical design decisions that break the stack&lt;/h2>
&lt;p>A short list of mistakes seen repeatedly in stacks that looked well designed on paper:&lt;/p>
&lt;p>&lt;strong>1. Coupling the client SDK to the inference engine.&lt;/strong> Dropping the gateway because &amp;ldquo;vLLM already speaks OpenAI-compatible&amp;rdquo; works on day one and hurts the day you have to add a fallback, a canary or a second model.&lt;/p>
&lt;p>&lt;strong>2. Sharing the LLM and embeddings endpoint.&lt;/strong> A &lt;code>qwen2.5-32b-Instruct&lt;/code> is chat-only; &lt;code>BadRequestError: &amp;quot;The model does not support Embeddings API&amp;quot;&lt;/code> is the cry of a design that confused the two layers.&lt;/p>
&lt;p>&lt;strong>3. Reusing the vector store collection when changing the embeddings model.&lt;/strong> Different dimensions do not allow &lt;code>upsert&lt;/code>. Versioning the collection by &lt;code>(model, dim, version)&lt;/code> is ugly but it saves the day of the change.&lt;/p>
&lt;p>&lt;strong>4. A &lt;code>try/except&lt;/code> wrapping an entire pipeline with the wrapper&amp;rsquo;s label.&lt;/strong> The log lies because the label is lexical, not causal. Every stage in its own &lt;code>try/except&lt;/code> with its own label and its own metric.&lt;/p>
&lt;p>&lt;strong>5. &lt;code>status: completed&lt;/code> with errors.&lt;/strong> The pipeline has to distinguish &lt;code>completed&lt;/code>, &lt;code>completed_with_errors&lt;/code> and &lt;code>failed&lt;/code>, and the alerting has to fire on the last two. Without this, observability exists in theory and does not warn in practice.&lt;/p>
&lt;p>&lt;strong>6. Images with a mutable tag.&lt;/strong> &lt;code>:latest&lt;/code> and &lt;code>:main&lt;/code> are not tags, they are aliases. Without a sha digest there is no reproducibility and no auditable SBOM.&lt;/p>
&lt;p>&lt;strong>7. Decommissioning a Service without a pre-decom check.&lt;/strong> The three-grep check (declarative + observed + live) takes two minutes and costs six days of incident when it is skipped.&lt;/p>
&lt;p>&lt;strong>8. Default &lt;code>limits.memory&lt;/code> on pods that load models.&lt;/strong> A sidecar loading &lt;code>sentence-transformers + torch + tokenizer&lt;/code> needs 2–4 GB; with &lt;code>limits.memory: 1Gi&lt;/code> you hit OOM on the first pod restart, and sometimes with no alert because the liveness probe answers through another route.&lt;/p>
&lt;p>All of them are variants of the same principle: the stack does not fail in its most expensive layer (inference, where nobody underestimates the cost), it fails in the cheap, boring layers (gateway, observability, GitOps, dependency tracking) where it is tempting to save.&lt;/p>
&lt;h2 id="applied-to-typical-on-premise-hardware-a-4h100-sxm-cluster">Applied to typical on-premise hardware: a 4×H100 SXM cluster&lt;/h2>
&lt;p>On the generic reference cluster (4×H100 SXM 80 GB, NVLink, 640 GB RAM), the suggested split into pods:&lt;/p>
&lt;div class="highlight">&lt;pre tabindex="0" class="chroma">&lt;code class="language-text" data-lang="text">&lt;span class="line">&lt;span class="cl">gpu-node-01 (4×H100 SXM, intra-node NVLink)
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">├── vllm-llm-general (TP=4) ~130 GB VRAM (4 GPUs)
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">└── (shares GPUs with the multi-LoRA pool on the same deployment)
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">gpu-node-02 (4×H100 SXM, second node)
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">├── vllm-llm-code (TP=2) ~60 GB VRAM (2 GPUs)
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">├── infinity-embeddings (×2) ~16 GB VRAM (shared on 1 GPU with optional MIG)
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">├── tei-reranker ~4 GB VRAM (cohabitant)
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">└── failover reserve ~120 GB VRAM free for canary / standby
&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>Not everything fits comfortably on a single node; two nodes with two H100 SXM each would be enough for the conservative setup, and the rest of the cluster (CPU-bound: gateway, vector store, observability, control plane) runs on GPU-free nodes.&lt;/p>
&lt;p>The operational rule: &lt;strong>inference concentrates&lt;/strong>, the rest of the stack distributes. Concentrating inference maximises the use of NVLink (cross-GPU tensor parallel without going through PCIe); distributing the rest prevents an event on the GPU node from taking the control plane down with it.&lt;/p>
&lt;p>An even more conservative configuration, for SMEs with a single 4×H100 SXM node as a starting point, serves the general LLM (TP=4) and embeddings/reranker cohabiting with MIG (Multi-Instance GPU to partition an H100 into hardware-isolated slices). The code LLM is deferred to a second phase. It is viable and cost-aware; what is not viable is doing without layers 5, 6 and 7.&lt;/p>
&lt;h2 id="what-we-have-not-covered-upcoming-posts">What we have not covered (upcoming posts)&lt;/h2>
&lt;p>This post focuses on the &lt;strong>static design&lt;/strong> of the stack. Some pieces still deserve an article of their own:&lt;/p>
&lt;ul>
&lt;li>&lt;strong>The multi-site active/standby plane&lt;/strong>: Cilium Cluster Mesh, cross-cluster Qdrant replication, realistic RTO/RPO, when active-active pays off and when it does not.&lt;/li>
&lt;li>&lt;strong>The continuous fine-tuning plane&lt;/strong>: how the LoRA pipeline closes the loop from production feedback to a promoted adapter, in the spirit of the &lt;a href="https://blog.lo0.es/en/posts/retrain-closing-the-loop-feedback-dataset-adapter/">retrain post&lt;/a>.&lt;/li>
&lt;li>&lt;strong>The safety/guardrails plane&lt;/strong>: where Llama Guard fits, Presidio for PII and XGrammar for guaranteed structured output, connected to layer 1, the gateway.&lt;/li>
&lt;li>&lt;strong>The cost plane&lt;/strong>: &lt;code>gen_ai.usage.*&lt;/code> instrumentation at tenant and model level, tokens/euro dashboards, GPU elasticity decisions via KEDA.&lt;/li>
&lt;li>&lt;strong>The compliance plane&lt;/strong>: how the stack maps to ENS High, NIS2 and ISO/IEC 42001 without turning the deployment into a compliance exercise that paralyses delivery.&lt;/li>
&lt;li>&lt;strong>Below the engine&lt;/strong>: the mini-series that opens the stack&amp;rsquo;s basement — &lt;a href="https://blog.lo0.es/en/posts/nvlink-nvswitch-nccl-cable-every-token-crosses/">the interconnect (NVLink/NCCL)&lt;/a>, &lt;a href="https://blog.lo0.es/en/posts/numa-hugepages-cpu-isolation-gpu-waits-kernel/">the host (NUMA, hugepages, CPU isolation)&lt;/a> and &lt;a href="https://blog.lo0.es/en/posts/cpu-memory-topology-manager-rke2/">the RKE2 resource managers&lt;/a> that pin each pod to the right NUMA node.&lt;/li>
&lt;/ul>
&lt;p>Each one falls into a different series on the blog and will be covered with the same discipline: a concrete piece, a justified decision, the typical mistake seen in practice.&lt;/p>
&lt;h2 id="references">References&lt;/h2>
&lt;ul>
&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 general framework this stack operates in.&lt;/li>
&lt;li>&lt;a href="https://blog.lo0.es/en/posts/oss-catalogue-llmops-six-stages-tool-by-tool/">The OSS catalogue for LLMOps in six stages&lt;/a> — a card per tool in the stack.&lt;/li>
&lt;li>&lt;a href="https://blog.lo0.es/en/posts/anatomy-llm-request-production-tour-six-stages/">Anatomy of an LLM request in production&lt;/a> — the same architecture seen from the perspective of an individual request.&lt;/li>
&lt;li>&lt;a href="https://blog.lo0.es/en/posts/parallel-catalogue-llmops-stages-open-source-hyperscalers/">OSS vs hyperscalers in LLMOps&lt;/a> — a comparison with the AWS/Azure/GCP stacks.&lt;/li>
&lt;li>&lt;a href="https://blog.lo0.es/en/posts/llm-tracing-opentelemetry-genai/">LLM tracing with OpenTelemetry GenAI&lt;/a> — the backbone of layer 5.&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 piece that gives per-tenant customisation without replicating the base.&lt;/li>
&lt;li>&lt;a href="https://blog.lo0.es/en/posts/rag-corpus-curation-active-librarian/">RAG corpus curation&lt;/a> and &lt;a href="https://blog.lo0.es/en/posts/reranker-hybrid-retrieval-five-chunks-llm-reads/">Reranker and hybrid retrieval&lt;/a> — everything that goes into layers 3-4 so that RAG does not degrade.&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> — the reason for the FP8 W8A8 in the sizing.&lt;/li>
&lt;/ul>
&lt;h3 id="relevant-official-documentation">Relevant official documentation&lt;/h3>
&lt;ul>
&lt;li>vLLM Production Stack — &lt;a href="https://docs.vllm.ai/">docs.vllm.ai&lt;/a>&lt;/li>
&lt;li>SGLang RadixAttention — &lt;a href="https://github.com/sgl-project/sglang">github.com/sgl-project/sglang&lt;/a>&lt;/li>
&lt;li>Envoy AI Gateway — &lt;a href="https://aigateway.envoyproxy.io/">aigateway.envoyproxy.io&lt;/a>&lt;/li>
&lt;li>Langfuse self-hosted — &lt;a href="https://langfuse.com/docs/self-hosting">langfuse.com/docs/self-hosting&lt;/a>&lt;/li>
&lt;li>OpenTelemetry Semantic Conventions for GenAI — &lt;a href="https://opentelemetry.io/docs/specs/semconv/gen-ai/">opentelemetry.io/docs/specs/semconv/gen-ai&lt;/a>&lt;/li>
&lt;li>Hubble flow observability — &lt;a href="https://docs.cilium.io/en/stable/observability/hubble/">docs.cilium.io/en/stable/observability/hubble&lt;/a>&lt;/li>
&lt;li>Otterize intent-based access — &lt;a href="https://docs.otterize.com/">docs.otterize.com&lt;/a>&lt;/li>
&lt;li>Flux GitOps toolkit — &lt;a href="https://fluxcd.io/">fluxcd.io&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>MoE inference: the call centre with 256 specialists and 8 taking each call — fundamentals, expert parallel and the economics of DeepSeek-V3</title><link>https://blog.lo0.es/en/posts/moe-inference-fundamentals-expert-parallel-deepseek-economics/</link><pubDate>Sat, 30 May 2026 08:00:00 +0200</pubDate><guid>https://blog.lo0.es/en/posts/moe-inference-fundamentals-expert-parallel-deepseek-economics/</guid><description>&lt;blockquote>
&lt;p>This post goes into an architectural decision in the Deploy stage of the &lt;a href="https://blog.lo0.es/en/posts/six-stage-llmops-pipeline-architecture-and-deep-dive/">six-stage LLMOps pipeline&lt;/a>. It complements those on &lt;a href="https://blog.lo0.es/en/posts/kv-cache-working-memory-llm-inference/">KV cache&lt;/a> (which does not change with MoE), &lt;a href="https://blog.lo0.es/en/posts/flashattention-librarian-io-awareness-async-blackwell-asymmetry/">FlashAttention&lt;/a> (still the attention kernel, also dense in every current MoE), &lt;a href="https://blog.lo0.es/en/posts/quantization-llm-inference-fp8-int4-gguf/">Quantization&lt;/a> (FP8/NVFP4 over expert weights is what lets DeepSeek-V3 fit into a modest cluster) and &lt;a href="https://blog.lo0.es/en/posts/speculative-decoding-fundamentals-maths-state-of-play/">Speculative decoding&lt;/a> (MTP in DeepSeek-V3 is native speculative decoding).&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(#moem)}.cyc{stroke:#888;stroke-width:1.2;fill:none;stroke-dasharray:4 2;marker-end:url(#moem)}&lt;/style>
&lt;defs>&lt;marker id="moem" 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 · compute sparsity without memory sparsity&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>A Mixture of Experts (MoE) replaces the transformer&amp;rsquo;s dense FFN layer with &lt;strong>N parallel experts plus a router&lt;/strong> that picks the &lt;code>k&lt;/code> experts relevant to each token. The operational consequence is that the model&amp;rsquo;s &lt;strong>total&lt;/strong> parameters (how many were trained and how many must be loaded into VRAM) decouple from the &lt;strong>active parameters per token&lt;/strong> (how many take part in each forward). DeepSeek-V3 has 671 B total but only 37 B active per token; Qwen3-235B-A22B is 235 B total and 22 B active; Mixtral 8x22B is 141 B and 39 B; Llama 4 Maverick is 400 B and 17 B. Compute per token falls to &lt;code>2·N_active&lt;/code> FLOPs, half that of a dense Llama-3-70B in DeepSeek-V3&amp;rsquo;s case, but memory stays proportional to &lt;code>N_total&lt;/code>: MoE &lt;em>sparsity&lt;/em> is &lt;strong>compute sparsity, not memory sparsity&lt;/strong>. Since each expert sees only &lt;code>batch·k/N&lt;/code> tokens per step, the memory-bound regime persists at a much higher batch than in dense. Serving DeepSeek-V3 671 B FP8 at low latency requires &lt;strong>expert parallel&lt;/strong> (EP) at scale, EP=32 in prefill and EP=144 in decode in DeepSeek&amp;rsquo;s production deployment, where the dominant bottleneck stops being HBM and becomes the &lt;strong>all-to-all of dispatch and combine&lt;/strong> between GPUs. As of May 2026, the pieces that make this viable are &lt;strong>DeepEP&lt;/strong> (FP8-native all-to-all CUDA kernels, intra-node NVLink plus inter-node RDMA), &lt;strong>EPLB&lt;/strong> (balancing by replicating &lt;em>hot&lt;/em> experts) and &lt;strong>Wide-EP&lt;/strong> on NVL72 (72 GPUs in one coherent NVLink domain). This post takes apart the mechanism, the maths (FLOPs/token, all-to-all bytes), the updated table of MoE models as of May 2026, the parallelisms (TP / EP / DP+EP / Wide-EP), the operational pitfalls (memory wall, cold experts, imbalance) and the real reproducible numbers on H100, B200 and GB200 NVL72.&lt;/p>
&lt;h2 id="the-analogy-the-call-centre-with-256-specialists">The analogy: the call centre with 256 specialists&lt;/h2>
&lt;p>A serious call centre handling complex technical queries runs on &lt;strong>specialists&lt;/strong>, not generalists. You have 256 operators on the payroll, each an expert in their sub-domain: employment law, mortgages, income tax, probate, and so on. But each incoming call only needs the attention of &lt;strong>eight&lt;/strong> specific specialists (top-8 routing): a client&amp;rsquo;s query about tax relief on a mortgage for an inherited second home involves the income tax person, the mortgage person, the capital gains person, and so on. The receptionist (the router) listens to the first second of the call, decides which eight specialists are relevant, conferences them in and combines their answers with weights.&lt;/p>
&lt;p>Three immediate consequences, identical to those of MoE in production:&lt;/p>
&lt;ol>
&lt;li>&lt;strong>You pay 256 salaries&lt;/strong> (every expert is loaded in VRAM) but you only &lt;strong>spend the time of 8&lt;/strong> (only k experts take part in the forward of each token). Your payroll cost scales with &lt;code>N_total&lt;/code>; your cost per minute of call scales with &lt;code>N_active&lt;/code>.&lt;/li>
&lt;li>&lt;strong>If all the operators fit in one office&lt;/strong> (a single GPU), fine: paper just moves between desks. But &lt;strong>if they do not fit&lt;/strong>, you have to open branches: the conference call between operators in different branches goes over a telephone line between buildings (intra-node NVLink, inter-node InfiniBand) and that line &lt;strong>becomes the dominant bottleneck&lt;/strong> once dozens of branches are coordinating.&lt;/li>
&lt;li>&lt;strong>If one week every client asks about income tax&lt;/strong>, the three income tax operators saturate while the probate ones sit idle. You need &lt;strong>a system that replicates the hot operators&lt;/strong> across several branches to absorb peaks, and that &lt;strong>pairs hot with cold&lt;/strong> so that no branch goes idle.&lt;/li>
&lt;/ol>
&lt;p>The receptionist is the &lt;strong>router&lt;/strong>. The branches are the GPUs. The conference call between branches is the &lt;strong>all-to-all&lt;/strong> (dispatch plus combine). The system for replicating hot operators is &lt;strong>EPLB&lt;/strong>. The good telephone line is &lt;strong>DeepEP&lt;/strong>. And when someone designs a building where all 72 branches fit together with very fast internal cabling, that is &lt;strong>NVL72&lt;/strong>.&lt;/p>
&lt;h2 id="the-bare-mechanism">The bare mechanism&lt;/h2>
&lt;p>A classic MoE layer replaces the dense FFN (&lt;code>y = FFN(x) = down(act(up(x) · gate(x)))&lt;/code> in SwiGLU) with:&lt;/p>
&lt;ol>
&lt;li>&lt;strong>Router&lt;/strong>: a linear projection &lt;code>g(x) = W_router · x&lt;/code> produces &lt;code>N&lt;/code> scores. A softmax over those scores gives affinity probabilities.&lt;/li>
&lt;li>&lt;strong>Top-k selection&lt;/strong>: the &lt;code>k&lt;/code> experts with the highest score are chosen. Their &lt;code>softmax_topk&lt;/code> weights are normalised to sum to 1.&lt;/li>
&lt;li>&lt;strong>Parallel compute&lt;/strong>: each of the &lt;code>k&lt;/code> selected experts runs its own FFN over &lt;code>x&lt;/code>. The &lt;code>N - k&lt;/code> not selected sit idle for this token.&lt;/li>
&lt;li>&lt;strong>Weighted combination&lt;/strong>: the output is &lt;code>y = Σ_{i ∈ topk} w_i · Expert_i(x)&lt;/code>.&lt;/li>
&lt;/ol>
&lt;p>Variants the market consolidated in 2025-2026:&lt;/p>
&lt;ul>
&lt;li>&lt;strong>Shared experts&lt;/strong>: on top of the &lt;code>k&lt;/code> routed ones, there are &lt;code>s&lt;/code> experts that always run (1 or 2). They capture general knowledge every token needs. DeepSeek-V3 uses 1 shared + 8 routed; Mixtral uses 0 shared.&lt;/li>
&lt;li>&lt;strong>Fine-grained experts&lt;/strong>: more small experts instead of a few large ones (DeepSeek: 256 vs Mixtral: 8). This allows better specialisation because the top-k combination covers more sub-domains.&lt;/li>
&lt;li>&lt;strong>Auxiliary-loss-free routing&lt;/strong> (DeepSeek-V3): instead of adding a balancing loss during training, it dynamically adjusts a bias in the routing. It keeps balance without polluting the main loss.&lt;/li>
&lt;/ul>
&lt;p>Attention is still &lt;strong>dense&lt;/strong> in every current MoE model (Mixtral, DeepSeek, Qwen3-MoE, Llama 4, Kimi K2). Only the FFN is replaced. That is why FlashAttention v3/v4 is still used as-is; DeepSeek&amp;rsquo;s MLA is an optimisation orthogonal to MoE.&lt;/p>
&lt;h2 id="the-relevant-moe-models-in-may-2026">The relevant MoE models in May 2026&lt;/h2>
&lt;table>
&lt;thead>
&lt;tr>
&lt;th>Model&lt;/th>
&lt;th>Date&lt;/th>
&lt;th>Total&lt;/th>
&lt;th>Active&lt;/th>
&lt;th>Experts&lt;/th>
&lt;th>Top-k&lt;/th>
&lt;th>Shared&lt;/th>
&lt;th>Notes&lt;/th>
&lt;/tr>
&lt;/thead>
&lt;tbody>
&lt;tr>
&lt;td>Mixtral 8x7B&lt;/td>
&lt;td>Dec 2023&lt;/td>
&lt;td>47 B&lt;/td>
&lt;td>13 B&lt;/td>
&lt;td>8&lt;/td>
&lt;td>2&lt;/td>
&lt;td>0&lt;/td>
&lt;td>The one that normalised MoE in open weights&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>Mixtral 8x22B&lt;/td>
&lt;td>Apr 2024&lt;/td>
&lt;td>141 B&lt;/td>
&lt;td>39 B&lt;/td>
&lt;td>8&lt;/td>
&lt;td>2&lt;/td>
&lt;td>0&lt;/td>
&lt;td>—&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>Grok-1 (open)&lt;/td>
&lt;td>Mar 2024&lt;/td>
&lt;td>314 B&lt;/td>
&lt;td>~86 B&lt;/td>
&lt;td>8&lt;/td>
&lt;td>2&lt;/td>
&lt;td>0&lt;/td>
&lt;td>Weights opened by xAI&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>DeepSeekMoE 16B&lt;/td>
&lt;td>Jan 2024&lt;/td>
&lt;td>16 B&lt;/td>
&lt;td>2.8 B&lt;/td>
&lt;td>64+2&lt;/td>
&lt;td>6&lt;/td>
&lt;td>2&lt;/td>
&lt;td>Introduces fine-grained + shared&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>DeepSeek-V2&lt;/td>
&lt;td>May 2024&lt;/td>
&lt;td>236 B&lt;/td>
&lt;td>21 B&lt;/td>
&lt;td>160+2&lt;/td>
&lt;td>6&lt;/td>
&lt;td>2&lt;/td>
&lt;td>+ MLA&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>Snowflake Arctic&lt;/td>
&lt;td>Apr 2024&lt;/td>
&lt;td>480 B&lt;/td>
&lt;td>17 B&lt;/td>
&lt;td>128&lt;/td>
&lt;td>2&lt;/td>
&lt;td>10 B dense residual&lt;/td>
&lt;td>Dense-MoE hybrid&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>Hunyuan-Large&lt;/td>
&lt;td>Nov 2024&lt;/td>
&lt;td>389 B&lt;/td>
&lt;td>52 B&lt;/td>
&lt;td>16+1&lt;/td>
&lt;td>1+shared&lt;/td>
&lt;td>1&lt;/td>
&lt;td>Cross-layer KV cache&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>&lt;strong>DeepSeek-V3&lt;/strong>&lt;/td>
&lt;td>Dec 2024&lt;/td>
&lt;td>671 B&lt;/td>
&lt;td>37 B&lt;/td>
&lt;td>256+1&lt;/td>
&lt;td>8&lt;/td>
&lt;td>1&lt;/td>
&lt;td>MLA + MTP, aux-loss-free, FP8 training&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>Llama 4 Scout&lt;/td>
&lt;td>Apr 2025&lt;/td>
&lt;td>109 B&lt;/td>
&lt;td>17 B&lt;/td>
&lt;td>16&lt;/td>
&lt;td>1+shared&lt;/td>
&lt;td>1&lt;/td>
&lt;td>Designed for 1 H100 node&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>Llama 4 Maverick&lt;/td>
&lt;td>Apr 2025&lt;/td>
&lt;td>400 B&lt;/td>
&lt;td>17 B&lt;/td>
&lt;td>128&lt;/td>
&lt;td>1+shared&lt;/td>
&lt;td>1&lt;/td>
&lt;td>—&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>Qwen3-235B-A22B&lt;/td>
&lt;td>Apr 2025&lt;/td>
&lt;td>235 B&lt;/td>
&lt;td>22 B&lt;/td>
&lt;td>128&lt;/td>
&lt;td>8&lt;/td>
&lt;td>0&lt;/td>
&lt;td>—&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>Kimi K2 (Moonshot)&lt;/td>
&lt;td>2025&lt;/td>
&lt;td>1 T&lt;/td>
&lt;td>32 B&lt;/td>
&lt;td>384&lt;/td>
&lt;td>8&lt;/td>
&lt;td>(MLA)&lt;/td>
&lt;td>Trained with MuonClip&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>DeepSeek-V3.2-Exp&lt;/td>
&lt;td>Sep 2025&lt;/td>
&lt;td>671 B&lt;/td>
&lt;td>37 B&lt;/td>
&lt;td>256+1&lt;/td>
&lt;td>8&lt;/td>
&lt;td>1&lt;/td>
&lt;td>+ DeepSeek Sparse Attention&lt;/td>
&lt;/tr>
&lt;/tbody>
&lt;/table>
&lt;p>Three operational observations:&lt;/p>
&lt;ol>
&lt;li>&lt;strong>The active/total ratio falls with every generation&lt;/strong>: Mixtral 8x7B was 28 %, DeepSeek-V3 is 5.5 %, Llama 4 Maverick is 4.3 %. What the industry discovered is that &lt;strong>sparsity can be very aggressive without losing quality&lt;/strong>, provided &lt;code>N&lt;/code> is large enough and the routing is well learned. Fine specialisation is worth more than per-expert capacity.&lt;/li>
&lt;li>&lt;strong>The flagships of the large providers (OpenAI, Anthropic, Google) are almost certainly MoE&lt;/strong>, but the architectures are not published. The only semi-credible leak (Hotz, Jul 2023) claimed GPT-4 was an 8×~220 B MoE; no official confirmation.&lt;/li>
&lt;li>&lt;strong>DeepSeek-V3 marks the inflection point&lt;/strong>: open weights, frontier-class, MoE with aggressive sparsity and compatible with FP8 quantisation. It is the model that forced the ecosystem (vLLM, SGLang, TensorRT-LLM) to optimise wide-EP in 2025.&lt;/li>
&lt;/ol>
&lt;h2 id="the-maths-that-matters">The maths that matters&lt;/h2>
&lt;p>Three numbers drive the whole architectural decision with MoE.&lt;/p>
&lt;p>&lt;strong>Compute per token.&lt;/strong> A dense transformer spends roughly &lt;code>2N&lt;/code> FLOPs per token in the forward pass (with &lt;code>N&lt;/code> = total parameters). A MoE spends &lt;code>2·N_active&lt;/code> FLOPs per token (the &lt;code>N_total - N_active&lt;/code> experts do not take part). For DeepSeek-V3: 2 × 37 B = &lt;strong>74 GFLOPs/token&lt;/strong>. For dense Llama-3-70B: 2 × 70 B = &lt;strong>140 GFLOPs/token&lt;/strong>. &lt;strong>DeepSeek does half the compute per token&lt;/strong> of Llama-3-70B while offering capacity equivalent to a model almost 10 × larger.&lt;/p>
&lt;p>&lt;strong>Total memory.&lt;/strong> But &lt;strong>all&lt;/strong> the experts must be loaded in VRAM at some point in the forward pass. The sparsity is in compute, not memory. Weight memory:&lt;/p>
$$\text{Memory}_{\text{weights}} \approx N_{\text{total}} \cdot \text{bytes per param}$$
&lt;p>For DeepSeek-V3 FP8 (1 byte): 685 GB. It does not fit in 8×H100 SXM (640 GB). You need either extra quantisation or 16+ GPUs. For Mixtral 8x22B FP8: 141 GB, comfortably inside 2×H100. For Qwen3-235B-A22B FP8: 235 GB, fits in 4×H100.&lt;/p>
&lt;p>&lt;strong>All-to-all bytes per MoE layer.&lt;/strong> When experts are spread across GPUs (EP), each token has to travel to the GPUs where its &lt;code>k&lt;/code> experts live. The volume per MoE layer is roughly:&lt;/p>
$$\text{bytes a2a/layer} \approx 2 \cdot \text{batch} \cdot \text{seq len} \cdot d_{\text{model}} \cdot k \cdot \text{bytes}$$
&lt;p>(the factor 2 is dispatch plus combine). For DeepSeek-V3 with &lt;code>d_model = 7168&lt;/code>, &lt;code>k = 8&lt;/code>, &lt;code>batch = 4096&lt;/code>, FP8 (1 byte): each MoE layer moves ~470 MB; multiplied by 61 MoE layers in DeepSeek-V3, the complete step adds &lt;strong>~29 GB of inter-GPU traffic&lt;/strong> in MoE comms alone. That is of the order of a GPU&amp;rsquo;s HBM bandwidth in a single step. This is why bandwidth and NVLink/InfiniBand topology determine how large EP can be before comms saturate.&lt;/p>
&lt;p>&lt;strong>Persistent memory-bound regime.&lt;/strong> Here is the least intuitive consequence. In dense, raising the batch moves the operation from memory-bound to compute-bound: with enough tokens per batch, the weights are amortised over more operations. In MoE, each expert sees only &lt;code>batch · k / N&lt;/code> tokens. For Qwen3-235B (k=8, N=128) with batch=128, each expert processes &lt;strong>8 tokens per step&lt;/strong>. To reach the compute-bound regime of the tensor cores (arithmetic intensity ~100-200 FLOPs/byte) you need batches in the &lt;strong>thousands&lt;/strong>, around 1,600 according to recent estimates for Qwen3 (Memory-Bound MoE Serving, arXiv:2512.09277). The operational consequence: &lt;strong>MoE scales throughput with batch more linearly than dense&lt;/strong>, but it also needs far larger batches to get close to its compute ceiling.&lt;/p>
&lt;h2 id="expert-parallel-and-the-all-to-all-bottleneck">Expert parallel and the all-to-all bottleneck&lt;/h2>
&lt;p>There are three main ways to parallelise a MoE across GPUs:&lt;/p>
&lt;p>&lt;strong>Tensor Parallel (TP, Megatron-style).&lt;/strong> Splits each weight matrix across GPUs by width or height. Every GPU processes all tokens and all layers. Comms per layer: 2 all-reduces. It works well for dense; for MoE it leaves the routing sparsity unexploited (each GPU holds fragments of every expert).&lt;/p>
&lt;p>&lt;strong>Expert Parallel (EP).&lt;/strong> Splits &lt;strong>whole experts&lt;/strong> across GPUs: 256 experts / 32 GPUs = 8 experts per GPU. Each token, after routing, travels to the GPUs where its top-k &lt;code>k&lt;/code> experts live. Comms per MoE layer: an &lt;strong>all-to-all dispatch&lt;/strong> (tokens towards experts) plus an &lt;strong>all-to-all combine&lt;/strong> (outputs back). The cost grows with EP size and becomes the bottleneck when EP &amp;gt; 8.&lt;/p>
&lt;p>&lt;strong>DP + EP / Wide-EP.&lt;/strong> Replicates the model across DP groups; inside each group, EP shards the experts. &lt;strong>Wide-EP&lt;/strong> takes EP to the full dimension of a rack (NVL72, 72 GPUs in a coherent NVLink domain). Each expert receives more tokens per step, so arithmetic intensity in each expert&amp;rsquo;s GEMMs improves and per-GPU throughput rises. vLLM reports &lt;strong>1.8× throughput per GPU with wide-EP&lt;/strong> against smaller setups on DeepSeek-V3 (vLLM blog, Dec 2025).&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="Expert Parallel with all-to-all dispatch and combine">
&lt;style>
.gpu{fill:#eaf3ff;stroke:#1f5fa8;stroke-width:1.4;rx:8}
.exp{fill:#fff4d6;stroke:#a48000;stroke-width:1.2;rx:4}
.tok{fill:#cdebd0;stroke:#2a7a40;stroke-width:1.2;rx:4}
.router{fill:#e6d0ff;stroke:#5a2db0;stroke-width:1.4;rx:6}
.lbl{font:600 12px sans-serif;fill:#222}
.sub{font:400 10px sans-serif;fill:#555}
.disp{stroke:#cc6622;stroke-width:1.5;fill:none;marker-end:url(#mma)}
.comb{stroke:#226699;stroke-width:1.5;fill:none;stroke-dasharray:4 2;marker-end:url(#mma)}
&lt;/style>
&lt;defs>&lt;marker id="mma" 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">MoE layer with Expert Parallel: EP=4 (4 GPUs, 8 experts/GPU)&lt;/text>
&lt;rect x="20" y="40" width="170" height="60" class="router"/>
&lt;text x="105" y="62" text-anchor="middle" class="lbl">Router (W_router)&lt;/text>
&lt;text x="105" y="78" text-anchor="middle" class="sub">softmax → top-k=2&lt;/text>
&lt;text x="105" y="92" text-anchor="middle" class="sub">per token&lt;/text>
&lt;rect x="20" y="115" width="80" height="35" class="tok"/>&lt;text x="60" y="138" text-anchor="middle" class="lbl">tokens&lt;/text>
&lt;text x="220" y="60" class="sub">all-to-all dispatch (FP8)&lt;/text>
&lt;path class="disp" d="M195,75 L300,160"/>
&lt;path class="disp" d="M195,80 L450,160"/>
&lt;path class="disp" d="M195,85 L600,160"/>
&lt;path class="disp" d="M195,90 L720,160"/>
&lt;rect x="240" y="160" width="120" height="120" class="gpu"/>
&lt;text x="300" y="178" text-anchor="middle" class="lbl">GPU 0&lt;/text>
&lt;rect x="252" y="190" width="42" height="20" class="exp"/>&lt;text x="273" y="204" text-anchor="middle" class="sub">E0&lt;/text>
&lt;rect x="296" y="190" width="42" height="20" class="exp"/>&lt;text x="317" y="204" text-anchor="middle" class="sub">E1&lt;/text>
&lt;rect x="252" y="215" width="42" height="20" class="exp"/>&lt;text x="273" y="229" text-anchor="middle" class="sub">E2&lt;/text>
&lt;rect x="296" y="215" width="42" height="20" class="exp"/>&lt;text x="317" y="229" text-anchor="middle" class="sub">E3&lt;/text>
&lt;text x="300" y="255" text-anchor="middle" class="sub">experts 0-7&lt;/text>
&lt;text x="300" y="270" text-anchor="middle" class="sub">(8 experts)&lt;/text>
&lt;rect x="390" y="160" width="120" height="120" class="gpu"/>
&lt;text x="450" y="178" text-anchor="middle" class="lbl">GPU 1&lt;/text>
&lt;rect x="402" y="190" width="42" height="20" class="exp"/>&lt;text x="423" y="204" text-anchor="middle" class="sub">E8&lt;/text>
&lt;rect x="446" y="190" width="42" height="20" class="exp"/>&lt;text x="467" y="204" text-anchor="middle" class="sub">E9&lt;/text>
&lt;text x="450" y="255" text-anchor="middle" class="sub">experts 8-15&lt;/text>
&lt;rect x="540" y="160" width="120" height="120" class="gpu"/>
&lt;text x="600" y="178" text-anchor="middle" class="lbl">GPU 2&lt;/text>
&lt;rect x="552" y="190" width="42" height="20" class="exp"/>&lt;text x="573" y="204" text-anchor="middle" class="sub">E16&lt;/text>
&lt;rect x="596" y="190" width="42" height="20" class="exp"/>&lt;text x="617" y="204" text-anchor="middle" class="sub">E17&lt;/text>
&lt;text x="600" y="255" text-anchor="middle" class="sub">experts 16-23&lt;/text>
&lt;rect x="660" y="160" width="100" height="120" class="gpu"/>
&lt;text x="710" y="178" text-anchor="middle" class="lbl">GPU 3&lt;/text>
&lt;rect x="668" y="190" width="42" height="20" class="exp"/>&lt;text x="689" y="204" text-anchor="middle" class="sub">E24&lt;/text>
&lt;rect x="712" y="190" width="42" height="20" class="exp"/>&lt;text x="733" y="204" text-anchor="middle" class="sub">E25&lt;/text>
&lt;text x="710" y="255" text-anchor="middle" class="sub">experts 24-31&lt;/text>
&lt;text x="220" y="310" class="sub">all-to-all combine (weighted sum returns to the origin)&lt;/text>
&lt;path class="comb" d="M300,280 L195,330"/>
&lt;path class="comb" d="M450,280 L195,330"/>
&lt;path class="comb" d="M600,280 L195,330"/>
&lt;path class="comb" d="M710,280 L195,330"/>
&lt;rect x="100" y="325" width="200" height="35" class="tok"/>
&lt;text x="200" y="346" text-anchor="middle" class="lbl">y = Σ w_i · Expert_i(x)&lt;/text>
&lt;/svg>
&lt;/div>
&lt;p>Quick decision table:&lt;/p>
&lt;table>
&lt;thead>
&lt;tr>
&lt;th>Case&lt;/th>
&lt;th>Recommendation&lt;/th>
&lt;/tr>
&lt;/thead>
&lt;tbody>
&lt;tr>
&lt;td>Small MoE that fits in 1 node (Mixtral 8x7B, Llama 4 Scout)&lt;/td>
&lt;td>TP only or EP=2 intra-node&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>Medium MoE (Mixtral 8x22B, Qwen3-235B) on 1-2 H100 nodes&lt;/td>
&lt;td>EP=8 intra-node, optionally TP=2&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>Large MoE (DeepSeek-V3) on a multi-node cluster&lt;/td>
&lt;td>TP × EP cross-node (e.g. TP=4 × EP=8 on 32 GPUs)&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>Large MoE with NVL72 available&lt;/td>
&lt;td>Wide-EP=72 (decode), EP=32 (prefill)&lt;/td>
&lt;/tr>
&lt;/tbody>
&lt;/table>
&lt;p>DeepSeek&amp;rsquo;s production deployment combines &lt;strong>Prefill EP=32&lt;/strong> (4 nodes × 8 GPUs) with &lt;strong>Decode EP=144&lt;/strong> (18 nodes × 8 GPUs). The prefill/decode split fits conceptually with &lt;a href="https://blog.lo0.es/en/posts/disaggregated-serving-prefill-decode-specialised-pods/">Disaggregated serving&lt;/a>: prefill is compute-bound and benefits from moderate EP; decode is memory-bound and benefits from Wide-EP, which raises the tokens per expert per step.&lt;/p>
&lt;h2 id="deepep-eplb-and-wide-ep-the-pieces-that-unblocked-2025">DeepEP, EPLB and Wide-EP: the pieces that unblocked 2025&lt;/h2>
&lt;p>&lt;strong>DeepEP&lt;/strong> (DeepSeek, opened in February 2025) is a library of all-to-all CUDA kernels optimised specifically for MoE EP. Four important properties:&lt;/p>
&lt;ul>
&lt;li>&lt;strong>Native FP8 dispatch&lt;/strong>: tokens travel in FP8, halving the bandwidth consumed.&lt;/li>
&lt;li>&lt;strong>Mixing intra-node NVLink with inter-node RDMA&lt;/strong>: the kernel picks the optimal route according to topology.&lt;/li>
&lt;li>&lt;strong>CPU bypass&lt;/strong>: the GPU→GPU dispatch goes over RDMA without touching the host.&lt;/li>
&lt;li>&lt;strong>Aligned with DeepSeek-V3&amp;rsquo;s group-limited gating&lt;/strong>: the routing prefers experts in the same node group where possible, minimising inter-node traffic.&lt;/li>
&lt;/ul>
&lt;p>In 2025 Tencent contributed optimisations that added +30 % on the normal kernels. There is a ROCm port for AMD.&lt;/p>
&lt;p>&lt;strong>EPLB (Expert Parallelism Load Balancer)&lt;/strong> solves the runtime imbalance problem: even when the model is well balanced in global distribution, &lt;strong>a specific batch can heavily activate certain experts&lt;/strong> (prompts in a single language, a single domain). EPLB &lt;strong>replicates the hot experts&lt;/strong> across several GPUs (redundant experts) and packs them heuristically with cold experts to minimise load variance. It has two modes: hierarchical (for prefill with medium EP) and global (for decode with Wide-EP). It is integrated into SGLang and vLLM.&lt;/p>
&lt;p>&lt;strong>Wide-EP on NVL72&lt;/strong> is the peak setup of 2026: 72 Blackwell GPUs in a coherent NVLink domain (1.8 TB/s bidirectional per GPU, intra-rack). Each expert receives many more tokens per step, so arithmetic intensity improves and so does throughput per GPU. Combined with NVFP4 over expert weights and FP8 over attention, SGLang reported on GB200 NVL72 &lt;strong>26,156 tok/s/GPU in prefill and 13,386 tok/s/GPU in decode&lt;/strong> for DeepSeek-V3 (LMSYS, Sep 2025), a &lt;strong>3.8× prefill and 4.8× decode versus H100&lt;/strong>.&lt;/p>
&lt;h2 id="operational-pitfalls">Operational pitfalls&lt;/h2>
&lt;p>&lt;strong>HBM fragmentation from expert weights.&lt;/strong> With EP=8 in 1 node, each GPU holds N/8 experts, each a tuple of matrices (&lt;code>gate&lt;/code>, &lt;code>up&lt;/code>, &lt;code>down&lt;/code>). If N=256, that is 32 FFNs per GPU. The allocator can fragment; in some cases you need a more conservative &lt;code>--gpu-memory-utilization&lt;/code> than usual.&lt;/p>
&lt;p>&lt;strong>Cold experts.&lt;/strong> In real distributions some experts are activated by &amp;lt;1 % of tokens. EPLB compensates by replicating hot ones, but the cold ones sit occupying HBM without taking part, &amp;ldquo;dead&amp;rdquo; memory. When real demand diverges a lot from the training distribution, this shows.&lt;/p>
&lt;p>&lt;strong>Continuous batching.&lt;/strong> It works with MoE, but the effective batch size per expert is &lt;code>batch_total · k / N&lt;/code>. With large N and a moderate batch, each expert sees very few tokens per step. You need a &lt;code>batch_total&lt;/code> far larger than in dense to match per-GPU throughput.&lt;/p>
&lt;p>&lt;strong>Speculative decoding + MoE.&lt;/strong> They interact &lt;strong>well&lt;/strong>. MoE sparsity keeps the memory-bound regime at high batch, which is exactly the regime where speculative decoding wins most. DeepSeek-V3 integrates &lt;strong>MTP&lt;/strong> (Multi-Token Prediction), speculative decoding native to the model, with no external draft, with acceptance around 85-90 % on the second token (see &lt;a href="https://blog.lo0.es/en/posts/speculative-decoding-fundamentals-maths-state-of-play/">Speculative decoding&lt;/a> for the mechanism).&lt;/p>
&lt;p>&lt;strong>MLA and FlashMLA are not MoE.&lt;/strong> DeepSeek-V3 combines MoE with &lt;strong>Multi-head Latent Attention&lt;/strong> (MLA), an attention variant that reduces the KV cache ~10× against standard MHA. MLA and MoE are orthogonal: MLA optimises attention, MoE optimises the FFN. For serving DeepSeek you use the specific &lt;strong>FlashMLA&lt;/strong> kernel (not FA3 directly).&lt;/p>
&lt;p>&lt;strong>DSA (DeepSeek Sparse Attention)&lt;/strong> in DeepSeek-V3.2-Exp (Sep 2025) introduces sparsity in attention too. The first time at the frontier that sparsity is applied in both attention and FFN.&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> Large MoEs are out of reach. What does fit: Mixtral 8x7B AWQ-INT4 (~24 GB of weights, no headroom for KV cache, so it really requires TP=2 across two 4090s); DeepSeekMoE 16B BF16 (~33 GB, does not fit whole; INT4 ~8 GB, fits with room to spare). The interesting case is &lt;strong>llama.cpp with &lt;code>--n-cpu-moe&lt;/code>&lt;/strong>: expert weights offloaded to RAM (CPU), attention and shared experts on the GPU. It lets you run DeepSeek-V3 IQ4 (~400 GB) on a workstation with 1-2 GPUs plus 512 GB of RAM at modest throughput, but it opens the door to a low-budget setup.&lt;/p>
&lt;p>&lt;strong>On a generic 4×H100 SXM cluster (320 GB, NVLink, native FP8).&lt;/strong> These fit comfortably:&lt;/p>
&lt;ul>
&lt;li>&lt;strong>Mixtral 8x22B FP8&lt;/strong> (~141 GB) with TP=2 or EP=4. Low latency, excellent throughput.&lt;/li>
&lt;li>&lt;strong>Qwen3-235B-A22B FP8&lt;/strong> (~235 GB) with TP=4 or EP=4. Plenty of room.&lt;/li>
&lt;li>&lt;strong>Llama 4 Scout 109B FP8&lt;/strong> (~110 GB) with TP=2 or TP=4. Designed by Meta to serve well on one H100 node.&lt;/li>
&lt;/ul>
&lt;p>What does &lt;strong>not&lt;/strong> fit in 4×H100: DeepSeek-V3 FP8 (~685 GB) without aggressive quantisation. Options: (1) scale to 8-16 H100; (2) use AWQ INT4 over routed experts plus FP16 over activated ones (~400 GB), which fits in 8×H100; (3) wait for Blackwell or use AMD MI300X (192 GB/GPU, so DeepSeek-V3 FP8 on 4×MI300X).&lt;/p>
&lt;p>The rule of thumb as of May 2026: &lt;strong>one 4×H100 node comfortably serves MoEs up to ~200 B total in FP8; for DeepSeek-V3 you have to scale to 2 nodes or wait for B200 NVL72&lt;/strong> (where the whole model fits with room for enormous batches).&lt;/p>
&lt;h2 id="what-we-have-not-covered">What we have not covered&lt;/h2>
&lt;ul>
&lt;li>&lt;strong>MLA and FlashMLA in detail&lt;/strong>: DeepSeek&amp;rsquo;s KV cache optimisation, orthogonal to MoE.&lt;/li>
&lt;li>&lt;strong>DeepSeek Sparse Attention (DSA)&lt;/strong> introduced in V3.2-Exp: the first production implementation of attention sparsity for frontier models.&lt;/li>
&lt;li>&lt;strong>MoE during training&lt;/strong>: load balancing losses, drop policies, auxiliary terms vs aux-loss-free, Kimi K2&amp;rsquo;s MuonClip.&lt;/li>
&lt;li>&lt;strong>MoE + LoRA&lt;/strong>: how adapter fine-tuning is done on a MoE, and what happens to the routing.&lt;/li>
&lt;li>&lt;strong>LLM-d&lt;/strong> and other open-source platforms that materialise Wide-EP on Kubernetes.&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: working memory&lt;/a> — structurally the KV cache is the same as in dense (attention is dense in every current MoE); MLA is the specific optimisation DeepSeek adds on top.&lt;/li>
&lt;li>&lt;a href="https://blog.lo0.es/en/posts/pagedattention-vllm-block-manager/">PagedAttention deep dive&lt;/a> — handling the KV cache in physical blocks stays identical under MoE; only the FFN changes.&lt;/li>
&lt;li>&lt;a href="https://blog.lo0.es/en/posts/flashattention-librarian-io-awareness-async-blackwell-asymmetry/">FlashAttention v1/v2/v3/v4&lt;/a> — the attention kernel is reused as-is; FlashMLA is the variant specific to DeepSeek&amp;rsquo;s MLA, adding a latent compression step to the FA pattern.&lt;/li>
&lt;li>&lt;a href="https://blog.lo0.es/en/posts/quantization-llm-inference-fp8-int4-gguf/">Quantization for LLM inference&lt;/a> — FP8/NVFP4 over expert weights is what makes DeepSeek-V3 fit into a modest cluster and what lets NVL72 reach its peak numbers.&lt;/li>
&lt;li>&lt;a href="https://blog.lo0.es/en/posts/speculative-decoding-fundamentals-maths-state-of-play/">Speculative decoding&lt;/a> — MTP in DeepSeek-V3 is native speculative decoding; MoE&amp;rsquo;s persistent memory-bound regime means speculative wins more in MoE than in dense at medium batch.&lt;/li>
&lt;li>&lt;a href="https://blog.lo0.es/en/posts/disaggregated-serving-prefill-decode-specialised-pods/">Disaggregated serving: prefill and decode&lt;/a> — DeepSeek&amp;rsquo;s real deployment separates prefill (EP=32) and decode (EP=144) pools; disaggregation is a prerequisite for Wide-EP.&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 other side of the coin. MoE needs far larger batches than dense for the same per-GPU throughput because each expert sees &lt;code>batch · k / N&lt;/code> tokens per step; the iterative scheduler is what makes it viable at scale.&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/capacity-planning-on-premise-llm-inference/">Capacity planning for on-premise LLM inference&lt;/a> — MoE sizing differs from dense: the &amp;ldquo;MoE case&amp;rdquo; section there quantifies the VRAM cost dominated by total weights and the TPOT dominated by active weights per token.&lt;/li>
&lt;li>&lt;a href="https://blog.lo0.es/en/posts/device-native-architectures-fine-grained-moe-pre-attention-router/">Device-native architectures: fine-grained MoE and pre-attention router&lt;/a> — MoE taken to the fine-grained extreme and designed from scratch for device: the pre-attention router prefetches experts from SSD in parallel with attention, hiding the storage I/O.&lt;/li>
&lt;/ul>
&lt;h2 id="references">References&lt;/h2>
&lt;ul>
&lt;li>Shazeer, N. et al. &lt;em>Outrageously Large Neural Networks: The Sparsely-Gated Mixture-of-Experts Layer&lt;/em>. ICLR 2017. &lt;a href="https://arxiv.org/abs/1701.06538">https://arxiv.org/abs/1701.06538&lt;/a>&lt;/li>
&lt;li>Lepikhin, D. et al. &lt;em>GShard: Scaling Giant Models with Conditional Computation&lt;/em>. ICLR 2021. &lt;a href="https://arxiv.org/abs/2006.16668">https://arxiv.org/abs/2006.16668&lt;/a>&lt;/li>
&lt;li>Fedus, W., Zoph, B., Shazeer, N. &lt;em>Switch Transformers: Scaling to Trillion Parameter Models with Simple and Efficient Sparsity&lt;/em>. JMLR 2022. &lt;a href="https://arxiv.org/abs/2101.03961">https://arxiv.org/abs/2101.03961&lt;/a>&lt;/li>
&lt;li>Gale, T., Narayanan, D., Young, C., Zaharia, M. &lt;em>MegaBlocks: Efficient Sparse Training with Mixture-of-Experts&lt;/em>. MLSys 2023. &lt;a href="https://arxiv.org/abs/2211.15841">https://arxiv.org/abs/2211.15841&lt;/a>&lt;/li>
&lt;li>Mistral AI. &lt;em>Mixtral of Experts&lt;/em>. 2024. &lt;a href="https://arxiv.org/abs/2401.04088">https://arxiv.org/abs/2401.04088&lt;/a>&lt;/li>
&lt;li>DeepSeek-AI. &lt;em>DeepSeekMoE: Towards Ultimate Expert Specialization in Mixture-of-Experts Language Models&lt;/em>. 2024. &lt;a href="https://arxiv.org/abs/2401.06066">https://arxiv.org/abs/2401.06066&lt;/a>&lt;/li>
&lt;li>DeepSeek-AI. &lt;em>DeepSeek-V2&lt;/em>. 2024. &lt;a href="https://arxiv.org/abs/2405.04434">https://arxiv.org/abs/2405.04434&lt;/a>&lt;/li>
&lt;li>DeepSeek-AI. &lt;em>DeepSeek-V3 Technical Report&lt;/em>. 2024. &lt;a href="https://arxiv.org/abs/2412.19437">https://arxiv.org/abs/2412.19437&lt;/a>&lt;/li>
&lt;li>Tencent. &lt;em>Hunyuan-Large&lt;/em>. 2024. &lt;a href="https://arxiv.org/abs/2411.02265">https://arxiv.org/abs/2411.02265&lt;/a>&lt;/li>
&lt;li>DeepEP repo: &lt;a href="https://github.com/deepseek-ai/DeepEP">https://github.com/deepseek-ai/DeepEP&lt;/a>&lt;/li>
&lt;li>EPLB repo: &lt;a href="https://github.com/deepseek-ai/EPLB">https://github.com/deepseek-ai/EPLB&lt;/a>&lt;/li>
&lt;li>DeepSeek open-infra-index, &lt;em>V3/R1 Inference System Overview&lt;/em>: &lt;a href="https://github.com/deepseek-ai/open-infra-index">https://github.com/deepseek-ai/open-infra-index&lt;/a>&lt;/li>
&lt;li>vLLM blog, &lt;em>Large-Scale Serving DeepSeek&lt;/em> (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 blog, &lt;em>WideEP en GB200&lt;/em> (Feb 2026): &lt;a href="https://blog.vllm.ai/2026/02/03/dsr1-gb200-part1.html">https://blog.vllm.ai/2026/02/03/dsr1-gb200-part1.html&lt;/a>&lt;/li>
&lt;li>LMSYS blog, &lt;em>DeepSeek 96 H100 PD+EP&lt;/em> (May 2025): &lt;a href="https://www.lmsys.org/blog/2025-05-05-large-scale-ep/">https://www.lmsys.org/blog/2025-05-05-large-scale-ep/&lt;/a>&lt;/li>
&lt;li>LMSYS blog, &lt;em>DeepSeek GB200 NVL72 part II&lt;/em> (Sep 2025): &lt;a href="https://www.lmsys.org/blog/2025-09-25-gb200-part-2/">https://www.lmsys.org/blog/2025-09-25-gb200-part-2/&lt;/a>&lt;/li>
&lt;li>NVIDIA Dynamo + GB200 NVL72 for MoE: &lt;a href="https://developer.nvidia.com/blog/how-nvidia-gb200-nvl72-and-nvidia-dynamo-boost-inference-performance-for-moe-models/">https://developer.nvidia.com/blog/how-nvidia-gb200-nvl72-and-nvidia-dynamo-boost-inference-performance-for-moe-models/&lt;/a>&lt;/li>
&lt;li>Tensor Economics, &lt;em>MoE Inference Economics from First Principles&lt;/em>: &lt;a href="https://www.tensoreconomics.com/p/moe-inference-economics-from-first">https://www.tensoreconomics.com/p/moe-inference-economics-from-first&lt;/a>&lt;/li>
&lt;li>Cohere, &lt;em>Why MoE models get more from speculative decoding&lt;/em>: &lt;a href="https://cohere.com/blog/mixture-of-experts-models-get-more-from-speculative-decoding">https://cohere.com/blog/mixture-of-experts-models-get-more-from-speculative-decoding&lt;/a>&lt;/li>
&lt;/ul></description></item><item><title>FlashAttention v1/v2/v3/v4: the librarian who never clears the desk — IO-awareness, async and Blackwell's asymmetry</title><link>https://blog.lo0.es/en/posts/flashattention-librarian-io-awareness-async-blackwell-asymmetry/</link><pubDate>Fri, 29 May 2026 17:00:00 +0200</pubDate><guid>https://blog.lo0.es/en/posts/flashattention-librarian-io-awareness-async-blackwell-asymmetry/</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/pagedattention-vllm-block-manager/">PagedAttention deep dive&lt;/a>. The KV cache post explains what is stored; PagedAttention, how it is managed in memory; FlashAttention, how the computation is executed. They are three different layers of the same problem and they stack multiplicatively.&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(#fam)}.cyc{stroke:#888;stroke-width:1.2;fill:none;stroke-dasharray:4 2;marker-end:url(#fam)}&lt;/style>
&lt;defs>&lt;marker id="fam" 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 · attention kernel, one layer below PagedAttention and KV cache&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 standard attention of a transformer has a structural problem on modern GPUs: when you look at it with a profiler, it is not compute-bound, it is &lt;strong>memory&lt;/strong>-bound. The &lt;code>S = QK^T&lt;/code> matrix of size &lt;code>N × N&lt;/code>, with N the sequence length, does not fit in the fast on-chip SRAM and forces round trips to HBM that dominate the total time. &lt;strong>FlashAttention&lt;/strong> is the family of kernels that avoids materialising that matrix by &lt;em>tiling&lt;/em> over Q, K and V, computing the softmax block by block with the &lt;em>online&lt;/em> version and keeping everything inside SRAM. Each version raises the utilisation ceiling: &lt;strong>FA1&lt;/strong> (Dao et al. 2022) abolished the N×N matrix and brought memory complexity down to O(N); &lt;strong>FA2&lt;/strong> (Dao 2023) parallelised along the sequence dimension and cut non-matmul FLOPs to get close to 70 % utilisation on A100; &lt;strong>FA3&lt;/strong> (Shah, Bikshandi, Zhang, Thakkar, Ramani, Dao 2024) exploited three Hopper-specific mechanisms, async WGMMA, TMA and FP8, to reach 740 TFLOPS BF16 (75 % of peak) and 1.2 PFLOPS FP8 on H100; &lt;strong>FA4&lt;/strong> (March 2026) rewrote the kernel from scratch for Blackwell, where the tensor core scales 2.25× but the SFU (where the softmax &lt;code>exp&lt;/code> runs) and the SMEM bandwidth do not scale at all. The answer is a software-emulated exponential that runs on tensor cores. The result on B200: 1,605 TFLOPS BF16, 1.3× faster than cuDNN 9.13 and 2× faster than FA3 on the same GPU. This post takes apart the why (memory roofline, IO complexity), the master analogy of the librarian, the minimum mathematics and the real numbers on H100 and B200.&lt;/p>
&lt;h2 id="the-analogy-the-librarian-who-never-clears-the-desk">The analogy: the librarian who never clears the desk&lt;/h2>
&lt;p>A large library with two zones: a very fast but small work desk (the librarian&amp;rsquo;s desk, 200 books fit on top of it), and a giant shelving system spanning three floors where everything lives (50 million books). The librarian has to cross-reference all the books in one thematic room and produce a summary.&lt;/p>
&lt;p>A &lt;strong>naive librarian&lt;/strong> does it the direct way: fetches every relevant book from the shelves, piles them on the work desk, and since they do not fit, leaves half of them on the floor, on the chair, on top of boxes. The day is spent running between the floor and the desk, opening and closing books, never able to concentrate. The work desk is extremely fast but underused, because most of the time the librarian is moving books between the floor and the desk. That is what &lt;strong>standard attention&lt;/strong> does: it materialises the &lt;code>S = QK^T&lt;/code> matrix in HBM and keeps coming back for it piece by piece.&lt;/p>
&lt;p>A &lt;strong>FlashAttention v1 librarian&lt;/strong> changes strategy: asks for one shelf at a time, brings it to the desk, reads what is needed, jots notes in a compact notebook (&amp;ldquo;from this shelf I care about this, this and this, with these relative weights&amp;rdquo;), returns the shelf to its place and brings the next one. The notebook is the only thing that carries over between shelves. Nothing is ever piled higher than the desk can hold. The trick that makes this possible is the &lt;em>online softmax&lt;/em>: instead of needing all the content at once in order to normalise, it keeps a running max and a running sum that are updated shelf by shelf.&lt;/p>
&lt;p>A &lt;strong>FlashAttention v2 librarian&lt;/strong> realises that several topics can be worked in parallel, because the desk is big and the shelves are independent along some axes. Three assistants are put to work, each with a notebook, each covering a different block of the room, and the results are combined at the end.&lt;/p>
&lt;p>A &lt;strong>FlashAttention v3 librarian&lt;/strong> manages to mechanise the flow: a conveyor belt with two stations is installed. While station A reads the current shelf and takes notes, station B already has the next shelf in transit from the shelving system. When A finishes, B hands over the new shelf with no waiting. This is the &lt;strong>ping-pong producer/consumer&lt;/strong> pattern: the TMA offloads the data loading and the consumer warps do the work in parallel with the loads. On top of that, the notes are written in lower-precision shorthand (FP8), because the pages that matter have already had an orthogonal pre-treatment that stops them losing precision where it hurts.&lt;/p>
&lt;p>A &lt;strong>FlashAttention v4 librarian&lt;/strong> discovers something new about the remodelled library (Blackwell): two much faster conveyor belts have been installed (tensor cores 2.25×), but the shorthand typewriter has not been upgraded (the SFU is unchanged). Now the bottleneck is writing the notes, not fetching the shelves. The fix is elegant: instead of using the shorthand machine, the notes are written with polynomial formulas that the tensor core itself can evaluate (software-emulated exponential). The belt no longer sits idle waiting for the machine.&lt;/p>
&lt;p>The analogy holds up with four mappings:&lt;/p>
&lt;ul>
&lt;li>&lt;strong>Work desk&lt;/strong> = SRAM per SM (228 KB on H100, 256 KB on B200).&lt;/li>
&lt;li>&lt;strong>Giant shelving system&lt;/strong> = HBM (3.35 TB/s on H100, 8 TB/s on B200).&lt;/li>
&lt;li>&lt;strong>Notebook with running max and sum&lt;/strong> = online softmax stats (&lt;code>m&lt;/code>, &lt;code>ℓ&lt;/code>).&lt;/li>
&lt;li>&lt;strong>Conveyor belt with two stations&lt;/strong> = TMA + WGMMA producer/consumer pipeline.&lt;/li>
&lt;/ul>
&lt;h2 id="why-standard-attention-was-the-bottleneck">Why standard attention was the bottleneck&lt;/h2>
&lt;p>The naive intuition, &amp;ldquo;attention is matmuls, and GPUs are good at matmuls&amp;rdquo;, is correct but incomplete. There are two matmuls (&lt;code>QK^T&lt;/code> and then &lt;code>softmax(S) V&lt;/code>), and in between a non-matmul operation (softmax) that requires materialising the intermediate matrix &lt;code>S&lt;/code> of size &lt;code>N × N&lt;/code>.&lt;/p>
&lt;p>Modern GPUs have a very concrete roofline. For H100 SXM5:&lt;/p>
&lt;ul>
&lt;li>Compute peak: 989 TFLOPS BF16 (tensor core, dense, without sparsity).&lt;/li>
&lt;li>Memory bandwidth: 3.35 TB/s HBM3.&lt;/li>
&lt;li>Break-even point (arithmetic intensity break-even): &lt;code>989 × 10¹² / (3.35 × 10¹²) ≈ 295 FLOP/byte&lt;/code>.&lt;/li>
&lt;/ul>
&lt;p>Any operation that fails to perform 295 operations for every byte it moves from HBM is memory-bound: the tensor core sits waiting for data. On B200 the ratio is similar (~281 FLOP/byte) because both compute and bandwidth went up.&lt;/p>
&lt;p>Standard attention materialising &lt;code>S&lt;/code> reads the matrix twice (once for the softmax, once to multiply by V) and writes it once. For Llama 3 70B with head dim &lt;code>d = 128&lt;/code> and context &lt;code>N = 128K&lt;/code>:&lt;/p>
&lt;ul>
&lt;li>Matrix &lt;code>S&lt;/code> per head per layer: &lt;code>N × N × 2 bytes = 128K × 128K × 2 = 34.36 GB&lt;/code>.&lt;/li>
&lt;li>80 layers × 64 Q-heads → aggregate HBM traffic (if it were materialised serially) on the order of TBs, prohibitive in transient.&lt;/li>
&lt;li>Even if it is not all materialised at once, the round trips dominate the time: effective arithmetic intensity well below 295 → memory-bound operation → tensor core underused at around 25 %.&lt;/li>
&lt;/ul>
&lt;p>&lt;strong>FlashAttention does not change the mathematics of attention, it changes the order of the operations so that S is never materialised in HBM&lt;/strong>. Its IO complexity is &lt;code>Θ(N²·d²/M)&lt;/code> with M = SRAM size per SM, against &lt;code>Θ(N·d + N²)&lt;/code> for standard attention. With d = 128 and M = 228 KB: an approximate reduction factor of M/d² ≈ 14× less HBM traffic. That is what moves the operation from memory-bound to nearly compute-bound.&lt;/p>
&lt;h2 id="the-trick-that-made-it-all-possible-online-softmax">The trick that made it all possible: online softmax&lt;/h2>
&lt;p>Without online softmax there is no FlashAttention. The idea comes from Milakov and Gimelshein, 2018 (the paper &amp;ldquo;Online normalizer calculation for softmax&amp;rdquo;, arXiv:1805.02867), and it allows &lt;code>softmax([x_1, ..., x_N])&lt;/code> to be computed in one incremental pass without needing to know the global maximum before starting.&lt;/p>
&lt;p>The standard softmax is:&lt;/p>
$$\text{softmax}(x_i) = \frac{e^{x_i - m}}{\sum_{j} e^{x_j - m}}, \quad m = \max_j x_j$$
&lt;p>The &lt;em>online&lt;/em> trick: keep a running maximum &lt;code>m^{(t)}&lt;/code> and a running sum &lt;code>ℓ^{(t)}&lt;/code>. When a new block of values arrives with local maximum &lt;code>m_{\text{new}}&lt;/code>:&lt;/p>
$$m^{(t+1)} = \max(m^{(t)}, m_{\text{new}})$$
$$\ell^{(t+1)} = e^{m^{(t)} - m^{(t+1)}} \cdot \ell^{(t)} + \sum_{j \in \text{new}} e^{x_j - m^{(t+1)}}$$
&lt;p>And the accumulated partial outputs are rescaled by the same factor &lt;code>e^{m^{(t)} - m^{(t+1)}}&lt;/code>. At the end, dividing by the final &lt;code>ℓ&lt;/code> gives exactly the same result as the standard softmax. It is mathematically exact, not an approximation.&lt;/p>
&lt;p>This is what allows K to be traversed block by block without materialising the whole &lt;code>S&lt;/code> matrix. Each block updates the stats and the accumulated outputs, and is then discarded. The desk never fills up.&lt;/p>
&lt;h2 id="the-four-versions-may-2026">The four versions (May 2026)&lt;/h2>
&lt;table>
&lt;thead>
&lt;tr>
&lt;th>&lt;/th>
&lt;th>&lt;strong>FA1&lt;/strong> (2022)&lt;/th>
&lt;th>&lt;strong>FA2&lt;/strong> (2023)&lt;/th>
&lt;th>&lt;strong>FA3&lt;/strong> (2024)&lt;/th>
&lt;th>&lt;strong>FA4&lt;/strong> (2026)&lt;/th>
&lt;/tr>
&lt;/thead>
&lt;tbody>
&lt;tr>
&lt;td>Target GPU&lt;/td>
&lt;td>A100 / Ampere&lt;/td>
&lt;td>A100 / H100&lt;/td>
&lt;td>H100 / Hopper&lt;/td>
&lt;td>B200 / Blackwell&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>Core idea&lt;/td>
&lt;td>Tiling + online softmax&lt;/td>
&lt;td>Sequence parallelism + work partitioning&lt;/td>
&lt;td>Async WGMMA + TMA + FP8&lt;/td>
&lt;td>Polynomial exp + 2-CTA tensor cores&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>Memory&lt;/td>
&lt;td>O(N) (vs O(N²))&lt;/td>
&lt;td>same&lt;/td>
&lt;td>same&lt;/td>
&lt;td>same&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>Typical peak util&lt;/td>
&lt;td>~25 % A100&lt;/td>
&lt;td>~70 % A100, ~35 % H100&lt;/td>
&lt;td>&lt;strong>75 % H100 BF16, 60 % H100 FP8&lt;/strong>&lt;/td>
&lt;td>&lt;strong>71 % B200 BF16&lt;/strong>&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>Effective TFLOPS&lt;/td>
&lt;td>—&lt;/td>
&lt;td>225 TFLOPS A100 BF16&lt;/td>
&lt;td>740 H100 BF16, 1,200 H100 FP8&lt;/td>
&lt;td>&lt;strong>1,605 B200 BF16&lt;/strong>&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>Speedup vs previous&lt;/td>
&lt;td>2-4× standard&lt;/td>
&lt;td>2× FA1&lt;/td>
&lt;td>1.5-2× FA2 (BF16), 2.6× (FP8)&lt;/td>
&lt;td>2× FA3 on B200&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>Paper&lt;/td>
&lt;td>arXiv:2205.14135&lt;/td>
&lt;td>arXiv:2307.08691&lt;/td>
&lt;td>arXiv:2407.08608&lt;/td>
&lt;td>arXiv:2603.05451&lt;/td>
&lt;/tr>
&lt;/tbody>
&lt;/table>
&lt;h3 id="fa1--changing-the-order-of-the-operations">FA1 — changing the order of the operations&lt;/h3>
&lt;p>Three ideas combined: tiling of Q/K/V into blocks that fit in SRAM, online softmax over those blocks, and recomputation in the backward pass. The N×N matrix S is not stored, only the stats &lt;code>(m, ℓ)&lt;/code>, and in the backward pass S is recomputed block by block from Q, K and the stats. Result: 7.6× speedup on GPT-2 versus PyTorch standard attention, memory O(N).&lt;/p>
&lt;h3 id="fa2--parallelising-along-the-sequence">FA2 — parallelising along the sequence&lt;/h3>
&lt;p>FA1 parallelised only over batch × heads. With a small batch (1-4) and models with few heads or aggressive GQA, the GPU was left with idle SMs. FA2 also parallelises along the sequence dimension: different SMs process different stretches of Q at the same time. It also rewrites the algorithm to minimise the non-matmul operations (softmax rescaling), because those do not go through tensor cores. And it improves work partitioning between warps (split-Q instead of split-K reduces shared memory traffic). Result: around 2× over FA1 on H100 and A100, 225 TFLOPS on A100 (72 % MFU). On H100 it stays around 30-35 % of BF16 peak because it does not exploit async WGMMA.&lt;/p>
&lt;h3 id="fa3--the-hopper-moment">FA3 — the Hopper moment&lt;/h3>
&lt;p>This is where FlashAttention stops being an algorithm and becomes a Hopper-specific artefact. Three pillars:&lt;/p>
&lt;ol>
&lt;li>&lt;strong>Async WGMMA&lt;/strong>: Hopper&amp;rsquo;s new tensor core instructions let a warpgroup fire a GEMM and have the rest of the warpgroup do something else (the softmax, for instance) while the tensor core keeps working. This is the trick that unlocks matmul/softmax overlap.&lt;/li>
&lt;li>&lt;strong>TMA (Tensor Memory Accelerator)&lt;/strong>: dedicated hardware for copying tiles between HBM and SRAM. It frees the SM from computing indices and predicating out-of-bounds accesses, which used to consume SM cycles. It is the equivalent of hiring warehouse staff: the librarian no longer has to carry the books personally.&lt;/li>
&lt;li>&lt;strong>FP8 with block quantisation + incoherent processing&lt;/strong>: quantising Q and K to FP8 doubles tensor core throughput. The precision loss is mitigated with two tricks: a per-tile scale (64×d) instead of a whole-tensor scale, and a pre-multiplication by a random orthogonal Hadamard-based matrix that &amp;ldquo;spreads&amp;rdquo; the outliers before quantising. Documented result: numerical error 2.6× lower than the FP8 baseline.&lt;/li>
&lt;/ol>
&lt;p>These three pillars combine with producer/consumer warp specialisation (producer warps do TMA loads; consumer warps do WGMMA + softmax) and ping-pong scheduling with two warpgroups taking turns so that there are never pipeline bubbles. When WG1 does softmax, WG2 does GEMM; then they swap.&lt;/p>
&lt;p>Numbers: 740 TFLOPS BF16 on H100 (75 % of the 989 peak), 1.2 PFLOPS FP8 (60 % of the 1978 FP8 dense peak). For sequences ≥ 1K it beats cuDNN. Speedup over FA2: 1.5-2× BF16, 2.6× FP8.&lt;/p>
&lt;h3 id="fa4--blackwells-asymmetry">FA4 — Blackwell&amp;rsquo;s asymmetry&lt;/h3>
&lt;p>Blackwell scaled everything unevenly:&lt;/p>
&lt;ul>
&lt;li>Tensor core BF16 throughput: 1 PFLOP H100 → 2.25 PFLOPS B200 (2.25×).&lt;/li>
&lt;li>SFU count (where the softmax &lt;code>exp&lt;/code> runs): unchanged.&lt;/li>
&lt;li>Shared memory bandwidth: unchanged.&lt;/li>
&lt;/ul>
&lt;p>In other words, if FA3 runs as-is on a B200 with no changes, the matmul goes twice as fast but the softmax stays exactly the same, and that stalls the pipeline. It was only a matter of time before someone fixed the imbalance.&lt;/p>
&lt;p>FA4 (March 2026, same team of Dao + Princeton + Together AI + Meta + NVIDIA + Colfax) is a ground-up rewrite with three ideas:&lt;/p>
&lt;ol>
&lt;li>&lt;strong>Software-emulated exponential&lt;/strong>: a polynomial approximation of &lt;code>exp&lt;/code> that runs on the tensor core instead of on the SFU. It loses a little precision (carefully bounded and compensated by the rest of the kernel) but keeps the conveyor belt moving.&lt;/li>
&lt;li>&lt;strong>Conditional softmax rescaling&lt;/strong>: avoids rescaling accumulators when the running max does not change significantly. A &amp;ldquo;lazy&amp;rdquo; style optimisation: it only pays the cost when the cost is needed.&lt;/li>
&lt;li>&lt;strong>2-CTA tensor core&lt;/strong>: two CTAs (Cooperative Thread Arrays) cooperate to feed the tensor cores with larger tiles. It gets more out of Blackwell&amp;rsquo;s new capabilities.&lt;/li>
&lt;/ol>
&lt;p>Written in CuTeDSL (NVIDIA&amp;rsquo;s CUTLASS Python DSL, not CUDA C++ directly). Result on B200 BF16: 1,605 TFLOPS (71 % of the 2250 peak). 1.3× over cuDNN 9.13. 2.7× over Triton. 2× over FA3 run as-is on B200 (which was the previous baseline). It is the first attention kernel to pass 1 PFLOPS.&lt;/p>
&lt;blockquote>
&lt;p>Note: there is recurring confusion about &amp;ldquo;FP4 attention&amp;rdquo;. Blackwell&amp;rsquo;s NVFP4/MXFP4 extensions apply to weights, not to attention. FA4 can be combined with NVFP4 weights, but the attention computation itself is still BF16 or FP8 depending on the configuration. FP4 quantisation of QK^T does exist in some proprietary kernels (Fireworks AI&amp;rsquo;s FireAttention V4 combines it) but it is not standard practice.&lt;/p>
&lt;/blockquote>
&lt;h2 id="implementations-and-libraries-in-2026">Implementations and libraries in 2026&lt;/h2>
&lt;ul>
&lt;li>&lt;strong>Dao-AILab/flash-attention&lt;/strong> (the canonical repo): supports SM 8.0 (Ampere) with FA2, SM 9.0 (Hopper) with FA3, SM 10.0 (Blackwell datacenter B100/B200/B300) with FA4. The consumer Blackwell version (5090, SM 12.0) has partial support as this post goes out.&lt;/li>
&lt;li>&lt;strong>FlashInfer&lt;/strong> (flashinfer-ai/flashinfer, arXiv:2501.01005): an attention engine aimed at &lt;em>serving&lt;/em> (not training). Its conceptual contribution is the &lt;strong>Block-Sparse Row (BSR)&lt;/strong>, a unified abstraction covering paged KV cache, the radix tree of prefix caching and the tree masks of speculative decoding. Internally it can call FA2/FA3, cuDNN, CUTLASS or trtllm-gen FMHA kernels depending on the case. It JIT-compiles specific variants at runtime. Integrated into vLLM, SGLang and TensorRT-LLM.&lt;/li>
&lt;li>&lt;strong>vLLM (May 2026)&lt;/strong>: automatic backend selection by GPU. Default &lt;strong>FA4 on SM 10.0+, FA3 on SM 9.0, FA2 on the rest&lt;/strong>. Fallbacks on Blackwell: TRT-LLM Ragged → FlashInfer → TokenSpeed MLA. For FP8 KV cache on B200, FlashInfer is competitive.&lt;/li>
&lt;li>&lt;strong>SGLang&lt;/strong>: uses FlashInfer as its attention backend; RadixAttention is the prefix caching layer on top (a radix tree over the KV cache).&lt;/li>
&lt;li>&lt;strong>TensorRT-LLM&lt;/strong>: its own fused kernels (trtllm-gen FMHA). XQA is NVIDIA&amp;rsquo;s own optimisation for GQA in decode.&lt;/li>
&lt;li>&lt;strong>PyTorch SDPA and FlexAttention&lt;/strong>: &lt;code>torch.nn.functional.scaled_dot_product_attention&lt;/code> selects the backend automatically. &lt;strong>FlexAttention&lt;/strong> (new) lets you define custom masks declaratively and compiles to kernels that can use FA4 as a backend.&lt;/li>
&lt;li>&lt;strong>xFormers&lt;/strong>: still alive but residual. The built-in PyTorch SDPA covers most cases.&lt;/li>
&lt;/ul>
&lt;h2 id="cases-where-flashattention-does-not-help">Cases where FlashAttention does not help&lt;/h2>
&lt;ul>
&lt;li>&lt;strong>Very short contexts (N &amp;lt; 512)&lt;/strong>: the tiling and kernel launch overhead does not pay off; cuDNN can win.&lt;/li>
&lt;li>&lt;strong>Non-standard custom masks&lt;/strong>: FA ships causal, sliding window and ALiBi only. Arbitrary masks need FlexAttention or the JIT variants of FlashInfer.&lt;/li>
&lt;li>&lt;strong>Non-standard head dim&lt;/strong>: FA optimises for d = 64, 128, 256. Odd dimensions (d = 96, d = 192) fall into slow paths.&lt;/li>
&lt;li>&lt;strong>GQA/MQA with extreme ratios&lt;/strong>: natively supported, but the speedup versus pure MHA depends on the Q-heads : KV-heads ratio.&lt;/li>
&lt;li>&lt;strong>Cross-attention&lt;/strong>: supported but less optimised; self-attention is where the gain is largest.&lt;/li>
&lt;li>&lt;strong>FP8 without block quantisation or incoherent processing&lt;/strong>: it loses several points in benchmarks. If your serving framework does not implement the two tricks from the FA3 paper, FP8 attention can be a bad idea.&lt;/li>
&lt;/ul>
&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-sm-89">On an RTX 4090 (24 GB, Ada Lovelace, SM 8.9)&lt;/h3>
&lt;p>The 4090 is Ada Lovelace, not Hopper. &lt;strong>It runs neither FA3 nor FA4&lt;/strong>; it runs FA2. That means around 70 % utilisation in BF16 attention (~250 effective TFLOPS against the 4090&amp;rsquo;s 330 TFLOPS BF16 peak). It is not a disaster, FA2 is already very good compared with standard attention, but the ceiling is clearly below that of an H100. For consumer deploys on a 4090 with Llama 3 8B BF16 or any 14B-32B INT4 AWQ, FA2 is what you will be using, and it is perfectly reasonable.&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>This is where FA3 shines and it is what vLLM/SGLang/TRT-LLM will select by default. Two common configurations:&lt;/p>
&lt;ul>
&lt;li>&lt;strong>Llama 3 70B FP8 with FA3 FP8 attention&lt;/strong>: 1.2 PFLOPS peak on the GPU, aggregate cluster throughput on the order of 8,000-12,000 tokens/s at medium batch depending on TP and context. For FP8 attention to deliver its full performance it is crucial to use the block quantisation + incoherent processing techniques from the FA3 paper (they are enabled by default in vLLM).&lt;/li>
&lt;li>&lt;strong>DeepSeek-V3 671B FP8 + MLA with FlashInfer&lt;/strong>: DeepSeek uses &lt;strong>Multi-head Latent Attention&lt;/strong> (MLA), a different variant of standard attention. FlashInfer has specific kernels (FlashMLA). The typical stack is vLLM/SGLang + FlashInfer + FlashMLA + an FA3 fallback for the non-MLA layers.&lt;/li>
&lt;/ul>
&lt;p>If the infrastructure is Blackwell (B200/B300, which some clusters start receiving in 2026), &lt;strong>FA4 is the right option&lt;/strong> and it should be enabled by default in vLLM 0.16+ and SGLang 0.5.11+.&lt;/p>
&lt;h2 id="what-we-have-not-covered">What we have not covered&lt;/h2>
&lt;ul>
&lt;li>&lt;strong>MLA (Multi-head Latent Attention)&lt;/strong> from DeepSeek and the specific FlashMLA kernels: they optimise KV cache compression but require different kernels.&lt;/li>
&lt;li>&lt;strong>Flexible masking and the FlexAttention use cases&lt;/strong> (PyTorch 2.5+): how to declare arbitrary masks without paying the cost of a custom kernel.&lt;/li>
&lt;li>&lt;strong>Hardware assistance for sparse attention&lt;/strong> (NVIDIA 2:4 sparse tensor cores) and why sparse attention has not established itself as a higher ceiling than dense FA.&lt;/li>
&lt;li>&lt;strong>FA in the backward pass of fine-tuning&lt;/strong>: this post focuses on inference, but FA3/FA4 also go through the backward pass and they are what makes training models with long contexts viable on H100/B200.&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-attention-backend-flashattention-flashinfer-prefill-decode-asymmetry/">The attention backend in vLLM (FlashAttention vs FlashInfer)&lt;/a> — the level above this post: how the engine chooses between FA2/FA3/FA4 and FlashInfer according to the architecture, and why prefill (compute-bound) and decode (memory-bound) call for different kernels.&lt;/li>
&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> — the KV cache is what FlashAttention traverses and multiplies against Q on every iteration; understanding one requires understanding the other.&lt;/li>
&lt;li>&lt;a href="https://blog.lo0.es/en/posts/pagedattention-vllm-block-manager/">PagedAttention deep dive&lt;/a> — PagedAttention organises the KV cache into non-contiguous physical blocks; FlashAttention is the kernel that iterates over those blocks. Different layers of the same problem.&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> — FA3&amp;rsquo;s FP8 attention with block quantisation and Blackwell&amp;rsquo;s FP4 weights stack up; this post gives the general quantisation framework.&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> — prefill is compute-bound and benefits a great deal from FA3/FA4 FP8; decode is memory-bound and benefits less but still benefits. Separating them allows the kernel to be optimised per phase.&lt;/li>
&lt;li>&lt;a href="https://blog.lo0.es/en/posts/speculative-decoding-fundamentals-maths-state-of-play/">Speculative decoding: the secretary who anticipates what the boss is going to say&lt;/a> — speculative decoding produces more tokens per forward pass; FlashAttention makes each forward pass cheaper. Multiplicative levers.&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> — attention is still dense in every MoE of 2026; FlashAttention is reused as-is. FlashMLA is the specific variant for DeepSeek&amp;rsquo;s Multi-head Latent Attention (latent compression of the KV cache) that extends the FA pattern with one extra step.&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;/ul>
&lt;h2 id="references">References&lt;/h2>
&lt;ul>
&lt;li>Dao, T., Fu, D., Ermon, S., Rudra, A., Ré, C. &lt;em>FlashAttention: Fast and Memory-Efficient Exact Attention with IO-Awareness&lt;/em>. NeurIPS 2022. &lt;a href="https://arxiv.org/abs/2205.14135">https://arxiv.org/abs/2205.14135&lt;/a>&lt;/li>
&lt;li>Dao, T. &lt;em>FlashAttention-2: Faster Attention with Better Parallelism and Work Partitioning&lt;/em>. 2023. &lt;a href="https://arxiv.org/abs/2307.08691">https://arxiv.org/abs/2307.08691&lt;/a>&lt;/li>
&lt;li>Shah, J., Bikshandi, G., Zhang, Y., Thakkar, V., Ramani, P., Dao, T. &lt;em>FlashAttention-3: Fast and Accurate Attention with Asynchrony and Low-precision&lt;/em>. NeurIPS 2024. &lt;a href="https://arxiv.org/abs/2407.08608">https://arxiv.org/abs/2407.08608&lt;/a>&lt;/li>
&lt;li>Dao, T. et al. &lt;em>FlashAttention-4: Algorithm and Kernel Pipelining Co-Design for Asymmetric Hardware Scaling&lt;/em>. 2026. &lt;a href="https://arxiv.org/abs/2603.05451">https://arxiv.org/abs/2603.05451&lt;/a>&lt;/li>
&lt;li>Milakov, M., Gimelshein, N. &lt;em>Online normalizer calculation for softmax&lt;/em>. 2018. &lt;a href="https://arxiv.org/abs/1805.02867">https://arxiv.org/abs/1805.02867&lt;/a>&lt;/li>
&lt;li>Ye, Z. et al. &lt;em>FlashInfer: Efficient and Customizable Attention Engine for LLM Inference Serving&lt;/em>. MLSys 2025. &lt;a href="https://arxiv.org/abs/2501.01005">https://arxiv.org/abs/2501.01005&lt;/a>&lt;/li>
&lt;li>Tri Dao FA3 blog: &lt;a href="https://tridao.me/blog/2024/flash3/">https://tridao.me/blog/2024/flash3/&lt;/a>&lt;/li>
&lt;li>Tri Dao FA4 blog: &lt;a href="https://tridao.me/blog/2026/flash4/">https://tridao.me/blog/2026/flash4/&lt;/a>&lt;/li>
&lt;li>PyTorch FlashAttention-3 announcement: &lt;a href="https://pytorch.org/blog/flashattention-3/">https://pytorch.org/blog/flashattention-3/&lt;/a>&lt;/li>
&lt;li>PyTorch FlexAttention + FA4: &lt;a href="https://pytorch.org/blog/flexattention-flashattention-4-fast-and-flexible/">https://pytorch.org/blog/flexattention-flashattention-4-fast-and-flexible/&lt;/a>&lt;/li>
&lt;li>Together AI FA4 blog: &lt;a href="https://www.together.ai/blog/flashattention-4">https://www.together.ai/blog/flashattention-4&lt;/a>&lt;/li>
&lt;li>Colfax Research FA3: &lt;a href="https://research.colfax-intl.com/flashattention-3-fast-and-accurate-attention-with-asynchrony-and-low-precision/">https://research.colfax-intl.com/flashattention-3-fast-and-accurate-attention-with-asynchrony-and-low-precision/&lt;/a>&lt;/li>
&lt;li>Colfax Research FA4: &lt;a href="https://research.colfax-intl.com/flashattention-4-algorithm-and-kernel-pipelining-co-design-for-asymmetric-hardware-scaling/">https://research.colfax-intl.com/flashattention-4-algorithm-and-kernel-pipelining-co-design-for-asymmetric-hardware-scaling/&lt;/a>&lt;/li>
&lt;li>Repo Dao-AILab/flash-attention: &lt;a href="https://github.com/Dao-AILab/flash-attention">https://github.com/Dao-AILab/flash-attention&lt;/a>&lt;/li>
&lt;li>Repo flashinfer-ai/flashinfer: &lt;a href="https://github.com/flashinfer-ai/flashinfer">https://github.com/flashinfer-ai/flashinfer&lt;/a>&lt;/li>
&lt;li>vLLM attention backends: &lt;a href="https://docs.vllm.ai/en/latest/design/attention_backends/">https://docs.vllm.ai/en/latest/design/attention_backends/&lt;/a>&lt;/li>
&lt;li>NVIDIA Hopper Architecture in Depth: &lt;a href="https://developer.nvidia.com/blog/nvidia-hopper-architecture-in-depth/">https://developer.nvidia.com/blog/nvidia-hopper-architecture-in-depth/&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><item><title>Quantization for LLM inference: FP8, INT4 (GPTQ, AWQ) and GGUF — the model's accounting zoom</title><link>https://blog.lo0.es/en/posts/quantization-llm-inference-fp8-int4-gguf/</link><pubDate>Wed, 27 May 2026 11:30:00 +0200</pubDate><guid>https://blog.lo0.es/en/posts/quantization-llm-inference-fp8-int4-gguf/</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 of LLM inference&lt;/a>, where cache quantisation is mentioned as a savings lever; here we go into the whole method, model weights and cache, and into why each format does what it does.&lt;/p>
&lt;/blockquote>
&lt;h2 id="tldr">TL;DR&lt;/h2>
&lt;p>Quantising is a change of representation: instead of storing every model weight as a &lt;code>float16&lt;/code> or &lt;code>bfloat16&lt;/code> (2 bytes), it is stored as a short integer (1 byte INT8, half a byte INT4) with a scale factor that reconstructs a value close to the original. The price is loss of numerical precision; the reward is 2-4× less VRAM, 2-3× more throughput and, on Hopper and Blackwell, a radically lower compute cost because FP8/FP4 units execute in fewer cycles than BF16 ones. The four dominant formats in May 2026 are &lt;strong>FP8&lt;/strong> (E4M3/E5M2, datacenter), &lt;strong>INT4 GPTQ&lt;/strong> (Hessian-aware reconstruction), &lt;strong>INT4 AWQ&lt;/strong> (activation-aware) and &lt;strong>GGUF&lt;/strong> (the llama.cpp family). Each has a sweet spot: FP8 when the datacenter is Hopper/Blackwell and quality matters; GPTQ and AWQ when serving runs on Ampere/Ada and 4 bits are mandatory; GGUF when the target is edge or a consumer GPU. This post explains the minimum maths, the algorithms behind each format, what each one loses measured in perplexity and MMLU, and how it all applies on a 4090 versus an H100 cluster.&lt;/p>
&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(#qm)}.cyc{stroke:#888;stroke-width:1.2;fill:none;stroke-dasharray:4 2;marker-end:url(#qm)}&lt;/style>
&lt;defs>&lt;marker id="qm" 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 · weight and KV cache quantization&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="the-analogy-jpeg-with-an-edge-detector">The analogy: JPEG with an edge detector&lt;/h2>
&lt;p>A JPEG compresses an image by reducing the precision with which pixels are stored, but it does not reduce it uniformly. Where there is flat sky, thousands of very similar pixels, it throws away detail without anyone noticing. Where there is a sharp edge, the outline of a face, it keeps the fidelity. The trick is to detect which parts are sensitive before compressing.&lt;/p>
&lt;p>Quantization of an LLM works the same way. You do not take every model weight and say &amp;ldquo;all of them in 4 bits&amp;rdquo;. Some weights are very important, projection weights that move the output a lot when they change, and others are less so. Modern techniques (GPTQ, AWQ) are basically &lt;strong>edge detectors&lt;/strong>: they identify which weights can be quantised aggressively and which need more bits or special treatment, and they apply the quantisation with that information.&lt;/p>
&lt;p>The analogy holds up in three details:&lt;/p>
&lt;ul>
&lt;li>&lt;strong>Calibration with a small dataset&lt;/strong> = the phase in which the JPEG encoder analyses the image before choosing blocks.&lt;/li>
&lt;li>&lt;strong>A block of 128 weights with a shared scale&lt;/strong> = the JPEG 8×8 block with its DCT.&lt;/li>
&lt;li>&lt;strong>Outliers preserved with more precision&lt;/strong> = the high frequencies of an edge are preserved more than the flat areas.&lt;/li>
&lt;/ul>
&lt;p>From there on, what follows is the maths and the operational detail.&lt;/p>
&lt;h2 id="the-minimum-maths-scale-and-zero-point">The minimum maths: scale and zero-point&lt;/h2>
&lt;p>Quantising a weight vector &lt;code>w ∈ ℝ^n&lt;/code> (in BF16) to INT4 means finding two things:&lt;/p>
&lt;ul>
&lt;li>A &lt;strong>scale&lt;/strong> &lt;code>s ∈ ℝ&lt;/code> (in BF16 or FP16).&lt;/li>
&lt;li>For each weight, an integer &lt;strong>code&lt;/strong> &lt;code>q ∈ {0, 1, ..., 15}&lt;/code> that fits in 4 bits.&lt;/li>
&lt;/ul>
&lt;p>And an approximate reconstruction formula:&lt;/p>
$$\hat{w}_i \approx s \cdot (q_i - z)$$
&lt;p>where &lt;code>z&lt;/code> is the &lt;strong>zero-point&lt;/strong> (the integer that defines which code represents the original zero). The zero-point exists in asymmetric INT4/INT8 so that half the range is not wasted on negative values when the weight distribution is not symmetric.&lt;/p>
&lt;p>The choice of &lt;code>s&lt;/code> and &lt;code>z&lt;/code> for a block of weights &lt;code>w_block&lt;/code>:&lt;/p>
$$s = \frac{\max(w_\text{block}) - \min(w_\text{block})}{2^{b} - 1}, \quad z = -\frac{\min(w_\text{block})}{s},$$
&lt;p>with &lt;code>b&lt;/code> = number of bits (4 in INT4). Encoding:&lt;/p>
$$q_i = \text{clip}\!\left(\text{round}\!\left(\frac{w_i}{s} + z\right),\, 0,\, 2^b - 1\right).$$
&lt;p>And decoding at inference time:&lt;/p>
$$\hat{w}_i = s \cdot (q_i - z).$$
&lt;h3 id="numerical-example">Numerical example&lt;/h3>
&lt;p>Take 8 real weights from a linear layer: &lt;code>w = [0.31, -0.12, 0.78, -0.05, 1.42, -0.91, 0.23, 0.66]&lt;/code>. We want to quantise to INT4 (16 levels).&lt;/p>
&lt;p>&lt;code>max = 1.42&lt;/code>, &lt;code>min = -0.91&lt;/code>. Range = 2.33.&lt;/p>
$$s = \frac{2.33}{15} \approx 0.1553, \quad z = -\frac{-0.91}{0.1553} \approx 5.86 \to 6.$$
&lt;p>Encoding of each weight:&lt;/p>
&lt;table>
&lt;thead>
&lt;tr>
&lt;th>&lt;code>w_i&lt;/code>&lt;/th>
&lt;th>&lt;code>w_i/s + z&lt;/code>&lt;/th>
&lt;th>&lt;code>round&lt;/code>&lt;/th>
&lt;th>&lt;code>q_i&lt;/code>&lt;/th>
&lt;th>&lt;code>ŵ_i = s·(q-z)&lt;/code>&lt;/th>
&lt;th>error&lt;/th>
&lt;/tr>
&lt;/thead>
&lt;tbody>
&lt;tr>
&lt;td>0.31&lt;/td>
&lt;td>8.00&lt;/td>
&lt;td>8&lt;/td>
&lt;td>8&lt;/td>
&lt;td>0.311&lt;/td>
&lt;td>+0.001&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>-0.12&lt;/td>
&lt;td>5.23&lt;/td>
&lt;td>5&lt;/td>
&lt;td>5&lt;/td>
&lt;td>-0.155&lt;/td>
&lt;td>-0.035&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>0.78&lt;/td>
&lt;td>11.02&lt;/td>
&lt;td>11&lt;/td>
&lt;td>11&lt;/td>
&lt;td>0.776&lt;/td>
&lt;td>-0.004&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>-0.05&lt;/td>
&lt;td>5.68&lt;/td>
&lt;td>6&lt;/td>
&lt;td>6&lt;/td>
&lt;td>0.000&lt;/td>
&lt;td>+0.050&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>1.42&lt;/td>
&lt;td>15.14&lt;/td>
&lt;td>15&lt;/td>
&lt;td>15&lt;/td>
&lt;td>1.398&lt;/td>
&lt;td>-0.022&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>-0.91&lt;/td>
&lt;td>-0.06&lt;/td>
&lt;td>0&lt;/td>
&lt;td>0&lt;/td>
&lt;td>-0.932&lt;/td>
&lt;td>-0.022&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>0.23&lt;/td>
&lt;td>7.48&lt;/td>
&lt;td>7&lt;/td>
&lt;td>7&lt;/td>
&lt;td>0.155&lt;/td>
&lt;td>-0.075&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>0.66&lt;/td>
&lt;td>10.25&lt;/td>
&lt;td>10&lt;/td>
&lt;td>10&lt;/td>
&lt;td>0.621&lt;/td>
&lt;td>-0.039&lt;/td>
&lt;/tr>
&lt;/tbody>
&lt;/table>
&lt;p>Mean squared error: &lt;code>MSE ≈ 0.0015&lt;/code>. For a single layer with millions of weights, the aggregate effect is what calibration tries to minimise.&lt;/p>
&lt;p>Storage: instead of 8 values × 2 bytes = 16 bytes (BF16), we have 8 × 4 bits = 4 bytes + 2 bytes (BF16 scale) + 0.5 byte (INT4 zero-point) ≈ 6.5 bytes. 2.5× less, yet the useful data is still recoverable with a small error.&lt;/p>
&lt;h2 id="ptq-vs-qat-when-quantisation-happens">PTQ vs QAT: when quantisation happens&lt;/h2>
&lt;p>Two different operational regimes.&lt;/p>
&lt;p>&lt;strong>Post-Training Quantization (PTQ)&lt;/strong> is applied after training, on a model already trained in BF16/FP16. It reads a small dataset (typically 128-512 examples) to calibrate the scales, runs the quantisation algorithm (GPTQ, AWQ, and so on) and produces the quantised weights. &lt;strong>Cost&lt;/strong>: minutes to a few hours. &lt;strong>Typical loss&lt;/strong>: 0.05-0.3 PPL in perplexity (~0.5-2 % in MMLU) for INT4 with modern methods.&lt;/p>
&lt;p>&lt;strong>Quantization-Aware Training (QAT)&lt;/strong> introduces the quantisation operations inside the training loop. During training the model &amp;ldquo;sees&amp;rdquo; that its weights get quantised and learns to be robust to it. &lt;strong>Cost&lt;/strong>: retraining the model (expensive), but little is needed, a short fine-tune on top of the already quantised PTQ model. &lt;strong>Typical loss&lt;/strong>: ~0 (quantisation becomes indistinguishable from the original model).&lt;/p>
&lt;p>&lt;strong>When to use which:&lt;/strong>&lt;/p>
&lt;ul>
&lt;li>&lt;strong>PTQ&lt;/strong> = default. 90 % of production cases. The model arrives in BF16, you quantise it in 1-2 hours on one GPU, you deploy it.&lt;/li>
&lt;li>&lt;strong>QAT&lt;/strong> = when PTQ loses too much and the difference matters (typical case: INT2/INT3, or sensitive models such as specific reasoning ones).&lt;/li>
&lt;/ul>
&lt;h2 id="the-dominant-formats-in-2026">The dominant formats in 2026&lt;/h2>
&lt;div class="diagram" style="max-width:760px;margin:1.5rem auto;">
&lt;svg viewBox="0 0 760 360" xmlns="http://www.w3.org/2000/svg" role="img" aria-label="Dominant quantization formats">
&lt;style>
.bx{fill:#f8f8f8;stroke:#444;stroke-width:1.4;rx:8}
.b1{fill:#ffe6d6}
.b2{fill:#d6eaff}
.b3{fill:#d9f5d6}
.b4{fill:#fff5b0}
.t{font:700 13px sans-serif;fill:#222}
.s{font:400 11px sans-serif;fill:#555}
.h{font:700 14px sans-serif;fill:#222}
&lt;/style>
&lt;text x="380" y="22" text-anchor="middle" class="h">Map of weight quantization formats (May 2026)&lt;/text>
&lt;rect x="20" y="40" width="170" height="300" class="bx b1"/>
&lt;text x="105" y="62" text-anchor="middle" class="t">FP8 (E4M3/E5M2)&lt;/text>
&lt;text x="30" y="84" class="s">Datacenter / Hopper-Blackwell&lt;/text>
&lt;text x="30" y="102" class="s">— H100, H200, B200&lt;/text>
&lt;text x="30" y="120" class="s">— native in vLLM&lt;/text>
&lt;text x="30" y="138" class="s">— FP8 tensor core hardware&lt;/text>
&lt;text x="30" y="160" class="t">Loss:&lt;/text>
&lt;text x="30" y="176" class="s">— PPL: +0.02-0.05&lt;/text>
&lt;text x="30" y="192" class="s">— MMLU: -0.3-0.8 pp&lt;/text>
&lt;text x="30" y="214" class="t">Sweet spot:&lt;/text>
&lt;text x="30" y="230" class="s">Model serving in a modern&lt;/text>
&lt;text x="30" y="246" class="s">datacenter. Quality almost identical&lt;/text>
&lt;text x="30" y="262" class="s">to BF16, ~2× less VRAM.&lt;/text>
&lt;text x="30" y="286" class="t">vLLM command:&lt;/text>
&lt;text x="30" y="304" class="s" font-family="monospace">--quantization=fp8&lt;/text>
&lt;text x="30" y="320" class="s" font-family="monospace">--kv-cache-dtype=fp8&lt;/text>
&lt;rect x="200" y="40" width="170" height="300" class="bx b2"/>
&lt;text x="285" y="62" text-anchor="middle" class="t">INT4 GPTQ&lt;/text>
&lt;text x="210" y="84" class="s">Hessian-aware reconstruction&lt;/text>
&lt;text x="210" y="102" class="s">— Ampere/Ada/Hopper&lt;/text>
&lt;text x="210" y="120" class="s">— vLLM, TensorRT-LLM, ExLlama&lt;/text>
&lt;text x="210" y="138" class="s">— calibration: 128 samples&lt;/text>
&lt;text x="210" y="160" class="t">Loss:&lt;/text>
&lt;text x="210" y="176" class="s">— PPL: +0.15-0.30&lt;/text>
&lt;text x="210" y="192" class="s">— MMLU: -1.5-3 pp&lt;/text>
&lt;text x="210" y="214" class="t">Sweet spot:&lt;/text>
&lt;text x="210" y="230" class="s">GPU serving without FP8 (Ampere/Ada),&lt;/text>
&lt;text x="210" y="246" class="s">mid-size models (8-70B).&lt;/text>
&lt;text x="210" y="262" class="s">~4× less VRAM.&lt;/text>
&lt;text x="210" y="286" class="t">vLLM command:&lt;/text>
&lt;text x="210" y="304" class="s" font-family="monospace">--quantization=gptq&lt;/text>
&lt;text x="210" y="320" class="s" font-family="monospace">(model *-GPTQ-Int4)&lt;/text>
&lt;rect x="380" y="40" width="170" height="300" class="bx b3"/>
&lt;text x="465" y="62" text-anchor="middle" class="t">INT4 AWQ&lt;/text>
&lt;text x="390" y="84" class="s">Activation-aware salient weights&lt;/text>
&lt;text x="390" y="102" class="s">— Ampere/Ada/Hopper&lt;/text>
&lt;text x="390" y="120" class="s">— vLLM, TensorRT-LLM&lt;/text>
&lt;text x="390" y="138" class="s">— keeps 1 % outlier channels&lt;/text>
&lt;text x="390" y="160" class="t">Loss:&lt;/text>
&lt;text x="390" y="176" class="s">— PPL: +0.10-0.25&lt;/text>
&lt;text x="390" y="192" class="s">— MMLU: -1-2 pp&lt;/text>
&lt;text x="390" y="214" class="t">Sweet spot:&lt;/text>
&lt;text x="390" y="230" class="s">Preferred alternative to GPTQ&lt;/text>
&lt;text x="390" y="246" class="s">in 2026. Better quality&lt;/text>
&lt;text x="390" y="262" class="s">preservation at similar cost.&lt;/text>
&lt;text x="390" y="286" class="t">vLLM command:&lt;/text>
&lt;text x="390" y="304" class="s" font-family="monospace">--quantization=awq_marlin&lt;/text>
&lt;text x="390" y="320" class="s" font-family="monospace">(model *-AWQ-INT4)&lt;/text>
&lt;rect x="560" y="40" width="180" height="300" class="bx b4"/>
&lt;text x="650" y="62" text-anchor="middle" class="t">GGUF (llama.cpp)&lt;/text>
&lt;text x="570" y="84" class="s">Edge / consumer / CPU-friendly&lt;/text>
&lt;text x="570" y="102" class="s">— CPU, Apple Silicon,&lt;/text>
&lt;text x="570" y="120" class="s"> consumer GPU (4090, AMD)&lt;/text>
&lt;text x="570" y="138" class="s">— sub-formats: Q4_K_M, Q5_K_M…&lt;/text>
&lt;text x="570" y="160" class="t">Loss (Q4_K_M):&lt;/text>
&lt;text x="570" y="176" class="s">— PPL: +0.20-0.40&lt;/text>
&lt;text x="570" y="192" class="s">— MMLU: -2-4 pp&lt;/text>
&lt;text x="570" y="214" class="t">Sweet spot:&lt;/text>
&lt;text x="570" y="230" class="s">Any non-CUDA deploy or one&lt;/text>
&lt;text x="570" y="246" class="s">with limited VRAM. Ollama, LMStudio.&lt;/text>
&lt;text x="570" y="262" class="s">~4× less VRAM/RAM.&lt;/text>
&lt;text x="570" y="286" class="t">Command:&lt;/text>
&lt;text x="570" y="304" class="s" font-family="monospace">ollama run llama3:8b-q4_K_M&lt;/text>
&lt;text x="570" y="320" class="s" font-family="monospace">llama.cpp --model *.gguf&lt;/text>
&lt;/svg>
&lt;/div>
&lt;h2 id="fp8-the-hopperblackwell-datacenter-format">FP8: the Hopper/Blackwell datacenter format&lt;/h2>
&lt;p>FP8 is not &amp;ldquo;INT8 + sign&amp;rdquo;: these are two 8-bit floating point formats.&lt;/p>
&lt;ul>
&lt;li>&lt;strong>E4M3&lt;/strong> (4 exponent bits, 3 mantissa bits): range ±448, reasonable precision around ±1.0. Typically used for &lt;strong>weights&lt;/strong> and for &lt;strong>activations in most layers&lt;/strong>.&lt;/li>
&lt;li>&lt;strong>E5M2&lt;/strong> (5 exponent, 2 mantissa): range ±57,344, lower precision. Used for &lt;strong>gradients&lt;/strong> during training or for activations with large outliers at inference time.&lt;/li>
&lt;/ul>
&lt;p>Why FP8 left INT8 behind in the datacenter: the tensor cores on H100/H200/B200 execute FP8 operations natively with 2× the throughput of BF16 and 4× that of FP16. And because FP8 preserves logarithmic dynamics (just like FP16), matrices with values spread out in magnitude, typical of transformers, quantise with less error than with INT8.&lt;/p>
&lt;p>The loss measured in production is minimal: for a Llama 3.1 70B FP8 versus BF16, perplexity rises by ~0.03 and MMLU drops by ~0.5 points. It is the default option in any modern deployment on H100/B200.&lt;/p>
&lt;h3 id="microscaling-nvfp4-and-mxfp4">Microscaling: NVFP4 and MXFP4&lt;/h3>
&lt;p>Blackwell (B100/B200, 2025) introduces &lt;strong>NVFP4&lt;/strong> and &lt;strong>MXFP4&lt;/strong>, 4-bit formats with small-block scaling (typically 16 or 32 elements per scale, against 128 in INT4 GPTQ). The scale is FP8 instead of FP16/BF16, which reduces storage further.&lt;/p>
&lt;p>The result: 4 bits with quality close to FP8. In 2026 NVFP4 is becoming the default option for very large models (200B+) on Blackwell clusters. For 4×H100 SXM, Hopper rather than Blackwell, FP8 remains the sweet spot.&lt;/p>
&lt;h2 id="int4-gptq-vs-awq">INT4: GPTQ vs AWQ&lt;/h2>
&lt;p>The two algorithms that dominate 4-bit quantisation solve the same problem with different strategies.&lt;/p>
&lt;h3 id="gptq-frantar-et-al-2022">GPTQ (Frantar et al. 2022)&lt;/h3>
&lt;p>The idea: layer-by-layer quantisation, explicitly minimising the error at the output of each linear layer using information from the Hessian matrix (the second derivative of the loss). For each layer:&lt;/p>
&lt;ol>
&lt;li>Estimate the Hessian &lt;code>H = X^T X&lt;/code> where &lt;code>X&lt;/code> are the calibration activations.&lt;/li>
&lt;li>Quantise one weight at a time in order (typically the most sensitive one first).&lt;/li>
&lt;li>&lt;strong>Update the remaining weights&lt;/strong> to compensate for the error of the weight just quantised.&lt;/li>
&lt;/ol>
&lt;p>Step 3 is what makes GPTQ better than naive round-to-nearest: weights compensate for their neighbours&amp;rsquo; errors. The official implementation quantises a Llama 3 70B in ~3-4 hours on one H100 with 128 calibration samples.&lt;/p>
&lt;h3 id="awq-lin-et-al-2023">AWQ (Lin et al. 2023)&lt;/h3>
&lt;p>The AWQ observation: within a layer, not all channels (weight columns) are equally important. Roughly &lt;strong>1 % of the channels&lt;/strong> account for most of the impact on the activations. AWQ identifies them by measuring the mean magnitude of the activations that multiply them, and scales them before quantising so they are better preserved.&lt;/p>
&lt;p>Concretely: if a channel &lt;code>c&lt;/code> has large mean activations, AWQ multiplies that channel&amp;rsquo;s weights by a factor &lt;code>s_c&lt;/code> before quantising, and when quantising the input to the next layer it divides by &lt;code>s_c&lt;/code>. The maths cancels out, but the important weights end up with more resolution inside the INT4 range. No retraining, no Hessian, faster than GPTQ (~1-2 h for 70B on an H100).&lt;/p>
&lt;h3 id="which-one-to-choose">Which one to choose&lt;/h3>
&lt;table>
&lt;thead>
&lt;tr>
&lt;th>Criterion&lt;/th>
&lt;th>GPTQ&lt;/th>
&lt;th>AWQ&lt;/th>
&lt;/tr>
&lt;/thead>
&lt;tbody>
&lt;tr>
&lt;td>Quantisation speed&lt;/td>
&lt;td>Slower&lt;/td>
&lt;td>Faster&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>Quality preserved in INT4&lt;/td>
&lt;td>Good&lt;/td>
&lt;td>Slightly better (~0.05-0.1 PPL)&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>Supported hardware&lt;/td>
&lt;td>Broad (Ampere+)&lt;/td>
&lt;td>Broad (Ampere+)&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>Ecosystem&lt;/td>
&lt;td>Mature, widely integrated&lt;/td>
&lt;td>More recent, gaining ground&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>Default in 2026&lt;/td>
&lt;td>When GPTQ artefacts already exist&lt;/td>
&lt;td>Default for new quantisations&lt;/td>
&lt;/tr>
&lt;/tbody>
&lt;/table>
&lt;p>The practical rule in May 2026: &lt;strong>AWQ by default&lt;/strong> for new INT4. &lt;strong>GPTQ&lt;/strong> when a GPTQ-Int4 artefact published by the community already meets your requirements.&lt;/p>
&lt;h2 id="gguf-the-llamacpp-ecosystem">GGUF: the llama.cpp ecosystem&lt;/h2>
&lt;p>GGUF is not a quantisation algorithm, it is a file format, and a whole tooling ecosystem, around the llama.cpp runtime.&lt;/p>
&lt;p>Its value: universal compatibility. The same GGUF file runs on:&lt;/p>
&lt;ul>
&lt;li>Pure CPU (Intel/AMD x86, ARM).&lt;/li>
&lt;li>Apple Silicon (M1/M2/M3/M4) with Metal acceleration.&lt;/li>
&lt;li>Consumer GPUs (RTX, AMD Radeon) with layer offload to VRAM.&lt;/li>
&lt;li>Edge devices (Jetson, ARM phones).&lt;/li>
&lt;/ul>
&lt;p>That is what llama.cpp allows and vLLM/TensorRT-LLM do not. The trade-off: lower peak throughput on datacenter GPUs than vLLM.&lt;/p>
&lt;p>The most used GGUF sub-formats in 2026:&lt;/p>
&lt;table>
&lt;thead>
&lt;tr>
&lt;th>Sub-format&lt;/th>
&lt;th>Effective bits&lt;/th>
&lt;th>Relative quality&lt;/th>
&lt;th>Typical use&lt;/th>
&lt;/tr>
&lt;/thead>
&lt;tbody>
&lt;tr>
&lt;td>&lt;code>Q8_0&lt;/code>&lt;/td>
&lt;td>8.5&lt;/td>
&lt;td>Almost lossless&lt;/td>
&lt;td>Baseline validation&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>&lt;code>Q6_K&lt;/code>&lt;/td>
&lt;td>6.6&lt;/td>
&lt;td>Very small loss&lt;/td>
&lt;td>High quality + savings&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>&lt;code>Q5_K_M&lt;/code>&lt;/td>
&lt;td>5.7&lt;/td>
&lt;td>Small loss&lt;/td>
&lt;td>Quality/size sweet spot&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>&lt;code>Q4_K_M&lt;/code>&lt;/td>
&lt;td>4.8&lt;/td>
&lt;td>Moderate loss&lt;/td>
&lt;td>&lt;strong>Consumer default&lt;/strong>&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>&lt;code>Q4_K_S&lt;/code>&lt;/td>
&lt;td>4.5&lt;/td>
&lt;td>Moderate-to-high loss&lt;/td>
&lt;td>When Q4_K_M does not fit&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>&lt;code>Q3_K_M&lt;/code>&lt;/td>
&lt;td>3.9&lt;/td>
&lt;td>Noticeable loss&lt;/td>
&lt;td>Very constrained hardware&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>&lt;code>Q2_K&lt;/code>&lt;/td>
&lt;td>3.3&lt;/td>
&lt;td>Large loss&lt;/td>
&lt;td>Last resort&lt;/td>
&lt;/tr>
&lt;/tbody>
&lt;/table>
&lt;p>The &lt;code>_K_M&lt;/code> suffix indicates the degree of mixing: inside the file, certain layers (typically &lt;code>attention.wv&lt;/code>, &lt;code>feed_forward.w2&lt;/code>) are stored with more bits than others. It is the equivalent of the JPEG &amp;ldquo;edge detector&amp;rdquo; applied layer by layer with a pre-established heuristic.&lt;/p>
&lt;h2 id="kv-cache-quantization">KV cache quantization&lt;/h2>
&lt;p>Quantising the model weights is half the problem. The &lt;strong>KV cache&lt;/strong>, covered in detail in &lt;a href="https://blog.lo0.es/en/posts/kv-cache-working-memory-llm-inference/">KV cache: the working memory&lt;/a>, typically consumes 20-50 % of VRAM in production with concurrency. Quantising the cache is a lever too:&lt;/p>
&lt;ul>
&lt;li>&lt;strong>&lt;code>--kv-cache-dtype=auto&lt;/code>&lt;/strong> (BF16/FP16, the default). 2 bytes per dimension × num_heads × head_dim × 2 (K and V).&lt;/li>
&lt;li>&lt;strong>&lt;code>--kv-cache-dtype=fp8&lt;/code>&lt;/strong> (E4M3 or E5M2 depending on hardware). 1 byte. Halves the cache with a typical loss of &amp;lt; 0.5 % on quality benchmarks.&lt;/li>
&lt;li>&lt;strong>&lt;code>--kv-cache-dtype=int4&lt;/code>&lt;/strong> (with blocks of 128). 0.5 bytes plus scale overhead. Divides the cache by ~3.5. Measurable loss (1-2 %) but acceptable with long contexts.&lt;/li>
&lt;/ul>
&lt;p>KV cache quantisation is &lt;strong>orthogonal&lt;/strong> to weight quantisation: you can have BF16 weights and an FP8 cache, or INT4 weights and an FP8 cache, and so on. The dominant combination in 2026 on H100: &lt;strong>FP8 weights + FP8 cache&lt;/strong>, which is almost indistinguishable from BF16 in quality and doubles concurrency capacity.&lt;/p>
&lt;h2 id="measured-quality-loss-llama-31-70b-instruct-reference">Measured quality loss (Llama 3.1 70B Instruct, reference)&lt;/h2>
&lt;p>A representative table for Llama 3.1 70B Instruct with a WikiText-2 calibration dataset (128 samples). Figures aggregated from public sources; they may vary by ±0.05 PPL and ±0.5 MMLU depending on implementation and seed.&lt;/p>
&lt;table>
&lt;thead>
&lt;tr>
&lt;th>Format&lt;/th>
&lt;th>Model VRAM&lt;/th>
&lt;th>Perplexity (WikiText-2)&lt;/th>
&lt;th>MMLU (5-shot)&lt;/th>
&lt;th>Relative speed (H100)&lt;/th>
&lt;/tr>
&lt;/thead>
&lt;tbody>
&lt;tr>
&lt;td>BF16 (baseline)&lt;/td>
&lt;td>140 GB&lt;/td>
&lt;td>4.85&lt;/td>
&lt;td>82.1&lt;/td>
&lt;td>1.00×&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>FP8 (E4M3)&lt;/td>
&lt;td>70 GB&lt;/td>
&lt;td>4.87 (+0.02)&lt;/td>
&lt;td>81.6 (-0.5)&lt;/td>
&lt;td>1.85×&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>INT8 SmoothQuant&lt;/td>
&lt;td>70 GB&lt;/td>
&lt;td>4.92 (+0.07)&lt;/td>
&lt;td>81.0 (-1.1)&lt;/td>
&lt;td>1.65×&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>INT4 AWQ&lt;/td>
&lt;td>35 GB&lt;/td>
&lt;td>4.99 (+0.14)&lt;/td>
&lt;td>80.4 (-1.7)&lt;/td>
&lt;td>2.50×&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>INT4 GPTQ&lt;/td>
&lt;td>35 GB&lt;/td>
&lt;td>5.05 (+0.20)&lt;/td>
&lt;td>80.0 (-2.1)&lt;/td>
&lt;td>2.40×&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>GGUF Q5_K_M&lt;/td>
&lt;td>49 GB&lt;/td>
&lt;td>4.94 (+0.09)&lt;/td>
&lt;td>81.1 (-1.0)&lt;/td>
&lt;td>n/a (llama.cpp)&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>GGUF Q4_K_M&lt;/td>
&lt;td>42 GB&lt;/td>
&lt;td>5.08 (+0.23)&lt;/td>
&lt;td>79.8 (-2.3)&lt;/td>
&lt;td>n/a (llama.cpp)&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>GGUF Q3_K_M&lt;/td>
&lt;td>33 GB&lt;/td>
&lt;td>5.45 (+0.60)&lt;/td>
&lt;td>77.5 (-4.6)&lt;/td>
&lt;td>n/a (llama.cpp)&lt;/td>
&lt;/tr>
&lt;/tbody>
&lt;/table>
&lt;p>Three lessons worth keeping:&lt;/p>
&lt;ol>
&lt;li>&lt;strong>FP8 is almost free in quality terms&lt;/strong>. If your hardware supports it, there is no debate.&lt;/li>
&lt;li>&lt;strong>INT4 AWQ is noticeably better than INT4 GPTQ&lt;/strong> in preserved quality, at comparable speed.&lt;/li>
&lt;li>&lt;strong>Q3 is already in measurable-loss territory&lt;/strong>; Q2 should no longer be used except for experiments or extreme demos.&lt;/li>
&lt;/ol>
&lt;h2 id="implications-for-on-premise-hardware">Implications for on-premise hardware&lt;/h2>
&lt;h3 id="on-an-rtx-4090-24-gb-ada-lovelace-no-native-fp8">On an RTX 4090 (24 GB, Ada Lovelace, no native FP8)&lt;/h3>
&lt;p>Llama 3.1 8B Instruct fits comfortably in BF16 (16 GB), but that leaves little headroom for a KV cache under concurrency. The usual sweet spot:&lt;/p>
&lt;ul>
&lt;li>&lt;strong>Llama 3.1 8B AWQ-INT4&lt;/strong>: ~5 GB of weights, 19 GB free for the KV cache → 4-8 concurrent sessions with a moderate context.&lt;/li>
&lt;li>&lt;strong>Llama 3 70B GGUF Q4_K_M&lt;/strong>: ~42 GB. &lt;strong>It does not fit on a whole 4090&lt;/strong>; it requires CPU offload with llama.cpp (slow decode but workable for a single user).&lt;/li>
&lt;li>&lt;strong>Llama 3 70B AWQ-INT4 with TP=2 (two 4090s)&lt;/strong>: ~17 GB per GPU → it fits and leaves headroom.&lt;/li>
&lt;/ul>
&lt;p>The 4090 &lt;strong>does not support native FP8&lt;/strong> (Ada Lovelace has the instruction but not Hopper&amp;rsquo;s accelerated throughput). In practice, FP8 on a 4090 works but without the speed gain: the sensible choice is INT4 AWQ.&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 FP8 shines:&lt;/p>
&lt;ul>
&lt;li>&lt;strong>Llama 3.1 70B FP8&lt;/strong> with TP=2: ~35 GB/GPU. Comfortable, leaving a huge amount of room for the KV cache → dozens of concurrent sessions.&lt;/li>
&lt;li>&lt;strong>Llama 3.1 405B FP8&lt;/strong> with TP=4: ~200 GB/GPU. It just fits, with prefill+decode in the same pool.&lt;/li>
&lt;li>&lt;strong>Llama 3.1 405B INT4 AWQ&lt;/strong> with TP=2: ~100 GB/GPU. It allows serving the large model without saturating the cluster; there is headroom left for the cache and for serving another model at the same time.&lt;/li>
&lt;/ul>
&lt;p>The rule of thumb on an H100 cluster in 2026: &lt;strong>FP8 if quality matters and the model fits; INT4 AWQ if the model does not fit in FP8 or if you want more concurrency at the cost of 1-2 MMLU points&lt;/strong>.&lt;/p>
&lt;h2 id="what-we-have-not-covered-upcoming-articles">What we have not covered (upcoming articles)&lt;/h2>
&lt;ul>
&lt;li>&lt;strong>Speculative decoding&lt;/strong>: the other big acceleration lever in inference. Orthogonal to quantization, it multiplies the speedup.&lt;/li>
&lt;li>&lt;strong>MoE quantization&lt;/strong>: Mixture-of-Experts models (Mixtral, DeepSeek V3, Qwen3-235B-A22B) have different quantisation patterns, experts are not quantised uniformly and there is dynamic routing.&lt;/li>
&lt;li>&lt;strong>Calibration dataset matters&lt;/strong>: how to choose the 128-512 calibration samples. The common mistake of grabbing a random dataset off the internet, and how to avoid it.&lt;/li>
&lt;li>&lt;strong>Multimodal quantization&lt;/strong>: vision-language models have heterogeneous layers (a CNN vision encoder, a transformer language model) that need separate treatment.&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> — the cache is also quantised with the same formats (FP8, INT4); this post goes into the detail of how, and with what loss.&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 two pools can be quantised &lt;strong>asymmetrically&lt;/strong>: prefill with less compression (quality), decode with more compression (throughput).&lt;/li>
&lt;li>&lt;a href="https://blog.lo0.es/en/posts/continuous-fine-tuning-production-real-traffic-deployed-adapter/">Continuous fine-tuning in production&lt;/a> — QLoRA uses NF4, a specific INT4 variant for the base model during training. It is the cousin of the inference formats described here.&lt;/li>
&lt;li>&lt;a href="https://blog.lo0.es/en/posts/modern-alignment-dpo-kto-orpo-simpo/">Modern alignment: DPO, KTO, ORPO, SimPO&lt;/a> — the reference model (&lt;code>π_ref&lt;/code>) in DPO can be quantised to FP8 to free up VRAM. The quality of the reference matters less than that of the trained model.&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/speculative-decoding-fundamentals-maths-state-of-play/">Speculative decoding: the secretary who anticipates what the boss is going to say&lt;/a> — a lever orthogonal to and multiplicative with quantization. The draft is usually left in BF16 even when the target is in FP8/INT4, because quantising it degrades α and kills the speedup.&lt;/li>
&lt;li>&lt;a href="https://blog.lo0.es/en/posts/flashattention-librarian-io-awareness-async-blackwell-asymmetry/">FlashAttention v1/v2/v3/v4: the librarian who never clears the desk&lt;/a> — FA3 does FP8 attention with block quantization and incoherent processing to preserve precision; FA4 combines that with Blackwell NVFP4 weights. The two quantisation layers (weights and attention) are applied at different moments in the kernel.&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> — DeepSeek-V3 671B FP8 (~685 GB) only fits on modest clusters thanks to aggressive quantisation of the routed experts; NVFP4 on expert weights + FP8 attention is the peak setup on NVL72.&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: one translator with a thousand glossaries&lt;/a> — the canonical stack is an FP8/INT4 base plus BF16 adapters. Quantising the base does not just free memory, it multiplies the economics: the same cluster goes from serving dozens to hundreds of concurrent adapters.&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 combination of AWQ (INT4 weights) + FP8 KV cache + speculative decoding is the reference config for squeezing an RTX 4090; the concrete numbers are laid out there.&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> — &lt;code>--kv-cache-dtype fp8&lt;/code> and &lt;code>--calculate-kv-scales&lt;/code> are the parameters that enable KV cache quantisation in vLLM, with the precision trade-off measured on long contexts.&lt;/li>
&lt;li>&lt;a href="https://blog.lo0.es/en/posts/llm-pruning-cutting-without-amputating/">Pruning LLM models&lt;/a> — an orthogonal lever: quantising reduces the precision of the weights pruning has kept; combined, 50% Wanda sparsity + AWQ INT4 shrinks the model to ~1/8 of its original size.&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 previous step in the pipeline: you distil first (a new, smaller model), you quantise afterwards (the same model, more efficient); the two techniques attack different dimensions.&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> — quantization is the first sizing lever when the BF16 arithmetic does not work out; the sensitivity table there quantifies the savings in VRAM and in TPOT.&lt;/li>
&lt;li>&lt;a href="https://blog.lo0.es/en/posts/test-time-quantization-without-calibration-dataset/">Test-time quantization: quantising on the fly&lt;/a> — the alternative with no calibration dataset: it derives activation-aware scales at inference time, at the cost of runtime overhead that weighs more heavily on small models.&lt;/li>
&lt;li>&lt;a href="https://blog.lo0.es/en/posts/aggressive-quantisation-4-bit-to-ternary/">Aggressive quantisation: from 4-bit to ternary&lt;/a> — what happens below 4-bit, where naive PTQ collapses and co-design is needed: codebooks (AQLM, QuIP#, QTIP), QAT and native ternary (BitNet b1.58).&lt;/li>
&lt;li>&lt;a href="https://blog.lo0.es/en/posts/llm-serving-engines-pareto-frontier-comparison/">Serving engines compared (vLLM/SGLang/TRT-LLM/Dynamo)&lt;/a> — how each engine supports the quantization formats described here (FP8, AWQ, GPTQ, GGUF) and what difference in goodput it makes to pick the one that implements them best.&lt;/li>
&lt;/ul>
&lt;h2 id="references">References&lt;/h2>
&lt;ul>
&lt;li>Frantar, E., Ashkboos, S., Hoefler, T., Alistarh, D. &lt;em>GPTQ: Accurate Post-Training Quantization for Generative Pre-trained Transformers&lt;/em> (ICLR 2023).&lt;/li>
&lt;li>Lin, J., Tang, J., Tang, H., Yang, S., Dang, X., Han, S. &lt;em>AWQ: Activation-aware Weight Quantization for LLM Compression and Acceleration&lt;/em> (MLSys 2024).&lt;/li>
&lt;li>Dettmers, T., Pagnoni, A., Holtzman, A., Zettlemoyer, L. &lt;em>QLoRA: Efficient Finetuning of Quantized LLMs&lt;/em> (NeurIPS 2023). Introduces NF4 and double quantization.&lt;/li>
&lt;li>Xiao, G., Lin, J., Seznec, M., Wu, H., Demouth, J., Han, S. &lt;em>SmoothQuant: Accurate and Efficient Post-Training Quantization for Large Language Models&lt;/em> (ICML 2023).&lt;/li>
&lt;li>NVIDIA. &lt;em>FP8 Formats for Deep Learning&lt;/em> — E4M3/E5M2 white paper: &lt;a href="https://arxiv.org/abs/2209.05433">https://arxiv.org/abs/2209.05433&lt;/a>.&lt;/li>
&lt;li>Rouhani, B. et al. &lt;em>Microscaling Data Formats for Deep Learning&lt;/em> — MXFP4/MXFP8: &lt;a href="https://arxiv.org/abs/2310.10537">https://arxiv.org/abs/2310.10537&lt;/a>.&lt;/li>
&lt;li>llama.cpp GGUF spec: &lt;a href="https://github.com/ggerganov/llama.cpp/blob/master/docs/gguf.md">https://github.com/ggerganov/llama.cpp/blob/master/docs/gguf.md&lt;/a>.&lt;/li>
&lt;li>vLLM quantization docs: &lt;a href="https://docs.vllm.ai/en/latest/quantization/">https://docs.vllm.ai/en/latest/quantization/&lt;/a>.&lt;/li>
&lt;/ul></description></item><item><title>The OSS catalogue for LLMOps in six stages: tool by tool, what each one does and when to choose it</title><link>https://blog.lo0.es/en/posts/oss-catalogue-llmops-six-stages-tool-by-tool/</link><pubDate>Sat, 23 May 2026 07:30:00 +0200</pubDate><guid>https://blog.lo0.es/en/posts/oss-catalogue-llmops-six-stages-tool-by-tool/</guid><description>&lt;h2 id="tldr">TL;DR&lt;/h2>
&lt;p>For each of the six LLMOps stages (Data, Tune, Eval, Deploy, Observe, Retrain) and the two cross-cutting components (prompt + data versioning), the open source ecosystem has canonical pieces that this blog has been citing again and again. This post gathers them in one place with &lt;strong>cards of roughly 150 words per core tool&lt;/strong>: what it does, how it differs from the alternatives inside the same bucket, its &lt;strong>licence and governance model&lt;/strong>, and a typical gotcha that you only learn in production. Plus more alternatives as bullets, a &lt;strong>decision matrix per stage&lt;/strong> according to the case (small / large corpus, single tenant / multi-tenant…), a &lt;strong>diagram&lt;/strong> of the connected OSS stack and a &lt;strong>master table&lt;/strong> of licences / EE offerings. The intention: that the reader closes the post knowing what is available, which company maintains it, what gap each piece fills, and when to choose it. This is not opinion: it is a curated catalogue.&lt;/p>
&lt;h2 id="you-are-here-every-stage-but-by-oss-column">You are here: every stage, but by OSS column&lt;/h2>
&lt;p>This post shares a map with the two previous ones in the series, the six stages and the two cross-cutting pieces are all active, but it zooms in on the &lt;strong>open source column&lt;/strong>.&lt;/p>
&lt;div class="diagram" style="max-width:820px;margin:1rem auto;">
&lt;svg viewBox="0 0 820 220" xmlns="http://www.w3.org/2000/svg" role="img" aria-label="OSS catalogue by LLMOps pipeline stage">
&lt;style>.box{stroke:#444;stroke-width:1.4;rx:6}.active{fill:#ff8a4c;stroke-width:2}.cross{fill:#ffe9d6;stroke:#c66;stroke-width:1.4;rx:6}.lbl{font:600 12px sans-serif;fill:#222}.sm{font:11px sans-serif;fill:#333}.tiny{font:10px sans-serif;fill:#444}.oss{fill:#dfe9f5;stroke:#356}&lt;/style>
&lt;text x="410" y="20" text-anchor="middle" class="lbl">OSS catalogue: the consultant's toolbox by stage&lt;/text>
&lt;rect x="20" y="38" width="120" height="32" class="box active"/>&lt;text x="80" y="58" text-anchor="middle" class="lbl">1 · Data&lt;/text>
&lt;rect x="150" y="38" width="120" height="32" class="box active"/>&lt;text x="210" y="58" text-anchor="middle" class="lbl">2 · Tune&lt;/text>
&lt;rect x="280" y="38" width="120" height="32" class="box active"/>&lt;text x="340" y="58" text-anchor="middle" class="lbl">3 · Eval&lt;/text>
&lt;rect x="410" y="38" width="120" height="32" class="box active"/>&lt;text x="470" y="58" text-anchor="middle" class="lbl">4 · Deploy&lt;/text>
&lt;rect x="540" y="38" width="120" height="32" class="box active"/>&lt;text x="600" y="58" text-anchor="middle" class="lbl">5 · Observe&lt;/text>
&lt;rect x="670" y="38" width="120" height="32" class="box active"/>&lt;text x="730" y="58" text-anchor="middle" class="lbl">6 · Retrain&lt;/text>
&lt;rect x="20" y="88" width="120" height="78" class="box oss"/>
&lt;text x="80" y="103" text-anchor="middle" class="tiny">DVC · lakeFS&lt;/text>
&lt;text x="80" y="117" text-anchor="middle" class="tiny">MinIO · Ceph&lt;/text>
&lt;text x="80" y="131" text-anchor="middle" class="tiny">Qdrant · pgvector&lt;/text>
&lt;text x="80" y="145" text-anchor="middle" class="tiny">Kafka · Flink&lt;/text>
&lt;text x="80" y="159" text-anchor="middle" class="tiny">Debezium · Karapace&lt;/text>
&lt;rect x="150" y="88" width="120" height="78" class="box oss"/>
&lt;text x="210" y="103" text-anchor="middle" class="tiny">HF Transformers&lt;/text>
&lt;text x="210" y="117" text-anchor="middle" class="tiny">PEFT · bitsandbytes&lt;/text>
&lt;text x="210" y="131" text-anchor="middle" class="tiny">DeepSpeed · FSDP&lt;/text>
&lt;text x="210" y="145" text-anchor="middle" class="tiny">Axolotl · LLaMA-Factory&lt;/text>
&lt;text x="210" y="159" text-anchor="middle" class="tiny">Ray Train · MLflow&lt;/text>
&lt;rect x="280" y="88" width="120" height="78" class="box oss"/>
&lt;text x="340" y="103" text-anchor="middle" class="tiny">DeepEval · RAGAS&lt;/text>
&lt;text x="340" y="117" text-anchor="middle" class="tiny">Promptfoo&lt;/text>
&lt;text x="340" y="131" text-anchor="middle" class="tiny">lm-eval-harness&lt;/text>
&lt;text x="340" y="145" text-anchor="middle" class="tiny">NeMo Guardrails&lt;/text>
&lt;text x="340" y="159" text-anchor="middle" class="tiny">Presidio · LlamaGuard&lt;/text>
&lt;rect x="410" y="88" width="120" height="78" class="box oss"/>
&lt;text x="470" y="103" text-anchor="middle" class="tiny">vLLM · TGI · SGLang&lt;/text>
&lt;text x="470" y="117" text-anchor="middle" class="tiny">TensorRT-LLM&lt;/text>
&lt;text x="470" y="131" text-anchor="middle" class="tiny">llama.cpp · Triton&lt;/text>
&lt;text x="470" y="145" text-anchor="middle" class="tiny">KServe · KubeRay&lt;/text>
&lt;text x="470" y="159" text-anchor="middle" class="tiny">Envoy AI · LiteLLM&lt;/text>
&lt;rect x="540" y="88" width="120" height="78" class="box oss"/>
&lt;text x="600" y="103" text-anchor="middle" class="tiny">OpenTelemetry&lt;/text>
&lt;text x="600" y="117" text-anchor="middle" class="tiny">Tempo · Jaeger&lt;/text>
&lt;text x="600" y="131" text-anchor="middle" class="tiny">Prometheus · Loki&lt;/text>
&lt;text x="600" y="145" text-anchor="middle" class="tiny">Langfuse · Phoenix&lt;/text>
&lt;text x="600" y="159" text-anchor="middle" class="tiny">Tetragon · Evidently&lt;/text>
&lt;rect x="670" y="88" width="120" height="78" class="box oss"/>
&lt;text x="730" y="103" text-anchor="middle" class="tiny">Airflow · Prefect&lt;/text>
&lt;text x="730" y="117" text-anchor="middle" class="tiny">Dagster · Argo&lt;/text>
&lt;text x="730" y="131" text-anchor="middle" class="tiny">Kubeflow Pipelines&lt;/text>
&lt;text x="730" y="145" text-anchor="middle" class="tiny">Feast&lt;/text>
&lt;text x="730" y="159" text-anchor="middle" class="tiny">Argilla · Label Studio&lt;/text>
&lt;rect x="20" y="180" width="380" height="22" class="cross"/>
&lt;text x="210" y="195" text-anchor="middle" class="sm">Prompt versioning: Langfuse Prompts · MLflow Prompt Registry&lt;/text>
&lt;rect x="410" y="180" width="380" height="22" class="cross"/>
&lt;text x="600" y="195" text-anchor="middle" class="sm">Data versioning: DVC · lakeFS · OpenLineage · DataHub&lt;/text>
&lt;/svg>
&lt;/div>
&lt;h2 id="the-analogy-the-electricians-toolbox">The analogy: the electrician&amp;rsquo;s toolbox&lt;/h2>
&lt;p>A professional electrician arrives at an installation with a box organised into compartments. Nothing is improvised: for every type of cable there is a specific wire stripper, for every screw a screwdriver of the exact gauge, for every measurement a multimeter and a clamp meter, for every connection the right terminal block or connector. The difference between a professional electrician and a handyman is not that one knows more theory, the handyman has often read the manuals, it is that &lt;strong>the right tool is within arm&amp;rsquo;s reach and he knows when to use each one&lt;/strong>. The day the specific stripper is missing, improvising with a box cutter breaks the insulation, leaves a badly terminated cable, and the panel comes back under warranty two months later.&lt;/p>
&lt;p>The OSS LLMOps stack works the same way. For every canonical problem, versioning a dataset, indexing a corpus for retrieval, serving tokens with dynamic batching, propagating &lt;code>trace_id&lt;/code> end-to-end, managing prompts with a &lt;code>production&lt;/code> label, orchestrating retraining pipelines, there is a canonical piece of the open source ecosystem that solves it, maintained by a serious community or foundation, with a clear licence and a well documented gotcha. The consultant who knows which tool to use for each job builds a robust system in weeks; the one who improvises with &amp;ldquo;whatever the team already knows&amp;rdquo; pays later in operations, usually once the system is carrying real load and any replacement is expensive.&lt;/p>
&lt;p>This post opens the toolbox and shows each card. It is not a user manual, that is what the deep-dive posts linked at the end are for; it is the &lt;strong>curated catalogue&lt;/strong>.&lt;/p>
&lt;h2 id="diagram-of-the-connected-reference-oss-stack">Diagram of the connected reference OSS stack&lt;/h2>
&lt;p>The catalogue makes sense once you see how the pieces connect into a single coherent architecture, the one this blog has been describing throughout the series. The boxes do not float; they talk to each other over stable contracts (HTTP, gRPC, OTel, Kafka, S3/MinIO API).&lt;/p>
&lt;div class="diagram" style="max-width:820px;margin:1rem auto;">
&lt;svg viewBox="0 0 820 460" xmlns="http://www.w3.org/2000/svg" role="img" aria-label="reference OSS LLMOps stack with connected boxes">
&lt;style>.b{stroke:#333;stroke-width:1.4;rx:6;fill:#eef2f7}.serv{fill:#ff8a4c;stroke:#a44}.data{fill:#dfe9f5;stroke:#356}.obs{fill:#d8eecf;stroke:#373}.ctrl{fill:#f5e3d8;stroke:#763}.bg{fill:#fafafa;stroke:#ccc;rx:8}.lbl{font:600 12px sans-serif;fill:#222}.sm{font:11px sans-serif;fill:#222}.tiny{font:600 10px sans-serif;fill:#222}.arr{stroke:#666;stroke-width:1.4;fill:none;marker-end:url(#a)}.otel{stroke:#1a73e8;stroke-width:1.4;fill:none;stroke-dasharray:3 2;marker-end:url(#ab)}&lt;/style>
&lt;defs>&lt;marker id="a" 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;marker id="ab" 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="#1a73e8"/>&lt;/marker>&lt;/defs>
&lt;text x="410" y="20" text-anchor="middle" class="lbl">Connected OSS LLMOps stack: serving, data, observability and control plane&lt;/text>
&lt;rect x="20" y="40" width="780" height="80" class="b bg"/>
&lt;text x="30" y="58" class="tiny">SERVING PLANE&lt;/text>
&lt;rect x="40" y="68" width="120" height="40" class="b serv"/>&lt;text x="100" y="93" text-anchor="middle" class="sm">Envoy AI Gateway&lt;/text>
&lt;rect x="180" y="68" width="120" height="40" class="b serv"/>&lt;text x="240" y="93" text-anchor="middle" class="sm">vLLM (LoRA hot-swap)&lt;/text>
&lt;rect x="320" y="68" width="120" height="40" class="b serv"/>&lt;text x="380" y="93" text-anchor="middle" class="sm">KServe / Operator&lt;/text>
&lt;rect x="460" y="68" width="120" height="40" class="b serv"/>&lt;text x="520" y="93" text-anchor="middle" class="sm">Triton (multi-model)&lt;/text>
&lt;rect x="600" y="68" width="180" height="40" class="b serv"/>&lt;text x="690" y="93" text-anchor="middle" class="sm">Kubernetes (RKE2 + Cilium)&lt;/text>
&lt;path class="arr" d="M160,88 L180,88"/>&lt;path class="arr" d="M300,88 L320,88"/>
&lt;rect x="20" y="140" width="380" height="170" class="b bg"/>
&lt;text x="30" y="158" class="tiny">DATA PLANE&lt;/text>
&lt;rect x="40" y="168" width="160" height="36" class="b data"/>&lt;text x="120" y="190" text-anchor="middle" class="sm">PostgreSQL + pgvector&lt;/text>
&lt;rect x="220" y="168" width="160" height="36" class="b data"/>&lt;text x="300" y="190" text-anchor="middle" class="sm">Qdrant (RAG)&lt;/text>
&lt;rect x="40" y="214" width="160" height="36" class="b data"/>&lt;text x="120" y="236" text-anchor="middle" class="sm">MinIO / Ceph (S3)&lt;/text>
&lt;rect x="220" y="214" width="160" height="36" class="b data"/>&lt;text x="300" y="236" text-anchor="middle" class="sm">Kafka + Debezium&lt;/text>
&lt;rect x="40" y="260" width="160" height="36" class="b data"/>&lt;text x="120" y="282" text-anchor="middle" class="sm">DVC + lakeFS&lt;/text>
&lt;rect x="220" y="260" width="160" height="36" class="b data"/>&lt;text x="300" y="282" text-anchor="middle" class="sm">Flink / Spark&lt;/text>
&lt;path class="arr" d="M380,186 L460,186 L460,88 L460,108"/>
&lt;rect x="420" y="140" width="380" height="170" class="b bg"/>
&lt;text x="430" y="158" class="tiny">OBSERVABILITY PLANE&lt;/text>
&lt;rect x="440" y="168" width="160" height="36" class="b obs"/>&lt;text x="520" y="190" text-anchor="middle" class="sm">OTel Collector&lt;/text>
&lt;rect x="620" y="168" width="160" height="36" class="b obs"/>&lt;text x="700" y="190" text-anchor="middle" class="sm">Langfuse&lt;/text>
&lt;rect x="440" y="214" width="160" height="36" class="b obs"/>&lt;text x="520" y="236" text-anchor="middle" class="sm">Tempo (traces)&lt;/text>
&lt;rect x="620" y="214" width="160" height="36" class="b obs"/>&lt;text x="700" y="236" text-anchor="middle" class="sm">Phoenix Arize OSS&lt;/text>
&lt;rect x="440" y="260" width="160" height="36" class="b obs"/>&lt;text x="520" y="282" text-anchor="middle" class="sm">Prometheus + Grafana&lt;/text>
&lt;rect x="620" y="260" width="160" height="36" class="b obs"/>&lt;text x="700" y="282" text-anchor="middle" class="sm">Loki + Tetragon&lt;/text>
&lt;path class="otel" d="M240,108 L240,130 L460,130 L460,168"/>
&lt;text x="350" y="146" class="tiny" fill="#1a73e8">OTel spans (gen_ai.*)&lt;/text>
&lt;rect x="20" y="330" width="780" height="110" class="b bg"/>
&lt;text x="30" y="348" class="tiny">CONTROL PLANE (Tune + Eval + Retrain + Prompt versioning)&lt;/text>
&lt;rect x="40" y="358" width="140" height="36" class="b ctrl"/>&lt;text x="110" y="380" text-anchor="middle" class="sm">Axolotl + PEFT&lt;/text>
&lt;rect x="200" y="358" width="140" height="36" class="b ctrl"/>&lt;text x="270" y="380" text-anchor="middle" class="sm">MLflow Tracking&lt;/text>
&lt;rect x="360" y="358" width="140" height="36" class="b ctrl"/>&lt;text x="430" y="380" text-anchor="middle" class="sm">Promptfoo + RAGAS&lt;/text>
&lt;rect x="520" y="358" width="140" height="36" class="b ctrl"/>&lt;text x="590" y="380" text-anchor="middle" class="sm">Argo / Kubeflow Pipelines&lt;/text>
&lt;rect x="680" y="358" width="100" height="36" class="b ctrl"/>&lt;text x="730" y="380" text-anchor="middle" class="sm">Argilla&lt;/text>
&lt;rect x="40" y="402" width="140" height="30" class="b ctrl"/>&lt;text x="110" y="420" text-anchor="middle" class="sm">Langfuse Prompts&lt;/text>
&lt;rect x="200" y="402" width="140" height="30" class="b ctrl"/>&lt;text x="270" y="420" text-anchor="middle" class="sm">Feast&lt;/text>
&lt;rect x="360" y="402" width="140" height="30" class="b ctrl"/>&lt;text x="430" y="420" text-anchor="middle" class="sm">NeMo Guardrails&lt;/text>
&lt;rect x="520" y="402" width="140" height="30" class="b ctrl"/>&lt;text x="590" y="420" text-anchor="middle" class="sm">OpenLineage + DataHub&lt;/text>
&lt;rect x="680" y="402" width="100" height="30" class="b ctrl"/>&lt;text x="730" y="420" text-anchor="middle" class="sm">Presidio&lt;/text>
&lt;path class="arr" d="M110,358 L110,300 L110,300"/>&lt;path class="arr" d="M110,300 L110,260 L120,260"/>
&lt;/svg>
&lt;/div>
&lt;p>Solid arrows mark data / control flow; the dashed blue ones are OTel traces. The K8s plane holds everything up. The control plane at the bottom is where the retraining pipelines, the evals in CI, the versioned prompts and the lineage live. The data plane on the left feeds both the serving side (RAG, configs) and the control plane (datasets, lineage). The observability plane receives from serving and from everything else.&lt;/p>
&lt;p>Now we go stage by stage. Each one opens with a context paragraph, then cards for the core tools (about 150 words each), bullets with relevant alternatives, and a specific decision matrix at the end.&lt;/p>
&lt;h2 id="stage-1--data--cross-cutting-data-versioning">Stage 1 — Data + cross-cutting Data versioning&lt;/h2>
&lt;p>The Data stage solves three distinct problems that beginners confuse: &lt;strong>versioning&lt;/strong> datasets (so that &lt;code>(dataset_id, version, hash)&lt;/code> exists and propagates), &lt;strong>storing and serving&lt;/strong> the operational corpus (object store + vector index + structured text), and &lt;strong>moving it&lt;/strong> between systems with CDC and stable schemas. Covered in detail in the posts on &lt;a href="https://blog.lo0.es/en/posts/data-versioning-llmops-dvc-lakefs-reproducible-golden-dataset/">data versioning with DVC and lakeFS&lt;/a>, &lt;a href="https://blog.lo0.es/en/posts/postgresql-qdrant-rag-ingestion-postman-syncing-two-worlds/">PostgreSQL + Qdrant in ingestion&lt;/a> and &lt;a href="https://blog.lo0.es/en/posts/rag-on-kafka-reference-architecture-streaming-datalakes/">RAG over Kafka&lt;/a>.&lt;/p>
&lt;h3 id="dvc-data-version-control">DVC (Data Version Control)&lt;/h3>
&lt;p>DVC puts datasets under version control with the same discipline that git applies to code. The &lt;code>.dvc&lt;/code> pointers live in git (plain text, about 200 bytes per dataset), the bulk content lives in a remote object store (S3, MinIO, Azure Blob, GCS). Each &lt;code>dvc add&lt;/code> computes a SHA-256 hash of the dataset, uploads it to the remote and stores the pointer. The key line: the &lt;code>dataset_hash&lt;/code> becomes the luggage tag that travels to the trainer, to experiment tracking and to lineage. The same dataset retrained twice produces the same hash, and therefore reproducible experiments. DVC integrates with MLflow and W&amp;amp;B as an input artefact. &lt;strong>Gotcha:&lt;/strong> it works well for datasets that change by replacement (I swap &lt;code>train.jsonl&lt;/code> for a new version) and worse for datasets with thousands of small files that change individually. For that case, combine it with lakeFS. Licence &lt;strong>Apache 2.0&lt;/strong>, maintained by &lt;strong>Iterative.ai&lt;/strong> since 2017. There is DVC Studio (managed) and &lt;code>dvc data&lt;/code> (pure CLI) on different planes.&lt;/p>
&lt;h3 id="lakefs">lakeFS&lt;/h3>
&lt;p>lakeFS brings git semantics (branch, commit, merge, rollback) to an entire S3/MinIO/ADLS bucket. Where DVC versions individual files as pointers in git, lakeFS versions &lt;strong>the whole bucket&lt;/strong>: you can branch the corpus, ingest new data into the branch, validate that it passes checks (recall@10 over golden queries for embeddings, completeness for a tabular corpus), and only then merge into &lt;code>main&lt;/code>. It is the piece that makes continuous RAG safe: the production corpus is always on &lt;code>main&lt;/code>, updates are tested on branches. It offers hooks (pre-merge, pre-commit) that fire automatic validations, and time-travel to reproduce the state of the bucket at a past date. &lt;strong>Gotcha:&lt;/strong> the manifest overhead on huge buckets (hundreds of millions of objects) deserves sizing; lakeFS keeps metadata in its own Postgres, not in the bucket. Licence &lt;strong>Apache 2.0&lt;/strong>, maintained by &lt;strong>Treeverse&lt;/strong> since 2020. Managed offering: &lt;strong>lakeFS Cloud&lt;/strong>.&lt;/p>
&lt;h3 id="minio">MinIO&lt;/h3>
&lt;p>MinIO is the S3-compatible object store that fills the &amp;ldquo;S3 on-premise&amp;rdquo; gap without surprises. API identical to S3 (the AWS SDKs work by pointing them at a different endpoint), its own CLI client (&lt;code>mc&lt;/code>), erasure-coded mode for fault tolerance, bucket-to-bucket replication, encryption at rest. It is the base on which the other data plane components are built: DVC remote, lakeFS underlying storage, Postgres snapshots, MLflow artefacts, eval datasets, saved models, distributed KV cache fabric. In small deployments it runs single-node multi-disk; in serious ones, distributed clusters. &lt;strong>Gotcha:&lt;/strong> the licence changed to &lt;strong>AGPLv3&lt;/strong> in 2021 (it was Apache 2.0 before), which means distributing software connected to MinIO obliges you to open the code that connects to it. For internal on-premise use this is not a problem; for a vendor packaging MinIO inside a commercial product, it is. Maintained by &lt;strong>MinIO Inc.&lt;/strong> with a SUBNET enterprise offering and a community fork called &lt;strong>AIStor&lt;/strong> launched in 2025.&lt;/p>
&lt;h3 id="qdrant">Qdrant&lt;/h3>
&lt;p>Qdrant is the OSS vector database most aligned with this blog&amp;rsquo;s &amp;ldquo;RAG corpus per tenant with strict ACLs&amp;rdquo; pattern. Written in Rust, it exposes a REST + gRPC API, indexes with HNSW + scalar/binary quantisation to cut memory, supports efficient payload filtering (it is not post-filtering: it integrates the filter into the HNSW search), and allows collections isolated per tenant. For the &lt;a href="https://blog.lo0.es/en/posts/anatomy-llm-request-production-tour-six-stages/">multi-tenant chatbot&lt;/a> scenario, Qdrant is where the &lt;code>tenant_&amp;lt;id&amp;gt;_kb_v3&lt;/code> collections with strict ACL live. It scales well horizontally (sharding by payload) and vertically (millions of chunks on a node with 64GB RAM). &lt;strong>Gotcha:&lt;/strong> binary quantisation is aggressive, it cuts VRAM by 32× but degrades recall by 10-20 %; enabling it without re-tuning the threshold breaks retrieval silently. Licence &lt;strong>Apache 2.0&lt;/strong>, maintained by &lt;strong>Qdrant Solutions GmbH&lt;/strong> (Germany). There is Qdrant Cloud (managed) and EU-only support for ENS cases.&lt;/p>
&lt;h3 id="postgresql--pgvector">PostgreSQL + pgvector&lt;/h3>
&lt;p>Postgres 18 with the &lt;code>pgvector&lt;/code> extension is the stack&amp;rsquo;s &amp;ldquo;hidden vector database&amp;rdquo;: when the corpus is small (under a million embeddings) and Postgres is already in production for operational data, running a separate Qdrant is expensive operations. pgvector adds a &lt;code>vector(dim)&lt;/code> type, HNSW and IVF indexes, and the operators &lt;code>&amp;lt;-&amp;gt;&lt;/code>, &lt;code>&amp;lt;#&amp;gt;&lt;/code>, &lt;code>&amp;lt;=&amp;gt;&lt;/code> for cosine, L2 and dot product. Combined with &lt;code>tsvector&lt;/code> (Postgres full-text search) it allows &lt;strong>hybrid search&lt;/strong>, dense + sparse, in a single SQL query. Version 0.8 (2025) introduced halfvec and bit support to reduce size by 4×-8×. &lt;strong>Gotcha:&lt;/strong> HNSW in pgvector consumes a fair amount of RAM to build the index (roughly 2× the size of the embeddings) and blocks inserts during the build; in production you build on a secondary, promote it, and discard the primary. Licence &lt;strong>PostgreSQL License&lt;/strong> (permissive BSD-style) for both the core and pgvector. Maintained by the &lt;strong>PostgreSQL Development Group&lt;/strong> plus pgvector by &lt;strong>Andrew Kane&lt;/strong> + Crunchy Data + Neon.&lt;/p>
&lt;h3 id="apache-kafka--debezium">Apache Kafka + Debezium&lt;/h3>
&lt;p>Kafka is the event bus where &amp;ldquo;everything that happens in the company is a stream&amp;rdquo; becomes real. For LLMOps in production it serves two functions: &lt;strong>CDC from source systems&lt;/strong> (Debezium captures changes in Postgres / MySQL / MongoDB and publishes them as topics) and &lt;strong>LLM event buffer&lt;/strong> (every request, every piece of feedback, every eval result ends up in a topic with the &lt;code>trace_id&lt;/code> propagated). As the &lt;a href="https://blog.lo0.es/en/posts/rag-on-kafka-reference-architecture-streaming-datalakes/">post on RAG over Kafka&lt;/a> explains, the RAG corpus is kept fresh by capturing changes from the CMS / source system as CDC, running the embedding in Flink streaming, and ingesting into Qdrant continuously. &lt;strong>Gotcha:&lt;/strong> a badly sized Kafka with long retention plus multi-client topics turns into a fast disk sink; measuring throughput per topic and key cardinality before production is mandatory. Kafka licence &lt;strong>Apache 2.0&lt;/strong> (&lt;strong>ASF&lt;/strong> project); Debezium &lt;strong>Apache 2.0&lt;/strong> (project incubated by &lt;strong>Red Hat&lt;/strong>). Kafka-compatible drop-in alternative: &lt;strong>Redpanda&lt;/strong> (BSL, restricted commercial use).&lt;/p>
&lt;h3 id="apache-flink-brief-mention">Apache Flink (brief mention)&lt;/h3>
&lt;p>Flink processes streams with sub-second latency and exactly-once semantics. On the LLM plane it is used to: run embeddings in streaming (over CDC topics), aggregate online metrics, materialise features for retraining. Licence Apache 2.0, ASF. Common alternative: &lt;strong>Spark Structured Streaming&lt;/strong> (also ASF, micro-batch latency).&lt;/p>
&lt;p>&lt;strong>More options for Data&lt;/strong>, mentioned on the blog:&lt;/p>
&lt;ul>
&lt;li>&lt;strong>Ceph&lt;/strong> — object store for large clusters with geo-distributed replication. LGPL/Apache licence, &lt;strong>Red Hat / IBM&lt;/strong>.&lt;/li>
&lt;li>&lt;strong>Milvus&lt;/strong> — C++ vector database, an alternative to Qdrant; better for corpora of billions. Apache 2.0, &lt;strong>Zilliz&lt;/strong>.&lt;/li>
&lt;li>&lt;strong>Karapace&lt;/strong> — Confluent-compatible OSS Schema Registry. Apache 2.0, &lt;strong>Aiven&lt;/strong>.&lt;/li>
&lt;li>&lt;strong>DataHub / Apache Atlas / OpenMetadata&lt;/strong> — catalogue + lineage. Apache 2.0, &lt;strong>Acryl Data / ASF / Collate&lt;/strong> respectively.&lt;/li>
&lt;li>&lt;strong>OpenLineage&lt;/strong> — cross-system lineage event standard. Apache 2.0, &lt;strong>Linux Foundation AI&amp;amp;Data&lt;/strong>.&lt;/li>
&lt;/ul>
&lt;p>&lt;strong>Decision matrix — Data:&lt;/strong>&lt;/p>
&lt;table>
&lt;thead>
&lt;tr>
&lt;th>If your case is&lt;/th>
&lt;th>Choose&lt;/th>
&lt;/tr>
&lt;/thead>
&lt;tbody>
&lt;tr>
&lt;td>Corpus &amp;lt; 1M embeddings, you already have Postgres&lt;/td>
&lt;td>&lt;strong>pgvector&lt;/strong> (one component fewer)&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>Corpus 1M-100M, multi-tenant with ACL&lt;/td>
&lt;td>&lt;strong>Qdrant&lt;/strong> (integrated filtering, ACLs per collection)&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>Corpus &amp;gt; 100M, aggressive sharding&lt;/td>
&lt;td>&lt;strong>Milvus&lt;/strong> (scales linearly better into the billions)&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>Training datasets + experiment tracking&lt;/td>
&lt;td>&lt;strong>DVC&lt;/strong> over MinIO + MLflow integration&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>RAG corpus with controlled releases&lt;/td>
&lt;td>&lt;strong>lakeFS&lt;/strong> over MinIO + pre-merge hooks&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>You want both&lt;/td>
&lt;td>&lt;strong>DVC + lakeFS&lt;/strong> as complements (the blog&amp;rsquo;s recommendation)&lt;/td>
&lt;/tr>
&lt;/tbody>
&lt;/table>
&lt;h2 id="stage-2--tune">Stage 2 — Tune&lt;/h2>
&lt;p>The Tune stage produces a new &lt;code>model_id, model_version&lt;/code>, typically a LoRA adapter over a stable base, with lineage back to the dataset and experiment tracking to reproduce it. Detail in the &lt;a href="https://blog.lo0.es/en/posts/continuous-fine-tuning-production-real-traffic-deployed-adapter/">post on continuous fine-tuning&lt;/a>.&lt;/p>
&lt;h3 id="huggingface-transformers--peft">HuggingFace Transformers + PEFT&lt;/h3>
&lt;p>&lt;code>transformers&lt;/code> is the canonical library for loading and training models of the decoder-only family (Llama, Mistral, Qwen, Gemma…) and encoder-decoder. &lt;code>peft&lt;/code> (Parameter-Efficient Fine-Tuning) is the complement that adds declarative support for LoRA, QLoRA, IA3 and assorted adapters. Together they form the &lt;strong>mandatory core&lt;/strong> of the OSS Tune stack: any higher-level framework (Axolotl, LLaMA-Factory) uses them underneath. PEFT allows you to train an adapter of about 280 MB (order of magnitude) instead of a full model of about 140 GB, with a functionally equivalent result on most style / domain adjustment tasks. &lt;strong>Gotcha:&lt;/strong> PEFT with a badly configured &lt;code>target_modules&lt;/code> trains an adapter that covers only Q and V of the attention, leaving out key, output proj and MLP. The result looks trained but performs poorly; adding &lt;code>target_modules=[&amp;quot;all-linear&amp;quot;]&lt;/code> fixes it (at the cost of a larger adapter). Licence &lt;strong>Apache 2.0&lt;/strong>, maintained by &lt;strong>Hugging Face SAS&lt;/strong> (a French company); open governance model with active external maintainers.&lt;/p>
&lt;h3 id="bitsandbytes">bitsandbytes&lt;/h3>
&lt;p>bitsandbytes implements weight quantisation to 8-bit and 4-bit with NF4 for models loaded with &lt;code>transformers&lt;/code>. It reduces the 140 GB of Llama 3 70B FP16 to about 40 GB in NF4, allowing QLoRA training on a single H100 80GB. The trick is that the weights stay quantised in memory while the sensitive computations (attention, gradient updates in the adapter) are done in FP16/BF16 with on-the-fly dequantisation. Ideal for fine-tuning on limited hardware and for serving with vLLM when you want to cut VRAM. &lt;strong>Gotcha:&lt;/strong> NF4 quantisation is lossy; on small models (&amp;lt; 7B) the quality degradation is noticeable. For production serving of models under 7B, INT8 is preferred (more memory, less loss) or FP8 if the hardware supports it (the H100 does). Licence &lt;strong>MIT&lt;/strong>, maintained by &lt;strong>Tim Dettmers&lt;/strong> (originally at U. Washington, now with support from Anthropic and HuggingFace).&lt;/p>
&lt;h3 id="mlflow-tracking">MLflow Tracking&lt;/h3>
&lt;p>MLflow is the reference OSS experiment tracking tool: each trainer run records parameters (lr, batch size, epochs, target_modules), metrics (loss curves, eval scores), artefacts (model, tokeniser, configs) and, crucially, &lt;strong>input artefacts&lt;/strong> (dataset_id, dataset_hash, parent_run). The model registry associates each &lt;code>model_version&lt;/code> with a reproducible &lt;code>run_id&lt;/code>. The line of continuity between Tune and Deploy passes through here: the deployment reads the model to serve from the registry, with its lineage made explicit. MLflow 2.x integrates &lt;strong>MLflow Prompts&lt;/strong> (prompt registry) and &lt;strong>MLflow Tracing&lt;/strong> (OTel-compatible spans), cutting the number of components needed. &lt;strong>Gotcha:&lt;/strong> the default backend store is SQLite, which works for personal experiments and breaks on a shared cluster. In production: Postgres as the backend store + MinIO/S3 as the artefact store. Licence &lt;strong>Apache 2.0&lt;/strong>, maintained by &lt;strong>LF AI &amp;amp; Data&lt;/strong> (donated by Databricks in 2020).&lt;/p>
&lt;h3 id="axolotl">Axolotl&lt;/h3>
&lt;p>Axolotl wraps &lt;code>transformers + PEFT + bitsandbytes + DeepSpeed + FSDP&lt;/code> in a declarative YAML configuration: instead of writing a script of about 300 lines to configure a fine-tuning run, you define a &lt;code>config.yml&lt;/code> with base model, dataset path, LoRA config and training hyperparameters, and run it in one line. It supports Llama, Mistral, Qwen, Gemma, Phi… loads. It keeps compatibility with the HuggingFace Hub to download models and datasets, and with MLflow / W&amp;amp;B for tracking. It is the convenience framework this blog cites when it talks about &amp;ldquo;productive fine-tuning without reinventing the wheel&amp;rdquo;. &lt;strong>Gotcha:&lt;/strong> the pace of change in the community is fast; a &lt;code>config.yml&lt;/code> that worked six months ago can break with a current version because of internal refactors. Pinning the exact Axolotl version in the environment mitigates this. Licence &lt;strong>Apache 2.0&lt;/strong>, maintained by the &lt;strong>OpenAccess AI Collective&lt;/strong> (community-driven). A very similar alternative, more widely used in China: &lt;strong>LLaMA-Factory&lt;/strong> (Apache 2.0, Beihang U.).&lt;/p>
&lt;h3 id="ray-train">Ray Train&lt;/h3>
&lt;p>Ray Train scales fine-tuning to multiple nodes by distributing workers across a Ray cluster. While DeepSpeed and FSDP are &lt;strong>intra-job parallelism&lt;/strong> (several GPUs collaborating on one job), Ray Train is the &lt;strong>orchestration plane&lt;/strong> that builds the cluster, launches workers, manages checkpoints, recovers from node failures, and integrates with Slurm or Kubernetes. For training runs beyond 8 GPUs on changing clusters, Ray Train avoids the operational burden of &amp;ldquo;manually launching N torchrun processes with NCCL&amp;rdquo;. It combines with MLflow for tracking. &lt;strong>Gotcha:&lt;/strong> the Ray learning curve is real; for a single node with 4-8 GPUs, &lt;code>torchrun&lt;/code> or Hugging Face Accelerate are simpler. Ray Train shines when there are N changing nodes. Licence &lt;strong>Apache 2.0&lt;/strong>, maintained by &lt;strong>Anyscale Inc.&lt;/strong> (commercial backer) + community. A more K8s-native alternative: &lt;strong>Kubeflow Training Operator&lt;/strong> (Apache 2.0, LF AI &amp;amp; Data).&lt;/p>
&lt;p>&lt;strong>More options for Tune:&lt;/strong>&lt;/p>
&lt;ul>
&lt;li>&lt;strong>DeepSpeed&lt;/strong> — ZeRO parallelism in 3 stages, mixed precision, CPU/NVMe offload. MIT, &lt;strong>Microsoft&lt;/strong>.&lt;/li>
&lt;li>&lt;strong>FSDP&lt;/strong> (Fully Sharded Data Parallel) — native PyTorch parallelism, an alternative to DeepSpeed. BSD, &lt;strong>Meta&lt;/strong>.&lt;/li>
&lt;li>&lt;strong>LLaMA-Factory&lt;/strong> — equivalent to Axolotl with a focus on the Llama family. Apache 2.0, &lt;strong>Beihang University&lt;/strong>.&lt;/li>
&lt;/ul>
&lt;p>&lt;strong>Decision matrix — Tune:&lt;/strong>&lt;/p>
&lt;table>
&lt;thead>
&lt;tr>
&lt;th>If your case is&lt;/th>
&lt;th>Choose&lt;/th>
&lt;/tr>
&lt;/thead>
&lt;tbody>
&lt;tr>
&lt;td>Fine-tune on 1 GPU with 24GB (RTX 4090)&lt;/td>
&lt;td>&lt;strong>QLoRA with bitsandbytes NF4&lt;/strong> + Axolotl&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>Fine-tune on 1 H100 80GB, models &amp;lt; 13B&lt;/td>
&lt;td>&lt;strong>LoRA bf16&lt;/strong> + Axolotl&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>Fine-tune on 4-8 GPUs, single node&lt;/td>
&lt;td>&lt;strong>transformers + PEFT + Accelerate&lt;/strong> + MLflow&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>Multi-node fine-tune on a K8s cluster&lt;/td>
&lt;td>&lt;strong>Kubeflow Training Operator&lt;/strong> or &lt;strong>Ray Train&lt;/strong>&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>Reproducible tracking is mandatory&lt;/td>
&lt;td>&lt;strong>MLflow + DVC input artefact&lt;/strong>&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>You want the minimum viable&lt;/td>
&lt;td>&lt;strong>Axolotl + MLflow&lt;/strong>&lt;/td>
&lt;/tr>
&lt;/tbody>
&lt;/table>
&lt;h2 id="stage-3--eval--guardrails">Stage 3 — Eval + Guardrails&lt;/h2>
&lt;p>Eval validates candidates pre and post promotion against a golden set with operational metrics; Guardrails runs safety online. Detailed in the posts on &lt;a href="https://blog.lo0.es/en/posts/evals-llm-layer-after-tracing/">evals&lt;/a> and &lt;a href="https://blog.lo0.es/en/posts/llm-guardrails-safety-four-lines-of-defence/">guardrails&lt;/a>.&lt;/p>
&lt;h3 id="deepeval">DeepEval&lt;/h3>
&lt;p>DeepEval is the &amp;ldquo;pytest-style&amp;rdquo; OSS eval suite: you define tests with assertions over faithfulness, answer relevancy, contextual precision, hallucination rate, summarisation quality… and run them in CI. Each metric is an evaluator: some rule-based, others LLM-as-judge with auditable prompts. The philosophy is &amp;ldquo;evals as unit tests&amp;rdquo;: parameterisable by dataset, failable in CI, integrable with GitHub Actions. &lt;strong>Gotcha:&lt;/strong> LLM-as-judge metrics vary between judge model versions, so if the judge moves up a version, the thresholds lose their earlier statistical meaning. Explicit pinning of the judge model in config plus periodic threshold recalibration is mandatory discipline. Licence &lt;strong>Apache 2.0&lt;/strong>, maintained by &lt;strong>Confident AI&lt;/strong> (a company); parallel commercial SaaS offering. Comparable: &lt;strong>TruLens&lt;/strong> (MIT, &lt;strong>TruEra&lt;/strong>) and &lt;strong>G-Eval&lt;/strong> (academic).&lt;/p>
&lt;h3 id="ragas-rag-assessment">RAGAS (RAG Assessment)&lt;/h3>
&lt;p>RAGAS specialises in evaluating RAG pipelines. It defines four canonical metrics: &lt;strong>faithfulness&lt;/strong> (the answer is supported by the retrieved chunks), &lt;strong>answer relevancy&lt;/strong> (the answer addresses the query), &lt;strong>context precision&lt;/strong> (the retrieved chunks are relevant), &lt;strong>context recall&lt;/strong> (all the relevant chunks were retrieved). Each metric is computed with LLM-as-judge over a dataset of (query, context, expected answer). For a RAG system, RAGAS is the evaluator that measures whether retrieval is aligned with generation. It integrates with Langfuse and MLflow to store results. &lt;strong>Gotcha:&lt;/strong> RAGAS works well with golden sets of under 1000 examples; over huge golden sets the cost of the LLM judge per evaluation explodes, and the practice is to sample. Licence &lt;strong>Apache 2.0&lt;/strong>, maintained by &lt;strong>Exploding Gradients&lt;/strong> (the authors&amp;rsquo; company).&lt;/p>
&lt;h3 id="promptfoo">Promptfoo&lt;/h3>
&lt;p>Promptfoo is the declarative, CI-oriented evaluator: in &lt;code>promptfooconfig.yaml&lt;/code> you define a set of prompts and a set of assertions (contains text X, does not contain Y, faithfulness &amp;gt; 0.8, judge approves…), point at a provider (OpenAI compatible, vLLM, Ollama…), and &lt;code>promptfoo eval&lt;/code> runs the matrix of prompts × providers × assertions, returns a diff against the baseline and fails CI if anything regresses. It is the most &amp;ldquo;DevOps-friendly&amp;rdquo; piece of the evals ecosystem: it integrates trivially with GitHub Actions, GitLab CI or Jenkins. &lt;strong>Gotcha:&lt;/strong> assertion thresholds have to be calibrated with real data; starting at a default &lt;code>&amp;gt; 0.5&lt;/code> produces false positives that erode the team&amp;rsquo;s trust. Calibrate after the first week. Licence &lt;strong>MIT&lt;/strong>, maintained by &lt;strong>Promptfoo, Inc.&lt;/strong> (a company); a commercial Promptfoo Cloud SaaS offering exists but the OSS version is complete.&lt;/p>
&lt;h3 id="nemo-guardrails">NeMo Guardrails&lt;/h3>
&lt;p>NeMo Guardrails is NVIDIA&amp;rsquo;s framework for defining and enforcing policies in LLM systems through a DSL called &lt;strong>Colang&lt;/strong>. It lets you express rules such as &amp;ldquo;if the user asks about topic X, answer with template Y&amp;rdquo; or &amp;ldquo;if the model tries to do Z, block it&amp;rdquo; in a conversational-script syntax rather than in Python. It runs as middleware between the app and the model: input rails (validate what comes in), output rails (validate what goes out), dialog rails (control the flow). Designed for complex multi-turn systems where the policies are nontrivial. &lt;strong>Gotcha:&lt;/strong> Colang adds latency per turn (roughly 50-200 ms depending on the policy graph); for high-throughput conversational chat, dialog rails are disabled and only input + output remain. Licence &lt;strong>Apache 2.0&lt;/strong>, maintained by &lt;strong>NVIDIA&lt;/strong>.&lt;/p>
&lt;h3 id="microsoft-presidio">Microsoft Presidio&lt;/h3>
&lt;p>Presidio is the most mature OSS PII (Personally Identifiable Information) detector in the ecosystem. It detects DNI, NIE, IBAN, phone numbers, emails, physical addresses, credit card numbers, personal names, dates of birth… with recognisers based on regex + NER (spaCy) + custom validators. It supports &lt;strong>redaction&lt;/strong> (replace with placeholders), &lt;strong>masking&lt;/strong> (asterisks) or &lt;strong>deterministic anonymisation&lt;/strong> (repeatable hash). For ENS/NIS2 scenarios, it is the piece placed in front (on input) and behind (on output) the LLM to guarantee that no PII is processed or emitted. &lt;strong>Gotcha:&lt;/strong> the built-in recognisers cover English well and the rest badly; for Spanish, Catalan and Basque you have to add custom recognisers, which is disciplined work but doable. Licence &lt;strong>MIT&lt;/strong>, maintained by &lt;strong>Microsoft&lt;/strong>.&lt;/p>
&lt;p>&lt;strong>More options for Eval:&lt;/strong>&lt;/p>
&lt;ul>
&lt;li>&lt;strong>Phoenix Arize OSS&lt;/strong> — combines tracing + evals, an alternative to Langfuse Evals. ELv2, &lt;strong>Arize AI&lt;/strong>.&lt;/li>
&lt;li>&lt;strong>lm-eval-harness&lt;/strong> — academic suite with standard benchmarks (MMLU, HellaSwag…). MIT, &lt;strong>EleutherAI&lt;/strong>.&lt;/li>
&lt;li>&lt;strong>HELM&lt;/strong> — holistic academic evals. Apache 2.0, &lt;strong>Stanford CRFM&lt;/strong>.&lt;/li>
&lt;li>&lt;strong>Guardrails AI&lt;/strong> — a pythonic alternative to NeMo Guardrails. Apache 2.0, &lt;strong>Guardrails AI Inc.&lt;/strong>.&lt;/li>
&lt;li>&lt;strong>LlamaGuard / PromptGuard / ShieldGemma&lt;/strong> — safety models, not frameworks. Open weights, Meta / Google.&lt;/li>
&lt;/ul>
&lt;p>&lt;strong>Decision matrix — Eval + Guardrails:&lt;/strong>&lt;/p>
&lt;table>
&lt;thead>
&lt;tr>
&lt;th>If your case is&lt;/th>
&lt;th>Choose&lt;/th>
&lt;/tr>
&lt;/thead>
&lt;tbody>
&lt;tr>
&lt;td>Eval in CI, &amp;ldquo;pytest for LLMs&amp;rdquo; style&lt;/td>
&lt;td>&lt;strong>Promptfoo + GitHub Actions&lt;/strong>&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>Eval specific to a RAG pipeline&lt;/td>
&lt;td>&lt;strong>RAGAS + Langfuse datasets&lt;/strong>&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>General eval with custom metrics&lt;/td>
&lt;td>&lt;strong>DeepEval + MLflow dataset&lt;/strong>&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>Dialog policy with declarative rules&lt;/td>
&lt;td>&lt;strong>NeMo Guardrails (Colang)&lt;/strong>&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>PII redaction in/out only&lt;/td>
&lt;td>&lt;strong>Presidio&lt;/strong> (you do not need NeMo)&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>Open safety model in Spanish&lt;/td>
&lt;td>&lt;strong>LlamaGuard 3&lt;/strong> or &lt;strong>ShieldGemma&lt;/strong>&lt;/td>
&lt;/tr>
&lt;/tbody>
&lt;/table>
&lt;h2 id="stage-4--deploy">Stage 4 — Deploy&lt;/h2>
&lt;p>Deploy serves tokens to the user with predictable throughput and latency, adapter hot-swap and multi-tenancy where it applies. Covered in the posts on &lt;a href="https://blog.lo0.es/en/posts/vllm-kubernetes-llm-inference-that-scales/">vLLM on K8s&lt;/a>, &lt;a href="https://blog.lo0.es/en/posts/llm-inference-operators-kubernetes/">LLM operators&lt;/a>, &lt;a href="https://blog.lo0.es/en/posts/gpu-cluster-as-multi-tenant-platform/">multi-tenant cluster&lt;/a>, &lt;a href="https://blog.lo0.es/en/posts/kv-cache-working-memory-llm-inference/">KV cache&lt;/a>, &lt;a href="https://blog.lo0.es/en/posts/pagedattention-vllm-block-manager/">PagedAttention&lt;/a> and &lt;a href="https://blog.lo0.es/en/posts/disaggregated-serving-prefill-decode-specialised-pods/">disaggregated serving&lt;/a>.&lt;/p>
&lt;h3 id="vllm">vLLM&lt;/h3>
&lt;p>vLLM is &lt;strong>the&lt;/strong> reference OSS inference engine. It implements &lt;strong>PagedAttention&lt;/strong> (KV cache paging in the style of virtual memory, avoiding fragmentation), &lt;strong>continuous batching&lt;/strong> (requests join the batch as they arrive, rather than waiting for the next batch), &lt;strong>prefix caching&lt;/strong> (common prefixes, system prompts, do not recompute the KV cache), &lt;strong>LoRA hot-swap&lt;/strong> (&lt;code>--enable-lora&lt;/code> allows loading and unloading adapters without restarting the engine), an &lt;strong>OpenAI-compatible&lt;/strong> API, and &lt;strong>disaggregated prefill/decode&lt;/strong> support since 2025. It covers almost everything from Llama 3 / Mistral / Qwen / DeepSeek. &lt;strong>Gotcha:&lt;/strong> maximum throughput is only reached with &lt;code>--max-num-seqs&lt;/code> and &lt;code>--gpu-memory-utilization&lt;/code> tuned for the specific model and hardware; the defaults are conservative. The initial tuning session pays off: 2-3× throughput. Licence &lt;strong>Apache 2.0&lt;/strong>, originated at UC Berkeley, today maintained by the &lt;strong>vLLM Project / LF AI &amp;amp; Data&lt;/strong> plus a broad community (Red Hat, NVIDIA, AWS, IBM contribute). Serious alternatives in the same bucket: &lt;strong>TGI&lt;/strong> (Apache 2.0, &lt;strong>Hugging Face&lt;/strong>), &lt;strong>SGLang&lt;/strong> (Apache 2.0, &lt;strong>LMSys&lt;/strong>), &lt;strong>TensorRT-LLM&lt;/strong> (Apache 2.0, &lt;strong>NVIDIA&lt;/strong>, requires conversion).&lt;/p>
&lt;h3 id="kserve">KServe&lt;/h3>
&lt;p>KServe is the Kubernetes operator for serving ML models, LLMs included, in a declarative pattern: you define an &lt;code>InferenceService&lt;/code> YAML with the model and predictor (which can be vLLM, TGI, Triton, or a custom container) and KServe handles scheduling onto GPU nodes, autoscaling (scale-to-zero included), traffic splitting for canary, and model registry integration. It is the layer that standardises &amp;ldquo;how a model is deployed on K8s&amp;rdquo; across multiple engines, instead of inventing engine-specific YAML. It supports multi-model with &lt;strong>Inference Graphs&lt;/strong> (chaining preprocessor → model → postprocessor) and integrates with KEDA/Karpenter for GPU pool autoscaling. &lt;strong>Gotcha:&lt;/strong> scale-to-zero on GPU works badly in practice because the warm-up (loading weights into VRAM) takes tens of seconds; &lt;code>minReplicas: 1&lt;/code> is better. Licence &lt;strong>Apache 2.0&lt;/strong>, maintained by &lt;strong>Kubeflow / LF AI &amp;amp; Data&lt;/strong>. Alternatives: &lt;strong>KubeRay&lt;/strong> (Apache 2.0, &lt;strong>Anyscale&lt;/strong>), &lt;strong>llm-d&lt;/strong> (Apache 2.0, &lt;strong>CNCF&lt;/strong>), &lt;strong>KAITO&lt;/strong> (MIT, &lt;strong>Microsoft Azure&lt;/strong>).&lt;/p>
&lt;h3 id="triton-inference-server">Triton Inference Server&lt;/h3>
&lt;p>Triton serves heterogeneous models on a single backend: LLMs (via the vLLM or TensorRT-LLM backend), traditional models (ONNX, TorchScript, TensorFlow), custom models. For systems that mix LLM inference with traditional classifiers, embedding encoders, reranking models, OCR and so on, Triton avoids having N different engines in N pods. It supports ensemble models (chaining models in a single request), dynamic batching, model versioning, model warmup. &lt;strong>Gotcha:&lt;/strong> Triton is flexible but heavy to operate; for systems serving only LLMs, vLLM directly is simpler and more optimised. Triton shines when there is real heterogeneity. Licence &lt;strong>BSD-3-Clause&lt;/strong>, maintained by &lt;strong>NVIDIA&lt;/strong>.&lt;/p>
&lt;h3 id="envoy-ai-gateway">Envoy AI Gateway&lt;/h3>
&lt;p>Envoy AI Gateway is the CNCF ecosystem&amp;rsquo;s &amp;ldquo;LLM-aware API gateway&amp;rdquo;. Built on Envoy Proxy, it adds knowledge of the OpenAI-compatible APIs (chat completions, embeddings, etc.), routing across multiple backends (local vLLM + OpenAI + Anthropic + Bedrock), &lt;strong>token-based rate limiting&lt;/strong> (limits by tokens per minute, not by requests), intelligent retries, fallback between providers, and built-in OTel observability. It is the piece that makes &amp;ldquo;AI Gateway&amp;rdquo; real as an architectural category. &lt;strong>Gotcha:&lt;/strong> integration with authentication (OIDC, JWT) is flexible but requires detailed Envoy configuration; an &amp;ldquo;out of the box&amp;rdquo; AI Gateway with no configuration produces an Envoy that passes everything through. Licence &lt;strong>Apache 2.0&lt;/strong>, maintained by &lt;strong>CNCF&lt;/strong> since the initial donation from Tetrate. Alternatives: &lt;strong>LiteLLM Proxy&lt;/strong> (MIT, &lt;strong>BerriAI&lt;/strong>), &lt;strong>Portkey&lt;/strong> (MIT, &lt;strong>Portkey AI&lt;/strong>), &lt;strong>Kong AI Gateway&lt;/strong> (Apache 2.0 base + EE, &lt;strong>Kong Inc.&lt;/strong>).&lt;/p>
&lt;h3 id="llamacpp">llama.cpp&lt;/h3>
&lt;p>llama.cpp serves LLMs on CPUs (and Apple Silicon, GPUs via Vulkan/Metal/CUDA) with very aggressive quantisation (GGUF format, down to 2-bit). It is the canonical option for inference on hardware without a dedicated GPU: edge devices, workstations, development machines. It covers everything from small models (Phi-3, Gemma 2B) to Llama 70B on hardware with enough RAM. &lt;strong>Gotcha:&lt;/strong> latency on CPU is orders of magnitude worse than on a dedicated GPU, so it is useful for offline evals, drift checks and local development, not for production serving under real load. Licence &lt;strong>MIT&lt;/strong>, maintained by &lt;strong>Georgi Gerganov&lt;/strong> + community.&lt;/p>
&lt;p>&lt;strong>More options for Deploy:&lt;/strong>&lt;/p>
&lt;ul>
&lt;li>&lt;strong>TensorRT-LLM&lt;/strong> — maximum optimisation on NVIDIA Hopper/Ada. Apache 2.0, NVIDIA.&lt;/li>
&lt;li>&lt;strong>SGLang&lt;/strong> — good for workloads with structured generation and JSON. Apache 2.0, LMSys.&lt;/li>
&lt;li>&lt;strong>TGI&lt;/strong> — a mature alternative, focused on the HuggingFace ecosystem. Apache 2.0, HuggingFace.&lt;/li>
&lt;li>&lt;strong>NVIDIA Dynamo&lt;/strong> — multi-node disaggregated serving. Apache 2.0, NVIDIA.&lt;/li>
&lt;li>&lt;strong>llm-d&lt;/strong> — K8s operator specific to LLMs. Apache 2.0, CNCF.&lt;/li>
&lt;/ul>
&lt;p>&lt;strong>Decision matrix — Deploy:&lt;/strong>&lt;/p>
&lt;table>
&lt;thead>
&lt;tr>
&lt;th>If your case is&lt;/th>
&lt;th>Choose&lt;/th>
&lt;/tr>
&lt;/thead>
&lt;tbody>
&lt;tr>
&lt;td>Production serving on NVIDIA H100/A100&lt;/td>
&lt;td>&lt;strong>vLLM&lt;/strong> (the safe default)&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>Absolute throughput squeezing on Hopper&lt;/td>
&lt;td>&lt;strong>TensorRT-LLM&lt;/strong> + vLLM plugin or standalone&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>Edge / local dev without GPU&lt;/td>
&lt;td>&lt;strong>llama.cpp&lt;/strong>&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>Multi-model (LLM + classifiers + encoders)&lt;/td>
&lt;td>&lt;strong>Triton&lt;/strong> with the vLLM backend&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>Declarative K8s with autoscaling&lt;/td>
&lt;td>&lt;strong>KServe&lt;/strong> + vLLM as predictor&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>AI Gateway with token rate limiting&lt;/td>
&lt;td>&lt;strong>Envoy AI Gateway&lt;/strong>&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>Multi-node disaggregated GPU cluster&lt;/td>
&lt;td>&lt;strong>NVIDIA Dynamo&lt;/strong> over vLLM&lt;/td>
&lt;/tr>
&lt;/tbody>
&lt;/table>
&lt;h2 id="stage-5--observe">Stage 5 — Observe&lt;/h2>
&lt;p>Observe propagates &lt;code>trace_id&lt;/code> end-to-end, emits runtime metrics, runs an LLM judge over sampling and detects drift. Detailed in &lt;a href="https://blog.lo0.es/en/posts/agentsight-llm-tracing-ebpf-zero-instrumentation/">tracing with AgentSight&lt;/a>, &lt;a href="https://blog.lo0.es/en/posts/mcp-deep-observability-opentelemetry/">MCP observability with OTel&lt;/a> and &lt;a href="https://blog.lo0.es/en/posts/ebpf-local-inference-statistical-drift-detection/">eBPF + drift&lt;/a>.&lt;/p>
&lt;h3 id="opentelemetry-collector">OpenTelemetry Collector&lt;/h3>
&lt;p>The OTel Collector is the agent that receives traces, metrics and logs in OTel format (or in any other via receivers), processes them (filters, sampling, attribute enrichment, PII redaction), and routes them to one or several backends (Tempo, Jaeger, Prometheus, Loki, Langfuse…). It is &lt;strong>the piece that decouples the apps from the observability backend&lt;/strong>: moving from Tempo to Jaeger means changing the Collector&amp;rsquo;s exporter, not the app. For LLMOps it matters especially because the &lt;strong>OTel GenAI semantic conventions&lt;/strong> spec defines the attributes &lt;code>gen_ai.request.model&lt;/code>, &lt;code>gen_ai.prompt.version&lt;/code>, &lt;code>gen_ai.response.tokens&lt;/code> and so on, which stitch the &lt;code>trace_id&lt;/code> to the system&amp;rsquo;s lineage. &lt;strong>Gotcha:&lt;/strong> the Collector configuration tends to grow; without discipline and periodic review it ends up as an 800-line YAML that nobody understands. Modularising with &lt;code>extensions&lt;/code> helps. Licence &lt;strong>Apache 2.0&lt;/strong>, maintained by the &lt;strong>CNCF / OpenTelemetry Project&lt;/strong>.&lt;/p>
&lt;h3 id="tempo-traces--jaeger">Tempo (traces) + Jaeger&lt;/h3>
&lt;p>Grafana Tempo is the distributed trace backend optimised for cost: it uses an object store (S3/MinIO) instead of Elasticsearch, deduplicates by &lt;code>trace_id&lt;/code>, and integrates natively with Grafana for visualisation. For LLMOps, where a real request generates 10-30 spans (gateway, prompt pull, RAG retrieval, prefill, decode N times, scoring), Tempo copes with high volumes at a reasonable cost. &lt;strong>Jaeger&lt;/strong> is the more established CNCF alternative, better for cases under 100k traces per day, worse for native object store. &lt;strong>Gotcha:&lt;/strong> Tempo has no traditional indexing; searches like &amp;ldquo;traces that took more than 5s and touched tenant X&amp;rdquo; require &lt;strong>TraceQL&lt;/strong> + Grafana and are not as fast as Jaeger with Elasticsearch. For immediate ad-hoc diagnosis it is worth keeping a parallel Jaeger with aggressive sampling. Licences &lt;strong>AGPL 3.0&lt;/strong> (Tempo) and &lt;strong>Apache 2.0&lt;/strong> (Jaeger), maintained by &lt;strong>Grafana Labs&lt;/strong> and &lt;strong>CNCF&lt;/strong> respectively.&lt;/p>
&lt;h3 id="prometheus--grafana">Prometheus + Grafana&lt;/h3>
&lt;p>Prometheus is the time-series metrics foundation of the ecosystem. Pull model (it scrapes &lt;code>/metrics&lt;/code> endpoints), PromQL for queries, exporters for everything (Postgres, Kafka, NVIDIA GPU via &lt;code>dcgm-exporter&lt;/code>, vLLM natively). Grafana visualises Prometheus + Tempo + Loki on a single plane. For LLMOps, the critical metrics are &lt;code>gpu_utilization&lt;/code>, &lt;code>kv_cache_usage_pct&lt;/code>, &lt;code>tokens_per_second&lt;/code>, &lt;code>prefill_latency_p95&lt;/code>, &lt;code>decode_latency_p95&lt;/code>, &lt;code>queue_depth&lt;/code>, aggregated by tenant. &lt;strong>Gotcha:&lt;/strong> Prometheus is very good up to about 1M active series; above that it is worth moving to &lt;strong>Thanos&lt;/strong> or &lt;strong>Mimir&lt;/strong> for long retention and horizontal scalability. For the blog&amp;rsquo;s typical LLM cluster (4-8 H100), Prometheus alone is enough. Licences &lt;strong>Apache 2.0&lt;/strong> (Prometheus, &lt;strong>CNCF&lt;/strong>) and &lt;strong>AGPL 3.0&lt;/strong> (Grafana 10+, &lt;strong>Grafana Labs&lt;/strong>).&lt;/p>
&lt;h3 id="langfuse">Langfuse&lt;/h3>
&lt;p>Langfuse is the LLM-specific OSS observability + prompt management tool. It captures spans with LLM semantic conventions (input, output, model, tokens, latency, score, user_id, session_id), visualises them as &lt;strong>conversational traces&lt;/strong> (not just span trees), manages &lt;strong>prompts versioned with a &lt;code>production&lt;/code> label&lt;/strong> and allows &lt;strong>curated datasets + evals&lt;/strong> from the same UI. For serious LLMOps, Langfuse fills the gap that neither Tempo nor Jaeger covers: a tracing UI designed to be LLM-first. &lt;strong>Gotcha:&lt;/strong> Langfuse maintains its own store (Postgres + ClickHouse for high volume); on large clusters the operational side of ClickHouse deserves attention. To get started, Postgres alone copes. Licence &lt;strong>MIT&lt;/strong> for the OSS core, &lt;strong>EE Enterprise Edition&lt;/strong> with additional features (SSO, audit logs, advanced RBAC). Maintained by &lt;strong>Langfuse GmbH&lt;/strong> (Berlin, German). There is Langfuse Cloud (SaaS).&lt;/p>
&lt;h3 id="phoenix-arize-oss">Phoenix Arize OSS&lt;/h3>
&lt;p>Phoenix is Arize AI&amp;rsquo;s OSS for LLM observability + evals, an alternative to Langfuse with a different emphasis: more oriented to evaluation and visual debugging (embedding drift, cluster analysis), less to prompt management. A good pairing with Langfuse when you want a dual approach: Langfuse for &amp;ldquo;production conversational traces&amp;rdquo;, Phoenix for &amp;ldquo;exploratory investigation of model behaviour&amp;rdquo;. &lt;strong>Gotcha:&lt;/strong> Phoenix duplicates functionality with Langfuse and with MLflow; having all three in production multiplies the operational burden. Pick one as the main tool and the others as complements. Licence &lt;strong>Elastic License 2.0&lt;/strong> (not strictly OSI), maintained by &lt;strong>Arize AI&lt;/strong>.&lt;/p>
&lt;h3 id="cilium-tetragon--hubble">Cilium Tetragon + Hubble&lt;/h3>
&lt;p>Tetragon (eBPF runtime security observer) and Hubble (eBPF network observer) are the low-level pieces that give real runtime visibility into the cluster: which processes run in which pods, what syscalls they make, what network connections they open, in real time. For ENS/NIS2 environments that demand &amp;ldquo;prove what ran in production&amp;rdquo;, Tetragon is the irrefutable audit layer: every process execution with its parent, its capabilities, its K8s context. Hubble visualises network flows by pod, namespace, service. &lt;strong>Gotcha:&lt;/strong> the volume of events generated is high; without kernel-side filtering (which Tetragon supports with &lt;code>TracingPolicy&lt;/code>), it saturates the observability plane fast. Discipline in policies. Licence &lt;strong>Apache 2.0&lt;/strong> for both, maintained by &lt;strong>Cilium / CNCF / Isovalent&lt;/strong>.&lt;/p>
&lt;h3 id="evidently-ai">Evidently AI&lt;/h3>
&lt;p>Evidently is the OSS library for &lt;strong>drift detection&lt;/strong>: it compares input and output distributions between two time windows (training vs production, current week vs previous week), applies statistical tests (KS, PSI, Wasserstein, chi-square) and generates HTML reports. For LLMOps it detects when the distribution of prompts changes (new topics, new lengths, new languages) or when the model starts answering shorter / longer / differently. &lt;strong>Gotcha:&lt;/strong> Evidently is oriented to tabular data and embeddings; for raw text it is best combined with an encoder embedder that produces vectors before applying the tests. Licence &lt;strong>Apache 2.0&lt;/strong>, maintained by &lt;strong>Evidently AI&lt;/strong> (a company). Alternatives: &lt;strong>NannyML&lt;/strong> (Apache 2.0, &lt;strong>NannyML BV&lt;/strong>), &lt;strong>Alibi Detect&lt;/strong> (Apache 2.0, &lt;strong>Seldon&lt;/strong>).&lt;/p>
&lt;p>&lt;strong>More options for Observe:&lt;/strong>&lt;/p>
&lt;ul>
&lt;li>&lt;strong>Loki&lt;/strong> — Prometheus-style logs backend for Grafana. AGPL 3.0, Grafana Labs.&lt;/li>
&lt;li>&lt;strong>Pixie&lt;/strong> — auto-instrumented eBPF observability. Apache 2.0, CNCF.&lt;/li>
&lt;/ul>
&lt;p>&lt;strong>Decision matrix — Observe:&lt;/strong>&lt;/p>
&lt;table>
&lt;thead>
&lt;tr>
&lt;th>If your case is&lt;/th>
&lt;th>Choose&lt;/th>
&lt;/tr>
&lt;/thead>
&lt;tbody>
&lt;tr>
&lt;td>Minimum viable stack&lt;/td>
&lt;td>&lt;strong>OTel Collector + Tempo + Prometheus + Grafana + Langfuse&lt;/strong>&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>Traces with strong ad-hoc search&lt;/td>
&lt;td>Add &lt;strong>Jaeger&lt;/strong> with aggressive sampling&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>ENS / NIS2 compliance runtime audit&lt;/td>
&lt;td>&lt;strong>Tetragon + Hubble&lt;/strong> + mandated retention&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>Exploratory investigation of the model&lt;/td>
&lt;td>&lt;strong>Phoenix Arize OSS&lt;/strong> alongside Langfuse&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>Statistical drift detection&lt;/td>
&lt;td>&lt;strong>Evidently&lt;/strong> over embeddings + inputs&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>Cluster beyond 1M Prometheus series&lt;/td>
&lt;td>&lt;strong>Mimir&lt;/strong> (Grafana Labs) or &lt;strong>Thanos&lt;/strong>&lt;/td>
&lt;/tr>
&lt;/tbody>
&lt;/table>
&lt;h2 id="stage-6--retrain--cross-cutting-pieces">Stage 6 — Retrain + cross-cutting pieces&lt;/h2>
&lt;p>Retrain closes the loop feedback → triage → enriched dataset → new adapter. Prompt versioning and data versioning stitch lineage across stages. Detailed in &lt;a href="https://blog.lo0.es/en/posts/retrain-closing-the-loop-feedback-dataset-adapter/">retrain&lt;/a>, &lt;a href="https://blog.lo0.es/en/posts/prompt-versioning-contract-five-word-change/">prompt versioning&lt;/a> and &lt;a href="https://blog.lo0.es/en/posts/data-versioning-llmops-dvc-lakefs-reproducible-golden-dataset/">data versioning&lt;/a>.&lt;/p>
&lt;h3 id="apache-airflow">Apache Airflow&lt;/h3>
&lt;p>Airflow is the most established OSS DAG scheduler. You define workflows as Python code (DAGs), each DAG with tasks (operators) that run according to declared dependencies plus a cron schedule. For retraining: a weekly DAG that extracts feedback from Postgres, triages it with an LLM-as-classifier, enriches the enriched dataset in DVC, launches the fine-tuning job on Kubernetes, runs evals against the golden set, and promotes if it passes the gates. There is a huge ecosystem of operators for everything (S3, Postgres, Kafka, Slack, K8s, Spark…). &lt;strong>Gotcha:&lt;/strong> Airflow 2.x improved a great deal over the chaos of 1.x, but the scheduler is still a component that deserves operational attention (Postgres backend, executor pool, sidecar workers); for simple pipelines it is over-engineering. Licence &lt;strong>Apache 2.0&lt;/strong>, maintained by the &lt;strong>ASF&lt;/strong>.&lt;/p>
&lt;h3 id="argo-workflows">Argo Workflows&lt;/h3>
&lt;p>Argo Workflows is the K8s-native equivalent of Airflow: each step is a container, the DAGs are defined as K8s YAML, the executor is Kubernetes itself. For environments where &lt;strong>everything is K8s&lt;/strong>, Argo fits without an extra component to maintain. Long tasks (a six-hour fine-tuning run) execute as Pods that survive control plane failures. It integrates trivially with Kubeflow Pipelines (which is built on top). &lt;strong>Gotcha:&lt;/strong> Argo&amp;rsquo;s YAML syntax is verbose; for complex DAGs, Argo feels less productive than Airflow in Python. Solutions: &lt;strong>Hera&lt;/strong> (Python DSL for Argo, a &lt;strong>DataBricks contribution&lt;/strong>) or &lt;strong>Argo + custom CRDs&lt;/strong>. Licence &lt;strong>Apache 2.0&lt;/strong>, maintained by &lt;strong>CNCF&lt;/strong>.&lt;/p>
&lt;h3 id="kubeflow-pipelines">Kubeflow Pipelines&lt;/h3>
&lt;p>Kubeflow Pipelines is the layer above Argo Workflows oriented specifically to ML: artefact tracking, experiment tracking, reusable pipeline templates, versioned components. Built on Argo, it adds the ML conceptual model (input artefact, output artefact, metrics) that raw Argo does not have. For cyclical retraining on a K8s cluster, it is the most &amp;ldquo;ML-ready&amp;rdquo; option in the OSS ecosystem. &lt;strong>Gotcha:&lt;/strong> Kubeflow as a full suite is heavy (10+ components); many organisations install only Pipelines + Training Operator + Katib and skip Notebook Server / legacy KFServing. Licence &lt;strong>Apache 2.0&lt;/strong>, maintained by &lt;strong>CNCF / LF AI &amp;amp; Data&lt;/strong>.&lt;/p>
&lt;h3 id="feast">Feast&lt;/h3>
&lt;p>Feast is the most widely used OSS feature store. It defines &lt;strong>feature views&lt;/strong> over batch sources (BigQuery, Postgres, Parquet) and online sources (Redis, DynamoDB, Postgres with an extension), exposes a consistent API for read-during-training and read-during-inference (point-in-time correctness), and guarantees that the features of the model in production are the same ones it was trained with. For LLMOps where the model needs consistent user / session / context features (latest plan, tenure as a customer, recent tickets), Feast provides the discipline. &lt;strong>Gotcha:&lt;/strong> for many pure LLM systems (a RAG chatbot with no complex features), Feast is over-engineering, Postgres is enough. When there are real features (recommendation, scoring, ranking), Feast shines. Licence &lt;strong>Apache 2.0&lt;/strong>, maintained by &lt;strong>LF AI &amp;amp; Data&lt;/strong>.&lt;/p>
&lt;h3 id="argilla">Argilla&lt;/h3>
&lt;p>Argilla is the OSS annotation + HiL (human-in-the-loop) platform most aligned with modern LLMOps. It creates annotation projects with templates (classification, ranking, span annotation, RLHF preference, free-form text), connects to HuggingFace datasets, and integrates with Langfuse to import traces from production as cases to annotate. It supports multiple annotators with reconciliation, kappa scoring and quality control. To enrich retrain datasets with cases from the &amp;ldquo;blunt tone&amp;rdquo; cluster of the &lt;a href="https://blog.lo0.es/en/posts/retrain-closing-the-loop-feedback-dataset-adapter/">Retrain post&lt;/a>, Argilla is the front end. &lt;strong>Gotcha:&lt;/strong> Argilla requires Elasticsearch for production performance; for small experiments SQLite is enough. Licence &lt;strong>Apache 2.0&lt;/strong>, maintained by &lt;strong>Argilla, Inc.&lt;/strong> (acquired by &lt;strong>Hugging Face&lt;/strong> in 2024). Alternative: &lt;strong>Label Studio&lt;/strong> (Apache 2.0, &lt;strong>HumanSignal&lt;/strong>), more generalist, less LLM-first.&lt;/p>
&lt;h3 id="langfuse-prompts--mlflow-prompt-registry">Langfuse Prompts + MLflow Prompt Registry&lt;/h3>
&lt;p>Langfuse Prompts manages prompts as versioned entities with labels (production, staging, experiment). The client reads the active prompt from Langfuse on the request path (with a local cache of a few seconds) and propagates &lt;code>prompt_id, prompt_version&lt;/code> to the OTel span, exactly as the &lt;a href="https://blog.lo0.es/en/posts/anatomy-llm-request-production-tour-six-stages/">forensic post&lt;/a> does. MLflow Prompt Registry does the same with a slightly different conceptual model (no labels-as-pointers; it uses stages, like the Models registry). Both are valid; the choice depends on which tracking tool is already in place. &lt;strong>Gotcha (Langfuse):&lt;/strong> labels are mutable, so changing &lt;code>production&lt;/code> points at another version with no explicit audit; prompts are better deployed via PR against the configs repo, not manually in the UI. Licences and governance covered above.&lt;/p>
&lt;p>&lt;strong>More options for Retrain + cross-cutting pieces:&lt;/strong>&lt;/p>
&lt;ul>
&lt;li>&lt;strong>Prefect&lt;/strong> — &amp;ldquo;modern&amp;rdquo; Python DAGs, an alternative to Airflow. Apache 2.0, &lt;strong>Prefect Tech&lt;/strong>.&lt;/li>
&lt;li>&lt;strong>Dagster&lt;/strong> — DAGs with a strong focus on data assets. Apache 2.0, &lt;strong>Dagster Labs&lt;/strong>.&lt;/li>
&lt;li>&lt;strong>Label Studio&lt;/strong> — generalist annotation. Apache 2.0, &lt;strong>HumanSignal&lt;/strong>.&lt;/li>
&lt;li>&lt;strong>OpenLineage&lt;/strong> — cross-system lineage event standard. Apache 2.0, &lt;strong>LF AI &amp;amp; Data&lt;/strong>.&lt;/li>
&lt;li>&lt;strong>DataHub / Apache Atlas / OpenMetadata&lt;/strong> — catalogue + lineage with a UI. Apache 2.0.&lt;/li>
&lt;/ul>
&lt;p>&lt;strong>Decision matrix — Retrain + cross-cutting pieces:&lt;/strong>&lt;/p>
&lt;table>
&lt;thead>
&lt;tr>
&lt;th>If your case is&lt;/th>
&lt;th>Choose&lt;/th>
&lt;/tr>
&lt;/thead>
&lt;tbody>
&lt;tr>
&lt;td>Simple pipelines with a catalogue of operators&lt;/td>
&lt;td>&lt;strong>Airflow&lt;/strong>&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>Everything is K8s, minimal components&lt;/td>
&lt;td>&lt;strong>Argo Workflows&lt;/strong>&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>ML pipelines with artefact tracking&lt;/td>
&lt;td>&lt;strong>Kubeflow Pipelines&lt;/strong>&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>HiL annotation for LLM retraining&lt;/td>
&lt;td>&lt;strong>Argilla&lt;/strong> + Langfuse integration&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>Features shared between training and inference&lt;/td>
&lt;td>&lt;strong>Feast&lt;/strong>&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>No complex features, only prompts + LLM&lt;/td>
&lt;td>Skip Feast&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>Lightweight prompt registry&lt;/td>
&lt;td>&lt;strong>Langfuse Prompts&lt;/strong>&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>MLflow is already centralised&lt;/td>
&lt;td>&lt;strong>MLflow Prompt Registry&lt;/strong>&lt;/td>
&lt;/tr>
&lt;/tbody>
&lt;/table>
&lt;h2 id="master-table-licence-governance-and-enterprise-offering">Master table: licence, governance and enterprise offering&lt;/h2>
&lt;table>
&lt;thead>
&lt;tr>
&lt;th>Tool&lt;/th>
&lt;th>Licence&lt;/th>
&lt;th>Governance / maintainer&lt;/th>
&lt;th>Commercial EE / SaaS&lt;/th>
&lt;/tr>
&lt;/thead>
&lt;tbody>
&lt;tr>
&lt;td>&lt;strong>DVC&lt;/strong>&lt;/td>
&lt;td>Apache 2.0&lt;/td>
&lt;td>Iterative.ai&lt;/td>
&lt;td>DVC Studio&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>&lt;strong>lakeFS&lt;/strong>&lt;/td>
&lt;td>Apache 2.0&lt;/td>
&lt;td>Treeverse&lt;/td>
&lt;td>lakeFS Cloud&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>&lt;strong>MinIO&lt;/strong>&lt;/td>
&lt;td>AGPL v3&lt;/td>
&lt;td>MinIO Inc.&lt;/td>
&lt;td>SUBNET / AIStor&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>&lt;strong>Qdrant&lt;/strong>&lt;/td>
&lt;td>Apache 2.0&lt;/td>
&lt;td>Qdrant GmbH&lt;/td>
&lt;td>Qdrant Cloud&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>&lt;strong>pgvector&lt;/strong>&lt;/td>
&lt;td>PostgreSQL License&lt;/td>
&lt;td>Andrew Kane + community&lt;/td>
&lt;td>— (built-in Postgres clouds)&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>&lt;strong>PostgreSQL&lt;/strong>&lt;/td>
&lt;td>PostgreSQL License&lt;/td>
&lt;td>PostgreSQL Global Dev Group&lt;/td>
&lt;td>several managed (Crunchy, Neon, Aiven, EDB)&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>&lt;strong>Apache Kafka&lt;/strong>&lt;/td>
&lt;td>Apache 2.0&lt;/td>
&lt;td>ASF&lt;/td>
&lt;td>Confluent Cloud&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>&lt;strong>Debezium&lt;/strong>&lt;/td>
&lt;td>Apache 2.0&lt;/td>
&lt;td>Red Hat / ASF&lt;/td>
&lt;td>Debezium Server / Confluent Connectors&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>&lt;strong>Apache Flink&lt;/strong>&lt;/td>
&lt;td>Apache 2.0&lt;/td>
&lt;td>ASF&lt;/td>
&lt;td>Ververica Platform, Aiven&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>&lt;strong>HF Transformers&lt;/strong>&lt;/td>
&lt;td>Apache 2.0&lt;/td>
&lt;td>Hugging Face SAS&lt;/td>
&lt;td>HF Inference Endpoints / Enterprise Hub&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>&lt;strong>PEFT&lt;/strong>&lt;/td>
&lt;td>Apache 2.0&lt;/td>
&lt;td>Hugging Face SAS&lt;/td>
&lt;td>— (part of the HF offering)&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>&lt;strong>bitsandbytes&lt;/strong>&lt;/td>
&lt;td>MIT&lt;/td>
&lt;td>Tim Dettmers + community&lt;/td>
&lt;td>—&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>&lt;strong>MLflow&lt;/strong>&lt;/td>
&lt;td>Apache 2.0&lt;/td>
&lt;td>LF AI &amp;amp; Data&lt;/td>
&lt;td>Databricks MLflow&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>&lt;strong>Axolotl&lt;/strong>&lt;/td>
&lt;td>Apache 2.0&lt;/td>
&lt;td>OpenAccess AI Collective&lt;/td>
&lt;td>—&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>&lt;strong>Ray (Train)&lt;/strong>&lt;/td>
&lt;td>Apache 2.0&lt;/td>
&lt;td>Anyscale + community&lt;/td>
&lt;td>Anyscale Platform&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>&lt;strong>DeepSpeed&lt;/strong>&lt;/td>
&lt;td>MIT&lt;/td>
&lt;td>Microsoft&lt;/td>
&lt;td>—&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>&lt;strong>DeepEval&lt;/strong>&lt;/td>
&lt;td>Apache 2.0&lt;/td>
&lt;td>Confident AI&lt;/td>
&lt;td>Confident AI SaaS&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>&lt;strong>RAGAS&lt;/strong>&lt;/td>
&lt;td>Apache 2.0&lt;/td>
&lt;td>Exploding Gradients&lt;/td>
&lt;td>—&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>&lt;strong>Promptfoo&lt;/strong>&lt;/td>
&lt;td>MIT&lt;/td>
&lt;td>Promptfoo, Inc.&lt;/td>
&lt;td>Promptfoo Cloud&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>&lt;strong>NeMo Guardrails&lt;/strong>&lt;/td>
&lt;td>Apache 2.0&lt;/td>
&lt;td>NVIDIA&lt;/td>
&lt;td>NeMo Microservices&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>&lt;strong>Presidio&lt;/strong>&lt;/td>
&lt;td>MIT&lt;/td>
&lt;td>Microsoft&lt;/td>
&lt;td>—&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>&lt;strong>Phoenix (Arize)&lt;/strong>&lt;/td>
&lt;td>Elastic v2&lt;/td>
&lt;td>Arize AI&lt;/td>
&lt;td>Arize Platform&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>&lt;strong>vLLM&lt;/strong>&lt;/td>
&lt;td>Apache 2.0&lt;/td>
&lt;td>vLLM Project / LF AI &amp;amp; Data&lt;/td>
&lt;td>several (Red Hat, AWS, IBM, NVIDIA)&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>&lt;strong>TGI&lt;/strong>&lt;/td>
&lt;td>Apache 2.0&lt;/td>
&lt;td>Hugging Face SAS&lt;/td>
&lt;td>HF Inference Endpoints&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>&lt;strong>SGLang&lt;/strong>&lt;/td>
&lt;td>Apache 2.0&lt;/td>
&lt;td>LMSys + community&lt;/td>
&lt;td>—&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>&lt;strong>TensorRT-LLM&lt;/strong>&lt;/td>
&lt;td>Apache 2.0&lt;/td>
&lt;td>NVIDIA&lt;/td>
&lt;td>NVIDIA AI Enterprise&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>&lt;strong>llama.cpp&lt;/strong>&lt;/td>
&lt;td>MIT&lt;/td>
&lt;td>Georgi Gerganov + community&lt;/td>
&lt;td>—&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>&lt;strong>Triton Inference Server&lt;/strong>&lt;/td>
&lt;td>BSD-3&lt;/td>
&lt;td>NVIDIA&lt;/td>
&lt;td>NVIDIA AI Enterprise&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>&lt;strong>KServe&lt;/strong>&lt;/td>
&lt;td>Apache 2.0&lt;/td>
&lt;td>LF AI &amp;amp; Data (Kubeflow)&lt;/td>
&lt;td>—&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>&lt;strong>Envoy AI Gateway&lt;/strong>&lt;/td>
&lt;td>Apache 2.0&lt;/td>
&lt;td>CNCF / Tetrate&lt;/td>
&lt;td>Tetrate Service Bridge&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>&lt;strong>LiteLLM&lt;/strong>&lt;/td>
&lt;td>MIT&lt;/td>
&lt;td>BerriAI&lt;/td>
&lt;td>LiteLLM Cloud&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>&lt;strong>OpenTelemetry&lt;/strong>&lt;/td>
&lt;td>Apache 2.0&lt;/td>
&lt;td>CNCF&lt;/td>
&lt;td>several vendors (Honeycomb, Datadog, Grafana)&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>&lt;strong>Tempo&lt;/strong>&lt;/td>
&lt;td>AGPL 3.0&lt;/td>
&lt;td>Grafana Labs&lt;/td>
&lt;td>Grafana Cloud Tempo&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>&lt;strong>Jaeger&lt;/strong>&lt;/td>
&lt;td>Apache 2.0&lt;/td>
&lt;td>CNCF&lt;/td>
&lt;td>—&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>&lt;strong>Prometheus&lt;/strong>&lt;/td>
&lt;td>Apache 2.0&lt;/td>
&lt;td>CNCF&lt;/td>
&lt;td>Grafana Cloud, AMP, GCP Managed Prom, Azure&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>&lt;strong>Grafana&lt;/strong>&lt;/td>
&lt;td>AGPL 3.0&lt;/td>
&lt;td>Grafana Labs&lt;/td>
&lt;td>Grafana Cloud, Grafana Enterprise&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>&lt;strong>Loki&lt;/strong>&lt;/td>
&lt;td>AGPL 3.0&lt;/td>
&lt;td>Grafana Labs&lt;/td>
&lt;td>Grafana Cloud Loki&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>&lt;strong>Langfuse&lt;/strong>&lt;/td>
&lt;td>MIT (core) / EE&lt;/td>
&lt;td>Langfuse GmbH&lt;/td>
&lt;td>Langfuse Cloud&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>&lt;strong>Tetragon&lt;/strong>&lt;/td>
&lt;td>Apache 2.0&lt;/td>
&lt;td>Cilium / CNCF / Isovalent&lt;/td>
&lt;td>Isovalent Enterprise&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>&lt;strong>Hubble&lt;/strong>&lt;/td>
&lt;td>Apache 2.0&lt;/td>
&lt;td>Cilium / CNCF&lt;/td>
&lt;td>Isovalent Enterprise&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>&lt;strong>Evidently AI&lt;/strong>&lt;/td>
&lt;td>Apache 2.0&lt;/td>
&lt;td>Evidently AI&lt;/td>
&lt;td>Evidently Cloud&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>&lt;strong>Apache Airflow&lt;/strong>&lt;/td>
&lt;td>Apache 2.0&lt;/td>
&lt;td>ASF&lt;/td>
&lt;td>Astronomer, MWAA, Cloud Composer&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>&lt;strong>Argo Workflows&lt;/strong>&lt;/td>
&lt;td>Apache 2.0&lt;/td>
&lt;td>CNCF&lt;/td>
&lt;td>—&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>&lt;strong>Kubeflow Pipelines&lt;/strong>&lt;/td>
&lt;td>Apache 2.0&lt;/td>
&lt;td>CNCF / LF AI &amp;amp; Data&lt;/td>
&lt;td>—&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>&lt;strong>Feast&lt;/strong>&lt;/td>
&lt;td>Apache 2.0&lt;/td>
&lt;td>LF AI &amp;amp; Data&lt;/td>
&lt;td>Tecton (commercial)&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>&lt;strong>Argilla&lt;/strong>&lt;/td>
&lt;td>Apache 2.0&lt;/td>
&lt;td>Hugging Face&lt;/td>
&lt;td>HF Hub features&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>&lt;strong>OpenLineage&lt;/strong>&lt;/td>
&lt;td>Apache 2.0&lt;/td>
&lt;td>LF AI &amp;amp; Data&lt;/td>
&lt;td>—&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>&lt;strong>DataHub&lt;/strong>&lt;/td>
&lt;td>Apache 2.0&lt;/td>
&lt;td>Acryl Data&lt;/td>
&lt;td>Acryl Cloud&lt;/td>
&lt;/tr>
&lt;/tbody>
&lt;/table>
&lt;p>&lt;strong>The pattern to watch for when reading the table:&lt;/strong> &lt;strong>AGPL 3.0&lt;/strong> and &lt;strong>Elastic v2&lt;/strong> are the ones that add the most friction in companies with strict licensing policies (legal asks for a specific review). The &lt;strong>Apache 2.0&lt;/strong> ones pass compliance without argument. Those with an &amp;ldquo;EE Enterprise&amp;rdquo; tier or equivalent hide a decision: the OSS version is functionally complete for production, but team features (SSO, audit, advanced RBAC) live in the commercial version. For ENS customers under a HIGH declaration, the EE features (SSO with corporate SAML/OIDC, immutable audit logs) are usually mandatory, so it is worth knowing the price beforehand.&lt;/p>
&lt;h2 id="when-to-move-from-the-minimum-stack-to-the-full-stack">When to move from the &amp;ldquo;minimum stack&amp;rdquo; to the &amp;ldquo;full stack&amp;rdquo;&lt;/h2>
&lt;p>The whole catalogue can be intimidating. But you do not build all of it on day one. There is a reasonable order that this blog has been validating in posts throughout the series. The &lt;strong>minimum viable stack&lt;/strong> that serves an LLM API with acceptable discipline:&lt;/p>
&lt;ul>
&lt;li>&lt;strong>Serving&lt;/strong>: vLLM on Kubernetes plus an Envoy AI Gateway in front.&lt;/li>
&lt;li>&lt;strong>Data&lt;/strong>: Postgres + pgvector (no Qdrant), MinIO for object store, no Kafka.&lt;/li>
&lt;li>&lt;strong>Tune&lt;/strong>: Axolotl + MLflow, no Ray Train.&lt;/li>
&lt;li>&lt;strong>Eval&lt;/strong>: Promptfoo in CI, no RAGAS and no judge in production.&lt;/li>
&lt;li>&lt;strong>Observe&lt;/strong>: OTel Collector + Prometheus + Grafana + Langfuse, no Phoenix and no Tetragon.&lt;/li>
&lt;li>&lt;strong>Retrain&lt;/strong>: feedback in Postgres + crontab scripts, no Airflow.&lt;/li>
&lt;li>&lt;strong>Versioning&lt;/strong>: prompts in Langfuse + datasets in DVC over MinIO, no lakeFS.&lt;/li>
&lt;/ul>
&lt;p>That is &lt;strong>about 8-10 components&lt;/strong> and it serves a reasonable LLM system for a single tenant with moderate traffic. As the system grows, there are identifiable moments where adding each piece pays off:&lt;/p>
&lt;table>
&lt;thead>
&lt;tr>
&lt;th>Trigger&lt;/th>
&lt;th>Component to add&lt;/th>
&lt;/tr>
&lt;/thead>
&lt;tbody>
&lt;tr>
&lt;td>Multi-tenant with isolated corpora&lt;/td>
&lt;td>&lt;strong>Qdrant&lt;/strong> (collections per tenant, ACL)&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>The corpus is refreshed often and periodically breaks&lt;/td>
&lt;td>&lt;strong>lakeFS&lt;/strong> (branches with pre-merge hooks)&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>The embedding pipeline needs streaming&lt;/td>
&lt;td>&lt;strong>Kafka + Debezium + Flink&lt;/strong>&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>Retraining moves from monthly to weekly&lt;/td>
&lt;td>&lt;strong>Airflow&lt;/strong> or &lt;strong>Argo Workflows&lt;/strong>&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>Shared features appear (customer profile, scoring)&lt;/td>
&lt;td>&lt;strong>Feast&lt;/strong>&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>Annotation outgrows informal capacity&lt;/td>
&lt;td>&lt;strong>Argilla&lt;/strong>&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>RAG eval needs specific metrics&lt;/td>
&lt;td>&lt;strong>RAGAS&lt;/strong> + Langfuse datasets&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>ENS compliance demands runtime audit&lt;/td>
&lt;td>&lt;strong>Tetragon + Hubble&lt;/strong>&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>Drift is invisible and shows up late&lt;/td>
&lt;td>&lt;strong>Evidently&lt;/strong>&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>A single stack stops covering multi-model&lt;/td>
&lt;td>&lt;strong>Triton&lt;/strong> or &lt;strong>KServe&lt;/strong> with several predictors&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>Multiple simultaneous multi-tenant adapters&lt;/td>
&lt;td>&lt;strong>vLLM Production Stack&lt;/strong> + a dedicated Operator&lt;/td>
&lt;/tr>
&lt;/tbody>
&lt;/table>
&lt;p>Each jump adds 1-2 components and is worth the cost only when the trigger is clear. Adding Kafka &amp;ldquo;just in case&amp;rdquo; when the corpus is updated once a month is net negative work.&lt;/p>
&lt;h2 id="what-we-have-not-covered-yet">What we have not covered (yet)&lt;/h2>
&lt;p>Some pieces still deserve a post of their own:&lt;/p>
&lt;ul>
&lt;li>&lt;strong>Schema Registry&lt;/strong> for LLM data and prompts (Confluent OSS, Karapace, JSON Schema Registry).&lt;/li>
&lt;li>&lt;strong>Catalogue + lineage&lt;/strong> in depth: DataHub vs Atlas vs OpenMetadata + OpenLineage taken seriously.&lt;/li>
&lt;li>&lt;strong>Federated learning&lt;/strong> over OSS (Flower, FedML) for scenarios where the data is not centralised.&lt;/li>
&lt;li>&lt;strong>OSS MCP Servers&lt;/strong> and their place in the stack as a tools / actions layer.&lt;/li>
&lt;li>&lt;strong>&amp;ldquo;Agentic&amp;rdquo; evals&lt;/strong> specific to multi-step systems with tool use.&lt;/li>
&lt;li>&lt;strong>Upgrade best practices&lt;/strong> for each component (vLLM every 6 weeks, Kafka major every 18 months, etc.).&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/anatomy-llm-request-production-tour-six-stages/">Anatomy of an LLM request in production&lt;/a> — the forensic piece that follows one request through the six stages; this catalogue is the list of tools that appeared along that route.&lt;/p>
&lt;/li>
&lt;li>
&lt;p>&lt;a href="https://blog.lo0.es/en/posts/parallel-catalogue-llmops-stages-open-source-hyperscalers/">The parallel catalogue: OSS vs hyperscalers&lt;/a> — the horizontal cut that shows, for each stage, what each OSS tool does and what its equivalent is on AWS, GCP and Azure.&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 of the pipeline to which this catalogue puts concrete OSS names.&lt;/p>
&lt;/li>
&lt;li>
&lt;p>&lt;a href="https://blog.lo0.es/en/posts/mlops-for-llms-landscape-2026/">MLOps specific to LLMs in 2026&lt;/a> — general context on LLMOps.&lt;/p>
&lt;/li>
&lt;li>
&lt;p>&lt;a href="https://blog.lo0.es/en/posts/data-versioning-llmops-dvc-lakefs-reproducible-golden-dataset/">Data versioning with DVC and lakeFS&lt;/a> — the deep-dive on the two OSS protagonists of the Data stage + cross-cutting piece.&lt;/p>
&lt;/li>
&lt;li>
&lt;p>&lt;a href="https://blog.lo0.es/en/posts/prompt-versioning-contract-five-word-change/">Prompt versioning with Langfuse and MLflow&lt;/a> — the deep-dive on the Prompt cross-cutting piece.&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 Tune stage in real operations.&lt;/p>
&lt;/li>
&lt;li>
&lt;p>&lt;a href="https://blog.lo0.es/en/posts/evals-llm-layer-after-tracing/">Evals: the layer after tracing&lt;/a> and &lt;a href="https://blog.lo0.es/en/posts/llm-guardrails-safety-four-lines-of-defence/">Guardrails and safety in LLMs&lt;/a> — the deep-dives on Eval + safety.&lt;/p>
&lt;/li>
&lt;li>
&lt;p>&lt;a href="https://blog.lo0.es/en/posts/llm-guard-anatomy-scanners-langfuse-vllm-litellm/">LLM Guard: the sworn translator with a notebook of equivalences&lt;/a> — a specific zoom on the Protect AI tool with its Anonymize + Vault + Deanonymize pattern, the 36 composable scanners and the four deployment modes (lib, FastAPI API, OTel sidecar, AI Gateway plugin).&lt;/p>
&lt;/li>
&lt;li>
&lt;p>&lt;a href="https://blog.lo0.es/en/posts/kv-cache-working-memory-llm-inference/">KV cache&lt;/a> · &lt;a href="https://blog.lo0.es/en/posts/pagedattention-vllm-block-manager/">PagedAttention&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-kubernetes-llm-inference-that-scales/">vLLM on K8s&lt;/a> · &lt;a href="https://blog.lo0.es/en/posts/llm-inference-operators-kubernetes/">LLM K8s operators&lt;/a> · &lt;a href="https://blog.lo0.es/en/posts/gpu-cluster-as-multi-tenant-platform/">Multi-tenant GPU cluster&lt;/a> — Deploy in all its layers.&lt;/p>
&lt;/li>
&lt;li>
&lt;p>&lt;a href="https://blog.lo0.es/en/posts/agentsight-llm-tracing-ebpf-zero-instrumentation/">AgentSight LLM tracing&lt;/a> · &lt;a href="https://blog.lo0.es/en/posts/mcp-deep-observability-opentelemetry/">MCP observability with OTel&lt;/a> · &lt;a href="https://blog.lo0.es/en/posts/ebpf-local-inference-statistical-drift-detection/">eBPF + drift&lt;/a> — Observe from its three angles.&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 loop&lt;/a> — the Retrain stage in detail.&lt;/p>
&lt;/li>
&lt;li>
&lt;p>&lt;a href="https://blog.lo0.es/en/posts/langfuse-inside-sorting-centre-bottleneck/">Langfuse from the inside: v3 architecture and the 10 backend knobs&lt;/a> — the operational deep-dive on the star observability tool of this catalogue: a six-service architecture, asynchronous ingestion and self-hosted tuning on the example cluster.&lt;/p>
&lt;/li>
&lt;li>
&lt;p>&lt;a href="https://blog.lo0.es/en/posts/isolating-ai-agents-workstation-to-cluster/">Isolating AI agents: overview&lt;/a> and &lt;a href="https://blog.lo0.es/en/posts/runbook-caging-ai-agent-bubblewrap-tetragon/">runbook&lt;/a> — the runtime security layer for agents that execute code: bubblewrap/ai-jail on the client and Tetragon (eBPF) on the cluster, with its Kata &lt;code>RuntimeClass&lt;/code>.&lt;/p>
&lt;/li>
&lt;li>
&lt;p>&lt;a href="https://blog.lo0.es/en/posts/kserve-open-inference-protocol-control-plane/">Model chain of trust (1/4): KServe and the Open Inference Protocol&lt;/a> — the deep-dive on the control plane of the Deploy stage: InferenceService, ServingRuntime and the API contract that this catalogue only enumerates.&lt;/p>
&lt;/li>
&lt;li>
&lt;p>&lt;a href="https://blog.lo0.es/en/posts/model-chain-of-trust-where-the-bytes-live-registry-oci-artefacts/">Model chain of trust (2/4): registry, OCI artefacts and distribution&lt;/a> — the missing piece between Tune and Deploy: where the weights live (OCI artefacts with ORAS, Harbor, MLflow) and how they reach the GPU node.&lt;/p>
&lt;/li>
&lt;/ul>
&lt;h2 id="references">References&lt;/h2>
&lt;ul>
&lt;li>&lt;a href="https://docs.vllm.ai/">vLLM&lt;/a> · &lt;a href="https://github.com/vllm-project/production-stack">vLLM Production Stack&lt;/a> · &lt;a href="https://huggingface.co/docs/text-generation-inference/">TGI&lt;/a> · &lt;a href="https://docs.sglang.ai/">SGLang&lt;/a> · &lt;a href="https://nvidia.github.io/TensorRT-LLM/">TensorRT-LLM&lt;/a> · &lt;a href="https://github.com/ggerganov/llama.cpp">llama.cpp&lt;/a> — OSS inference engines.&lt;/li>
&lt;li>&lt;a href="https://docs.nvidia.com/deeplearning/triton-inference-server/">Triton Inference Server&lt;/a> · &lt;a href="https://kserve.github.io/website/">KServe&lt;/a> · &lt;a href="https://aigateway.envoyproxy.io/">Envoy AI Gateway&lt;/a> · &lt;a href="https://docs.litellm.ai/">LiteLLM&lt;/a> — orchestration and AI gateway.&lt;/li>
&lt;li>&lt;a href="https://qdrant.tech/documentation/">Qdrant&lt;/a> · &lt;a href="https://github.com/pgvector/pgvector">pgvector&lt;/a> · &lt;a href="https://milvus.io/docs">Milvus&lt;/a> — vector databases.&lt;/li>
&lt;li>&lt;a href="https://dvc.org/doc">DVC&lt;/a> · &lt;a href="https://docs.lakefs.io/">lakeFS&lt;/a> · &lt;a href="https://min.io/docs/">MinIO&lt;/a> — versioning and object store.&lt;/li>
&lt;li>&lt;a href="https://kafka.apache.org/documentation/">Apache Kafka&lt;/a> · &lt;a href="https://debezium.io/documentation/">Debezium&lt;/a> · &lt;a href="https://flink.apache.org/">Apache Flink&lt;/a> — streams and CDC.&lt;/li>
&lt;li>&lt;a href="https://huggingface.co/docs/transformers">Hugging Face Transformers&lt;/a> · &lt;a href="https://huggingface.co/docs/peft">PEFT&lt;/a> · &lt;a href="https://huggingface.co/docs/bitsandbytes">bitsandbytes&lt;/a> · &lt;a href="https://docs.axolotl.ai/">Axolotl&lt;/a> — fine-tuning.&lt;/li>
&lt;li>&lt;a href="https://mlflow.org/docs/">MLflow&lt;/a> · &lt;a href="https://docs.ray.io/en/latest/train/">Ray Train&lt;/a> · &lt;a href="https://www.kubeflow.org/docs/components/trainer/">Kubeflow Training Operator&lt;/a> — training orchestration.&lt;/li>
&lt;li>&lt;a href="https://docs.confident-ai.com/">DeepEval&lt;/a> · &lt;a href="https://docs.ragas.io/">RAGAS&lt;/a> · &lt;a href="https://promptfoo.dev/docs/">Promptfoo&lt;/a> · &lt;a href="https://docs.nvidia.com/nemo/guardrails/">NeMo Guardrails&lt;/a> · &lt;a href="https://microsoft.github.io/presidio/">Presidio&lt;/a> — evals and guardrails.&lt;/li>
&lt;li>&lt;a href="https://opentelemetry.io/docs/">OpenTelemetry&lt;/a> · &lt;a href="https://opentelemetry.io/docs/specs/semconv/gen-ai/">OTel GenAI semconv&lt;/a> · &lt;a href="https://grafana.com/docs/tempo/">Tempo&lt;/a> · &lt;a href="https://prometheus.io/docs/">Prometheus&lt;/a> · &lt;a href="https://grafana.com/docs/">Grafana&lt;/a> · &lt;a href="https://grafana.com/docs/loki/">Loki&lt;/a> — observability foundation.&lt;/li>
&lt;li>&lt;a href="https://langfuse.com/docs">Langfuse&lt;/a> · &lt;a href="https://docs.arize.com/phoenix">Phoenix Arize&lt;/a> · &lt;a href="https://docs.evidentlyai.com/">Evidently AI&lt;/a> — LLM observability and drift.&lt;/li>
&lt;li>&lt;a href="https://docs.cilium.io/">Cilium&lt;/a> · &lt;a href="https://tetragon.io/">Tetragon&lt;/a> · &lt;a href="https://docs.cilium.io/en/stable/observability/hubble/">Hubble&lt;/a> — eBPF runtime.&lt;/li>
&lt;li>&lt;a href="https://airflow.apache.org/docs/">Apache Airflow&lt;/a> · &lt;a href="https://argo-workflows.readthedocs.io/">Argo Workflows&lt;/a> · &lt;a href="https://www.kubeflow.org/docs/components/pipelines/">Kubeflow Pipelines&lt;/a> · &lt;a href="https://docs.feast.dev/">Feast&lt;/a> · &lt;a href="https://docs.argilla.io/">Argilla&lt;/a> — orchestration + retrain + annotation.&lt;/li>
&lt;/ul></description></item><item><title>Disaggregated serving: prefill and decode in specialised pods</title><link>https://blog.lo0.es/en/posts/disaggregated-serving-prefill-decode-specialised-pods/</link><pubDate>Fri, 22 May 2026 01:00:00 +0200</pubDate><guid>https://blog.lo0.es/en/posts/disaggregated-serving-prefill-decode-specialised-pods/</guid><description>&lt;h2 id="tldr">TL;DR&lt;/h2>
&lt;p>LLM inference has two phases with opposite profiles: &lt;strong>prefill&lt;/strong> (processing the whole prompt in one go) is compute-bound, &lt;strong>decode&lt;/strong> (generating token by token) is memory-bandwidth-bound. Running them on the same GPU forces a choice between two incompatible optimal hardware profiles, and leaves between 60 % and 80 % of peak capacity unused. The industry consolidated the pattern in 2026: &lt;strong>disaggregated serving&lt;/strong>, separate pods for each phase, connected by a KV cache transfer channel (NIXL over UCX, RDMA, or NCCL as a fallback). DistServe demonstrated 7.4× more request rate at the same SLO; NVIDIA Dynamo 1.0 (GA at GTC 2026) takes the pattern to production at datacenter scale. Mixing heterogeneous hardware, H100 for prefill and commodity GPUs for decode, cuts up to 48 % off the cost per token. This article explains the why, the how, and the numbers that matter for a typical on-premise infrastructure.&lt;/p>
&lt;h2 id="you-are-here-deploy">You are here: Deploy&lt;/h2>
&lt;p>Disaggregated serving is an architectural decision in the &lt;strong>Deploy&lt;/strong> stage of the &lt;a href="https://blog.lo0.es/en/posts/six-stage-llmops-pipeline-architecture-and-deep-dive/">six-stage LLMOps pipeline&lt;/a>. It does not change the model, it does not change the data, it does not change the evals. It only changes &lt;strong>how inference pods are spread over the GPU hardware&lt;/strong>. But that change moves aggregate throughput between 2× and 7×.&lt;/p>
&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:#ff8a4c;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(#dsm)}.cyc{stroke:#888;stroke-width:1.2;fill:none;stroke-dasharray:4 2;marker-end:url(#dsm)}&lt;/style>
&lt;defs>&lt;marker id="dsm" 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 · prefill/decode pod topology and KV cache transfer&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="the-analogy-the-kitchen-with-two-brigades">The analogy: the kitchen with two brigades&lt;/h2>
&lt;p>A serious industrial kitchen, any kitchen serving more than 50 covers a night, runs with two distinct brigades and two physically separate spaces.&lt;/p>
&lt;p>The &lt;strong>prep brigade&lt;/strong> starts at dawn. Its job is the &lt;em>mise en place&lt;/em>: chopping, marinating, blanching, boiling stocks, preparing complex components. Equipment: good knives, big burners, convection ovens, 40-litre pots. It is capacity-intensive work and it is done in one batch. When it finishes, everything is left in labelled trays ready to use.&lt;/p>
&lt;p>The &lt;strong>service brigade&lt;/strong> comes in mid-afternoon. Its job is service: taking the trays from prep, heating portions, plating, running the pass. Equipment: salamanders, small griddles, thin spatulas, plenty of crockery. It is wrist work, rhythm work, not-failing-the-customer-who-has-the-plate-in-front-of-them work. Capacity per hour matters less than latency per plate.&lt;/p>
&lt;p>If you make &lt;strong>the same person&lt;/strong> do prep and service, both suffer. The cook is idle while doing mise en place in the middle of the afternoon. They have to stop and plate up when five orders land at once. Their working kit is designed for one or the other, not for both.&lt;/p>
&lt;p>Serious kitchens solved this decades ago: separate brigades, separate spaces, separate equipment. The only thing that crosses between them is the trays of mise en place.&lt;/p>
&lt;p>The &lt;strong>trays are the KV cache&lt;/strong>. The separation is &lt;strong>disaggregated serving&lt;/strong>. The handover from prep to service is the &lt;strong>KV cache transfer&lt;/strong>, solved today with NIXL over RDMA. And the specialised pods are the two brigades with their optimal kit.&lt;/p>
&lt;h2 id="quick-recap-prefill-and-decode">Quick recap: prefill and decode&lt;/h2>
&lt;p>A request to an LLM always goes through two phases:&lt;/p>
&lt;p>&lt;strong>Prefill.&lt;/strong> Take the complete prompt (say, 4,000 tokens) and process it in a single pass through all the layers of the model. The result is the KV cache of those 4,000 tokens (see the &lt;a href="https://blog.lo0.es/en/posts/kv-cache-working-memory-llm-inference/">previous article on KV cache&lt;/a> if you want to recall exactly what it holds). This step is massively parallel: all the tokens go through the attention matrices at once, which translates into huge, dense matrix multiplications. The GPU sits at 90-95 % compute utilisation. &lt;strong>TTFT&lt;/strong> (time to first token) is determined by this phase.&lt;/p>
&lt;p>&lt;strong>Decode.&lt;/strong> Once the KV cache is ready, the model generates tokens one by one. Each new token is a pass through all the layers with a single query vector, reading the whole accumulated KV cache to compute attention. There is no parallelism between tokens (each one depends on the previous). What limits you here is not compute but bandwidth: every step has to read the model&amp;rsquo;s full weights from HBM. The GPU sits at 20-40 % compute utilisation, but at 90 % HBM utilisation. &lt;strong>TBT&lt;/strong> (time between tokens) is determined by this phase.&lt;/p>
&lt;table>
&lt;thead>
&lt;tr>
&lt;th>Phase&lt;/th>
&lt;th>Characteristic&lt;/th>
&lt;th>Bottleneck&lt;/th>
&lt;th>Key metric&lt;/th>
&lt;/tr>
&lt;/thead>
&lt;tbody>
&lt;tr>
&lt;td>Prefill&lt;/td>
&lt;td>Massive parallel compute over N tokens at once&lt;/td>
&lt;td>TFLOPS (compute)&lt;/td>
&lt;td>TTFT&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>Decode&lt;/td>
&lt;td>Streaming weights from HBM, 1 token at a time&lt;/td>
&lt;td>HBM bandwidth&lt;/td>
&lt;td>TBT (inter-token latency)&lt;/td>
&lt;/tr>
&lt;/tbody>
&lt;/table>
&lt;div class="diagram" style="max-width: 720px; margin: 1.5rem auto;">
&lt;svg viewBox="0 0 720 290" xmlns="http://www.w3.org/2000/svg" role="img" aria-label="Compute vs bandwidth utilisation in prefill and decode">
&lt;style>
.bar { stroke: #333; stroke-width: 1; }
.b-compute { fill: #2a9d8f; }
.b-bandwidth { fill: #e76f51; }
.b-low { fill-opacity: 0.35; }
.ax { stroke: #333; stroke-width: 1.5; }
.grid { stroke: #ddd; stroke-width: 1; stroke-dasharray: 3,3; }
.lbl { font: 600 13px sans-serif; fill: #222; }
.lbl-sm { font: 11px sans-serif; fill: #444; }
.tag { font: 600 12px sans-serif; }
&lt;/style>
&lt;text x="360" y="22" text-anchor="middle" class="lbl">GPU utilisation during each phase (typical order of magnitude)&lt;/text>
&lt;line class="ax" x1="100" y1="240" x2="100" y2="60"/>
&lt;line class="ax" x1="100" y1="240" x2="680" y2="240"/>
&lt;line class="grid" x1="100" y1="78" x2="680" y2="78"/>
&lt;line class="grid" x1="100" y1="114" x2="680" y2="114"/>
&lt;line class="grid" x1="100" y1="150" x2="680" y2="150"/>
&lt;line class="grid" x1="100" y1="186" x2="680" y2="186"/>
&lt;text x="90" y="63" text-anchor="end" class="lbl-sm">100%&lt;/text>
&lt;text x="90" y="117" text-anchor="end" class="lbl-sm">75%&lt;/text>
&lt;text x="90" y="153" text-anchor="end" class="lbl-sm">50%&lt;/text>
&lt;text x="90" y="189" text-anchor="end" class="lbl-sm">25%&lt;/text>
&lt;text x="90" y="243" text-anchor="end" class="lbl-sm">0%&lt;/text>
&lt;text x="240" y="270" text-anchor="middle" class="lbl">PREFILL&lt;/text>
&lt;text x="240" y="284" text-anchor="middle" class="lbl-sm">compute-bound&lt;/text>
&lt;text x="540" y="270" text-anchor="middle" class="lbl">DECODE&lt;/text>
&lt;text x="540" y="284" text-anchor="middle" class="lbl-sm">memory-bound&lt;/text>
&lt;rect x="160" y="69" width="65" height="171" class="bar b-compute"/>
&lt;text x="193" y="62" text-anchor="middle" class="tag" fill="#2a9d8f">95%&lt;/text>
&lt;text x="193" y="255" text-anchor="middle" class="lbl-sm">compute&lt;/text>
&lt;rect x="245" y="132" width="65" height="108" class="bar b-bandwidth b-low"/>
&lt;text x="278" y="125" text-anchor="middle" class="tag" fill="#e76f51">60%&lt;/text>
&lt;text x="278" y="255" text-anchor="middle" class="lbl-sm">HBM&lt;/text>
&lt;rect x="460" y="177" width="65" height="63" class="bar b-compute b-low"/>
&lt;text x="493" y="170" text-anchor="middle" class="tag" fill="#2a9d8f">35%&lt;/text>
&lt;text x="493" y="255" text-anchor="middle" class="lbl-sm">compute&lt;/text>
&lt;rect x="545" y="78" width="65" height="162" class="bar b-bandwidth"/>
&lt;text x="578" y="71" text-anchor="middle" class="tag" fill="#e76f51">90%&lt;/text>
&lt;text x="578" y="255" text-anchor="middle" class="lbl-sm">HBM&lt;/text>
&lt;/svg>
&lt;/div>
&lt;p>The asymmetry is structural: prefill burns compute and leaves memory half idle, decode does the opposite. &lt;strong>A GPU designed to be excellent at both at once is a GPU designed to be badly used all the time.&lt;/strong>&lt;/p>
&lt;h2 id="why-putting-them-on-the-same-gpu-is-a-bad-deal">Why putting them on the same GPU is a bad deal&lt;/h2>
&lt;p>Until 2023, the universal assumption was to run prefill and decode &lt;strong>in the same inference process, on the same GPU&lt;/strong>. The engine scheduler (vLLM, TGI, Triton) decided on each cycle whether to prefill a new request or decode the ones already in flight. The intuition was that sharing hardware saves money.&lt;/p>
&lt;p>The intuition is wrong. The problem has three faces:&lt;/p>
&lt;p>&lt;strong>Latency interference.&lt;/strong> When the engine decides to prefill a new request, it &lt;strong>interrupts&lt;/strong> every decode in progress. That pushes up the TBT of the other requests. The user who was watching tokens flow smoothly down the screen notices a stall of several hundred milliseconds. This is known as &lt;em>prefill-decode interference&lt;/em> and it degrades the experience visibly as concurrency rises.&lt;/p>
&lt;p>&lt;strong>Sub-optimal hardware for each phase.&lt;/strong> An H100 SXM has 989 TFLOPS BF16 of compute and 3.35 TB/s of HBM3. It is excellent for prefill, where compute is the limit. For decode, where the only thing that matters is bandwidth, those 989 TFLOPS are 60-70 % wasted. Conversely, a GPU with less compute but similar relative bandwidth (RTX 4090, L40S) would handle decode just as well for a fraction of the price.&lt;/p>
&lt;p>&lt;strong>Low aggregate utilisation.&lt;/strong> In real workloads with Llama 3 70B and 512-token outputs, &lt;strong>around 80 % of the wall-clock is spent in decode&lt;/strong>. That means 80 % of your H100 cluster budget is doing memory reads, not calculations. It is like paying for a Ferrari to use it in a car park queue.&lt;/p>
&lt;h2 id="the-idea-specialised-pods-kv-cache-as-the-deliverable">The idea: specialised pods, KV cache as the deliverable&lt;/h2>
&lt;p>Disaggregated serving breaks the inference cycle into two distinct services:&lt;/p>
&lt;p>&lt;strong>Prefill pod.&lt;/strong> Receives the prompt, runs the prefill, produces the KV cache. Hardware: high-compute GPUs (H100, H200, B200). Optimised for aggressive batching and throughput, not for individual latency: if 32 prompts arrive within 100 ms, it processes them together.&lt;/p>
&lt;p>&lt;strong>Decode pod.&lt;/strong> Receives the already-built KV cache, runs the token-by-token generation, streams to the client. Hardware: GPUs with good bandwidth but ideally cheaper per TFLOPS (RTX 4090, L40S, A100, even A30 depending on the case). Optimised for per-token latency (low TBT).&lt;/p>
&lt;p>Between the two: a &lt;strong>KV cache transfer&lt;/strong> over the network, which can be node-local (shared memory, NVLink), intra-rack (RDMA with InfiniBand or RoCE) or cross-rack (NIXL over UCX). The cost of this transfer scales linearly with context length, and it is the economic crux of the scheme.&lt;/p>
&lt;div class="diagram" style="max-width: 720px; margin: 1.5rem auto;">
&lt;svg viewBox="0 0 720 340" xmlns="http://www.w3.org/2000/svg" role="img" aria-label="Monolithic vs disaggregated architecture">
&lt;style>
.node { stroke: #333; stroke-width: 1.5; }
.n-mono { fill: #ffe9d6; }
.n-prefill { fill: #d9f5d6; }
.n-decode { fill: #d6eaff; }
.n-router { fill: #fffae6; }
.lbl { font: 600 13px sans-serif; fill: #222; }
.lbl-sm { font: 11px sans-serif; fill: #444; }
.lbl-section { font: 700 14px sans-serif; fill: #222; }
.arr { stroke: #444; stroke-width: 1.6; fill: none; marker-end: url(#ah4); }
.arr-int { stroke: #c1121f; stroke-width: 1.4; fill: none; stroke-dasharray: 5,3; marker-end: url(#ah4r); }
&lt;/style>
&lt;defs>
&lt;marker id="ah4" 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;marker id="ah4r" viewBox="0 0 10 10" refX="9" refY="5" markerWidth="5" markerHeight="5" orient="auto">&lt;path d="M0,0 L10,5 L0,10 z" fill="#c1121f"/>&lt;/marker>
&lt;/defs>
&lt;text x="170" y="25" text-anchor="middle" class="lbl-section">Monolithic (aggregated)&lt;/text>
&lt;rect x="40" y="50" width="260" height="220" rx="10" class="node n-mono"/>
&lt;text x="170" y="78" text-anchor="middle" class="lbl">Single GPU&lt;/text>
&lt;text x="170" y="98" text-anchor="middle" class="lbl-sm">scheduler decides each cycle:&lt;/text>
&lt;rect x="65" y="115" width="100" height="40" rx="5" class="node n-prefill"/>
&lt;text x="115" y="140" text-anchor="middle" class="lbl-sm">prefill&lt;/text>
&lt;rect x="180" y="115" width="100" height="40" rx="5" class="node n-decode"/>
&lt;text x="230" y="140" text-anchor="middle" class="lbl-sm">decode&lt;/text>
&lt;path class="arr-int" d="M165,128 L180,128"/>
&lt;path class="arr-int" d="M180,145 L165,145"/>
&lt;text x="170" y="180" text-anchor="middle" class="lbl-sm" fill="#c1121f">interference on every switch&lt;/text>
&lt;text x="170" y="200" text-anchor="middle" class="lbl-sm">→ TBT rises when prefill arrives&lt;/text>
&lt;text x="170" y="230" text-anchor="middle" class="lbl-sm">one HW optimal for both:&lt;/text>
&lt;text x="170" y="250" text-anchor="middle" class="lbl-sm">impossible&lt;/text>
&lt;text x="540" y="25" text-anchor="middle" class="lbl-section">Disaggregated&lt;/text>
&lt;rect x="370" y="50" width="150" height="100" rx="10" class="node n-prefill"/>
&lt;text x="445" y="80" text-anchor="middle" class="lbl">prefill pod&lt;/text>
&lt;text x="445" y="102" text-anchor="middle" class="lbl-sm">H100 / H200 / B200&lt;/text>
&lt;text x="445" y="120" text-anchor="middle" class="lbl-sm">high compute, aggressive&lt;/text>
&lt;text x="445" y="138" text-anchor="middle" class="lbl-sm">batching&lt;/text>
&lt;rect x="560" y="50" width="150" height="100" rx="10" class="node n-decode"/>
&lt;text x="635" y="80" text-anchor="middle" class="lbl">decode pod&lt;/text>
&lt;text x="635" y="102" text-anchor="middle" class="lbl-sm">4090 / L40S / A100&lt;/text>
&lt;text x="635" y="120" text-anchor="middle" class="lbl-sm">high bandwidth, stable&lt;/text>
&lt;text x="635" y="138" text-anchor="middle" class="lbl-sm">TBT&lt;/text>
&lt;path class="arr" d="M520,100 L560,100"/>
&lt;text x="540" y="92" text-anchor="middle" class="lbl-sm">KV cache&lt;/text>
&lt;text x="540" y="115" text-anchor="middle" class="lbl-sm">NIXL/RDMA&lt;/text>
&lt;rect x="450" y="180" width="180" height="50" rx="8" class="node n-router"/>
&lt;text x="540" y="200" text-anchor="middle" class="lbl">router (vLLM/Dynamo)&lt;/text>
&lt;text x="540" y="218" text-anchor="middle" class="lbl-sm">routes prompts and streams&lt;/text>
&lt;path class="arr" d="M445,150 L500,180"/>
&lt;path class="arr" d="M635,150 L580,180"/>
&lt;text x="540" y="260" text-anchor="middle" class="lbl-sm" fill="#2a9d8f">→ stable TBT, low TTFT&lt;/text>
&lt;text x="540" y="280" text-anchor="middle" class="lbl-sm">cost: KV cache transfer&lt;/text>
&lt;text x="540" y="298" text-anchor="middle" class="lbl-sm">~5-50 ms depending on interconnect&lt;/text>
&lt;/svg>
&lt;/div>
&lt;h2 id="the-transfer-protocol-the-economics-of-movement">The transfer protocol: the economics of movement&lt;/h2>
&lt;p>The KV cache transferred for a Llama 3 70B with 4K of context weighs roughly &lt;strong>2.6 GB&lt;/strong> (80 layers × 8 KV heads × 128 dim × 4,096 tokens × 2 (K and V) × 2 bytes in BF16). Moving 2.6 GB between two GPUs is not trivial:&lt;/p>
&lt;table>
&lt;thead>
&lt;tr>
&lt;th>Channel&lt;/th>
&lt;th style="text-align:right">Effective bandwidth&lt;/th>
&lt;th style="text-align:right">Time for 2.6 GB&lt;/th>
&lt;/tr>
&lt;/thead>
&lt;tbody>
&lt;tr>
&lt;td>Intra-node NVLink (NVSwitch)&lt;/td>
&lt;td style="text-align:right">~450 GB/s&lt;/td>
&lt;td style="text-align:right">~6 ms&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>Shared memory (same node, PCIe 5)&lt;/td>
&lt;td style="text-align:right">~60 GB/s&lt;/td>
&lt;td style="text-align:right">~45 ms&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>RDMA InfiniBand 400 Gbps&lt;/td>
&lt;td style="text-align:right">~50 GB/s&lt;/td>
&lt;td style="text-align:right">~55 ms&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>RDMA RoCE 200 Gbps&lt;/td>
&lt;td style="text-align:right">~25 GB/s&lt;/td>
&lt;td style="text-align:right">~105 ms&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>TCP/IP 10 GbE&lt;/td>
&lt;td style="text-align:right">~1 GB/s&lt;/td>
&lt;td style="text-align:right">~2.6 s&lt;/td>
&lt;/tr>
&lt;/tbody>
&lt;/table>
&lt;p>The immediate reading: above InfiniBand grade, the transfer is comfortable. Below it, it ruins the very TTFT we are trying to improve. &lt;strong>Disaggregated serving is only viable with a decent interconnect.&lt;/strong> It is not a pattern for clusters built out of consumer Ethernet switches.&lt;/p>
&lt;p>NVIDIA answered this with &lt;strong>NIXL&lt;/strong> (NVIDIA Inference Transfer Library), released in mid-2025: a library that abstracts the transport (UCX, NCCL, direct RDMA verbs, shared memory) and picks the best available path automatically. vLLM has integrated it since late 2025 through the &lt;code>NixlConnector&lt;/code>. It is now the de facto default for new deployments.&lt;/p>
&lt;h2 id="real-implementations-in-may-2026">Real implementations in May 2026&lt;/h2>
&lt;p>The pattern&amp;rsquo;s path over two years:&lt;/p>
&lt;pre tabindex="0">&lt;code>2024 Jan · DistServe (HKU + UCSD): 7.4× requests at the same SLO
2024 May · SplitWise (Microsoft): variant with heterogeneous hardware
2024 Dec · experimental vLLM disagg (SharedStorage + PyNcclConnector)
2025 Mar · NIXL release (NVIDIA): unified transfer library
2025 Jul · stable vLLM NixlConnector
2025 Nov · SGLang, llm-d, MoonCake adopt the pattern
2026 Mar · NVIDIA Dynamo 1.0 GA (GTC 2026): production-ready at datacenter scale
&lt;/code>&lt;/pre>&lt;p>As of today, &lt;strong>the pattern is the default&lt;/strong> in any serious serving framework. The ones still monolithic are the small ones or the educational ones.&lt;/p>
&lt;p>Three realistic options for an on-premise infrastructure:&lt;/p>
&lt;ol>
&lt;li>&lt;strong>vLLM disagg with NixlConnector.&lt;/strong> The most open route, requiring two sets of vLLM pods to be deployed (one with &lt;code>--kv-transfer-config '{&amp;quot;kv_role&amp;quot;:&amp;quot;producer&amp;quot;}'&lt;/code>, another with &lt;code>&amp;quot;kv_role&amp;quot;:&amp;quot;consumer&amp;quot;&lt;/code>) plus a proxy router. Enough for clusters of 4-16 GPUs.&lt;/li>
&lt;li>&lt;strong>SGLang with disagg.&lt;/strong> Conceptually equivalent, better performance on some MoE workloads.&lt;/li>
&lt;li>&lt;strong>NVIDIA Dynamo 1.0.&lt;/strong> The one taking over at datacenter scale. It covers routing, KV cache management, monitoring and scheduling in a single control plane. Heavier, but the reference solution if your cluster grows beyond 32 GPUs.&lt;/li>
&lt;/ol>
&lt;h2 id="the-numbers-that-matter">The numbers that matter&lt;/h2>
&lt;p>What disaggregation unlocks, in direct terms:&lt;/p>
&lt;table>
&lt;thead>
&lt;tr>
&lt;th>Metric&lt;/th>
&lt;th>Aggregated (monolithic)&lt;/th>
&lt;th>Disaggregated&lt;/th>
&lt;th>Improvement&lt;/th>
&lt;/tr>
&lt;/thead>
&lt;tbody>
&lt;tr>
&lt;td>Goodput (req/s at the SLO)&lt;/td>
&lt;td>baseline&lt;/td>
&lt;td>1.4 – 2×&lt;/td>
&lt;td>up to 2×&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>TTFT under high load&lt;/td>
&lt;td>rises sharply from QPS 4&lt;/td>
&lt;td>stable up to QPS 7+&lt;/td>
&lt;td>~2×&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>Request rate at the same SLO (DistServe paper)&lt;/td>
&lt;td>baseline&lt;/td>
&lt;td>7.4×&lt;/td>
&lt;td>7.4×&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>MoE throughput on Blackwell (Dynamo, GB300 NVL72)&lt;/td>
&lt;td>baseline (Hopper)&lt;/td>
&lt;td>up to 50×&lt;/td>
&lt;td>depends on the model&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>Cost per token (heterogeneous H100 + commodity)&lt;/td>
&lt;td>baseline (all H100)&lt;/td>
&lt;td>-48 %&lt;/td>
&lt;td>almost half&lt;/td>
&lt;/tr>
&lt;/tbody>
&lt;/table>
&lt;p>These numbers have to be read carefully: the most spectacular ones (7× and 50×) need specific hardware (Blackwell GB200/GB300 NVL72) and specific models (large MoE). The &lt;strong>realistic range for a typical on-premise setup is 1.4-2× in goodput and -30 to -50 % in cost per token&lt;/strong>, depending on how heterogeneous the GPU mix is and how optimised the KV cache transfer is.&lt;/p>
&lt;h2 id="heterogeneity-the-radical-version">Heterogeneity: the radical version&lt;/h2>
&lt;p>The logical next step, proposed by SplitWise in 2024 and matured in 2025-2026 (Cronus, Tessera and others), is to &lt;strong>mix GPU types&lt;/strong>: expensive high-compute GPUs for prefill, commodity GPUs with good bandwidth for decode.&lt;/p>
&lt;p>Indicative cost (typical market prices in mid-2026):&lt;/p>
&lt;ul>
&lt;li>&lt;strong>H100 SXM&lt;/strong>: ~30-40 kUSD capex, ~3-4 USD/h amortised. Compute-heavy profile.&lt;/li>
&lt;li>&lt;strong>L40S&lt;/strong>: ~8-10 kUSD capex, ~1.5 USD/h. Intermediate profile, 864 GB/s of bandwidth.&lt;/li>
&lt;li>&lt;strong>RTX 4090&lt;/strong>: ~1.5 kUSD capex, ~0.30 USD/h. Modest compute profile but 1 TB/s of GDDR6X bandwidth, enough for decode of models up to ~30B parameters.&lt;/li>
&lt;/ul>
&lt;p>A realistic mixed cluster for serving an 8B model:&lt;/p>
&lt;pre tabindex="0">&lt;code>2× RTX 4090 (prefill batch) → ~3,000 USD capex, ~0.60 USD/h
4× RTX 4090 (decode pool) → ~6,000 USD capex, ~1.20 USD/h
TOTAL → ~9,000 USD capex, ~1.80 USD/h
&lt;/code>&lt;/pre>&lt;p>Against the monolithic alternative with equivalent throughput:&lt;/p>
&lt;pre tabindex="0">&lt;code>2× H100 SXM (all in one) → ~70,000 USD capex, ~7 USD/h
&lt;/code>&lt;/pre>&lt;p>The same throughput at a fraction of the capex and a quarter of the hourly cost, &lt;strong>at the price of operational complexity&lt;/strong>: you now have two pools to coordinate, a transfer network to look after, and a scheduler that is not trivial.&lt;/p>
&lt;p>For larger models (Llama 3 70B), the decode pool no longer fits on a single 4090 (the model does not fit in 24 GB, not even quantised to INT4 with headroom). There the sensible mix is H100 for prefill plus L40S or A100 80GB for decode, with a typical saving of 30-40 % over the all-H100 option.&lt;/p>
&lt;h2 id="applied-to-typical-on-premise-hardware">Applied to typical on-premise hardware&lt;/h2>
&lt;h3 id="case-1--one-or-two-rtx-4090s-monolithic-still-wins">Case 1 — One or two RTX 4090s: monolithic still wins&lt;/h3>
&lt;p>With a single GPU there is no disaggregation to speak of: the pattern needs at least two GPUs in separate pods. With two 4090s you can technically try it (one for prefill, one for decode with the KV cache transferred over PCIe 5 or basic RDMA), but the transfer overhead eats the gain for small models where prefill is already fast.&lt;/p>
&lt;p>&lt;strong>Recommendation:&lt;/strong> stay monolithic (traditional vLLM, &lt;a href="https://blog.lo0.es/en/posts/kv-cache-working-memory-llm-inference/">properly configured with a quantised KV cache&lt;/a>). The next justifiable level of complexity is a cluster with a fast interconnect.&lt;/p>
&lt;h3 id="case-2--4h100-sxm-cluster-320-gb-nvlink-the-sweet-spot">Case 2 — 4×H100 SXM cluster (320 GB, NVLink): the sweet spot&lt;/h3>
&lt;p>The minimum realistic configuration for serious disaggregation, serving a 70B model in production:&lt;/p>
&lt;pre tabindex="0">&lt;code>2× H100 (TP=2) → 2 prefill pods
2× H100 (TP=2) → decode pods with several instances sharing TP
NIXL over NVLink → KV cache transfer &amp;lt;6 ms
Router (vLLM or Dynamo) → prompt distribution and streaming
&lt;/code>&lt;/pre>&lt;p>Realistic expected result: goodput &lt;strong>1.6-1.9× relative to the same cluster running monolithic&lt;/strong>, with TTFT stable up to loads of QPS 7-8 (against the QPS 4 at which the monolithic setup starts to degrade).&lt;/p>
&lt;p>If the heterogeneous mix is possible (adding 4-8 L40S to the cluster to make the decode pool), the cost per token drops a further 25 % to 35 %, while still serving the 70B model whole.&lt;/p>
&lt;h2 id="position-within-the-architecture">Position within the architecture&lt;/h2>
&lt;p>Disaggregated serving is a &lt;strong>cross-cutting layer&lt;/strong> over almost everything discussed in previous articles. It touches:&lt;/p>
&lt;ul>
&lt;li>The &lt;a href="https://blog.lo0.es/en/posts/kv-cache-working-memory-llm-inference/">KV cache&lt;/a> because it is the artefact transferred between pods. Without a solid grasp of how much the cache weighs and how it grows with context, you cannot size the transfer.&lt;/li>
&lt;li>&lt;a href="https://blog.lo0.es/en/posts/continuous-fine-tuning-production-real-traffic-deployed-adapter/">Continuous fine-tuning&lt;/a> because multi-LoRA hot-swap keeps its semantics: each pod (prefill or decode) loads the adapters separately, and the router decides which adapter to apply in each phase.&lt;/li>
&lt;li>Cluster topology: it changes the recommended HW, the networking required and the cost model.&lt;/li>
&lt;/ul>
&lt;p>If you are designing an inference infrastructure for 2026 from scratch, &lt;strong>disaggregation stops being optional&lt;/strong> for any cluster beyond 4 GPUs of capacity. If you are modernising an existing one, it is the upgrade with the best return per euro invested, provided the networking between pods is decent (intra-node NVLink or intra-rack RDMA as a minimum).&lt;/p>
&lt;h2 id="what-we-have-not-covered-upcoming-articles">What we have not covered (upcoming articles)&lt;/h2>
&lt;ul>
&lt;li>&lt;strong>NIXL in detail&lt;/strong>: how it picks the optimal transport, how UCX is configured, what happens when RDMA fails and you have to fall back to TCP.&lt;/li>
&lt;li>&lt;strong>Routing scheduler&lt;/strong>: how the orchestrator decides which pod gets which request, dynamic batching, priority handling.&lt;/li>
&lt;li>&lt;strong>Multi-tenant disagg&lt;/strong>: KV cache isolation between tenants, per-adapter ACLs, multi-LoRA over specialised pods.&lt;/li>
&lt;li>&lt;strong>Disagg + prefix caching&lt;/strong>: how it combines with the KV cache reuse pattern when several prompts share a prefix (a common system prompt).&lt;/li>
&lt;li>&lt;strong>Disagg at the edge / local inference&lt;/strong>: viability on home hardware (4090 + Mac Studio, for example), where the transfer depends on Thunderbolt or residential Ethernet.&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/six-stage-llmops-pipeline-architecture-and-deep-dive/">The six-stage LLMOps pipeline&lt;/a> — the master map the Deploy stage belongs to. This post digs into one concrete architectural decision within that stage.&lt;/li>
&lt;li>&lt;a href="https://blog.lo0.es/en/posts/gpu-cluster-as-multi-tenant-platform/">The GPU cluster as a multi-tenant platform&lt;/a> — the Gateway/Quota/Isolation/Observability layered pattern on top of which the disaggregation described here sits: the H100 cluster serving several tenants combines both patterns.&lt;/li>
&lt;li>&lt;a href="https://blog.lo0.es/en/posts/llm-inference-operators-kubernetes/">LLM inference operators on Kubernetes&lt;/a> — the operators (vLLM Production Stack, NVIDIA Dynamo, llm-d, OME) that materialise the specialised prefill and decode pods in Kubernetes.&lt;/li>
&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> — the exact artefact transferred between pods, with the full formula for its size.&lt;/li>
&lt;li>&lt;a href="https://blog.lo0.es/en/posts/pagedattention-vllm-block-manager/">PagedAttention from the inside: blocks, page table, eviction and the state of the art of KV cache in 2026&lt;/a> — the KV cache mechanics that disaggregation exploits at block level, and the landscape of derived optimisations (vAttention, LMCache, RadixAttention).&lt;/li>
&lt;li>&lt;a href="https://blog.lo0.es/en/posts/continuous-fine-tuning-production-real-traffic-deployed-adapter/">Continuous fine-tuning in production&lt;/a> — how multi-LoRA hot-swap coexists with disaggregation: each pod loads adapters separately, the router chooses.&lt;/li>
&lt;li>&lt;a href="https://blog.lo0.es/en/posts/quantization-llm-inference-fp8-int4-gguf/">Quantization for LLM inference: FP8, INT4 and GGUF&lt;/a> — the two pools (prefill and decode) can be quantised asymmetrically: prefill keeps more precision, decode prioritises throughput. The maths and the formats are there.&lt;/li>
&lt;li>&lt;a href="https://blog.lo0.es/en/posts/speculative-decoding-fundamentals-maths-state-of-play/">Speculative decoding: the secretary who anticipates what the boss is going to say&lt;/a> — speculative only helps in the decode phase (it does not touch prefill); disaggregation makes it easier by isolating the decode pool where the technique can be applied without contaminating the prefill one.&lt;/li>
&lt;li>&lt;a href="https://blog.lo0.es/en/posts/flashattention-librarian-io-awareness-async-blackwell-asymmetry/">FlashAttention v1/v2/v3/v4: the librarian who never clears the desk&lt;/a> — the kernel each pod uses underneath. Prefill (compute-bound) and decode (memory-bound) benefit from FA3/FA4 in different ways and allow a backend to be chosen per phase.&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 real DeepSeek-V3 deployment combines &lt;strong>EP=32 in prefill (4 nodes) with EP=144 in decode (18 nodes)&lt;/strong>: disaggregation is the prerequisite that allows expert parallelism to be specialised per phase.&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: the single translator with a thousand glossaries&lt;/a> — once pods are separated, adapters are managed per pool; the 2026 strategy is to replicate hot ones on every pod (prefill and decode) and evict cold ones.&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 layer underneath. DistServe is the paper that contributed the goodput metric and the idea of disaggregating prefill/decode; understanding the iterative scheduler is a prerequisite to understanding why the separation unlocked additional gains.&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> — disaggregation appears as the third sizing lever when the workload&amp;rsquo;s prefill/decode mix is asymmetric; when to apply it and how much it saves are quantified there.&lt;/li>
&lt;li>&lt;a href="https://blog.lo0.es/en/posts/nvlink-nvswitch-nccl-cable-every-token-crosses/">NVLink, NVSwitch and NCCL: the cable every token travels down&lt;/a> — moving the KV cache between the prefill pool and the decode pool travels over the same interconnect as the all-reduces and competes with them; designing the disaggregation without counting that transfer cost is the classic trap.&lt;/li>
&lt;li>&lt;a href="https://blog.lo0.es/en/posts/network-numa-cilium-ebpf-dranet-fourth-leg-pinning/">The kitchen door the maître never looked at: network NUMA, Cilium eBPF and DRANET&lt;/a> — when that KV cache crosses by RDMA across nodes, a badly placed NIC pays for it in latency; the fourth leg (NIC↔GPU locality via DRANET) is what keeps separating prefill and decode from being expensive in transfer.&lt;/li>
&lt;li>&lt;a href="https://blog.lo0.es/en/posts/pantry-lift-from-disk-to-hbm-why-the-kitchen-opens-late/">From disk to HBM: cold start and model loading&lt;/a> — bringing up decode pools on demand pays the cold start of loading the model into every new pod; the elasticity of the pattern depends on how fast they start.&lt;/li>
&lt;li>&lt;a href="https://blog.lo0.es/en/posts/sms-cuda-streams-cuda-graphs-inference/">SM, CUDA streams and CUDA graphs&lt;/a> — pure decode pods are the ideal case for &lt;code>FULL_DECODE_ONLY&lt;/code>: they maximise the benefit of the CUDA graph in the most launch-bound phase.&lt;/li>
&lt;/ul>
&lt;h2 id="references">References&lt;/h2>
&lt;ul>
&lt;li>Zhong et al., &lt;em>DistServe: Disaggregating Prefill and Decoding for Goodput-optimized Large Language Model Serving&lt;/em> (OSDI 2024).&lt;/li>
&lt;li>Patel et al., &lt;em>SplitWise: Efficient Generative LLM Inference Using Phase Splitting&lt;/em> (ISCA 2024).&lt;/li>
&lt;li>NVIDIA, &lt;em>NVIDIA Dynamo 1.0: Production-Ready Disaggregated Inference&lt;/em> (GTC 2026, March): &lt;a href="https://developer.nvidia.com/blog/nvidia-dynamo-1-production-ready/">https://developer.nvidia.com/blog/nvidia-dynamo-1-production-ready/&lt;/a>.&lt;/li>
&lt;li>NVIDIA, &lt;em>NIXL: NVIDIA Inference Transfer Library&lt;/em> — official documentation.&lt;/li>
&lt;li>vLLM, &lt;em>Disaggregated Prefilling&lt;/em>: &lt;a href="https://docs.vllm.ai/en/stable/features/disagg_prefill/">https://docs.vllm.ai/en/stable/features/disagg_prefill/&lt;/a>.&lt;/li>
&lt;li>vLLM, &lt;em>NixlConnector Usage Guide&lt;/em>: &lt;a href="https://docs.vllm.ai/en/stable/features/nixl_connector_usage/">https://docs.vllm.ai/en/stable/features/nixl_connector_usage/&lt;/a>.&lt;/li>
&lt;li>Hao AI Lab, &lt;em>Disaggregated Inference: 18 Months Later&lt;/em> (UCSD, 2025) — technical retrospective on the DistServe paper.&lt;/li>
&lt;/ul></description></item><item><title>Continuous fine-tuning in production: from real traffic to the deployed adapter</title><link>https://blog.lo0.es/en/posts/continuous-fine-tuning-production-real-traffic-deployed-adapter/</link><pubDate>Thu, 21 May 2026 10:00:00 +0200</pubDate><guid>https://blog.lo0.es/en/posts/continuous-fine-tuning-production-real-traffic-deployed-adapter/</guid><description>&lt;h2 id="tldr">TL;DR&lt;/h2>
&lt;p>Continuous fine-tuning is not &amp;ldquo;training the model every so often&amp;rdquo;. It is a &lt;strong>closed loop&lt;/strong> where real production traffic generates the datasets, a short pipeline trains a LoRA adapter, a battery of evaluations decides whether it is promoted, and vLLM loads it &lt;strong>without restarting&lt;/strong>. The state of the art in May 2026 has fragmented the stack: it is no longer DPO against everything, but a choice between SFT, DPO, KTO, ORPO and SimPO according to the type of signal your product captures. What has consolidated the pattern is the combination of PostgreSQL 18 + pgvector 0.8 as the &lt;strong>nervous system of the pipeline&lt;/strong>, traffic capture, dataset versioning, eval results, adapter registry, together with vLLM multi-LoRA hot-swap, which turns deployment into an HTTP call. This article takes the cycle apart with concrete schemas, real queries, and the numbers it costs on an RTX 4090 against a 4×H100 cluster.&lt;/p>
&lt;h2 id="you-are-here-tune--retrain">You are here: Tune + Retrain&lt;/h2>
&lt;p>This post crosses two stages of the &lt;a href="https://blog.lo0.es/en/posts/six-stage-llmops-pipeline-architecture-and-deep-dive/">six-stage LLMOps pipeline&lt;/a>: the decision to &lt;strong>train a new adapter&lt;/strong> (the &lt;strong>Tune&lt;/strong> stage) is triggered by the signals from &lt;strong>Observe&lt;/strong> that travel through the &lt;strong>Retrain&lt;/strong> stage until the loop closes. The post takes apart the complete circuit between the two boxes.&lt;/p>
&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: Tune + Retrain">
&lt;style>.box{stroke:#444;stroke-width:1.4;rx:6}.active{fill:#ff8a4c;stroke-width:3}.active2{fill:#fff5b0;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(#ftm)}.cyc{stroke:#888;stroke-width:1.2;fill:none;stroke-dasharray:4 2;marker-end:url(#ftm)}&lt;/style>
&lt;defs>&lt;marker id="ftm" 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: TUNE + RETRAIN · continuous adapter cycle driven by real traffic&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 active"/>&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 idle"/>&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 active2"/>&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="the-analogy-the-restaurant-that-tunes-its-menu">The analogy: the restaurant that tunes its menu&lt;/h2>
&lt;p>Picture a neighbourhood restaurant with a signature dish that works, but the chef knows it can be tuned. Every night things happen:&lt;/p>
&lt;ul>
&lt;li>Some diners &lt;strong>leave part of the dish&lt;/strong>: a weak signal that something did not quite land.&lt;/li>
&lt;li>Others ask for &lt;strong>another version&lt;/strong> (&amp;ldquo;could you put less salt in it?&amp;rdquo;): an explicit, directional signal.&lt;/li>
&lt;li>Others &lt;strong>finish the dish and come back the following week&lt;/strong>: the only signal that really matters, but it arrives late.&lt;/li>
&lt;li>And a select group offers an opinion without being asked, usually a negative one.&lt;/li>
&lt;/ul>
&lt;p>The chef does not redo the menu every night. He does something more interesting: he writes down in a notebook the dishes served, the returns, the changes requested, the tips. Every so often, he &lt;strong>reads the whole notebook&lt;/strong>, decides on minimal adjustments to a recipe, tries the new version at a private table with his staff, and only if they take to it does he add it to the next day&amp;rsquo;s menu. Sometimes he even serves two different versions of the dish to different tables for a week, measures what happens, and picks one.&lt;/p>
&lt;p>That is &lt;strong>continuous fine-tuning&lt;/strong>. The notebook is Postgres. The dish is the base model. The notes are feedback signals, explicit and implicit. The &amp;ldquo;minimal adjustment&amp;rdquo; is a 30 MB LoRA adapter. The private table is the battery of automated evaluations. The next day&amp;rsquo;s menu is vLLM with multi-LoRA hot-swap, which loads the new adapter without restarting the service. Serving two versions to different tables is A/B testing with real traffic.&lt;/p>
&lt;p>The analogy is exact on one critical point: &lt;strong>the chef does not throw away the original recipe&lt;/strong>. He keeps the base recipe and holds a separate notebook with the &amp;ldquo;modifications that work well for the neighbourhood regulars&amp;rdquo;. That notebook is the LoRA adapter: on top of the base model, not in its place.&lt;/p>
&lt;h2 id="the-cycle-taken-apart">The cycle, taken apart&lt;/h2>
&lt;p>Before going into components, it helps to fix the complete flow. These seven steps are what any serious team replicates with variations:&lt;/p>
&lt;div class="diagram" style="max-width: 720px; margin: 1.5rem auto;">
&lt;svg viewBox="0 0 720 420" xmlns="http://www.w3.org/2000/svg" role="img" aria-label="Closed loop of continuous fine-tuning">
&lt;style>
.node { fill: #f4f4f4; stroke: #333; stroke-width: 1.5; }
.n-serve { fill: #d6eaff; }
.n-data { fill: #ffe9d6; }
.n-train { fill: #d9f5d6; }
.n-eval { fill: #f4e1ff; }
.lbl { font: 600 12px sans-serif; fill: #222; }
.lbl-sm { font: 10.5px sans-serif; fill: #444; }
.arr { stroke: #444; stroke-width: 1.4; fill: none; marker-end: url(#ah2); }
.arr-dim { stroke: #888; stroke-width: 1.2; fill: none; stroke-dasharray: 4,3; marker-end: url(#ah2); }
&lt;/style>
&lt;defs>
&lt;marker id="ah2" 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="360" y="22" text-anchor="middle" class="lbl">The closed loop of continuous fine-tuning&lt;/text>
&lt;rect x="280" y="40" width="160" height="50" rx="8" class="node n-serve"/>
&lt;text x="360" y="62" text-anchor="middle" class="lbl">1 · vLLM serving&lt;/text>
&lt;text x="360" y="78" text-anchor="middle" class="lbl-sm">base + active adapters&lt;/text>
&lt;rect x="500" y="120" width="170" height="50" rx="8" class="node n-data"/>
&lt;text x="585" y="142" text-anchor="middle" class="lbl">2 · Traffic capture&lt;/text>
&lt;text x="585" y="158" text-anchor="middle" class="lbl-sm">prompts, responses, feedback&lt;/text>
&lt;rect x="500" y="210" width="170" height="50" rx="8" class="node n-data"/>
&lt;text x="585" y="232" text-anchor="middle" class="lbl">3 · Curation&lt;/text>
&lt;text x="585" y="248" text-anchor="middle" class="lbl-sm">dedup, PII, balancing, snapshot&lt;/text>
&lt;rect x="280" y="290" width="160" height="50" rx="8" class="node n-train"/>
&lt;text x="360" y="312" text-anchor="middle" class="lbl">4 · LoRA training&lt;/text>
&lt;text x="360" y="328" text-anchor="middle" class="lbl-sm">SFT / DPO / KTO / ORPO / SimPO&lt;/text>
&lt;rect x="50" y="210" width="170" height="50" rx="8" class="node n-eval"/>
&lt;text x="135" y="232" text-anchor="middle" class="lbl">5 · Eval gates&lt;/text>
&lt;text x="135" y="248" text-anchor="middle" class="lbl-sm">3 stages: PR, full, canary&lt;/text>
&lt;rect x="50" y="120" width="170" height="50" rx="8" class="node n-train"/>
&lt;text x="135" y="142" text-anchor="middle" class="lbl">6 · Adapter registry&lt;/text>
&lt;text x="135" y="158" text-anchor="middle" class="lbl-sm">status: canary | prod | retired&lt;/text>
&lt;rect x="50" y="40" width="170" height="50" rx="8" class="node n-serve"/>
&lt;text x="135" y="62" text-anchor="middle" class="lbl">7 · Hot-swap&lt;/text>
&lt;text x="135" y="78" text-anchor="middle" class="lbl-sm">POST /v1/load_lora_adapter&lt;/text>
&lt;path class="arr" d="M440,75 C480,80 495,100 510,120"/>
&lt;path class="arr" d="M585,170 L585,210"/>
&lt;path class="arr" d="M500,250 C460,270 440,280 440,300"/>
&lt;path class="arr" d="M280,315 C240,290 230,275 220,260"/>
&lt;path class="arr" d="M135,210 L135,170"/>
&lt;path class="arr" d="M135,120 L135,90"/>
&lt;path class="arr" d="M220,65 L280,65"/>
&lt;rect x="280" y="170" width="160" height="80" rx="10" fill="#fffae6" stroke="#d4a52a" stroke-width="2"/>
&lt;text x="360" y="200" text-anchor="middle" class="lbl">PostgreSQL 18&lt;/text>
&lt;text x="360" y="218" text-anchor="middle" class="lbl-sm">+ pgvector 0.8&lt;/text>
&lt;text x="360" y="234" text-anchor="middle" class="lbl-sm">single source of truth&lt;/text>
&lt;path class="arr-dim" d="M500,145 L440,180"/>
&lt;path class="arr-dim" d="M500,235 L440,225"/>
&lt;path class="arr-dim" d="M280,225 L220,235"/>
&lt;path class="arr-dim" d="M280,200 L220,160"/>
&lt;path class="arr-dim" d="M360,290 L360,250"/>
&lt;/svg>
&lt;/div>
&lt;p>The cycle lasts between 1 and 4 weeks in real production. What changes between teams is the tempo (faster in assistant chat, slower in regulated banking) and the details of each step. The structure is the same.&lt;/p>
&lt;h2 id="why-continuous-fine-tuning-and-why-it-is-not-rag">Why continuous fine-tuning (and why it is not RAG)&lt;/h2>
&lt;p>Before going deeper, a distinction that people still confuse. &lt;strong>Fine-tuning is for form, not for facts.&lt;/strong> If your problem is that the model does not know the customer&amp;rsquo;s rates or the updated catalogue, do not fine-tune: use RAG. If your problem is that the model responds in a tone that does not fit, does not respect your JSON format, refuses legitimate cases or invents structure, then fine-tuning is the answer.&lt;/p>
&lt;p>In 2026 the boundary is well established by community practice:&lt;/p>
&lt;table>
&lt;thead>
&lt;tr>
&lt;th>Observed problem&lt;/th>
&lt;th>Solution&lt;/th>
&lt;/tr>
&lt;/thead>
&lt;tbody>
&lt;tr>
&lt;td>The model does not know X (X changes weekly)&lt;/td>
&lt;td>RAG&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>The model knows X but responds badly in tone or format&lt;/td>
&lt;td>SFT fine-tuning&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>There are two ways of responding and I prefer one over the other&lt;/td>
&lt;td>Fine-tuning with preferences (DPO/KTO/ORPO/SimPO)&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>The model reasons badly in a verifiable domain (code, maths)&lt;/td>
&lt;td>RL with verifiable reward (GRPO/DAPO)&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>The model is competent, it just needs a memory of facts&lt;/td>
&lt;td>RAG, not fine-tuning&lt;/td>
&lt;/tr>
&lt;/tbody>
&lt;/table>
&lt;p>Continuous fine-tuning is the disciplined version of the second and third cases. The key word is &lt;strong>continuous&lt;/strong>: it is not a one-off &amp;ldquo;we aligned the model&amp;rdquo; event, it is a process that runs every time the traffic distribution drifts far enough, or new use cases appear.&lt;/p>
&lt;h2 id="the-four-techniques-according-to-the-signal-you-capture">The four techniques according to the signal you capture&lt;/h2>
&lt;p>The most important change of the last 12 months has been the end of DPO&amp;rsquo;s monopoly. In 2024 every team doing alignment used DPO with &lt;code>(chosen, rejected)&lt;/code> pairs. In 2026 the choice is finer and depends on &lt;strong>what the signal you collect in your product looks like&lt;/strong>:&lt;/p>
&lt;table>
&lt;thead>
&lt;tr>
&lt;th>Real signal in the product&lt;/th>
&lt;th>Recommended technique&lt;/th>
&lt;th>Why&lt;/th>
&lt;/tr>
&lt;/thead>
&lt;tbody>
&lt;tr>
&lt;td>Labelled correct examples (input → expected output)&lt;/td>
&lt;td>&lt;strong>SFT + LoRA&lt;/strong>&lt;/td>
&lt;td>Still the baseline. 500-5,000 examples are enough for style.&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>Explicit &lt;code>(chosen, rejected)&lt;/code> pairs&lt;/td>
&lt;td>&lt;strong>DPO&lt;/strong> or &lt;strong>SimPO&lt;/strong>&lt;/td>
&lt;td>SimPO removes the reference model → 50 % less VRAM in training.&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>Loose 👍 / 👎 on responses&lt;/td>
&lt;td>&lt;strong>KTO&lt;/strong>&lt;/td>
&lt;td>The method that fits real telemetry most naturally.&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>SFT and preferences in a single pass&lt;/td>
&lt;td>&lt;strong>ORPO&lt;/strong>&lt;/td>
&lt;td>A single model in memory, avoids drift between phases.&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>Verifiable reward (tests, solutions)&lt;/td>
&lt;td>&lt;strong>GRPO&lt;/strong> / &lt;strong>DAPO&lt;/strong>&lt;/td>
&lt;td>Reasoning, not chat. A different world.&lt;/td>
&lt;/tr>
&lt;/tbody>
&lt;/table>
&lt;p>The practical rule: &lt;strong>design the feedback capture in the product thinking about which method you will be able to use afterwards&lt;/strong>. If your UI only has 👍/👎, you force the path to KTO. If you add a &amp;ldquo;regenerate response&amp;rdquo; button, you unlock DPO from regenerate-as-rejected (we will see it below). If you add an &amp;ldquo;edit response&amp;rdquo; button, the edited response becomes high-quality direct SFT.&lt;/p>
&lt;p>There is a cost detail that gets little publicity. DPO needs to keep &lt;strong>two models&lt;/strong> in memory: the one you are training and the reference one. SimPO removes that second model. ORPO does too. For a Llama 3 8B in BF16 this is the difference between needing ~32 GB of active VRAM during training (DPO) or ~16 GB (SimPO/ORPO). It is the difference between the training fitting on an RTX 4090 with aggressive QLoRA, or not fitting without offload.&lt;/p>
&lt;h2 id="postgres-as-the-nervous-system-of-the-pipeline">Postgres as the nervous system of the pipeline&lt;/h2>
&lt;p>Here is the strong technical opinion of this article, and it is one worth defending with data: &lt;strong>Postgres 18 + pgvector 0.8 + an S3/MinIO bucket for the weights is enough for the whole pipeline&lt;/strong>. You do not need MLflow, you do not need lakeFS, you do not need DVC.&lt;/p>
&lt;p>This is not ideological minimalism. It is about three concrete advantages that no alternative stack matches in the on-premise scenario with compliance:&lt;/p>
&lt;ol>
&lt;li>&lt;strong>A single source of truth, a single authorisation model.&lt;/strong> The ACLs you already have for Postgres cover the training data, the eval results, the adapter registry and the audit log. You do not multiply control planes.&lt;/li>
&lt;li>&lt;strong>SQL as the pipeline&amp;rsquo;s universal language.&lt;/strong> The query that generates the dataset, the eval gate predicate, the A/B traffic assignment, the promotion decision: it is all SQL. Your team already knows SQL.&lt;/li>
&lt;li>&lt;strong>Audit and cryptographic reproducibility for free.&lt;/strong> The &lt;code>pg_audit&lt;/code> and &lt;code>pgcrypto&lt;/code> extensions, combined with &lt;code>set_hash&lt;/code> over the dataset, give you cryptographic traceability with no additional code. It is a subject that would fill an article of its own.&lt;/li>
&lt;/ol>
&lt;h3 id="a-concrete-schema">A concrete schema&lt;/h3>
&lt;p>We start with the traffic table, partitioned by week so that &lt;code>DROP PARTITION&lt;/code> is cheap:&lt;/p>
&lt;div class="highlight">&lt;pre tabindex="0" class="chroma">&lt;code class="language-sql" data-lang="sql">&lt;span class="line">&lt;span class="cl">&lt;span class="k">CREATE&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="k">TABLE&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="n">obs&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="n">inference_log&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="n">id&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="n">BIGSERIAL&lt;/span>&lt;span class="p">,&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="n">request_id&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="n">UUID&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="k">NOT&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="k">NULL&lt;/span>&lt;span class="p">,&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="n">tenant_id&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="nb">INT&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="k">NOT&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="k">NULL&lt;/span>&lt;span class="p">,&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="n">user_hash&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="n">BYTEA&lt;/span>&lt;span class="p">,&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="c1">-- GDPR pseudonymisation
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="c1">&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="n">adapter_id&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="nb">TEXT&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="k">NOT&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="k">NULL&lt;/span>&lt;span class="p">,&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="c1">-- e.g. &amp;#34;support-es-v4.1&amp;#34;
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="c1">&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="n">experiment&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="nb">TEXT&lt;/span>&lt;span class="p">,&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="c1">-- e.g. &amp;#34;rerank-v2-canary&amp;#34;
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="c1">&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="n">variant&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="nb">CHAR&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="mi">1&lt;/span>&lt;span class="p">),&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="c1">-- &amp;#39;A&amp;#39; | &amp;#39;B&amp;#39; | NULL
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="c1">&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="n">messages&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="n">JSONB&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="k">NOT&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="k">NULL&lt;/span>&lt;span class="p">,&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="k">completion&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="nb">TEXT&lt;/span>&lt;span class="p">,&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="n">ttft_ms&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="nb">INT&lt;/span>&lt;span class="p">,&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="n">tokens_in&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="nb">INT&lt;/span>&lt;span class="p">,&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="n">tokens_out&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="nb">INT&lt;/span>&lt;span class="p">,&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="c1">-- feedback signals
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="c1">&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="n">fb_explicit&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="nb">SMALLINT&lt;/span>&lt;span class="p">,&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="c1">-- -1/0/+1 (KTO-ready)
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="c1">&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="n">fb_regen&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="nb">BOOLEAN&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="k">DEFAULT&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="k">false&lt;/span>&lt;span class="p">,&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="c1">-- user regenerated -&amp;gt; DPO-rejected
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="c1">&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="n">fb_edited&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="nb">BOOLEAN&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="k">DEFAULT&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="k">false&lt;/span>&lt;span class="p">,&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="c1">-- user edited -&amp;gt; SFT golden
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="c1">&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="n">parent_id&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="nb">BIGINT&lt;/span>&lt;span class="p">,&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="c1">-- self-reference for regenerate
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="c1">&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="c1">-- vector and meta
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="c1">&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="n">embedding&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="n">HALFVEC&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="mi">1024&lt;/span>&lt;span class="p">),&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="c1">-- pgvector 0.8, half the RAM
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="c1">&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="n">pii_flags&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="nb">SMALLINT&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="k">DEFAULT&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="mi">0&lt;/span>&lt;span class="p">,&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="c1">-- bitmask
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="c1">&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="n">created_at&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="n">TIMESTAMPTZ&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="k">DEFAULT&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="n">now&lt;/span>&lt;span class="p">()&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w">&lt;/span>&lt;span class="p">)&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="n">PARTITION&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="k">BY&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="n">RANGE&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="n">created_at&lt;/span>&lt;span class="p">);&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w">&lt;/span>&lt;span class="k">CREATE&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="k">TABLE&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="n">obs&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="n">inference_log_2026w21&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="n">PARTITION&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="k">OF&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="n">obs&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="n">inference_log&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="k">FOR&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="k">VALUES&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="k">FROM&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="s1">&amp;#39;2026-05-18&amp;#39;&lt;/span>&lt;span class="p">)&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="k">TO&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="s1">&amp;#39;2026-05-25&amp;#39;&lt;/span>&lt;span class="p">);&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w">&lt;/span>&lt;span class="k">CREATE&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="k">INDEX&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="k">ON&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="n">obs&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="n">inference_log_2026w21&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="k">USING&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="n">hnsw&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="n">embedding&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="n">halfvec_cosine_ops&lt;/span>&lt;span class="p">);&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w">&lt;/span>&lt;span class="k">CREATE&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="k">INDEX&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="k">ON&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="n">obs&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="n">inference_log_2026w21&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="n">tenant_id&lt;/span>&lt;span class="p">,&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="n">adapter_id&lt;/span>&lt;span class="p">,&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="n">created_at&lt;/span>&lt;span class="p">);&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>Three decisions deserve a note:&lt;/p>
&lt;p>&lt;strong>&lt;code>HALFVEC(1024)&lt;/code>.&lt;/strong> Native FP16 vectors in pgvector 0.8. Half the RAM and disk, with a loss of precision that is irrelevant for semantic deduplication. This alone, at a scale of millions of rows, saves between 4 and 8 GB.&lt;/p>
&lt;p>&lt;strong>Weekly partitioning by time range.&lt;/strong> At 90 days, &lt;code>DROP TABLE obs.inference_log_2026wXX&lt;/code> frees space in milliseconds without a prolonged lock. Autovacuum never touches frozen partitions again.&lt;/p>
&lt;p>&lt;strong>Self-referencing &lt;code>parent_id&lt;/code>.&lt;/strong> The user regenerates the response → a new row is inserted with &lt;code>parent_id&lt;/code> pointing at the previous one. That will give us a DPO dataset without touching the UX.&lt;/p>
&lt;h3 id="the-adapter-registry">The adapter registry&lt;/h3>
&lt;div class="highlight">&lt;pre tabindex="0" class="chroma">&lt;code class="language-sql" data-lang="sql">&lt;span class="line">&lt;span class="cl">&lt;span class="k">CREATE&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="k">TABLE&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="n">serve&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="n">adapter&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="n">id&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="nb">TEXT&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="k">PRIMARY&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="k">KEY&lt;/span>&lt;span class="p">,&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="n">base_model&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="nb">TEXT&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="k">NOT&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="k">NULL&lt;/span>&lt;span class="p">,&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="n">rank&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="nb">INT&lt;/span>&lt;span class="p">,&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="n">alpha&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="nb">INT&lt;/span>&lt;span class="p">,&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="n">target_modules&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="n">JSONB&lt;/span>&lt;span class="p">,&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="k">method&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="nb">TEXT&lt;/span>&lt;span class="p">,&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="c1">-- &amp;#39;sft&amp;#39;|&amp;#39;dpo&amp;#39;|&amp;#39;kto&amp;#39;|&amp;#39;orpo&amp;#39;|&amp;#39;simpo&amp;#39;
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="c1">&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="n">training_run_id&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="n">UUID&lt;/span>&lt;span class="p">,&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="n">dataset_snapshot_id&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="n">UUID&lt;/span>&lt;span class="p">,&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="n">weights_uri&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="nb">TEXT&lt;/span>&lt;span class="p">,&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="c1">-- s3://.../v4.2.safetensors
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="c1">&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="n">eval_summary&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="n">JSONB&lt;/span>&lt;span class="p">,&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="n">status&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="nb">TEXT&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="k">NOT&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="k">NULL&lt;/span>&lt;span class="p">,&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="c1">-- &amp;#39;training&amp;#39;|&amp;#39;canary&amp;#39;|&amp;#39;prod&amp;#39;|&amp;#39;retired&amp;#39;
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="c1">&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="n">traffic_pct&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="nb">NUMERIC&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="mi">5&lt;/span>&lt;span class="p">,&lt;/span>&lt;span class="mi">2&lt;/span>&lt;span class="p">)&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="k">DEFAULT&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="mi">0&lt;/span>&lt;span class="p">,&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="n">promoted_at&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="n">TIMESTAMPTZ&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w">&lt;/span>&lt;span class="p">);&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>The vLLM router reads this table with a TTL of a few seconds. An &lt;code>UPDATE serve.adapter SET status='prod', traffic_pct=100 WHERE id='v4.2'&lt;/code> is a promotion. An &lt;code>UPDATE ... SET status='retired'&lt;/code> is a rollback. The audit of who did what and when comes from &lt;code>pg_audit&lt;/code> without writing a single additional line of code.&lt;/p>
&lt;h2 id="generating-dpo-and-kto-datasets-from-real-traffic">Generating DPO and KTO datasets from real traffic&lt;/h2>
&lt;p>This is where the elegance of the schema pays off. The dataset is not a static file: it is a &lt;strong>materialised view&lt;/strong> built with SQL over &lt;code>obs.inference_log&lt;/code>.&lt;/p>
&lt;h3 id="kto-dataset-from-">KTO dataset from 👍/👎&lt;/h3>
&lt;p>KTO is the method that best fits the signal any decent chat product captures. The query:&lt;/p>
&lt;div class="highlight">&lt;pre tabindex="0" class="chroma">&lt;code class="language-sql" data-lang="sql">&lt;span class="line">&lt;span class="cl">&lt;span class="k">CREATE&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="n">MATERIALIZED&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="k">VIEW&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="n">train&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="n">kto_v3_candidate&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="k">AS&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w">&lt;/span>&lt;span class="k">SELECT&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="n">messages&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="k">AS&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="n">prompt&lt;/span>&lt;span class="p">,&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="k">completion&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="k">AS&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="n">response&lt;/span>&lt;span class="p">,&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="k">CASE&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="k">WHEN&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="n">fb_explicit&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="o">&amp;gt;&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="mi">0&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="k">THEN&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="k">true&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="k">ELSE&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="k">false&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="k">END&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="k">AS&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="n">label&lt;/span>&lt;span class="p">,&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="n">adapter_id&lt;/span>&lt;span class="p">,&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="n">created_at&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w">&lt;/span>&lt;span class="k">FROM&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="n">obs&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="n">inference_log&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w">&lt;/span>&lt;span class="k">WHERE&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="n">fb_explicit&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="o">!=&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="mi">0&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="k">AND&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="n">created_at&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="o">&amp;gt;&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="n">now&lt;/span>&lt;span class="p">()&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="o">-&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="nb">interval&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="s1">&amp;#39;60 days&amp;#39;&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="k">AND&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="n">pii_flags&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="o">=&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="mi">0&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="k">AND&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="n">tenant_id&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="k">IN&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="k">SELECT&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="n">id&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="k">FROM&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="n">tenant&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="k">WHERE&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="n">consent_training&lt;/span>&lt;span class="p">);&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>Simple. Every row with explicit feedback becomes an example &lt;code>(prompt, response, desirable_yes_no)&lt;/code>. KTO trains directly on this signal, with no need to build pairs.&lt;/p>
&lt;h3 id="dpo-dataset-from-regenerate">DPO dataset from &amp;ldquo;regenerate&amp;rdquo;&lt;/h3>
&lt;p>The trick that is worth this article on its own. When the user presses &amp;ldquo;regenerate response&amp;rdquo;, they are giving an extraordinarily strong signal: the first response was no good to them. If the second is neither regenerated nor rated negatively, we assume it was. That is a DPO pair without a single extra click in the UI:&lt;/p>
&lt;div class="highlight">&lt;pre tabindex="0" class="chroma">&lt;code class="language-sql" data-lang="sql">&lt;span class="line">&lt;span class="cl">&lt;span class="k">CREATE&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="n">MATERIALIZED&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="k">VIEW&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="n">train&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="n">dpo_v3_candidate&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="k">AS&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w">&lt;/span>&lt;span class="k">SELECT&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="n">rej&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="n">messages&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="k">AS&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="n">prompt&lt;/span>&lt;span class="p">,&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="n">cho&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="k">completion&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="k">AS&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="n">chosen&lt;/span>&lt;span class="p">,&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="n">rej&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="k">completion&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="k">AS&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="n">rejected&lt;/span>&lt;span class="p">,&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="n">rej&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="n">adapter_id&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w">&lt;/span>&lt;span class="k">FROM&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="n">obs&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="n">inference_log&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="n">rej&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w">&lt;/span>&lt;span class="k">JOIN&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="n">obs&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="n">inference_log&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="n">cho&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="k">ON&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="n">cho&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="n">parent_id&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="o">=&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="n">rej&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="n">id&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w">&lt;/span>&lt;span class="k">WHERE&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="n">rej&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="n">fb_regen&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="o">=&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="k">true&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="k">AND&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="n">cho&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="n">fb_explicit&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="o">&amp;gt;=&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="mi">0&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="c1">-- length bias mitigation in classic DPO
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="c1">&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="k">AND&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="n">cho&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="n">tokens_out&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="k">BETWEEN&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="n">rej&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="n">tokens_out&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="o">*&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="mi">0&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="mi">5&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="k">AND&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="n">rej&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="n">tokens_out&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="o">*&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="mi">2&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="mi">5&lt;/span>&lt;span class="p">;&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>The clause on lengths is the cheap cure for the &lt;strong>length bias&lt;/strong> documented in DPO. Without it, the model learns that &amp;ldquo;longer = better&amp;rdquo; because the responses the user accepts tend to be slightly longer. With SimPO or ORPO this filter is optional; with classic DPO it is necessary.&lt;/p>
&lt;h3 id="semantic-deduplication-with-pgvector">Semantic deduplication with pgvector&lt;/h3>
&lt;p>Before training, dedup. Two almost identical prompts in the dataset are noise that biases the model:&lt;/p>
&lt;div class="highlight">&lt;pre tabindex="0" class="chroma">&lt;code class="language-sql" data-lang="sql">&lt;span class="line">&lt;span class="cl">&lt;span class="k">WITH&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="n">ranked&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="k">AS&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="k">SELECT&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="n">id&lt;/span>&lt;span class="p">,&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="n">embedding&lt;/span>&lt;span class="p">,&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="n">row_number&lt;/span>&lt;span class="p">()&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="n">OVER&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="n">PARTITION&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="k">BY&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="n">hashtext&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="n">messages&lt;/span>&lt;span class="p">::&lt;/span>&lt;span class="nb">text&lt;/span>&lt;span class="p">)&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="k">ORDER&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="k">BY&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="n">fb_explicit&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="k">DESC&lt;/span>&lt;span class="p">,&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="n">created_at&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="k">DESC&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="p">)&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="k">AS&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="n">rn&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="k">FROM&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="n">obs&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="n">inference_log&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="k">WHERE&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="n">created_at&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="o">&amp;gt;&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="n">now&lt;/span>&lt;span class="p">()&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="o">-&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="nb">interval&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="s1">&amp;#39;60 days&amp;#39;&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w">&lt;/span>&lt;span class="p">)&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w">&lt;/span>&lt;span class="k">DELETE&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="k">FROM&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="n">train&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="n">kto_v3_candidate&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="n">kto&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w">&lt;/span>&lt;span class="k">USING&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="n">ranked&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="n">r&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w">&lt;/span>&lt;span class="k">WHERE&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="n">r&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="n">rn&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="o">&amp;gt;&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="mi">1&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="k">AND&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="n">kto&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="n">id&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="o">=&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="n">r&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="n">id&lt;/span>&lt;span class="p">;&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>And for the semantic duplicates (paraphrases) we use pgvector 0.8 directly with an &lt;code>iterative index scan&lt;/code>:&lt;/p>
&lt;div class="highlight">&lt;pre tabindex="0" class="chroma">&lt;code class="language-sql" data-lang="sql">&lt;span class="line">&lt;span class="cl">&lt;span class="c1">-- Find near-duplicates of any given example
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="c1">&lt;/span>&lt;span class="k">SELECT&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="n">id&lt;/span>&lt;span class="p">,&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="n">messages&lt;/span>&lt;span class="p">,&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="n">embedding&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="o">&amp;lt;=&amp;gt;&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="err">$&lt;/span>&lt;span class="mi">1&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="k">AS&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="n">dist&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w">&lt;/span>&lt;span class="k">FROM&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="n">obs&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="n">inference_log&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w">&lt;/span>&lt;span class="k">WHERE&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="n">created_at&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="o">&amp;gt;&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="n">now&lt;/span>&lt;span class="p">()&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="o">-&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="nb">interval&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="s1">&amp;#39;60 days&amp;#39;&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="k">AND&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="n">embedding&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="o">&amp;lt;=&amp;gt;&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="err">$&lt;/span>&lt;span class="mi">1&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="o">&amp;lt;&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="mi">0&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="mi">05&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w">&lt;/span>&lt;span class="k">ORDER&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="k">BY&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="n">embedding&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="o">&amp;lt;=&amp;gt;&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="err">$&lt;/span>&lt;span class="mi">1&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w">&lt;/span>&lt;span class="k">LIMIT&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="mi">50&lt;/span>&lt;span class="p">;&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>The &lt;code>iterative scan&lt;/code> is a key improvement in pgvector 0.8: before, the HNSW index could return fewer results than requested when there were additional filters (&lt;code>WHERE&lt;/code>); now it iterates until it meets the limit. Without that improvement, curation queries over datasets of millions of rows were unworkable without a brutal pre-filter.&lt;/p>
&lt;h2 id="eval-gates-three-stages-all-sql">Eval gates: three stages, all SQL&lt;/h2>
&lt;p>The most common mistake when implementing continuous fine-tuning is skipping or watering down the eval gates. That turns the cycle into a roulette wheel. The pattern that works in 2026 is &lt;strong>three stages&lt;/strong>, each with a different latency/coverage trade-off:&lt;/p>
&lt;div class="diagram" style="max-width: 720px; margin: 1.5rem auto;">
&lt;svg viewBox="0 0 720 200" xmlns="http://www.w3.org/2000/svg" role="img" aria-label="Three stages of eval gates">
&lt;style>
.stage { stroke: #333; stroke-width: 1.5; }
.s1 { fill: #d6eaff; }
.s2 { fill: #d9f5d6; }
.s3 { fill: #ffe9d6; }
.lbl { font: 600 13px sans-serif; fill: #222; }
.lbl-sm { font: 11px sans-serif; fill: #444; }
.arr { stroke: #444; stroke-width: 1.6; fill: none; marker-end: url(#ah3); }
&lt;/style>
&lt;defs>
&lt;marker id="ah3" 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="360" y="22" text-anchor="middle" class="lbl">Three stages of eval gates&lt;/text>
&lt;rect x="30" y="50" width="200" height="110" rx="10" class="stage s1"/>
&lt;text x="130" y="78" text-anchor="middle" class="lbl">Stage 1 · PR&lt;/text>
&lt;text x="130" y="98" text-anchor="middle" class="lbl-sm">&amp;lt; 90 seconds&lt;/text>
&lt;text x="130" y="118" text-anchor="middle" class="lbl-sm">schema-lint + prompt-lint&lt;/text>
&lt;text x="130" y="135" text-anchor="middle" class="lbl-sm">+ 50 mini-eval cases&lt;/text>
&lt;rect x="260" y="50" width="200" height="110" rx="10" class="stage s2"/>
&lt;text x="360" y="78" text-anchor="middle" class="lbl">Stage 2 · pre-merge&lt;/text>
&lt;text x="360" y="98" text-anchor="middle" class="lbl-sm">&amp;lt; 20 minutes&lt;/text>
&lt;text x="360" y="118" text-anchor="middle" class="lbl-sm">200-500 golden cases&lt;/text>
&lt;text x="360" y="135" text-anchor="middle" class="lbl-sm">+ LLM-as-judge&lt;/text>
&lt;rect x="490" y="50" width="200" height="110" rx="10" class="stage s3"/>
&lt;text x="590" y="78" text-anchor="middle" class="lbl">Stage 3 · canary&lt;/text>
&lt;text x="590" y="98" text-anchor="middle" class="lbl-sm">24-72 hours&lt;/text>
&lt;text x="590" y="118" text-anchor="middle" class="lbl-sm">1-5 % real traffic&lt;/text>
&lt;text x="590" y="135" text-anchor="middle" class="lbl-sm">online metrics + feedback&lt;/text>
&lt;path class="arr" d="M230,105 L260,105"/>
&lt;path class="arr" d="M460,105 L490,105"/>
&lt;/svg>
&lt;/div>
&lt;p>And this is where Postgres shines again: the promotion gate is expressed as an SQL predicate. Nothing more:&lt;/p>
&lt;div class="highlight">&lt;pre tabindex="0" class="chroma">&lt;code class="language-sql" data-lang="sql">&lt;span class="line">&lt;span class="cl">&lt;span class="k">CREATE&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="k">TABLE&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="n">train&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="n">eval_result&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="n">adapter_id&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="nb">TEXT&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="k">REFERENCES&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="n">serve&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="n">adapter&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="n">id&lt;/span>&lt;span class="p">),&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="n">suite_id&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="nb">TEXT&lt;/span>&lt;span class="p">,&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="c1">-- &amp;#39;safety-es&amp;#39;, &amp;#39;support-helpfulness&amp;#39;
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="c1">&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="n">metric&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="nb">TEXT&lt;/span>&lt;span class="p">,&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="n">score&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="nb">NUMERIC&lt;/span>&lt;span class="p">,&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="n">judge_model&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="nb">TEXT&lt;/span>&lt;span class="p">,&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="n">judged_at&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="n">TIMESTAMPTZ&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="k">DEFAULT&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="n">now&lt;/span>&lt;span class="p">(),&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="k">PRIMARY&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="k">KEY&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="n">adapter_id&lt;/span>&lt;span class="p">,&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="n">suite_id&lt;/span>&lt;span class="p">,&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="n">metric&lt;/span>&lt;span class="p">)&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w">&lt;/span>&lt;span class="p">);&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w">&lt;/span>&lt;span class="k">CREATE&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="k">OR&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="k">REPLACE&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="k">FUNCTION&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="n">serve&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="n">can_promote&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="n">candidate&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="nb">TEXT&lt;/span>&lt;span class="p">,&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="k">current&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="nb">TEXT&lt;/span>&lt;span class="p">)&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w">&lt;/span>&lt;span class="k">RETURNS&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="nb">BOOLEAN&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="k">AS&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="err">$$&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="k">SELECT&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="k">NOT&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="k">EXISTS&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="k">SELECT&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="mi">1&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="k">FROM&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="n">train&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="n">eval_result&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="k">c&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="k">JOIN&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="n">train&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="n">eval_result&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="n">p&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="k">USING&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="n">suite_id&lt;/span>&lt;span class="p">,&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="n">metric&lt;/span>&lt;span class="p">)&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="k">WHERE&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="k">c&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="n">adapter_id&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="o">=&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="n">candidate&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="k">AND&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="n">p&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="n">adapter_id&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="o">=&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="k">current&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="k">AND&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="n">suite_id&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="k">IN&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="s1">&amp;#39;safety-es&amp;#39;&lt;/span>&lt;span class="p">,&lt;/span>&lt;span class="s1">&amp;#39;support-helpfulness&amp;#39;&lt;/span>&lt;span class="p">,&lt;/span>&lt;span class="s1">&amp;#39;refusal-rate&amp;#39;&lt;/span>&lt;span class="p">)&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="k">AND&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="k">c&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="n">score&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="o">&amp;lt;&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="n">p&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="n">score&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="o">*&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="mi">0&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="mi">98&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="c1">-- 2 % tolerance
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="c1">&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="p">);&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w">&lt;/span>&lt;span class="err">$$&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="k">LANGUAGE&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="k">sql&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="k">STABLE&lt;/span>&lt;span class="p">;&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>An SQL function as the gate. Usable from CI with &lt;code>psql -c &amp;quot;SELECT serve.can_promote('v4.2','v4.1')&amp;quot;&lt;/code> and an exit code of 0/1. No orchestrator needed, no specific UI needed. The audit stays in the Postgres log.&lt;/p>
&lt;h2 id="vllm-multi-lora-the-deploy-is-an-http-post">vLLM multi-LoRA: the deploy is an HTTP POST&lt;/h2>
&lt;p>Two years ago, deploying a new fine-tune meant rotating inference pods. Today it is an HTTP call. vLLM 0.7+ supports loading and unloading LoRA adapters &lt;strong>hot&lt;/strong>, keeping several resident in VRAM and choosing the right one per request.&lt;/p>
&lt;p>Server 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-8B-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> --enable-lora &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-loras &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-lora-rank &lt;span class="m">64&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> --env &lt;span class="nv">VLLM_ALLOW_RUNTIME_LORA_UPDATING&lt;/span>&lt;span class="o">=&lt;/span>True
&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>Deploying a new adapter:&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">curl -X POST http://localhost:8000/v1/load_lora_adapter &lt;span class="se">\
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="se">&lt;/span> -H &lt;span class="s2">&amp;#34;Content-Type: application/json&amp;#34;&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> -d &lt;span class="s1">&amp;#39;{
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="s1"> &amp;#34;lora_name&amp;#34;: &amp;#34;support-es-v4.2&amp;#34;,
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="s1"> &amp;#34;lora_path&amp;#34;: &amp;#34;/mnt/adapters/support-es-v4.2&amp;#34;
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="s1"> }&amp;#39;&lt;/span>
&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>From that moment on, requests including &lt;code>&amp;quot;model&amp;quot;: &amp;quot;support-es-v4.2&amp;quot;&lt;/code> are served with that adapter applied on top of the base model. Switching between adapters has negligible latency (the most recent research on Activated LoRA takes this to levels where the switching cost is invisible).&lt;/p>
&lt;p>This changes operations substantially. &lt;strong>Deploying a new fine-tune stops being an infrastructure event and becomes a state change in Postgres&lt;/strong>. The router queries the &lt;code>serve.adapter&lt;/code> table, sees that &lt;code>v4.2&lt;/code> is in &lt;code>canary&lt;/code> with &lt;code>traffic_pct=5&lt;/code>, and directs 5 % of requests to the new adapter. The exact route of that 5 % is decided with deterministic hashing of the &lt;code>user_id&lt;/code> so that the same user always sees the same variant (sticky):&lt;/p>
&lt;div class="highlight">&lt;pre tabindex="0" class="chroma">&lt;code class="language-sql" data-lang="sql">&lt;span class="line">&lt;span class="cl">&lt;span class="c1">-- No assignment table, no additional state
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="c1">-- the variant is computed in-place in SQL or in the router:
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="c1">&lt;/span>&lt;span class="k">SELECT&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="k">CASE&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="k">WHEN&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="n">hashtext&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="err">$&lt;/span>&lt;span class="n">user_id&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="o">||&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="err">$&lt;/span>&lt;span class="n">experiment&lt;/span>&lt;span class="p">)&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="o">%&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="mi">100&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="o">&amp;lt;&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="k">SELECT&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="n">traffic_pct&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="k">FROM&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="n">serve&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="n">adapter&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="k">WHERE&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="n">id&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="o">=&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="err">$&lt;/span>&lt;span class="n">candidate&lt;/span>&lt;span class="p">)&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="k">THEN&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="err">$&lt;/span>&lt;span class="n">candidate&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="k">ELSE&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="err">$&lt;/span>&lt;span class="k">current&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="k">END&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="k">AS&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="n">adapter_id&lt;/span>&lt;span class="p">;&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;h2 id="ab-with-real-traffic-measure-or-live-deceived">A/B with real traffic: measure or live deceived&lt;/h2>
&lt;p>The eval gates measure against fixed benchmarks. That is necessary but insufficient. Reality is only measured with real traffic. Once the adapter is in canary, what matters are the &lt;strong>online metrics&lt;/strong> measured over &lt;code>obs.inference_log&lt;/code> for each variant:&lt;/p>
&lt;div class="highlight">&lt;pre tabindex="0" class="chroma">&lt;code class="language-sql" data-lang="sql">&lt;span class="line">&lt;span class="cl">&lt;span class="k">SELECT&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="n">adapter_id&lt;/span>&lt;span class="p">,&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="k">COUNT&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="o">*&lt;/span>&lt;span class="p">)&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="k">AS&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="n">n&lt;/span>&lt;span class="p">,&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="k">AVG&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="n">fb_explicit&lt;/span>&lt;span class="p">)&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="k">AS&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="n">mean_score&lt;/span>&lt;span class="p">,&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="n">STDDEV&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="n">fb_explicit&lt;/span>&lt;span class="p">)&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="o">/&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="n">SQRT&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="k">COUNT&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="o">*&lt;/span>&lt;span class="p">))&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="k">AS&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="n">sem&lt;/span>&lt;span class="p">,&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="k">AVG&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="n">ttft_ms&lt;/span>&lt;span class="p">)&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="k">AS&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="n">ttft_avg&lt;/span>&lt;span class="p">,&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="n">percentile_cont&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="mi">0&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="mi">5&lt;/span>&lt;span class="p">)&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="n">WITHIN&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="k">GROUP&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="k">ORDER&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="k">BY&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="n">ttft_ms&lt;/span>&lt;span class="p">)&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="k">AS&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="n">ttft_p50&lt;/span>&lt;span class="p">,&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="n">percentile_cont&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="mi">0&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="mi">95&lt;/span>&lt;span class="p">)&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="n">WITHIN&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="k">GROUP&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="k">ORDER&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="k">BY&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="n">ttft_ms&lt;/span>&lt;span class="p">)&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="k">AS&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="n">ttft_p95&lt;/span>&lt;span class="p">,&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="k">AVG&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="k">CASE&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="k">WHEN&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="n">fb_regen&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="k">THEN&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="mi">1&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="k">ELSE&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="mi">0&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="k">END&lt;/span>&lt;span class="p">)&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="k">AS&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="n">regen_rate&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w">&lt;/span>&lt;span class="k">FROM&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="n">obs&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="n">inference_log&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w">&lt;/span>&lt;span class="k">WHERE&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="n">experiment&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="o">=&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="err">$&lt;/span>&lt;span class="mi">1&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="k">AND&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="n">created_at&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="o">&amp;gt;&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="n">now&lt;/span>&lt;span class="p">()&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="o">-&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="nb">interval&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="s1">&amp;#39;7 days&amp;#39;&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w">&lt;/span>&lt;span class="k">GROUP&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="k">BY&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="n">adapter_id&lt;/span>&lt;span class="p">;&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>What to look at: explicit feedback, latency (TTFT, p50, p95), regeneration rate. An adapter that raises the mean feedback but also raises the regeneration rate is suspicious, probably it is responding in a flashier but less useful way. An adapter that lowers latency but lowers feedback may be worth studying: it may be being more concise than it should.&lt;/p>
&lt;p>Promotion to &lt;code>prod&lt;/code> happens when, after 24-72 hours in canary, the candidate adapter beats the current one on at least one key metric without degrading the others. Again: it is an &lt;code>UPDATE&lt;/code> in Postgres.&lt;/p>
&lt;h2 id="applied-to-typical-on-premise-hardware">Applied to typical on-premise hardware&lt;/h2>
&lt;p>Let us come down to two representative configurations, one for iteration and one for production.&lt;/p>
&lt;h3 id="case-1--rtx-4090-24-gb-for-development-iteration">Case 1 — RTX 4090 (24 GB) for development iteration&lt;/h3>
&lt;p>An RTX 4090 with 4-bit QLoRA can train adapters on an 8B model without trouble. The VRAM budget combines four components; the &lt;a href="https://blog.lo0.es/en/posts/kv-cache-working-memory-llm-inference/">KV cache&lt;/a> during the intermediate evaluations is not negligible and deserves an explicit margin:&lt;/p>
&lt;pre tabindex="0">&lt;code>8B base model in 4-bit: ~5 GB
Activations + gradients: ~8 GB (depends on batch and context)
Optimizer state (LoRA r=16): ~0.5 GB
KV cache during eval: ~2 GB
Safety margin: ~8 GB
&lt;/code>&lt;/pre>&lt;p>Typical times (an estimate based on community benchmarks; measure in the lab before promising anything):&lt;/p>
&lt;table>
&lt;thead>
&lt;tr>
&lt;th>Dataset&lt;/th>
&lt;th>Technique&lt;/th>
&lt;th style="text-align:right">Adapter rank&lt;/th>
&lt;th style="text-align:right">Approximate time&lt;/th>
&lt;/tr>
&lt;/thead>
&lt;tbody>
&lt;tr>
&lt;td>1,000 SFT examples&lt;/td>
&lt;td>LoRA r=16&lt;/td>
&lt;td style="text-align:right">16&lt;/td>
&lt;td style="text-align:right">20-40 min&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>5,000 SFT examples&lt;/td>
&lt;td>LoRA r=32&lt;/td>
&lt;td style="text-align:right">32&lt;/td>
&lt;td style="text-align:right">2-4 h&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>2,000 DPO pairs&lt;/td>
&lt;td>LoRA r=16&lt;/td>
&lt;td style="text-align:right">16&lt;/td>
&lt;td style="text-align:right">1-2 h&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>5,000 KTO examples&lt;/td>
&lt;td>LoRA r=32&lt;/td>
&lt;td style="text-align:right">32&lt;/td>
&lt;td style="text-align:right">3-5 h&lt;/td>
&lt;/tr>
&lt;/tbody>
&lt;/table>
&lt;p>This puts the iteration cycle, a change in the dataset, retrain, eval, look at the number, in the range of a &lt;strong>working day&lt;/strong>. Enough to validate a hypothesis before moving anything to the production cluster.&lt;/p>
&lt;h3 id="case-2--4h100-sxm-cluster-320-gb-nvlink-for-production">Case 2 — 4×H100 SXM cluster (320 GB, NVLink) for production&lt;/h3>
&lt;p>With a cluster of this order the whole picture changes. You can:&lt;/p>
&lt;ul>
&lt;li>Train &lt;strong>LoRA on a 70B in BF16 with no quantisation&lt;/strong> with tensor parallel = 4.&lt;/li>
&lt;li>Do &lt;strong>full DPO with a resident reference model&lt;/strong> when the reference is quantised to FP8, or move to &lt;strong>SimPO / ORPO&lt;/strong>, which remove that intermediate model and simplify VRAM planning (see the table of techniques above).&lt;/li>
&lt;li>Support &lt;strong>multi-tenant fine-tuning&lt;/strong>: several customer adapters training in parallel in separate logical pipelines, each isolated in a different Postgres partition with its own ACLs.&lt;/li>
&lt;li>Serve &lt;strong>multi-LoRA with &lt;code>--max-loras 8&lt;/code>&lt;/strong> on top of the base model without concurrency lowering throughput perceptibly.&lt;/li>
&lt;/ul>
&lt;p>The practical budgeting rule: over a 12-month horizon, a team with this cluster can run &lt;strong>~150-200 continuous fine-tuning cycles&lt;/strong> (training + eval + canary + promotion or discard) if the discipline around the dataset and the eval gates is strict. If it is not, it will run twice as many but with half the usefulness.&lt;/p>
&lt;h2 id="position-within-the-architecture-what-this-article-covers-and-what-it-does-not">Position within the architecture: what this article covers and what it does not&lt;/h2>
&lt;p>To place the scope: the cycle drawn at the start has seven boxes, all of them covered here in their mechanics. Three cross-cutting layers are &lt;strong>deliberately left out&lt;/strong>, and they are the ones that end up separating a pipeline that works technically from one that survives an audit:&lt;/p>
&lt;ol>
&lt;li>&lt;strong>Cryptographic provenance and traceability.&lt;/strong> We have mentioned &lt;code>dataset_snapshot&lt;/code> and &lt;code>pg_audit&lt;/code>, but the complete mechanics, the &lt;code>set_hash&lt;/code> over the examples, the integration with the EU AI Act, the frozen &lt;code>query_sql&lt;/code> as proof of what trained the model, is enough for a whole analysis.&lt;/li>
&lt;li>&lt;strong>Judge calibration.&lt;/strong> We have assumed LLM-as-judge works. It needs calibrating against a human rubric on at least 100 cases per critical suite before trusting it. Without that calibration, the eval gates are theatre.&lt;/li>
&lt;li>&lt;strong>The forgetting problem.&lt;/strong> What happens if a user exercises their GDPR right to be forgotten and their interactions were part of the dataset of an adapter already in production? There is no clean solution. There are options, incremental retrain, sample-level machine unlearning, negative LoRA, and it is worth knowing them before a customer asks.&lt;/li>
&lt;/ol>
&lt;h2 id="what-we-have-not-covered-upcoming-articles">What we have not covered (upcoming articles)&lt;/h2>
&lt;ul>
&lt;li>&lt;strong>Cryptographic provenance on Postgres&lt;/strong>: how &lt;code>set_hash&lt;/code> and a frozen &lt;code>query_sql&lt;/code> compose an auditable chain of custody under the EU AI Act.&lt;/li>
&lt;li>&lt;strong>Honest judge calibration&lt;/strong>: why &lt;code>score &amp;gt; 0.85&lt;/code> means nothing without a human baseline, and how to build that baseline without it costing a month of work.&lt;/li>
&lt;li>&lt;strong>The forgetting problem in adapters&lt;/strong>: sample-level machine unlearning, incremental retrain and other techniques for answering GDPR without throwing the adapter away.&lt;/li>
&lt;li>&lt;strong>Online DPO and on-policy continual learning&lt;/strong>: the state of 2026 research (Fast-Slow Chasing, RLOO, iterative on-policy) and why it is not production yet.&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/six-stage-llmops-pipeline-architecture-and-deep-dive/">The six-stage LLMOps pipeline&lt;/a> — the master map the Tune and Retrain stages belong to. This post is the operational deep-dive of that cycle.&lt;/li>
&lt;li>&lt;a href="https://blog.lo0.es/en/posts/mlops-for-llms-landscape-2026/">LLM-specific MLOps in 2026: the landscape&lt;/a> — the opening of the series. It places continuous fine-tuning within the framework of three modalities (fine-tuning, RAG, agents) and seven structural differences from classic MLOps.&lt;/li>
&lt;li>&lt;a href="https://blog.lo0.es/en/posts/postgresql-qdrant-rag-ingestion-postman-syncing-two-worlds/">PostgreSQL + Qdrant in the ingestion stage&lt;/a> — the Data stage that feeds this cycle: how events enter Postgres and embeddings enter Qdrant, which this post would then curate as a dataset.&lt;/li>
&lt;li>&lt;a href="https://blog.lo0.es/en/posts/evals-llm-layer-after-tracing/">Evals: the layer after tracing&lt;/a> — the Eval stage of the pipeline. The eval gates described here as SQL predicates are the materialisation of that post&amp;rsquo;s generic framework.&lt;/li>
&lt;li>&lt;a href="https://blog.lo0.es/en/posts/gpu-cluster-as-multi-tenant-platform/">The GPU cluster as a multi-tenant platform&lt;/a> — the Deploy stage where the multi-LoRA hot-swap described here coexists with quotas, gateway and isolation.&lt;/li>
&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> — the fundamentals of the cache that comes into play in every intermediate eval of the training and in every deployment of the resulting adapter.&lt;/li>
&lt;li>&lt;a href="https://blog.lo0.es/en/posts/pagedattention-vllm-block-manager/">PagedAttention from the inside&lt;/a> — the mechanics of the KV cache and the landscape of optimisations (vAttention, EvicPress, RadixAttention) that hold up the throughput of the intermediate evals in the fine-tuning pipeline.&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 serving pattern the multi-LoRA hot-swap described here connects to: each specialised pod loads its adapters separately.&lt;/li>
&lt;li>&lt;a href="https://blog.lo0.es/en/posts/data-versioning-llmops-dvc-lakefs-reproducible-golden-dataset/">Data versioning for LLMOps: DVC, lakeFS and a reproducible golden dataset&lt;/a> — the opposite position. This post defends a minimalist stack (Postgres + pgvector + S3) with no DVC/lakeFS; the other explains when the line is crossed and why.&lt;/li>
&lt;li>&lt;a href="https://blog.lo0.es/en/posts/modern-alignment-dpo-kto-orpo-simpo/">Modern alignment: DPO, KTO, ORPO and SimPO&lt;/a> — the mathematical foundations behind each of the preference optimisation methods this post uses operationally. Derivation of DPO from RLHF, a numerical example, biases.&lt;/li>
&lt;li>&lt;a href="https://blog.lo0.es/en/posts/llm-as-judge-exam-marker-not-oracle/">LLM-as-judge: the exam marker that evaluates other models&lt;/a> — the mechanism that generates the &lt;code>(chosen, rejected)&lt;/code> pairs that then feed DPO/SimPO/ORPO in this cycle.&lt;/li>
&lt;li>&lt;a href="https://blog.lo0.es/en/posts/quantization-llm-inference-fp8-int4-gguf/">Quantisation for LLM inference: FP8, INT4 and GGUF&lt;/a> — the QLoRA with NF4 that holds up the training on an RTX 4090 is the quantisation family seen in detail there. And the DPO reference model is quantised with the same formats to free VRAM.&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: the single translator with a thousand glossaries&lt;/a> — the other side of the loop. This post trains the adapters; multi-LoRA serving (SGMV, S-LoRA, vLLM/LoRAX) is what puts hundreds of them to work concurrently against a single base without replicating the base.&lt;/li>
&lt;/ul>
&lt;h2 id="references">References&lt;/h2>
&lt;ul>
&lt;li>Hu et al., &lt;em>LoRA: Low-Rank Adaptation of Large Language Models&lt;/em> (ICLR 2022).&lt;/li>
&lt;li>Dettmers et al., &lt;em>QLoRA: Efficient Finetuning of Quantized LLMs&lt;/em> (NeurIPS 2023).&lt;/li>
&lt;li>Rafailov et al., &lt;em>Direct Preference Optimization: Your Language Model is Secretly a Reward Model&lt;/em> (NeurIPS 2023).&lt;/li>
&lt;li>Meng, Xia, Chen, &lt;em>SimPO: Simple Preference Optimization with a Reference-Free Reward&lt;/em> (NeurIPS 2024).&lt;/li>
&lt;li>Hong et al., &lt;em>ORPO: Monolithic Preference Optimization without Reference Model&lt;/em> (2024).&lt;/li>
&lt;li>Ethayarajh et al., &lt;em>KTO: Model Alignment as Prospect Theoretic Optimization&lt;/em> (2024).&lt;/li>
&lt;li>Kwon et al., &lt;em>Efficient Memory Management for Large Language Model Serving with PagedAttention&lt;/em> (SOSP 2023) — original vLLM.&lt;/li>
&lt;li>Official vLLM Multi-LoRA documentation: &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>Official pgvector 0.8 documentation: &lt;a href="https://github.com/pgvector/pgvector">https://github.com/pgvector/pgvector&lt;/a>.&lt;/li>
&lt;li>TRL (HuggingFace) docs: &lt;a href="https://huggingface.co/docs/trl">https://huggingface.co/docs/trl&lt;/a>.&lt;/li>
&lt;li>EU AI Act, consolidated text and application timeline: &lt;a href="https://artificialintelligenceact.eu/">https://artificialintelligenceact.eu/&lt;/a>.&lt;/li>
&lt;/ul></description></item><item><title>eBPF in local inference and statistical drift detection: closing the LLM observability loop in 2026</title><link>https://blog.lo0.es/en/posts/ebpf-local-inference-statistical-drift-detection/</link><pubDate>Wed, 20 May 2026 09:00:00 +0200</pubDate><guid>https://blog.lo0.es/en/posts/ebpf-local-inference-statistical-drift-detection/</guid><description>&lt;h2 id="tldr">TL;DR&lt;/h2>
&lt;p>Tracing, evals, guardrails, MCP observability: the layers we have already covered see what is happening right now. What they do not see is what changes silently: the agent that answered well last week and this week, without anyone touching anything, answers slightly worse. What they also do not see is the fine mechanics of local inference: why a llama.cpp on an edge device takes 200 ms when it should take 100, which specific runtime function is the bottleneck. This post closes the two series of the week with the two layers that were missing: eBPF applied to local inference (uprobes on &lt;code>llama.cpp&lt;/code>, &lt;code>vLLM&lt;/code>, &lt;code>libcudart.so&lt;/code>, integrated hardware perf counters, with &amp;lt;4% overhead, formalised in the &lt;a href="https://arxiv.org/abs/2601.20755">ProfInfer 2026&lt;/a> paper, which is to local inference what Hubble is to the network) and statistical analysis of agent flows to detect drift before your user notices it (KS, PSI, MMD, embedding-space clustering, with &lt;a href="https://www.evidentlyai.com/">Evidently AI&lt;/a>, &lt;a href="https://www.nannyml.com/">NannyML&lt;/a> and &lt;a href="https://whylabs.ai/">WhyLabs&lt;/a> as the dominant tools). The three types of LLM drift in 2026, prompt drift, model drift and eval-score drift, demand different tests. The complete stack (tracing, evals, guardrails, MCP observability, eBPF observability, drift detection) forms the loop that any serious agentic system needs in order to operate with a real SLA rather than with hope.&lt;/p>
&lt;blockquote>
&lt;p>This post closes two series: the post-tracing series (&lt;a href="https://blog.lo0.es/en/posts/evals-llm-layer-after-tracing/">Evals&lt;/a>, &lt;a href="https://blog.lo0.es/en/posts/llm-guardrails-safety-four-lines-of-defence/">Guardrails&lt;/a>, &lt;a href="https://blog.lo0.es/en/posts/mcp-deep-observability-opentelemetry/">MCP observability&lt;/a>) and the eBPF series (&lt;a href="https://blog.lo0.es/en/posts/ebpf-zero-to-cilium-kernel-tcp-ip-bypass/">eBPF from zero to Cilium&lt;/a>, &lt;a href="https://blog.lo0.es/en/posts/tetragon-cilium-security-syscalls-kernel/">Tetragon&lt;/a>, &lt;a href="https://blog.lo0.es/en/posts/hubble-ebpf-network-observability-ai-agents/">Hubble&lt;/a>, &lt;a href="https://blog.lo0.es/en/posts/agentsight-llm-tracing-ebpf-zero-instrumentation/">AgentSight&lt;/a>). It brings the two threads together: eBPF applied to the local inference engine plus statistical analysis of the flows that every layer produces.&lt;/p>
&lt;/blockquote>
&lt;h2 id="the-analogy-the-agents-cardiogram">The analogy: the agent&amp;rsquo;s cardiogram&lt;/h2>
&lt;p>A doctor who only looks at acute symptoms, the patient arrives with a high fever and you have to act, is practising reactive medicine. To practise preventive medicine, they need time series: blood pressure every year, cholesterol every six months, an ECG when there is a suspicion. It is not &amp;ldquo;right now&amp;rdquo; information, it is information about how something that should be stable is evolving. When a time series deviates from its baseline, you investigate before it turns into a high fever.&lt;/p>
&lt;p>The LLM observability layers we have seen so far are reactive medicine: tracing tells you what happened in a specific conversation; evals tell you whether that conversation was good; guardrails tell you whether there was a specific threat; MCP observability tells you which tools were invoked and how. They all look at events, not trends.&lt;/p>
&lt;p>Drift detection is preventive medicine. It looks at time series, of prompt embeddings, of evaluation scores, of distributions of generated tokens, and fires alerts when something drifts away from its normality. It does not tell you &amp;ldquo;this answer is bad&amp;rdquo;; it tells you &amp;ldquo;the distribution of prompts over the last 6 hours does not look like the distribution of the last month&amp;rdquo;. There you decide whether to investigate.&lt;/p>
&lt;p>And the other half of the post, eBPF in local inference, is the equivalent of the MRI scanner: once you know there is a problem, it lets you see the inside of the model at a resolution no external wrapper gives you. To see which specific runtime function is slow, which CUDA kernel is the bottleneck, how tokens move through the internal buffers before going out to the client.&lt;/p>
&lt;p>The two together close the loop: the time series detect that something is wrong, the scanner locates where.&lt;/p>
&lt;h2 id="part-1--ebpf-applied-to-local-inference">Part 1 — eBPF applied to local inference&lt;/h2>
&lt;h3 id="why-local-inference-changes-the-game">Why local inference changes the game&lt;/h3>
&lt;p>When the LLM runs locally, vLLM on a Kubernetes node, llama.cpp on an edge device, Ollama on a workstation, MLX on macOS, and not behind an external API, observability changes shape:&lt;/p>
&lt;ul>
&lt;li>&lt;strong>You control the binary&lt;/strong>: you can attach hooks that would otherwise be impossible.&lt;/li>
&lt;li>&lt;strong>The internal buffers exist in accessible RAM&lt;/strong>: the output-token stream, the logits, the KV caches, the scheduler structures are right there, at addresses a uprobe can read.&lt;/li>
&lt;li>&lt;strong>There is no cable to sniff&lt;/strong>: AgentSight&amp;rsquo;s analogy with SSL hooks does not apply because there is no TLS; the model answers you with an in-process function return, not with an HTTPS response.&lt;/li>
&lt;li>&lt;strong>The distance between kernel and model is minimal&lt;/strong>: the CUDA kernels running attention are one syscall deep; eBPF can observe both sides of that boundary with the same tracer.&lt;/li>
&lt;/ul>
&lt;p>This opens up a class of observability that is structurally impossible with LLM-as-a-service (the Anthropic, OpenAI or Vertex APIs). For apps serving inference on-premise or on-edge, an inference cluster, a mobile device, an RTX 4090 server in the rack, it is a new layer.&lt;/p>
&lt;h3 id="profinfer-the-paper-that-formalises-the-pattern">ProfInfer: the paper that formalises the pattern&lt;/h3>
&lt;p>&lt;a href="https://arxiv.org/abs/2601.20755">ProfInfer (arxiv 2601.20755, 2026)&lt;/a> is the reference academic piece that systematises what the ecosystem had been doing ad-hoc. The paper&amp;rsquo;s subtitle says it all: &lt;em>An eBPF-based Fine-Grained LLM Inference Profiler&lt;/em>.&lt;/p>
&lt;p>What it proposes:&lt;/p>
&lt;ul>
&lt;li>&lt;strong>Attaching uprobes dynamically&lt;/strong> to runtime functions of engines such as &lt;code>llama.cpp&lt;/code> (and by extension vLLM, Ollama). No recompiling, no modifying the source code. It is like &lt;code>bpftrace&lt;/code> for LLM inference.&lt;/li>
&lt;li>&lt;strong>Combining runtime events with hardware performance counters&lt;/strong>. A uprobe tells you when &lt;code>llama_decode&lt;/code> runs; a hardware counter tells you how many floating-point instructions executed while it was inside. The correlation between the two is what gives the fine resolution.&lt;/li>
&lt;li>&lt;strong>&amp;lt;4% measured overhead&lt;/strong> under real workloads. That is a production-grade cost.&lt;/li>
&lt;li>&lt;strong>Visualisations&lt;/strong> in three views: operators (which tensor operations ran), graphs (how they relate) and timelines (when).&lt;/li>
&lt;/ul>
&lt;p>The paper focuses particularly on models on mobile platforms (Llama served on a Pixel or an iPhone), where classic observability with Prometheus and exported metrics barely exists. But the pattern applies to any local inference.&lt;/p>
&lt;h3 id="where-to-hook-the-map-by-engine">Where to hook: the map by engine&lt;/h3>
&lt;p>Now to the detail of the hooks. The target functions vary by engine:&lt;/p>
&lt;h4 id="llamacpp">llama.cpp&lt;/h4>
&lt;p>&lt;code>llama.cpp&lt;/code> is pure C++, with symbols visible in the binary. The typical hooks:&lt;/p>
&lt;ul>
&lt;li>&lt;code>llama_decode&lt;/code>: the function that runs one inference pass (processes the current batch). Spans for latency per iteration, tokens processed.&lt;/li>
&lt;li>&lt;code>llama_token_to_piece&lt;/code>: converts a token ID to text. A hook here captures the stream of generated tokens before returning to the caller. It is the local equivalent of the SSL uprobes: you see the model&amp;rsquo;s output before it even reaches the consumer.&lt;/li>
&lt;li>&lt;code>llama_get_logits&lt;/code>: reads the logits of the last decode. If you want to record the model&amp;rsquo;s probabilities (not just the chosen token), here.&lt;/li>
&lt;li>&lt;code>ggml_compute_forward_*&lt;/code> (several functions): the operation kernels (matmul, attention, layernorm). Hooks for per-operation profiling.&lt;/li>
&lt;li>&lt;code>ggml_backend_*&lt;/code>: the backend functions (CPU, Metal, CUDA, ROCm). Hooks here break down the cost per device.&lt;/li>
&lt;/ul>
&lt;p>An example with &lt;code>bpftrace&lt;/code>:&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">&lt;span class="c1"># Latency and count of llama_decode&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">bpftrace -e &lt;span class="s1">&amp;#39;
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="s1">uprobe:/path/to/llama-server:llama_decode {
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="s1"> @start[tid] = nsecs;
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="s1">}
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="s1">uretprobe:/path/to/llama-server:llama_decode /@start[tid]/ {
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="s1"> @decode_lat = hist((nsecs - @start[tid]) / 1000);
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="s1"> delete(@start[tid]);
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="s1">}
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="s1">&amp;#39;&lt;/span>
&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>Output: a histogram of decode latencies in microseconds. Zero modification to the binary.&lt;/p>
&lt;h4 id="vllm">vLLM&lt;/h4>
&lt;p>vLLM is mostly Python. The C/CUDA symbols live in its native extensions (&lt;code>vllm._C&lt;/code>, &lt;code>vllm._moe_C&lt;/code>). The typical hooks:&lt;/p>
&lt;ul>
&lt;li>&lt;strong>uprobes on &lt;code>vllm._C.*&lt;/code>&lt;/strong> for custom operators (the paged attention kernel, the sampling kernel).&lt;/li>
&lt;li>&lt;strong>uprobes on &lt;code>libcudart.so&lt;/code> and &lt;code>libcuda.so&lt;/code>&lt;/strong> to capture &lt;code>cudaMalloc&lt;/code>, &lt;code>cudaLaunchKernel&lt;/code>, &lt;code>cudaMemcpy&lt;/code>. This maps the cost of host↔device transfers and kernel launches.&lt;/li>
&lt;li>&lt;strong>Python tracepoints with &lt;code>bpftrace&lt;/code> over &lt;code>usdt&lt;/code> points&lt;/strong>: vLLM does not expose native static tracepoints, but they can be placed with USDT (&lt;code>dtrace&lt;/code> style) at strategic points in the scheduler.&lt;/li>
&lt;/ul>
&lt;p>vLLM also exposes native Prometheus metrics (&lt;code>vllm:num_requests_running&lt;/code>, &lt;code>vllm:gpu_cache_usage_perc&lt;/code>, and so on). The added value of the eBPF approach is going down from the scheduler&amp;rsquo;s metrics to the individual functions: when a request is slow, seeing whether it was prefill, decode, scheduler overhead, transfer or synchronisation.&lt;/p>
&lt;h4 id="cuda-in-general">CUDA in general&lt;/h4>
&lt;p>Independently of the engine, uprobes on &lt;code>libcudart.so&lt;/code> capture all the CUDA activity of the process:&lt;/p>
&lt;ul>
&lt;li>&lt;code>cudaMalloc(size)&lt;/code> → tracking of device memory allocations.&lt;/li>
&lt;li>&lt;code>cudaLaunchKernel(func, ...)&lt;/code> → spans for each kernel launch.&lt;/li>
&lt;li>&lt;code>cudaMemcpyAsync(dst, src, size, kind)&lt;/code> → host↔device transfers.&lt;/li>
&lt;li>&lt;code>cudaStreamSynchronize(stream)&lt;/code> → synchronisation points (where the host waits for the device).&lt;/li>
&lt;/ul>
&lt;p>This gives you a complete timeline of CUDA activity without needing NVIDIA Nsight Systems (which is excellent but heavy and oriented towards development, not continuous production).&lt;/p>
&lt;h3 id="hardware-counters-the-other-half">Hardware counters: the other half&lt;/h3>
&lt;p>eBPF can read performance counters from the CPU/GPU&amp;rsquo;s PMU (Performance Monitoring Unit). That includes instructions executed, cache misses, branch mispredictions and, on GPUs with support, FLOPS, SM occupancy, HBM bandwidth.&lt;/p>
&lt;p>Combining:&lt;/p>
&lt;ul>
&lt;li>uprobe: &amp;ldquo;&lt;code>llama_decode&lt;/code> ran from T1 to T2 with tokens=4&amp;rdquo;.&lt;/li>
&lt;li>perf counter: &amp;ldquo;during that window, L2 cache misses = 15,000, instructions = 2.3 million&amp;rdquo;.&lt;/li>
&lt;/ul>
&lt;p>This lets you answer: why is it slow? Is it memory-bound (many cache misses), compute-bound (all the instructions in the FPU), bandwidth-bound (a lot of data movement)? State of the art for professional profiling.&lt;/p>
&lt;h3 id="comparison-with-agentsight">Comparison with AgentSight&lt;/h3>
&lt;p>There are two eBPF products for LLMs today with different focuses:&lt;/p>
&lt;ul>
&lt;li>&lt;strong>&lt;a href="https://blog.lo0.es/en/posts/agentsight-llm-tracing-ebpf-zero-instrumentation/">AgentSight&lt;/a>&lt;/strong> (covered in the eBPF series): observes agents that call external APIs. It hooks SSL to see the plaintext of HTTPS calls to the remote LLM, plus stdio for local MCP servers. The client view.&lt;/li>
&lt;li>&lt;strong>ProfInfer / the eBPF-on-local-inference pattern&lt;/strong>: observes the engine that runs the model locally. It hooks the engine&amp;rsquo;s internal functions (llama.cpp, vLLM) and the CUDA layer. The server (internal) view.&lt;/li>
&lt;/ul>
&lt;p>They are complementary. If your agent uses the Claude API plus your own local vLLM with Llama 3 for specific tasks, AgentSight sees the first, eBPF/ProfInfer sees the second. If everything is local, clearly the domain of the second. If everything is an external API, of the first.&lt;/p>
&lt;h3 id="use-cases-for-ebpf-in-local-inference">Use cases for eBPF in local inference&lt;/h3>
&lt;p>Three cases where it is the right tool:&lt;/p>
&lt;p>&lt;strong>Fine-grained profiling for optimisation&lt;/strong>: your vLLM takes 50ms more per token than expected. With eBPF plus hardware counters you pin down which specific kernel it is. Previously this required Nsight Systems in a development session; now it is continuous in production.&lt;/p>
&lt;p>&lt;strong>Token-level observability without modifying the engine&lt;/strong>: capturing the stream of generated tokens before returning them to the client. Useful for auditing, for drift detection over the outputs, for local tracing without going through wrapper instrumentation.&lt;/p>
&lt;p>&lt;strong>Detecting specific degradation&lt;/strong>: a new version of vLLM introduces a subtle regression in paged attention. With perf counter baselines, you detect the change even if the external metric (tokens/sec) looks the same.&lt;/p>
&lt;h2 id="part-2--statistical-analysis-of-flows-detecting-drift">Part 2 — Statistical analysis of flows: detecting drift&lt;/h2>
&lt;p>Now to the other side of the problem: the time series.&lt;/p>
&lt;h3 id="why-tracing-evals-and-guardrails-do-not-detect-drift">Why tracing, evals and guardrails do not detect drift&lt;/h3>
&lt;p>The layers we have already seen operate on individual events:&lt;/p>
&lt;ul>
&lt;li>Tracing: a trace of one conversation.&lt;/li>
&lt;li>Evals: a score for one answer.&lt;/li>
&lt;li>Guardrails: a verdict on one prompt or answer.&lt;/li>
&lt;li>MCP observability: spans of one tool invocation.&lt;/li>
&lt;/ul>
&lt;p>Each one answers a point-in-time question (&amp;ldquo;is this all right?&amp;rdquo;). None of them answers the question of evolution (&amp;ldquo;is something changing over time?&amp;rdquo;).&lt;/p>
&lt;p>The operational problem: drift is invisible in individual events. If the mean eval score drops from 0.92 to 0.85 over three weeks, no individual evaluation will raise an alarm, since they all continue to be &amp;ldquo;reasonable&amp;rdquo;. What changes is the distribution. And that only shows up when you look at many evaluations aggregated over time.&lt;/p>
&lt;h3 id="the-three-types-of-llm-drift-in-2026">The three types of LLM drift in 2026&lt;/h3>
&lt;p>&lt;a href="https://futureagi.com/blog/what-is-llm-drift-2026">FutureAGI&lt;/a> consolidates them like this, and the industry is converging on this vocabulary:&lt;/p>
&lt;p>&lt;strong>1. Prompt drift&lt;/strong>: someone updates the system prompt and the side effects break cases that used to work. Almost always intentional, but with unanticipated consequences. Detection: comparing response distributions before and after the change, monitoring eval scores per prompt version (linked in Langfuse, see the &lt;a href="https://blog.lo0.es/en/posts/agentsight-llm-tracing-ebpf-zero-instrumentation/">AgentSight&lt;/a> post where we covered prompt management).&lt;/p>
&lt;p>&lt;strong>2. Model drift&lt;/strong>: the provider (OpenAI, Anthropic) updates the model without warning. The same prompt produces answers with a slightly different tone, similar but different quality, or degradation on some subset. Detection: comparing today&amp;rsquo;s response embeddings with the baseline; monitoring rubric scores; alerting if intra-model variance grows.&lt;/p>
&lt;p>&lt;strong>3. Eval-score drift&lt;/strong>: the rolling mean of your eval metrics (faithfulness, answer relevancy, custom rubrics) trends downwards. The root cause can be any of the above or a change in the user mix. Detection: alerts on the trends of the eval series.&lt;/p>
&lt;p>To these three you can add a fourth, subtler one:&lt;/p>
&lt;p>&lt;strong>4. Persona drift / user mix shift&lt;/strong>: the population of users using the system changes. It is not that the model or the prompt got worse; it is that the new users ask different questions and the system, while still just as good at what it was good at, fails on the new material. Detection: embedding clustering of prompts, monitoring the appearance of new clusters or the growth of a minority one.&lt;/p>
&lt;h3 id="the-key-technical-concept-embedding-space-shift">The key technical concept: embedding-space shift&lt;/h3>
&lt;p>&lt;a href="https://stackpulsar.com/blog/llm-model-drift-detection/">Stack Pulsar&lt;/a> puts it plainly: in LLMs, drift is best measured in embedding space. Classic distances in token space do not capture fine semantics; in embedding space they do.&lt;/p>
&lt;p>The canonical pipeline:&lt;/p>
&lt;ol>
&lt;li>&lt;strong>Establish a baseline&lt;/strong>: during a stable period (say the first two weeks after a release), capture a large sample of prompt and response embeddings.&lt;/li>
&lt;li>&lt;strong>Continuous monitoring&lt;/strong>: every hour or every day, capture a new sample of production traffic.&lt;/li>
&lt;li>&lt;strong>Compare distributions&lt;/strong>: apply a statistical test that compares the current distribution with the baseline in embedding space.&lt;/li>
&lt;li>&lt;strong>Alert&lt;/strong>: if the divergence exceeds a threshold, fire an alert and an investigation workflow.&lt;/li>
&lt;/ol>
&lt;p>As a bonus, monitor clusters: if your baseline has 5 clusters of prompts (technical questions, general support, sales, and so on) and suddenly a sixth cluster appears that was not there, the most likely explanation is that a new user segment has arrived.&lt;/p>
&lt;h3 id="statistical-tests-ks-psi-mmd">Statistical tests: KS, PSI, MMD&lt;/h3>
&lt;p>Three tests that any drift system uses, each with its place:&lt;/p>
&lt;p>&lt;strong>Kolmogorov-Smirnov (KS)&lt;/strong>: non-parametric. It computes the maximum distance between two empirical CDFs. It returns a statistic and a p-value. Advantage: very sensitive to subtle changes, especially in the tails. Disadvantage: with large datasets it is &amp;ldquo;too sensitive&amp;rdquo;, firing alarms for changes that are real but clinically irrelevant.&lt;/p>
&lt;p>&lt;strong>Population Stability Index (PSI)&lt;/strong>: you bin the reference distribution and the current one, then sum &lt;code>(p_actual - p_ref) × log(p_actual / p_ref)&lt;/code> over the bins. The canonical interpretation: PSI &amp;lt; 0.1 stable, 0.1-0.25 mild drift, &amp;gt; 0.25 significant drift. Advantage: interpretable, threshold-based, with a tradition of use in credit scoring (Capital One, Goldman Sachs). Disadvantage: less sensitive than KS, so it misses drift in the tails.&lt;/p>
&lt;p>&lt;strong>Maximum Mean Discrepancy (MMD)&lt;/strong>: measures the divergence between two distributions by embedding each one in a Hilbert space via a kernel. It works for complex multivariate distributions (high-dimensional embeddings). Advantage: the only one that scales reasonably to embeddings of 768/1024/4096 dimensions. Disadvantage: harder to interpret.&lt;/p>
&lt;p>The recommended practice in 2026:&lt;/p>
&lt;ul>
&lt;li>PSI for simple features (prompt length, tokens, number of tools invoked).&lt;/li>
&lt;li>KS for continuous features where you want high sensitivity.&lt;/li>
&lt;li>MMD for embeddings (high-dimensional spaces).&lt;/li>
&lt;/ul>
&lt;p>&lt;a href="https://www.evidentlyai.com/blog/data-drift-detection-large-datasets">Evidently&amp;rsquo;s analysis&lt;/a> on real datasets showed that KS detects drift 6+ hours earlier than PSI in some incidents. The operational consequence: use KS for early warning, PSI for confirmation with an interpretable threshold.&lt;/p>
&lt;h3 id="tools-in-2026">Tools in 2026&lt;/h3>
&lt;p>Three products dominate the field:&lt;/p>
&lt;h4 id="evidently-ai">Evidently AI&lt;/h4>
&lt;p>&lt;a href="https://github.com/evidentlyai/evidently">Evidently&lt;/a> is open-source (Apache 2.0), Python-first. Its value:&lt;/p>
&lt;ul>
&lt;li>&lt;strong>HTML drift reports&lt;/strong>: you generate a report comparing two datasets (reference vs current) and you get an HTML file with all the statistical tests, visualisations and conclusions. No server, no infrastructure; one shareable file.&lt;/li>
&lt;li>&lt;strong>Native LLM support&lt;/strong>: on top of tabular, it supports text. It computes embeddings and applies the appropriate tests.&lt;/li>
&lt;li>&lt;strong>100+ metrics&lt;/strong> in the suite. It covers everything from a single framework.&lt;/li>
&lt;li>&lt;strong>Integration with MLflow and kube&lt;/strong>: CI workflows with reports on every release.&lt;/li>
&lt;/ul>
&lt;div class="highlight">&lt;pre tabindex="0" class="chroma">&lt;code class="language-python" data-lang="python">&lt;span class="line">&lt;span class="cl">&lt;span class="kn">from&lt;/span> &lt;span class="nn">evidently&lt;/span> &lt;span class="kn">import&lt;/span> &lt;span class="n">Report&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="kn">from&lt;/span> &lt;span class="nn">evidently.metrics&lt;/span> &lt;span class="kn">import&lt;/span> &lt;span class="n">DataDriftPreset&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="n">ref&lt;/span> &lt;span class="o">=&lt;/span> &lt;span class="n">load_baseline_dataset&lt;/span>&lt;span class="p">()&lt;/span> &lt;span class="c1"># prompts from last week&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="n">cur&lt;/span> &lt;span class="o">=&lt;/span> &lt;span class="n">load_current_dataset&lt;/span>&lt;span class="p">()&lt;/span> &lt;span class="c1"># prompts from the last hour&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="n">report&lt;/span> &lt;span class="o">=&lt;/span> &lt;span class="n">Report&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="n">metrics&lt;/span>&lt;span class="o">=&lt;/span>&lt;span class="p">[&lt;/span>&lt;span class="n">DataDriftPreset&lt;/span>&lt;span class="p">()])&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="n">report&lt;/span>&lt;span class="o">.&lt;/span>&lt;span class="n">run&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="n">reference_data&lt;/span>&lt;span class="o">=&lt;/span>&lt;span class="n">ref&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="n">current_data&lt;/span>&lt;span class="o">=&lt;/span>&lt;span class="n">cur&lt;/span>&lt;span class="p">)&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="n">report&lt;/span>&lt;span class="o">.&lt;/span>&lt;span class="n">save_html&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="s2">&amp;#34;drift_report.html&amp;#34;&lt;/span>&lt;span class="p">)&lt;/span>
&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>When this functionality detects drift, it also tells you which column and which test fired.&lt;/p>
&lt;h4 id="nannyml">NannyML&lt;/h4>
&lt;p>&lt;a href="https://www.nannyml.com/">NannyML&lt;/a> has a different focus: estimating model performance when you have no ground truth. The techniques:&lt;/p>
&lt;ul>
&lt;li>&lt;strong>CBPE (Confidence-Based Performance Estimation)&lt;/strong>: estimates accuracy using the model&amp;rsquo;s confidence in its predictions.&lt;/li>
&lt;li>&lt;strong>DLE (Direct Loss Estimation)&lt;/strong>: estimates the loss directly.&lt;/li>
&lt;/ul>
&lt;p>Useful when your LLM app has no immediate human feedback but you want to know whether its quality has dropped. Apache 2.0, Python.&lt;/p>
&lt;h4 id="whylabs">WhyLabs&lt;/h4>
&lt;p>&lt;a href="https://whylabs.ai/">WhyLabs&lt;/a> is commercial (with whylogs as the underlying OSS library), aimed at enterprise production:&lt;/p>
&lt;ul>
&lt;li>&lt;strong>Managed SaaS&lt;/strong> with SOC 2 Type 2 and HIPAA compliance.&lt;/li>
&lt;li>&lt;strong>Real-time monitoring&lt;/strong> via continuous log ingestion.&lt;/li>
&lt;li>&lt;strong>Embedding tracking&lt;/strong>: native support for embedding distributions, not just tabular features.&lt;/li>
&lt;li>&lt;strong>Token probability shifts&lt;/strong>: it monitors the probability distribution of generated tokens, not just metadata.&lt;/li>
&lt;/ul>
&lt;p>For regulated companies that do not want to operate their own drift detection platform, it is the lowest-friction option.&lt;/p>
&lt;h4 id="other-mentions">Other mentions&lt;/h4>
&lt;p>&lt;a href="https://phoenix.arize.com/">Arize Phoenix&lt;/a> (seen in the Evals post) includes drift detection as a module. &lt;a href="https://galileo.ai/">Galileo&lt;/a> has commercial products specialised in LLM monitoring. &lt;a href="https://www.fiddler.ai/">Fiddler AI&lt;/a> and &lt;a href="https://github.com/SeldonIO/alibi-detect">Alibi Detect&lt;/a> (Seldon) are more generalist alternatives that also cover LLMs.&lt;/p>
&lt;table>
&lt;thead>
&lt;tr>
&lt;th>Tool&lt;/th>
&lt;th>Licence&lt;/th>
&lt;th>Focus&lt;/th>
&lt;th>Typical stack&lt;/th>
&lt;/tr>
&lt;/thead>
&lt;tbody>
&lt;tr>
&lt;td>&lt;strong>Evidently AI&lt;/strong>&lt;/td>
&lt;td>Apache 2.0&lt;/td>
&lt;td>Drift reports + LLM&lt;/td>
&lt;td>OSS Python, HTML reports&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>&lt;strong>NannyML&lt;/strong>&lt;/td>
&lt;td>Apache 2.0&lt;/td>
&lt;td>Performance without GT&lt;/td>
&lt;td>OSS Python, batch&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>&lt;strong>WhyLabs&lt;/strong>&lt;/td>
&lt;td>Commercial (whylogs OSS)&lt;/td>
&lt;td>Enterprise SaaS, embeddings&lt;/td>
&lt;td>Continuous logs, compliance&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>&lt;strong>Arize Phoenix&lt;/strong>&lt;/td>
&lt;td>ELv2&lt;/td>
&lt;td>Tracing + drift unified&lt;/td>
&lt;td>OSS, OTel-native&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>&lt;strong>Galileo&lt;/strong>&lt;/td>
&lt;td>Commercial&lt;/td>
&lt;td>Premium LLM monitoring&lt;/td>
&lt;td>SaaS, ML expert team&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>&lt;strong>Alibi Detect&lt;/strong>&lt;/td>
&lt;td>Apache 2.0&lt;/td>
&lt;td>General drift detection&lt;/td>
&lt;td>OSS Python, Seldon ecosystem&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>&lt;strong>Fiddler AI&lt;/strong>&lt;/td>
&lt;td>Commercial&lt;/td>
&lt;td>Explainability + monitoring&lt;/td>
&lt;td>Enterprise SaaS&lt;/td>
&lt;/tr>
&lt;/tbody>
&lt;/table>
&lt;h2 id="part-3--the-complete-stack-how-everything-fits">Part 3 — The complete stack: how everything fits&lt;/h2>
&lt;p>Let us recap the layers the two series have covered, ordered from closest to the individual request to closest to the aggregate trend:&lt;/p>
&lt;pre tabindex="0">&lt;code>Individual EVENTS Aggregated TRENDS
│ │
Tracing ──→ Evals ──→ Guardrails ──→ MCP obs ──→ Drift detection
│ │
AgentSight ──→ Tetragon ──→ Hubble ──→ eBPF on-device
│ │
(what happens) (what changes)
&lt;/code>&lt;/pre>&lt;p>Each layer answers a different question:&lt;/p>
&lt;table>
&lt;thead>
&lt;tr>
&lt;th>Layer&lt;/th>
&lt;th>Question it answers&lt;/th>
&lt;th>Granularity&lt;/th>
&lt;/tr>
&lt;/thead>
&lt;tbody>
&lt;tr>
&lt;td>&lt;strong>Tracing&lt;/strong> (Langfuse, AgentSight)&lt;/td>
&lt;td>What exactly did the agent do?&lt;/td>
&lt;td>One session&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>&lt;strong>Evals&lt;/strong>&lt;/td>
&lt;td>Was the answer good?&lt;/td>
&lt;td>One answer&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>&lt;strong>Guardrails&lt;/strong>&lt;/td>
&lt;td>Is this prompt/answer safe?&lt;/td>
&lt;td>One message&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>&lt;strong>MCP observability&lt;/strong>&lt;/td>
&lt;td>Which tools did it invoke, at what cost?&lt;/td>
&lt;td>One tool call&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>&lt;strong>eBPF on agent/network&lt;/strong> (AgentSight, Hubble)&lt;/td>
&lt;td>How did the system behave?&lt;/td>
&lt;td>Per process/connection&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>&lt;strong>eBPF on the local engine&lt;/strong> (ProfInfer-like)&lt;/td>
&lt;td>How did the model execute?&lt;/td>
&lt;td>Per runtime function&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>&lt;strong>Drift detection&lt;/strong>&lt;/td>
&lt;td>Is something changing silently?&lt;/td>
&lt;td>Distribution&lt;/td>
&lt;/tr>
&lt;/tbody>
&lt;/table>
&lt;p>None of them replaces the others. Full coverage requires all seven. The practical operation:&lt;/p>
&lt;ol>
&lt;li>Layers 1-3 (tracing, evals, guardrails) are mandatory from day one. Any LLM app in production that does not have them is flying blind.&lt;/li>
&lt;li>Layer 4 (MCP) becomes mandatory when there are agents with tools, which is the majority in 2026.&lt;/li>
&lt;li>Layers 5-6 (eBPF) become valuable when the scale justifies the operating cost (&amp;gt;10 services, &amp;gt;100 inference pods).&lt;/li>
&lt;li>Layer 7 (drift) is the one that is most neglected and most expensive to ignore: it takes a day&amp;rsquo;s work to get the basic pipeline and it saves weeks of future incidents.&lt;/li>
&lt;/ol>
&lt;h2 id="the-operational-drift-pattern-in-2026">The operational drift pattern in 2026&lt;/h2>
&lt;p>The minimum recipe any serious LLM app should have:&lt;/p>
&lt;h3 id="step-1--establish-a-baseline">Step 1 — Establish a baseline&lt;/h3>
&lt;p>During a stable post-release period (2 weeks minimum), store:&lt;/p>
&lt;ul>
&lt;li>&lt;strong>Embeddings of every prompt&lt;/strong> (vector + metadata: timestamp, user_segment, tenant).&lt;/li>
&lt;li>&lt;strong>Embeddings of the responses&lt;/strong>.&lt;/li>
&lt;li>&lt;strong>Automated eval scores&lt;/strong> over a sample (e.g. 5-10% of traffic with G-Eval).&lt;/li>
&lt;li>&lt;strong>The distribution of tools invoked&lt;/strong> (which tools, with which typical arguments, at what frequency).&lt;/li>
&lt;/ul>
&lt;p>Storage: any vector store plus a relational one. Reasonable cardinality at whatever scale you have.&lt;/p>
&lt;h3 id="step-2--continuous-comparison-pipeline">Step 2 — Continuous comparison pipeline&lt;/h3>
&lt;p>Every hour (or every day depending on scale):&lt;/p>
&lt;ul>
&lt;li>Take the sample for the current period (the last hour).&lt;/li>
&lt;li>Apply the statistical tests against the baseline:
&lt;ul>
&lt;li>PSI over simple features (prompt length, tokens, num tools).&lt;/li>
&lt;li>KS over continuous features (latency, score).&lt;/li>
&lt;li>MMD over embeddings.&lt;/li>
&lt;/ul>
&lt;/li>
&lt;li>Generate a drift report (Evidently does it in one line of Python).&lt;/li>
&lt;/ul>
&lt;h3 id="step-3--alerts-and-investigation-workflow">Step 3 — Alerts and investigation workflow&lt;/h3>
&lt;p>Configure thresholds and routes:&lt;/p>
&lt;ul>
&lt;li>&lt;strong>PSI &amp;gt; 0.25 over tokens consumed&lt;/strong>: moderate alert (it may be legitimate, investigate segments).&lt;/li>
&lt;li>&lt;strong>Significant MMD over prompt embeddings&lt;/strong>: high alert (a change in user mix or a coordinated attack).&lt;/li>
&lt;li>&lt;strong>Eval rubric score down &amp;gt;5% over a rolling 7d&lt;/strong>: critical alert.&lt;/li>
&lt;li>&lt;strong>A new cluster in embedding space accounting for 10%+ of traffic&lt;/strong>: a review workflow (it may be a legitimate new segment or an anomaly).&lt;/li>
&lt;/ul>
&lt;p>Every alert must lead to a drill-down dashboard with the affected segments, not to an empty Slack message. The operational rule: if someone cannot investigate the alert in &amp;lt;5 minutes, it will not get investigated.&lt;/p>
&lt;h3 id="step-4--baseline-refresh">Step 4 — Baseline refresh&lt;/h3>
&lt;p>The baseline is not static. Every N weeks, refresh the baseline by incorporating the &amp;ldquo;new stable&amp;rdquo;. If over 3 months the usage pattern has legitimately changed (more international users, new languages), the baseline must reflect it. The typical cadence: quarterly.&lt;/p>
&lt;h2 id="operational-traps">Operational traps&lt;/h2>
&lt;h3 id="a-contaminated-baseline">A contaminated baseline&lt;/h3>
&lt;p>You take the baseline from a period that already contained the problem in embryo. Result: the baseline includes the bad behaviour and the tests never fire. Solution: verify the baseline against a second independent sample (for example, the first week vs the second) before blessing it.&lt;/p>
&lt;h3 id="a-threshold-that-is-too-low">A threshold that is too low&lt;/h3>
&lt;p>PSI &amp;gt; 0.05 fires constantly. Your team learns to ignore the alerts. Calibrate thresholds against the natural noise of your system: run the system with the baseline plus successive weekly samples and measure the PSI distribution; set the threshold a couple of deviations above normal.&lt;/p>
&lt;h3 id="embeddings-that-are-not-representative">Embeddings that are not representative&lt;/h3>
&lt;p>You use OpenAI&amp;rsquo;s &lt;code>text-embedding-3-small&lt;/code> embedding model to detect drift in a system serving technical questions in Spanish about Cisco networks. Result: the embedding model does not capture the fine semantics of the domain. Solution: use embeddings finetuned for your domain, or one that is strong in multilingual and technical material.&lt;/p>
&lt;h3 id="storage-overload">Storage overload&lt;/h3>
&lt;p>Storing an embedding of every prompt in production at scale (millions of prompts/day) fills disks and increases cost. Stratified sampling: keep 5-10% of traffic, but make sure minority segments are over-represented so you do not lose them.&lt;/p>
&lt;h3 id="confusing-drift-with-the-system-works">Confusing drift with &amp;ldquo;the system works&amp;rdquo;&lt;/h3>
&lt;p>Sometimes drift is good drift: new users discover the agent knows how to do X, and suddenly 30% of traffic is for X. The distribution changed because the product found a new use. Before pulling the alarm, check whether the change is desirable.&lt;/p>
&lt;h3 id="privacy-in-embedding-storage">Privacy in embedding storage&lt;/h3>
&lt;p>Embeddings can be partially inverted back to their original text with embedding inversion techniques. If the prompts contain PII, storing embeddings for months for drift detection is a leak vector. Encrypt at rest and rotate regularly, or work with aggregated/averaged embeddings.&lt;/p>
&lt;h3 id="ebpf-in-production-without-profile-guardrails">eBPF in production without profile guardrails&lt;/h3>
&lt;p>Attaching uprobes to hot-path functions such as &lt;code>llama_decode&lt;/code> can hit throughput if it is not done carefully. Always test in staging and monitor the overhead. ProfInfer reports &amp;lt;4%; what you measure may vary with your binary and kernel.&lt;/p>
&lt;h2 id="closing-the-two-series">Closing the two series&lt;/h2>
&lt;p>This week we have written 12 articles that walk the modern LLM inference stack in production from top to bottom:&lt;/p>
&lt;p>&lt;strong>LLM inference series (4 articles)&lt;/strong>:&lt;/p>
&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> — fundamentals.&lt;/li>
&lt;li>&lt;a href="https://blog.lo0.es/en/posts/vllm-kubernetes-llm-inference-that-scales/">vLLM on Kubernetes&lt;/a> — the engine.&lt;/li>
&lt;li>&lt;a href="https://blog.lo0.es/en/posts/pagedattention-vllm-block-manager/">PagedAttention deep dive&lt;/a> — how it works inside.&lt;/li>
&lt;li>&lt;a href="https://blog.lo0.es/en/posts/llm-inference-operators-kubernetes/">LLM Operators on Kubernetes&lt;/a> — orchestration.&lt;/li>
&lt;/ul>
&lt;p>&lt;strong>eBPF series (4 articles)&lt;/strong>:&lt;/p>
&lt;ul>
&lt;li>&lt;a href="https://blog.lo0.es/en/posts/ebpf-zero-to-cilium-kernel-tcp-ip-bypass/">eBPF from zero to Cilium&lt;/a> — the substrate.&lt;/li>
&lt;li>&lt;a href="https://blog.lo0.es/en/posts/tetragon-cilium-security-syscalls-kernel/">Tetragon&lt;/a> — runtime security.&lt;/li>
&lt;li>&lt;a href="https://blog.lo0.es/en/posts/hubble-ebpf-network-observability-ai-agents/">Hubble&lt;/a> — network observability.&lt;/li>
&lt;li>&lt;a href="https://blog.lo0.es/en/posts/agentsight-llm-tracing-ebpf-zero-instrumentation/">AgentSight&lt;/a> — agent observability.&lt;/li>
&lt;/ul>
&lt;p>&lt;strong>Post-tracing series (4 articles)&lt;/strong>:&lt;/p>
&lt;ul>
&lt;li>&lt;a href="https://blog.lo0.es/en/posts/evals-llm-layer-after-tracing/">Evals&lt;/a> — reactive quality.&lt;/li>
&lt;li>&lt;a href="https://blog.lo0.es/en/posts/llm-guardrails-safety-four-lines-of-defence/">Guardrails&lt;/a> — preventive safety.&lt;/li>
&lt;li>&lt;a href="https://blog.lo0.es/en/posts/mcp-deep-observability-opentelemetry/">MCP observability&lt;/a> — the tool protocol.&lt;/li>
&lt;li>This one — drift detection and eBPF in local inference.&lt;/li>
&lt;/ul>
&lt;p>If you read the twelve in order you have a reasonably complete map of what it takes to operate AI agents in serious production in 2026, with enough detail not to crash into the usual problems in the first month. And, above all, with the mindset that LLM observability is a stack, not a product: each layer solves a problem, none of them solves all of them, and the combination is what separates an operable system from one that holds up until the first incident.&lt;/p>
&lt;h2 id="what-is-left-for-future-series">What is left for future series&lt;/h2>
&lt;ul>
&lt;li>&lt;strong>MLOps specific to LLMs&lt;/strong>: continuous fine-tuning, RAG over data lakes, agent training.&lt;/li>
&lt;li>&lt;strong>Constitutional AI and alignment at runtime&lt;/strong>: how the model self-regulates with internal guardrails.&lt;/li>
&lt;li>&lt;strong>GPU networking&lt;/strong>: InfiniBand, NCCL, GPUDirect, the angle we left untouched.&lt;/li>
&lt;li>&lt;strong>Edge inference&lt;/strong>: llama.cpp on phones, MLX on macOS, Snapdragon NPU.&lt;/li>
&lt;li>&lt;strong>Theoretical inference scheduling&lt;/strong>: CFS-like algorithms applied to multi-tenant LLM serving.&lt;/li>
&lt;/ul>
&lt;p>We will cover them in time. That is it for now, and thanks for reading these twelve posts. If they gave you something, share them with a colleague.&lt;/p>
&lt;h2 id="references">References&lt;/h2>
&lt;p>eBPF in local inference:&lt;/p>
&lt;ul>
&lt;li>&lt;a href="https://arxiv.org/abs/2601.20755">ProfInfer: An eBPF-based Fine-Grained LLM Inference Profiler (arxiv 2601.20755)&lt;/a> — the 2026 reference paper.&lt;/li>
&lt;li>&lt;a href="https://www.glukhov.org/observability/monitoring-llm-inference-prometheus-grafana/">Monitor LLM Inference in Production 2026 (Glukhov)&lt;/a> — Prometheus + Grafana for vLLM/TGI/llama.cpp.&lt;/li>
&lt;li>&lt;a href="https://www.armosec.io/blog/observability-for-ai-inference-servers/">AI Inference Server Observability in Kubernetes (ARMO)&lt;/a> — the four signals MLOps tools do not capture.&lt;/li>
&lt;li>&lt;a href="https://developers.redhat.com/articles/2025/09/30/vllm-or-llamacpp-choosing-right-llm-inference-engine-your-use-case">vLLM vs llama.cpp: Choosing the right engine (Red Hat)&lt;/a>.&lt;/li>
&lt;/ul>
&lt;p>Drift detection concepts:&lt;/p>
&lt;ul>
&lt;li>&lt;a href="https://futureagi.com/blog/what-is-llm-drift-2026">What is LLM Drift? Types, Detection, Mitigation 2026 (FutureAGI)&lt;/a>.&lt;/li>
&lt;li>&lt;a href="https://stackpulsar.com/blog/llm-model-drift-detection/">LLM Model Drift Detection 2026 (Stack Pulsar)&lt;/a>.&lt;/li>
&lt;li>&lt;a href="https://galileo.ai/blog/best-llm-output-drift-monitoring-platforms">9 Best LLM Drift Monitoring Platforms in 2026 (Galileo)&lt;/a>.&lt;/li>
&lt;li>&lt;a href="https://arxiv.org/abs/2404.18673">Open-Source Drift Detection Tools in Action (arxiv 2404.18673)&lt;/a>.&lt;/li>
&lt;li>&lt;a href="https://arxiv.org/abs/2309.10000">Detecting covariate drift in text data using document embeddings (arxiv 2309.10000)&lt;/a>.&lt;/li>
&lt;li>&lt;a href="https://futureagi.com/blog/best-ai-drift-detection-tools-2026">Best AI Drift Detection Tools in 2026 (FutureAGI)&lt;/a>.&lt;/li>
&lt;/ul>
&lt;p>Tools:&lt;/p>
&lt;ul>
&lt;li>&lt;a href="https://github.com/evidentlyai/evidently">Evidently AI (GitHub)&lt;/a> — open-source.&lt;/li>
&lt;li>&lt;a href="https://www.evidentlyai.com/">Evidently — official site&lt;/a>.&lt;/li>
&lt;li>&lt;a href="https://www.nannyml.com/">NannyML&lt;/a> — performance without ground truth.&lt;/li>
&lt;li>&lt;a href="https://whylabs.ai/">WhyLabs&lt;/a> — managed observability.&lt;/li>
&lt;li>&lt;a href="https://github.com/SeldonIO/alibi-detect">Alibi Detect (Seldon)&lt;/a> — general drift detection.&lt;/li>
&lt;li>&lt;a href="https://phoenix.arize.com/">Arize Phoenix&lt;/a> — drift integrated with tracing.&lt;/li>
&lt;/ul>
&lt;p>Statistical tests:&lt;/p>
&lt;ul>
&lt;li>&lt;a href="https://mlpipeline-cloud.com/blog/data-drift-detection-psi-ks">Data drift detection: PSI vs Kolmogorov–Smirnov (MLPipeline)&lt;/a> — a practical comparison.&lt;/li>
&lt;li>&lt;a href="https://brandonwie.dev/posts/psi-model-drift-detection">Population Stability Index for Model Drift Detection&lt;/a>.&lt;/li>
&lt;li>&lt;a href="https://www.evidentlyai.com/blog/data-drift-detection-large-datasets">Which test is the best? 5 methods to detect data drift (Evidently)&lt;/a> — the 6 hours of advantage for KS.&lt;/li>
&lt;/ul>
&lt;p>Cross-references (the three complete series):&lt;/p>
&lt;ul>
&lt;li>LLM inference series: &lt;a href="https://blog.lo0.es/en/posts/kv-cache-working-memory-llm-inference/">KV cache&lt;/a>, &lt;a href="https://blog.lo0.es/en/posts/vllm-kubernetes-llm-inference-that-scales/">vLLM on K8s&lt;/a>, &lt;a href="https://blog.lo0.es/en/posts/pagedattention-vllm-block-manager/">PagedAttention&lt;/a>, &lt;a href="https://blog.lo0.es/en/posts/llm-inference-operators-kubernetes/">LLM K8s Operators&lt;/a>.&lt;/li>
&lt;li>eBPF series: &lt;a href="https://blog.lo0.es/en/posts/ebpf-zero-to-cilium-kernel-tcp-ip-bypass/">eBPF from zero to Cilium&lt;/a>, &lt;a href="https://blog.lo0.es/en/posts/tetragon-cilium-security-syscalls-kernel/">Tetragon&lt;/a>, &lt;a href="https://blog.lo0.es/en/posts/hubble-ebpf-network-observability-ai-agents/">Hubble&lt;/a>, &lt;a href="https://blog.lo0.es/en/posts/agentsight-llm-tracing-ebpf-zero-instrumentation/">AgentSight&lt;/a>.&lt;/li>
&lt;li>Post-tracing series: &lt;a href="https://blog.lo0.es/en/posts/evals-llm-layer-after-tracing/">Evals&lt;/a>, &lt;a href="https://blog.lo0.es/en/posts/llm-guardrails-safety-four-lines-of-defence/">Guardrails&lt;/a>, &lt;a href="https://blog.lo0.es/en/posts/mcp-deep-observability-opentelemetry/">MCP observability&lt;/a>.&lt;/li>
&lt;/ul></description></item><item><title>LLM inference operators on Kubernetes: OME, vLLM Production Stack, NVIDIA Dynamo and llm-d</title><link>https://blog.lo0.es/en/posts/llm-inference-operators-kubernetes/</link><pubDate>Mon, 18 May 2026 17:00:00 +0200</pubDate><guid>https://blog.lo0.es/en/posts/llm-inference-operators-kubernetes/</guid><description>&lt;h2 id="tldr">TL;DR&lt;/h2>
&lt;p>Serving an LLM in production is not running a binary: it is coordinating a model (tens of gigabytes that take minutes to load), a runtime (vLLM, SGLang, TensorRT-LLM with a hundred flags), heterogeneous GPUs (NVLink, MIG, PCIe), prefill and decode that live better apart, a KV cache that wants offloading to colder tiers, intelligent routing that exploits prefix caching, and autoscaling that reacts to metrics other than CPU%. A flat Kubernetes &lt;code>Deployment&lt;/code> only covers the first 20% of that. The other 80% is covered by LLM inference operators, of which four matter in 2026: &lt;strong>OME&lt;/strong> (LMSYS, July 2025, multi-engine with a focus on SGLang), &lt;strong>vLLM Production Stack&lt;/strong> (a curated Helm chart from vLLM itself with LMCache for tiered KV), &lt;strong>NVIDIA Dynamo&lt;/strong> (the official successor to Triton, multi-engine, with its own Grove scheduler) and &lt;strong>llm-d&lt;/strong> (a CNCF donation of March 2026 by Red Hat + Google + IBM + CoreWeave + NVIDIA, built on vLLM, focused on distributed scale). Behind all four sits &lt;strong>KServe&lt;/strong>, the CNCF parent operator that normalised the &lt;code>InferenceService&lt;/code> concept and that several of them lean on. This article walks through the full hierarchy, gives a decision map and teaches you not to get lost when someone drops seven acronyms in the first meeting.&lt;/p>
&lt;blockquote>
&lt;p>This article closes the LLM inference series. The previous ones were &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>, &lt;a href="https://blog.lo0.es/en/posts/vllm-kubernetes-llm-inference-that-scales/">vLLM on Kubernetes: the LLM inference piece that actually scales&lt;/a> and &lt;a href="https://blog.lo0.es/en/posts/pagedattention-vllm-block-manager/">PagedAttention from the inside and the state of the art of the KV cache in 2026&lt;/a>. There we explained what happens inside one inference process. Here we explain how many inference processes are coordinated through Kubernetes.&lt;/p>
&lt;/blockquote>
&lt;h2 id="the-analogy-from-initd-to-systemd-to-operators">The analogy: from &lt;code>init.d&lt;/code> to systemd to operators&lt;/h2>
&lt;p>Anyone with 20 years of sysadmin behind them will recognise the pattern. Decades ago, starting a service on Linux was a shell script in &lt;code>/etc/init.d/&lt;/code>: start, stop, status, reloaded by hand. When services got more complex, with dependencies between them, monitoring, restart on failure, per-user slots, it became obvious that a script was not enough. Along came systemd, which turned &amp;ldquo;a service&amp;rdquo; into a declarative unit with dependencies, resources, restart policy, sockets and timers. The script did not disappear; it moved up one level of abstraction.&lt;/p>
&lt;p>Kubernetes made the same move for distributed services. A &lt;code>Deployment&lt;/code> declares &amp;ldquo;I want N replicas of this container&amp;rdquo;; a &lt;code>Service&lt;/code> declares &amp;ldquo;these replicas are exposed like this&amp;rdquo;; an &lt;code>Ingress&lt;/code> declares &amp;ldquo;this HTTP traffic comes in here&amp;rdquo;. The controller translates the declaration into real state and keeps the system converged.&lt;/p>
&lt;p>Serving LLMs in 2024 was the equivalent of &lt;code>/etc/init.d/&lt;/code>: every team wrote its own &lt;code>Deployment&lt;/code>/&lt;code>Service&lt;/code>/&lt;code>HPA&lt;/code> with custom scripts for model loading, session draining and GPU handling. We covered it in the &lt;a href="https://blog.lo0.es/en/posts/vllm-kubernetes-llm-inference-that-scales/">vLLM on Kubernetes article&lt;/a>: it can be done, and it does work, but it is repetitive, fragile and nobody is extracting the right abstractions. Serving LLMs in 2026 has gone through the same transition as services: the equivalent of systemd has appeared, the inference operators, which normalise the abstractions and let the engineer declare what matters: &amp;ldquo;this model, with this runtime, this scalable, with this routing policy&amp;rdquo;.&lt;/p>
&lt;p>There are four relevant operators in 2026 and a fifth common ancestor. Let us take them in order.&lt;/p>
&lt;h2 id="why-an-operator-and-not-just-a-deployment">Why an operator, and not just a Deployment&lt;/h2>
&lt;p>Listing what an inference operator adds over a flat Deployment is the best way to understand what problem it solves:&lt;/p>
&lt;p>&lt;strong>The model as a first-class citizen.&lt;/strong> In a Deployment, the model is &amp;ldquo;the thing you download in an initContainer and mount as a volume&amp;rdquo;. In an operator, the model is a &lt;code>CustomResource&lt;/code> with metadata (origin, fingerprint, licence, GPU requirements). It can be shared between InferenceServices, versioned, replicated to multiple nodes. It is the difference between &amp;ldquo;a file&amp;rdquo; and &amp;ldquo;a managed artifact&amp;rdquo;.&lt;/p>
&lt;p>&lt;strong>The runtime as a first-class citizen.&lt;/strong> The same goes for the runtime (vLLM/SGLang/TRT-LLM): it is not &amp;ldquo;a Docker image with flags&amp;rdquo;; it is a &lt;code>ServingRuntime&lt;/code> that declares which args it accepts, which metrics it exports, which deployment types it supports (single-node, multi-node TP, PD-disag). Changing runtime is changing a reference, not rewriting every manifest.&lt;/p>
&lt;p>&lt;strong>Declarative composition.&lt;/strong> An &lt;code>InferenceService&lt;/code> (the core CRD of KServe and its descendants) references a model and a runtime, declares the scaling policy, wires up observability and configures routing. The controller composes all the pieces: Deployment(s), Service, HPA, possibly a LeaderWorkerSet, a KEDA ScaledObject, a Gateway API HTTPRoute. You declare intent; the operator emits the 8 derived resources.&lt;/p>
&lt;p>&lt;strong>Operational prefill–decode disaggregation.&lt;/strong> As we saw in the PagedAttention article, separating prefill and decode into distinct pools can give 7× goodput. Modelling that with flat Deployments is feasible, but it requires coordinating two sets of pods, a transport to move the KV cache and conditional routing. An operator models it as a single &lt;code>InferenceService&lt;/code> with two sub-pools.&lt;/p>
&lt;p>&lt;strong>Autoscaling on LLM metrics.&lt;/strong> The standard HPA does not understand &lt;code>vllm:num_requests_waiting&lt;/code>. An operator integrates KEDA or Prometheus Adapter automatically and exposes the right metrics as knobs on the CRD.&lt;/p>
&lt;p>&lt;strong>Multi-tenancy.&lt;/strong> Multiple models on the same cluster, with quotas, priorities and fairness. One Deployment per model scaling independently is fine up to the fifth model; beyond that, coordinating GPUs between tenants becomes operationally hostile.&lt;/p>
&lt;p>&lt;strong>Model lifecycle.&lt;/strong> Weights on a shared PVC, warm-up of the first pod, rolling updates with &lt;code>maxUnavailable: 0&lt;/code>, draining of active sessions, integrated observability. Things that with a flat Deployment have to be reinvented by every team.&lt;/p>
&lt;p>If your workload is one model, one node, up to three replicas, a flat Deployment is enough and an operator is overkill. If your workload is two or more models, serious scaling, disaggregation or multi-tenancy, an operator stops being optional.&lt;/p>
&lt;h2 id="kserve-the-common-ancestor">KServe: the common ancestor&lt;/h2>
&lt;p>Before the four new ones, we have to mention &lt;a href="https://kserve.github.io/website/">KServe&lt;/a>, the parent operator from which all the rest conceptually descend. It was born as KFServing inside the Kubeflow project in 2019, was renamed KServe when it became independent in 2021, and in 2025 was &lt;a href="https://thenewstack.io/kserve-joins-cncf-to-standardize-ai-model-serving-on-kubernetes/">accepted into the CNCF&lt;/a> as a project incubating towards graduation.&lt;/p>
&lt;p>KServe&amp;rsquo;s conceptual contribution is the &lt;code>InferenceService&lt;/code> CRD, which has become the field&amp;rsquo;s common vocabulary: a declarative K8s object that joins a &lt;code>model&lt;/code> (origin + metadata) with a &lt;code>predictor&lt;/code> (runtime + resources) and produces a ready HTTP service. Under the hood, the controller emits Deployments, Services, HorizontalPodAutoscalers, Knative Services if you go serverless, Istio VirtualServices if you do traffic splitting.&lt;/p>
&lt;p>KServe was designed in a pre-LLM era: its first use cases were traditional scikit-learn, TensorFlow and PyTorch models served as simple REST APIs. That gives it strengths (it is mature, with 6 years in production at Bloomberg, JPMorgan and others) and weaknesses (it was not designed to manage multi-node tensor parallel, prefill–decode disaggregation, or the patterns specific to LLMs).&lt;/p>
&lt;p>The way the ecosystem has reacted is elegant: the new LLM operators inherit from or take inspiration from &lt;code>InferenceService&lt;/code> but extend the API with LLM-specific primitives. OME is the clearest example: it uses the name &lt;code>InferenceService&lt;/code> and the idea of &amp;ldquo;model + runtime → service&amp;rdquo;, but adds &lt;code>BaseModel&lt;/code>, a &lt;code>ServingRuntime&lt;/code> with LLM-aware flags, and deployment modes (PD-disag, multi-node) that KServe does not cover natively.&lt;/p>
&lt;h2 id="ome-open-model-engine">OME (Open Model Engine)&lt;/h2>
&lt;p>&lt;a href="https://github.com/ome-projects/ome">OME&lt;/a> was published by the LMSYS team in July 2025 (announced on &lt;a href="https://www.lmsys.org/blog/2025-07-08-ome/">their blog&lt;/a>). It is an operator that understands SGLang deeply (that is its first-class runtime) but also supports vLLM, TensorRT-LLM and Triton.&lt;/p>
&lt;h3 id="the-crd-hierarchy">The CRD hierarchy&lt;/h3>
&lt;p>OME models the domain with four main CRDs:&lt;/p>
&lt;ul>
&lt;li>&lt;strong>&lt;code>BaseModel&lt;/code>&lt;/strong> and &lt;strong>&lt;code>ClusterBaseModel&lt;/code>&lt;/strong>: the model itself. It defines origin (Hugging Face, S3, URL), fingerprint and metadata. The &lt;code>Cluster*&lt;/code> version is global; &lt;code>BaseModel&lt;/code> is namespaced. It lets multiple &lt;code>InferenceService&lt;/code> objects reference the same model without duplicating the download.&lt;/li>
&lt;li>&lt;strong>&lt;code>FineTunedWeight&lt;/code>&lt;/strong>: LoRA adapters or finetuned weights served on top of a &lt;code>BaseModel&lt;/code>. Critical for multi-tenant setups where each customer has their own finetune.&lt;/li>
&lt;li>&lt;strong>&lt;code>ServingRuntime&lt;/code>&lt;/strong> and &lt;strong>&lt;code>ClusterServingRuntime&lt;/code>&lt;/strong>: the runtime (vLLM, SGLang, and so on) with its configuration. It declares which args it accepts, which metrics it exports, which deployment modes it supports.&lt;/li>
&lt;li>&lt;strong>&lt;code>InferenceService&lt;/code>&lt;/strong>: the central declarative piece, joining &lt;code>BaseModel&lt;/code> + &lt;code>ServingRuntime&lt;/code> + infrastructure.&lt;/li>
&lt;/ul>
&lt;div class="highlight">&lt;pre tabindex="0" class="chroma">&lt;code class="language-yaml" data-lang="yaml">&lt;span class="line">&lt;span class="cl">&lt;span class="nt">apiVersion&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">ome.io/v1beta1&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w">&lt;/span>&lt;span class="nt">kind&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">InferenceService&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w">&lt;/span>&lt;span class="nt">metadata&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">name&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">llama3-70b-prod&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">namespace&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">inference&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w">&lt;/span>&lt;span class="nt">spec&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">model&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">name&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">meta-llama-3-70b-instruct &lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="c"># reference to a BaseModel&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">runtime&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">name&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">sglang-h100 &lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="c"># reference to a ServingRuntime&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">deploymentMode&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">PrefillDecodeDisaggregated &lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="c"># standard | PD | MultiNode | Serverless&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">prefill&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">minReplicas&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="m">2&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">maxReplicas&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="m">8&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">resources&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">requests&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">nvidia.com/gpu&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="m">4&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">decode&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">minReplicas&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="m">4&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">maxReplicas&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="m">16&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">resources&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">requests&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">nvidia.com/gpu&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="m">1&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">router&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">type&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">cache-aware &lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="c"># SGLang router with cache awareness&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">autoscaling&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">metricSource&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">keda&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">metrics&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>- &lt;span class="nt">type&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">prometheus&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">metricName&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">vllm_requests_waiting&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">threshold&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="s2">&amp;#34;10&amp;#34;&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>That is what the operator takes as input. The output is roughly 8 derived resources that would be horrible to declare by hand: two LeaderWorkerSets (one per prefill/decode pool), two Services, a Deployment for the router, KEDA ScaledObjects for each pool, a Gateway API HTTPRoute, and a PriorityClass that hooks into Kueue for gang scheduling.&lt;/p>
&lt;h3 id="the-four-deployment-modes">The four deployment modes&lt;/h3>
&lt;p>OME materialises the &lt;code>InferenceService&lt;/code> differently depending on &lt;code>deploymentMode&lt;/code>:&lt;/p>
&lt;ul>
&lt;li>&lt;strong>Standard&lt;/strong>: one Deployment with N replicas; the classic. For small or single-GPU models.&lt;/li>
&lt;li>&lt;strong>PrefillDecodeDisaggregated&lt;/strong>: two coordinated pools; the SGLang router routes between them.&lt;/li>
&lt;li>&lt;strong>MultiNode&lt;/strong>: tensor parallel across multiple nodes via LeaderWorkerSet, with NCCL/InfiniBand. For models above 70B where a single node is not enough.&lt;/li>
&lt;li>&lt;strong>Serverless&lt;/strong>: Knative-style scale-to-zero. For sporadic workloads where the cost of keeping GPUs powered up does not pay off. The trade-off: the first request pays the model&amp;rsquo;s cold start cost (minutes).&lt;/li>
&lt;/ul>
&lt;h3 id="integration-with-the-k8s-ecosystem">Integration with the K8s ecosystem&lt;/h3>
&lt;p>OME does not invent primitives where they already exist. It leans on:&lt;/p>
&lt;ul>
&lt;li>&lt;a href="https://kueue.sigs.k8s.io/">Kueue&lt;/a> for gang scheduling: all the pods of a tensor parallel group have to start at once or not at all; Kueue guarantees it.&lt;/li>
&lt;li>&lt;a href="https://lws.sigs.k8s.io/">LeaderWorkerSet (LWS)&lt;/a> for multi-node: workers join the leader&amp;rsquo;s Ray cluster, with an atomic lifecycle (one going down restarts the group).&lt;/li>
&lt;li>KEDA for autoscaling on LLM-specific Prometheus metrics (queue depth, GPU cache usage, TTFT p95).&lt;/li>
&lt;li>&lt;a href="https://gateway-api.sigs.k8s.io/">Gateway API&lt;/a> and its Inference Extension for advanced routing (model-aware, prefix-aware, weighted canary).&lt;/li>
&lt;/ul>
&lt;p>The consequence: OME feels &amp;ldquo;idiomatically Kubernetes&amp;rdquo;. It does not introduce new concepts where they are not needed; it uses standard primitives and concentrates on what is specific to the LLM domain.&lt;/p>
&lt;h3 id="when-to-choose-it">When to choose it&lt;/h3>
&lt;p>OME is the natural choice if SGLang is your main runtime and/or if you come from the KServe ecosystem and want an idiomatic evolution of it. It is mature but relatively young (a year old at the time of writing); expect rough edges on advanced features.&lt;/p>
&lt;h2 id="vllm-production-stack">vLLM Production Stack&lt;/h2>
&lt;p>&lt;a href="https://github.com/vllm-project/production-stack">vLLM Production Stack&lt;/a> is the official project from vLLM itself for production on Kubernetes. Its philosophy is the opposite of OME&amp;rsquo;s: instead of an operator with new CRDs, it is a curated Helm chart that deploys a coherent set of pieces.&lt;/p>
&lt;h3 id="the-three-pieces">The three pieces&lt;/h3>
&lt;p>The stack has three components:&lt;/p>
&lt;ol>
&lt;li>&lt;strong>Serving engines&lt;/strong>: vLLM pods, configured with the flags we have been seeing throughout the series (&lt;code>--enable-prefix-caching&lt;/code>, &lt;code>--kv-cache-dtype fp8&lt;/code>, and so on). The Helm chart lets you declare them as a list; it deploys the underlying Deployments and Services.&lt;/li>
&lt;li>&lt;strong>Request router&lt;/strong>: a proxy in front of the engines that decides which one each request goes to. It supports several policies:
&lt;ul>
&lt;li>&lt;strong>Round-robin&lt;/strong>: trivial, for a baseline.&lt;/li>
&lt;li>&lt;strong>Session-based&lt;/strong>: pins each session to one replica to keep its KV cache.&lt;/li>
&lt;li>&lt;strong>Prefix-aware&lt;/strong>: detects shared prefixes between requests and routes them to the replica that already has them cached.&lt;/li>
&lt;li>&lt;strong>KV-aware&lt;/strong>: sees each replica&amp;rsquo;s &lt;code>gpu_cache_usage_perc&lt;/code> and avoids the saturated ones.&lt;/li>
&lt;li>&lt;strong>Disaggregated-prefill&lt;/strong> with native LMCache: separates prefill and decode, with LMCache as the transport for the KV cache between the two.&lt;/li>
&lt;/ul>
&lt;/li>
&lt;li>&lt;strong>Observability stack&lt;/strong>: Prometheus + Grafana with ready-made dashboards. It measures TTFT, TBT (Time-Between-Tokens), throughput, queue depth and GPU memory.&lt;/li>
&lt;/ol>
&lt;h3 id="lmcache-and-tiered-kv">LMCache and tiered KV&lt;/h3>
&lt;p>One of the most interesting pieces the stack brings is &lt;a href="https://github.com/LMCache/LMCache">LMCache&lt;/a>, which adds a KV cache with multiple tiers: GPU HBM as L1, CPU RAM as L2, local disk as L3, and optionally remote storage as L4. When a KV cache block does not fit in HBM, instead of evicting and recomputing it, LMCache drops it to a lower tier. For workloads with shared prefixes and multi-turn conversations, the saving is huge.&lt;/p>
&lt;p>LMCache integrates as a sidecar to the engines and as part of the transport in disaggregated-prefill. Production Stack ships it enabled by default in its Helm chart.&lt;/p>
&lt;h3 id="a-typical-manifest-valuesyaml">A typical manifest (values.yaml)&lt;/h3>
&lt;div class="highlight">&lt;pre tabindex="0" class="chroma">&lt;code class="language-yaml" data-lang="yaml">&lt;span class="line">&lt;span class="cl">&lt;span class="nt">servingEngineSpec&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">modelSpec&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>- &lt;span class="nt">name&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">llama3-8b&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">repository&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">vllm/vllm-openai&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">tag&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">v0.6.3&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">modelURL&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">meta-llama/Meta-Llama-3-8B-Instruct&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">replicaCount&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="m">3&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">requestCPU&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="m">4&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">requestMemory&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">16Gi&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">requestGPU&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="m">1&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">vllmConfig&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">enablePrefixCaching&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="kc">true&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">kvCacheDtype&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">fp8&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">maxModelLen&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="m">32768&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">enableChunkedPrefill&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="kc">true&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w">&lt;/span>&lt;span class="nt">routerSpec&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">routingLogic&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">prefix-aware &lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="c"># round-robin | session | prefix-aware | kv-aware&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">sessionKey&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">x-user-id &lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="c"># when routingLogic=session&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w">&lt;/span>&lt;span class="nt">cacheserverSpec&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">enabled&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="kc">true&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="c"># LMCache for tiered KV&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">storageBackends&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>- &lt;span class="l">cpu&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>- &lt;span class="l">disk &lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="c"># offload to local disk&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w">&lt;/span>&lt;span class="nt">observabilitySpec&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">prometheus&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">enabled&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="kc">true&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">grafana&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">enabled&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="kc">true&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">dashboards&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>- &lt;span class="l">vllm-engine-metrics&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>- &lt;span class="l">lmcache-metrics&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>This is declarative but these are not CRDs: they are Helm chart values. The difference with OME is not semantic (both start from declaration) but operational: with Helm, changes go through &lt;code>helm upgrade&lt;/code>; with CRDs, they go through &lt;code>kubectl apply&lt;/code>. For teams already living in GitOps with Argo CD or Flux, both approaches integrate cleanly, but the flows are different.&lt;/p>
&lt;h3 id="when-to-choose-it-1">When to choose it&lt;/h3>
&lt;p>If vLLM is your only runtime and you want the closest thing to &amp;ldquo;the happy path the project recommends&amp;rdquo;, this one. It is the productionised version maintained by the same people who write the engine. The downsides: it ties you to vLLM (it is not generic) and it does not solve some advanced cases such as multi-tenancy with strict quotas or gang scheduling, where OME or full-fledged operators are better.&lt;/p>
&lt;h2 id="nvidia-dynamo">NVIDIA Dynamo&lt;/h2>
&lt;p>&lt;a href="https://developer.nvidia.com/dynamo">NVIDIA Dynamo&lt;/a> is the official successor to Triton Inference Server, announced at GTC 2025 and merged into the brand as Dynamo-Triton in March of that year. Triton had spent years as the most-used inference engine in &amp;ldquo;serious&amp;rdquo; NVIDIA infrastructures; Dynamo is what NVIDIA believes the new generation needs.&lt;/p>
&lt;h3 id="what-it-is-exactly">What it is exactly&lt;/h3>
&lt;p>Dynamo is a distributed inference framework, not exactly a Kubernetes operator. It has its own runtime (it can run engines), a scheduler (Grove), intelligent routing, multi-tier KV cache management and disaggregation. It supports SGLang, TensorRT-LLM and vLLM as engines, but the engines are executed by Dynamo, not the other way round: the model is &amp;ldquo;Dynamo manages, the engine executes&amp;rdquo;.&lt;/p>
&lt;p>On Kubernetes, Dynamo is deployed via its own operator plus CRDs, normalised with the K8s integration NVIDIA formalised at the end of 2025 (covered by &lt;a href="https://www.infoq.com/news/2025/12/nvidia-dynamo-kubernetes/">this InfoQ piece&lt;/a>). The CRDs are product-specific: they define a &lt;code>DynamoCluster&lt;/code>, a topology of prefill/decode workers and a routing policy.&lt;/p>
&lt;h3 id="the-four-contributions">The four contributions&lt;/h3>
&lt;p>Dynamo is sold on four pillars, with figures reported by NVIDIA:&lt;/p>
&lt;ol>
&lt;li>&lt;strong>Disaggregated serving&lt;/strong> built in, with its own scheduler.&lt;/li>
&lt;li>&lt;strong>Smart routing&lt;/strong> based on cache state: if a worker already has most of a prompt cached, the request goes there.&lt;/li>
&lt;li>&lt;strong>Multi-tier KV cache&lt;/strong>: analogous to LMCache, with HBM/RAM/SSD/NVMe.&lt;/li>
&lt;li>&lt;strong>Autoscaling&lt;/strong> integrated with Dynamo&amp;rsquo;s scheduler.&lt;/li>
&lt;/ol>
&lt;p>The marketing figure: up to 30× more throughput than legacy Triton on the same hardware. With all the caution a vendor benchmark deserves.&lt;/p>
&lt;h3 id="grove-its-own-scheduler">Grove: its own scheduler&lt;/h3>
&lt;p>A controversial decision in Dynamo is not to rely 100% on the Kubernetes scheduler and instead bring its own scheduler, called Grove, that understands GPU topologies. Grove decides which worker runs on which physical GPU, which interconnects (NVLink/InfiniBand) are relevant, and how to distribute tensor parallel across nodes. That gives it more control than the standard kube-scheduler.&lt;/p>
&lt;p>Operationally: if your cluster is &amp;ldquo;pure Kubernetes&amp;rdquo; with kube-scheduler and heterogeneous workloads (not only LLMs), Grove adds one more component to operate. If your cluster is dedicated to LLM inference and there is already a team dedicated to running it, Grove gives you more levers.&lt;/p>
&lt;h3 id="when-to-choose-it-2">When to choose it&lt;/h3>
&lt;p>Dynamo makes sense if:&lt;/p>
&lt;ul>
&lt;li>Your infrastructure is NVIDIA-heavy (Hopper, Blackwell, GB200) and you want to exploit the latest in TensorRT-LLM with the familiar Triton integration, modernised.&lt;/li>
&lt;li>You were already a Triton user for legacy inference (vision, recommendation) and want to keep the ecosystem.&lt;/li>
&lt;li>You have a dedicated SRE team for inference and Grove&amp;rsquo;s extra operational complexity is not a problem.&lt;/li>
&lt;/ul>
&lt;p>It is the vendor-specific option of the quartet. In exchange it gives you NVIDIA&amp;rsquo;s support and first-rate integration with their hardware. If your organisation is already fighting NVIDIA for GPUs, they may even call you offering help with Dynamo.&lt;/p>
&lt;h2 id="llm-d">llm-d&lt;/h2>
&lt;p>&lt;a href="https://github.com/llm-d/llm-d">llm-d&lt;/a> is the youngest and the most &amp;ldquo;political&amp;rdquo; of the four. In March 2026, at &lt;a href="https://siliconangle.com/2026/03/24/red-hat-bets-big-kubernetes-inference-llm-d-kubeconeu/">KubeCon Europe Amsterdam&lt;/a>, Red Hat, Google Cloud, IBM Research, CoreWeave and NVIDIA announced the joint donation of the project to the CNCF as a Sandbox project, with backing from AMD, Cisco, Hugging Face, Intel, Lambda, Mistral AI, UC Berkeley and the University of Chicago. An explicit coalition for vendor neutrality.&lt;/p>
&lt;h3 id="philosophy">Philosophy&lt;/h3>
&lt;p>llm-d positions itself as the vendor-neutral &amp;ldquo;Kubernetes blueprint&amp;rdquo; for distributed inference. It is not a runtime; it is a system that sits on top of vLLM (the default engine) and orchestrates the control plane.&lt;/p>
&lt;p>The primitives the project puts on the table:&lt;/p>
&lt;ul>
&lt;li>&lt;strong>Intelligent routing&lt;/strong> with prefix-cache awareness and load-aware balancing.&lt;/li>
&lt;li>&lt;strong>Tiered KV cache&lt;/strong> with offload to CPU and disk for multi-turn.&lt;/li>
&lt;li>&lt;strong>Prefill/decode disaggregation&lt;/strong> over fast interconnects.&lt;/li>
&lt;li>&lt;strong>Wide expert parallelism&lt;/strong> for serving very large Mixture-of-Experts (MoE) models, a critical pattern popularised by DeepSeek-V3 and Mixtral, where the experts live on different GPUs and tokens have to be routed to the right expert.&lt;/li>
&lt;/ul>
&lt;h3 id="numbers">Numbers&lt;/h3>
&lt;p>The &lt;a href="https://github.com/llm-d/llm-d/releases">v0.5 release&lt;/a> validates ~3.1k tok/s per decode GPU on B200, and up to 50k output tok/s on a 16×16 B200 prefill/decode topology. The most interesting benchmark: an order-of-magnitude reduction in TTFT against a round-robin baseline. In other words, intelligent routing is worth what they say it is.&lt;/p>
&lt;h3 id="cncf-and-the-future">CNCF and the future&lt;/h3>
&lt;p>Donating to the CNCF as a Sandbox project means neutral governance: no vendor is in charge. For an organisation wary of being tied to a single supplier, llm-d is probably the safest medium-term bet. The price: like any Sandbox project, it is not yet &amp;ldquo;boring&amp;rdquo; in the way vLLM is. There is API churn, features that move around, documentation that lags the code.&lt;/p>
&lt;h3 id="when-to-choose-it-3">When to choose it&lt;/h3>
&lt;p>llm-d makes sense if:&lt;/p>
&lt;ul>
&lt;li>You want multi-vendor portability without ties to NVIDIA, Red Hat or Google.&lt;/li>
&lt;li>Your workload includes large MoE models (DeepSeek-V3, Mixtral 8x22B, Llama 4 Behemoth if the size is confirmed), where wide expert parallelism is decisive.&lt;/li>
&lt;li>Your organisation is already comfortable with CNCF Sandbox (projects under active evolution, not yet a stable 1.0).&lt;/li>
&lt;li>You want to bet on the project that will probably be the de facto standard in 2-3 years.&lt;/li>
&lt;/ul>
&lt;h2 id="the-common-ancestor-is-still-there-kserve">The common ancestor is still there: KServe&lt;/h2>
&lt;p>It is worth reconnecting before the comparison: KServe is still alive and heavily used in organisations that serve both LLMs and traditional models (scikit-learn, XGBoost, PyTorch CV). Its &lt;code>InferenceService&lt;/code> is generic enough to serve any model, including vLLM or SGLang as a &lt;code>ServingRuntime&lt;/code>. What it does not do well is the LLM-specific part: disaggregation, multi-node tensor parallel, routing with KV cache awareness. If your organisation already has KServe in production for other models, adding an LLM-specific operator alongside it (OME, vLLM Stack or llm-d) is reasonable. Fighting it all out from pure KServe is not.&lt;/p>
&lt;h2 id="decision-map">Decision map&lt;/h2>
&lt;table>
&lt;thead>
&lt;tr>
&lt;th>Dimension&lt;/th>
&lt;th>OME&lt;/th>
&lt;th>vLLM Prod Stack&lt;/th>
&lt;th>NVIDIA Dynamo&lt;/th>
&lt;th>llm-d&lt;/th>
&lt;/tr>
&lt;/thead>
&lt;tbody>
&lt;tr>
&lt;td>&lt;strong>Philosophy&lt;/strong>&lt;/td>
&lt;td>Classic K8s-idiomatic operator&lt;/td>
&lt;td>Curated Helm chart&lt;/td>
&lt;td>Framework with its own scheduler&lt;/td>
&lt;td>Vendor-neutral CNCF blueprint&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>&lt;strong>Own CRDs&lt;/strong>&lt;/td>
&lt;td>Yes (BaseModel, ServingRuntime, InferenceService&amp;hellip;)&lt;/td>
&lt;td>No (Helm values)&lt;/td>
&lt;td>Yes (DynamoCluster)&lt;/td>
&lt;td>Yes (KServe-derived + extensions)&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>&lt;strong>Primary runtime&lt;/strong>&lt;/td>
&lt;td>SGLang (first class), also vLLM/TRT-LLM/Triton&lt;/td>
&lt;td>vLLM only&lt;/td>
&lt;td>TensorRT-LLM (first class), also SGLang/vLLM&lt;/td>
&lt;td>vLLM (first class)&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>&lt;strong>PD-disaggregation&lt;/strong>&lt;/td>
&lt;td>Yes, declarative&lt;/td>
&lt;td>Yes, with LMCache&lt;/td>
&lt;td>Yes, own scheduler&lt;/td>
&lt;td>Yes, native&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>&lt;strong>Multi-node TP&lt;/strong>&lt;/td>
&lt;td>Yes, via LWS&lt;/td>
&lt;td>Limited&lt;/td>
&lt;td>Yes, via Grove&lt;/td>
&lt;td>Yes, via LWS and MoE EP&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>&lt;strong>Multi-model in a cluster&lt;/strong>&lt;/td>
&lt;td>Yes, mature multi-tenant&lt;/td>
&lt;td>Yes (list of models in values)&lt;/td>
&lt;td>Yes&lt;/td>
&lt;td>Yes&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>&lt;strong>Multi-LoRA&lt;/strong>&lt;/td>
&lt;td>Yes, first class (FineTunedWeight CRD)&lt;/td>
&lt;td>Limited&lt;/td>
&lt;td>Yes&lt;/td>
&lt;td>On the roadmap&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>&lt;strong>Tiered KV cache&lt;/strong>&lt;/td>
&lt;td>Via LMCache (external integration)&lt;/td>
&lt;td>Native LMCache&lt;/td>
&lt;td>Own multi-tier&lt;/td>
&lt;td>Yes, native&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>&lt;strong>Intelligent routing&lt;/strong>&lt;/td>
&lt;td>Cache-aware via the SGLang router&lt;/td>
&lt;td>Prefix-aware / KV-aware / session-based&lt;/td>
&lt;td>Own smart routing&lt;/td>
&lt;td>Prefix-cache + load-aware&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>&lt;strong>GPU scheduler&lt;/strong>&lt;/td>
&lt;td>kube-scheduler + Kueue&lt;/td>
&lt;td>kube-scheduler&lt;/td>
&lt;td>Grove (its own)&lt;/td>
&lt;td>kube-scheduler + Kueue&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>&lt;strong>Hardware&lt;/strong>&lt;/td>
&lt;td>NVIDIA, AMD ROCm, Intel&lt;/td>
&lt;td>NVIDIA, AMD ROCm&lt;/td>
&lt;td>NVIDIA only (with emphasis)&lt;/td>
&lt;td>NVIDIA, AMD, Intel — neutral&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>&lt;strong>Maturity (mid-2026)&lt;/strong>&lt;/td>
&lt;td>Young, evolving&lt;/td>
&lt;td>Stable&lt;/td>
&lt;td>Stable, vendor-driven&lt;/td>
&lt;td>CNCF Sandbox, evolving fast&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>&lt;strong>Governance&lt;/strong>&lt;/td>
&lt;td>LMSYS (academic-industrial)&lt;/td>
&lt;td>vLLM project (academic)&lt;/td>
&lt;td>NVIDIA (vendor)&lt;/td>
&lt;td>CNCF (neutral)&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>&lt;strong>Learning curve&lt;/strong>&lt;/td>
&lt;td>Medium (4 new CRDs)&lt;/td>
&lt;td>Low (familiar Helm values)&lt;/td>
&lt;td>Medium-high (Grove + own CRDs)&lt;/td>
&lt;td>Medium (similar to extended KServe)&lt;/td>
&lt;/tr>
&lt;/tbody>
&lt;/table>
&lt;h3 id="when-to-choose-each-one">When to choose each one&lt;/h3>
&lt;p>&lt;strong>Choose OME&lt;/strong> if:&lt;/p>
&lt;ul>
&lt;li>SGLang is your main engine.&lt;/li>
&lt;li>You need multi-LoRA serving in production.&lt;/li>
&lt;li>The hierarchical abstraction (BaseModel → ServingRuntime → InferenceService) fits you and you come from, or live alongside, KServe.&lt;/li>
&lt;li>You have an appetite for a young and very active project.&lt;/li>
&lt;/ul>
&lt;p>&lt;strong>Choose vLLM Production Stack&lt;/strong> if:&lt;/p>
&lt;ul>
&lt;li>vLLM is your only engine and you want to align with what the project recommends.&lt;/li>
&lt;li>Your team already lives in Helm and does not want to learn new CRDs.&lt;/li>
&lt;li>LMCache + advanced routing inside a single Helm chart is exactly what you need.&lt;/li>
&lt;li>Your scale is medium (tens of replicas), not extreme.&lt;/li>
&lt;/ul>
&lt;p>&lt;strong>Choose NVIDIA Dynamo&lt;/strong> if:&lt;/p>
&lt;ul>
&lt;li>Your infrastructure is NVIDIA-heavy and you want the most optimised path for Hopper/Blackwell.&lt;/li>
&lt;li>You already ran Triton for legacy inference and the transition is natural.&lt;/li>
&lt;li>You accept vendor lock-in in exchange for direct NVIDIA support.&lt;/li>
&lt;li>Your organisation has a dedicated SRE team for inference.&lt;/li>
&lt;/ul>
&lt;p>&lt;strong>Choose llm-d&lt;/strong> if:&lt;/p>
&lt;ul>
&lt;li>You want to bet on the future CNCF standard, neutral between vendors.&lt;/li>
&lt;li>Your workload includes large MoE models with wide expert parallelism.&lt;/li>
&lt;li>You operate multi-cloud or multi-hardware and portability is valuable.&lt;/li>
&lt;li>You accept the immaturity of a Sandbox project in exchange for the bet on the future.&lt;/li>
&lt;/ul>
&lt;p>&lt;strong>Choose pure KServe&lt;/strong> if:&lt;/p>
&lt;ul>
&lt;li>You already serve non-LLM models and want to unify; LLMs are a minority of your workload.&lt;/li>
&lt;li>You need the most conservative and mature use case.&lt;/li>
&lt;li>You accept that advanced LLM features (disaggregation, MoE EP, smart routing) are yours to add with external pieces.&lt;/li>
&lt;/ul>
&lt;h3 id="concrete-scenarios">Concrete scenarios&lt;/h3>
&lt;p>&lt;strong>Scenario A — Small startup, 1-2 models, 1-3 GPU nodes.&lt;/strong> You probably do not need an operator. Deployment + Service + HPA with KEDA metrics, as in the vLLM on Kubernetes article. When you grow to 5+ models, reassess.&lt;/p>
&lt;p>&lt;strong>Scenario B — Mid-sized company, 5-15 models, internal multi-tenant.&lt;/strong> vLLM Production Stack or OME are the reasonable options. Production Stack if vLLM is all you are going to use; OME if you want runtime flexibility and idiomatic CRDs.&lt;/p>
&lt;p>&lt;strong>Scenario C — Internal corporate platform or an external service for end customers.&lt;/strong> llm-d or Dynamo. llm-d if you value vendor neutrality; Dynamo if you live on NVIDIA infrastructure and want the path they recommend.&lt;/p>
&lt;p>&lt;strong>Scenario D — Mixed cluster of LLMs plus traditional models.&lt;/strong> KServe as the base, with an LLM operator alongside (OME is the most natural given its conceptual kinship).&lt;/p>
&lt;h2 id="common-traps">Common traps&lt;/h2>
&lt;p>&lt;strong>&amp;ldquo;I&amp;rsquo;ll start with pure KServe because it&amp;rsquo;s mature&amp;rdquo;.&lt;/strong> For mid-sized LLMs and up, pure KServe leaves a lot of optimisation on the table. The sensible route is KServe as the base if you live alongside other models, but with an LLM-specific operator next to it.&lt;/p>
&lt;p>&lt;strong>&amp;ldquo;I&amp;rsquo;ll wire everything by hand to understand it&amp;rdquo;.&lt;/strong> Reasonable in a PoC, suicidal in production. There are 8 derived resources per model. Multiply by 10 models. You are writing 80 YAMLs and maintaining them. Use an operator.&lt;/p>
&lt;p>&lt;strong>&amp;ldquo;I&amp;rsquo;ll pick the one I like best and pivot if I&amp;rsquo;m wrong&amp;rdquo;.&lt;/strong> Pivoting between operators is not free: although the &lt;code>InferenceService&lt;/code> abstraction is converging, the details (how LoRA is modelled, how routing is configured, how metrics are exposed) vary. Migrating from OME to Dynamo is a project of weeks, not days.&lt;/p>
&lt;p>&lt;strong>&amp;ldquo;I&amp;rsquo;ll go with Dynamo because it&amp;rsquo;s NVIDIA&amp;rsquo;s and therefore better&amp;rdquo;.&lt;/strong> Only if your organisation is already aligned with its operational philosophy (its own scheduler, acceptable vendor lock-in). For many cases, vLLM Production Stack or llm-d give 95% of the value with less friction.&lt;/p>
&lt;p>&lt;strong>&amp;ldquo;Helm chart vs operator is a technical decision&amp;rdquo;.&lt;/strong> It is a cultural and operational decision. If your team ships via Argo CD with Helm values in Git, Production Stack fits without friction. If your team lives in direct &lt;code>kubectl apply -f&lt;/code> and the idea of operators feels natural, OME or llm-d.&lt;/p>
&lt;h2 id="what-we-have-not-covered">What we have not covered&lt;/h2>
&lt;ul>
&lt;li>&lt;strong>&lt;a href="https://github.com/kvcache-ai/Mooncake">Mooncake&lt;/a>&lt;/strong>: the KV cache system shared between instances that Kimi/Moonshot runs in production across hundreds of millions of queries. It is a primitive (not a complete operator), but it integrates as a cache tier with several of the above.&lt;/li>
&lt;li>&lt;strong>&lt;a href="https://docs.ray.io/en/latest/serve/llm/serving-llms.html">Ray Serve LLM&lt;/a>&lt;/strong>: Anyscale&amp;rsquo;s offering, on Kubernetes through KubeRay. More tied to the Ray ecosystem than to native K8s CRDs. Useful if Ray is already part of your infrastructure.&lt;/li>
&lt;li>&lt;strong>&lt;a href="https://fireworks.ai/">Fireworks AI&lt;/a>, &lt;a href="https://www.modular.com/">Modular MAX&lt;/a>&lt;/strong>: commercial platforms with similar primitives, but hosted. They are not K8s operators; they are competitors at another layer.&lt;/li>
&lt;li>&lt;strong>Gateway API Inference Extension&lt;/strong>: the sigwg proposal to extend Gateway API with LLM primitives (model-aware routing, sticky sessions, fairness). In 2026 it is in alpha; the operators above are already starting to support it. When it matures, routing will stop being each operator&amp;rsquo;s problem and become part of the Kubernetes standard.&lt;/li>
&lt;li>&lt;strong>A generic inference observability stack&lt;/strong>: Prometheus + Grafana is standardising around the &lt;code>vllm:*&lt;/code> metrics we covered in the vLLM article. There is an OpenTelemetry effort for LLMs (&lt;code>gen-ai&lt;/code> semantic conventions) that is probably the next link in the chain.&lt;/li>
&lt;/ul>
&lt;h2 id="closing-the-series">Closing the series&lt;/h2>
&lt;p>This series of four articles has walked through LLM inference in production from the bottom up:&lt;/p>
&lt;ol>
&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> — why each token consumes VRAM, and how much.&lt;/li>
&lt;li>&lt;a href="https://blog.lo0.es/en/posts/vllm-kubernetes-llm-inference-that-scales/">vLLM on Kubernetes: the LLM inference piece that actually scales&lt;/a> — how a model is served in production with a serious Deployment.&lt;/li>
&lt;li>&lt;a href="https://blog.lo0.es/en/posts/pagedattention-vllm-block-manager/">PagedAttention from the inside and the state of the art of the KV cache in 2026&lt;/a> — what happens inside the engine at block level, and what has arrived since.&lt;/li>
&lt;li>This one — how many models are orchestrated in a cluster.&lt;/li>
&lt;/ol>
&lt;p>If you have got this far, you have the vocabulary and the map to sit in a meeting where five people throw acronyms around and place each one correctly. And, more importantly, to start making reasoned decisions about where to begin.&lt;/p>
&lt;h2 id="see-also">See also&lt;/h2>
&lt;ul>
&lt;li>&lt;a href="https://blog.lo0.es/en/posts/kserve-open-inference-protocol-control-plane/">Chain of trust for the model (1/4): KServe and the Open Inference Protocol&lt;/a> — the KServe deep dive, the common ancestor this post cites: InferenceService, ServingRuntime, deployment modes and the V2 API contract against the OpenAI API.&lt;/li>
&lt;li>&lt;a href="https://blog.lo0.es/en/posts/model-chain-of-trust-where-the-bytes-live-registry-oci-artefacts/">Chain of trust for the model (2/4): registry, OCI artifacts and distribution&lt;/a> — where the &lt;code>storageUri&lt;/code> of any of these operators gets its weights from.&lt;/li>
&lt;/ul>
&lt;h2 id="references">References&lt;/h2>
&lt;p>Operators and projects covered:&lt;/p>
&lt;ul>
&lt;li>&lt;a href="https://github.com/ome-projects/ome">OME — Open Model Engine (GitHub)&lt;/a> — LMSYS operator for LLM serving with SGLang/vLLM/TRT-LLM/Triton.&lt;/li>
&lt;li>&lt;a href="https://www.lmsys.org/blog/2025-07-08-ome/">Introducing OME (LMSYS Blog, jul 2025)&lt;/a> — announcement and architecture.&lt;/li>
&lt;li>&lt;a href="https://github.com/vllm-project/production-stack">vLLM Production Stack (GitHub)&lt;/a> — official vLLM Helm chart for K8s.&lt;/li>
&lt;li>&lt;a href="https://docs.vllm.ai/projects/production-stack/en/latest/deployment/">vLLM Production Stack docs&lt;/a> — installation and configuration.&lt;/li>
&lt;li>&lt;a href="https://github.com/LMCache/LMCache">LMCache (GitHub)&lt;/a> — KV cache with tiers.&lt;/li>
&lt;li>&lt;a href="https://developer.nvidia.com/dynamo">NVIDIA Dynamo&lt;/a> — successor to Triton.&lt;/li>
&lt;li>&lt;a href="https://www.infoq.com/news/2025/12/nvidia-dynamo-kubernetes/">NVIDIA Dynamo Addresses Multi-Node LLM Inference Challenges (InfoQ, dic 2025)&lt;/a> — K8s integration.&lt;/li>
&lt;li>&lt;a href="https://github.com/llm-d/llm-d">llm-d (GitHub)&lt;/a> — CNCF Sandbox project.&lt;/li>
&lt;li>&lt;a href="https://thenewstack.io/llm-d-cncf-kubernetes-inference/">IBM, Red Hat, and Google donated llm-d to CNCF (The New Stack)&lt;/a> — KubeCon EU 2026 announcement.&lt;/li>
&lt;li>&lt;a href="https://siliconangle.com/2026/03/24/red-hat-bets-big-kubernetes-inference-llm-d-kubeconeu/">Red Hat bets big on Kubernetes inference with llm-d (SiliconANGLE, mar 2026)&lt;/a> — coverage of the announcement.&lt;/li>
&lt;/ul>
&lt;p>Ancestors and primitives:&lt;/p>
&lt;ul>
&lt;li>&lt;a href="https://kserve.github.io/website/">KServe (site)&lt;/a> and &lt;a href="https://thenewstack.io/kserve-joins-cncf-to-standardize-ai-model-serving-on-kubernetes/">KServe joins CNCF (The New Stack)&lt;/a>.&lt;/li>
&lt;li>&lt;a href="https://kueue.sigs.k8s.io/">Kueue&lt;/a> — gang scheduling.&lt;/li>
&lt;li>&lt;a href="https://lws.sigs.k8s.io/">LeaderWorkerSet&lt;/a> — coordinated workloads such as multi-pod tensor parallel.&lt;/li>
&lt;li>&lt;a href="https://keda.sh/">KEDA&lt;/a> — autoscaling on external metrics.&lt;/li>
&lt;li>&lt;a href="https://gateway-api.sigs.k8s.io/">Gateway API&lt;/a> — the successor to Ingress.&lt;/li>
&lt;/ul>
&lt;p>Analysis and perspectives:&lt;/p>
&lt;ul>
&lt;li>&lt;a href="https://jimmysong.io/blog/cloud-native-llm-inference-stack/">Building Efficient LLM Inference with the Cloud Native Quartet: KServe, vLLM, llm-d, and WG Serving (Jimmy Song)&lt;/a> — an integrating view.&lt;/li>
&lt;li>&lt;a href="https://dev.to/x4nent/complete-guide-to-llm-d-cncf-sandbox-kubernetes-native-distributed-llm-inference-1imj">Complete Guide to llm-d CNCF Sandbox (DEV Community)&lt;/a> — an operational walkthrough.&lt;/li>
&lt;li>Previous articles on this blog: &lt;a href="https://blog.lo0.es/en/posts/kv-cache-working-memory-llm-inference/">KV cache&lt;/a>, &lt;a href="https://blog.lo0.es/en/posts/vllm-kubernetes-llm-inference-that-scales/">vLLM on Kubernetes&lt;/a>, &lt;a href="https://blog.lo0.es/en/posts/pagedattention-vllm-block-manager/">PagedAttention deep dive&lt;/a>.&lt;/li>
&lt;/ul></description></item><item><title>vLLM on Kubernetes: the LLM inference piece that actually scales</title><link>https://blog.lo0.es/en/posts/vllm-kubernetes-llm-inference-that-scales/</link><pubDate>Mon, 18 May 2026 13:00:00 +0200</pubDate><guid>https://blog.lo0.es/en/posts/vllm-kubernetes-llm-inference-that-scales/</guid><description>&lt;h2 id="tldr">TL;DR&lt;/h2>
&lt;p>vLLM is the inference engine that turns a general-purpose GPU into a productive LLM server. Its value is not in running a model, since any &lt;code>transformers.pipeline&lt;/code> does that in three lines of Python, but in squeezing the GPU down to the last gigabyte and the last cycle: PagedAttention for the KV cache, &lt;em>continuous batching&lt;/em> to mix requests, its own scheduler to share GPU time between sessions. Kubernetes is its natural habitat because vLLM behaves like a modern UNIX process, with a health endpoint, Prometheus metrics, orderly draining and declarable resources, and K8s already knows how to manage those. But there are traps: the standard HPA does not scale vLLM well, the model takes minutes to load, and naive rolling updates cut sessions halfway through decoding. This article takes the engine apart and then fits it, with real manifests, into a cluster that can actually serve it.&lt;/p>
&lt;blockquote>
&lt;p>This article is the natural continuation of &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>. There we explained why every token consumes VRAM. Here we look at what you do with that VRAM when you want to offer it as a service.&lt;/p>
&lt;/blockquote>
&lt;h2 id="the-analogy-a-multiprocess-kernel-for-your-gpu">The analogy: a multiprocess kernel for your GPU&lt;/h2>
&lt;p>Imagine you have a single processor and need to serve a hundred concurrent processes without any of them blocking the rest. Nobody in their right mind would write a &lt;code>while-true&lt;/code> loop dispatching processes one by one: they would install an operating system. The kernel takes care of scheduling, memory paging, isolation, priorities and cleanup on exit. The &amp;ldquo;process&amp;rdquo; becomes a convenient abstraction and the kernel does the dirty work.&lt;/p>
&lt;p>vLLM is, for your GPU, what the kernel is for your CPU. As far as the GPU is concerned, a conversation with an LLM is a process that lives across many decoding steps, occupies a slice of VRAM (its KV cache) and demands compute time every time a token has to be generated. You have a hundred of those processes at once. You need to:&lt;/p>
&lt;ul>
&lt;li>&lt;strong>Share GPU time between them&lt;/strong> without pausing any of them entirely (it would be disastrous if one long conversation monopolised the GPU).&lt;/li>
&lt;li>&lt;strong>Manage memory with paging&lt;/strong> because, just as in RAM, contiguous allocation is inefficient.&lt;/li>
&lt;li>&lt;strong>Queue new requests&lt;/strong> when the GPU is saturated and serve them in a reasonable order.&lt;/li>
&lt;li>&lt;strong>Reclaim resources&lt;/strong> when a session ends.&lt;/li>
&lt;/ul>
&lt;p>PagedAttention is the virtual memory of the KV cache. &lt;em>Continuous batching&lt;/em> is the time-slicing scheduler that shares the GPU token by token. The OpenAI-compatible server is the uniform syscall interface. Calling it a &amp;ldquo;kernel&amp;rdquo; for the GPU is marketing, but it is marketing that captures the idea well.&lt;/p>
&lt;h2 id="what-vllm-does-inside">What vLLM does inside&lt;/h2>
&lt;h3 id="continuous-batching-stop-waiting-for-the-slowest">Continuous batching: stop waiting for the slowest&lt;/h3>
&lt;p>The naive inference engine does &lt;em>static batching&lt;/em>: it groups N requests, processes them until all of them are finished, returns and starts another round. The problem is obvious: if one request asks for 8 tokens and another asks for 800, the other seven wait for the slow one. GPU utilisation collapses.&lt;/p>
&lt;p>&lt;em>Continuous batching&lt;/em> (Yu et al., 2022, popularised by vLLM) changes the model. At every decode step, which produces one token for each active session, the engine composes the batch out of the active tokens of ALL sessions alive at that instant. When a session finishes its generation it releases its slot immediately and another request from the queue takes it. The batch never waits for the slowest session because nobody is blocked: everyone advances at one token per step.&lt;/p>
&lt;p>The original paper measured 5–23× more throughput than the equivalent static batching. The exact number depends on how variable the response lengths are, but the order of magnitude holds up in practice.&lt;/p>
&lt;div class="diagram" style="max-width: 720px; margin: 1.5rem auto;">
&lt;svg viewBox="0 0 720 240" xmlns="http://www.w3.org/2000/svg" role="img" aria-label="Static vs continuous batching">
&lt;style>.title{font:600 13px sans-serif;fill:#222}.lbl{font:11px sans-serif;fill:#444}.s1{fill:#2a9d8f}.s2{fill:#e76f51}.s3{fill:#264653}.s4{fill:#e9c46a}.empty{fill:#eee;stroke:#999;stroke-dasharray:3 2}&lt;/style>
&lt;text x="180" y="20" text-anchor="middle" class="title">Static batching&lt;/text>
&lt;text x="540" y="20" text-anchor="middle" class="title">Continuous batching&lt;/text>
&lt;text x="20" y="55" class="lbl">session 1&lt;/text>
&lt;text x="20" y="80" class="lbl">session 2&lt;/text>
&lt;text x="20" y="105" class="lbl">session 3&lt;/text>
&lt;text x="20" y="130" class="lbl">session 4&lt;/text>
&lt;rect x="70" y="40" width="40" height="20" class="s1"/>
&lt;rect x="70" y="65" width="120" height="20" class="s2"/>
&lt;rect x="70" y="90" width="60" height="20" class="s3"/>
&lt;rect x="70" y="115" width="30" height="20" class="s4"/>
&lt;rect x="110" y="40" width="80" height="20" class="empty"/>
&lt;rect x="130" y="90" width="60" height="20" class="empty"/>
&lt;rect x="100" y="115" width="90" height="20" class="empty"/>
&lt;text x="180" y="160" text-anchor="middle" class="lbl">empty slots wait for session 2&lt;/text>
&lt;rect x="380" y="40" width="40" height="20" class="s1"/>
&lt;rect x="420" y="40" width="80" height="20" class="s3"/>
&lt;rect x="500" y="40" width="40" height="20" class="s4"/>
&lt;rect x="540" y="40" width="40" height="20" class="s1"/>
&lt;rect x="380" y="65" width="120" height="20" class="s2"/>
&lt;rect x="500" y="65" width="40" height="20" class="s3"/>
&lt;rect x="540" y="65" width="80" height="20" class="s4"/>
&lt;rect x="380" y="90" width="60" height="20" class="s3"/>
&lt;rect x="440" y="90" width="50" height="20" class="s2"/>
&lt;rect x="490" y="90" width="40" height="20" class="s4"/>
&lt;rect x="530" y="90" width="100" height="20" class="s1"/>
&lt;rect x="380" y="115" width="30" height="20" class="s4"/>
&lt;rect x="410" y="115" width="80" height="20" class="s2"/>
&lt;rect x="490" y="115" width="60" height="20" class="s3"/>
&lt;rect x="550" y="115" width="80" height="20" class="s1"/>
&lt;text x="540" y="160" text-anchor="middle" class="lbl">slots reassigned token by token&lt;/text>
&lt;line x1="70" y1="190" x2="630" y2="190" stroke="#666"/>
&lt;text x="350" y="210" text-anchor="middle" class="lbl">time →&lt;/text>
&lt;/svg>
&lt;/div>
&lt;p>The consequence for the operator is counterintuitive: &lt;strong>a single vLLM replica performs like three naive replicas&lt;/strong>. There is no point adding pods without justifying it with real metrics.&lt;/p>
&lt;h3 id="pagedattention-virtual-memory-for-the-kv-cache">PagedAttention: virtual memory for the KV cache&lt;/h3>
&lt;p>We already flagged it in the KV cache article: the naive engine reserves one contiguous block per session, sized for the &lt;em>worst case&lt;/em> (&lt;code>max_context_len&lt;/code>), and wastes 60–80% of the VRAM because real sessions come nowhere near their ceiling.&lt;/p>
&lt;p>PagedAttention borrows the solution operating systems have been using for half a century: split the VRAM into small blocks (16 tokens in the default implementation) and keep a logical → physical page table per session. A session with 273 tokens of context occupies 18 blocks (not necessarily contiguous), and grows block by block as it generates. The paper measured &amp;lt;4% waste, an order of magnitude better than contiguous allocation, and that translates into 2–4× more aggregate throughput on the same hardware, because more sessions fit at once.&lt;/p>
&lt;p>There is a cost: every attention operation has to be indirected through the page table. But vLLM&amp;rsquo;s CUDA kernels are written so that this indirection is cheap, and the net result is massively positive.&lt;/p>
&lt;h3 id="prefill-vs-decode-two-phases-with-opposite-profiles">Prefill vs decode: two phases with opposite profiles&lt;/h3>
&lt;p>An LLM request has two phases with radically different GPU profiles:&lt;/p>
&lt;ul>
&lt;li>&lt;strong>Prefill&lt;/strong>: processes the entire prompt in one go. It is &lt;em>compute-bound&lt;/em>: it uses the tensor cores intensively, the GPU sits above 90%, and it lasts between hundreds of ms and a few seconds depending on prompt size.&lt;/li>
&lt;li>&lt;strong>Decode&lt;/strong>: generates token by token. It is &lt;em>memory-bound&lt;/em>: the compute is modest but the whole KV cache has to be read for every token, and it lasts from tens of ms per token up to minutes for long responses.&lt;/li>
&lt;/ul>
&lt;p>A naive server treats each request as a unit and serves the two phases in series. vLLM decouples them: it mixes requests in prefill with requests in decode in the same step (a technique called &lt;em>chunked prefill&lt;/em> when it also splits long prefills into chunks). The result: the GPU is always busy doing &lt;em>something&lt;/em>, the tensor cores with prefills, the HBM bandwidth with decodes, instead of oscillating between phases.&lt;/p>
&lt;p>Operational implication: the &amp;ldquo;% GPU utilisation&amp;rdquo; metric from &lt;code>nvidia-smi&lt;/code> is misleading. A GPU at 100% doing prefills may have its HBM bandwidth idle. A GPU at 40% doing decodes may have its HBM saturated. For LLM serving, &lt;strong>the useful metric is effective HBM bandwidth&lt;/strong>, not the compute percentage.&lt;/p>
&lt;h3 id="tensor-parallel-when-the-model-does-not-fit-on-one-gpu">Tensor parallel: when the model does not fit on one GPU&lt;/h3>
&lt;p>Llama 3 70B in BF16 is ~140 GB. There is no single GPU on the market that can hold it. The solution is tensor parallel: split each model layer by columns and run the partitions on N GPUs in parallel, synchronising with an &lt;em>all-reduce&lt;/em> after each layer.&lt;/p>
&lt;p>For N=5 GPUs and a 70B model, each GPU sees roughly 28 GB of weights. That sounds fine until you remember that the all-reduce on every layer means reading and writing large tensors between GPUs. If the GPUs share NVLink/NVSwitch (300–900 GB/s), the all-reduce is cheap. If they only share PCIe (~32 GB/s gen4 x16), the all-reduce eats half the time and throughput collapses.&lt;/p>
&lt;p>The implication for K8s, which comes next: the scheduler has to guarantee that the N GPUs are physically close. That translates into NodeAffinity to the right product (&lt;code>NVIDIA-H100-80GB-HBM3&lt;/code>), a single pod with &lt;code>nvidia.com/gpu: N&lt;/code> (not N pods sharing) and, if multi-node is unavoidable, InfiniBand with NCCL as transport.&lt;/p>
&lt;h3 id="the-openai-compatible-server">The OpenAI-compatible server&lt;/h3>
&lt;p>On top of all of the above, vLLM exposes an HTTP server with endpoints identical to OpenAI&amp;rsquo;s: &lt;code>/v1/chat/completions&lt;/code>, &lt;code>/v1/completions&lt;/code>, &lt;code>/v1/embeddings&lt;/code>, &lt;code>/v1/models&lt;/code>. It supports Server-Sent Events streaming. It supports tool calling. It supports logprobs.&lt;/p>
&lt;p>The value of this is enormous and underrated: &lt;strong>any client using the OpenAI SDK works without changes&lt;/strong>. Your application points at &lt;code>https://vllm.your-cluster.local/v1&lt;/code> instead of &lt;code>https://api.openai.com/v1&lt;/code>, and everything else, the LangChain, LlamaIndex, OpenAI Python and OpenAI JS SDKs, just works. It is the main reason vLLM has gained traction over technically comparable alternatives: &lt;strong>it is the boring option that works&lt;/strong>.&lt;/p>
&lt;h2 id="why-kubernetes-is-the-natural-habitat">Why Kubernetes is the natural habitat&lt;/h2>
&lt;p>vLLM is a well-behaved process: it starts, exposes metrics, serves a health endpoint, takes SIGTERM with dignity and declares the resources it needs. Kubernetes has spent ten years perfecting the management of processes like that. The only thing K8s took a while to absorb properly was the GPU, and that is now solved.&lt;/p>
&lt;h3 id="the-gpu-as-a-primitive-resource">The GPU as a primitive resource&lt;/h3>
&lt;p>The plumbing goes like this:&lt;/p>
&lt;ol>
&lt;li>The node has the NVIDIA driver installed (or the GPU Operator installs it).&lt;/li>
&lt;li>A DaemonSet, nvidia-device-plugin, registers the physical GPUs as &lt;code>nvidia.com/gpu&lt;/code> resources with kubelet.&lt;/li>
&lt;li>The Kubernetes scheduler sees those resources the way it sees CPU and memory, puts them in its accounting and assigns them to Pods that ask for them.&lt;/li>
&lt;li>The nvidia-container-toolkit makes sure containerd injects the right devices into the container at startup.&lt;/li>
&lt;/ol>
&lt;p>For the pod, asking for a GPU looks like this:&lt;/p>
&lt;div class="highlight">&lt;pre tabindex="0" class="chroma">&lt;code class="language-yaml" data-lang="yaml">&lt;span class="line">&lt;span class="cl">&lt;span class="nt">resources&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">requests&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">nvidia.com/gpu&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="m">1&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">limits&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">nvidia.com/gpu&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="m">1&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>Without MIG, MPS or time-slicing configured, &lt;strong>a GPU is not shared between pods&lt;/strong>: you ask for it whole or you do not ask for it. For vLLM, which wants the entire GPU to itself, that is exactly what you want.&lt;/p>
&lt;h3 id="the-vllm-pod-lifecycle">The vLLM Pod lifecycle&lt;/h3>
&lt;p>Differences from a typical webapp Pod:&lt;/p>
&lt;ul>
&lt;li>&lt;strong>Long startup&lt;/strong>. Loading 16 GB of weights into VRAM over the network takes 30 seconds in the best case and 5 minutes in the worst. A &lt;code>readinessProbe&lt;/code> with &lt;code>initialDelaySeconds: 30&lt;/code> and &lt;code>failureThreshold: 3&lt;/code> kills the pod before it starts. Solution: a &lt;code>startupProbe&lt;/code> with a high threshold before the &lt;code>livenessProbe&lt;/code> starts evaluating.&lt;/li>
&lt;li>&lt;strong>Warm-up matters&lt;/strong>. The first prefill compiles CUDA kernels specific to the input shape. The first 2–3 requests are noticeably slower. If latency matters from second one, it is worth firing a warm-up POST after ready.&lt;/li>
&lt;li>&lt;strong>Draining is not instantaneous&lt;/strong>. SIGTERM must not kill in-flight sessions. vLLM, configured with &lt;code>--disable-graceful-shutdown false&lt;/code> (the default), finishes active requests before closing. That can take 30–180 seconds. &lt;code>terminationGracePeriodSeconds&lt;/code> has to accommodate it.&lt;/li>
&lt;li>&lt;strong>Hostile rollouts&lt;/strong>. A naive rolling update (&lt;code>maxUnavailable: 1&lt;/code>) can leave you with no replicas serving if the new one is slow to load. Set &lt;code>maxSurge: 1, maxUnavailable: 0&lt;/code> so the new pod is Ready before the old one is drained.&lt;/li>
&lt;/ul>
&lt;h2 id="anatomy-of-a-serious-deployment">Anatomy of a serious deployment&lt;/h2>
&lt;h3 id="first-of-all-gpu-operator">First of all: GPU Operator&lt;/h3>
&lt;p>Without the GPU Operator (or an equivalent manual installation), a Pod with &lt;code>nvidia.com/gpu: 1&lt;/code> stays Pending forever. What the operator installs as DaemonSets on every GPU node:&lt;/p>
&lt;ul>
&lt;li>&lt;code>nvidia-driver-daemonset&lt;/code> — the kernel-mode driver (if you do not have it installed at host level).&lt;/li>
&lt;li>&lt;code>nvidia-device-plugin-daemonset&lt;/code> — registers the GPUs as a kubelet resource.&lt;/li>
&lt;li>&lt;code>nvidia-container-toolkit-daemonset&lt;/code> — the containerd integration.&lt;/li>
&lt;li>&lt;code>nvidia-dcgm-exporter&lt;/code> — Prometheus metrics for the GPU (utilisation, temperature, ECC errors, memory).&lt;/li>
&lt;li>&lt;code>gpu-feature-discovery&lt;/code> — node labels: &lt;code>nvidia.com/gpu.product&lt;/code>, &lt;code>nvidia.com/gpu.memory&lt;/code>, and so on, essential for NodeAffinity.&lt;/li>
&lt;/ul>
&lt;p>The recommended installation is the official Helm chart. The delicate part is aligning the driver with the host kernel version: if the nodes run kernel 6.x, the operator needs a compatible driver branch.&lt;/p>
&lt;h3 id="a-complete-annotated-vllm-deployment">A complete, annotated vLLM Deployment&lt;/h3>
&lt;p>The following deploys Llama 3 8B with an FP8-quantised KV cache, up to 32K of context, on an RTX 4090. It is the reference manifest; the comments explain the non-obvious decisions.&lt;/p>
&lt;div class="highlight">&lt;pre tabindex="0" class="chroma">&lt;code class="language-yaml" data-lang="yaml">&lt;span class="line">&lt;span class="cl">&lt;span class="nt">apiVersion&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">apps/v1&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w">&lt;/span>&lt;span class="nt">kind&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">Deployment&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w">&lt;/span>&lt;span class="nt">metadata&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">name&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">vllm-llama3-8b&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">namespace&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">inference&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w">&lt;/span>&lt;span class="nt">spec&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">replicas&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="m">1&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">strategy&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">type&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">RollingUpdate&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">rollingUpdate&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">maxSurge&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="m">1&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">maxUnavailable&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="m">0&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="c"># never run out of replicas during the rollout&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">selector&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">matchLabels&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">app&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">vllm-llama3-8b&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">template&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">metadata&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">labels&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">app&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">vllm-llama3-8b&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">annotations&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">prometheus.io/scrape&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="s2">&amp;#34;true&amp;#34;&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">prometheus.io/port&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="s2">&amp;#34;8000&amp;#34;&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">prometheus.io/path&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="s2">&amp;#34;/metrics&amp;#34;&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">spec&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="c"># Only nodes with the GPU we expect&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">nodeSelector&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">nvidia.com/gpu.product&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">NVIDIA-GeForce-RTX-4090&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">tolerations&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>- &lt;span class="nt">key&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">nvidia.com/gpu&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">operator&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">Exists&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="c"># Pre-download the weights if they are not on the shared PVC&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">initContainers&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>- &lt;span class="nt">name&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">model-download&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">image&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">ghcr.io/huggingface/huggingface-cli:latest&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">command&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="p">[&lt;/span>&lt;span class="s2">&amp;#34;sh&amp;#34;&lt;/span>&lt;span class="p">,&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="s2">&amp;#34;-c&amp;#34;&lt;/span>&lt;span class="p">]&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">args&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>- &lt;span class="p">|&lt;/span>&lt;span class="sd">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="sd"> if [ ! -f /models/llama-3-8b/config.json ]; then
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="sd"> huggingface-cli download meta-llama/Meta-Llama-3-8B-Instruct \
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="sd"> --local-dir /models/llama-3-8b --local-dir-use-symlinks False
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="sd"> fi&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">env&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>- &lt;span class="nt">name&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">HF_TOKEN&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">valueFrom&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">secretKeyRef&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">name&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">huggingface&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">key&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">token&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">volumeMounts&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>- &lt;span class="nt">name&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">models&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">mountPath&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">/models&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">containers&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>- &lt;span class="nt">name&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">vllm&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">image&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">vllm/vllm-openai:v0.6.3&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">args&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>- --&lt;span class="l">model=/models/llama-3-8b&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>- --&lt;span class="l">served-model-name=llama-3-8b&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>- --&lt;span class="l">tensor-parallel-size=1&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>- --&lt;span class="l">max-model-len=32768&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>- --&lt;span class="l">kv-cache-dtype=fp8&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>- --&lt;span class="l">enable-chunked-prefill&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>- --&lt;span class="l">enable-prefix-caching&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>- --&lt;span class="l">gpu-memory-utilization=0.92&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>- --&lt;span class="l">port=8000&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">ports&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>- &lt;span class="nt">name&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">http&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">containerPort&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="m">8000&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>- &lt;span class="nt">name&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">metrics&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">containerPort&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="m">8000&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="c"># same port as http; /metrics&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">resources&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">requests&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">cpu&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="s2">&amp;#34;4&amp;#34;&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">memory&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">8Gi&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">nvidia.com/gpu&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="m">1&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">limits&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">cpu&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="s2">&amp;#34;8&amp;#34;&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">memory&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">16Gi&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">nvidia.com/gpu&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="m">1&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">startupProbe&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">httpGet&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">path&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">/health&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">port&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="m">8000&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">periodSeconds&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="m">10&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">failureThreshold&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="m">60&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="c"># 10 min of grace to load the model&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">readinessProbe&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">httpGet&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">path&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">/health&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">port&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="m">8000&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">periodSeconds&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="m">5&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">livenessProbe&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">httpGet&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">path&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">/health&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">port&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="m">8000&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">periodSeconds&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="m">20&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">failureThreshold&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="m">3&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">volumeMounts&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>- &lt;span class="nt">name&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">models&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">mountPath&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">/models&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">readOnly&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="kc">true&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="c"># no process should write here at runtime&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>- &lt;span class="nt">name&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">shm&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">mountPath&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">/dev/shm &lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="c"># vLLM uses shared memory for IPC between workers&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">volumes&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>- &lt;span class="nt">name&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">models&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">persistentVolumeClaim&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">claimName&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">model-cache&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>- &lt;span class="nt">name&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">shm&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">emptyDir&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">medium&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">Memory&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">sizeLimit&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">4Gi&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">terminationGracePeriodSeconds&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="m">120&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="c"># accommodates draining of active sessions&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w">&lt;/span>&lt;span class="nn">---&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w">&lt;/span>&lt;span class="nt">apiVersion&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">v1&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w">&lt;/span>&lt;span class="nt">kind&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">Service&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w">&lt;/span>&lt;span class="nt">metadata&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">name&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">vllm-llama3-8b&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">namespace&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">inference&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w">&lt;/span>&lt;span class="nt">spec&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">selector&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">app&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">vllm-llama3-8b&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">ports&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>- &lt;span class="nt">name&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">http&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">port&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="m">80&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">targetPort&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="m">8000&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>Five things that are not visible on a first read:&lt;/p>
&lt;ol>
&lt;li>&lt;strong>&lt;code>/dev/shm&lt;/code> in memory, 4 GB&lt;/strong>. vLLM launches worker processes (one per GPU in tensor parallel, plus the driver) that communicate over shared memory. Docker&amp;rsquo;s default (64 MB) blows up as soon as the model is mid-sized. Without this, the pod starts but fails the moment it serves the first complex request.&lt;/li>
&lt;li>&lt;strong>&lt;code>--enable-prefix-caching&lt;/code>&lt;/strong>. If the prompts in your workload share structure (a common system prompt, few-shot examples), vLLM reuses the KV cache of the shared part. A free 30–60% gain in TTFT.&lt;/li>
&lt;li>&lt;strong>&lt;code>--gpu-memory-utilization=0.92&lt;/code>&lt;/strong>. vLLM reserves the given percentage of the VRAM for itself. The remaining 8% leaves room for activations, CUDA kernels and the overhead that does not get counted. Lowering it buys safety; raising it above 0.95 invites OOM.&lt;/li>
&lt;li>&lt;strong>A &lt;code>ReadOnlyMany&lt;/code> PVC&lt;/strong> is ideal. The model does not change at runtime. Several pods can mount the same PVC without contention.&lt;/li>
&lt;li>&lt;strong>No &lt;code>livenessProbe&lt;/code> shorter than the &lt;code>terminationGracePeriodSeconds&lt;/code>&lt;/strong>. If a drain takes 90s and liveness kills at 60s, rollouts lose sessions.&lt;/li>
&lt;/ol>
&lt;h3 id="multi-pod-tensor-parallel-leaderworkerset">Multi-pod tensor parallel: LeaderWorkerSet&lt;/h3>
&lt;p>When the model needs more GPUs than a single node has, the pattern is a group of coordinated pods, one per GPU, that behave as a single replica. For years this was modelled with a StatefulSet plus init scripts; since Kubernetes 1.32, the idiomatic primitive is LeaderWorkerSet (LWS):&lt;/p>
&lt;div class="highlight">&lt;pre tabindex="0" class="chroma">&lt;code class="language-yaml" data-lang="yaml">&lt;span class="line">&lt;span class="cl">&lt;span class="nt">apiVersion&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">leaderworkerset.x-k8s.io/v1&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w">&lt;/span>&lt;span class="nt">kind&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">LeaderWorkerSet&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w">&lt;/span>&lt;span class="nt">metadata&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">name&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">vllm-llama3-70b&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">namespace&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">inference&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w">&lt;/span>&lt;span class="nt">spec&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">replicas&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="m">1&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">leaderWorkerTemplate&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">size&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="m">5&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="c"># 1 leader + 4 workers = 5 pods, 5 GPUs&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">restartPolicy&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">RecreateGroupOnPodRestart&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">leaderTemplate&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">spec&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">nodeSelector&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">nvidia.com/gpu.product&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">NVIDIA-H100-80GB-HBM3&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">containers&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>- &lt;span class="nt">name&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">vllm-leader&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">image&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">vllm/vllm-openai:v0.6.3&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">args&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>- --&lt;span class="l">model=/models/llama-3-70b&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>- --&lt;span class="l">tensor-parallel-size=5&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>- --&lt;span class="l">distributed-executor-backend=ray&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="c"># ...&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">workerTemplate&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">spec&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">nodeSelector&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">nvidia.com/gpu.product&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">NVIDIA-H100-80GB-HBM3&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">containers&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>- &lt;span class="nt">name&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">vllm-worker&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">image&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">vllm/vllm-openai:v0.6.3&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="c"># the workers join the leader&amp;#39;s Ray cluster&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>LWS guarantees startup order (workers first, leader afterwards) and an atomic lifecycle (if a worker goes down, the whole group restarts, not a single pod). Without it, the coordination is manually fragile.&lt;/p>
&lt;p>A simpler alternative, if all the tensor parallel GPUs fit on a single node (the case with HGX H100 boxes with 8 GPUs and an internal NVSwitch): one single Pod with &lt;code>nvidia.com/gpu: 5&lt;/code>, &lt;code>--tensor-parallel-size=5&lt;/code>, and vLLM handles everything internally. No Ray, no LWS, far simpler. It is the recommended path whenever it is available.&lt;/p>
&lt;h3 id="autoscaling-the-standard-hpa-is-no-use">Autoscaling: the standard HPA is no use&lt;/h3>
&lt;p>An HPA on CPU% is useless for vLLM. The GPU does the work; the pod&amp;rsquo;s CPU sits at 5–10% even under maximum load. Nor is the GPU utilisation percentage from &lt;code>dcgm-exporter&lt;/code> any use: a pod at 100% GPU% with &lt;code>gpu_cache_usage_perc=15%&lt;/code> is serving one long session without being saturated, while a pod at 60% GPU% with &lt;code>gpu_cache_usage_perc=95%&lt;/code> is on the edge of evicting sessions.&lt;/p>
&lt;p>The right metrics are exported by vLLM itself at &lt;code>/metrics&lt;/code> (Prometheus format):&lt;/p>
&lt;table>
&lt;thead>
&lt;tr>
&lt;th>Metric&lt;/th>
&lt;th>What it says&lt;/th>
&lt;th>When to scale&lt;/th>
&lt;/tr>
&lt;/thead>
&lt;tbody>
&lt;tr>
&lt;td>&lt;code>vllm:num_requests_waiting&lt;/code>&lt;/td>
&lt;td>Requests queued and not yet in the batch.&lt;/td>
&lt;td>If it stays above 5–10.&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>&lt;code>vllm:num_requests_running&lt;/code>&lt;/td>
&lt;td>Active requests in the batch.&lt;/td>
&lt;td>For capacity planning, not for scaling.&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>&lt;code>vllm:gpu_cache_usage_perc&lt;/code>&lt;/td>
&lt;td>% of the KV cache occupied.&lt;/td>
&lt;td>If sustained above 80%, there is a risk of preemption.&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>&lt;code>vllm:time_to_first_token_seconds&lt;/code>&lt;/td>
&lt;td>Prefill latency (histogram).&lt;/td>
&lt;td>If p95 exceeds your SLA.&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>&lt;code>vllm:e2e_request_latency_seconds&lt;/code>&lt;/td>
&lt;td>Total latency per request.&lt;/td>
&lt;td>An output metric.&lt;/td>
&lt;/tr>
&lt;/tbody>
&lt;/table>
&lt;p>For the HPA to consume them there are two routes: Prometheus Adapter (exposes custom metrics to the K8s API) or KEDA (scales on Prometheus queries directly, far more convenient). With KEDA:&lt;/p>
&lt;div class="highlight">&lt;pre tabindex="0" class="chroma">&lt;code class="language-yaml" data-lang="yaml">&lt;span class="line">&lt;span class="cl">&lt;span class="nt">apiVersion&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">keda.sh/v1alpha1&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w">&lt;/span>&lt;span class="nt">kind&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">ScaledObject&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w">&lt;/span>&lt;span class="nt">metadata&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">name&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">vllm-scaler&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">namespace&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">inference&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w">&lt;/span>&lt;span class="nt">spec&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">scaleTargetRef&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">name&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">vllm-llama3-8b&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">minReplicaCount&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="m">1&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">maxReplicaCount&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="m">8&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">pollingInterval&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="m">10&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">cooldownPeriod&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="m">120&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="c"># 2 min before scale-down (long sessions)&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">triggers&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>- &lt;span class="nt">type&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">prometheus&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">metadata&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">serverAddress&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">http://prometheus.monitoring:9090&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">threshold&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="s1">&amp;#39;5&amp;#39;&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">query&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="p">|&lt;/span>&lt;span class="sd">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="sd"> sum(vllm:num_requests_waiting{app=&amp;#34;vllm-llama3-8b&amp;#34;})&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>The long &lt;code>cooldownPeriod&lt;/code> matters: if you drop replicas while there are sessions decoding, you kill them. Better to leave 2 minutes of slack.&lt;/p>
&lt;h3 id="observability-the-four-metrics-that-matter">Observability: the four metrics that matter&lt;/h3>
&lt;p>Of everything &lt;code>/metrics&lt;/code> exports, a minimal dashboard needs these four:&lt;/p>
&lt;ol>
&lt;li>&lt;strong>TTFT p50/p95&lt;/strong> (time to first token) — what the user perceives when they hit send.&lt;/li>
&lt;li>&lt;strong>TPOT p50/p95&lt;/strong> (time per output token) — the &amp;ldquo;speed&amp;rdquo; of the streaming.&lt;/li>
&lt;li>&lt;strong>Aggregate throughput&lt;/strong> (tokens generated per second across the cluster) — for capacity planning.&lt;/li>
&lt;li>&lt;strong>Queue depth&lt;/strong> (&lt;code>vllm:num_requests_waiting&lt;/code>) — the leading indicator: if it grows, everything is about to degrade.&lt;/li>
&lt;/ol>
&lt;p>To that, add HBM utilisation and free memory per GPU (from &lt;code>dcgm-exporter&lt;/code>) to spot bandwidth saturation and fragmentation problems. A decent Grafana dashboard with those 6 charts anticipates 90% of incidents.&lt;/p>
&lt;h2 id="two-concrete-scenarios">Two concrete scenarios&lt;/h2>
&lt;p>We reuse the same hardware as the previous article for continuity. The same cache arithmetic, now with the engine on top.&lt;/p>
&lt;h3 id="scenario-a--1rtx-4090-workstation-or-small-k8s-node">Scenario A — 1×RTX 4090 (workstation or small K8s node)&lt;/h3>
&lt;ul>
&lt;li>&lt;strong>Topology&lt;/strong>: 1 Pod, &lt;code>--tensor-parallel-size=1&lt;/code>, 1 GPU, 1 node.&lt;/li>
&lt;li>&lt;strong>Model&lt;/strong>: up to 8B BF16 (Llama 3 8B, Qwen3 8B, Mistral 7B) or up to 14B in FP8/AWQ.&lt;/li>
&lt;li>&lt;strong>PVC&lt;/strong>: the node&amp;rsquo;s local SSD. The 4090 reads 1 TB/s from HBM; an NVMe SSD at 5 GB/s takes 5 seconds to feed 25 GB of weights into VRAM, negligible next to initialisation.&lt;/li>
&lt;li>&lt;strong>HPA&lt;/strong>: irrelevant inside the 4090 (always 1 vLLM replica per GPU), but useful across nodes: 3 replicas on 3 nodes with a 4090 each, and the K8s Service spreads round-robin.&lt;/li>
&lt;li>&lt;strong>Useful concurrency&lt;/strong>: 4–8 simultaneous sessions with 8K of context, 1–2 with 32K.&lt;/li>
&lt;li>&lt;strong>Natural use case&lt;/strong>: PoC, small teams, departmental environments, edge.&lt;/li>
&lt;/ul>
&lt;p>The manifest above is sized for this scenario. Changing only the model and the args, the same Deployment serves Qwen, Mistral or whatever is next.&lt;/p>
&lt;h3 id="scenario-b--5h100-sxm-cluster-with-nvlinknvswitch">Scenario B — 5×H100 SXM (cluster with NVLink/NVSwitch)&lt;/h3>
&lt;ul>
&lt;li>&lt;strong>Topology&lt;/strong>: 1 Pod with &lt;code>nvidia.com/gpu: 5&lt;/code> on an HGX node, &lt;code>--tensor-parallel-size=5&lt;/code>. If the platform does not allow grouping 5 GPUs into a single Pod, a LeaderWorkerSet with 5 pods coordinated by Ray.&lt;/li>
&lt;li>&lt;strong>Model&lt;/strong>: up to 70B BF16 (Llama 3 70B) or up to 200B+ in FP8 with cache quantisation.&lt;/li>
&lt;li>&lt;strong>PVC&lt;/strong>: NVMe attached directly to the node, or fast network storage (Ceph with a 25/100 GbE network, Lustre, GPFS). Loading 140 GB of weights over a slow network takes 5 minutes per start.&lt;/li>
&lt;li>&lt;strong>HPA&lt;/strong>: irrelevant inside the 5-GPU cluster (the 5 are an indivisible unit), but useful for adding whole HGX nodes when the load passes a given threshold. This combines with Cluster Autoscaler if the underlying infrastructure allows it.&lt;/li>
&lt;li>&lt;strong>Useful concurrency&lt;/strong>: 32–128 simultaneous sessions with mid-sized contexts, 4–16 with huge contexts.&lt;/li>
&lt;li>&lt;strong>Natural use case&lt;/strong>: internal corporate service, public exposure with an SLA, multi-tenant.&lt;/li>
&lt;/ul>
&lt;h3 id="a-and-b-side-by-side">A and B, side by side&lt;/h3>
&lt;table>
&lt;thead>
&lt;tr>
&lt;th>Aspect&lt;/th>
&lt;th>A (1×4090)&lt;/th>
&lt;th>B (5×H100 SXM)&lt;/th>
&lt;/tr>
&lt;/thead>
&lt;tbody>
&lt;tr>
&lt;td>Pod topology&lt;/td>
&lt;td>1 pod, 1 GPU&lt;/td>
&lt;td>1 pod with 5 GPUs (or an LWS of 5)&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>Maximum BF16 model&lt;/td>
&lt;td>8 B&lt;/td>
&lt;td>70 B&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>TTFT @ 8K context, idle&lt;/td>
&lt;td>~250 ms&lt;/td>
&lt;td>~80 ms&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>TPOT, idle&lt;/td>
&lt;td>~30 ms/tok&lt;/td>
&lt;td>~15 ms/tok&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>Throughput @ concurrency 16&lt;/td>
&lt;td>~50 tok/s/session&lt;/td>
&lt;td>~200 tok/s/session&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>Session drain&lt;/td>
&lt;td>30–60 s&lt;/td>
&lt;td>60–180 s&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>Useful autoscaling&lt;/td>
&lt;td>Replicas on peer nodes&lt;/td>
&lt;td>Whole nodes via Cluster Autoscaler&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>Reasonable multi-tenancy&lt;/td>
&lt;td>Limited: 4–8 sessions&lt;/td>
&lt;td>Comfortable: 32–128 sessions&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>Indicative cost (hardware)&lt;/td>
&lt;td>~2 K €&lt;/td>
&lt;td>~250 K € (≈ 125×)&lt;/td>
&lt;/tr>
&lt;/tbody>
&lt;/table>
&lt;p>The asymmetry is the same one as in the previous article: 125× more expensive, only ~4× more throughput per session and ~10× more concurrency. What the cluster buys is not proportional; it buys &lt;strong>access to models an order of magnitude larger&lt;/strong> and &lt;strong>latencies low enough for interactive use at scale&lt;/strong>. If your workload is batch or asynchronous agents where latency is not critical, several 4090s come surprisingly close.&lt;/p>
&lt;h2 id="vllm-against-tensorrt-llm-and-sglang">vLLM against TensorRT-LLM and SGLang&lt;/h2>
&lt;p>Honestly, all three are good engines. The choice depends on practical criteria, not technical ones. A decision map, not a benchmark:&lt;/p>
&lt;table>
&lt;thead>
&lt;tr>
&lt;th>Criterion&lt;/th>
&lt;th>vLLM&lt;/th>
&lt;th>TensorRT-LLM&lt;/th>
&lt;th>SGLang&lt;/th>
&lt;/tr>
&lt;/thead>
&lt;tbody>
&lt;tr>
&lt;td>Supported hardware&lt;/td>
&lt;td>NVIDIA, AMD ROCm, Intel Gaudi&lt;/td>
&lt;td>NVIDIA only&lt;/td>
&lt;td>NVIDIA, AMD ROCm&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>Pure latency (TTFT)&lt;/td>
&lt;td>Good&lt;/td>
&lt;td>&lt;strong>Best&lt;/strong>: kernels compiled for the exact hardware&lt;/td>
&lt;td>Good&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>Aggregate throughput&lt;/td>
&lt;td>Excellent&lt;/td>
&lt;td>Excellent&lt;/td>
&lt;td>Excellent (RadixAttention)&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>Deployment&lt;/td>
&lt;td>&lt;strong>Trivial&lt;/strong>: Docker image + args&lt;/td>
&lt;td>Complex: build an engine per model and per GPU&lt;/td>
&lt;td>Moderate&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>OpenAI-compatible API&lt;/td>
&lt;td>&lt;strong>Native, complete&lt;/strong>&lt;/td>
&lt;td>Yes, through Triton Inference Server&lt;/td>
&lt;td>Yes&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>Support for new models&lt;/td>
&lt;td>&lt;strong>Days after release&lt;/strong>&lt;/td>
&lt;td>Weeks (recompile the engine)&lt;/td>
&lt;td>Days&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>Quantization&lt;/td>
&lt;td>AWQ, GPTQ, FP8 cache&lt;/td>
&lt;td>INT4/INT8/FP8, very mature&lt;/td>
&lt;td>AWQ, FP8&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>Multi-modal&lt;/td>
&lt;td>Yes (Llava, Pixtral, Qwen-VL)&lt;/td>
&lt;td>Yes&lt;/td>
&lt;td>Excellent, a priority&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>Function calling / tool use&lt;/td>
&lt;td>Good&lt;/td>
&lt;td>Limited&lt;/td>
&lt;td>&lt;strong>First class&lt;/strong>&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>Community / release cadence&lt;/td>
&lt;td>Very active, weekly&lt;/td>
&lt;td>Active, NVIDIA-driven&lt;/td>
&lt;td>Very active, academic&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>Licence&lt;/td>
&lt;td>Apache 2.0&lt;/td>
&lt;td>Apache 2.0&lt;/td>
&lt;td>Apache 2.0&lt;/td>
&lt;/tr>
&lt;/tbody>
&lt;/table>
&lt;p>&lt;strong>When to choose each one&lt;/strong>:&lt;/p>
&lt;ul>
&lt;li>
&lt;p>&lt;strong>vLLM&lt;/strong>: the &amp;ldquo;boring choice&amp;rdquo; that works. The path of least friction to production. If your team has no dedicated inference serving specialist, this one. It supports varied hardware, up-to-date models, a stable API and a huge community.&lt;/p>
&lt;/li>
&lt;li>
&lt;p>&lt;strong>TensorRT-LLM&lt;/strong>: when per-request latency is the only metric that matters and your model is stable (trained in-house, not swapped every fortnight). The price of the performance is that every model plus every GPU plus every TRT version requires an engine rebuild, and that blocks fast iteration.&lt;/p>
&lt;/li>
&lt;li>
&lt;p>&lt;strong>SGLang&lt;/strong>: for agent-dominated workloads (heavy tool calling) or complex multi-modal. Its RadixAttention, structural caching of prompts with shared prefixes, shines in ReAct-style patterns where the same system prompt is repeated thousands of times.&lt;/p>
&lt;/li>
&lt;/ul>
&lt;p>For most teams starting out with on-prem LLM serving, &lt;strong>vLLM is the right answer until you have production data pushing you elsewhere&lt;/strong>.&lt;/p>
&lt;h2 id="common-operational-traps">Common operational traps&lt;/h2>
&lt;p>A list of gotchas that come up again and again:&lt;/p>
&lt;h3 id="the-model-is-downloaded-on-every-rolling-update">The model is downloaded on every rolling update&lt;/h3>
&lt;p>&lt;strong>Symptom&lt;/strong>: every deploy takes 5+ minutes to become available.
&lt;strong>Cause&lt;/strong>: there is no shared PVC. Every new pod downloads the model from Hugging Face from scratch.
&lt;strong>Fix&lt;/strong>: a ReadOnlyMany PVC on fast storage, or a local mirror of the registry (a Pod with &lt;code>huggingface-cli&lt;/code> serving a directory over HTTP). In CI/CD, hydrating the PVC before the rollout is 1 line of bash.&lt;/p>
&lt;h3 id="short-readiness-timeouts-that-kill-pods-while-they-load">Short readiness timeouts that kill pods while they load&lt;/h3>
&lt;p>&lt;strong>Symptom&lt;/strong>: new pods go into &lt;code>CrashLoopBackOff&lt;/code> during the first model load.
&lt;strong>Cause&lt;/strong>: a &lt;code>readinessProbe&lt;/code> with too low a timeout fires before vLLM finishes loading; the &lt;code>livenessProbe&lt;/code> finishes the job.
&lt;strong>Fix&lt;/strong>: a &lt;code>startupProbe&lt;/code> with &lt;code>failureThreshold: 60&lt;/code> or more (10 minutes of grace) before liveness starts evaluating.&lt;/p>
&lt;h3 id="an-unquantised-kv-cache-then-oom">An unquantised KV cache, then OOM&lt;/h3>
&lt;p>&lt;strong>Symptom&lt;/strong>: the pod starts fine, serves for five minutes, then gets OOMKilled when session number five arrives with a long context.
&lt;strong>Cause&lt;/strong>: a BF16 KV cache (the default) consumes twice what FP8 does.
&lt;strong>Fix&lt;/strong>: &lt;code>--kv-cache-dtype=fp8&lt;/code>. Negligible quality loss in the vast majority of cases, double the capacity.&lt;/p>
&lt;h3 id="confusing-replicas-with-concurrency">Confusing replicas with concurrency&lt;/h3>
&lt;p>&lt;strong>Symptom&lt;/strong>: the HPA scales to 8 replicas under light real load and the cloud bill goes up. Latency does not improve.
&lt;strong>Cause&lt;/strong>: someone configured &lt;code>targetAverageUtilization: 50%&lt;/code> on CPU, thinking that is &amp;ldquo;load&amp;rdquo;. The reality: a single vLLM replica serves dozens of simultaneous sessions.
&lt;strong>Fix&lt;/strong>: an HPA on &lt;code>vllm:num_requests_waiting&lt;/code>. If the queue is empty, one replica is enough even if the GPU is at 90%.&lt;/p>
&lt;h3 id="tensor-parallel-on-gpus-without-nvlink">Tensor parallel on GPUs without NVLink&lt;/h3>
&lt;p>&lt;strong>Symptom&lt;/strong>: throughput 3× worse than expected, GPUs at 30%, a lot of PCIe traffic.
&lt;strong>Cause&lt;/strong>: &lt;code>tensor_parallel=4&lt;/code> on 4 GPUs connected only by PCIe; the all-reduce saturates the bus on every layer.
&lt;strong>Fix&lt;/strong>: either the GPUs share NVLink/NVSwitch (SXM/HGX models), or you use pipeline parallel (worse latency but less all-reduce), or you reduce TP and accept that the whole model does not fit.&lt;/p>
&lt;h3 id="sessions-cut-off-during-a-rolling-update">Sessions cut off during a rolling update&lt;/h3>
&lt;p>&lt;strong>Symptom&lt;/strong>: users see truncated responses during the deploy.
&lt;strong>Cause&lt;/strong>: &lt;code>terminationGracePeriodSeconds: 30&lt;/code> (the default) is not enough to drain long generations.
&lt;strong>Fix&lt;/strong>: &lt;code>terminationGracePeriodSeconds: 120–180&lt;/code>. Combined with &lt;code>maxUnavailable: 0&lt;/code>, rollouts are invisible to active users.&lt;/p>
&lt;h2 id="what-we-have-not-covered-upcoming-articles">What we have not covered (upcoming articles)&lt;/h2>
&lt;ul>
&lt;li>&lt;strong>vLLM with hot LoRA adapters&lt;/strong>: serving a base model plus N tenant-specific adapters without reloading weights.&lt;/li>
&lt;li>&lt;strong>Disaggregated serving&lt;/strong>: separating prefill and decode into specialised pods, each optimised for its GPU profile.&lt;/li>
&lt;li>&lt;strong>Quantization deep-dive&lt;/strong>: AWQ vs GPTQ vs dynamic FP8 vs FP4, the real trade-offs, and when to use each.&lt;/li>
&lt;li>&lt;strong>Gateway API + AI Inference Extensions&lt;/strong>: the sigwg proposal to make LLMs first-class citizens in K8s (routing by model, sticky sessions per conversation, multi-tenant fairness).&lt;/li>
&lt;li>&lt;strong>Multi-modal serving&lt;/strong>: the same runtime, a different kind of request — images, audio, embeddings.&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/llm-serving-engines-pareto-frontier-comparison/">Comparing serving engines (vLLM/SGLang/TRT-LLM/Dynamo)&lt;/a> — how vLLM behaves against SGLang and TRT-LLM on the goodput-latency Pareto frontier: the data that justifies (or questions) the choice of vLLM on Kubernetes.&lt;/li>
&lt;li>&lt;a href="https://blog.lo0.es/en/posts/cloud-gpu-on-demand-reserved-spot-neoclouds-2026-prices/">Cloud GPU: comparing prices, commitment and sovereign neoclouds&lt;/a> — if the Kubernetes cluster is not on-premise but cloud, here are the spot and reserved prices of the European neoclouds where you would run these manifests.&lt;/li>
&lt;/ul>
&lt;h2 id="references">References&lt;/h2>
&lt;ul>
&lt;li>Kwon et al., &lt;a href="https://arxiv.org/abs/2309.06180">&lt;em>Efficient Memory Management for Large Language Model Serving with PagedAttention&lt;/em>&lt;/a> (SOSP 2023) — the original vLLM paper.&lt;/li>
&lt;li>Yu et al., &lt;a href="https://www.usenix.org/conference/osdi22/presentation/yu">&lt;em>Orca: A Distributed Serving System for Transformer-Based Generative Models&lt;/em>&lt;/a> (OSDI 2022) — the paper that popularised &lt;em>continuous batching&lt;/em>.&lt;/li>
&lt;li>&lt;a href="https://docs.vllm.ai/">Official vLLM documentation&lt;/a> — operational and well maintained.&lt;/li>
&lt;li>&lt;a href="https://docs.nvidia.com/datacenter/cloud-native/gpu-operator/">NVIDIA GPU Operator&lt;/a> — installation and troubleshooting of the GPU layer on Kubernetes.&lt;/li>
&lt;li>&lt;a href="https://kubernetes.io/blog/2024/04/16/introducing-leaderworkerset/">LeaderWorkerSet&lt;/a> — the primitive for coordinated workloads such as multi-pod tensor parallel.&lt;/li>
&lt;li>&lt;a href="https://keda.sh/">KEDA&lt;/a> — event-driven autoscaling, ideal for scaling on queue metrics.&lt;/li>
&lt;li>&lt;a href="https://github.com/NVIDIA/TensorRT-LLM">TensorRT-LLM&lt;/a> and &lt;a href="https://github.com/sgl-project/sglang">SGLang&lt;/a> — the two most serious comparables.&lt;/li>
&lt;li>&lt;a href="https://lmsys.org/">LMSYS Chatbot Arena&lt;/a> — periodic benchmarks comparing the three engines.&lt;/li>
&lt;li>Previous article on this blog: &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>.&lt;/li>
&lt;/ul></description></item><item><title>KV cache: the working memory that holds up LLM inference</title><link>https://blog.lo0.es/en/posts/kv-cache-working-memory-llm-inference/</link><pubDate>Mon, 18 May 2026 10:00:00 +0200</pubDate><guid>https://blog.lo0.es/en/posts/kv-cache-working-memory-llm-inference/</guid><description>&lt;h2 id="tldr">TL;DR&lt;/h2>
&lt;p>The KV cache is the &lt;strong>working memory&lt;/strong> a language model keeps during a conversation. Without it, every new token would force a recomputation of the whole conversation from the start, at a cost that is &lt;strong>quadratic&lt;/strong> in the length of the text. With it, the cost is linear, but in exchange the cache &lt;strong>lives in VRAM and grows with every token&lt;/strong>. In practice, it is not the model that limits how much context you can serve: it is the KV cache. On an RTX 4090 running Llama 3 8B, the model fits in 16 GB and barely leaves room for ~64 K tokens of cache in total, adding up all the simultaneous sessions. Understanding that number is the difference between promising a client &amp;ldquo;128 K of context&amp;rdquo; and actually delivering it.&lt;/p>
&lt;h2 id="you-are-here-deploy">You are here: Deploy&lt;/h2>
&lt;p>This post opens the series on LLM inference fundamentals. Within the &lt;a href="https://blog.lo0.es/en/posts/six-stage-llmops-pipeline-architecture-and-deep-dive/">six-stage LLMOps pipeline&lt;/a> that articulates the whole system, the KV cache lives in the &lt;strong>Deploy&lt;/strong> stage: it is the piece that dictates how much traffic fits in your inference engine and, therefore, how much platform you can offer on top of it.&lt;/p>
&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:#ff8a4c;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(#kvm)}.cyc{stroke:#888;stroke-width:1.2;fill:none;stroke-dasharray:4 2;marker-end:url(#kvm)}&lt;/style>
&lt;defs>&lt;marker id="kvm" 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 · KV cache as the VRAM bottleneck&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="the-analogy-the-speaker-with-amnesia">The analogy: the speaker with amnesia&lt;/h2>
&lt;p>Imagine you are at a two-hour technical conference. Every time the speaker is about to say a new sentence, he &lt;strong>mentally rewinds the whole talk from the beginning&lt;/strong>, reassembles the thread, and only then carries on. His next sentence requires recalling the previous one; the one after that, the two before it; an hour in, every new word costs him an hour of recap. A conference like that would be materially impossible.&lt;/p>
&lt;p>Now imagine the same speaker with a notebook in which he jots down, as he goes, the two or three key ideas of each sentence: subject, object, link to what came before. Before each new sentence he glances at the notebook and continues. His next word only costs a glance at the notebook, not a rewind of the entire talk.&lt;/p>
&lt;p>That notebook, in a transformer, is called the &lt;strong>KV cache&lt;/strong>. Without it, conversational language models would be unworkable. With it, they are commercial products. But the notebook &lt;strong>weighs something&lt;/strong>, and understanding how much, where and why is what separates an inference infrastructure that works from one that falls over at the third concurrent client.&lt;/p>
&lt;h2 id="the-mechanism-itself-in-plain-terms">The mechanism itself (in plain terms)&lt;/h2>
&lt;p>A transformer generates text &lt;strong>one token at a time&lt;/strong>. To decide the next token, the model applies a mechanism called &lt;strong>attention&lt;/strong> over all previous tokens: it asks &amp;ldquo;which parts of the earlier context are relevant for predicting what comes next?&amp;rdquo;.&lt;/p>
&lt;p>Internally, each input token is projected into three vectors:&lt;/p>
&lt;ul>
&lt;li>&lt;strong>Q&lt;/strong> (Query): &amp;ldquo;what I am looking for&amp;rdquo;&lt;/li>
&lt;li>&lt;strong>K&lt;/strong> (Key): &amp;ldquo;what this token offers&amp;rdquo;&lt;/li>
&lt;li>&lt;strong>V&lt;/strong> (Value): &amp;ldquo;what information this token carries&amp;rdquo;&lt;/li>
&lt;/ul>
&lt;p>The attention of the current token against the context is computed by multiplying its &lt;strong>Q&lt;/strong> against the &lt;strong>K&lt;/strong> of all previous tokens, normalising with softmax, and weighting the corresponding &lt;strong>V&lt;/strong>. The result is a contextualised representation of the current token.&lt;/p>
&lt;div class="diagram" style="max-width: 720px; margin: 1.5rem auto;">
&lt;svg viewBox="0 0 720 260" xmlns="http://www.w3.org/2000/svg" role="img" aria-label="Diagram of the attention computation with Q, K, V">
&lt;style>
.box { fill: #f4f4f4; stroke: #333; stroke-width: 1.5; }
.box-q { fill: #ffe9d6; }
.box-k { fill: #d6eaff; }
.box-v { fill: #d9f5d6; }
.lbl { font: 600 13px sans-serif; fill: #222; }
.lbl-sm { font: 11px sans-serif; fill: #444; }
.arr { stroke: #444; stroke-width: 1.4; fill: none; marker-end: url(#ah); }
&lt;/style>
&lt;defs>
&lt;marker id="ah" 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="360" y="22" text-anchor="middle" class="lbl">Attention computation for token N&lt;/text>
&lt;rect x="40" y="60" width="120" height="40" rx="6" class="box box-q"/>
&lt;text x="100" y="85" text-anchor="middle" class="lbl">Q (token N)&lt;/text>
&lt;text x="100" y="115" text-anchor="middle" class="lbl-sm">"what I seek"&lt;/text>
&lt;rect x="280" y="60" width="160" height="40" rx="6" class="box box-k"/>
&lt;text x="360" y="85" text-anchor="middle" class="lbl">K (tokens 1..N)&lt;/text>
&lt;text x="360" y="115" text-anchor="middle" class="lbl-sm">from the cache&lt;/text>
&lt;rect x="560" y="60" width="120" height="40" rx="6" class="box box-v"/>
&lt;text x="620" y="85" text-anchor="middle" class="lbl">V (tokens 1..N)&lt;/text>
&lt;text x="620" y="115" text-anchor="middle" class="lbl-sm">from the cache&lt;/text>
&lt;path class="arr" d="M160,80 L280,80"/>
&lt;path class="arr" d="M440,80 L560,80"/>
&lt;p>&lt;text x="220" y="74" text-anchor="middle" class="lbl-sm">Q·Kᵀ → softmax&lt;/text>
&lt;text x="500" y="74" text-anchor="middle" class="lbl-sm">× V&lt;/text>&lt;/p>
&lt;rect x="240" y="170" width="240" height="44" rx="6" class="box"/>
&lt;text x="360" y="197" text-anchor="middle" class="lbl">representation of token N&lt;/text>
&lt;path class="arr" d="M620,100 C620,150 480,150 480,170"/>
&lt;path class="arr" d="M100,100 C100,150 240,150 240,170"/>
&lt;/svg>
&lt;/div>
&lt;p>Here is the key point: to predict token N, I only need the &lt;strong>new Q&lt;/strong> (that of token N) and the &lt;strong>K, V of all previous tokens&lt;/strong>. The K and V of tokens 1..N-1 have not changed since the previous iteration. Recomputing them would be throwing work away.&lt;/p>
&lt;p>&lt;strong>The KV cache is exactly that: the memory that holds the K and V of every token already processed, in every layer of the model, so they never have to be recomputed.&lt;/strong>&lt;/p>
&lt;h2 id="why-it-exists-the-quadratic-cost-without-it">Why it exists: the quadratic cost without it&lt;/h2>
&lt;p>Generating a text of N tokens involves N steps. At step &lt;code>i&lt;/code>, attention is computed over &lt;code>i&lt;/code> previous tokens. Without a cache, at every step you recompute the K, V of the &lt;code>i-1&lt;/code> previous tokens &lt;strong>plus&lt;/strong> those of the new one. The total count of attention computations grows as:&lt;/p>
$$\sum_{i=1}^{N} i = \frac{N(N+1)}{2} \approx \frac{N^2}{2}$$
&lt;p>With a KV cache, you only process the new token at each step: cost &lt;strong>linear in N&lt;/strong>.&lt;/p>
&lt;div class="diagram" style="max-width: 720px; margin: 1.5rem auto;">
&lt;svg viewBox="0 0 720 320" xmlns="http://www.w3.org/2000/svg" role="img" aria-label="Comparison of linear vs quadratic cost">
&lt;style>
.kv-ax { stroke: currentColor; stroke-width: 1.5; opacity: 0.4; fill: none; }
.kv-grid { stroke: currentColor; stroke-width: 1; stroke-dasharray: 4,4; opacity: 0.15; fill: none; }
.kv-lin { stroke: #2a9d8f; stroke-width: 3; fill: none; }
.kv-quad { stroke: #e76f51; stroke-width: 3; fill: none; }
.kv-lbl { font: 600 13px sans-serif; fill: currentColor; }
.kv-sm { font: 11px sans-serif; fill: currentColor; opacity: 0.6; }
.kv-note { font: italic 10px sans-serif; fill: currentColor; opacity: 0.45; }
.kv-tlin { fill: #2a9d8f; font: 700 12px sans-serif; }
.kv-tqud { fill: #e76f51; font: 700 12px sans-serif; }
&lt;/style>
&lt;!-- Title -->
&lt;p>&lt;text x="380" y="22" text-anchor="middle" class="kv-lbl">Cumulative compute to generate N tokens&lt;/text>
&lt;text x="380" y="38" text-anchor="middle" class="kv-note">(schematic scale — exact data is in the table)&lt;/text>&lt;/p>
&lt;!-- Axes -->
&lt;line class="kv-ax" x1="80" y1="260" x2="680" y2="260"/>
&lt;line class="kv-ax" x1="80" y1="55" x2="80" y2="260"/>
&lt;!-- Axis labels -->
&lt;p>&lt;text x="380" y="295" text-anchor="middle" class="kv-sm">tokens generated (N)&lt;/text>
&lt;text x="25" y="158" text-anchor="middle" class="kv-sm" transform="rotate(-90 25 158)">attention operations&lt;/text>&lt;/p>
&lt;!-- X ticks -->
&lt;p>&lt;text x="80" y="277" text-anchor="middle" class="kv-sm">0&lt;/text>
&lt;text x="230" y="277" text-anchor="middle" class="kv-sm">1K&lt;/text>
&lt;text x="380" y="277" text-anchor="middle" class="kv-sm">2K&lt;/text>
&lt;text x="530" y="277" text-anchor="middle" class="kv-sm">3K&lt;/text>
&lt;text x="680" y="277" text-anchor="middle" class="kv-sm">4K&lt;/text>&lt;/p>
&lt;!-- Grid -->
&lt;line class="kv-grid" x1="80" y1="210" x2="680" y2="210"/>
&lt;line class="kv-grid" x1="80" y1="160" x2="680" y2="160"/>
&lt;line class="kv-grid" x1="80" y1="110" x2="680" y2="110"/>
&lt;line class="kv-grid" x1="80" y1="60" x2="680" y2="60"/>
&lt;!-- Linear curve (with KV cache): O(N) — gentle, visible slope -->
&lt;path class="kv-lin" d="M80,260 L680,215"/>
&lt;!-- Quadratic curve (without KV cache): O(N²) — starts flat, rises fast -->
&lt;path class="kv-quad" d="M80,260 C250,258 420,200 680,58"/>
&lt;!-- Curve labels -->
&lt;p>&lt;text x="570" y="208" class="kv-tlin">with KV cache&lt;/text>
&lt;text x="570" y="222" class="kv-tlin">O(N) — linear&lt;/text>&lt;/p>
&lt;p>&lt;text x="440" y="95" class="kv-tqud">without KV cache&lt;/text>
&lt;text x="440" y="109" class="kv-tqud">O(N²) — quadratic&lt;/text>
&lt;/svg>&lt;/p>
&lt;/div>
&lt;p>The concrete numbers are devastating:&lt;/p>
&lt;table>
&lt;thead>
&lt;tr>
&lt;th style="text-align:right">Tokens generated&lt;/th>
&lt;th style="text-align:right">Without KV cache (operations)&lt;/th>
&lt;th style="text-align:right">With KV cache&lt;/th>
&lt;th style="text-align:right">Ratio&lt;/th>
&lt;/tr>
&lt;/thead>
&lt;tbody>
&lt;tr>
&lt;td style="text-align:right">128&lt;/td>
&lt;td style="text-align:right">8,256&lt;/td>
&lt;td style="text-align:right">128&lt;/td>
&lt;td style="text-align:right">64×&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td style="text-align:right">1,024&lt;/td>
&lt;td style="text-align:right">524,800&lt;/td>
&lt;td style="text-align:right">1,024&lt;/td>
&lt;td style="text-align:right">512×&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td style="text-align:right">4,096&lt;/td>
&lt;td style="text-align:right">8,390,656&lt;/td>
&lt;td style="text-align:right">4,096&lt;/td>
&lt;td style="text-align:right">2,048×&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td style="text-align:right">32,768&lt;/td>
&lt;td style="text-align:right">536,887,296&lt;/td>
&lt;td style="text-align:right">32,768&lt;/td>
&lt;td style="text-align:right">16,384×&lt;/td>
&lt;/tr>
&lt;/tbody>
&lt;/table>
&lt;p>At 32 K tokens, &lt;strong>the cache saves you four orders of magnitude&lt;/strong> of compute. This is not an optimisation: it is what makes conversational inference possible.&lt;/p>
&lt;h2 id="the-price-how-heavy-the-backpack-is">The price: how heavy the backpack is&lt;/h2>
&lt;p>The KV cache is paid for in VRAM. The formula, per &lt;strong>sequence&lt;/strong>, is:&lt;/p>
&lt;pre tabindex="0">&lt;code>KV_size = 2 · n_layers · n_kv_heads · head_dim · context_len · bytes_per_param
↑
K and V
&lt;/code>&lt;/pre>&lt;p>Per &lt;strong>token&lt;/strong> (dropping &lt;code>context_len&lt;/code>), it is a constant of the model itself. Let us look at real numbers:&lt;/p>
&lt;table>
&lt;thead>
&lt;tr>
&lt;th>Model&lt;/th>
&lt;th style="text-align:right">n_layers&lt;/th>
&lt;th style="text-align:right">n_kv_heads&lt;/th>
&lt;th style="text-align:right">head_dim&lt;/th>
&lt;th style="text-align:right">Bytes/token (BF16)&lt;/th>
&lt;th style="text-align:right">GB at 8 K ctx&lt;/th>
&lt;th style="text-align:right">GB at 32 K&lt;/th>
&lt;th style="text-align:right">GB at 128 K&lt;/th>
&lt;/tr>
&lt;/thead>
&lt;tbody>
&lt;tr>
&lt;td>Llama 3 8B (hypothetical MHA)&lt;/td>
&lt;td style="text-align:right">32&lt;/td>
&lt;td style="text-align:right">32&lt;/td>
&lt;td style="text-align:right">128&lt;/td>
&lt;td style="text-align:right">524,288&lt;/td>
&lt;td style="text-align:right">4.00&lt;/td>
&lt;td style="text-align:right">16.00&lt;/td>
&lt;td style="text-align:right">64.00&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>&lt;strong>Llama 3 8B (real GQA)&lt;/strong>&lt;/td>
&lt;td style="text-align:right">32&lt;/td>
&lt;td style="text-align:right">8&lt;/td>
&lt;td style="text-align:right">128&lt;/td>
&lt;td style="text-align:right">131,072&lt;/td>
&lt;td style="text-align:right">&lt;strong>1.00&lt;/strong>&lt;/td>
&lt;td style="text-align:right">&lt;strong>4.00&lt;/strong>&lt;/td>
&lt;td style="text-align:right">&lt;strong>16.00&lt;/strong>&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>Llama 3 70B (GQA)&lt;/td>
&lt;td style="text-align:right">80&lt;/td>
&lt;td style="text-align:right">8&lt;/td>
&lt;td style="text-align:right">128&lt;/td>
&lt;td style="text-align:right">327,680&lt;/td>
&lt;td style="text-align:right">2.50&lt;/td>
&lt;td style="text-align:right">10.00&lt;/td>
&lt;td style="text-align:right">40.00&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>Qwen3 8B (GQA)&lt;/td>
&lt;td style="text-align:right">36&lt;/td>
&lt;td style="text-align:right">8&lt;/td>
&lt;td style="text-align:right">128&lt;/td>
&lt;td style="text-align:right">147,456&lt;/td>
&lt;td style="text-align:right">1.12&lt;/td>
&lt;td style="text-align:right">4.50&lt;/td>
&lt;td style="text-align:right">18.00&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>Mistral 7B (GQA)&lt;/td>
&lt;td style="text-align:right">32&lt;/td>
&lt;td style="text-align:right">8&lt;/td>
&lt;td style="text-align:right">128&lt;/td>
&lt;td style="text-align:right">131,072&lt;/td>
&lt;td style="text-align:right">1.00&lt;/td>
&lt;td style="text-align:right">4.00&lt;/td>
&lt;td style="text-align:right">16.00&lt;/td>
&lt;/tr>
&lt;/tbody>
&lt;/table>
&lt;p>Two immediate readings:&lt;/p>
&lt;ol>
&lt;li>&lt;strong>Without GQA there is no 128 K worth talking about.&lt;/strong> A Llama 3 8B with classic multi-head attention would need 64 GB of KV cache alone for a single sequence with 128 K tokens. In other words, &lt;strong>it does not fit on any consumer GPU&lt;/strong>. That is why Meta, Mistral and the rest adopted Grouped Query Attention.&lt;/li>
&lt;li>&lt;strong>The KV cache can be larger than the model.&lt;/strong> Llama 3 8B in BF16 takes ~16 GB. With 128 K of context, its cache is another 16 GB. A single session ties with the model in VRAM.&lt;/li>
&lt;/ol>
&lt;div class="diagram" style="max-width: 720px; margin: 1.5rem auto;">
&lt;svg viewBox="0 0 720 280" xmlns="http://www.w3.org/2000/svg" role="img" aria-label="Growth of the KV cache with context length">
&lt;style>
.ax { stroke: #333; stroke-width: 1.5; }
.grid { stroke: #ddd; stroke-width: 1; stroke-dasharray: 3,3; }
.l8b { stroke: #2a9d8f; stroke-width: 2.5; fill: none; }
.l70b { stroke: #e76f51; stroke-width: 2.5; fill: none; }
.lq8 { stroke: #6a4c93; stroke-width: 2.5; fill: none; stroke-dasharray: 5,3; }
.lim { stroke: #c1121f; stroke-width: 1.5; stroke-dasharray: 4,4; }
.lbl { font: 600 13px sans-serif; fill: #222; }
.lbl-sm { font: 11px sans-serif; fill: #555; }
.tag { font: 600 11px sans-serif; }
&lt;/style>
&lt;text x="360" y="20" text-anchor="middle" class="lbl">KV cache (GB) vs context length (1 sequence, BF16)&lt;/text>
&lt;line class="ax" x1="80" y1="240" x2="680" y2="240"/>
&lt;line class="ax" x1="80" y1="40" x2="80" y2="240"/>
&lt;line class="grid" x1="80" y1="190" x2="680" y2="190"/>
&lt;line class="grid" x1="80" y1="140" x2="680" y2="140"/>
&lt;line class="grid" x1="80" y1="90" x2="680" y2="90"/>
&lt;p>&lt;text x="75" y="244" text-anchor="end" class="lbl-sm">0&lt;/text>
&lt;text x="75" y="194" text-anchor="end" class="lbl-sm">10&lt;/text>
&lt;text x="75" y="144" text-anchor="end" class="lbl-sm">20&lt;/text>
&lt;text x="75" y="94" text-anchor="end" class="lbl-sm">30&lt;/text>
&lt;text x="75" y="44" text-anchor="end" class="lbl-sm">40 GB&lt;/text>&lt;/p>
&lt;p>&lt;text x="80" y="258" text-anchor="middle" class="lbl-sm">0&lt;/text>
&lt;text x="180" y="258" text-anchor="middle" class="lbl-sm">8K&lt;/text>
&lt;text x="305" y="258" text-anchor="middle" class="lbl-sm">32K&lt;/text>
&lt;text x="430" y="258" text-anchor="middle" class="lbl-sm">64K&lt;/text>
&lt;text x="680" y="258" text-anchor="middle" class="lbl-sm">128K&lt;/text>&lt;/p>
&lt;!-- Available VRAM limit on an RTX 4090 (~8 GB free after the model) -->
&lt;line class="lim" x1="80" y1="200" x2="680" y2="200"/>
&lt;text x="680" y="196" text-anchor="end" class="tag" fill="#c1121f">≈ VRAM free after loading an 8B on a 4090&lt;/text>
&lt;!-- Llama 3 8B GQA: linear, 1 GB @8K, 16 GB @128K -->
&lt;path class="l8b" d="M80,240 L180,235 L305,220 L430,200 L680,160"/>
&lt;!-- Qwen3 8B GQA -->
&lt;path class="lq8" d="M80,240 L180,234 L305,217 L430,194 L680,150"/>
&lt;!-- Llama 3 70B GQA -->
&lt;path class="l70b" d="M80,240 L180,228 L305,190 L430,140 L680,40"/>
&lt;p>&lt;text x="690" y="160" class="tag" fill="#2a9d8f">Llama 3 8B&lt;/text>
&lt;text x="690" y="148" class="tag" fill="#6a4c93">Qwen3 8B&lt;/text>
&lt;text x="690" y="42" class="tag" fill="#e76f51">Llama 3 70B&lt;/text>
&lt;/svg>&lt;/p>
&lt;/div>
&lt;p>The dashed red line marks the realistic VRAM available on an RTX 4090 after loading the model. &lt;strong>Any model whose curve crosses that line will not be able to serve that context&lt;/strong> without additional strategies: cache quantisation, offload, partitioning.&lt;/p>
&lt;h2 id="inference-is-memory-bound-not-compute-bound">Inference is memory-bound, not compute-bound&lt;/h2>
&lt;p>There is a common misconception: thinking that &amp;ldquo;fast GPU = fast inference&amp;rdquo;. In the regime where inference services with a KV cache actually operate, &lt;strong>what gets measured is memory bandwidth&lt;/strong>. Every new token requires reading the K and V of all previous tokens from HBM. The compute is modest; the data movement is massive.&lt;/p>
&lt;p>That is why an H100 SXM (3.35 TB/s of HBM3) can be 2-3× faster than an A100 (1.55-2 TB/s) &lt;strong>without the clock frequency or the core count fully explaining the difference&lt;/strong>. Bandwidth explains it.&lt;/p>
&lt;p>And that is also why offers of &amp;ldquo;cheap GPUs with plenty of VRAM but slow HBM&amp;rdquo; (some variants with GDDR6 or LPDDR5) disappoint in inference with long contexts: they have room to store the cache but take forever to re-read it.&lt;/p>
&lt;h2 id="tricks-to-make-the-notebook-thinner">Tricks to make the notebook thinner&lt;/h2>
&lt;p>Three techniques, in chronological order, have progressively flattened the size of the KV cache:&lt;/p>
&lt;p>&lt;strong>Multi-Head Attention (MHA).&lt;/strong> The original transformer design (Vaswani et al., 2017). Every attention head has its own K and V. Expensive in cache but theoretically maximal in expressiveness. It is what models had until around 2023.&lt;/p>
&lt;p>&lt;strong>Multi-Query Attention (MQA).&lt;/strong> A single K and V shared by all heads. It reduces the cache by a factor of &lt;code>n_heads&lt;/code>. It works reasonably well but degrades generation quality on some benchmarks.&lt;/p>
&lt;p>&lt;strong>Grouped Query Attention (GQA).&lt;/strong> The middle ground that won. Heads are grouped: in Llama 3 8B, 32 query heads share K, V in groups of 4, giving 8 KV groups. It cuts the cache 4× relative to MHA with almost identical quality. It has been the de facto standard since 2024.&lt;/p>
&lt;p>&lt;strong>Multi-Head Latent Attention (MLA).&lt;/strong> DeepSeek-V2/V3&amp;rsquo;s innovation: instead of storing K, V per head, it compresses the state into a smaller latent vector and projects out to K, V on the fly. The cache can come down to 70 bytes/token, two orders of magnitude less than GQA. It is the main reason DeepSeek-V3 (671 B parameters, 37 B active) is servable on affordable infrastructure.&lt;/p>
&lt;div class="diagram" style="max-width: 640px; margin: 1.5rem auto;">
&lt;svg viewBox="0 0 640 200" xmlns="http://www.w3.org/2000/svg" role="img" aria-label="KV cache reduction by technique">
&lt;style>
.bar { stroke: #333; stroke-width: 1; }
.b-mha { fill: #e76f51; }
.b-gqa { fill: #f4a261; }
.b-mqa { fill: #e9c46a; }
.b-mla { fill: #2a9d8f; }
.lbl { font: 600 13px sans-serif; fill: #222; }
.lbl-sm{ font: 11px sans-serif; fill: #444; }
&lt;/style>
&lt;text x="320" y="20" text-anchor="middle" class="lbl">KB of cache per token (Llama 3 8B equivalent, BF16)&lt;/text>
&lt;rect x="200" y="40" width="380" height="22" class="bar b-mha"/>
&lt;text x="170" y="56" text-anchor="end" class="lbl-sm">MHA (32 KV heads)&lt;/text>
&lt;text x="595" y="56" class="lbl-sm">512 KB&lt;/text>
&lt;rect x="200" y="76" width="95" height="22" class="bar b-gqa"/>
&lt;text x="170" y="92" text-anchor="end" class="lbl-sm">GQA (8 KV heads)&lt;/text>
&lt;text x="310" y="92" class="lbl-sm">128 KB&lt;/text>
&lt;rect x="200" y="112" width="12" height="22" class="bar b-mqa"/>
&lt;text x="170" y="128" text-anchor="end" class="lbl-sm">MQA (1 KV head)&lt;/text>
&lt;text x="225" y="128" class="lbl-sm">16 KB&lt;/text>
&lt;rect x="200" y="148" width="3" height="22" class="bar b-mla"/>
&lt;text x="170" y="164" text-anchor="end" class="lbl-sm">MLA (DeepSeek-V3)&lt;/text>
&lt;text x="215" y="164" class="lbl-sm">~0.5 KB (real V3)&lt;/text>
&lt;/svg>
&lt;/div>
&lt;blockquote>
&lt;p>&lt;strong>Note:&lt;/strong> the MLA bar is illustrative, using typical values published by DeepSeek; the exact implementation depends on the latent size. What matters is the order of magnitude.&lt;/p>
&lt;/blockquote>
&lt;p>On top of this comes a fourth, orthogonal technique: &lt;strong>quantising the cache&lt;/strong> to FP8, INT8 or even INT4. vLLM and TensorRT-LLM already support it in production. Going from BF16 (2 bytes) to FP8 (1 byte) &lt;strong>halves the cache&lt;/strong> at a small cost in quality. Going to INT4 divides it by four, at a somewhat higher cost.&lt;/p>
&lt;h2 id="the-next-dragon-fragmentation">The next dragon: fragmentation&lt;/h2>
&lt;p>So far we have talked about the cache as if it were a contiguous block. In practice, an inference server handles &lt;strong>dozens of simultaneous sessions&lt;/strong>, each with its own cache growing at a different rate. Naive allocation, reserving the maximum possible per session, &lt;strong>wastes between 60 % and 80 % of the VRAM&lt;/strong> according to the original PagedAttention paper.&lt;/p>
&lt;div class="diagram" style="max-width: 720px; margin: 1.5rem auto;">
&lt;svg viewBox="0 0 720 240" xmlns="http://www.w3.org/2000/svg" role="img" aria-label="KV cache fragmentation: naive vs PagedAttention">
&lt;style>
.used { fill: #2a9d8f; stroke: #1a6e63; stroke-width: 1; }
.free { fill: #f0e7d8; stroke: #aaa; stroke-width: 1; }
.blk { stroke: #555; stroke-width: 0.5; }
.lbl { font: 600 13px sans-serif; fill: #222; }
.lbl-sm { font: 11px sans-serif; fill: #444; }
&lt;/style>
&lt;text x="180" y="22" text-anchor="middle" class="lbl">Naive allocation (contiguous)&lt;/text>
&lt;text x="540" y="22" text-anchor="middle" class="lbl">PagedAttention (blocks)&lt;/text>
&lt;!-- Naive: 4 sessions reserve the maximum, use little -->
&lt;p>&lt;text x="30" y="60" class="lbl-sm">session A&lt;/text>
&lt;rect x="90" y="48" width="50" height="18" class="used"/>
&lt;rect x="140" y="48" width="180" height="18" class="free"/>&lt;/p>
&lt;p>&lt;text x="30" y="92" class="lbl-sm">session B&lt;/text>
&lt;rect x="90" y="80" width="25" height="18" class="used"/>
&lt;rect x="115" y="80" width="205" height="18" class="free"/>&lt;/p>
&lt;p>&lt;text x="30" y="124" class="lbl-sm">session C&lt;/text>
&lt;rect x="90" y="112" width="100" height="18" class="used"/>
&lt;rect x="190" y="112" width="130" height="18" class="free"/>&lt;/p>
&lt;p>&lt;text x="30" y="156" class="lbl-sm">session D&lt;/text>
&lt;rect x="90" y="144" width="35" height="18" class="used"/>
&lt;rect x="125" y="144" width="195" height="18" class="free"/>&lt;/p>
&lt;p>&lt;text x="180" y="190" text-anchor="middle" class="lbl-sm">→ ~70 % of VRAM reserved and empty&lt;/text>&lt;/p>
&lt;!-- PagedAttention: small blocks, dense occupancy -->
&lt;g transform="translate(400,40)">
&lt;!-- 8 blocks x 5 rows -->
&lt;g>
&lt;!-- row 1 -->
&lt;rect x="0" y="0" width="30" height="20" class="used blk"/>
&lt;rect x="30" y="0" width="30" height="20" class="used blk"/>
&lt;rect x="60" y="0" width="30" height="20" class="used blk"/>
&lt;rect x="90" y="0" width="30" height="20" class="used blk"/>
&lt;rect x="120" y="0" width="30" height="20" class="used blk"/>
&lt;rect x="150" y="0" width="30" height="20" class="used blk"/>
&lt;rect x="180" y="0" width="30" height="20" class="used blk"/>
&lt;rect x="210" y="0" width="30" height="20" class="used blk"/>
&lt;pre>&lt;code> &amp;lt;rect x=&amp;quot;0&amp;quot; y=&amp;quot;22&amp;quot; width=&amp;quot;30&amp;quot; height=&amp;quot;20&amp;quot; class=&amp;quot;used blk&amp;quot;/&amp;gt;
&amp;lt;rect x=&amp;quot;30&amp;quot; y=&amp;quot;22&amp;quot; width=&amp;quot;30&amp;quot; height=&amp;quot;20&amp;quot; class=&amp;quot;used blk&amp;quot;/&amp;gt;
&amp;lt;rect x=&amp;quot;60&amp;quot; y=&amp;quot;22&amp;quot; width=&amp;quot;30&amp;quot; height=&amp;quot;20&amp;quot; class=&amp;quot;used blk&amp;quot;/&amp;gt;
&amp;lt;rect x=&amp;quot;90&amp;quot; y=&amp;quot;22&amp;quot; width=&amp;quot;30&amp;quot; height=&amp;quot;20&amp;quot; class=&amp;quot;used blk&amp;quot;/&amp;gt;
&amp;lt;rect x=&amp;quot;120&amp;quot; y=&amp;quot;22&amp;quot; width=&amp;quot;30&amp;quot; height=&amp;quot;20&amp;quot; class=&amp;quot;used blk&amp;quot;/&amp;gt;
&amp;lt;rect x=&amp;quot;150&amp;quot; y=&amp;quot;22&amp;quot; width=&amp;quot;30&amp;quot; height=&amp;quot;20&amp;quot; class=&amp;quot;used blk&amp;quot;/&amp;gt;
&amp;lt;rect x=&amp;quot;180&amp;quot; y=&amp;quot;22&amp;quot; width=&amp;quot;30&amp;quot; height=&amp;quot;20&amp;quot; class=&amp;quot;free blk&amp;quot;/&amp;gt;
&amp;lt;rect x=&amp;quot;210&amp;quot; y=&amp;quot;22&amp;quot; width=&amp;quot;30&amp;quot; height=&amp;quot;20&amp;quot; class=&amp;quot;free blk&amp;quot;/&amp;gt;
&amp;lt;rect x=&amp;quot;0&amp;quot; y=&amp;quot;44&amp;quot; width=&amp;quot;30&amp;quot; height=&amp;quot;20&amp;quot; class=&amp;quot;used blk&amp;quot;/&amp;gt;
&amp;lt;rect x=&amp;quot;30&amp;quot; y=&amp;quot;44&amp;quot; width=&amp;quot;30&amp;quot; height=&amp;quot;20&amp;quot; class=&amp;quot;used blk&amp;quot;/&amp;gt;
&amp;lt;rect x=&amp;quot;60&amp;quot; y=&amp;quot;44&amp;quot; width=&amp;quot;30&amp;quot; height=&amp;quot;20&amp;quot; class=&amp;quot;used blk&amp;quot;/&amp;gt;
&amp;lt;rect x=&amp;quot;90&amp;quot; y=&amp;quot;44&amp;quot; width=&amp;quot;30&amp;quot; height=&amp;quot;20&amp;quot; class=&amp;quot;free blk&amp;quot;/&amp;gt;
&amp;lt;rect x=&amp;quot;120&amp;quot; y=&amp;quot;44&amp;quot; width=&amp;quot;30&amp;quot; height=&amp;quot;20&amp;quot; class=&amp;quot;free blk&amp;quot;/&amp;gt;
&amp;lt;rect x=&amp;quot;150&amp;quot; y=&amp;quot;44&amp;quot; width=&amp;quot;30&amp;quot; height=&amp;quot;20&amp;quot; class=&amp;quot;free blk&amp;quot;/&amp;gt;
&amp;lt;rect x=&amp;quot;180&amp;quot; y=&amp;quot;44&amp;quot; width=&amp;quot;30&amp;quot; height=&amp;quot;20&amp;quot; class=&amp;quot;free blk&amp;quot;/&amp;gt;
&amp;lt;rect x=&amp;quot;210&amp;quot; y=&amp;quot;44&amp;quot; width=&amp;quot;30&amp;quot; height=&amp;quot;20&amp;quot; class=&amp;quot;free blk&amp;quot;/&amp;gt;
&amp;lt;/g&amp;gt;
&lt;/code>&lt;/pre>
&lt;/g>
&lt;text x="540" y="190" text-anchor="middle" class="lbl-sm">→ &amp;lt; 4 % waste (vLLM paper)&lt;/text>
&lt;/svg>
&lt;/div>
&lt;p>&lt;strong>PagedAttention&lt;/strong>, the idea from Kwon et al. (2023) that gave rise to vLLM, solves this by borrowing a technique from operating systems: divide the VRAM into small &lt;strong>blocks&lt;/strong> (typically 16 tokens) and keep a &lt;strong>page table&lt;/strong> mapping logical to physical blocks per session. A session no longer reserves one huge contiguous block: it grows one block at a time, and the blocks can be scattered across the VRAM. The result is an effective occupancy of 90 % instead of 30 %, and therefore &lt;strong>2-4× more aggregate throughput&lt;/strong> on the same hardware.&lt;/p>
&lt;p>PagedAttention deserves an article of its own. I am noting it down for the next one.&lt;/p>
&lt;h2 id="applied-to-generic-on-premise-hardware">Applied to generic on-premise hardware&lt;/h2>
&lt;p>Let us come down to concrete cases.&lt;/p>
&lt;h3 id="case-1--rtx-4090-24-gb-ada-lovelace">Case 1 — RTX 4090 (24 GB, Ada Lovelace)&lt;/h3>
&lt;p>A typical configuration with Qwen3-8B BF16:&lt;/p>
&lt;pre tabindex="0">&lt;code>Model in BF16: ~16 GB
Activations + overhead: ~2 GB
VRAM available for KV cache: ~6 GB (with margin)
&lt;/code>&lt;/pre>&lt;p>At 144 KB/token (Qwen3-8B GQA), that comes to &lt;strong>~43 K total tokens of cache&lt;/strong> distributed across all simultaneous sessions. In practice:&lt;/p>
&lt;table>
&lt;thead>
&lt;tr>
&lt;th style="text-align:right">Concurrency&lt;/th>
&lt;th style="text-align:right">Maximum context per session&lt;/th>
&lt;/tr>
&lt;/thead>
&lt;tbody>
&lt;tr>
&lt;td style="text-align:right">1&lt;/td>
&lt;td style="text-align:right">32,768&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td style="text-align:right">4&lt;/td>
&lt;td style="text-align:right">8,192&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td style="text-align:right">8&lt;/td>
&lt;td style="text-align:right">4,096&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td style="text-align:right">16&lt;/td>
&lt;td style="text-align:right">2,048&lt;/td>
&lt;/tr>
&lt;/tbody>
&lt;/table>
&lt;p>If you need to advertise &amp;ldquo;we support 32 K of context&amp;rdquo; with concurrency of 4 or more, you have to &lt;strong>quantise the cache&lt;/strong> (FP8 brings it down to 72 KB/token, doubling capacity) or &lt;strong>move up the model range&lt;/strong> (a 4B with GQA and a quantised cache would have room to spare).&lt;/p>
&lt;h3 id="case-2--cluster-of-4h100-320-gb-total-nvlink">Case 2 — Cluster of 4×H100 (320 GB total, NVLink)&lt;/h3>
&lt;p>With tensor parallel = 4 and Llama 3 70B BF16:&lt;/p>
&lt;pre tabindex="0">&lt;code>Model in BF16: ~140 GB (35 GB/GPU)
vLLM overhead per GPU: ~2 GB
VRAM free for KV per GPU: ~43 GB → ~172 GB aggregate
&lt;/code>&lt;/pre>&lt;p>At 320 KB/token (Llama 3 70B GQA), that comes to &lt;strong>~537 K total tokens of cache&lt;/strong>. Ample margin for long contexts with high concurrency:&lt;/p>
&lt;table>
&lt;thead>
&lt;tr>
&lt;th style="text-align:right">Concurrency&lt;/th>
&lt;th style="text-align:right">Maximum context per session&lt;/th>
&lt;/tr>
&lt;/thead>
&lt;tbody>
&lt;tr>
&lt;td style="text-align:right">4&lt;/td>
&lt;td style="text-align:right">134,000&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td style="text-align:right">16&lt;/td>
&lt;td style="text-align:right">33,500&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td style="text-align:right">64&lt;/td>
&lt;td style="text-align:right">8,375&lt;/td>
&lt;/tr>
&lt;/tbody>
&lt;/table>
&lt;p>For DeepSeek-V3 671 B with MLA the economics change radically, because the cache is ~100× thinner. What limits you is no longer the cache but the VRAM of the model itself (quantised to FP8 it is ~335 GB, so it fits on 4×H100 with room for the KV cache).&lt;/p>
&lt;h3 id="operational-implications">Operational implications&lt;/h3>
&lt;p>Three observations we repeat in every consulting engagement:&lt;/p>
&lt;p>First, &lt;strong>the maximum context a model advertises is not the one you can serve on your hardware&lt;/strong>. Llama 3 8B &amp;ldquo;supports&amp;rdquo; 128 K, but on a 4090 with 4 simultaneous sessions your effective context is ~8 K. It is trivial to check before promising it to a client.&lt;/p>
&lt;p>Second, &lt;strong>quantising the KV cache is one of the optimisations with the best cost/benefit ratio in the ENS context&lt;/strong>. It does not touch the weights, it does not affect audit reproducibility, and it doubles capacity. vLLM supports it via &lt;code>--kv-cache-dtype fp8&lt;/code>.&lt;/p>
&lt;p>Third, &lt;strong>if the SLAs dictate long contexts with many concurrent users, GQA is necessary but not sufficient&lt;/strong>. In the medium term you need to look at models with MLA or attention variants with compression.&lt;/p>
&lt;h2 id="what-we-have-not-covered-upcoming-articles">What we have not covered (upcoming articles)&lt;/h2>
&lt;ul>
&lt;li>&lt;strong>PagedAttention&lt;/strong> and its implementation in vLLM: blocks, page table, eviction.&lt;/li>
&lt;li>&lt;strong>Prefix caching&lt;/strong>: when several requests share the system prompt, there is no need to recompute the K, V of the common part.&lt;/li>
&lt;li>&lt;strong>Speculative decoding&lt;/strong> and its interaction with the cache.&lt;/li>
&lt;li>&lt;strong>Cache offloading&lt;/strong>: moving cold blocks to RAM or NVMe, a key technique for contexts above 1 M.&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/six-stage-llmops-pipeline-architecture-and-deep-dive/">The six-stage LLMOps pipeline&lt;/a> — the master map of the system in production, in which the Deploy stage is one box out of six. This post goes into one of the critical decisions inside Deploy.&lt;/li>
&lt;li>&lt;a href="https://blog.lo0.es/en/posts/pagedattention-vllm-block-manager/">Inside PagedAttention: blocks, page table, eviction and the state of the art of the KV cache in 2026&lt;/a> — a theoretical deep dive at block level plus a survey of derived optimisations (vAttention, EvicPress, RadixAttention, speculative decoding). It continues this post from the academic theory.&lt;/li>
&lt;li>&lt;a href="https://blog.lo0.es/en/posts/continuous-fine-tuning-production-real-traffic-deployed-adapter/">Continuous fine-tuning in production: from real traffic to the deployed adapter&lt;/a> — how the loop between inference and incremental training closes on the same stack (vLLM + Postgres), with VRAM budgets that explicitly include the KV cache during eval.&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 KV cache stops being a private GPU buffer and becomes the artefact transferred between pods. Here the cache size formula determines the economics of the transfer.&lt;/li>
&lt;li>&lt;a href="https://blog.lo0.es/en/posts/gpu-cluster-as-multi-tenant-platform/">The GPU cluster as a multi-tenant platform&lt;/a> — how the cluster becomes a service with tenants, gateway, quotas and isolation. That is where the KV cache stops being only a performance resource and becomes a platform matter.&lt;/li>
&lt;li>&lt;a href="https://blog.lo0.es/en/posts/vllm-kubernetes-llm-inference-that-scales/">vLLM on Kubernetes: the LLM inference piece that does scale&lt;/a> — the engine that materialises everything discussed here, deployed on K8s with tensor parallel and autoscaling.&lt;/li>
&lt;li>&lt;a href="https://blog.lo0.es/en/posts/quantization-llm-inference-fp8-int4-gguf/">Quantization for LLM inference: FP8, INT4 and GGUF&lt;/a> — KV cache quantisation (&lt;code>--kv-cache-dtype=fp8/int4&lt;/code>), mentioned here as the fourth orthogonal technique, is taken apart there with the maths, the formats and the measurable loss.&lt;/li>
&lt;li>&lt;a href="https://blog.lo0.es/en/posts/speculative-decoding-fundamentals-maths-state-of-play/">Speculative decoding: the secretary who anticipates what the boss will say&lt;/a> — the memory-bound decode regime caused by the KV cache is exactly what speculative decoding exploits: a forward pass with γ tokens costs almost the same as one with a single token.&lt;/li>
&lt;li>&lt;a href="https://blog.lo0.es/en/posts/flashattention-librarian-io-awareness-async-blackwell-asymmetry/">FlashAttention v1/v2/v3/v4: the librarian who never clears the desk&lt;/a> — the kernel that sweeps the KV cache against Q on every iteration without materialising the N×N matrix. The compute layer underneath the cache.&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> — attention is still dense in every MoE of 2026 (Mixtral, DeepSeek, Qwen3, Llama 4, Kimi K2), so the KV cache keeps its shape; DeepSeek&amp;rsquo;s MLA is the orthogonal optimisation that compresses it ~10× to make long context viable on modest clusters.&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 iterative scheduler manages the dynamic allocation of the KV cache across requests; without PagedAttention, continuous batching would fragment the HBM, and without continuous batching the KV cache would be underused because of padding.&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 KV cache is the dominant component of the VRAM budget when sizing a cluster from an SLO; the spreadsheet is built there step by step.&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> — the four concrete knobs (chunked prefill, prefix caching, FP8 KV, max-model-len) that turn KV cache theory into production parameters for the RTX 4090 and the L40.&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> — how &lt;code>--gpu-memory-utilization&lt;/code>, speculative decoding and an FP8 KV cache combine to squeeze small hardware during the generation phase.&lt;/li>
&lt;/ul>
&lt;h2 id="references">References&lt;/h2>
&lt;ul>
&lt;li>Vaswani et al., &lt;em>Attention Is All You Need&lt;/em> (NeurIPS 2017) — foundational transformer paper.&lt;/li>
&lt;li>Ainslie et al., &lt;em>GQA: Training Generalized Multi-Query Transformer Models from Multi-Head Checkpoints&lt;/em> (EMNLP 2023).&lt;/li>
&lt;li>Kwon et al., &lt;em>Efficient Memory Management for Large Language Model Serving with PagedAttention&lt;/em> (SOSP 2023) — original vLLM paper.&lt;/li>
&lt;li>DeepSeek-AI, &lt;em>DeepSeek-V2 Technical Report&lt;/em> (2024) — introduction of Multi-Head Latent Attention.&lt;/li>
&lt;li>Official vLLM documentation: &lt;a href="https://docs.vllm.ai/">https://docs.vllm.ai/&lt;/a>.&lt;/li>
&lt;li>Llama 3 model card (Meta): GQA specs, n_layers, n_kv_heads.&lt;/li>
&lt;/ul></description></item></channel></rss>