<?xml version="1.0" encoding="utf-8" standalone="yes"?><rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom"><channel><title>Prefix-Caching on lo0 — Tech Blog</title><link>https://blog.lo0.es/en/tags/prefix-caching/</link><description>Recent content in Prefix-Caching on lo0 — Tech Blog</description><generator>Hugo -- gohugo.io</generator><language>en</language><lastBuildDate>Mon, 08 Jun 2026 05:20:00 +0200</lastBuildDate><atom:link href="https://blog.lo0.es/en/tags/prefix-caching/index.xml" rel="self" type="application/rss+xml"/><item><title>The pantry of pigeonholes: PagedAttention and the vLLM block manager</title><link>https://blog.lo0.es/en/posts/pagedattention-vllm-block-manager/</link><pubDate>Mon, 08 Jun 2026 05:20:00 +0200</pubDate><guid>https://blog.lo0.es/en/posts/pagedattention-vllm-block-manager/</guid><description>&lt;blockquote>
&lt;p>Part of the &lt;em>under the engine&lt;/em> series. The &lt;a href="https://blog.lo0.es/en/posts/vllm-scheduler-step-maitre-d-each-round/">scheduler post&lt;/a> ended with a loose end: the scheduler has a second budget, the &lt;strong>KV blocks&lt;/strong>, and when they run out it preempts. This post opens up that budget. It is the piece the &lt;a href="https://blog.lo0.es/en/posts/kv-cache-working-memory-llm-inference/">KV cache post&lt;/a> took for granted, &lt;em>what&lt;/em> gets stored, in order to explain &lt;em>how it is managed in memory&lt;/em>. And it is the one the &lt;a href="https://blog.lo0.es/en/posts/flashattention-librarian-io-awareness-async-blackwell-asymmetry/">FlashAttention post&lt;/a> had been promising for months.&lt;/p>
&lt;/blockquote>
&lt;h2 id="tldr">TL;DR&lt;/h2>
&lt;p>The &lt;a href="https://blog.lo0.es/en/posts/kv-cache-working-memory-llm-inference/">KV cache&lt;/a> grows a little with every token generated, and the problem was never its &lt;strong>total size&lt;/strong> but the &lt;strong>way it is reserved&lt;/strong>. The first servers asked, per request, for a contiguous chunk of HBM the size of the maximum possible context. Since almost no request reaches that maximum, the result was catastrophic: &lt;strong>60-80% of the HBM wasted&lt;/strong> on fragmentation. PagedAttention applies to the KV the oldest and most battle-tested idea in operating systems, &lt;strong>paging&lt;/strong>: split the KV into &lt;strong>fixed-size blocks&lt;/strong> (16 tokens by default), store them in non-contiguous HBM wherever there is room, and keep a &lt;strong>block table&lt;/strong> that translates each sequence&amp;rsquo;s &lt;em>logical&lt;/em> block into its &lt;em>physical&lt;/em> one. Waste falls to &lt;strong>~4%&lt;/strong> (only the last block, half-filled). And since each block can be identified by the &lt;strong>hash of its content&lt;/strong>, two requests that share a prefix point at the &lt;strong>same physical block&lt;/strong> and share memory, with &lt;strong>copy-on-write&lt;/strong> when one of them diverges: that is the engine of &lt;a href="https://blog.lo0.es/en/posts/prefix-cache-hit-rate-engineering/">prefix caching&lt;/a>. This post explains fragmentation with numbers, the block manager, the block table, COW, the block-size trade-off, the 10 knobs and the trap of confusing &lt;em>&amp;ldquo;fragmentation solved&amp;rdquo;&lt;/em> with &lt;em>&amp;ldquo;zero waste&amp;rdquo;&lt;/em>. On the generic 4×H100 SXM cluster.&lt;/p>
&lt;h2 id="where-you-are-the-pantry-underneath-the-scheduler">Where you are: the pantry, underneath the scheduler&lt;/h2>
&lt;p>Go back to the kitchen of the &lt;a href="https://blog.lo0.es/en/posts/vllm-scheduler-step-maitre-d-each-round/">previous post&lt;/a>. The front-of-house manager assembles trays, but behind him there is a pantry where the ingredients each table accumulates over its meal are stored, its KV cache. The question in this post is how that pantry is organised.&lt;/p>
&lt;p>The naive way: each table gets a whole contiguous shelf, sized for the greediest imaginable customer. The problem is obvious: a table that orders little leaves almost all of its shelf empty, but that shelf is already reserved and nobody else can use it. With many tables, the pantry fills up with half-empty shelves and no new table fits, even though the gaps added together leave plenty of room.&lt;/p>
&lt;p>The PagedAttention way: the pantry is divided into &lt;strong>small, identical pigeonholes&lt;/strong>. Each table is given the pigeonholes it needs, one at a time, wherever there is room, and they do not have to be next to each other. A &lt;strong>map book&lt;/strong> records which physical pigeonholes each table has and in what order. When a table leaves, its pigeonholes go back to the pile. There are no half-empty shelves: the only waste is the last pigeonhole of each table, the half-filled one. That is, almost literally, the virtual memory of an operating system applied to the KV cache.&lt;/p>
&lt;h2 id="why-contiguous-memory-fragmented">Why contiguous memory fragmented&lt;/h2>
&lt;p>Reserving contiguously and up front produces three distinct kinds of waste:&lt;/p>
&lt;ol>
&lt;li>&lt;strong>Reservation fragmentation.&lt;/strong> You set aside space for &lt;code>max_model_len&lt;/code> (8192 tokens, say) even though the request will use 800. Reserved and never used.&lt;/li>
&lt;li>&lt;strong>Internal fragmentation.&lt;/strong> Within what you reserved, everything above what you actually use at any given moment.&lt;/li>
&lt;li>&lt;strong>External fragmentation.&lt;/strong> Gaps between contiguous reservations that are too small for a new request, even though added together they would be plenty.&lt;/li>
&lt;/ol>
&lt;p>The original vLLM paper measured that earlier systems &lt;strong>wasted 60% to 80%&lt;/strong> of the KV memory through these three routes (&lt;a href="https://arxiv.org/pdf/2309.06180">Kwon et al., SOSP 2023&lt;/a>). That is: on a GPU with room for 100 real requests, only 20-40 fitted. Paging attacks all three at once. It eliminates reservation waste (on-demand allocation) and external waste (blocks need not be contiguous), and leaves only a remainder of the internal kind: the last partial block.&lt;/p>
&lt;h2 id="the-mechanism-blocks-block-table-and-the-kernel-gather">The mechanism: blocks, block table and the kernel gather&lt;/h2>
&lt;p>The KV of a sequence is chopped into &lt;strong>logical blocks&lt;/strong> of $b$ tokens (by default $b = 16$). Each logical block is mapped, via the &lt;strong>block table&lt;/strong>, to a &lt;strong>physical block&lt;/strong> somewhere in the HBM. The block table is the &amp;ldquo;map book&amp;rdquo;: a per-request list of which physical block corresponds to each logical one (&lt;a href="https://docs.vllm.ai/en/v0.6.1/automatic_prefix_caching/details.html">vLLM implementation&lt;/a>).&lt;/p>
&lt;svg viewBox="0 0 720 250" xmlns="http://www.w3.org/2000/svg" font-family="sans-serif" font-size="12" role="img" aria-label="Logical blocks, block table and non-contiguous physical blocks">
&lt;text x="20" y="20" fill="currentColor" font-size="13">Request A — logical blocks (sequence order)&lt;/text>
&lt;rect x="20" y="30" width="50" height="34" fill="none" stroke="#2563eb" stroke-width="1.5"/>&lt;text x="45" y="52" text-anchor="middle" fill="#2563eb">L0&lt;/text>
&lt;rect x="75" y="30" width="50" height="34" fill="none" stroke="#2563eb" stroke-width="1.5"/>&lt;text x="100" y="52" text-anchor="middle" fill="#2563eb">L1&lt;/text>
&lt;rect x="130" y="30" width="50" height="34" fill="none" stroke="#2563eb" stroke-width="1.5"/>&lt;text x="155" y="52" text-anchor="middle" fill="#2563eb">L2&lt;/text>
&lt;rect x="185" y="30" width="50" height="34" fill="none" stroke="#2563eb" stroke-width="1.5" stroke-dasharray="4 3"/>&lt;text x="210" y="52" text-anchor="middle" fill="#2563eb">L3*&lt;/text>
&lt;text x="20" y="100" fill="currentColor" font-size="13">block table: L0→F7 · L1→F2 · L2→F9 · L3→F4 (last one, half-filled)&lt;/text>
&lt;text x="20" y="135" fill="currentColor" font-size="13">Physical HBM — pigeonholes wherever there is room (non-contiguous)&lt;/text>
&lt;rect x="20" y="150" width="40" height="34" fill="none" stroke="currentColor"/>&lt;text x="40" y="171" text-anchor="middle" fill="currentColor" font-size="10">F0&lt;/text>
&lt;rect x="65" y="150" width="40" height="34" fill="#16a34a" opacity="0.7"/>&lt;text x="85" y="171" text-anchor="middle" fill="currentColor" font-size="10">F2·L1&lt;/text>
&lt;rect x="110" y="150" width="40" height="34" fill="none" stroke="currentColor"/>&lt;text x="130" y="171" text-anchor="middle" fill="currentColor" font-size="10">F3&lt;/text>
&lt;rect x="155" y="150" width="40" height="34" fill="#f59e0b" opacity="0.7"/>&lt;text x="175" y="171" text-anchor="middle" fill="currentColor" font-size="10">F4·L3&lt;/text>
&lt;rect x="200" y="150" width="40" height="34" fill="none" stroke="currentColor"/>&lt;text x="220" y="171" text-anchor="middle" fill="currentColor" font-size="10">F5&lt;/text>
&lt;rect x="290" y="150" width="40" height="34" fill="#16a34a" opacity="0.7"/>&lt;text x="310" y="171" text-anchor="middle" fill="currentColor" font-size="10">F7·L0&lt;/text>
&lt;rect x="380" y="150" width="40" height="34" fill="#16a34a" opacity="0.7"/>&lt;text x="400" y="171" text-anchor="middle" fill="currentColor" font-size="10">F9·L2&lt;/text>
&lt;text x="20" y="220" fill="currentColor" font-size="11">The attention kernel does a gather: it walks the block table and reads F7,F2,F9,F4 as if they were contiguous.&lt;/text>
&lt;text x="20" y="238" fill="#f59e0b" font-size="11">* Only F4 is half-filled: that is the only waste (≈ half a block per sequence).&lt;/text>
&lt;/svg>
&lt;p>The key point is that the attention kernel knows how to read like that. Instead of assuming a contiguous KV tensor, the PagedAttention kernel receives the block table and does a &lt;strong>gather&lt;/strong>: for each sequence it walks its physical blocks in logical order and reads K and V as if they were together. That is why PagedAttention is not just a data structure: it is a &lt;strong>kernel&lt;/strong> that knows how to attend over paged memory. And that is why the &lt;a href="https://blog.lo0.es/en/posts/flashattention-librarian-io-awareness-async-blackwell-asymmetry/">attention backend&lt;/a> and the block manager are tied together, the second deciding where the KV lives and the first knowing how to read it from there.&lt;/p>
&lt;h2 id="the-block-manager-the-pantry-librarian">The block manager: the pantry librarian&lt;/h2>
&lt;p>The &lt;strong>block manager&lt;/strong> (in V1, the &lt;code>KVCacheManager&lt;/code>) is the one that keeps the map book. Its responsibilities:&lt;/p>
&lt;ul>
&lt;li>Maintain a pool of free physical blocks (a queue of available blocks).&lt;/li>
&lt;li>Allocate blocks to a sequence as it grows (a new block every $b$ tokens).&lt;/li>
&lt;li>Free the blocks when the sequence finishes or is preempted.&lt;/li>
&lt;li>Maintain the block tables (logical→physical) of each request.&lt;/li>
&lt;li>Manage prefix caching: detect blocks with identical content and share them.&lt;/li>
&lt;li>When free blocks run out, tell the scheduler to preempt (see &lt;a href="https://blog.lo0.es/en/posts/vllm-scheduler-step-maitre-d-each-round/">the scheduler post&lt;/a>).&lt;/li>
&lt;/ul>
&lt;p>When the block manager says &amp;ldquo;no blocks left&amp;rdquo;, the scheduler has to take somebody off the train. That is why the two budgets, tokens and blocks, are the two hands of the same engine.&lt;/p>
&lt;h2 id="prefix-caching-sharing-pigeonholes-with-copy-on-write">Prefix caching: sharing pigeonholes with copy-on-write&lt;/h2>
&lt;p>Here is the elegant part. If two requests start with the same prefix, the same system prompt, the same context document, the first KV blocks of both are identical byte for byte. Why compute and store them twice?&lt;/p>
&lt;p>vLLM gives each block a &lt;strong>hash&lt;/strong> summarising its content (the tokens that formed it, plus the hash of the previous block, so that the hash captures position). It keeps a global table of blocks by hash. When a new request produces a block whose hash already exists, it allocates no new memory: it points its block table at the physical block that was already there (&lt;a href="https://docs.vllm.ai/en/v0.8.1/design/automatic_prefix_caching.html">automatic prefix caching, vLLM&lt;/a>).&lt;/p>
&lt;svg viewBox="0 0 720 200" xmlns="http://www.w3.org/2000/svg" font-family="sans-serif" font-size="12" role="img" aria-label="Prefix caching with copy-on-write">
&lt;text x="20" y="20" fill="currentColor" font-size="13">Common prefix (system prompt) shared between A and B&lt;/text>
&lt;rect x="20" y="35" width="55" height="34" fill="#16a34a" opacity="0.7"/>&lt;text x="47" y="56" text-anchor="middle" fill="currentColor" font-size="10">F1&lt;/text>
&lt;rect x="80" y="35" width="55" height="34" fill="#16a34a" opacity="0.7"/>&lt;text x="107" y="56" text-anchor="middle" fill="currentColor" font-size="10">F2&lt;/text>
&lt;text x="200" y="56" fill="currentColor">← A and B both point here (shared memory)&lt;/text>
&lt;path d="M47 90 V70" stroke="#2563eb" stroke-width="1.5" marker-end="url(#b)"/>&lt;text x="47" y="105" text-anchor="middle" fill="#2563eb" font-size="10">A&lt;/text>
&lt;path d="M107 90 V70" stroke="#7c3aed" stroke-width="1.5" marker-end="url(#b)"/>&lt;text x="120" y="105" text-anchor="middle" fill="#7c3aed" font-size="10">B&lt;/text>
&lt;text x="20" y="140" fill="currentColor" font-size="13">A and B diverge → copy-on-write: B copies the block before writing&lt;/text>
&lt;rect x="20" y="150" width="55" height="30" fill="#2563eb" opacity="0.7"/>&lt;text x="47" y="169" text-anchor="middle" fill="currentColor" font-size="10">F3·A&lt;/text>
&lt;rect x="90" y="150" width="55" height="30" fill="#7c3aed" opacity="0.7"/>&lt;text x="117" y="169" text-anchor="middle" fill="currentColor" font-size="10">F8·B (copy)&lt;/text>
&lt;defs>&lt;marker id="b" markerWidth="8" markerHeight="8" refX="7" refY="4" orient="auto">&lt;path d="M0 0 L8 4 L0 8 z" fill="currentColor"/>&lt;/marker>&lt;/defs>
&lt;/svg>
&lt;p>&lt;strong>Copy-on-write&lt;/strong> is the safeguard: while A and B share a block, neither can modify it. The moment one of the two needs to write something different into that block (because their sequences diverge, or in &lt;em>parallel sampling&lt;/em> / beam search where several branches share a prefix), the block manager copies the block for that branch and only then writes (&lt;a href="https://docs.vllm.ai/en/v0.6.1/automatic_prefix_caching/details.html">details, vLLM&lt;/a>). It is the same COW that &lt;code>fork()&lt;/code> uses in an OS: share until somebody writes.&lt;/p>
&lt;p>The saving is direct: if 50 requests share a system prompt of 1,000 tokens, instead of 50 copies of that prefix&amp;rsquo;s KV there is one. How to maximise that saving in practice is the subject of the &lt;a href="https://blog.lo0.es/en/posts/prefix-cache-hit-rate-engineering/">prefix cache hit rate post&lt;/a>.&lt;/p>
&lt;h2 id="the-maths-that-matter-how-much-kv-how-many-blocks">The maths that matter: how much KV, how many blocks&lt;/h2>
&lt;p>&lt;strong>KV bytes per token.&lt;/strong> For a transformer block with $L$ layers, $h_{kv}$ KV heads (GQA), per-head dimension $d$ and $s$ bytes per element (2 in FP16):&lt;/p>
$$\text{KV/token} = 2 \cdot L \cdot h_{kv} \cdot d \cdot s$$
&lt;p>For a Llama-70B ($L=80$, $h_{kv}=8$, $d=128$, FP16):&lt;/p>
$$\text{KV/token} = 2 \cdot 80 \cdot 8 \cdot 128 \cdot 2 = 327680 \text{ bytes} \approx 320 \text{ KB}$$
&lt;p>A block of 16 tokens takes up $16 \times 320\,\text{KB} = 5.12$ MB.&lt;/p>
&lt;p>&lt;strong>How many requests fit.&lt;/strong> If after loading the weights ~120 GB of the node&amp;rsquo;s 320 are left for KV:&lt;/p>
$$\text{KV tokens} = \frac{120 \cdot 10^9}{327680} \approx 366000 \text{ tokens} \approx 22900 \text{ blocks}$$
&lt;p>With average contexts of 4,000 tokens (250 blocks each), that is ~90 concurrent requests. That number, not &lt;code>max_num_seqs&lt;/code>, is the real concurrency ceiling, and it is exactly the &amp;ldquo;block budget&amp;rdquo; of the &lt;a href="https://blog.lo0.es/en/posts/vllm-scheduler-step-maitre-d-each-round/">scheduler&lt;/a>.&lt;/p>
&lt;p>&lt;strong>The waste that remains.&lt;/strong> PagedAttention does not get to zero: each sequence wastes, on average, half a block (the last one, half-filled). With 16-token blocks and sequences of 4,000, that is $8 / 4000 = 0.2\%$ per sequence, and the famous aggregate &amp;ldquo;~4%&amp;rdquo; from the paper includes other overheads. The lesson: the waste does not disappear, it is &lt;strong>bounded&lt;/strong> to the size of one block.&lt;/p>
&lt;h2 id="the-block-size-trade-off">The block-size trade-off&lt;/h2>
&lt;p>The &lt;code>block_size&lt;/code> (16 by default) is a trade-off, not a magic constant:&lt;/p>
&lt;table>
&lt;thead>
&lt;tr>
&lt;th>Block&lt;/th>
&lt;th>Advantage&lt;/th>
&lt;th>Drawback&lt;/th>
&lt;/tr>
&lt;/thead>
&lt;tbody>
&lt;tr>
&lt;td>Small (8)&lt;/td>
&lt;td>less internal waste; finer-grained prefix sharing&lt;/td>
&lt;td>more block table entries; more management and gather overhead&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>Large (32)&lt;/td>
&lt;td>fewer metadata; more efficient gather&lt;/td>
&lt;td>more waste in the last block; prefix caching shares at a coarser grain (fewer hits)&lt;/td>
&lt;/tr>
&lt;/tbody>
&lt;/table>
&lt;p>A large block shares worse: prefix caching can only reuse complete and identical blocks, so with 32-token blocks two prompts that agree on 20 tokens share nothing (they do not fill a common block), whereas with 8-token blocks they share two blocks. The default of 16 is the point vLLM found reasonable for most workloads; it is worth testing if your workload has short, heavily repeated prefixes.&lt;/p>
&lt;h2 id="the-10-knobs">The 10 knobs&lt;/h2>
&lt;table>
&lt;thead>
&lt;tr>
&lt;th>#&lt;/th>
&lt;th>Knob&lt;/th>
&lt;th>What it controls&lt;/th>
&lt;th>Cost if you overdo it&lt;/th>
&lt;/tr>
&lt;/thead>
&lt;tbody>
&lt;tr>
&lt;td>1&lt;/td>
&lt;td>&lt;code>block_size&lt;/code>&lt;/td>
&lt;td>tokens per block&lt;/td>
&lt;td>waste / overhead (see table)&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>2&lt;/td>
&lt;td>&lt;code>enable_prefix_caching&lt;/code>&lt;/td>
&lt;td>sharing blocks by hash&lt;/td>
&lt;td>almost none; usually on&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>3&lt;/td>
&lt;td>&lt;code>gpu_memory_utilization&lt;/code>&lt;/td>
&lt;td>how many physical blocks there are&lt;/td>
&lt;td>OOM if too high&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>4&lt;/td>
&lt;td>&lt;code>kv_cache_dtype&lt;/code> (FP8)&lt;/td>
&lt;td>bytes per KV element&lt;/td>
&lt;td>quality (measure, do not assume)&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>5&lt;/td>
&lt;td>&lt;code>swap_space&lt;/code>&lt;/td>
&lt;td>blocks that fit on the host (SWAP)&lt;/td>
&lt;td>PCIe traffic on preemption&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>6&lt;/td>
&lt;td>&lt;code>max_model_len&lt;/code>&lt;/td>
&lt;td>maximum length per request&lt;/td>
&lt;td>fewer requests if too high&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>7&lt;/td>
&lt;td>eviction policy&lt;/td>
&lt;td>who gets blocks taken away&lt;/td>
&lt;td>prefix cache hit rate&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>8&lt;/td>
&lt;td>sliding window&lt;/td>
&lt;td>discarding old KV&lt;/td>
&lt;td>quality on long contexts&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>9&lt;/td>
&lt;td>TP / KV sharding&lt;/td>
&lt;td>splitting the KV across GPUs&lt;/td>
&lt;td>NVLink traffic&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>10&lt;/td>
&lt;td>num_gpu_blocks (override)&lt;/td>
&lt;td>forcing the block count&lt;/td>
&lt;td>OOM or underuse&lt;/td>
&lt;/tr>
&lt;/tbody>
&lt;/table>
&lt;h2 id="how-it-connects-to-the-rest-of-the-stack">How it connects to the rest of the stack&lt;/h2>
&lt;p>&lt;strong>With the scheduler.&lt;/strong> The &amp;ldquo;block budget&amp;rdquo; of the &lt;a href="https://blog.lo0.es/en/posts/vllm-scheduler-step-maitre-d-each-round/">scheduler&lt;/a> is administered by this block manager. When it says there are no blocks, the scheduler preempts (RECOMPUTE by default).&lt;/p>
&lt;p>&lt;strong>With the KV cache.&lt;/strong> The &lt;a href="https://blog.lo0.es/en/posts/kv-cache-working-memory-llm-inference/">KV cache post&lt;/a> explains &lt;em>what&lt;/em> each token stores; this one, &lt;em>how&lt;/em> it is placed in memory without fragmenting.&lt;/p>
&lt;p>&lt;strong>With prefix caching.&lt;/strong> COW and block hashes are the mechanism; &lt;a href="https://blog.lo0.es/en/posts/prefix-cache-hit-rate-engineering/">hit rate engineering&lt;/a> is how to squeeze it (prompt structure, prefix-aware routing).&lt;/p>
&lt;p>&lt;strong>With KV quantisation.&lt;/strong> Moving the KV to &lt;a href="https://blog.lo0.es/en/posts/fp8-end-to-end-enable-measure-quality-decide/">FP8&lt;/a> halves the bytes/token: the same node holds twice the tokens. It is the most direct lever on concurrency.&lt;/p>
&lt;p>&lt;strong>With the attention backend.&lt;/strong> The &lt;a href="https://blog.lo0.es/en/posts/flashattention-librarian-io-awareness-async-blackwell-asymmetry/">FlashAttention/FlashInfer&lt;/a> kernel has to know how to attend over paged blocks; the block manager decides where they live, the kernel knows how to read them.&lt;/p>
&lt;p>&lt;strong>With disaggregated serving.&lt;/strong> Moving a request from a prefill pool to a decode pool in &lt;a href="https://blog.lo0.es/en/posts/disaggregated-serving-prefill-decode-specialised-pods/">disaggregated serving&lt;/a> is, at bottom, &lt;strong>transferring its KV blocks&lt;/strong> between engines, over NVLink or over the network.&lt;/p>
&lt;p>&lt;strong>With multi-LoRA.&lt;/strong> In &lt;a href="https://blog.lo0.es/en/posts/multi-lora-serving-shared-base-concurrent-adapters-sgmv-kernel/">multi-LoRA serving&lt;/a>, the base shares prefix KV between requests from different adapters as long as the prefix is identical.&lt;/p>
&lt;h2 id="traps-and-things-that-are-not-what-they-seem">Traps and things that are not what they seem&lt;/h2>
&lt;p>&lt;strong>&amp;ldquo;PagedAttention eliminates waste.&amp;rdquo;&lt;/strong> It &lt;strong>bounds&lt;/strong> it, it does not eliminate it. What remains is the last partial block per sequence (~half a block) plus the block table metadata. It is ~4% instead of 60-80%, but it is not zero. Sizing as if it were zero leaves you without a cushion.&lt;/p>
&lt;p>&lt;strong>&amp;ldquo;Bigger blocks always perform better.&amp;rdquo;&lt;/strong> The gather is somewhat more efficient, yes, but you lose &lt;em>sharing&lt;/em> granularity: prefix caching shares worse and the waste in the last block grows. On workloads with many short repeated prefixes, small blocks can win.&lt;/p>
&lt;p>&lt;strong>&amp;ldquo;Prefix caching shares KV between users, that is a privacy problem.&amp;rdquo;&lt;/strong> It shares only blocks that are &lt;strong>identical token by token&lt;/strong> (same system prompt, same document). It does not expose one user&amp;rsquo;s content to another: if the tokens do not match, there is no common block. What is worth watching is &lt;strong>information leaking through timing side channels&lt;/strong> (a hit is faster than a miss), relevant only in very adversarial multi-tenant scenarios.&lt;/p>
&lt;p>&lt;strong>&amp;ldquo;FP8 in the KV is free: twice the concurrency.&amp;rdquo;&lt;/strong> It does double the tokens that fit, yes, but KV in FP8 &lt;strong>degrades quality&lt;/strong> measurably on long contexts. It is a real lever, not a free lunch: quality has to be measured (&lt;a href="https://blog.lo0.es/en/posts/fp8-end-to-end-enable-measure-quality-decide/">FP8 end-to-end&lt;/a>), not assumed.&lt;/p>
&lt;p>&lt;strong>&amp;ldquo;Going back to contiguous memory would be simpler and almost as good.&amp;rdquo;&lt;/strong> That is nostalgia for the contiguous tensor. The &amp;ldquo;simple&amp;rdquo; option reintroduces 60-80% fragmentation: on a GPU, that is the difference between 30 and 90 concurrent requests. The complexity of the block table pays for itself many times over.&lt;/p>
&lt;p>&lt;strong>SWAP versus RECOMPUTE when preempting.&lt;/strong> Configuring a lot of &lt;code>swap_space&lt;/code> &amp;ldquo;so as not to lose KV&amp;rdquo; puts gigabyte transfers over &lt;a href="https://blog.lo0.es/en/posts/pcie-topology-gpudirect-p2p-acs-security-guard/">PCIe&lt;/a> on the critical path. In V1, RECOMPUTE is usually better; swap is for specific cases.&lt;/p>
&lt;h2 id="conclusion">Conclusion&lt;/h2>
&lt;p>The bottleneck in serving an LLM was never only how much memory you have, but &lt;strong>how you hand it out&lt;/strong>. The first servers treated the KV cache as a contiguous shelf per client and threw two thirds of the HBM in the bin without it showing up on any dashboard. PagedAttention stole from the operating system its best idea of fifty years ago, paging, and applied it at the exact place where it hurt: small pigeonholes, a map book, on-demand allocation and, as a bonus, the possibility that two requests starting the same way share the same pigeonholes until they stop resembling each other. The result is not magic: the waste is still there, but bounded to the size of one block instead of to the size of the worst imaginable case. And that difference, from 70% to 4%, is what turned a GPU serving thirty clients into one serving ninety, without touching the hardware. The pantry did not get bigger; it got better organised.&lt;/p>
&lt;h2 id="see-also">See also&lt;/h2>
&lt;ul>
&lt;li>&lt;a href="https://blog.lo0.es/en/posts/vllm-scheduler-step-maitre-d-each-round/">The pass: the vLLM scheduler step&lt;/a> — the block budget this block manager administers; when it runs out, preemption.&lt;/li>
&lt;li>&lt;a href="https://blog.lo0.es/en/posts/kv-cache-working-memory-llm-inference/">KV cache: the working memory&lt;/a> — &lt;em>what&lt;/em> each token stores, the data that gets paged here.&lt;/li>
&lt;li>&lt;a href="https://blog.lo0.es/en/posts/prefix-cache-hit-rate-engineering/">Prefix cache hit rate engineering&lt;/a> — how to squeeze the block sharing that COW makes possible.&lt;/li>
&lt;li>&lt;a href="https://blog.lo0.es/en/posts/flashattention-librarian-io-awareness-async-blackwell-asymmetry/">FlashAttention v1/v2/v3/v4&lt;/a> — the kernel that knows how to attend over paged KV.&lt;/li>
&lt;li>&lt;a href="https://blog.lo0.es/en/posts/fp8-end-to-end-enable-measure-quality-decide/">FP8 end-to-end: weights and KV&lt;/a> — halving the bytes/token and doubling concurrency, while measuring quality.&lt;/li>
&lt;li>&lt;a href="https://blog.lo0.es/en/posts/disaggregated-serving-prefill-decode-specialised-pods/">Disaggregated serving: prefill and decode separated&lt;/a> — moving a request between pools is transferring its KV blocks.&lt;/li>
&lt;li>&lt;a href="https://blog.lo0.es/en/posts/pcie-topology-gpudirect-p2p-acs-security-guard/">PCIe, GPUDirect P2P and ACS&lt;/a> — where the blocks travel when SWAP happens or KV moves between GPUs.&lt;/li>
&lt;li>&lt;a href="https://blog.lo0.es/en/posts/multi-lora-serving-shared-base-concurrent-adapters-sgmv-kernel/">Multi-LoRA serving&lt;/a> — sharing a prefix between requests from different adapters.&lt;/li>
&lt;/ul>
&lt;h2 id="references">References&lt;/h2>
&lt;ul>
&lt;li>W. Kwon et al., &lt;em>Efficient Memory Management for Large Language Model Serving with PagedAttention&lt;/em> (SOSP 2023): &lt;a href="https://arxiv.org/pdf/2309.06180">https://arxiv.org/pdf/2309.06180&lt;/a>.&lt;/li>
&lt;li>vLLM, &lt;em>Automatic Prefix Caching&lt;/em> (design, block hashing): &lt;a href="https://docs.vllm.ai/en/v0.8.1/design/automatic_prefix_caching.html">https://docs.vllm.ai/en/v0.8.1/design/automatic_prefix_caching.html&lt;/a>.&lt;/li>
&lt;li>vLLM, &lt;em>Automatic Prefix Caching — Implementation&lt;/em> (block table, COW): &lt;a href="https://docs.vllm.ai/en/v0.6.1/automatic_prefix_caching/details.html">https://docs.vllm.ai/en/v0.6.1/automatic_prefix_caching/details.html&lt;/a>.&lt;/li>
&lt;li>H. Elshafie, &lt;em>Paged Attention from First Principles: A View Inside vLLM&lt;/em>: &lt;a href="https://hamzaelshafie.bearblog.dev/paged-attention-from-first-principles-a-view-inside-vllm/">https://hamzaelshafie.bearblog.dev/paged-attention-from-first-principles-a-view-inside-vllm/&lt;/a>.&lt;/li>
&lt;li>&lt;em>vAttention: Dynamic Memory Management for Serving LLMs without PagedAttention&lt;/em> (alternative, critical context): &lt;a href="https://arxiv.org/pdf/2405.04437">https://arxiv.org/pdf/2405.04437&lt;/a>.&lt;/li>
&lt;/ul></description></item><item><title>Prefix cache: hit rate engineering to go from 15% to 75%</title><link>https://blog.lo0.es/en/posts/prefix-cache-hit-rate-engineering/</link><pubDate>Fri, 05 Jun 2026 04:00:00 +0000</pubDate><guid>https://blog.lo0.es/en/posts/prefix-cache-hit-rate-engineering/</guid><description>&lt;h2 id="tldr">TL;DR&lt;/h2>
&lt;p>vLLM&amp;rsquo;s prefix cache stores the KV cache blocks of shared prefixes and reuses them in later requests. A hit avoids recomputing that prefix: TTFT drops to the cost of the variable suffix only. In enterprise workloads with fixed system prompts (RAG, domain chatbots, assistants with long instructions) the hit rate should be &amp;gt;70%. In practice it is 10-20% for entirely avoidable reasons. This article identifies them, fixes them, and gives the OTel queries to confirm the result.&lt;/p>
&lt;hr>
&lt;h2 id="the-analogy">The analogy&lt;/h2>
&lt;p>A simultaneous conference interpreter who has to translate the speeches of twenty speakers. All of them open with the same two-page protocol preamble: the conference declaration, the rules of conduct, the day&amp;rsquo;s programme. An interpreter with no memory rereads the two pages for each speaker before starting on their specific speech. An interpreter with good notes reads them once, files them, and when the second speaker starts goes straight to the speech.&lt;/p>
&lt;p>The prefix cache is that file. The prefix hash is the reference that lets you skip to the new part. But if the preamble changes by even one word, because someone drops in the day&amp;rsquo;s date, the interpreter has to reread everything from the beginning.&lt;/p>
&lt;hr>
&lt;h2 id="how-the-prefix-cache-hash-works">How the prefix cache hash works&lt;/h2>
&lt;p>vLLM splits the KV cache into blocks of 16 tokens. Each block has a hash computed over its exact content. When a new request arrives, vLLM checks whether any initial block of the prompt is already in cache by comparing hashes.&lt;/p>
&lt;p>The hash is computed over &lt;strong>the byte-by-byte content of the tokens&lt;/strong>. Any difference, a space, a different character, one extra token, produces a completely different hash. There is no partial matching inside a block.&lt;/p>
&lt;p>Direct consequence: if your system prompt has 512 tokens and token number 3 changes between requests (because you interpolate a date, an ID, a version number), &lt;strong>no block hits&lt;/strong> even though 99% of the text is identical.&lt;/p>
&lt;pre tabindex="0">&lt;code>Block 0 (tokens 0-15): hash = a3f7... ← in cache?
Block 1 (tokens 16-31): hash = 9d2c... ← in cache?
...
Block 31 (tokens 496-511): hash = 7e1a... ← in cache?
&lt;/code>&lt;/pre>&lt;p>If block 0 does not hit (because its content changed), blocks 1-31 are not even checked although they are identical. The prefix cache is sequential.&lt;/p>
&lt;hr>
&lt;h2 id="audit-why-your-real-hit-rate-is-low">Audit: why your real hit rate is low&lt;/h2>
&lt;p>Before changing anything, you need to know &lt;em>what&lt;/em> is breaking the hash. The most direct method: pull the last 1000 production prompts and work out what fraction of the prefix varies.&lt;/p>
&lt;div class="highlight">&lt;pre tabindex="0" class="chroma">&lt;code class="language-python" data-lang="python">&lt;span class="line">&lt;span class="cl">&lt;span class="c1"># audit_prefix_cache.py&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="kn">import&lt;/span> &lt;span class="nn">langfuse&lt;/span>&lt;span class="o">,&lt;/span> &lt;span class="nn">hashlib&lt;/span>&lt;span class="o">,&lt;/span> &lt;span class="nn">collections&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="kn">from&lt;/span> &lt;span class="nn">transformers&lt;/span> &lt;span class="kn">import&lt;/span> &lt;span class="n">AutoTokenizer&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="n">client&lt;/span> &lt;span class="o">=&lt;/span> &lt;span class="n">langfuse&lt;/span>&lt;span class="o">.&lt;/span>&lt;span class="n">Langfuse&lt;/span>&lt;span class="p">()&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="n">tokenizer&lt;/span> &lt;span class="o">=&lt;/span> &lt;span class="n">AutoTokenizer&lt;/span>&lt;span class="o">.&lt;/span>&lt;span class="n">from_pretrained&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="s2">&amp;#34;Qwen/Qwen2.5-14B-Instruct&amp;#34;&lt;/span>&lt;span class="p">)&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="n">traces&lt;/span> &lt;span class="o">=&lt;/span> &lt;span class="n">client&lt;/span>&lt;span class="o">.&lt;/span>&lt;span class="n">fetch_traces&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="n">limit&lt;/span>&lt;span class="o">=&lt;/span>&lt;span class="mi">1000&lt;/span>&lt;span class="p">)&lt;/span>&lt;span class="o">.&lt;/span>&lt;span class="n">data&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="n">prompts&lt;/span> &lt;span class="o">=&lt;/span> &lt;span class="p">[&lt;/span>&lt;span class="n">t&lt;/span>&lt;span class="o">.&lt;/span>&lt;span class="n">input&lt;/span> &lt;span class="k">for&lt;/span> &lt;span class="n">t&lt;/span> &lt;span class="ow">in&lt;/span> &lt;span class="n">traces&lt;/span> &lt;span class="k">if&lt;/span> &lt;span class="n">t&lt;/span>&lt;span class="o">.&lt;/span>&lt;span class="n">input&lt;/span>&lt;span class="p">]&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="c1"># Tokenise and extract the first 512 tokens (the typical system prompt)&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="n">prefixes&lt;/span> &lt;span class="o">=&lt;/span> &lt;span class="p">[]&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="k">for&lt;/span> &lt;span class="n">prompt&lt;/span> &lt;span class="ow">in&lt;/span> &lt;span class="n">prompts&lt;/span>&lt;span class="p">:&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="n">tokens&lt;/span> &lt;span class="o">=&lt;/span> &lt;span class="n">tokenizer&lt;/span>&lt;span class="o">.&lt;/span>&lt;span class="n">encode&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="n">prompt&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="n">add_special_tokens&lt;/span>&lt;span class="o">=&lt;/span>&lt;span class="kc">False&lt;/span>&lt;span class="p">)&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="n">prefix_tokens&lt;/span> &lt;span class="o">=&lt;/span> &lt;span class="nb">tuple&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="n">tokens&lt;/span>&lt;span class="p">[:&lt;/span>&lt;span class="mi">512&lt;/span>&lt;span class="p">])&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="n">prefixes&lt;/span>&lt;span class="o">.&lt;/span>&lt;span class="n">append&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="n">prefix_tokens&lt;/span>&lt;span class="p">)&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="c1"># How many unique prefixes are there?&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="n">unique&lt;/span> &lt;span class="o">=&lt;/span> &lt;span class="nb">len&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="nb">set&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="n">prefixes&lt;/span>&lt;span class="p">))&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="n">total&lt;/span> &lt;span class="o">=&lt;/span> &lt;span class="nb">len&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="n">prefixes&lt;/span>&lt;span class="p">)&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="nb">print&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="sa">f&lt;/span>&lt;span class="s2">&amp;#34;Unique prefixes: &lt;/span>&lt;span class="si">{&lt;/span>&lt;span class="n">unique&lt;/span>&lt;span class="si">}&lt;/span>&lt;span class="s2">/&lt;/span>&lt;span class="si">{&lt;/span>&lt;span class="n">total&lt;/span>&lt;span class="si">}&lt;/span>&lt;span class="s2"> (&lt;/span>&lt;span class="si">{&lt;/span>&lt;span class="n">unique&lt;/span>&lt;span class="o">/&lt;/span>&lt;span class="n">total&lt;/span>&lt;span class="o">*&lt;/span>&lt;span class="mi">100&lt;/span>&lt;span class="si">:&lt;/span>&lt;span class="s2">.1f&lt;/span>&lt;span class="si">}&lt;/span>&lt;span class="s2">%)&amp;#34;&lt;/span>&lt;span class="p">)&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="nb">print&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="sa">f&lt;/span>&lt;span class="s2">&amp;#34;Theoretical hit rate if they were all equal: &lt;/span>&lt;span class="si">{&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="mi">1&lt;/span> &lt;span class="o">-&lt;/span> &lt;span class="n">unique&lt;/span>&lt;span class="o">/&lt;/span>&lt;span class="n">total&lt;/span>&lt;span class="p">)&lt;/span>&lt;span class="o">*&lt;/span>&lt;span class="mi">100&lt;/span>&lt;span class="si">:&lt;/span>&lt;span class="s2">.1f&lt;/span>&lt;span class="si">}&lt;/span>&lt;span class="s2">%&amp;#34;&lt;/span>&lt;span class="p">)&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="c1"># Find which token differs between the most common prefix and the rest&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="kn">from&lt;/span> &lt;span class="nn">collections&lt;/span> &lt;span class="kn">import&lt;/span> &lt;span class="n">Counter&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="n">most_common_prefix&lt;/span> &lt;span class="o">=&lt;/span> &lt;span class="n">Counter&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="n">prefixes&lt;/span>&lt;span class="p">)&lt;/span>&lt;span class="o">.&lt;/span>&lt;span class="n">most_common&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="mi">1&lt;/span>&lt;span class="p">)[&lt;/span>&lt;span class="mi">0&lt;/span>&lt;span class="p">][&lt;/span>&lt;span class="mi">0&lt;/span>&lt;span class="p">]&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="n">divergence_positions&lt;/span> &lt;span class="o">=&lt;/span> &lt;span class="p">[]&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="k">for&lt;/span> &lt;span class="n">prefix&lt;/span> &lt;span class="ow">in&lt;/span> &lt;span class="n">prefixes&lt;/span>&lt;span class="p">:&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="k">if&lt;/span> &lt;span class="n">prefix&lt;/span> &lt;span class="o">==&lt;/span> &lt;span class="n">most_common_prefix&lt;/span>&lt;span class="p">:&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="k">continue&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="k">for&lt;/span> &lt;span class="n">i&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="p">(&lt;/span>&lt;span class="n">a&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="n">b&lt;/span>&lt;span class="p">)&lt;/span> &lt;span class="ow">in&lt;/span> &lt;span class="nb">enumerate&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="nb">zip&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="n">most_common_prefix&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="n">prefix&lt;/span>&lt;span class="p">)):&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="k">if&lt;/span> &lt;span class="n">a&lt;/span> &lt;span class="o">!=&lt;/span> &lt;span class="n">b&lt;/span>&lt;span class="p">:&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="n">divergence_positions&lt;/span>&lt;span class="o">.&lt;/span>&lt;span class="n">append&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="n">i&lt;/span>&lt;span class="p">)&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="k">break&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="k">if&lt;/span> &lt;span class="n">divergence_positions&lt;/span>&lt;span class="p">:&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="n">pos&lt;/span> &lt;span class="o">=&lt;/span> &lt;span class="n">Counter&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="n">divergence_positions&lt;/span>&lt;span class="p">)&lt;/span>&lt;span class="o">.&lt;/span>&lt;span class="n">most_common&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="mi">1&lt;/span>&lt;span class="p">)[&lt;/span>&lt;span class="mi">0&lt;/span>&lt;span class="p">][&lt;/span>&lt;span class="mi">0&lt;/span>&lt;span class="p">]&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="n">token_text&lt;/span> &lt;span class="o">=&lt;/span> &lt;span class="n">tokenizer&lt;/span>&lt;span class="o">.&lt;/span>&lt;span class="n">decode&lt;/span>&lt;span class="p">([&lt;/span>&lt;span class="n">most_common_prefix&lt;/span>&lt;span class="p">[&lt;/span>&lt;span class="n">pos&lt;/span>&lt;span class="p">]])&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="nb">print&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="sa">f&lt;/span>&lt;span class="s2">&amp;#34;&lt;/span>&lt;span class="se">\n&lt;/span>&lt;span class="s2">Most frequent divergence at position &lt;/span>&lt;span class="si">{&lt;/span>&lt;span class="n">pos&lt;/span>&lt;span class="si">}&lt;/span>&lt;span class="s2">: &amp;#39;&lt;/span>&lt;span class="si">{&lt;/span>&lt;span class="n">token_text&lt;/span>&lt;span class="si">}&lt;/span>&lt;span class="s2">&amp;#39;&amp;#34;&lt;/span>&lt;span class="p">)&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="nb">print&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="s2">&amp;#34;→ The token at that position varies between requests&amp;#34;&lt;/span>&lt;span class="p">)&lt;/span>
&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>The most common culprits, in order of frequency:&lt;/p>
&lt;p>&lt;strong>1. Timestamps and dates:&lt;/strong>&lt;/p>
&lt;div class="highlight">&lt;pre tabindex="0" class="chroma">&lt;code class="language-python" data-lang="python">&lt;span class="line">&lt;span class="cl">&lt;span class="c1"># ❌ Breaks the hash on every request&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="n">system&lt;/span> &lt;span class="o">=&lt;/span> &lt;span class="sa">f&lt;/span>&lt;span class="s2">&amp;#34;Current date: &lt;/span>&lt;span class="si">{&lt;/span>&lt;span class="n">datetime&lt;/span>&lt;span class="o">.&lt;/span>&lt;span class="n">now&lt;/span>&lt;span class="p">()&lt;/span>&lt;span class="o">.&lt;/span>&lt;span class="n">strftime&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="s1">&amp;#39;%Y-%m-&lt;/span>&lt;span class="si">%d&lt;/span>&lt;span class="s1"> %H:%M&amp;#39;&lt;/span>&lt;span class="p">)&lt;/span>&lt;span class="si">}&lt;/span>&lt;span class="s2">. You are an assistant...&amp;#34;&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="c1"># ✅ Take the date out of the system prompt&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="n">system&lt;/span> &lt;span class="o">=&lt;/span> &lt;span class="s2">&amp;#34;You are an assistant specialising in cloud infrastructure.&amp;#34;&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="c1"># Pass the date as part of the user message if it is needed&lt;/span>
&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>&lt;strong>2. Session and user IDs:&lt;/strong>&lt;/p>
&lt;div class="highlight">&lt;pre tabindex="0" class="chroma">&lt;code class="language-python" data-lang="python">&lt;span class="line">&lt;span class="cl">&lt;span class="c1"># ❌&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="n">system&lt;/span> &lt;span class="o">=&lt;/span> &lt;span class="sa">f&lt;/span>&lt;span class="s2">&amp;#34;User ID: &lt;/span>&lt;span class="si">{&lt;/span>&lt;span class="n">user_id&lt;/span>&lt;span class="si">}&lt;/span>&lt;span class="s2">. Preferences: &lt;/span>&lt;span class="si">{&lt;/span>&lt;span class="n">user_prefs&lt;/span>&lt;span class="si">}&lt;/span>&lt;span class="s2">. You are an assistant...&amp;#34;&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="c1"># ✅ Separate the static from the contextual&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="n">system&lt;/span> &lt;span class="o">=&lt;/span> &lt;span class="s2">&amp;#34;You are a specialised assistant.&amp;#34;&lt;/span> &lt;span class="c1"># always the same&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="c1"># Add the user context as the first message of the history&lt;/span>
&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>&lt;strong>3. Interpolated prompt versions:&lt;/strong>&lt;/p>
&lt;div class="highlight">&lt;pre tabindex="0" class="chroma">&lt;code class="language-python" data-lang="python">&lt;span class="line">&lt;span class="cl">&lt;span class="c1"># ❌&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="n">system&lt;/span> &lt;span class="o">=&lt;/span> &lt;span class="sa">f&lt;/span>&lt;span class="s2">&amp;#34;[v&lt;/span>&lt;span class="si">{&lt;/span>&lt;span class="n">PROMPT_VERSION&lt;/span>&lt;span class="si">}&lt;/span>&lt;span class="s2">] You are an assistant...&amp;#34;&lt;/span> &lt;span class="c1"># changes with every deploy&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="c1"># ✅ Do not version in the text, version in the prompt name in Langfuse&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="n">system&lt;/span> &lt;span class="o">=&lt;/span> &lt;span class="s2">&amp;#34;You are an assistant...&amp;#34;&lt;/span>
&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>&lt;strong>4. Dynamic few-shots:&lt;/strong>&lt;/p>
&lt;div class="highlight">&lt;pre tabindex="0" class="chroma">&lt;code class="language-python" data-lang="python">&lt;span class="line">&lt;span class="cl">&lt;span class="c1"># ❌ Examples retrieved at random from a pool&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="n">examples&lt;/span> &lt;span class="o">=&lt;/span> &lt;span class="n">random&lt;/span>&lt;span class="o">.&lt;/span>&lt;span class="n">sample&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="n">example_pool&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="n">k&lt;/span>&lt;span class="o">=&lt;/span>&lt;span class="mi">3&lt;/span>&lt;span class="p">)&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="n">system&lt;/span> &lt;span class="o">=&lt;/span> &lt;span class="sa">f&lt;/span>&lt;span class="s2">&amp;#34;Examples:&lt;/span>&lt;span class="se">\n&lt;/span>&lt;span class="si">{&lt;/span>&lt;span class="n">format_examples&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="n">examples&lt;/span>&lt;span class="p">)&lt;/span>&lt;span class="si">}&lt;/span>&lt;span class="se">\n\n&lt;/span>&lt;span class="s2">You are an assistant...&amp;#34;&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="c1"># ✅ Fixed few-shots always in the same order&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="n">FIXED_EXAMPLES&lt;/span> &lt;span class="o">=&lt;/span> &lt;span class="p">[&lt;/span>&lt;span class="n">example_pool&lt;/span>&lt;span class="p">[&lt;/span>&lt;span class="mi">0&lt;/span>&lt;span class="p">],&lt;/span> &lt;span class="n">example_pool&lt;/span>&lt;span class="p">[&lt;/span>&lt;span class="mi">1&lt;/span>&lt;span class="p">],&lt;/span> &lt;span class="n">example_pool&lt;/span>&lt;span class="p">[&lt;/span>&lt;span class="mi">2&lt;/span>&lt;span class="p">]]&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="n">system&lt;/span> &lt;span class="o">=&lt;/span> &lt;span class="sa">f&lt;/span>&lt;span class="s2">&amp;#34;Examples:&lt;/span>&lt;span class="se">\n&lt;/span>&lt;span class="si">{&lt;/span>&lt;span class="n">format_examples&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="n">FIXED_EXAMPLES&lt;/span>&lt;span class="p">)&lt;/span>&lt;span class="si">}&lt;/span>&lt;span class="se">\n\n&lt;/span>&lt;span class="s2">You are an assistant...&amp;#34;&lt;/span>
&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;hr>
&lt;h2 id="template-engineering-the-structure-that-maximises-hits">Template engineering: the structure that maximises hits&lt;/h2>
&lt;p>The principle is simple: &lt;strong>everything static goes first, everything dynamic goes after&lt;/strong>. The prefix cache is sequential. Once a block fails to hit, the rest are not looked up either.&lt;/p>
&lt;pre tabindex="0">&lt;code>OPTIMAL STRUCTURE for maximising prefix cache:
┌──────────────────────────────────────────────┐
│ STATIC BLOCK (tokens 0-511) │ ← hit rate ~100%
│ Invariant system prompt │
│ Fixed instructions │
│ Few-shots always in the same order │
├──────────────────────────────────────────────┤
│ SEMI-STATIC BLOCK (tokens 512-1023) │ ← hit rate ~60-80%
│ RAG documents for this session │
│ Conversation history so far │
├──────────────────────────────────────────────┤
│ DYNAMIC BLOCK (tokens 1024+) │ ← hit rate ~0% (expected)
│ Current user message │
│ Context specific to this request │
└──────────────────────────────────────────────┘
&lt;/code>&lt;/pre>&lt;p>For RAG specifically: if the retrieved documents are the same across a set of similar queries (very frequent in RAG over fixed corporate documents), ordering them &lt;strong>always in the same order&lt;/strong> (by ID, by a fixed score, not by a variable score) multiplies the hit rate of the semi-static block.&lt;/p>
&lt;hr>
&lt;h2 id="prefix-aware-routing-the-next-level">Prefix-aware routing: the next level&lt;/h2>
&lt;p>With a single vLLM instance, the prefix cache works automatically. The problem shows up with multiple replicas: the load balancer distributes requests round-robin, and the prefix cached on replica A is no use at all when the request lands on replica B.&lt;/p>
&lt;p>The solution is &lt;strong>prefix-aware routing&lt;/strong>: send requests with the same prefix to the same node.&lt;/p>
&lt;p>&lt;strong>With Ray Serve (native integration):&lt;/strong>&lt;/p>
&lt;div class="highlight">&lt;pre tabindex="0" class="chroma">&lt;code class="language-python" data-lang="python">&lt;span class="line">&lt;span class="cl">&lt;span class="c1"># ray_serve_prefix_router.py&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="kn">from&lt;/span> &lt;span class="nn">ray&lt;/span> &lt;span class="kn">import&lt;/span> &lt;span class="n">serve&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="kn">from&lt;/span> &lt;span class="nn">ray.serve.llm&lt;/span> &lt;span class="kn">import&lt;/span> &lt;span class="n">LLMConfig&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="n">build_llm_deployment&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="nd">@serve.deployment&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="k">class&lt;/span> &lt;span class="nc">PrefixAwareRouter&lt;/span>&lt;span class="p">:&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="k">def&lt;/span> &lt;span class="fm">__init__&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="bp">self&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="n">replicas&lt;/span>&lt;span class="p">):&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="bp">self&lt;/span>&lt;span class="o">.&lt;/span>&lt;span class="n">replicas&lt;/span> &lt;span class="o">=&lt;/span> &lt;span class="n">replicas&lt;/span> &lt;span class="c1"># list of vLLM handles&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="k">async&lt;/span> &lt;span class="k">def&lt;/span> &lt;span class="fm">__call__&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="bp">self&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="n">request&lt;/span>&lt;span class="p">):&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="n">body&lt;/span> &lt;span class="o">=&lt;/span> &lt;span class="k">await&lt;/span> &lt;span class="n">request&lt;/span>&lt;span class="o">.&lt;/span>&lt;span class="n">json&lt;/span>&lt;span class="p">()&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="n">messages&lt;/span> &lt;span class="o">=&lt;/span> &lt;span class="n">body&lt;/span>&lt;span class="o">.&lt;/span>&lt;span class="n">get&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="s2">&amp;#34;messages&amp;#34;&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="p">[])&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="c1"># Compute the hash of the system prompt (static prefix)&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="n">system_content&lt;/span> &lt;span class="o">=&lt;/span> &lt;span class="s2">&amp;#34;&amp;#34;&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="k">for&lt;/span> &lt;span class="n">msg&lt;/span> &lt;span class="ow">in&lt;/span> &lt;span class="n">messages&lt;/span>&lt;span class="p">:&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="k">if&lt;/span> &lt;span class="n">msg&lt;/span>&lt;span class="p">[&lt;/span>&lt;span class="s2">&amp;#34;role&amp;#34;&lt;/span>&lt;span class="p">]&lt;/span> &lt;span class="o">==&lt;/span> &lt;span class="s2">&amp;#34;system&amp;#34;&lt;/span>&lt;span class="p">:&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="n">system_content&lt;/span> &lt;span class="o">=&lt;/span> &lt;span class="n">msg&lt;/span>&lt;span class="p">[&lt;/span>&lt;span class="s2">&amp;#34;content&amp;#34;&lt;/span>&lt;span class="p">]&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="k">break&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="n">prefix_hash&lt;/span> &lt;span class="o">=&lt;/span> &lt;span class="nb">hash&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="n">system_content&lt;/span>&lt;span class="p">)&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="c1"># Deterministic routing: same hash → same node&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="n">replica_idx&lt;/span> &lt;span class="o">=&lt;/span> &lt;span class="n">prefix_hash&lt;/span> &lt;span class="o">%&lt;/span> &lt;span class="nb">len&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="bp">self&lt;/span>&lt;span class="o">.&lt;/span>&lt;span class="n">replicas&lt;/span>&lt;span class="p">)&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="k">return&lt;/span> &lt;span class="k">await&lt;/span> &lt;span class="bp">self&lt;/span>&lt;span class="o">.&lt;/span>&lt;span class="n">replicas&lt;/span>&lt;span class="p">[&lt;/span>&lt;span class="n">replica_idx&lt;/span>&lt;span class="p">]&lt;/span>&lt;span class="o">.&lt;/span>&lt;span class="n">remote&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="n">request&lt;/span>&lt;span class="p">)&lt;/span>
&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>&lt;strong>With an L7 gateway (Nginx/Traefik):&lt;/strong>&lt;/p>
&lt;div class="highlight">&lt;pre tabindex="0" class="chroma">&lt;code class="language-nginx" data-lang="nginx">&lt;span class="line">&lt;span class="cl">&lt;span class="c1"># nginx.conf — routing by X-Prefix-Hash header
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="c1">&lt;/span>&lt;span class="k">upstream&lt;/span> &lt;span class="s">vllm_backends&lt;/span> &lt;span class="p">{&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="kn">hash&lt;/span> &lt;span class="nv">$http_x_prefix_hash&lt;/span> &lt;span class="s">consistent&lt;/span>&lt;span class="p">;&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="kn">server&lt;/span> &lt;span class="n">vllm-0&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="mi">8000&lt;/span>&lt;span class="p">;&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="kn">server&lt;/span> &lt;span class="n">vllm-1&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="mi">8000&lt;/span>&lt;span class="p">;&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="kn">server&lt;/span> &lt;span class="n">vllm-2&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="mi">8000&lt;/span>&lt;span class="p">;&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="kn">server&lt;/span> &lt;span class="n">vllm-3&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="mi">8000&lt;/span>&lt;span class="p">;&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="p">}&lt;/span>
&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>The client computes the hash of the static prefix and includes it as a header:&lt;/p>
&lt;div class="highlight">&lt;pre tabindex="0" class="chroma">&lt;code class="language-python" data-lang="python">&lt;span class="line">&lt;span class="cl">&lt;span class="kn">import&lt;/span> &lt;span class="nn">hashlib&lt;/span>&lt;span class="o">,&lt;/span> &lt;span class="nn">requests&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="k">def&lt;/span> &lt;span class="nf">llm_request&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="n">messages&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="n">base_url&lt;/span>&lt;span class="p">):&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="n">system_msg&lt;/span> &lt;span class="o">=&lt;/span> &lt;span class="nb">next&lt;/span>&lt;span class="p">((&lt;/span>&lt;span class="n">m&lt;/span>&lt;span class="p">[&lt;/span>&lt;span class="s2">&amp;#34;content&amp;#34;&lt;/span>&lt;span class="p">]&lt;/span> &lt;span class="k">for&lt;/span> &lt;span class="n">m&lt;/span> &lt;span class="ow">in&lt;/span> &lt;span class="n">messages&lt;/span> &lt;span class="k">if&lt;/span> &lt;span class="n">m&lt;/span>&lt;span class="p">[&lt;/span>&lt;span class="s2">&amp;#34;role&amp;#34;&lt;/span>&lt;span class="p">]&lt;/span> &lt;span class="o">==&lt;/span> &lt;span class="s2">&amp;#34;system&amp;#34;&lt;/span>&lt;span class="p">),&lt;/span> &lt;span class="s2">&amp;#34;&amp;#34;&lt;/span>&lt;span class="p">)&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="n">prefix_hash&lt;/span> &lt;span class="o">=&lt;/span> &lt;span class="n">hashlib&lt;/span>&lt;span class="o">.&lt;/span>&lt;span class="n">sha256&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="n">system_msg&lt;/span>&lt;span class="o">.&lt;/span>&lt;span class="n">encode&lt;/span>&lt;span class="p">())&lt;/span>&lt;span class="o">.&lt;/span>&lt;span class="n">hexdigest&lt;/span>&lt;span class="p">()[:&lt;/span>&lt;span class="mi">16&lt;/span>&lt;span class="p">]&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="k">return&lt;/span> &lt;span class="n">requests&lt;/span>&lt;span class="o">.&lt;/span>&lt;span class="n">post&lt;/span>&lt;span class="p">(&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="sa">f&lt;/span>&lt;span class="s2">&amp;#34;&lt;/span>&lt;span class="si">{&lt;/span>&lt;span class="n">base_url&lt;/span>&lt;span class="si">}&lt;/span>&lt;span class="s2">/v1/chat/completions&amp;#34;&lt;/span>&lt;span class="p">,&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="n">json&lt;/span>&lt;span class="o">=&lt;/span>&lt;span class="p">{&lt;/span>&lt;span class="s2">&amp;#34;messages&amp;#34;&lt;/span>&lt;span class="p">:&lt;/span> &lt;span class="n">messages&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="s2">&amp;#34;model&amp;#34;&lt;/span>&lt;span class="p">:&lt;/span> &lt;span class="s2">&amp;#34;my-model&amp;#34;&lt;/span>&lt;span class="p">},&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="n">headers&lt;/span>&lt;span class="o">=&lt;/span>&lt;span class="p">{&lt;/span>&lt;span class="s2">&amp;#34;X-Prefix-Hash&amp;#34;&lt;/span>&lt;span class="p">:&lt;/span> &lt;span class="n">prefix_hash&lt;/span>&lt;span class="p">}&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="p">)&lt;/span>
&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;hr>
&lt;h2 id="measuring-the-impact-with-otel">Measuring the impact with OTel&lt;/h2>
&lt;div class="highlight">&lt;pre tabindex="0" class="chroma">&lt;code class="language-promql" data-lang="promql">&lt;span class="line">&lt;span class="cl">&lt;span class="c1"># Current hit rate (0.0 to 1.0) — target &amp;gt; 0.70 with enterprise workloads&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w">&lt;/span>&lt;span class="nv">vllm&lt;/span>&lt;span class="err">:&lt;/span>&lt;span class="nv">gpu_prefix_cache_hit_rate&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w">&lt;/span>&lt;span class="c1"># TTFT by percentile — should fall when the hit rate rises&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w">&lt;/span>&lt;span class="kr">histogram_quantile&lt;/span>&lt;span class="o">(&lt;/span>&lt;span class="mf">0.50&lt;/span>&lt;span class="p">,&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="kr">rate&lt;/span>&lt;span class="o">(&lt;/span>&lt;span class="nv">vllm&lt;/span>&lt;span class="err">:&lt;/span>&lt;span class="nv">time_to_first_token_seconds_bucket&lt;/span>&lt;span class="p">[&lt;/span>&lt;span class="s">5m&lt;/span>&lt;span class="p">]&lt;/span>&lt;span class="o">))&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w">&lt;/span>&lt;span class="kr">histogram_quantile&lt;/span>&lt;span class="o">(&lt;/span>&lt;span class="mf">0.95&lt;/span>&lt;span class="p">,&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="kr">rate&lt;/span>&lt;span class="o">(&lt;/span>&lt;span class="nv">vllm&lt;/span>&lt;span class="err">:&lt;/span>&lt;span class="nv">time_to_first_token_seconds_bucket&lt;/span>&lt;span class="p">[&lt;/span>&lt;span class="s">5m&lt;/span>&lt;span class="p">]&lt;/span>&lt;span class="o">))&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>The inverse correlation between hit rate and TTFT is the proof that the cache is working. If the hit rate goes from 15% to 70% and the p50 TTFT does not change, there is a configuration problem: the cache may be disabled, or the routing may not be sending requests to the right node.&lt;/p>
&lt;p>&lt;strong>Correlation query in Grafana&lt;/strong> (two-axis panel):&lt;/p>
&lt;div class="highlight">&lt;pre tabindex="0" class="chroma">&lt;code class="language-promql" data-lang="promql">&lt;span class="line">&lt;span class="cl">&lt;span class="c1"># Left Y axis: hit rate&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w">&lt;/span>&lt;span class="nv">vllm&lt;/span>&lt;span class="err">:&lt;/span>&lt;span class="nv">gpu_prefix_cache_hit_rate&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w">&lt;/span>&lt;span class="c1"># Right Y axis: TTFT p50 (inverted)&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w">&lt;/span>&lt;span class="kr">histogram_quantile&lt;/span>&lt;span class="o">(&lt;/span>&lt;span class="mf">0.50&lt;/span>&lt;span class="p">,&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="kr">rate&lt;/span>&lt;span class="o">(&lt;/span>&lt;span class="nv">vllm&lt;/span>&lt;span class="err">:&lt;/span>&lt;span class="nv">time_to_first_token_seconds_bucket&lt;/span>&lt;span class="p">[&lt;/span>&lt;span class="s">5m&lt;/span>&lt;span class="p">]&lt;/span>&lt;span class="o">))&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>The inverse slope should be visible: when the hit rate drops (a spike of requests with new prompts), TTFT rises. When the hit rate stabilises (users repeating the same flow), TTFT falls.&lt;/p>
&lt;hr>
&lt;h2 id="the-impact-in-numbers">The impact in numbers&lt;/h2>
&lt;p>For a system with 100 req/min, a 512-token system prompt and a before/after hit rate:&lt;/p>
&lt;table>
&lt;thead>
&lt;tr>
&lt;th style="text-align:left">Metric&lt;/th>
&lt;th style="text-align:right">Hit rate 15%&lt;/th>
&lt;th style="text-align:right">Hit rate 75%&lt;/th>
&lt;th style="text-align:right">Difference&lt;/th>
&lt;/tr>
&lt;/thead>
&lt;tbody>
&lt;tr>
&lt;td style="text-align:left">Prefill tokens per minute&lt;/td>
&lt;td style="text-align:right">5,100&lt;/td>
&lt;td style="text-align:right">12,800 — 50% cached → 6,400 effective&lt;/td>
&lt;td style="text-align:right">−37% load&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td style="text-align:left">TTFT p50 (512 prompt + 100 suffix)&lt;/td>
&lt;td style="text-align:right">~820 ms&lt;/td>
&lt;td style="text-align:right">~180 ms (suffix only)&lt;/td>
&lt;td style="text-align:right">−78%&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td style="text-align:left">Prefill capacity freed&lt;/td>
&lt;td style="text-align:right">—&lt;/td>
&lt;td style="text-align:right">+1,200 tok/min&lt;/td>
&lt;td style="text-align:right">available for more requests&lt;/td>
&lt;/tr>
&lt;/tbody>
&lt;/table>
&lt;p>The 75% hit rate in this example is equivalent to being able to serve 37% more requests on the same hardware, because the prefill work of 3 out of every 4 requests is already done.&lt;/p>
&lt;hr>
&lt;h2 id="when-the-prefix-cache-does-not-help">When the prefix cache does not help&lt;/h2>
&lt;p>The prefix cache is ineffective in workloads where every request has a completely unique prompt: translating a different document every time, code analysis with always-different context, creative generation with no system prompt. In these cases the hit rate structurally cannot exceed 5-10% and the template engineering effort does not pay off.&lt;/p>
&lt;p>The signal: if your p99 input length is greater than the p50, you have high prompt variance and the prefix cache contributes little. If the p50 and the p99 are similar (consistent prompts), the prefix cache is the cheapest lever available.&lt;/p>
&lt;hr>
&lt;h2 id="see-also">See also&lt;/h2>
&lt;ul>
&lt;li>
&lt;p>&lt;a href="https://blog.lo0.es/en/posts/vllm-attention-backend-flashattention-flashinfer-prefill-decode-asymmetry/">FlashInfer&amp;rsquo;s cascade attention: the &lt;em>compute&lt;/em> side of the shared prefix that the prefix cache solves in &lt;em>memory&lt;/em> — attending the common prefix once instead of R times&lt;/a>&lt;/p>
&lt;/li>
&lt;li>
&lt;p>&lt;a href="https://blog.lo0.es/en/posts/optimising-prefill-vllm-knobs-ttft-will-not-forgive/">&lt;code>--enable-prefix-caching&lt;/code> and the interaction with chunked prefill: only the first chunk benefits from the cache, which affects the optimal budget for &lt;code>max-num-batched-tokens&lt;/code>&lt;/a>&lt;/p>
&lt;/li>
&lt;li>
&lt;p>&lt;a href="https://blog.lo0.es/en/posts/kv-cache-working-memory-llm-inference/">the block structure the prefix cache operates on: why the 16-token granularity matters for template design&lt;/a>&lt;/p>
&lt;/li>
&lt;li>
&lt;p>&lt;a href="https://blog.lo0.es/en/posts/vllm-batch-sizing-two-hour-grid-search/">the grid search that determines the optimal &lt;code>max-num-seqs&lt;/code>, which interacts with the number of blocks available for the cache&lt;/a>&lt;/p>
&lt;/li>
&lt;li>
&lt;p>&lt;a href="https://blog.lo0.es/en/posts/llm-inference-router-l7-switchboard/">the L7 gateway where prefix-aware routing via header is implemented&lt;/a>&lt;/p>
&lt;/li>
&lt;li>
&lt;p>&lt;a href="https://blog.lo0.es/en/posts/instrumenting-vllm-otel-measuring-optimisations/">how to configure &lt;code>gpu_prefix_cache_hit_rate&lt;/code> on the Grafana dashboard and the alert for when it falls below the target threshold&lt;/a>&lt;/p>
&lt;/li>
&lt;li>
&lt;p>&lt;a href="https://blog.lo0.es/en/posts/prefix-routing-what-litellm-does-not-do/">Prefix routing: what LiteLLM does not do&lt;/a> — the real state of KV-aware routers in September 2026, and how much they genuinely improve.&lt;/p>
&lt;/li>
&lt;/ul>
&lt;h3 id="in-this-same-series">In this same series&lt;/h3>
&lt;ul>
&lt;li>&lt;a href="https://blog.lo0.es/en/posts/vllm-batch-sizing-two-hour-grid-search/">the first optimisation in the series: the grid search of max-num-seqs × max-num-batched-tokens&lt;/a>&lt;/li>
&lt;li>&lt;a href="https://blog.lo0.es/en/posts/fp8-end-to-end-enable-measure-quality-decide/">FP8 in weights and KV cache: doubling the VRAM available for cache and measuring the quality degradation before going to production&lt;/a>&lt;/li>
&lt;li>&lt;a href="https://blog.lo0.es/en/posts/one-big-replica-or-many-small-ones-platform-decision/">TP=4×1 vs TP=2×2: the per-session routing that complements the prefix-aware routing in this article&lt;/a>&lt;/li>
&lt;/ul>
&lt;hr>
&lt;h2 id="references">References&lt;/h2>
&lt;ul>
&lt;li>&lt;a href="https://docs.vllm.ai/en/stable/design/prefix_caching/">vLLM Automatic Prefix Caching — official documentation&lt;/a>&lt;/li>
&lt;li>&lt;a href="https://docs.ray.io/en/latest/serve/llm/user-guides/prefix-aware-routing.html">Prefix-aware routing — Ray Serve&lt;/a>&lt;/li>
&lt;li>&lt;a href="https://llm-d.ai/blog/kvcache-wins-you-can-see">KV-Cache Wins You Can See — llm-d blog&lt;/a>&lt;/li>
&lt;li>&lt;a href="https://www.digitalocean.com/blog/reduce-llm-inference-costs-prefix-caching">The Inference Tax: Prefix-Aware Routing — DigitalOcean&lt;/a>&lt;/li>
&lt;li>&lt;a href="https://github.com/vllm-project/vllm/issues/24394">vLLM issue #24394: Improve Prefix Cache Hit Rate&lt;/a>&lt;/li>
&lt;/ul></description></item></channel></rss>