<?xml version="1.0" encoding="utf-8" standalone="yes"?><rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom" xmlns:content="http://purl.org/rss/1.0/modules/content/"><channel><title>KV-Cache on vishctl</title><link>https://vishctl.dev/tags/kv-cache/</link><description>Recent content in KV-Cache on vishctl</description><generator>Hugo</generator><language>en-us</language><lastBuildDate>Mon, 12 Oct 2026 03:00:00 +0800</lastBuildDate><atom:link href="https://vishctl.dev/tags/kv-cache/index.xml" rel="self" type="application/rss+xml"/><item><title>SGLang vs. vLLM: RadixAttention, Structured Outputs, and When to Pick Which</title><link>https://vishctl.dev/posts/sglang-vs-vllm/</link><pubDate>Mon, 12 Oct 2026 03:00:00 +0800</pubDate><guid>https://vishctl.dev/posts/sglang-vs-vllm/</guid><category>sglang</category><category>vllm</category><category>inference</category><category>kv-cache</category><category>ai-infra</category><category>kubernetes</category><description>A simple, diagram-first comparison of SGLang and vLLM: how RadixAttention works, how jump-forward decoding speeds up JSON outputs, how their schedulers compare, and which engine fits your workload.</description><content:encoded><![CDATA[<p>If you self-host open-weight LLMs today, your choice almost always comes down to two engines: <strong><a href="https://github.com/vllm-project/vllm">vLLM</a></strong> and <strong><a href="https://github.com/sgl-project/sglang">SGLang</a></strong>.</p>
<p>Both started in the same UC Berkeley research circle. Both expose an OpenAI-compatible API. Both store the KV cache in pages, and both plug into Kubernetes routers like <a href="https://vishctl.dev/posts/llm-d-kubernetes-inference-routing/">llm-d</a>.</p>
<p>Yet one engine often beats the other on a real workload. Why? Because they were built to solve two different problems first:</p>
<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-text" data-lang="text"><span class="line"><span class="cl">vLLM    = built like OS virtual memory: pack many independent users onto any GPU
</span></span><span class="line"><span class="cl">SGLang  = built for agent programs: reuse branching prompts and force fast JSON outputs
</span></span></code></pre></div><figure class="mermaid-figure">
  <div class="mermaid">flowchart TD
    subgraph V["vLLM: many chats"]
        direction LR
        U1["User 1<br/>summarize email"] a1@--> G1["Shared GPU<br/>Paged KV pool"]
        U2["User 2<br/>write SQL"] a2@--> G1
        U3["User 3<br/>translate text"] a3@--> G1
    end

    subgraph S["SGLang: one agent"]
        direction LR
        A0["Agent<br/>6k-token runbook"] b1@--> B1["Check<br/>pod logs"]
        A0 b2@--> B2["Check<br/>node events"]
        A0 b3@--> B3["Return<br/>strict JSON"]
    end

    V ~~~ S
    a1@{ animate: true }
    a2@{ animate: true }
    a3@{ animate: true }
    b1@{ animate: true }
    b2@{ animate: true }
    b3@{ animate: true }

    classDef input fill:#13264a,stroke:#38bdf8,color:#e6f6ff,stroke-width:2px;
    classDef gpu fill:#173f59,stroke:#22d3ee,color:#ecfeff,stroke-width:2px;
    classDef agent fill:#281d48,stroke:#a78bfa,color:#f1edff,stroke-width:2px;
    class U1,U2,U3 input;
    class G1 gpu;
    class A0 agent;
    class B1,B2,B3 gpu;</div>
  <figcaption>Top: vLLM started with many separate callers sharing one GPU. Bottom: SGLang started with one agent branching into sub-tasks and returning strict JSON.</figcaption>
</figure>

<p>Over the past year, both engines have borrowed each other&rsquo;s best ideas. Still, three core pieces explain why they behave differently in practice:</p>
<ol>
<li><strong>RadixAttention vs. block hash tables</strong> (how they reuse the KV cache)</li>
<li><strong>Jump-forward decoding</strong> (how they generate strict JSON)</li>
<li><strong>Overlapped CPU scheduling</strong> (how they keep the GPU from waiting on Python)</li>
</ol>
<p>Let&rsquo;s walk through each one with simple examples.</p>
<h2 id="1-kv-cache-reuse-block-hash-tables-vs-radixattention">1. KV cache reuse: block hash tables vs. RadixAttention</h2>
<p>First, clear up a common myth: <strong>both vLLM and SGLang use paged GPU memory.</strong> When a model reads a prompt, both engines store the resulting attention state (<a href="https://vishctl.dev/posts/llm-basics-visual-guide/#5-context-is-the-input-kv-cache-saves-work">the KV cache</a>) in small physical memory blocks on the GPU.</p>
<p>The difference is <strong>how they organize and look up those blocks</strong> when the next request arrives.</p>
<h3 id="how-vllm-does-it-a-hash-table-of-fixed-blocks">How vLLM does it: a hash table of fixed blocks</h3>
<p>When you turn on <a href="https://docs.vllm.ai/en/latest/features/automatic_prefix_caching/">Automatic Prefix Caching</a> in vLLM, it slices your prompt into fixed-size chunks (usually 16 tokens per block).</p>
<p>It hashes Block 0, then hashes Block 0 + Block 1, and stores those hashes in a lookup table:</p>
<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-text" data-lang="text"><span class="line"><span class="cl">Block 0 (tokens 1..16)   → hash_A → GPU page 12
</span></span><span class="line"><span class="cl">Block 1 (tokens 17..32)  → hash_B → GPU page 45
</span></span><span class="line"><span class="cl">Block 2 (tokens 33..48)  → hash_C → GPU page 08
</span></span></code></pre></div><p>If a new request starts with the exact same 16-token blocks, vLLM finds the hashes in its table and reuses those GPU pages.</p>
<h3 id="how-sglang-does-it-radixattention-a-shared-family-tree">How SGLang does it: RadixAttention (a shared family tree)</h3>
<p>Instead of a flat hash table, SGLang stores cached token sequences in a <strong>radix tree</strong> (a compressed tree where shared prefixes form a single trunk, and differences split into branches).</p>
<figure class="mermaid-figure">
  <div class="mermaid">flowchart TD
    subgraph VLLM["vLLM: hash table"]
        direction LR
        H1["Block 0<br/>16 tokens<br/>hash 9f2a"] a1@--> H2["Block 1<br/>16 tokens<br/>hash 4c1b"]
        H2 a2@--> H3["Block 2A<br/>16 tokens<br/>hash 7e8d"]
        H2 a3@--> H4["Block 2B<br/>16 tokens<br/>hash 1a3f"]
    end

    subgraph SGL["SGLang: Radix tree"]
        direction LR
        R0["Trunk<br/>System prompt +<br/>6k-token runbook"] b1@--> B1["Branch 1<br/>Pod OOM check<br/>+ answer"]
        R0 b2@--> B2["Branch 2<br/>Node disk check<br/>+ answer"]
        B1 b3@--> L1["Leaf 1A<br/>Suggest memory limit"]
        B1 b4@--> L2["Leaf 1B<br/>Draft PR patch"]
    end

    VLLM ~~~ SGL
    a1@{ animate: true }
    a2@{ animate: true }
    a3@{ animate: true }
    b1@{ animate: true }
    b2@{ animate: true }
    b3@{ animate: true }
    b4@{ animate: true }

    classDef input fill:#13264a,stroke:#38bdf8,color:#e6f6ff,stroke-width:2px;
    classDef tree fill:#281d48,stroke:#a78bfa,color:#f1edff,stroke-width:2px;
    classDef hit fill:#173f59,stroke:#22d3ee,color:#ecfeff,stroke-width:2px;
    classDef leaf fill:#302410,stroke:#fbbf24,color:#fff3cf,stroke-width:2px;
    class H1,H2,H3,H4 input;
    class R0 tree;
    class B1,B2 hit;
    class L1,L2 leaf;</div>
  <figcaption>Top: vLLM matches fixed 16-token blocks in a hash table. Bottom: SGLang keeps prompts and answers in a branching Radix tree.</figcaption>
</figure>

<h3 id="example-1-one-incident-agent-four-steps">Example 1: One incident agent, four steps</h3>
<p>Imagine an on-call Kubernetes agent that starts with a <strong>6,000-token runbook</strong> and runs four steps. Here is how SGLang&rsquo;s radix tree handles it:</p>
<div class="table-scroll">
  <table>
    <thead>
        <tr>
            <th scope="col">Step</th>
            <th scope="col">What the agent sends</th>
            <th scope="col">What happens in the Radix tree</th>
            <th scope="col">Prefill work saved</th>
        </tr>
    </thead>
    <tbody>
        <tr>
            <td><strong>1</strong></td>
            <td><code>Runbook + &quot;Why did payment-worker crash?&quot;</code></td>
            <td>No match yet. Creates one node holding the runbook, question, and the model&rsquo;s answer.</td>
            <td>0 tokens (cold start)</td>
        </tr>
        <tr>
            <td><strong>2</strong></td>
            <td><code>Runbook + &quot;Did the node run out of disk?&quot;</code></td>
            <td>Splits the tree right after <code>Runbook</code>. Adds a second branch for the disk question.</td>
            <td><strong>Reuses 6,000 runbook tokens</strong></td>
        </tr>
        <tr>
            <td><strong>3</strong></td>
            <td><code>Step 1 chat history + &quot;Raise memory limit to 2Gi&quot;</code></td>
            <td>Matches the trunk <em>and</em> Branch 1 (including the model&rsquo;s Step 1 answer). Adds a leaf.</td>
            <td><strong>Reuses runbook + Step 1 Q&amp;A</strong></td>
        </tr>
        <tr>
            <td><strong>4</strong></td>
            <td>GPU memory gets full</td>
            <td>Evicts the oldest unused <strong>leaf</strong> first (<code>Step 2</code>), keeping the shared <code>Runbook</code> trunk alive.</td>
            <td>Trunk stays warm for future calls</td>
        </tr>
    </tbody>
  </table>
</div>
<p>Watch how the tree splits and evicts from the leaves inward:</p>
<figure class="mermaid-figure">
  <div class="mermaid">flowchart TD
    T["Shared Trunk<br/>6,000-token Runbook<br/>(kept warm)"] e1@--> B1["Branch 1<br/>Q1: payment-worker crash?<br/>+ Model Answer 1"]
    T e2@--> B2["Branch 2<br/>Q2: node disk full?<br/>(evicted first when idle)"]
    B1 e3@--> L1["Leaf<br/>Q3: Raise limit to 2Gi"]

    e1@{ animate: true }
    e2@{ animate: true }
    e3@{ animate: true }

    classDef trunk fill:#281d48,stroke:#a78bfa,color:#f1edff,stroke-width:3px;
    classDef warm fill:#173f59,stroke:#22d3ee,color:#ecfeff,stroke-width:2px;
    classDef evict fill:#302410,stroke:#fbbf24,color:#fff3cf,stroke-dasharray:5 4;
    class T trunk;
    class B1,L1 warm;
    class B2 evict;</div>
  <figcaption>Step 1 builds a single branch. Step 2 splits the edge at the shared runbook. Step 3 extends Branch 1. When memory fills up, unused leaves get evicted before the shared trunk.</figcaption>
