<?xml version="1.0" encoding="utf-8" standalone="yes"?><rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom"><channel><title>Prefill on lo0 — Tech Blog</title><link>https://blog.lo0.es/en/tags/prefill/</link><description>Recent content in Prefill on lo0 — Tech Blog</description><generator>Hugo -- gohugo.io</generator><language>en</language><lastBuildDate>Tue, 09 Jun 2026 02:20:00 +0000</lastBuildDate><atom:link href="https://blog.lo0.es/en/tags/prefill/index.xml" rel="self" type="application/rss+xml"/><item><title>Aggressive RAG in small models: trading parameters for retrieval</title><link>https://blog.lo0.es/en/posts/aggressive-rag-small-models-trading-parameters-for-retrieval/</link><pubDate>Tue, 09 Jun 2026 02:20:00 +0000</pubDate><guid>https://blog.lo0.es/en/posts/aggressive-rag-small-models-trading-parameters-for-retrieval/</guid><description>&lt;blockquote>
&lt;p>This post belongs to the series on inference performance in small models. Its sibling piece, &lt;a href="https://blog.lo0.es/en/posts/roofline-flips-optimising-small-models/">The roofline inverts in small models&lt;/a>, explains why the compute-bound prefill is the bottleneck that shapes the whole discussion here. It is worth reading first: here we assume that &lt;strong>adding more context is not free&lt;/strong>.&lt;/p>
&lt;/blockquote>
&lt;h2 id="tldr">TL;DR&lt;/h2>
&lt;p>An SLM (say 1B–8B parameters) knows &lt;strong>fewer facts&lt;/strong> than a 70B–700B model, simply because it has fewer weights in which to memorise them. But its ability to &lt;strong>reason over text in front of it&lt;/strong>, following instructions, extracting, synthesising, comparing, degrades far less with size than its encyclopaedic knowledge does. The operational consequence is direct: use the SLM as a &lt;strong>reasoning engine over curated context&lt;/strong>, not as a database. Move knowledge from the weights into the context via retrieval. The problem is that &amp;ldquo;aggressive retrieval&amp;rdquo; is often read as &amp;ldquo;put in lots of chunks&amp;rdquo;, and that collides head-on with three facts about SLMs: shorter context windows, worse use of long context (the &lt;em>lost in the middle&lt;/em> effect is more severe the smaller the model) and a &lt;strong>compute-bound prefill&lt;/strong> whose cost grows with the context length $C$, linearly in the projections and quadratically in attention. You cannot simply add tokens. Each retrieved token is paid for twice, in degraded quality and in TTFT, and the SLM is the worst placed to absorb either cost. The way out is not to retrieve less, but to retrieve &lt;em>better&lt;/em>: reranking for precision over recall, compressing the context before injecting it, prefix caching the stable documents, semantic caching of answers, and structured output with external tools that stand in for internal knowledge. This post works through the maths and gives a TTFT number before and after compressing a context from 4,000 to 1,000 tokens on an RTX 4090.&lt;/p>
&lt;h2 id="the-analogy-the-open-book-exam">The analogy: the open-book exam&lt;/h2>
&lt;p>Two students sit the same exam. The first has a prodigious memory: they have memorised the entire syllabus, paragraph by paragraph. The second has an ordinary memory, forgetting dates, confusing names, but is allowed to bring in a &lt;strong>crib sheet&lt;/strong>.&lt;/p>
&lt;p>If the second student&amp;rsquo;s crib sheet is a chaos of piled-up photocopies, they lose: they take too long to find what they are after, get distracted by irrelevant pages and run out of time. But if their crib sheet is &lt;em>excellent&lt;/em>, trimmed to the essentials, reordered by relevance, with the important things at the top and no filler, then they not only do not lose: they often &lt;em>win&lt;/em>, because they reason just as well as the first student and, on top of that, work from verified material instead of from blurry recollections they may be inventing.&lt;/p>
&lt;p>The moral has three layers, and each maps to an engineering decision:&lt;/p>
&lt;ul>
&lt;li>&lt;strong>Memorising everything is expensive.&lt;/strong> The first student invested months. A large model invests parameters, and VRAM, and inference FLOPs, in memorising facts.&lt;/li>
&lt;li>&lt;strong>The crib sheet matters more than its size.&lt;/strong> A well-made one-page crib sheet beats ten badly organised pages. More retrieved context is not better context: the material&amp;rsquo;s precision beats volume.&lt;/li>
&lt;li>&lt;strong>Knowing how to search and synthesise is a different skill from knowing.&lt;/strong> It is the one the SLM retains. The whole strategy consists of leaning on that skill and subcontracting the memory.&lt;/li>
&lt;/ul>
&lt;p>The rest of the post is, essentially, how to build an excellent crib sheet under the constraint that the student (the SLM) reads slowly and gets tired with long texts.&lt;/p>
&lt;h2 id="the-capacity-argument-how-many-facts-fit-in-the-weights">The capacity argument: how many facts fit in the weights&lt;/h2>
&lt;p>Let us start by justifying the thesis with an order of magnitude, not with faith. How much factual knowledge really fits in a model&amp;rsquo;s weights?&lt;/p>
&lt;p>There is a recurring empirical estimate in the interpretability and memorisation literature: a dense model can store on the order of &lt;strong>2 bits of memorised information per parameter&lt;/strong> before saturating (the exact figure varies by study and training regime; take it as an order of magnitude, not as a law). An 8B-parameter model then has an information storage ceiling on the order of:&lt;/p>
$$8 \times 10^9 \text{ params} \times 2 \text{ bits/param} = 1.6 \times 10^{10} \text{ bits} \approx 2 \text{ GB of information}$$
&lt;p>And that budget &lt;strong>is not only for facts&lt;/strong>: the vast majority goes on grammar, syntax, reasoning ability, code, formatting, and only a fraction is left for encyclopaedic knowledge. Compare it with the other side: a retrievable corpus of several million documents, a corporate wiki, a document repository, a technical knowledge base, easily takes up &lt;strong>hundreds of GB to terabytes&lt;/strong> of text, indexed and queryable with millisecond latency. The asymmetry is &lt;strong>two or three orders of magnitude&lt;/strong> in favour of the external corpus.&lt;/p>
&lt;p>The conclusion is not that the weights are useless, they are where reasoning lives, which is the expensive thing to replicate, but that &lt;strong>competing with an external index on fact capacity is losing by construction&lt;/strong>. A 70B model has ~9× more memorisation budget than an 8B one, but it is still negligible against the corpus. That is why the large model &lt;em>also&lt;/em> does RAG in production. The difference is that the SLM &lt;em>needs it&lt;/em>: without retrieval, its factual knowledge is too sparse and, worse, &lt;strong>prone to hallucinating&lt;/strong> exactly in the gaps it did not memorise.&lt;/p>
&lt;div class="diagram" style="max-width:780px;margin:1.5rem auto;">
&lt;svg viewBox="0 0 780 250" xmlns="http://www.w3.org/2000/svg" role="img" aria-label="Knowledge in weights versus knowledge in context">
&lt;text x="390" y="24" text-anchor="middle" font-size="15" font-weight="700" fill="currentColor">Where knowledge lives&lt;/text>
&lt;text x="200" y="58" text-anchor="middle" font-size="13" font-weight="700" fill="currentColor">In the weights (memorised)&lt;/text>
&lt;rect x="90" y="70" width="220" height="60" fill="#d4ecff" stroke="#1f5fa8" stroke-width="1.4"/>
&lt;text x="200" y="95" text-anchor="middle" font-size="12" fill="#1f3550">~2 GB of useful info in 8B&lt;/text>
&lt;text x="200" y="113" text-anchor="middle" font-size="11" fill="#1f3550">fixed, costly to update, hallucinates in gaps&lt;/text>
&lt;text x="580" y="58" text-anchor="middle" font-size="13" font-weight="700" fill="currentColor">In the context (retrieved)&lt;/text>
&lt;rect x="430" y="70" width="300" height="60" fill="#cdebd0" stroke="#2a7a40" stroke-width="1.4"/>
&lt;text x="580" y="95" text-anchor="middle" font-size="12" fill="#1c3a26">hundreds of GB – TB indexed&lt;/text>
&lt;text x="580" y="113" text-anchor="middle" font-size="11" fill="#1c3a26">fresh, citable, verifiable, no retraining&lt;/text>
&lt;text x="390" y="165" text-anchor="middle" font-size="13" font-weight="700" fill="currentColor">The SLM as a reasoning engine&lt;/text>
&lt;rect x="240" y="178" width="300" height="46" fill="#fff4d6" stroke="#a48000" stroke-width="1.4"/>
&lt;text x="390" y="200" text-anchor="middle" font-size="12" fill="#5a4500">reasons over the curated context&lt;/text>
&lt;text x="390" y="216" text-anchor="middle" font-size="11" fill="#5a4500">not the database: the one who reads and synthesises it&lt;/text>
&lt;path d="M200,130 L360,178" stroke="currentColor" stroke-width="1.2" fill="none"/>
&lt;path d="M580,130 L420,178" stroke="currentColor" stroke-width="1.2" fill="none"/>
&lt;/svg>
&lt;/div>
&lt;h2 id="the-central-tension-retrieving-more-is-not-putting-in-more">The central tension: retrieving more is not putting in more&lt;/h2>
&lt;p>This is where most naive designs break. &amp;ldquo;Aggressive retrieval&amp;rdquo; sounds like a large &lt;em>top-k&lt;/em>: if retrieval helps, retrieve 20 chunks instead of 5. But in an SLM that fails for two independent reasons, one of &lt;em>quality&lt;/em> and one of &lt;em>cost&lt;/em>.&lt;/p>
&lt;h3 id="a-slms-make-worse-use-of-long-context">(a) SLMs make worse use of long context&lt;/h3>
&lt;p>The &lt;em>lost in the middle&lt;/em> effect (Liu et al., 2023) is well known: LLMs retrieve information placed at the beginning and end of the context better, and the information in the middle worse. What gets less emphasis is that &lt;strong>the effect is more severe the smaller the model&lt;/strong>. An SLM has fewer attention heads, fewer layers and poorer internal representations for &amp;ldquo;tracking&amp;rdquo; a relevant fact buried at position 14 out of 20 chunks. Its nominal context window also tends to be shorter (4K–32K against the 128K+ of the large ones), and the &lt;strong>effective window&lt;/strong>, the length beyond which quality collapses, is smaller still. Putting in 20 chunks does not mean the model reads all 20: it means it will probably ignore or misread the ones in the middle, while paying the cost of all of them.&lt;/p>
&lt;h3 id="b-prefill-grows-with-the-context-and-is-compute-bound">(b) Prefill grows with the context and is compute-bound&lt;/h3>
&lt;p>This is the blow people underestimate. Prefill, processing the full prompt before emitting the first token, is the &lt;strong>compute-bound&lt;/strong> phase of inference (unlike decode, which is memory-bound; the detail lives in &lt;a href="https://blog.lo0.es/en/posts/roofline-flips-optimising-small-models/">The roofline inverts&lt;/a>). Its cost &lt;strong>grows with the context length&lt;/strong> $C$, and it determines TTFT (time to first token). More chunks → more prefill tokens → more TTFT and more compute cost per request. In an SLM, where prefill is proportionally more expensive relative to the model, this hurts especially.&lt;/p>
&lt;p>The operational conclusion is uncomfortable but clear: &lt;strong>you cannot compensate for fewer parameters simply by putting in more context.&lt;/strong> Each retrieved token is paid for twice, in degraded quality and in TTFT, and the SLM is the worst placed to absorb both costs. The way out is to retrieve &lt;em>less but better&lt;/em>, and to compress what you retrieve.&lt;/p>
&lt;h2 id="the-maths-of-prefill">The maths of prefill&lt;/h2>
&lt;p>Let us put numbers on &amp;ldquo;prefill grows with the context&amp;rdquo;. For a context of $C$ tokens, a transformer layer does two classes of work:&lt;/p>
&lt;ol>
&lt;li>&lt;strong>Linear projections&lt;/strong> (QKV, attention output, FFN): each token is multiplied by fixed-size weight matrices. The cost is $O(C)$ in FLOPs, linear in the number of tokens.&lt;/li>
&lt;li>&lt;strong>Attention&lt;/strong> ($QK^\top$ and the multiplication by $V$): each token attends to all the others. The cost is $O(C^2)$, quadratic in the number of tokens.&lt;/li>
&lt;/ol>
&lt;p>The total prefill cost per layer has the form:&lt;/p>
$$\text{FLOPs}_{\text{prefill}} \approx \underbrace{a \cdot C}_{\text{projections}} + \underbrace{b \cdot C^2}_{\text{attention}}$$
&lt;p>with $a$ and $b$ constants that depend on the model dimension. For moderate contexts (a few thousand tokens) in an SLM, the linear term still dominates or is comparable to the quadratic one; the quadratic term becomes dominant at long contexts. What matters: &lt;strong>if you compress the context&lt;/strong> $C \to C/k$, the linear term falls $\times k$ and the quadratic one falls $\times k^2$. Compression is the only lever that attacks both terms at once, and it attacks the worse one disproportionately.&lt;/p>
&lt;h3 id="numerical-example-ttft-before-and-after-compressing-rtx-4090">Numerical example: TTFT before and after compressing, RTX 4090&lt;/h3>
&lt;p>Let us model TTFT as the time to process the prefill tokens at a given prefill throughput. Take an RTX 4090 (24 GB, Ada Lovelace) serving a quantised SLM, with a &lt;strong>prefill throughput of ~5,000 tok/s&lt;/strong> (an illustrative figure; the real value depends on the model, the quantisation and the batch, so measure it, do not assume it).&lt;/p>
&lt;p>Let there be a retrieved context of &lt;strong>4,000 tokens&lt;/strong> (8 chunks of ~500 tokens). Approximating TTFT as dominated by the context prefill:&lt;/p>
$$\text{TTFT}_{\text{before}} \approx \frac{4000 \text{ tok}}{5000 \text{ tok/s}} = 0.80 \text{ s}$$
&lt;p>Now we compress that context to &lt;strong>1,000 tokens&lt;/strong> ($k = 4$). Prefill throughput is not constant with $C$, it drops a little at long contexts because of the quadratic term, but taking the conservative linear tokens/throughput approximation:&lt;/p>
$$\text{TTFT}_{\text{after}} \approx \frac{1000 \text{ tok}}{5000 \text{ tok/s}} = 0.20 \text{ s}$$
&lt;p>TTFT falls from &lt;strong>0.80 s to 0.20 s&lt;/strong>, a $4\times$ reduction in the linear part. But the FLOPs arithmetic is even more favourable in the attention component: that part of the work falls $\sim k^2 = 16\times$. In practice total TTFT does not fall 16× because the cost is not purely quadratic at this scale, but the real reduction lies &lt;strong>between 4× and something larger depending on how much attention weighed&lt;/strong>, and the aggregate compute saving (what pays the electricity bill and frees the GPU for another request) is substantially bigger than the plain 4× of the token count.&lt;/p>
&lt;p>The argument generalises: &lt;strong>compressing the context by a factor $k$ reduces TTFT by at least $\sim k\times$ and the attention cost by $\sim k^2\times$.&lt;/strong> For an SLM, where TTFT is often the SLA that matters, this is the difference between an assistant that answers instantly and one that feels slow.&lt;/p>
&lt;h2 id="the-five-levers-to-resolve-the-tension">The five levers to resolve the tension&lt;/h2>
&lt;p>The strategy is not &amp;ldquo;retrieve less and settle&amp;rdquo;. It is &lt;strong>retrieve aggressively from the index and then distil aggressively what you retrieved&lt;/strong> before it reaches the SLM. Five levers, in order of application within the pipeline.&lt;/p>
&lt;h3 id="1-aggressive-reranking-precision-over-recall">1. Aggressive reranking: precision over recall&lt;/h3>
&lt;p>The initial retriever (dense, sparse or hybrid) optimises &lt;em>recall&lt;/em>: it brings back 50–100 candidates so as not to leave anything out. The reranker, a cross-encoder that sees the query and the document together, optimises &lt;em>precision&lt;/em>: it reorders those candidates and you keep the &lt;strong>3–5 best&lt;/strong>. For an SLM this is not a luxury, it is structural: since the model uses long context badly, every chunk that goes in must &lt;em>earn its place&lt;/em>. Better 4 chunks of very high relevance than 15 mediocre ones. The detail of hybrid retrieval and reranking is in &lt;a href="https://blog.lo0.es/en/posts/aggressive-rag-small-models-trading-parameters-for-retrieval/#see-also">Reranking and hybrid retrieval&lt;/a>; here the rule is enough: &lt;strong>maximise recall in the retriever, maximise precision in the reranker, and inject few&lt;/strong>.&lt;/p>
&lt;h3 id="2-context-compression-distilling-the-crib-sheet">2. Context compression: distilling the crib sheet&lt;/h3>
&lt;p>Once you have the best chunks, they still contain filler: padding sentences, redundancy, context irrelevant to the specific query. &lt;strong>Context compression&lt;/strong> trims them before injection:&lt;/p>
&lt;ul>
&lt;li>&lt;strong>Extractive compression&lt;/strong> (LLMLingua / LongLLMLingua style, Jiang et al. 2023): a small model scores the &lt;em>perplexity&lt;/em> or relevance of each token/sentence with respect to the query and removes the low-information ones, keeping the densest extractive subset. It reduces tokens without a second large generative model in the loop. LongLLMLingua adds position-aware reordering to mitigate &lt;em>lost in the middle&lt;/em>.&lt;/li>
&lt;li>&lt;strong>Abstractive compression&lt;/strong>: a model summarises the retrieved chunks into shorter text. More aggressive in token reduction, but it introduces a generative step (cost and possible loss of fidelity).&lt;/li>
&lt;li>&lt;strong>Soft prompts / context distillation&lt;/strong>: compressing the retrieved context not into text, but into a handful of &lt;strong>embeddings/soft tokens&lt;/strong> that the model consumes directly. It reduces the number of prefill tokens to a minimum, at the price of a trained, model-specific component.&lt;/li>
&lt;/ul>
&lt;p>The key point connects with the maths above: &lt;strong>compressing what you retrieved by a factor $k$ reduces prefill tokens $\times k$, and therefore TTFT $\sim\times k$ and the attention cost $\sim\times k^2$.&lt;/strong> It is the lever with the best return when long context is the bottleneck.&lt;/p>
&lt;h3 id="3-prefix-caching-of-stable-context">3. Prefix caching of stable context&lt;/h3>
&lt;p>Not all the context changes between requests. System instructions, definitions, recurring reference documents, schemas: these are &lt;strong>stable prefixes&lt;/strong>. &lt;strong>Prefix caching&lt;/strong> stores the already-computed KV cache of those prefixes and reuses it, so that prefill only processes the new part (the query and the specific chunks). If 60 % of your context is stable, you save 60 % of that segment&amp;rsquo;s prefill on every hit. For it to work, &lt;strong>the stable context must go at the start of the prompt&lt;/strong> (the KV cache is prefix-dependent) and it pays to maximise the &lt;em>hit rate&lt;/em>; the hit-rate engineering detail is in &lt;a href="https://blog.lo0.es/en/posts/aggressive-rag-small-models-trading-parameters-for-retrieval/#see-also">Prefix cache hit rate&lt;/a>. It combines especially well with RAG: retrieved documents that repeat across sessions get cached once.&lt;/p>
&lt;h3 id="4-semantic-caching-of-answers">4. Semantic caching of answers&lt;/h3>
&lt;p>A layer in front of the model: if a query is &lt;strong>semantically equivalent&lt;/strong> to one answered before (embedding similarity above a threshold), return the cached answer and &lt;strong>skip the model entirely&lt;/strong>, retrieval, prefill and decode included. In real workloads with long tails of repeated or near-repeated questions (FAQ, support), the saving is enormous because it eliminates the full cost, not just the prefill one. The trap is the threshold: too lax and you serve wrong answers to similar-but-different questions. The design is in &lt;a href="https://blog.lo0.es/en/posts/aggressive-rag-small-models-trading-parameters-for-retrieval/#see-also">Semantic caching for RAG&lt;/a>.&lt;/p>
&lt;h3 id="5-structured-output-and-function-calling-lean-on-tools-not-on-memory">5. Structured output and function calling: lean on tools, not on memory&lt;/h3>
&lt;p>The last lever changes what the SLM depends on. Instead of asking it to &lt;em>know&lt;/em> a fact (its weak point), make it &lt;em>call a tool&lt;/em> that knows it: a database query, an API, a calculator, a validator. &lt;strong>Structured output&lt;/strong> (forcing JSON conforming to a schema) and &lt;strong>function calling&lt;/strong> turn the SLM into an orchestrator that extracts arguments from the context and delegates the computation or the query. A reasonably capable SLM emits a well-formed &lt;em>tool call&lt;/em> far more reliably than it recalls a specific fact. This reduces the pressure on parametric knowledge &lt;em>and&lt;/em> on retrieval: for structured, fresh data (prices, inventory, states), querying beats retrieving text and beats memorising. The fundamentals are in &lt;a href="https://blog.lo0.es/en/posts/aggressive-rag-small-models-trading-parameters-for-retrieval/#see-also">Structured output&lt;/a> and &lt;a href="https://blog.lo0.es/en/posts/aggressive-rag-small-models-trading-parameters-for-retrieval/#see-also">Function calling&lt;/a>.&lt;/p>
&lt;h2 id="the-complete-pipeline">The complete pipeline&lt;/h2>
&lt;p>The five levers are not alternatives: they chain together. The flow, with the token counter falling at each step:&lt;/p>
&lt;div class="diagram" style="max-width:780px;margin:1.5rem auto;">
&lt;svg viewBox="0 0 780 290" xmlns="http://www.w3.org/2000/svg" role="img" aria-label="Retrieve, rerank, compress, SLM pipeline with the token counter falling">
&lt;defs>&lt;marker id="rag1" 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="22" text-anchor="middle" font-size="15" font-weight="700" fill="currentColor">Retrieve aggressively, distil aggressively, reason cheaply&lt;/text>
&lt;rect x="20" y="50" width="120" height="60" fill="#d4ecff" stroke="#1f5fa8" stroke-width="1.4"/>
&lt;text x="80" y="76" text-anchor="middle" font-size="12" font-weight="700" fill="#1f3550">Retriever&lt;/text>
&lt;text x="80" y="93" text-anchor="middle" font-size="11" fill="#1f3550">hybrid, recall&lt;/text>
&lt;text x="80" y="130" text-anchor="middle" font-size="12" font-weight="700" fill="#1f5fa8">~80 chunks&lt;/text>
&lt;rect x="180" y="50" width="120" height="60" fill="#e6d0ff" stroke="#5a2db0" stroke-width="1.4"/>
&lt;text x="240" y="76" text-anchor="middle" font-size="12" font-weight="700" fill="#3a1d70">Reranker&lt;/text>
&lt;text x="240" y="93" text-anchor="middle" font-size="11" fill="#3a1d70">precision&lt;/text>
&lt;text x="240" y="130" text-anchor="middle" font-size="12" font-weight="700" fill="#5a2db0">5 chunks · 4,000 tok&lt;/text>
&lt;rect x="340" y="50" width="120" height="60" fill="#fff4d6" stroke="#a48000" stroke-width="1.4"/>
&lt;text x="400" y="76" text-anchor="middle" font-size="12" font-weight="700" fill="#5a4500">Compression&lt;/text>
&lt;text x="400" y="93" text-anchor="middle" font-size="11" fill="#5a4500">extractive k=4&lt;/text>
&lt;text x="400" y="130" text-anchor="middle" font-size="12" font-weight="700" fill="#a48000">1,000 tok&lt;/text>
&lt;rect x="500" y="50" width="120" height="60" fill="#cdebd0" stroke="#2a7a40" stroke-width="1.4"/>
&lt;text x="560" y="73" text-anchor="middle" font-size="12" font-weight="700" fill="#1c3a26">Prefix cache&lt;/text>
&lt;text x="560" y="90" text-anchor="middle" font-size="11" fill="#1c3a26">+ semantic&lt;/text>
&lt;text x="560" y="103" text-anchor="middle" font-size="11" fill="#1c3a26">cache&lt;/text>
&lt;text x="560" y="130" text-anchor="middle" font-size="12" font-weight="700" fill="#2a7a40">minimal prefill&lt;/text>
&lt;rect x="660" y="50" width="100" height="60" fill="#f6caca" stroke="#a52a2a" stroke-width="1.4"/>
&lt;text x="710" y="73" text-anchor="middle" font-size="12" font-weight="700" fill="#6a1a1a">SLM&lt;/text>
&lt;text x="710" y="90" text-anchor="middle" font-size="11" fill="#6a1a1a">reasons +&lt;/text>
&lt;text x="710" y="103" text-anchor="middle" font-size="11" fill="#6a1a1a">tool calls&lt;/text>
&lt;text x="710" y="130" text-anchor="middle" font-size="12" font-weight="700" fill="#a52a2a">answer&lt;/text>
&lt;path d="M140,80 L180,80" stroke="currentColor" stroke-width="1.6" fill="none" marker-end="url(#rag1)"/>
&lt;path d="M300,80 L340,80" stroke="currentColor" stroke-width="1.6" fill="none" marker-end="url(#rag1)"/>
&lt;path d="M460,80 L500,80" stroke="currentColor" stroke-width="1.6" fill="none" marker-end="url(#rag1)"/>
&lt;path d="M620,80 L660,80" stroke="currentColor" stroke-width="1.6" fill="none" marker-end="url(#rag1)"/>
&lt;text x="390" y="180" text-anchor="middle" font-size="13" font-weight="700" fill="currentColor">The prefill token counter falls along the pipeline&lt;/text>
&lt;rect x="100" y="200" width="560" height="22" fill="none" stroke="currentColor" stroke-width="1"/>
&lt;rect x="100" y="200" width="560" height="22" fill="#d4ecff"/>
&lt;rect x="240" y="200" width="280" height="22" fill="#fff4d6"/>
&lt;rect x="240" y="200" width="70" height="22" fill="#cdebd0"/>
&lt;text x="180" y="216" text-anchor="middle" font-size="11" fill="#1f3550">retrieve: a lot&lt;/text>
&lt;text x="380" y="216" text-anchor="middle" font-size="11" fill="#5a4500">rerank: 4,000 tok&lt;/text>
&lt;text x="275" y="216" text-anchor="middle" font-size="11" fill="#1c3a26">1,000&lt;/text>
&lt;text x="390" y="252" text-anchor="middle" font-size="12" fill="currentColor">TTFT on RTX 4090 at ~5,000 tok/s · 4,000 tok = 0.80 s → 1,000 tok = 0.20 s&lt;/text>
&lt;text x="390" y="272" text-anchor="middle" font-size="11" fill="currentColor">attention falls ~k² = 16× in that part of the compute&lt;/text>
&lt;/svg>
&lt;/div>
&lt;p>The order matters. Retrieving aggressively (high recall) &lt;em>before&lt;/em> filtering guarantees that the right material is among the candidates; reranking and compressing &lt;em>afterwards&lt;/em> guarantees that only the dense and relevant material pays the prefill toll; caching wraps everything so as not to repeat work. The SLM only sees the final crib sheet, short and ordered.&lt;/p>
&lt;h2 id="implications-for-on-premise-inference">Implications for on-premise inference&lt;/h2>
&lt;p>The mental trap to avoid: treating the SLM as a large model with less quality. It is not. It is a &lt;strong>different cost profile&lt;/strong> that rewards a different design. Three practical consequences:&lt;/p>
&lt;ul>
&lt;li>&lt;strong>The token budget is a first-class resource.&lt;/strong> With a large model with a 128K window, &amp;ldquo;putting in a bit more&amp;rdquo; is cheap relative to the model. With an SLM, every context token shows up in TTFT and in quality. Treat context size as a quantity to &lt;strong>minimise subject to covering the answer&lt;/strong>, not to maximise.&lt;/li>
&lt;li>&lt;strong>The investment is worth it precisely because the model is cheap.&lt;/strong> Reranker, compressor and caches add complexity, but the model they serve is economical enough to run many replicas. The bottleneck shifts from the model to the data pipeline, which is exactly where you want it.&lt;/li>
&lt;li>&lt;strong>Retrieving does not replace adapting; they combine.&lt;/strong> For deep, recurring domain knowledge, adapting the SLM with LoRA (see the sibling &lt;a href="https://blog.lo0.es/en/posts/qlora-multi-lora-limit-small-models/">QLoRA and aggressive multi-LoRA&lt;/a>) can put part of the knowledge &amp;ldquo;into the weights&amp;rdquo; cheaply, reducing what has to be retrieved. Aggressive RAG and aggressive adaptation do not compete: the first gives freshness and citability, the second gives fluency and domain formatting. A good design uses both.&lt;/li>
&lt;/ul>
&lt;h3 id="on-the-rtx-4090-24-gb-ada-lovelace">On the RTX 4090 (24 GB, Ada Lovelace)&lt;/h3>
&lt;p>The canonical scenario: a quantised SLM (4B–8B in INT4/FP8) fits with room to spare, leaving VRAM for a generous KV cache, essential for prefix caching, and for the reranker (a cross-encoder of a few hundred MB). The LLMLingua-style extractive compressor runs on a separate small model or on CPU. The TTFT calculation above (0.80 s → 0.20 s compressing 4× at ~5,000 tok/s) is representative of this card. The rule of thumb: if TTFT goes above your SLA, &lt;strong>the first adjustment is to compress the context, not to change the model&lt;/strong>.&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>With 320 GB and native FP8 prefill is much faster, so the temptation is to relax the token discipline. That is not quite advisable: the lever changes from TTFT to &lt;em>aggregate throughput&lt;/em>. Compressing the context not only speeds up each request but &lt;strong>frees prefill compute&lt;/strong> to serve more requests per GPU; compute-bound prefill is exactly the resource that saturates first under load. Here prefix caching and semantic caching, shared across replicas, are what pay off most: at high QPS, the prefill work you avoid by caching is pure throughput you gain. The SLM is still the cheap reasoning engine; the difference is that you now run many in parallel and the data pipeline is what decides how many requests fit.&lt;/p>
&lt;h2 id="what-we-have-not-covered">What we have not covered&lt;/h2>
&lt;ul>
&lt;li>&lt;strong>Evaluating compression&lt;/strong>: how to measure that compressing $k=4$ does not throw away correct answers (faithfulness, answer recall over a set of questions with ground truth).&lt;/li>
&lt;li>&lt;strong>Query-aware versus query-agnostic compression&lt;/strong>: compressing before or after knowing the question changes what can be cached and what can be discarded.&lt;/li>
&lt;li>&lt;strong>Chunking and granularity&lt;/strong>: chunk size interacts with reranking and compression; that is left for the corpus curation post.&lt;/li>
&lt;li>&lt;strong>Multi-hop and agents&lt;/strong>: when a question requires several retrieval rounds, the token budget is split across hops and compression discipline becomes critical.&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/reranker-hybrid-retrieval-five-chunks-llm-reads/">Reranking and hybrid retrieval for RAG&lt;/a> — lever 1 in detail: maximise recall in the retriever and precision in the reranker so as to inject few but excellent chunks, which is what an SLM needs.&lt;/li>
&lt;li>&lt;a href="https://blog.lo0.es/en/posts/rag-corpus-curation-active-librarian/">Corpus curation for RAG&lt;/a> — a clean, well-chunked corpus reduces the filler the compressor has to remove; the crib sheet&amp;rsquo;s quality starts upstream.&lt;/li>
&lt;li>&lt;a href="https://blog.lo0.es/en/posts/semantic-cache-rag-receptionist-photographic-memory/">Semantic caching for RAG&lt;/a> — lever 4: skipping the model entirely when a query is semantically equivalent to one already answered.&lt;/li>
&lt;li>&lt;a href="https://blog.lo0.es/en/posts/embeddings-2026-dense-sparse-multivector-families/">Embeddings 2026: dense, sparse and multivector&lt;/a> — the basis of hybrid retrieval and of the semantic cache threshold; which representation retrieves better with less noise.&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> — lever 3: how to structure the prompt (stable context first) to maximise reuse of the retrieved context&amp;rsquo;s KV cache.&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> — compute-bound prefill is the cost this whole discussion tries to minimise; here are the concrete parameters to speed it up.&lt;/li>
&lt;li>&lt;a href="https://blog.lo0.es/en/posts/structured-output-bitmask-outlines-xgrammar-llguidance/">Structured output: fundamentals&lt;/a> — lever 5: forcing schema-conforming JSON so the SLM orchestrates tools instead of recalling data.&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&lt;/a> — when querying an API or database beats retrieving text and beats memorising; the SLM as a tool orchestrator.&lt;/li>
&lt;li>&lt;a href="https://blog.lo0.es/en/posts/roofline-flips-optimising-small-models/">The roofline inverts in small models&lt;/a> — why compute-bound prefill is the bottleneck that shapes this whole post: adding more context is not free.&lt;/li>
&lt;li>&lt;a href="https://blog.lo0.es/en/posts/qlora-multi-lora-limit-small-models/">QLoRA and aggressive multi-LoRA in SLMs&lt;/a> — the complementary alternative: adapting the SLM per domain to put part of the knowledge &amp;ldquo;into the weights&amp;rdquo; and reduce what has to be retrieved.&lt;/li>
&lt;/ul>
&lt;h2 id="references">References&lt;/h2>
&lt;ul>
&lt;li>Lewis, P., et al. &lt;em>Retrieval-Augmented Generation for Knowledge-Intensive NLP Tasks&lt;/em>. NeurIPS 2020. &lt;a href="https://arxiv.org/abs/2005.11401">https://arxiv.org/abs/2005.11401&lt;/a>&lt;/li>
&lt;li>Liu, N.F., et al. &lt;em>Lost in the Middle: How Language Models Use Long Contexts&lt;/em>. TACL 2024. &lt;a href="https://arxiv.org/abs/2307.03172">https://arxiv.org/abs/2307.03172&lt;/a>&lt;/li>
&lt;li>Jiang, H., et al. &lt;em>LLMLingua: Compressing Prompts for Accelerated Inference of Large Language Models&lt;/em>. EMNLP 2023. &lt;a href="https://arxiv.org/abs/2310.05736">https://arxiv.org/abs/2310.05736&lt;/a>&lt;/li>
&lt;li>Jiang, H., et al. &lt;em>LongLLMLingua: Accelerating and Enhancing LLMs in Long Context Scenarios via Prompt Compression&lt;/em>. ACL 2024. &lt;a href="https://arxiv.org/abs/2310.06839">https://arxiv.org/abs/2310.06839&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>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>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></channel></rss>