</figure>

<p>And here is what happens across a multi-turn chat when both the prompt and the model&rsquo;s generated reply stay in the tree:</p>
<figure class="mermaid-figure">
  <div class="mermaid">sequenceDiagram
    participant A as Agent
    participant S as SGLang Radix Tree
    participant G as GPU

    A->>S: 1. Runbook + Check pod status
    S->>G: Prefill Runbook + Q1, decode Answer 1
    G-->>S: Save Runbook + Q1 + Answer 1 in tree
    S-->>A: Answer 1 (Pod was OOMKilled)

    A->>S: 2. Turn 1 history + Write YAML fix
    Note over S: Full cache hit on Runbook + Q1 + Answer 1
    S->>G: Prefill only Write YAML fix
    G-->>A: Answer 2 (YAML patch)</div>
  <figcaption>In SGLang, Turn 1 stores both your prompt and the model&#39;s answer in the radix tree. Turn 2 only computes the new follow-up line.</figcaption>
</figure>

<p>One bonus trick: because SGLang can see the whole tree, its scheduler <strong>reorders waiting requests</strong> so requests sharing the same branch run back-to-back before their cached KV pages get evicted.</p>
<h2 id="2-structured-outputs-why-json-schemas-slow-models-down-and-how-jump-forward-fixes-it">2. Structured outputs: why JSON schemas slow models down (and how Jump-Forward fixes it)</h2>
<p>When an <a href="https://vishctl.dev/posts/ai-agent-basics/">AI agent</a> calls a tool or updates a ticket, it cannot return a chatty paragraph. Your code needs strict JSON:</p>
<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-json" data-lang="json"><span class="line"><span class="cl"><span class="p">{</span><span class="nt">&#34;pod_name&#34;</span><span class="p">:</span> <span class="s2">&#34;payment-worker&#34;</span><span class="p">,</span> <span class="nt">&#34;oom_killed&#34;</span><span class="p">:</span> <span class="kc">true</span><span class="p">,</span> <span class="nt">&#34;memory_mb&#34;</span><span class="p">:</span> <span class="mi">2048</span><span class="p">}</span>
</span></span></code></pre></div><p>If you only ask for JSON in the system prompt, the model will eventually add markdown backticks or miss a quote. <strong>Guided (or constrained) decoding</strong> guarantees valid JSON by blocking every invalid token at each step.</p>
<h3 id="the-slow-way-checking-one-token-at-a-time">The slow way: checking one token at a time</h3>
<p>Normally, a model generates text one token at a time. With naive guided decoding, every single step looks like this:</p>
<ol>
<li>Check the JSON schema to see which tokens from the 128,000-word vocabulary are legal right now.</li>
<li>Mask out all illegal tokens.</li>
<li>Run a full GPU forward pass to pick <strong>one</strong> token.</li>
</ol>
<h3 id="example-2-count-the-wasted-gpu-steps-in-one-json-line">Example 2: Count the wasted GPU steps in one JSON line</h3>
<p>Look at our JSON output again:</p>
<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-text" data-lang="text"><span class="line"><span class="cl">{&#34;pod_name&#34;: &#34;payment-worker&#34;, &#34;oom_killed&#34;: true, &#34;memory_mb&#34;: 2048}
</span></span></code></pre></div><p>Once the model starts the object, does it really have a choice about typing <code>{&quot;pod_name&quot;: &quot;</code>?</p>
<p>No! The schema requires <code>pod_name</code> first. And after the model generates <code>payment-worker</code>, the very next characters <strong>must</strong> be <code>&quot;, &quot;oom_killed&quot;: </code>.</p>
<p>In a standard token-by-token loop, the GPU still runs a separate decode forward pass for <code>{</code>, <code>&quot;</code>, <code>pod</code>, <code>_name</code>, <code>&quot;:</code>, and <code>&quot;</code>. You are using a massive neural network as an expensive typewriter for fixed punctuation.</p>
<h3 id="the-fast-way-sglangs-jump-forward-decoding">The fast way: SGLang&rsquo;s Jump-Forward Decoding</h3>
<p>SGLang turns your JSON schema into a <strong>Compressed Finite-State Machine (FSM)</strong>. Whenever the schema leaves only one valid path, SGLang <strong>jumps forward</strong>: it inserts the entire fixed string (<code>{&quot;pod_name&quot;: &quot;</code>) at once without running step-by-step decode passes, and only asks the GPU to sample when there is a real decision to make.</p>
<figure class="mermaid-figure">
  <div class="mermaid">flowchart TD
    subgraph SLOW["Token-by-token decode"]
        direction LR
        T1["open<br/>brace"] --> T2["pod"] --> T3["_name"] --> T4["colon +<br/>quote"] --> T5["payment-<br/>worker"] --> T6["...and<br/>so on"]
    end

    subgraph FAST["Jump-Forward decode"]
        direction LR
        J1["Jump at once<br/>pod_name key"] b1@--> J2["GPU sample<br/>payment-worker"]
        J2 b2@--> J3["Jump at once<br/>oom_killed key"]
        J3 b3@--> J4["GPU sample<br/>true"]
    end

    SLOW ~~~ FAST
    b1@{ animate: true }
    b2@{ animate: true }
    b3@{ animate: true }

    classDef waste fill:#302410,stroke:#fbbf24,color:#fff3cf,stroke-width:2px;
    classDef jump fill:#281d48,stroke:#a78bfa,color:#f1edff,stroke-width:2px;
    classDef sample fill:#173f59,stroke:#22d3ee,color:#ecfeff,stroke-width:2px;
    class T1,T2,T3,T4,T6 waste;
    class T5 sample;
    class J1,J3 jump;
    class J2,J4 sample;</div>
  <figcaption>Top: token-by-token decoding runs a GPU step for every forced key and quote. Bottom: Jump-Forward pastes the fixed schema text at once and only samples the values.</figcaption>
</figure>

<h3 id="what-about-xgrammar">What about XGrammar?</h3>
<p>To make the vocabulary masks themselves fast, the SGLang / CMU team also built <strong><a href="https://arxiv.org/abs/2411.15100">XGrammar</a></strong>, which precomputes grammar masks in CPU cache so checking valid tokens takes microseconds instead of milliseconds.</p>
<p>Good news if you use vLLM: <strong>vLLM V1 now uses XGrammar too.</strong> Today, both engines give you fast grammar masking through the standard OpenAI <code>response_format</code> field, while SGLang adds jump-forward decoding and its own Python DSL for multi-step templates.</p>
<h2 id="3-cpu-scheduling-why-fast-gpus-sit-idle-waiting-on-python">3. CPU scheduling: why fast GPUs sit idle waiting on Python</h2>
<p>You might think LLM serving is 100% GPU work. On modern hardware, it isn&rsquo;t.</p>
<h3 id="example-3-the-10-ms-gpu-step-and-the-5-ms-python-pause">Example 3: The 10 ms GPU step and the 5 ms Python pause</h3>
<p>Suppose a batch decode step on an H100 takes <strong>10 ms</strong> on the GPU.</p>
<p>Between every step, the Python process on the CPU has to:</p>
<ul>
<li>Check which sequences finished</li>
<li>Look up prefix cache blocks or radix tree nodes</li>
<li>Build page tables for the next step</li>
<li>Turn token IDs back into text strings for streaming</li>
</ul>
<p>Suppose that CPU bookkeeping takes <strong>5 ms</strong>. If the engine runs the CPU step and the GPU step one after the other, every 10 ms of GPU work is followed by 5 ms of silence:</p>
<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-text" data-lang="text"><span class="line"><span class="cl">Sequential:  [CPU 5ms] → [GPU 10ms] → [CPU 5ms (GPU idle!)] → [GPU 10ms]
</span></span><span class="line"><span class="cl">Result:      The GPU sits idle 33% of the time.
</span></span></code></pre></div><h3 id="the-fix-overlapped-scheduling">The fix: Overlapped Scheduling</h3>
<p>SGLang solved this with its <strong><a href="https://lmsys.org/blog/2024-12-04-sglang-v0-4/">Zero-Overhead Batch Scheduler</a></strong>: while the GPU is busy running Step 1, the CPU is <em>already</em> preparing the metadata for Step 2 in parallel. By the time the GPU finishes Step 1, Step 2 is waiting at the door.</p>
<figure class="mermaid-figure">
  <div class="mermaid">flowchart TD
    subgraph SEQ["Without overlap"]
        direction LR
        C1["CPU prep 1<br/>5 ms"] --> G1["GPU run 1<br/>10 ms"] --> C2["CPU prep 2<br/>GPU idle 5 ms"] --> G2["GPU run 2<br/>10 ms"]
    end

    subgraph OVL["With overlap"]
        direction LR
        OC1["CPU prep 1"] b1@--> OG1["GPU run 1<br/>10 ms"]
        OG1 b2@--> OG2["GPU run 2<br/>10 ms"]
        OC1 b3@--> OC2["CPU prep 2<br/>in parallel"]
        OC2 b4@--> OG2
    end

    SEQ ~~~ OVL
    b1@{ animate: true }
    b2@{ animate: true }
    b3@{ animate: true }
    b4@{ animate: true }

    classDef cpu fill:#281d48,stroke:#a78bfa,color:#f1edff,stroke-width:2px;
    classDef gpu fill:#173f59,stroke:#22d3ee,color:#ecfeff,stroke-width:2px;
    classDef stall fill:#302410,stroke:#fbbf24,color:#fff3cf,stroke-width:2px;
    class C1,OC1,OC2 cpu;
    class G1,G2,OG1,OG2 gpu;
    class C2 stall;</div>
  <figcaption>Top: without overlap, the GPU waits every time the CPU prepares the next batch. Bottom: SGLang and vLLM V1 prepare Step 2 on the CPU while Step 1 is still running on the GPU.</figcaption>
</figure>

<p>vLLM saw the exact same bottleneck and rewrote its core loop in <strong><a href="https://blog.vllm.ai/2025/01/27/v1-alpha-release.html">vLLM V1</a></strong> to overlap CPU scheduling with GPU execution as well. That is why gap benchmarks between vLLM v0.x and SGLang narrowed significantly once vLLM V1 became the default.</p>
<h2 id="where-do-they-fit-in-a-kubernetes-cluster">Where do they fit in a Kubernetes cluster?</h2>
<p>If you read my earlier posts on <a href="https://vishctl.dev/posts/llm-d-kubernetes-inference-routing/">llm-d</a> and <a href="https://vishctl.dev/posts/why-lmcache-is-needed/">LMCache</a>, it helps to see how all of these tools fit together. They do not replace each other; they sit at different layers:</p>
<figure class="mermaid-figure">
  <div class="mermaid">flowchart TD
    C["Client / Agent<br/>OpenAI API request"] e1@--> R["llm-d Router<br/>Picks cache-warm pod"]
    R e2@--> P1["Pod A: vLLM or SGLang<br/>Local GPU KV cache"]
    R -.-> P2["Pod B: vLLM or SGLang<br/>Local GPU KV cache"]
    P1 e3@--> L["LMCache / Mooncake<br/>Shared CPU or remote KV pool"]
    P2 -.-> L

    e1@{ animate: true }
    e2@{ animate: true }
    e3@{ animate: true }

    classDef input fill:#13264a,stroke:#38bdf8,color:#e6f6ff,stroke-width:2px;
    classDef router fill:#281d48,stroke:#a78bfa,color:#f1edff,stroke-width:2px;
    classDef pod fill:#173f59,stroke:#22d3ee,color:#ecfeff,stroke-width:2px;
    classDef store fill:#302410,stroke:#fbbf24,color:#fff3cf,stroke-width:2px;
    class C input;
    class R router;
    class P1,P2 pod;
    class L store;</div>
  <figcaption>llm-d routes requests to the right pod. Inside that pod, you run either vLLM or SGLang. LMCache or Mooncake can sit underneath to share KV cache across pods.</figcaption>
</figure>

<ul>
<li><strong>llm-d</strong> sits <strong>above</strong> the pods and routes a request to whichever pod already has the prefix warm.</li>
<li><strong>vLLM or SGLang</strong> runs <strong>inside</strong> each pod to execute the model and manage that pod&rsquo;s GPU KV cache.</li>
<li><strong>LMCache or Mooncake</strong> sits <strong>beside/below</strong> the pods to offload or move KV tensors between pods when one pod&rsquo;s GPU memory isn&rsquo;t enough.</li>
</ul>
<h2 id="hands-on-run-the-same-json-test-on-both-servers">Hands-on: run the same JSON test on both servers</h2>
<p>Because both servers speak the OpenAI API, you can swap one for the other without touching your client code.</p>
<h3 id="1-start-vllm">1. Start vLLM</h3>
<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-bash" data-lang="bash"><span class="line"><span class="cl">vllm serve Qwen/Qwen3-8B <span class="se">\
</span></span></span><span class="line"><span class="cl">  --port <span class="m">8000</span> <span class="se">\
</span></span></span><span class="line"><span class="cl">  --enable-prefix-caching <span class="se">\
</span></span></span><span class="line"><span class="cl">  --structured-outputs-config <span class="s1">&#39;{&#34;backend&#34;: &#34;xgrammar&#34;}&#39;</span>
</span></span></code></pre></div><h3 id="2-start-sglang">2. Start SGLang</h3>
<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-bash" data-lang="bash"><span class="line"><span class="cl">python3 -m sglang.launch_server <span class="se">\
</span></span></span><span class="line"><span class="cl">  --model-path Qwen/Qwen3-8B <span class="se">\
</span></span></span><span class="line"><span class="cl">  --port <span class="m">30000</span> <span class="se">\
</span></span></span><span class="line"><span class="cl">  --grammar-backend xgrammar
</span></span></code></pre></div><h3 id="3-send-the-exact-same-structured-output-request-to-either-port">3. Send the exact same structured-output request to either port</h3>
<p>Point this <code>curl</code> command at <code>localhost:8000</code> (vLLM) or <code>localhost:30000</code> (SGLang):</p>
<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-bash" data-lang="bash"><span class="line"><span class="cl">curl -sS http://localhost:30000/v1/chat/completions <span class="se">\
</span></span></span><span class="line"><span class="cl">  -H <span class="s2">&#34;Content-Type: application/json&#34;</span> <span class="se">\
</span></span></span><span class="line"><span class="cl">  -d <span class="s1">&#39;{
</span></span></span><span class="line"><span class="cl"><span class="s1">    &#34;model&#34;: &#34;Qwen/Qwen3-8B&#34;,
</span></span></span><span class="line"><span class="cl"><span class="s1">    &#34;messages&#34;: [
</span></span></span><span class="line"><span class="cl"><span class="s1">      {
</span></span></span><span class="line"><span class="cl"><span class="s1">        &#34;role&#34;: &#34;system&#34;,
</span></span></span><span class="line"><span class="cl"><span class="s1">        &#34;content&#34;: &#34;You are a Kubernetes incident responder. Classify the alert into the required JSON schema.&#34;
</span></span></span><span class="line"><span class="cl"><span class="s1">      },
</span></span></span><span class="line"><span class="cl"><span class="s1">      {
</span></span></span><span class="line"><span class="cl"><span class="s1">        &#34;role&#34;: &#34;user&#34;,
</span></span></span><span class="line"><span class="cl"><span class="s1">        &#34;content&#34;: &#34;Pod payment-worker-7d9f8b-x2k9p is in CrashLoopBackOff with exit code 137 (OOMKilled).&#34;
</span></span></span><span class="line"><span class="cl"><span class="s1">      }
</span></span></span><span class="line"><span class="cl"><span class="s1">    ],
</span></span></span><span class="line"><span class="cl"><span class="s1">    &#34;response_format&#34;: {
</span></span></span><span class="line"><span class="cl"><span class="s1">      &#34;type&#34;: &#34;json_schema&#34;,
</span></span></span><span class="line"><span class="cl"><span class="s1">      &#34;json_schema&#34;: {
</span></span></span><span class="line"><span class="cl"><span class="s1">        &#34;name&#34;: &#34;incident_triage&#34;,
</span></span></span><span class="line"><span class="cl"><span class="s1">        &#34;strict&#34;: true,
</span></span></span><span class="line"><span class="cl"><span class="s1">        &#34;schema&#34;: {
</span></span></span><span class="line"><span class="cl"><span class="s1">          &#34;type&#34;: &#34;object&#34;,
</span></span></span><span class="line"><span class="cl"><span class="s1">          &#34;properties&#34;: {
</span></span></span><span class="line"><span class="cl"><span class="s1">            &#34;pod&#34;: { &#34;type&#34;: &#34;string&#34; },
</span></span></span><span class="line"><span class="cl"><span class="s1">            &#34;reason&#34;: { &#34;type&#34;: &#34;string&#34;, &#34;enum&#34;: [&#34;OOMKilled&#34;, &#34;ImagePullBackOff&#34;, &#34;CrashLoopBackOff&#34;, &#34;NodeNotReady&#34;] },
</span></span></span><span class="line"><span class="cl"><span class="s1">            &#34;oom_killed&#34;: { &#34;type&#34;: &#34;boolean&#34; },
</span></span></span><span class="line"><span class="cl"><span class="s1">            &#34;recommended_action&#34;: { &#34;type&#34;: &#34;string&#34; }
</span></span></span><span class="line"><span class="cl"><span class="s1">          },
</span></span></span><span class="line"><span class="cl"><span class="s1">          &#34;required&#34;: [&#34;pod&#34;, &#34;reason&#34;, &#34;oom_killed&#34;, &#34;recommended_action&#34;],
</span></span></span><span class="line"><span class="cl"><span class="s1">          &#34;additionalProperties&#34;: false
</span></span></span><span class="line"><span class="cl"><span class="s1">        }
</span></span></span><span class="line"><span class="cl"><span class="s1">      }
</span></span></span><span class="line"><span class="cl"><span class="s1">    }
</span></span></span><span class="line"><span class="cl"><span class="s1">  }&#39;</span>
</span></span></code></pre></div><h3 id="4-check-the-cache-hit-metrics">4. Check the cache hit metrics</h3>
<p>Send the request a second time with a different pod name, then check <code>/metrics</code> to confirm the system prompt hit the cache:</p>
<div class="table-scroll">
  <table>
    <thead>
        <tr>
            <th scope="col">Engine</th>
            <th scope="col">Metric to check</th>
            <th scope="col">What you want to see</th>
        </tr>
    </thead>
    <tbody>
        <tr>
            <td><strong>vLLM</strong></td>
            <td><code>vllm:prefix_cache_hits_total</code> ÷ <code>vllm:prefix_cache_queries_total</code></td>
            <td>Hit ratio climbs on repeated system prompts</td>
        </tr>
        <tr>
            <td><strong>SGLang</strong></td>
            <td><code>sglang:cache_hit_rate</code> and <code>sglang:cached_tokens_total</code></td>
            <td>Radix tree hit rate climbs and prefill time drops</td>
        </tr>
    </tbody>
  </table>
</div>
<p>Both servers also return the cached token count directly in the API response under <code>usage.prompt_tokens_details.cached_tokens</code>.</p>
<h2 id="side-by-side-comparison">Side-by-side comparison</h2>
<div class="table-scroll">
  <table>
    <thead>
        <tr>
            <th scope="col">Feature</th>
            <th scope="col">vLLM</th>
            <th scope="col">SGLang</th>
        </tr>
    </thead>
    <tbody>
        <tr>
            <td><strong>Main strength</strong></td>
            <td>Broadest model and hardware support; default Kubernetes serving engine</td>
            <td>Branching prefix reuse (RadixAttention), structured outputs, and frontier MoE speed</td>
        </tr>
        <tr>
            <td><strong>KV cache lookup</strong></td>
            <td>Block hash table (<code>--enable-prefix-caching</code>)</td>
            <td><strong>RadixAttention</strong> tree with edge splitting and leaf-first LRU eviction</td>
        </tr>
        <tr>
            <td><strong>Structured JSON</strong></td>
            <td>XGrammar / <code>llguidance</code></td>
            <td>XGrammar / <code>llguidance</code> + <strong>Jump-Forward Decoding</strong></td>
        </tr>
        <tr>
            <td><strong>CPU scheduler</strong></td>
            <td>Overlapped multiprocess loop (vLLM V1)</td>
            <td>Zero-overhead overlapped batch scheduler</td>
        </tr>
        <tr>
            <td><strong>DeepSeek / MoE tuning</strong></td>
            <td>Strong support (MLA, DeepEP, P/D disaggregation)</td>
            <td>Often first to ship new DeepSeek-V3/R1 kernels (MLA, Two-Batch Overlap, MTP)</td>
        </tr>
        <tr>
            <td><strong>Hardware support</strong></td>
            <td><strong>Widest:</strong> NVIDIA, AMD ROCm, Google TPU, Intel Gaudi/XPU, AWS Neuron, Ascend, Apple Silicon, CPU</td>
            <td>Primarily NVIDIA and AMD ROCm, with growing Ascend/TPU/CPU support</td>
        </tr>
        <tr>
            <td><strong>Model variety</strong></td>
            <td><strong>200+ architectures:</strong> LLMs, vision, audio/Whisper, embeddings, rerankers</td>
            <td>Major production LLMs and vision-language models</td>
        </tr>
    </tbody>
  </table>
</div>
<h2 id="when-to-pick-which">When to pick which</h2>
<p>Follow the arrows based on your hardware and traffic:</p>
<figure class="mermaid-figure">
  <div class="mermaid">flowchart TD
    Q1{"Running on non-NVIDIA hardware<br/>(TPU, Gaudi, Neuron) or serving<br/>embeddings / audio / niche models?"}
    Q1 e1@-->|"Yes"| V1["Pick vLLM<br/>Widest hardware and model support"]
    Q1 e2@-->|"No (NVIDIA / AMD + standard LLM)"| Q2{"What does your workload<br/>do most?"}

    Q2 e3@-->|"Agent loops, branching prompts,<br/>multi-turn forks, strict JSON outputs"| S1["Pick SGLang<br/>RadixAttention + Jump-Forward"]
    Q2 e4@-->|"Huge MoE models<br/>(DeepSeek-V3/R1, Qwen3-MoE)"| S2["Test SGLang first<br/>then compare against vLLM V1"]
    Q2 e5@-->|"General chat API or standard<br/>Kubernetes recipes (llm-d, KServe)"| V2["Pick vLLM<br/>Default across K8s tooling"]

    e1@{ animate: true }
    e2@{ animate: true }
    e3@{ animate: true }
    e4@{ animate: true }
    e5@{ animate: true }

    classDef question fill:#281d48,stroke:#a78bfa,color:#f1edff,stroke-width:2px;
    classDef vllm fill:#13264a,stroke:#38bdf8,color:#e6f6ff,stroke-width:3px;
    classDef sglang fill:#173f59,stroke:#22d3ee,color:#ecfeff,stroke-width:3px;
    class Q1,Q2 question;
    class V1,V2 vllm;
    class S1,S2 sglang;</div>
  <figcaption>Start at the top. Hardware and model breadth point to vLLM; branching agent trees, heavy JSON schemas, and large MoE throughput point to SGLang.</figcaption>
</figure>

<h3 id="pick-vllm-when">Pick vLLM when:</h3>
<ul>
<li><strong>You run many different model types or hardware chips.</strong> If one platform team serves chat models, embeddings, rerankers, and Whisper across mixed GPUs (or TPUs and Gaudi), vLLM handles all of them with one tool.</li>
<li><strong>You want plug-and-play Kubernetes guides.</strong> Projects like <a href="https://vishctl.dev/posts/llm-d-kubernetes-inference-routing/">llm-d</a>, <a href="https://vishctl.dev/posts/why-lmcache-is-needed/">LMCache</a>, and KServe test their default recipes on vLLM first.</li>
<li><strong>Your traffic is mostly straightforward chat or independent prompts</strong> where fixed-block prefix caching is plenty.</li>
</ul>
<h3 id="pick-sglang-when">Pick SGLang when:</h3>
<ul>
<li><strong>You run multi-step agents or branching workflows.</strong> If one request forks into sub-tasks, reuses multi-turn answers, and demands strict JSON on every call, RadixAttention and Jump-Forward decoding are built for your exact traffic.</li>
<li><strong>You serve large MoE models like DeepSeek-V3/R1 or Qwen3-MoE on NVIDIA GPUs.</strong> SGLang has consistently led on specialized MoE kernels, Multi-head Latent Attention (MLA), and Two-Batch Overlap.</li>
</ul>
<h2 id="cheat-sheet">Cheat sheet</h2>
<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-text" data-lang="text"><span class="line"><span class="cl">PagedAttention        = store KV cache in small GPU memory pages (both use this)
</span></span><span class="line"><span class="cl">vLLM APC              = find reusable prefixes using a hash table of 16-token blocks
</span></span><span class="line"><span class="cl">RadixAttention        = SGLang&#39;s shared prefix tree; splits branches and evicts leaves first
</span></span><span class="line"><span class="cl">Constrained decoding  = block invalid tokens so the model cannot break your JSON schema
</span></span><span class="line"><span class="cl">Jump-Forward          = paste fixed JSON keys at once instead of decoding token by token
</span></span><span class="line"><span class="cl">XGrammar              = fast grammar mask engine now shared by both SGLang and vLLM
</span></span><span class="line"><span class="cl">Overlap scheduler     = prepare Step 2 on the CPU while Step 1 is running on the GPU
</span></span><span class="line"><span class="cl">vLLM&#39;s sweet spot     = model variety, hardware portability, Kubernetes ecosystem defaults
</span></span><span class="line"><span class="cl">SGLang&#39;s sweet spot   = branching agent prompts, heavy JSON outputs, large MoE speed
</span></span></code></pre></div><p>Because both engines expose the same <code>/v1/chat/completions</code> endpoint, switching is a container-image change, not an application rewrite. To see how a smart Kubernetes router sits in front of either engine, read my <a href="https://vishctl.dev/posts/llm-d-kubernetes-inference-routing/">guide to llm-d</a> or try the <a href="https://vishctl.dev/posts/simulate-gpu-vllm-llm-d-on-laptop/">laptop GPU simulation lab</a>.</p>
]]></content:encoded></item><item><title>llm-d, Simply: A Smarter Router for vLLM on Kubernetes</title><link>https://vishctl.dev/posts/llm-d-kubernetes-inference-routing/</link><pubDate>Tue, 06 Oct 2026 00:00:00 +0800</pubDate><guid>https://vishctl.dev/posts/llm-d-kubernetes-inference-routing/</guid><category>llm-d</category><category>vllm</category><category>kubernetes</category><category>inference</category><category>ai-infra</category><category>kv-cache</category><description>A practical, diagram-first guide to llm-d: why round-robin wastes GPUs, how cache-aware routing works, which well-lit path to start with, and the commands to deploy it.</description><content:encoded><![CDATA[<p>You run eight vLLM pods behind a Kubernetes Service. Every request carries the same 6,000-token runbook as its system prompt.</p>
<p>The Service spreads requests round-robin. So each pod processes that runbook again and again, and one pod sits on a deep queue while another is idle.</p>
<p><strong>The GPUs are fine. The routing is blind.</strong></p>
<p><a href="https://llm-d.ai/">llm-d</a> fixes that. It is a Kubernetes-native layer that sits <strong>above</strong> vLLM (or SGLang, or TensorRT-LLM) and decides <strong>which pod</strong> gets each request.</p>
<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-text" data-lang="text"><span class="line"><span class="cl">vLLM   → runs the model fast on one pod
</span></span><span class="line"><span class="cl">llm-d  → picks the right pod, and splits work across many
</span></span></code></pre></div><blockquote>
<p>Written against llm-d <strong>v0.10.0</strong> (released 29 September 2026). llm-d is a <a href="https://www.cncf.io/blog/2026/03/24/welcome-llm-d-to-the-cncf-evolving-kubernetes-into-sota-ai-infrastructure/">CNCF Sandbox project</a> founded by Red Hat, Google Cloud, IBM Research, CoreWeave, and NVIDIA.</p>
</blockquote>
<h2 id="the-problem-in-one-picture">The problem in one picture</h2>
<p>A Kubernetes Service sees TCP connections. It doesn&rsquo;t know about prompts, KV caches, or queue depth.</p>
<figure class="mermaid-figure">
  <div class="mermaid">flowchart LR
    subgraph RR["Kubernetes Service"]
        direction TB
        Q1["Runbook + question"] a1@--> P1["pod-1<br/>rebuild KV"]
        Q1 a2@--> P2["pod-2<br/>rebuild KV"]
        Q1 a3@--> P3["pod-3<br/>rebuild KV"]
    end
    subgraph LD["llm-d router"]
        direction TB
        Q2["Runbook + question"] b1@--> R{"Who has<br/>this prefix?"}
        R b2@--> P4["pod-1<br/>cache hit"]
        R -.-> P5["pod-2"]
        R -.-> P6["pod-3"]
    end
    RR ~~~ LD
    a1@{ animate: true }
    a2@{ animate: true }
    a3@{ animate: true }
    b1@{ animate: true }
    b2@{ animate: true }
    classDef input fill:#13264a,stroke:#38bdf8,color:#e6f6ff;
    classDef waste fill:#302410,stroke:#fbbf24,color:#fff3cf;
    classDef hit fill:#173f59,stroke:#22d3ee,color:#ecfeff,stroke-width:3px;
    classDef router fill:#281d48,stroke:#a78bfa,color:#f1edff;
    classDef idle fill:#101e27,stroke:#324950,color:#8aa0a8;
    class Q1,Q2 input;
    class P1,P2,P3 waste;
    class P4 hit;
    class R router;
    class P5,P6 idle;</div>
  <figcaption>Left: round-robin sends a repeated prefix to every pod, so each one rebuilds its KV cache. Right: llm-d sends it where the cache already lives.</figcaption>
</figure>

<p>Rebuilding the KV cache is <strong>prefill</strong>: the slow, compute-heavy step before the first token. Skip it and time to first token (TTFT) drops. If prefill and the KV cache are new to you, start with my <a href="https://vishctl.dev/posts/llm-basics-visual-guide/#5-context-is-the-input-kv-cache-saves-work">visual LLM guide</a> and the <a href="https://vishctl.dev/posts/why-lmcache-is-needed/">LMCache post</a>.</p>
<h2 id="the-pieces">The pieces</h2>
<div class="table-scroll">
  <table>
    <thead>
        <tr>
            <th scope="col">Piece</th>
            <th scope="col">Job</th>
            <th scope="col">Think of it as</th>
        </tr>
    </thead>
    <tbody>
        <tr>
            <td><strong>Proxy</strong> (Envoy, or a Gateway such as Istio or agentgateway)</td>
            <td>Receives the OpenAI-style request</td>
            <td>The front door</td>
        </tr>
        <tr>
            <td><strong>Router / EPP</strong> (Endpoint Picker)</td>
            <td>Scores pods and picks one</td>
            <td>The brain</td>
        </tr>
        <tr>
            <td><strong>InferencePool</strong></td>
            <td>Groups the model server pods (a <a href="https://gateway-api-inference-extension.sigs.k8s.io/">Gateway API Inference Extension</a> resource)</td>
            <td>The Service, but model-aware</td>
        </tr>
        <tr>
            <td><strong>Model servers</strong></td>
            <td>vLLM, SGLang, or TensorRT-LLM pods</td>
            <td>The muscle</td>
        </tr>
        <tr>
            <td><strong>Sidecar</strong> (P/D only)</td>
            <td>Coordinates prefill and decode pods</td>
            <td>The relay runner</td>
        </tr>
        <tr>
            <td><strong>Autoscaler</strong></td>
            <td>Scales on inference signals, not CPU</td>
            <td>The thermostat</td>
        </tr>
    </tbody>
  </table>
</div>
<p>The request path is short:</p>
<figure class="mermaid-figure">
  <div class="mermaid">flowchart LR
    C["Client<br/>/v1/completions"] e1@--> P["Proxy<br/>Envoy"]
    P e2@-->|"which pod?"| E["Router<br/>EPP"]
    E e3@-->|"pod-3"| V["vLLM pod-3<br/>InferencePool"]
    e1@{ animate: true }
    e2@{ animate: true }
    e3@{ animate: true }
    classDef input fill:#13264a,stroke:#38bdf8,color:#e6f6ff;
    classDef router fill:#281d48,stroke:#a78bfa,color:#f1edff;
    classDef gpu fill:#173f59,stroke:#22d3ee,color:#ecfeff;
    class C,P input;
    class E router;
    class V gpu;</div>
  <figcaption>The proxy asks the router which pod to use, then forwards the request there. Pods report queue depth and KV usage back to the router.</figcaption>
</figure>

<p>The client still talks to one OpenAI-compatible endpoint. Nothing changes in your app.</p>
<h2 id="how-the-router-picks-a-pod">How the router picks a pod</h2>
<p>The default setup, called the <strong>optimized baseline</strong>, runs two steps for every request.</p>
<figure class="mermaid-figure">
  <div class="mermaid">flowchart TD
    R["Request"] e1@--> F{"1. Filter<br/>Which pods likely<br/>hold this prefix?"}
    F e2@-->|"sticky pods"| H{"Any of them<br/>saturated?"}
    H e3@-->|"no"| S["2. Score<br/>lowest token load"]
    H -.->|"yes"| X["Widen to<br/>all pods"]
    X -.-> S
    S e4@--> W["Winner pod"]
    e1@{ animate: true }
    e2@{ animate: true }
    e3@{ animate: true }
    e4@{ animate: true }
    classDef input fill:#13264a,stroke:#38bdf8,color:#e6f6ff;
    classDef router fill:#281d48,stroke:#a78bfa,color:#f1edff;
    classDef warn fill:#302410,stroke:#fbbf24,color:#fff3cf;
    classDef gpu fill:#173f59,stroke:#22d3ee,color:#ecfeff;
    class R input;
    class F,S router;
    class H,X warn;
    class W gpu;</div>
  <figcaption>Filter for cache affinity, then score by load. A hot pod loses its cache advantage so load still spreads.</figcaption>
</figure>

<ul>
<li><strong>Prefix-cache affinity filter</strong>: keeps only the pods that likely hold the start of this prompt.</li>
<li><strong>Saturation override</strong>: if those pods get hot, it stops being sticky and spreads the load.</li>
<li><strong>Token load scorer</strong>: picks the pod with the least prefill work queued.</li>
</ul>
<p>That is the whole default config. This is the router config the guide ships:</p>
<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-yaml" data-lang="yaml"><span class="line"><span class="cl"><span class="nt">apiVersion</span><span class="p">:</span><span class="w"> </span><span class="l">llm-d.ai/v1alpha1</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="nt">kind</span><span class="p">:</span><span class="w"> </span><span class="l">EndpointPickerConfig</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="nt">plugins</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="cl">- <span class="nt">type</span><span class="p">:</span><span class="w"> </span><span class="l">approx-prefix-cache-producer   </span><span class="w"> </span><span class="c"># tracks which pod saw which prefix</span><span class="w">
</span></span></span><span class="line"><span class="cl">- <span class="nt">type</span><span class="p">:</span><span class="w"> </span><span class="l">inflight-load-producer         </span><span class="w"> </span><span class="c"># tracks in-flight work per pod</span><span class="w">
</span></span></span><span class="line"><span class="cl">- <span class="nt">type</span><span class="p">:</span><span class="w"> </span><span class="l">prefix-cache-affinity-filter   </span><span class="w"> </span><span class="c"># step 1: keep cache-warm pods</span><span class="w">
</span></span></span><span class="line"><span class="cl">- <span class="nt">type</span><span class="p">:</span><span class="w"> </span><span class="l">token-load-scorer              </span><span class="w"> </span><span class="c"># step 2: prefer the least loaded</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="nt">schedulingProfiles</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="cl">- <span class="nt">name</span><span class="p">:</span><span class="w"> </span><span class="l">default</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">  </span><span class="nt">plugins</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">  </span>- <span class="nt">pluginRef</span><span class="p">:</span><span class="w"> </span><span class="l">prefix-cache-affinity-filter</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">  </span>- <span class="nt">pluginRef</span><span class="p">:</span><span class="w"> </span><span class="l">token-load-scorer</span><span class="w">
</span></span></span></code></pre></div><p><strong>Producers</strong> gather signals. <strong>Filters</strong> remove pods. <strong>Scorers</strong> rank what&rsquo;s left. Every routing feature in llm-d is built from those three kinds of plugin.</p>
<h2 id="example-four-requests-eight-pods">Example: four requests, eight pods</h2>
<p>Same runbook assistant. Here is what the router does (illustrative):</p>
<div class="table-scroll">
  <table>
    <thead>
        <tr>
            <th scope="col">#</th>
            <th scope="col">Request</th>
            <th scope="col">Router sees</th>
            <th scope="col">Goes to</th>
            <th scope="col">Why</th>
        </tr>
    </thead>
    <tbody>
        <tr>
            <td>1</td>
            <td>Runbook + &ldquo;How do I drain a node?&rdquo;</td>
            <td>No pod has this prefix</td>
            <td>pod-2</td>
            <td>Least loaded</td>
        </tr>
        <tr>
            <td>2</td>
            <td>Runbook + &ldquo;Pre-upgrade checks?&rdquo;</td>
            <td>pod-2 has the runbook</td>
            <td><strong>pod-2</strong></td>
            <td>Cache hit: skips ~6,000 tokens of prefill</td>
        </tr>
        <tr>
            <td>3</td>
            <td>Runbook + &ldquo;Roll back a deploy?&rdquo;</td>
            <td>pod-2 has it, but is saturated</td>
            <td>pod-5</td>
            <td>Override spreads load; pod-5 builds a second warm copy</td>
        </tr>
        <tr>
            <td>4</td>
            <td>Different app, different prompt</td>
            <td>No match</td>
            <td>pod-7</td>
            <td>Least loaded</td>
        </tr>
    </tbody>
  </table>
</div>
<figure class="mermaid-figure">
  <div class="mermaid">sequenceDiagram
    participant U as Users
    participant R as Router
    participant P2 as pod-2
    participant P5 as pod-5
    U->>R: 1. runbook + drain?
    R->>P2: no match, least loaded
    Note over P2: prefill full runbook
    U->>R: 2. runbook + upgrade?
    R->>P2: prefix match
    Note over P2: cache hit, fast first token
    U->>R: 3. runbook + rollback?
    Note over R: pod-2 saturated
    R->>P5: spread load
    Note over P5: builds a second warm copy</div>
  <figcaption>Requests 1 and 2 share a warm cache on pod-2. Request 3 spills to pod-5 once pod-2 is busy.</figcaption>
</figure>

<p>Without llm-d, request 2 has about a one-in-eight chance of landing on pod-2.</p>
<h2 id="pick-a-well-lit-path">Pick a well-lit path</h2>
<p>llm-d ships <strong>well-lit paths</strong>: tested recipes with Helm charts, Kustomize overlays, and benchmarks. Start with the optimized baseline, then add a path when you hit its symptom.</p>
<figure class="mermaid-figure">
  <div class="mermaid">flowchart LR
    OB["Optimized baseline<br/>prefix + load aware"]
    OB a1@-->|"approximate tracking<br/>misses cache hits"| PP["Precise prefix-cache<br/>routing"]
    OB a2@-->|"KV cache doesn't<br/>fit in GPU memory"| TP["Tiered prefix cache<br/>CPU / disk"]
    OB a3@-->|"long inputs slow<br/>down token streaming"| PD["P/D<br/>disaggregation"]
    OB a4@-->|"huge MoE model<br/>like DeepSeek-R1"| EP["Wide expert<br/>parallelism"]
    OB a5@-->|"traffic spikes,<br/>idle GPUs at night"| AS["Workload<br/>autoscaling"]
    OB a6@-->|"tenants starve<br/>each other"| FC["Flow control"]
    a1@{ animate: true }
    a2@{ animate: true }
    a3@{ animate: true }
    a4@{ animate: true }
    a5@{ animate: true }
    a6@{ animate: true }
    classDef base fill:#173f59,stroke:#22d3ee,color:#ecfeff,stroke-width:3px;
    classDef cache fill:#281d48,stroke:#a78bfa,color:#f1edff;
    classDef scale fill:#302410,stroke:#fbbf24,color:#fff3cf;
    classDef ops fill:#13264a,stroke:#38bdf8,color:#e6f6ff;
    class OB base;
    class PP,TP cache;
    class PD,EP scale;
    class AS,FC ops;</div>
  <figcaption>Start in the middle. Follow an arrow only when you see the symptom on it.</figcaption>
</figure>

<div class="table-scroll">
  <table>
    <thead>
        <tr>
            <th scope="col">Path</th>
            <th scope="col">One-line idea</th>
        </tr>
    </thead>
    <tbody>
        <tr>
            <td><a href="https://llm-d.ai/docs/well-lit-paths/foundations/optimized-baseline">Optimized baseline</a></td>
            <td>Route by prefix and load. Start here.</td>
        </tr>
        <tr>
            <td>Precise prefix-cache routing</td>
            <td>Track real KV cache state from the pods instead of guessing from routing history</td>
        </tr>
        <tr>
            <td><a href="https://llm-d.ai/docs/well-lit-paths/foundations/tiered-prefix-cache">Tiered prefix cache</a></td>
            <td>Spill KV cache to CPU or disk; keep more conversations warm</td>
        </tr>
        <tr>
            <td>P/D disaggregation</td>
            <td>Separate pods for reading the prompt and writing the answer</td>
        </tr>
        <tr>
            <td><a href="https://llm-d.ai/docs/well-lit-paths/foundations/wide-expert-parallelism">Wide expert parallelism</a></td>
            <td>Spread MoE experts across many GPUs over fast interconnects</td>
        </tr>
        <tr>
            <td>Workload autoscaling</td>
            <td>Scale on queue and SLO signals, including down to zero</td>
        </tr>
        <tr>
            <td>Flow control</td>
            <td>Hold excess requests at the router, with fairness between tenants</td>
        </tr>
    </tbody>
  </table>
</div>
<p>The full list is on the <a href="https://llm-d.ai/docs/well-lit-paths">well-lit paths page</a>.</p>
<h2 id="pd-disaggregation-in-one-flow">P/D disaggregation in one flow</h2>
<p>An LLM request has two jobs with opposite needs:</p>
<ul>
<li><strong>Prefill</strong> reads the whole prompt at once. Compute-bound.</li>
<li><strong>Decode</strong> writes one token at a time. Memory-bandwidth-bound.</li>
</ul>
<p>On the same GPU, a big prefill stalls everyone else&rsquo;s token stream. P/D puts them on different pods.</p>
<figure class="mermaid-figure">
  <div class="mermaid">flowchart TD
    C["Request<br/>10k-token prompt"] e1@--> R["Router<br/>picks P + D"]
    R e2@--> S["Decode pod<br/>sidecar"]
    S e3@-->|"1. prompt"| P["Prefill pod<br/>build KV cache"]
    P e4@-->|"2. KV blocks via NIXL<br/>RDMA or TCP"| D["Decode pod<br/>vLLM"]
    D e5@-->|"3. stream tokens"| O["Client"]
    e1@{ animate: true }
    e2@{ animate: true }
    e3@{ animate: true }
    e4@{ animate: true }
    e5@{ animate: true }
    classDef input fill:#13264a,stroke:#38bdf8,color:#e6f6ff;
    classDef router fill:#281d48,stroke:#a78bfa,color:#f1edff;
    classDef prefill fill:#302410,stroke:#fbbf24,color:#fff3cf;
    classDef decode fill:#173f59,stroke:#22d3ee,color:#ecfeff;
    class C,O input;
    class R router;
    class P prefill;
    class S,D decode;</div>
  <figcaption>The router picks both pods. Prefill builds the KV cache, NIXL moves it to decode, and decode streams the answer.</figcaption>
</figure>

<p>The reference guide runs <code>gpt-oss-120b</code> as <strong>8 prefill pods (TP=1)</strong> and <strong>2 decode pods (TP=4)</strong>: many small pods for the compute-bound job, a few wide ones for the memory-bound job.</p>
<p><strong>Use it when:</strong> the model is medium to large, inputs are long (think 10k in, 1k out), or it&rsquo;s a sparse MoE.
<strong>Skip it when:</strong> prompts and answers are short (200 in, 200 out). The KV transfer costs more than it saves.</p>
<p>The KV transfer runs over the network, so RDMA matters here. My <a href="https://vishctl.dev/posts/kubernetes-gpu-platform-engineering/">GPU platform post</a> covers that layer.</p>
<h2 id="hands-on-deploy-the-optimized-baseline">Hands-on: deploy the optimized baseline</h2>
<p>The reference setup is <code>Qwen/Qwen3-32B</code> on <strong>8 replicas × 2 H100s = 16 GPUs</strong>. Fewer GPUs? Lower <code>replicas</code> and <code>--tensor-parallel-size</code> in the guide&rsquo;s <code>modelserver/gpu/vllm/base/patch-vllm.yaml</code>.</p>
<p>You need <code>kubectl</code>, <code>helm</code>, a GPU cluster, and a Hugging Face token.</p>
<p><strong>1. Clone and set variables</strong></p>
<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-bash" data-lang="bash"><span class="line"><span class="cl">git clone https://github.com/llm-d/llm-d.git <span class="o">&amp;&amp;</span> <span class="nb">cd</span> llm-d
</span></span><span class="line"><span class="cl"><span class="nb">export</span> <span class="nv">REPO_ROOT</span><span class="o">=</span><span class="k">$(</span><span class="nb">pwd</span><span class="k">)</span>
</span></span><span class="line"><span class="cl"><span class="nb">export</span> <span class="nv">GUIDE_NAME</span><span class="o">=</span>optimized-baseline
</span></span><span class="line"><span class="cl"><span class="nb">export</span> <span class="nv">NAMESPACE</span><span class="o">=</span>llm-d-optimized-baseline
</span></span><span class="line"><span class="cl"><span class="nb">export</span> <span class="nv">ACCELERATOR_TYPE</span><span class="o">=</span>gpu <span class="nv">MODEL_SERVER</span><span class="o">=</span>vllm <span class="nv">INFRA_PROVIDER</span><span class="o">=</span>base
</span></span><span class="line"><span class="cl"><span class="nb">export</span> <span class="nv">MODEL</span><span class="o">=</span>Qwen/Qwen3-32B
</span></span><span class="line"><span class="cl"><span class="nb">source</span> guides/env.sh   <span class="c1"># sets chart versions and the router chart reference</span>
</span></span></code></pre></div><p><strong>2. Install CRDs, namespace, and token secret</strong></p>
<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-bash" data-lang="bash"><span class="line"><span class="cl">kubectl apply -f https://github.com/kubernetes-sigs/gateway-api-inference-extension/<span class="si">${</span><span class="nv">GAIE_URL</span><span class="si">}</span>/v1-manifests.yaml
</span></span><span class="line"><span class="cl">kubectl create namespace <span class="si">${</span><span class="nv">NAMESPACE</span><span class="si">}</span>
</span></span><span class="line"><span class="cl">kubectl create secret generic llm-d-hf-token <span class="se">\
</span></span></span><span class="line"><span class="cl">  --from-literal<span class="o">=</span><span class="s2">&#34;HF_TOKEN=</span><span class="si">${</span><span class="nv">HF_TOKEN</span><span class="si">}</span><span class="s2">&#34;</span> -n <span class="si">${</span><span class="nv">NAMESPACE</span><span class="si">}</span>
</span></span></code></pre></div><p><strong>3. Deploy the router (standalone mode, Envoy sidecar)</strong></p>
<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-bash" data-lang="bash"><span class="line"><span class="cl">helm install <span class="si">${</span><span class="nv">GUIDE_NAME</span><span class="si">}</span> <span class="si">${</span><span class="nv">ROUTER_STANDALONE_CHART</span><span class="si">}</span> <span class="se">\
</span></span></span><span class="line"><span class="cl">  -f guides/recipes/router/base.values.yaml <span class="se">\
</span></span></span><span class="line"><span class="cl">  -f guides/<span class="si">${</span><span class="nv">GUIDE_NAME</span><span class="si">}</span>/router/<span class="si">${</span><span class="nv">GUIDE_NAME</span><span class="si">}</span>.values.yaml <span class="se">\
</span></span></span><span class="line"><span class="cl">  -n <span class="si">${</span><span class="nv">NAMESPACE</span><span class="si">}</span> --version <span class="si">${</span><span class="nv">ROUTER_CHART_VERSION</span><span class="si">}</span>
</span></span></code></pre></div><p><strong>4. Deploy the vLLM pods</strong></p>
<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-bash" data-lang="bash"><span class="line"><span class="cl">kubectl apply -n <span class="si">${</span><span class="nv">NAMESPACE</span><span class="si">}</span> <span class="se">\
</span></span></span><span class="line"><span class="cl">  -k guides/<span class="si">${</span><span class="nv">GUIDE_NAME</span><span class="si">}</span>/modelserver/<span class="si">${</span><span class="nv">ACCELERATOR_TYPE</span><span class="si">}</span>/<span class="si">${</span><span class="nv">MODEL_SERVER</span><span class="si">}</span>/<span class="si">${</span><span class="nv">INFRA_PROVIDER</span><span class="si">}</span>/
</span></span></code></pre></div><p><strong>5. Send a request</strong></p>
<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-bash" data-lang="bash"><span class="line"><span class="cl"><span class="nb">export</span> <span class="nv">IP</span><span class="o">=</span><span class="k">$(</span>kubectl get service <span class="si">${</span><span class="nv">GUIDE_NAME</span><span class="si">}</span>-epp -n <span class="si">${</span><span class="nv">NAMESPACE</span><span class="si">}</span> <span class="se">\
</span></span></span><span class="line"><span class="cl">  -o <span class="nv">jsonpath</span><span class="o">=</span><span class="s1">&#39;{.spec.clusterIP}&#39;</span><span class="k">)</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl">kubectl run curl-test --rm -i --restart<span class="o">=</span>Never -n <span class="si">${</span><span class="nv">NAMESPACE</span><span class="si">}</span> <span class="se">\
</span></span></span><span class="line"><span class="cl">  --image<span class="o">=</span>curlimages/curl --env<span class="o">=</span><span class="s2">&#34;IP=</span><span class="si">${</span><span class="nv">IP</span><span class="si">}</span><span class="s2">&#34;</span> --env<span class="o">=</span><span class="s2">&#34;MODEL=</span><span class="si">${</span><span class="nv">MODEL</span><span class="si">}</span><span class="s2">&#34;</span> -- <span class="se">\
</span></span></span><span class="line"><span class="cl">  sh -c <span class="s1">&#39;curl -sS http://${IP}/v1/completions -H &#34;Content-Type: application/json&#34; \
</span></span></span><span class="line"><span class="cl"><span class="s1">    -d &#34;{\&#34;model\&#34;: \&#34;${MODEL}\&#34;, \&#34;prompt\&#34;: \&#34;How are you today?\&#34;}&#34;&#39;</span>
</span></span></code></pre></div><p>A JSON completion back means the full path works: proxy → router → vLLM pod.</p>
<p>These steps are trimmed from the <a href="https://github.com/llm-d/llm-d/tree/main/guides/optimized-baseline">optimized baseline guide</a>, which also covers AMD, Intel XPU, TPU, CPU, SGLang, TensorRT-LLM, and Gateway mode. Clean up with <code>helm uninstall</code> and <code>kubectl delete namespace</code>.</p>
<blockquote>
<p><strong>No GPUs yet?</strong> <a href="https://github.com/llm-d/llm-d-inference-sim">llm-d-inference-sim</a> is a lightweight vLLM simulator for mocking replicas. Use it to learn the routing layer on a laptop cluster.</p>
</blockquote>
<h2 id="what-to-watch">What to watch</h2>
<p>Routing has two goals that pull against each other: <strong>cache hits</strong> and <strong>even load</strong>. Watch both.</p>
<div class="table-scroll">
  <table>
    <thead>
        <tr>
            <th scope="col">Signal</th>
            <th scope="col">Metric</th>
            <th scope="col">Healthy looks like</th>
        </tr>
    </thead>
    <tbody>
        <tr>
            <td>Cache hit rate</td>
            <td><code>vllm:prefix_cache_hits_total</code> ÷ <code>vllm:prefix_cache_queries_total</code></td>
            <td>High, and stable as you add replicas</td>
        </tr>
        <tr>
            <td>Load balance</td>
            <td><code>vllm:num_requests_running</code> per pod</td>
            <td>Roughly even; no hot pod next to idle ones</td>
        </tr>
        <tr>
            <td>Pressure</td>
            <td><code>vllm:kv_cache_usage_perc</code>, <code>vllm:num_requests_waiting</code></td>
            <td>No single pod pinned near full</td>
        </tr>
        <tr>
            <td>User experience</td>
            <td><code>vllm:time_to_first_token_seconds</code></td>
            <td>Drops compared with round-robin</td>
        </tr>
    </tbody>
  </table>
</div>
<p>On hardware other than the guide&rsquo;s H100 reference, uneven load usually means the saturation override isn&rsquo;t kicking in. The guide&rsquo;s calibration recipe measures <code>peakPrefillThroughput</code> for your model and GPU.</p>
<h2 id="do-the-numbers-hold-up">Do the numbers hold up?</h2>
<p>These are <strong>project-reported</strong> results. Your workload will differ.</p>
<ul>
<li><strong>3x output throughput, 2x faster TTFT</strong>: prefix-aware routing vs round-robin, Llama 3.1 70B on 4× AMD MI300X (<a href="https://llm-d.ai/blog/production-grade-llm-inference-at-scale-kserve-llm-d-vllm">source</a>)</li>
<li><strong>Up to 70% higher tokens/sec</strong>: P/D vs standard vLLM, GPT-OSS on NVIDIA B200, AWS (<a href="https://aws.amazon.com/blogs/machine-learning/introducing-disaggregated-inference-on-aws-powered-by-llm-d/">source</a>)</li>
<li><strong>40% lower TTFT and ITL</strong>: predicted-latency scheduling vs heuristics, Google (<a href="https://llm-d.ai/blog/predicted-latency-based-scheduling-for-llms">source</a>)</li>
</ul>
<p>The gains come from <strong>reuse and balance</strong>. No shared prefixes and steady load means less to win. Each guide ships a benchmark profile, so measure against your own traffic. Reproducible runs are on <a href="https://prism.llm-d.ai">Prism</a>.</p>
<h2 id="when-you-dont-need-llm-d">When you don&rsquo;t need llm-d</h2>
<ul>
<li>One vLLM replica. There is nothing to route between.</li>
<li>Short, unique prompts with no shared prefix.</li>
<li>Low, steady traffic where round-robin never creates a hot pod.</li>
</ul>
<p>A plain Service is fine there. llm-d starts paying off at <strong>several replicas + shared prefixes + bursty traffic</strong>.</p>
<h2 id="cheat-sheet">Cheat sheet</h2>
<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-text" data-lang="text"><span class="line"><span class="cl">vLLM / SGLang     = run the model on one pod
</span></span><span class="line"><span class="cl">llm-d router      = pick the best pod per request (EPP)
</span></span><span class="line"><span class="cl">InferencePool     = model-aware group of pods
</span></span><span class="line"><span class="cl">producer          = gathers signals (prefix seen, load)
</span></span><span class="line"><span class="cl">filter            = removes pods (not cache-warm)
</span></span><span class="line"><span class="cl">scorer            = ranks pods (least token load)
</span></span><span class="line"><span class="cl">well-lit path     = tested recipe + benchmark
</span></span><span class="line"><span class="cl">prefill / decode  = read prompt / write answer
</span></span><span class="line"><span class="cl">P/D               = split them across pods, KV moves via NIXL
</span></span><span class="line"><span class="cl">start here        = optimized baseline
</span></span></code></pre></div><p>vLLM made one GPU fast. llm-d makes a fleet of them act like it knows what it&rsquo;s doing.</p>
]]></content:encoded></item><item><title>Why LMCache Exists: Stop Making GPUs Read the Same Context Again</title><link>https://vishctl.dev/posts/why-lmcache-is-needed/</link><pubDate>Fri, 18 Sep 2026 00:00:00 +0800</pubDate><guid>https://vishctl.dev/posts/why-lmcache-is-needed/</guid><category>lmcache</category><category>vllm</category><category>kv-cache</category><category>inference</category><category>ai-infra</category><category>rag</category><description>An example-driven guide to LMCache: repeated prefill, KV cache reuse, CPU offloading, multi-replica serving, and where caching actually helps.</description><content:encoded><![CDATA[<p>Imagine an internal assistant that answers questions about your Kubernetes platform. Every request includes the same long operations runbook.</p>
<p>One engineer asks, “How do I drain a node?” Another asks, “What should I check before upgrading the cluster?” A third asks, “How do we recover a failed deployment?”</p>
<p>The questions differ. Most of the input is identical. Yet whenever the serving system cannot reuse its previous work, the GPU processes that runbook again before it can start answering.</p>
<p><strong>LMCache helps preserve and reuse that prompt-processing work, including beyond GPU memory and across compatible serving instances.</strong> The payoff is less repeated computation and potentially a shorter wait for the first token. It plugs into the serving stack; the model still generates an answer to each new question. <a href="https://github.com/LMCache/LMCache">The LMCache project</a> describes this as a KV cache management layer.</p>
<h2 id="which-kind-of-cache-are-we-talking-about">Which kind of cache are we talking about?</h2>
<p>Three different caches can appear in the same LLM application:</p>
<div class="table-scroll">
  <table>
    <thead>
        <tr>
            <th scope="col">Cache</th>
            <th scope="col">What it reuses</th>
            <th scope="col">Example</th>
        </tr>
    </thead>
    <tbody>
        <tr>
            <td>Retrieval or embedding cache</td>
            <td>Search-related work</td>
            <td>Reuse a query embedding or retrieved document list</td>
        </tr>
        <tr>
            <td>Response cache</td>
            <td>A completed answer</td>
            <td>Return an answer previously generated for a matching request</td>
        </tr>
        <tr>
            <td>KV cache</td>
            <td>Intermediate model attention state</td>
            <td>Reuse runbook processing while answering a different question</td>
        </tr>
    </tbody>
  </table>
</div>
<p>LMCache&rsquo;s KV layer addresses the third row. Your vector database still retrieves context, and the model still reasons over the supplied input to produce output.</p>
<h2 id="first-where-does-the-waiting-happen">First, where does the waiting happen?</h2>
<p>For a typical decoder-only language model, serving a request has two main phases:</p>
<ul>
<li><strong>Prefill:</strong> process the input tokens and build the attention state needed to generate an answer.</li>
<li><strong>Decode:</strong> generate output tokens one step at a time, using that state and extending it.</li>
</ul>
<p>The attention state includes <strong>keys and values</strong>, stored as tensors across the model&rsquo;s layers. This is the <strong><a href="https://vishctl.dev/posts/llm-basics-visual-guide/#5-context-is-the-input-kv-cache-saves-work">KV cache</a></strong>, covered in more detail in my visual guide, <em>What Happens When You Ask an LLM a Question</em>. It lets later tokens attend to earlier tokens without rebuilding all their keys and values each time.</p>
<figure class="mermaid-figure">
  <div class="mermaid">flowchart TD
    P["Prompt<br/>Runbook + question"] --> F["PREFILL<br/>Process input tokens"]
    F --> K["KV cache<br/>Attention keys and values"]
    K --> D["DECODE<br/>Generate answer tokens"]
    D --> A["Stream answer to the user"]
    classDef input fill:#13264a,stroke:#38bdf8,color:#e6f6ff,stroke-width:2px;
    classDef compute fill:#302410,stroke:#fbbf24,color:#fff3cf,stroke-width:2px;
    classDef cache fill:#281d48,stroke:#a78bfa,color:#f1edff,stroke-width:2px;
    classDef output fill:#173f59,stroke:#22d3ee,color:#ecfeff,stroke-width:2px;
    class P input;
    class F compute;
    class K cache;
    class D,A output;</div>
  <figcaption>The first answer token comes after prompt processing. Long repeated inputs create an opportunity to reuse that work.</figcaption>
</figure>

<p><strong>Time to first token (TTFT)</strong> measures how long the caller waits before output starts. Queueing, cache lookup or loading, and prefill can all contribute. Once output starts, decode speed determines how quickly the rest arrives.</p>
<p>Reusing prompt KV primarily attacks the prefill part. It does not remove the need to generate the answer. vLLM&rsquo;s <a href="https://docs.vllm.ai/en/latest/features/automatic_prefix_caching/">automatic prefix caching documentation</a> makes this distinction explicit: prefix reuse helps repeated inputs, but does not directly speed up decoding.</p>
<h2 id="example-1-one-runbook-a-hundred-questions">Example 1: one runbook, a hundred questions</h2>
<p>Suppose each request has a 20,000-token shared prefix followed by a 100-token question. For simplicity, assume compatible model settings and tokenization, cacheable block boundaries, and a cache that retains the whole shared prefix.</p>
<div class="table-scroll">
  <table>
    <thead>
        <tr>
            <th scope="col">Workload</th>
            <th scope="col">No reusable prompt cache</th>
            <th scope="col">Ideal warm prefix reuse</th>
        </tr>
    </thead>
    <tbody>
        <tr>
            <td>First question</td>
            <td>Prefill 20,100 input tokens</td>
            <td>Prefill 20,100 input tokens and retain KV</td>
        </tr>
        <tr>
            <td>Each later question</td>
            <td>Prefill another 20,100 tokens</td>
            <td>Restore shared KV; prefill about 100 new tokens</td>
        </tr>
        <tr>
            <td>100 questions</td>
            <td>2,010,000 input tokens processed through prefill</td>
            <td>About 30,000 input tokens processed through prefill, plus cache transfers</td>
        </tr>
    </tbody>
  </table>
</div>
<p>This is illustrative token accounting, <strong>not a latency benchmark</strong>. The new question still attends to the long context, cache transfers take time, and generation still runs. A 67× reduction in the counted prefill tokens does not imply a 67× faster application.</p>
<p>The useful observation is simpler: the shared 20,000-token input should not require the same full computation for every question.</p>
<figure class="mermaid-figure">
  <div class="mermaid">flowchart TD
    R["Runbook + new question"] --> L{"Compatible cached<br/>prefix available?"}
    L -->|Miss| F["Prefill the full prompt"]
    F --> S["Save reusable KV<br/>through LMCache"]
    F --> D["Decode a fresh answer"]
    L -->|Hit| H["Load cached prefix KV<br/>into GPU memory"]
    H --> N["Prefill uncached suffix<br/>including the question"]
    N --> D
    S -.->|Available to later requests| L
    classDef input fill:#13264a,stroke:#38bdf8,color:#e6f6ff,stroke-width:2px;
    classDef cache fill:#281d48,stroke:#a78bfa,color:#f1edff,stroke-width:2px;
    classDef compute fill:#302410,stroke:#fbbf24,color:#fff3cf,stroke-width:2px;
    classDef output fill:#173f59,stroke:#22d3ee,color:#ecfeff,stroke-width:2px;
    class R input;
    class L,S,H cache;
    class F,N compute;
    class D output;</div>
  <figcaption>A miss builds the reusable state. A later hit restores that state and computes the new suffix before generating a fresh answer.</figcaption>
</figure>

<p>The diagram shows the logical flow; storage and execution may overlap in an actual deployment.</p>
<h2 id="doesnt-vllm-already-cache-prefixes">Doesn&rsquo;t vLLM already cache prefixes?</h2>
<p>Yes. A later request can already reuse a matching prefix that vLLM retains. If all your useful prefixes fit in its GPU cache and requests keep reaching the right instance, that may be enough.</p>
<p>The gap appears when reusable state is evicted under memory pressure, requests move between replicas, or you want cache storage to outlive an inference process. LMCache adds offloading, storage, and sharing around the serving engine. Its supported storage options include CPU memory, local storage, and remote backends. <a href="https://github.com/LMCache/LMCache">LMCache&rsquo;s feature overview</a> describes these capabilities.</p>
<div class="table-scroll">
  <table>
    <thead>
        <tr>
            <th scope="col">Component</th>
            <th scope="col">Main responsibility</th>
        </tr>
    </thead>
    <tbody>
        <tr>
            <td>vLLM serving engine</td>
            <td>Schedule requests and execute the model</td>
        </tr>
        <tr>
            <td>Native GPU prefix cache</td>
            <td>Reuse matching prompt state retained by the engine</td>
        </tr>
        <tr>
            <td>LMCache</td>
            <td>Manage additional KV storage and reuse through an engine integration</td>
        </tr>
        <tr>
            <td>Configured storage or transport backend</td>
            <td>Hold or move the cached tensors</td>
        </tr>
    </tbody>
  </table>
</div>
<p>In the current recommended <strong>multiprocess mode</strong>, LMCache runs as a separate service. Multiple vLLM instances on a node can share that service, and cache resources can be managed independently of the engine. The <a href="https://docs.lmcache.ai/mp/index.html">architecture documentation</a> explains this separation.</p>
<figure class="mermaid-figure">
  <div class="mermaid">flowchart TD
    A["Application"] --> V["vLLM<br/>Scheduler + model execution"]
    V <--> G["GPU memory<br/>Active KV + retained prefixes"]
    V <-->|KV connector| L["LMCache service"]
    L <--> C["CPU memory<br/>Reusable KV"]
    L <--> S["Optional local storage"]
    L <--> R["Optional remote backend"]
    classDef engine fill:#13264a,stroke:#38bdf8,color:#e6f6ff,stroke-width:2px;
    classDef gpu fill:#302410,stroke:#fbbf24,color:#fff3cf,stroke-width:2px;
    classDef cache fill:#281d48,stroke:#a78bfa,color:#f1edff,stroke-width:2px;
    classDef store fill:#173f59,stroke:#22d3ee,color:#ecfeff,stroke-width:2px;
    class A,V engine;
    class G gpu;
    class L cache;
    class C,S,R store;</div>
  <figcaption>A conceptual deployment: the inference engine uses GPU KV, while LMCache manages additional storage. Backends are configured choices, not a mandatory chain.</figcaption>
</figure>

<p>CPU RAM is volatile. A separate cache process can survive an engine restart while that cache process remains alive, but surviving a machine restart requires a suitable persistent backend and recovery configuration.</p>
<h2 id="example-2-a-conversation-that-keeps-growing">Example 2: a conversation that keeps growing</h2>
<p>Consider an incident assistant:</p>
<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-text" data-lang="text"><span class="line"><span class="cl">Turn 1: system instructions + runbook + initial incident details
</span></span><span class="line"><span class="cl">Turn 2: previous conversation + &#34;Here are the latest pod events&#34;
</span></span><span class="line"><span class="cl">Turn 3: previous conversation + &#34;The rollout is still stuck&#34;
</span></span></code></pre></div><p>Each turn extends earlier context. Preserved KV can let the engine process the newly appended portion instead of rebuilding the entire history. If a user pauses while other requests fill GPU memory, an external cache can make that older state available again.</p>
<p>This is particularly useful for agents that repeatedly send stable instructions, tool definitions, and an accumulating history. The application still needs to supply the appropriate context; KV caching is not a replacement for conversation storage.</p>
<p>Prompt layout matters. Put stable material before changing material when the application permits it. A changing timestamp at the beginning can break an otherwise useful exact prefix. Likewise, editing an earlier message or summarizing the history changes which tokens remain reusable.</p>
<h2 id="example-3-the-next-request-lands-on-another-replica">Example 3: the next request lands on another replica</h2>
<p>Suppose request one runs on node A and warms its cache. The load balancer sends a related request to node B. A cache hit on A does not automatically mean B has the data.</p>
<p>LMCache&rsquo;s <a href="https://docs.lmcache.ai/mp/p2p.html">P2P sharing mode</a> lets node-local cache services retrieve KV from peers. The documented design uses a coordinator for peer discovery and a transfer channel for the actual data; production performance depends on the network and configuration.</p>
<figure class="mermaid-figure">
  <div class="mermaid">flowchart TD
    R["Related requests"] --> LB["Load balancer"]
    LB -->|First request| A["Node A<br/>Inference engine"]
    LB -->|Later request| B["Node B<br/>Inference engine"]
    A -->|Store KV| CA["LMCache A<br/>Warm prefix"]
    CB["LMCache B"] -->|Restore KV| B
    CA -->|Peer KV transfer| CB
    C["Coordinator<br/>Peer discovery"] -.-> CA
    C -.-> CB
    classDef engine fill:#13264a,stroke:#38bdf8,color:#e6f6ff,stroke-width:2px;
    classDef cache fill:#281d48,stroke:#a78bfa,color:#f1edff,stroke-width:2px;
    classDef control fill:#173f59,stroke:#22d3ee,color:#ecfeff,stroke-width:2px;
    class R,LB,A,B engine;
    class CA,CB cache;
    class C control;</div>
  <figcaption>With P2P configured, node B can retrieve a compatible prefix cached on node A. Cross-node reuse requires an explicit sharing setup.</figcaption>
</figure>

<p>This can reduce redundant prefill across the fleet. Cache-aware routing may still help by keeping requests near useful state and avoiding transfers.</p>
<p>Compatibility remains essential: cached tensors are tied to model computation. Do not assume that a cache produced with one model, adapter, or KV representation can be loaded by an arbitrary other configuration.</p>
<h2 id="what-about-rag-documents-in-a-different-order">What about RAG documents in a different order?</h2>
<p>Retrieval-augmented generation introduces a harder case:</p>
<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-text" data-lang="text"><span class="line"><span class="cl">Request A: system + document A + document B + question A
</span></span><span class="line"><span class="cl">Request B: system + document B + document A + question B
</span></span></code></pre></div><p>The documents repeat, but the document prefix does not. Attention state depends on preceding context, so moving a document is not equivalent to reusing an unchanged prefix.</p>
<p><strong>CacheBlend</strong> extends reuse to repeated chunks outside the prefix by selectively recomputing part of the state. It requires explicit configuration; ordinary prefix caching does not acquire this behavior automatically. Selective recomputation adds work to recover interactions between chunks that their cached states did not capture. Recomputing too little can hurt answer quality; recomputing more spends some of the latency saving. This is a quality-versus-compute tradeoff, so compare both against full prefill on your own workload. The <a href="https://arxiv.org/abs/2405.16444">CacheBlend paper</a> explains the mechanism and its evaluated results. The current <a href="https://docs.lmcache.ai/kv_cache_optimizations/cacheblend.html">CacheBlend documentation</a> describes the blend engine.</p>
<h2 id="the-tradeoff-moving-data-versus-doing-math">The tradeoff: moving data versus doing math</h2>
<p>A cache hit is useful when retrieving and restoring the KV costs less than the computation it avoids.</p>
<p>For an illustrative request, suppose recomputing the shared prefix takes 800 ms. If lookup and restoration cost 120 ms, that portion of the path saves roughly 680 ms. If they cost 950 ms, the cache makes that path slower. These are invented timings to explain the decision, not measured LMCache results.</p>
<p>KV tensors can occupy much more space than their source text. Their size depends on token count, layer count, KV heads, head dimensions, and precision. A small text file can therefore create substantial storage and network traffic.</p>
<div class="table-scroll">
  <table>
    <thead>
        <tr>
            <th scope="col">Workload characteristic</th>
            <th scope="col">Expected opportunity</th>
        </tr>
    </thead>
    <tbody>
        <tr>
            <td>Long repeated prefixes that exceed GPU cache capacity</td>
            <td>Strong candidate for offloading and reuse</td>
        </tr>
        <tr>
            <td>Related requests distributed across replicas</td>
            <td>Sharing may recover otherwise missed reuse</td>
        </tr>
        <tr>
            <td>Mostly short, unique prompts</td>
            <td>Little repeated work to avoid</td>
        </tr>
        <tr>
            <td>Long answers with very short prompts</td>
            <td>Decode may dominate total latency</td>
        </tr>
        <tr>
            <td>Slow storage or congested network</td>
            <td>Transfers may erase the gain</td>
        </tr>
        <tr>
            <td>Working set already fits in native GPU prefix cache</td>
            <td>Additional benefit may be limited</td>
        </tr>
    </tbody>
  </table>
</div>
<p>These are workload deductions from the reuse mechanism, not universal performance guarantees.</p>
<h2 id="how-i-would-evaluate-it">How I would evaluate it</h2>
<p>Start with a representative workload and compare <strong>vLLM with its native prefix cache</strong> against the same setup with LMCache. Keep the model, hardware, prompt mix, concurrency, and output limits consistent.</p>
<p>Measure cold requests separately from warm ones. Include enough distinct documents or conversations to create realistic cache pressure; repeatedly submitting one tiny prompt mostly tests an easy case.</p>
<p>Track TTFT at the median and tail, total request latency, throughput, tokens reused, and cache load time. For CacheBlend, add an answer-quality evaluation. The official <a href="https://docs.lmcache.ai/getting_started/benchmarking.html">benchmarking guide</a> provides a long-document Q&amp;A workload and a baseline-versus-LMCache comparison.</p>
<p>For implementation, use the current <a href="https://docs.lmcache.ai/getting_started/quickstart.html">quickstart</a>, which recommends a standalone LMCache service connected through <code>LMCacheMPConnector</code>. Match the instructions to your installed versions: older examples use a different in-process connector.</p>
<p>The motivating question is practical: <strong>how much GPU work are we repeating because useful context state was discarded or is sitting on another machine?</strong> If the answer is “a lot,” LMCache gives that work a longer useful life.</p>
<h2 id="cheat-sheet">Cheat sheet</h2>
<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-text" data-lang="text"><span class="line"><span class="cl">Prefill           = process the input tokens and build the attention state before answering
</span></span><span class="line"><span class="cl">Decode            = generate output tokens one step at a time
</span></span><span class="line"><span class="cl">KV cache          = saved attention keys and values, so earlier tokens are not recomputed
</span></span><span class="line"><span class="cl">TTFT              = time to first token: queueing, cache loading, and prefill all add to it
</span></span><span class="line"><span class="cl">Prefix caching    = reuse KV for an input that starts with exactly the same tokens
</span></span><span class="line"><span class="cl">vLLM native cache = prefix reuse inside one engine&#39;s GPU memory; gone once it is evicted
</span></span><span class="line"><span class="cl">LMCache           = a KV cache management layer: offload, store, and share KV beyond the GPU
</span></span><span class="line"><span class="cl">Multiprocess mode = LMCache runs as a separate service that several vLLM instances share
</span></span><span class="line"><span class="cl">P2P sharing       = node-local cache services fetch KV from peers on other nodes
</span></span><span class="line"><span class="cl">CacheBlend        = reuse repeated chunks outside the prefix by selectively recomputing state
</span></span><span class="line"><span class="cl">Break-even rule   = a hit helps only if loading the KV costs less than recomputing it
</span></span><span class="line"><span class="cl">Prompt layout     = put stable content first; a changing timestamp up front breaks the prefix
</span></span></code></pre></div><p>For the serving-engine side of the story, see <a href="https://vishctl.dev/posts/why-vllm-is-dominating-ai-inference-market/">why vLLM matters for AI inference</a>. For routing requests to the replica that already holds the cache, see <a href="https://vishctl.dev/posts/llm-d-kubernetes-inference-routing/">llm-d</a>.</p>
]]></content:encoded></item></channel></rss>