<?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>Llm-D on vishctl</title><link>https://vishctl.dev/tags/llm-d/</link><description>Recent content in Llm-D on vishctl</description><generator>Hugo</generator><language>en-us</language><lastBuildDate>Tue, 06 Oct 2026 02:30:00 +0800</lastBuildDate><atom:link href="https://vishctl.dev/tags/llm-d/index.xml" rel="self" type="application/rss+xml"/><item><title>No GPU? Simulate One: vLLM, llm-d, and 16 Fake H100s on a Laptop</title><link>https://vishctl.dev/posts/simulate-gpu-vllm-llm-d-on-laptop/</link><pubDate>Tue, 06 Oct 2026 02:30:00 +0800</pubDate><guid>https://vishctl.dev/posts/simulate-gpu-vllm-llm-d-on-laptop/</guid><category>llm-d</category><category>vllm</category><category>kubernetes</category><category>kind</category><category>gpu</category><category>simulation</category><category>inference</category><category>ai-infra</category><description>A hands-on lab for learning GPU scheduling and LLM routing without a GPU: kind, fake-gpu-operator, llm-d-inference-sim, and the llm-d router, with real screenshots and benchmark numbers.</description><content:encoded><![CDATA[<p>My <a href="https://vishctl.dev/posts/llm-d-kubernetes-inference-routing/">llm-d post</a> ended with a 16-GPU reference setup: 8 vLLM replicas on 2 H100s each.</p>
<p>I don&rsquo;t have 16 H100s. I have a MacBook.</p>
<p>You don&rsquo;t need GPUs to learn <strong>routing, scheduling, and failure modes</strong>. You need things that <em>behave</em> like GPUs and <em>behave</em> like vLLM. This post builds that lab end to end on a laptop, then uses it to answer a real question:</p>
<p><strong>Does llm-d beat a plain Kubernetes Service, and when does it lose?</strong></p>
<p>Spoiler: it won <strong>3x</strong> on throughput for one traffic pattern and lost <strong>5x</strong> on another, until I tuned two knobs. I found all of that on a laptop in an evening.</p>
<blockquote>
<p>Tested on an Apple M4 (32 GB), Colima 0.10.3, kind v0.33.0 (Kubernetes v1.37.0), fake-gpu-operator 0.2.1, llm-d-inference-sim v0.11.4, and the llm-d router v0.10.0. Every screenshot is from that run.</p>
</blockquote>
<h2 id="whats-real-whats-fake">What&rsquo;s real, what&rsquo;s fake</h2>
<p>The trick is to fake only the two expensive layers: the GPU and the model. Everything above them is the real software you&rsquo;d run in production.</p>
<figure class="mermaid-figure">
  <div class="mermaid">flowchart TD
    C["Client<br/>OpenAI API calls"] e1@--> E["Envoy proxy"]
    E e2@-->|"which pod?"| R["llm-d router (EPP)<br/>filter, score, pick"]
    R e3@--> P["InferencePool<br/>8 model server pods"]
    P e4@--> V["llm-d-inference-sim<br/>acts like vLLM"]
    V e5@--> G["fake-gpu-operator<br/>16 x H100 80GB"]
    G e6@--> K["kind nodes<br/>Docker containers"]
    K e7@--> M["Colima VM on a Mac"]
    e1@{ animate: true }
    e2@{ animate: true }
    e3@{ animate: true }
    e4@{ animate: true }
    e5@{ animate: true }
    e6@{ animate: true }
    e7@{ animate: true }
    classDef real fill:#173f59,stroke:#22d3ee,color:#ecfeff;
    classDef router fill:#281d48,stroke:#a78bfa,color:#f1edff;
    classDef fake fill:#302410,stroke:#fbbf24,color:#fff3cf,stroke-dasharray:6 4;
    classDef base fill:#101e27,stroke:#324950,color:#8aa0a8;
    class C,E,P real;
    class R router;
    class V,G fake;
    class K,M base;</div>
  <figcaption>Solid boxes are the real production software. Dashed boxes are simulators that look the same from above.</figcaption>
</figure>

<div class="table-scroll">
  <table>
    <thead>
        <tr>
            <th scope="col">Layer</th>
            <th scope="col">Real thing</th>
            <th scope="col">In this lab</th>
            <th scope="col">What still works</th>
        </tr>
    </thead>
    <tbody>
        <tr>
            <td>GPU</td>
            <td>NVIDIA device plugin + driver</td>
            <td><a href="https://github.com/run-ai/fake-gpu-operator">fake-gpu-operator</a></td>
            <td><code>nvidia.com/gpu</code> scheduling, <code>nvidia-smi</code>, DCGM metrics</td>
        </tr>
        <tr>
            <td>Model server</td>
            <td>vLLM</td>
            <td><a href="https://github.com/llm-d/llm-d-inference-sim">llm-d-inference-sim</a></td>
            <td>OpenAI API, streaming, prefix cache, TTFT, <code>vllm:*</code> metrics</td>
        </tr>
        <tr>
            <td>Router</td>
            <td>llm-d EPP + Envoy</td>
            <td><strong>the real thing</strong></td>
            <td>Every routing decision</td>
        </tr>
        <tr>
            <td>Cluster</td>
            <td>GPU nodes</td>
            <td>kind on Colima</td>
            <td>Real Kubernetes v1.37</td>
        </tr>
    </tbody>
  </table>
</div>
<p>The router has two parts, and you&rsquo;ll see both names a lot below:</p>
<ul>
<li><strong>EPP (Endpoint Picker)</strong> is the decision-maker. For each request it checks every model server pod (what&rsquo;s in its prefix cache, how much work it has queued) and picks one. It doesn&rsquo;t carry traffic itself.</li>
<li><strong>Envoy</strong> is the proxy that carries the traffic. It asks the EPP &ldquo;which pod?&rdquo;, then forwards the request there.</li>
</ul>
<p>The EPP is a <a href="https://gateway-api-inference-extension.sigs.k8s.io/">Gateway API Inference Extension</a> component; llm-d ships its own build with extra routing plugins.</p>
<p>A few more terms that come up often:</p>
<ul>
<li><strong>TTFT (time to first token)</strong>: how long a user waits before the first word of the answer appears. Mostly the time to read the prompt (prefill).</li>
<li><strong>Prefix cache</strong>: vLLM keeps the processed start of recent prompts in GPU memory. A new request that starts the same way skips that work, so TTFT drops. It only helps on the pod that holds the cache.</li>
<li><strong>InferencePool</strong>: a Kubernetes resource that groups the model server pods, like a Service that knows it&rsquo;s serving a model.</li>
<li><strong>DCGM</strong>: NVIDIA&rsquo;s GPU monitoring tool. Its exporter publishes GPU metrics to Prometheus.</li>
<li><strong>p50 / p90</strong>: the median request, and the request slower than 90% of the others. p90 shows what your unlucky users feel.</li>
</ul>
<h2 id="the-lab">The lab</h2>
<p>Three kind nodes. The two workers each pretend to have 8 H100s. Eight simulated vLLM pods take 2 GPUs each, the same shape as the llm-d reference guide.</p>
<figure class="mermaid-figure">
  <div class="mermaid">flowchart LR
    CL["client pod<br/>loadtest.py"] c1@--> EPP["sim-lab-epp<br/>Envoy + EPP"]
    CL -.->|"baseline"| SVC["Service qwen3-32b<br/>kube-proxy"]
    EPP c2@--> W1["gpu-sim-worker<br/>8 x fake H100<br/>4 sim pods x 2 GPU"]
    EPP c3@--> W2["gpu-sim-worker2<br/>8 x fake H100<br/>4 sim pods x 2 GPU"]
    SVC -.-> W1
    SVC -.-> W2
    c1@{ animate: true }
    c2@{ animate: true }
    c3@{ animate: true }
    classDef gpu fill:#173f59,stroke:#22d3ee,color:#ecfeff;
    classDef router fill:#281d48,stroke:#a78bfa,color:#f1edff;
    classDef input fill:#13264a,stroke:#38bdf8,color:#e6f6ff;
    classDef idle fill:#101e27,stroke:#324950,color:#8aa0a8;
    class W1,W2 gpu;
    class EPP router;
    class CL input;
    class SVC idle;</div>
  <figcaption>The lab topology. Both paths reach the same 8 pods. All of it runs as Docker containers inside one Colima VM.</figcaption>
</figure>

<p>The <strong>client pod</strong> sends identical traffic to two targets: the plain Service (baseline) and the llm-d router. Same pods, same prompts, different routing.</p>
<h2 id="step-1-a-cluster-with-nodes-that-want-gpus">Step 1: a cluster with nodes that want GPUs</h2>
<p>Start Docker. I gave Colima 6 CPUs and 12 GB. The whole lab used a fraction of that.</p>
<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-bash" data-lang="bash"><span class="line"><span class="cl">colima start --cpu <span class="m">6</span> --memory <span class="m">12</span> --disk <span class="m">60</span>
</span></span><span class="line"><span class="cl">brew install kind
</span></span></code></pre></div><p>Label the two workers so the fake GPU operator knows which nodes to &ldquo;install&rdquo; GPUs on:</p>
<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-yaml" data-lang="yaml"><span class="line"><span class="cl"><span class="c"># kind-config.yaml</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">Cluster</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="nt">apiVersion</span><span class="p">:</span><span class="w"> </span><span class="l">kind.x-k8s.io/v1alpha4</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">gpu-sim</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="nt">nodes</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="cl">- <span class="nt">role</span><span class="p">:</span><span class="w"> </span><span class="l">control-plane</span><span class="w">
</span></span></span><span class="line"><span class="cl">- <span class="nt">role</span><span class="p">:</span><span class="w"> </span><span class="l">worker</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">  </span><span class="nt">labels</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">    </span><span class="nt">run.ai/simulated-gpu-node-pool</span><span class="p">:</span><span class="w"> </span><span class="l">h100</span><span class="w">
</span></span></span><span class="line"><span class="cl">- <span class="nt">role</span><span class="p">:</span><span class="w"> </span><span class="l">worker</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">  </span><span class="nt">labels</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">    </span><span class="nt">run.ai/simulated-gpu-node-pool</span><span class="p">:</span><span class="w"> </span><span class="l">h100</span><span class="w">
</span></span></span></code></pre></div><div class="highlight"><pre tabindex="0" class="chroma"><code class="language-bash" data-lang="bash"><span class="line"><span class="cl">kind create cluster --config kind-config.yaml
</span></span></code></pre></div><figure class="post-screenshot">
    <img loading="lazy" src="https://vishctl.dev/images/posts/gpu-sim-lab/01-kind-cluster.png"
         alt="kind get nodes and docker ps showing three kindest/node containers"/> <figcaption>
            <p>Each Kubernetes node is just a Docker container running kindest/node:v1.37.0.</p>
        </figcaption>
</figure>

<blockquote>
<p><strong>Gotcha on Colima:</strong> if <code>kind</code> or <code>helm</code> fails with <code>docker-credential-osxkeychain: executable file not found</code>, your <code>~/.docker/config.json</code> still points at Docker Desktop&rsquo;s credential helper. Either <code>brew install docker-credential-helper</code>, or point <code>DOCKER_CONFIG</code> at a folder with an empty <code>{}</code> config.json. For Helm OCI pulls, also set <code>HELM_REGISTRY_CONFIG</code> to that file.</p>
</blockquote>
<h2 id="step-2-install-16-fake-h100s">Step 2: install 16 fake H100s</h2>
<p><a href="https://github.com/run-ai/fake-gpu-operator">fake-gpu-operator</a> (from Run:ai, now NVIDIA) replaces the NVIDIA GPU stack with fakes. It runs a device plugin that advertises <code>nvidia.com/gpu</code>, a fake <code>nvidia-smi</code>, and a DCGM exporter with GPU metrics.</p>
<figure class="mermaid-figure">
  <div class="mermaid">sequenceDiagram
    participant SU as status-updater
    participant N as Node
    participant DP as fake device plugin
    participant S as Scheduler
    participant P as Your pod
    SU->>N: label: H100-80GB-HBM3, count 8
    DP->>N: advertise nvidia.com/gpu = 8
    P->>S: limits nvidia.com/gpu: 2
    S->>N: bind if 2 GPUs are free
    DP->>P: assign GPU IDs + fake nvidia-smi
    Note over P: nvidia-smi shows 2 x H100</div>
  <figcaption>The fake operator plugs into the same Kubernetes hooks as the real NVIDIA stack, so the scheduler can&#39;t tell the difference.</figcaption>
</figure>

<p>The operator ships GPU profiles: <code>a100</code>, <code>h100</code>, <code>b200</code>, <code>gb200</code>, <code>gb300</code>, <code>l40s</code>, <code>t4</code>. Pick one per node pool:</p>
<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-yaml" data-lang="yaml"><span class="line"><span class="cl"><span class="c"># fake-gpu-values.yaml</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="nt">topology</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">  </span><span class="nt">nodePools</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">    </span><span class="nt">h100</span><span class="p">:</span><span class="w">            </span><span class="c"># matches the node label value</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">      </span><span class="nt">gpu</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">        </span><span class="nt">backend</span><span class="p">:</span><span class="w"> </span><span class="l">fake</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">        </span><span class="nt">profile</span><span class="p">:</span><span class="w"> </span><span class="l">h100</span><span class="w">
</span></span></span></code></pre></div><div class="highlight"><pre tabindex="0" class="chroma"><code class="language-bash" data-lang="bash"><span class="line"><span class="cl">helm upgrade -i gpu-operator <span class="se">\
</span></span></span><span class="line"><span class="cl">  oci://ghcr.io/run-ai/fake-gpu-operator/fake-gpu-operator <span class="se">\
</span></span></span><span class="line"><span class="cl">  --version 0.2.1 -n gpu-operator --create-namespace <span class="se">\
</span></span></span><span class="line"><span class="cl">  -f fake-gpu-values.yaml
</span></span></code></pre></div><figure class="post-screenshot">
    <img loading="lazy" src="https://vishctl.dev/images/posts/gpu-sim-lab/02-fake-gpu-operator.png"
         alt="fake-gpu-operator pods running: device-plugin, dcgm-exporter, status-updater, topology-server"/> <figcaption>
            <p>A device plugin and a DCGM exporter on each GPU node, plus the status updater and topology server.</p>
        </figcaption>
</figure>

<p>About a minute later, the workers report GPUs. The <code>h100</code> profile gives each node 8 GPUs with 81,920 MiB each:</p>
<figure class="post-screenshot">
    <img loading="lazy" src="https://vishctl.dev/images/posts/gpu-sim-lab/03-node-gpus.png"
         alt="kubectl get nodes showing NVIDIA-H100-80GB-HBM3 with GPU count 8 on both workers"/> <figcaption>
            <p>Two workers, each with 8 NVIDIA-H100-80GB-HBM3. The control plane has none, as on a real cluster.</p>
        </figcaption>
</figure>

<p>Ask for 2 GPUs from a plain <code>ubuntu:24.04</code> pod and run <code>nvidia-smi</code>:</p>
<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-yaml" data-lang="yaml"><span class="line"><span class="cl"><span class="c"># gpu-test-pod.yaml</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="nt">apiVersion</span><span class="p">:</span><span class="w"> </span><span class="l">v1</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">Pod</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="nt">metadata</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">  </span><span class="nt">name</span><span class="p">:</span><span class="w"> </span><span class="l">gpu-test</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="nt">spec</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">  </span><span class="nt">restartPolicy</span><span class="p">:</span><span class="w"> </span><span class="l">Never</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">  </span><span class="nt">containers</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">  </span>- <span class="nt">name</span><span class="p">:</span><span class="w"> </span><span class="l">cuda</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">    </span><span class="nt">image</span><span class="p">:</span><span class="w"> </span><span class="l">ubuntu:24.04</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">    </span><span class="nt">command</span><span class="p">:</span><span class="w"> </span><span class="p">[</span><span class="s2">&#34;sh&#34;</span><span class="p">,</span><span class="w"> </span><span class="s2">&#34;-c&#34;</span><span class="p">,</span><span class="w"> </span><span class="s2">&#34;nvidia-smi &amp;&amp; sleep 3600&#34;</span><span class="p">]</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">    </span><span class="nt">resources</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">      </span><span class="nt">limits</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">        </span><span class="nt">nvidia.com/gpu</span><span class="p">:</span><span class="w"> </span><span class="m">2</span><span class="w">
</span></span></span></code></pre></div><figure class="post-screenshot">
    <img loading="lazy" src="https://vishctl.dev/images/posts/gpu-sim-lab/04-nvidia-smi.png"
         alt="Fake nvidia-smi output inside an Ubuntu pod showing two H100 GPUs"/> <figcaption>
            <p>nvidia-smi inside a stock Ubuntu image, with no driver and no CUDA. The device plugin mounts a fake binary.</p>
        </figcaption>
</figure>

<p>That is enough to test quotas, Kueue, bin-packing, node affinity, and GPU dashboards. It is <strong>not</strong> enough to run CUDA code. There is no GPU behind it.</p>
<h2 id="step-3-8-simulated-vllm-pods">Step 3: 8 simulated vLLM pods</h2>
<p><a href="https://github.com/llm-d/llm-d-inference-sim">llm-d-inference-sim</a> is a small Go server that acts like vLLM. It serves the same OpenAI endpoints and the same <code>vllm:*</code> Prometheus metrics, and it keeps a real prefix cache, so a repeated prompt is faster the second time.</p>
<p>Its latency model is the part that makes routing experiments meaningful:</p>
<figure class="mermaid-figure">
  <div class="mermaid">flowchart LR
    Q["Request<br/>3,833 prompt tokens"] e1@--> C{"Prefix cache<br/>on this pod?"}
    C e2@-->|"miss"| M["Prefill<br/>20 ms + 3,833 x 0.2 ms<br/>≈ 790 ms"]
    C e3@-->|"hit"| H["Prefill<br/>20 ms + 9 x 0.2 ms<br/>≈ 22 ms"]
    M e4@--> D["Decode<br/>15 ms per token"]
    H e5@--> D
    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 warn fill:#302410,stroke:#fbbf24,color:#fff3cf;
    classDef gpu fill:#173f59,stroke:#22d3ee,color:#ecfeff,stroke-width:3px;
    class Q input;
    class C router;
    class M warn;
    class H,D gpu;</div>
  <figcaption>Prefill cost depends only on uncached tokens, so a cache hit really is faster. The numbers are the lab&#39;s settings.</figcaption>
</figure>

<p>Here is the Deployment. Each flag maps to a vLLM concept:</p>
<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-yaml" data-lang="yaml"><span class="line"><span class="cl"><span class="c"># vllm-sim.yaml</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="nt">apiVersion</span><span class="p">:</span><span class="w"> </span><span class="l">apps/v1</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">Deployment</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="nt">metadata</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">  </span><span class="nt">name</span><span class="p">:</span><span class="w"> </span><span class="l">qwen3-32b-sim</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">  </span><span class="nt">namespace</span><span class="p">:</span><span class="w"> </span><span class="l">llm-d-sim</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="nt">spec</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">  </span><span class="nt">replicas</span><span class="p">:</span><span class="w"> </span><span class="m">8</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">  </span><span class="nt">strategy</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">    </span><span class="nt">rollingUpdate</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">      </span><span class="nt">maxSurge</span><span class="p">:</span><span class="w"> </span><span class="m">0</span><span class="w">          </span><span class="c"># no spare GPUs for a surge pod</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">      </span><span class="nt">maxUnavailable</span><span class="p">:</span><span class="w"> </span><span class="m">1</span><span class="w">    </span><span class="c"># replace one replica at a time</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">  </span><span class="nt">selector</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">    </span><span class="nt">matchLabels</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">      </span><span class="nt">app</span><span class="p">:</span><span class="w"> </span><span class="l">qwen3-32b-sim</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">  </span><span class="nt">template</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">    </span><span class="nt">metadata</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">      </span><span class="nt">labels</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">        </span><span class="nt">app</span><span class="p">:</span><span class="w"> </span><span class="l">qwen3-32b-sim</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">        </span><span class="nt">llm-d.ai/guide</span><span class="p">:</span><span class="w"> </span><span class="l">sim-lab         </span><span class="w"> </span><span class="c"># the router selects pods by this label</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">    </span><span class="nt">spec</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">      </span><span class="nt">containers</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">      </span>- <span class="nt">name</span><span class="p">:</span><span class="w"> </span><span class="l">vllm</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">        </span><span class="nt">image</span><span class="p">:</span><span class="w"> </span><span class="l">ghcr.io/llm-d/llm-d-inference-sim:v0.11.4</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">        </span><span class="nt">args</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">        </span>- --<span class="l">model=Qwen/Qwen3-32B         </span><span class="w"> </span><span class="c"># name only; no weights are loaded</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">        </span>- --<span class="l">port=8000</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">        </span>- --<span class="l">max-num-seqs=4               </span><span class="w"> </span><span class="c"># concurrent &#34;running&#34; requests per pod</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">        </span>- --<span class="l">max-model-len=16384</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">        </span>- --<span class="l">enable-kvcache               </span><span class="w"> </span><span class="c"># track prefix-cache blocks like vLLM</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">        </span>- --<span class="l">kv-cache-size=1024           </span><span class="w"> </span><span class="c"># 1024 blocks x 16 = 16k tokens per pod</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">        </span>- --<span class="l">block-size=16</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">        </span>- --<span class="l">latency-calculator=per-token </span><span class="w"> </span><span class="c"># prefill cost scales with uncached tokens</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">        </span>- --<span class="l">prefill-overhead=20ms</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">        </span>- --<span class="l">prefill-time-per-token=200us </span><span class="w"> </span><span class="c"># 4,000 uncached tokens ≈ 0.8 s TTFT</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">        </span>- --<span class="l">inter-token-latency=15ms</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">        </span>- --<span class="l">time-factor-under-load=2.0</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">        </span><span class="nt">env</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">        </span>- <span class="nt">name</span><span class="p">:</span><span class="w"> </span><span class="l">POD_NAME                 </span><span class="w"> </span><span class="c"># sim echoes this in x-inference-pod</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">          </span><span class="nt">valueFrom</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">            </span><span class="nt">fieldRef</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">              </span><span class="nt">fieldPath</span><span class="p">:</span><span class="w"> </span><span class="l">metadata.name</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">        </span>- <span class="nt">name</span><span class="p">:</span><span class="w"> </span><span class="l">POD_NAMESPACE</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">          </span><span class="nt">valueFrom</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">            </span><span class="nt">fieldRef</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">              </span><span class="nt">fieldPath</span><span class="p">:</span><span class="w"> </span><span class="l">metadata.namespace</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">        </span>- <span class="nt">name</span><span class="p">:</span><span class="w"> </span><span class="l">POD_IP                   </span><span class="w"> </span><span class="c"># required when --enable-kvcache is set</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">          </span><span class="nt">valueFrom</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">            </span><span class="nt">fieldRef</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">              </span><span class="nt">fieldPath</span><span class="p">:</span><span class="w"> </span><span class="l">status.podIP</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">        </span><span class="nt">ports</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">        </span>- <span class="nt">name</span><span class="p">:</span><span class="w"> </span><span class="l">http</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">          </span><span class="nt">containerPort</span><span class="p">:</span><span class="w"> </span><span class="m">8000</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">        </span><span class="nt">readinessProbe</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">          </span><span class="nt">httpGet</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">            </span><span class="nt">path</span><span class="p">:</span><span class="w"> </span><span class="l">/health</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">            </span><span class="nt">port</span><span class="p">:</span><span class="w"> </span><span class="m">8000</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">        </span><span class="nt">resources</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">          </span><span class="nt">requests</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">            </span><span class="nt">cpu</span><span class="p">:</span><span class="w"> </span><span class="l">50m</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">            </span><span class="nt">memory</span><span class="p">:</span><span class="w"> </span><span class="l">64Mi</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">          </span><span class="nt">limits</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">            </span><span class="nt">nvidia.com/gpu</span><span class="p">:</span><span class="w"> </span><span class="m">2</span><span class="w">             </span><span class="c"># fake H100s from fake-gpu-operator</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="nn">---</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="nt">apiVersion</span><span class="p">:</span><span class="w"> </span><span class="l">v1</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">Service</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="nt">metadata</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">  </span><span class="nt">name</span><span class="p">:</span><span class="w"> </span><span class="l">qwen3-32b</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">  </span><span class="nt">namespace</span><span class="p">:</span><span class="w"> </span><span class="l">llm-d-sim</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="nt">spec</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">  </span><span class="nt">selector</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">    </span><span class="nt">app</span><span class="p">:</span><span class="w"> </span><span class="l">qwen3-32b-sim</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">  </span><span class="nt">ports</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">  </span>- <span class="nt">name</span><span class="p">:</span><span class="w"> </span><span class="l">http</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">    </span><span class="nt">port</span><span class="p">:</span><span class="w"> </span><span class="m">8000</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">    </span><span class="nt">targetPort</span><span class="p">:</span><span class="w"> </span><span class="m">8000</span><span class="w">
</span></span></span></code></pre></div><p>Two choices are deliberate:</p>
<ul>
<li><strong>Small KV cache (16k tokens per pod).</strong> It holds about four 3,800-token system prompts, so cache placement matters.</li>
<li><strong>Low <code>max-num-seqs</code> (4).</strong> Real vLLM batches far more requests. I set it low so a laptop can drive a pod into saturation.</li>
</ul>
<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-bash" data-lang="bash"><span class="line"><span class="cl">kubectl create namespace llm-d-sim
</span></span><span class="line"><span class="cl">kubectl apply -f vllm-sim.yaml
</span></span></code></pre></div><p>Each pod uses 50m of CPU and 64 Mi of memory, but the scheduler places them by <strong>GPU</strong>: 4 pods × 2 GPUs fill each node.</p>
<figure class="post-screenshot">
    <img loading="lazy" src="https://vishctl.dev/images/posts/gpu-sim-lab/05-sim-pods-on-gpus.png"
         alt="Eight sim pods spread four per worker node, and node allocated resources showing nvidia.com/gpu 8 of 8"/> <figcaption>
            <p>Eight replicas, four per node. Each worker has all 8 GPUs allocated.</p>
        </figcaption>
</figure>

<p>Now ask for a ninth replica:</p>
<figure class="post-screenshot">
    <img loading="lazy" src="https://vishctl.dev/images/posts/gpu-sim-lab/06-ninth-replica-pending.png"
         alt="Ninth replica stuck Pending with FailedScheduling: Insufficient nvidia.com/gpu"/> <figcaption>
            <p>The ninth replica stays Pending: Insufficient nvidia.com/gpu. Fake GPUs, real scheduling.</p>
        </figcaption>
</figure>

<p>This is why the Deployment sets <code>maxSurge: 0</code>. With default settings, my first rollout <strong>stalled</strong>: the new pods needed GPUs held by the old pods they were meant to replace. You&rsquo;d hit the same thing on a full GPU cluster, and here it costs nothing to find.</p>
<blockquote>
<p><strong>Gotcha:</strong> the sim crashed on startup with <code>IP should be defined in the environment (POD_IP) for KV cache to work</code>. <code>--enable-kvcache</code> needs the <code>POD_IP</code> env var, which is why it is in the manifest above.</p>
</blockquote>
<h2 id="step-4-install-the-real-llm-d-router">Step 4: install the real llm-d router</h2>
<p>Install the Gateway API Inference Extension CRDs, then the llm-d router chart in standalone mode (Envoy runs as a sidecar of the EPP).</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/releases/download/v1.6.2/v1-manifests.yaml
</span></span></code></pre></div><p>The chart&rsquo;s defaults request 8 CPUs for the EPP and 4 for Envoy. These values shrink that and use the same plugin chain as the optimized baseline guide:</p>
<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-yaml" data-lang="yaml"><span class="line"><span class="cl"><span class="c"># router-values.yaml</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="nt">router</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">  </span><span class="nt">extraServicePorts</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">  </span>- <span class="nt">name</span><span class="p">:</span><span class="w"> </span><span class="l">http</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">    </span><span class="nt">port</span><span class="p">:</span><span class="w"> </span><span class="m">80</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">    </span><span class="nt">protocol</span><span class="p">:</span><span class="w"> </span><span class="l">TCP</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">    </span><span class="nt">targetPort</span><span class="p">:</span><span class="w"> </span><span class="m">8081</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">  </span><span class="nt">modelServers</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">    </span><span class="nt">matchLabels</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">      </span><span class="nt">llm-d.ai/guide</span><span class="p">:</span><span class="w"> </span><span class="l">sim-lab      </span><span class="w"> </span><span class="c"># pick up the simulator pods</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">  </span><span class="nt">inferencePool</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">    </span><span class="nt">failureMode</span><span class="p">:</span><span class="w"> </span><span class="l">FailOpen</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">  </span><span class="nt">epp</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">    </span><span class="nt">image</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">      </span><span class="nt">tag</span><span class="p">:</span><span class="w"> </span><span class="l">v0.10.0</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">      </span><span class="nt">pullPolicy</span><span class="p">:</span><span class="w"> </span><span class="l">IfNotPresent</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">    </span><span class="nt">flags</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">      </span><span class="nt">v</span><span class="p">:</span><span class="w"> </span><span class="m">3</span><span class="w">                          </span><span class="c"># log every scheduling decision</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">    </span><span class="nt">resources</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">      </span><span class="nt">requests</span><span class="p">:</span><span class="w"> </span>{<span class="nt">cpu</span><span class="p">:</span><span class="w"> </span><span class="nt">200m, memory</span><span class="p">:</span><span class="w"> </span><span class="l">256Mi}</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">      </span><span class="nt">limits</span><span class="p">:</span><span class="w"> </span>{<span class="nt">memory</span><span class="p">:</span><span class="w"> </span><span class="l">1Gi}</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">    </span><span class="nt">pluginsConfigFile</span><span class="p">:</span><span class="w"> </span><span class="l">sim-lab-plugins.yaml</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">    </span><span class="nt">pluginsCustomConfig</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">      </span><span class="nt">sim-lab-plugins.yaml</span><span class="p">:</span><span class="w"> </span><span class="p">|</span><span class="sd">
</span></span></span><span class="line"><span class="cl"><span class="sd">        apiVersion: llm-d.ai/v1alpha1
</span></span></span><span class="line"><span class="cl"><span class="sd">        kind: EndpointPickerConfig
</span></span></span><span class="line"><span class="cl"><span class="sd">        plugins:
</span></span></span><span class="line"><span class="cl"><span class="sd">        - type: approx-prefix-cache-producer
</span></span></span><span class="line"><span class="cl"><span class="sd">        - type: inflight-load-producer
</span></span></span><span class="line"><span class="cl"><span class="sd">        - type: prefix-cache-affinity-filter
</span></span></span><span class="line"><span class="cl"><span class="sd">        - type: token-load-scorer
</span></span></span><span class="line"><span class="cl"><span class="sd">        schedulingProfiles:
</span></span></span><span class="line"><span class="cl"><span class="sd">        - name: default
</span></span></span><span class="line"><span class="cl"><span class="sd">          plugins:
</span></span></span><span class="line"><span class="cl"><span class="sd">          - pluginRef: prefix-cache-affinity-filter
</span></span></span><span class="line"><span class="cl"><span class="sd">          - pluginRef: token-load-scorer</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">  </span><span class="nt">proxy</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">    </span><span class="nt">args</span><span class="p">:</span><span class="w"> </span><span class="p">[</span><span class="s2">&#34;--service-node&#34;</span><span class="p">,</span><span class="w"> </span><span class="s2">&#34;envoy-sidecar&#34;</span><span class="p">,</span><span class="w"> </span><span class="s2">&#34;--log-level&#34;</span><span class="p">,</span><span class="w"> </span><span class="s2">&#34;warn&#34;</span><span class="p">,</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">           </span><span class="s2">&#34;--concurrency&#34;</span><span class="p">,</span><span class="w"> </span><span class="s2">&#34;2&#34;</span><span class="p">,</span><span class="w"> </span><span class="s2">&#34;-c&#34;</span><span class="p">,</span><span class="w"> </span><span class="s2">&#34;/etc/envoy/envoy.yaml&#34;</span><span class="p">]</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">    </span><span class="nt">resources</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">      </span><span class="nt">requests</span><span class="p">:</span><span class="w"> </span>{<span class="nt">cpu</span><span class="p">:</span><span class="w"> </span><span class="nt">100m, memory</span><span class="p">:</span><span class="w"> </span><span class="l">128Mi}</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">      </span><span class="nt">limits</span><span class="p">:</span><span class="w"> </span>{<span class="nt">memory</span><span class="p">:</span><span class="w"> </span><span class="l">512Mi}</span><span class="w">
</span></span></span></code></pre></div><div class="highlight"><pre tabindex="0" class="chroma"><code class="language-bash" data-lang="bash"><span class="line"><span class="cl">helm upgrade -i sim-lab oci://ghcr.io/llm-d/charts/llm-d-router-standalone <span class="se">\
</span></span></span><span class="line"><span class="cl">  --version v0 -n llm-d-sim -f router-values.yaml
</span></span></code></pre></div><figure class="post-screenshot">
    <img loading="lazy" src="https://vishctl.dev/images/posts/gpu-sim-lab/07-router-inferencepool.png"
         alt="Router deployment, services, and the InferencePool spec selecting pods by label and pointing at the EPP service"/> <figcaption>
            <p>The InferencePool is the model-aware Service: select pods by label, target port 8000, and ask sim-lab-epp which pod to use.</p>
        </figcaption>
</figure>

<p>Send a request through the router:</p>
<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-bash" data-lang="bash"><span class="line"><span class="cl">kubectl -n llm-d-sim port-forward svc/sim-lab-epp 8080:80 <span class="p">&amp;</span>
</span></span></code></pre></div><p>Save the request body as <code>req.json</code>:</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></span><span class="line"><span class="cl">  <span class="nt">&#34;model&#34;</span><span class="p">:</span> <span class="s2">&#34;Qwen/Qwen3-32B&#34;</span><span class="p">,</span>
</span></span><span class="line"><span class="cl">  <span class="nt">&#34;messages&#34;</span><span class="p">:</span> <span class="p">[{</span><span class="nt">&#34;role&#34;</span><span class="p">:</span> <span class="s2">&#34;user&#34;</span><span class="p">,</span> <span class="nt">&#34;content&#34;</span><span class="p">:</span> <span class="s2">&#34;How do I drain a Kubernetes node?&#34;</span><span class="p">}],</span>
</span></span><span class="line"><span class="cl">  <span class="nt">&#34;max_tokens&#34;</span><span class="p">:</span> <span class="mi">24</span>
</span></span><span class="line"><span class="cl"><span class="p">}</span>
</span></span></code></pre></div><figure class="post-screenshot">
    <img loading="lazy" src="https://vishctl.dev/images/posts/gpu-sim-lab/08-first-request.png"
         alt="curl through the router returns 200 with x-inference-pod header and an OpenAI-style usage block"/> <figcaption>
            <p>A 200 with the serving pod in the x-inference-pod header. The reply is filler text, which is fine, because routing is what we&rsquo;re testing.</p>
        </figcaption>
</figure>

<p>Two headers make the rest of this post possible:</p>
<ul>
<li><code>x-inference-pod</code> tells you <strong>which pod</strong> served the request.</li>
<li><code>usage.prompt_tokens_details.cached_tokens</code> tells you <strong>whether it hit the cache</strong>.</li>
</ul>
<h2 id="how-one-request-is-routed">How one request is routed</h2>
<p>Envoy talks to the EPP through <strong>ext_proc</strong> (external processing), an Envoy filter that pauses each request, sends its headers and body to an outside gRPC service, and waits for an answer. Here the answer is a pod address.</p>
<figure class="mermaid-figure">
  <div class="mermaid">sequenceDiagram
    participant C as Client
    participant E as Envoy
    participant R as EPP
    participant P as sim pod
    C->>E: POST /v1/chat/completions
    E->>R: ext_proc: headers + body
    Note over R: filter: keep pods that<br/>likely cache this prefix
    Note over R: score: least token load
    Note over R: pick: max score
    R-->>E: x-gateway-destination-endpoint
    E->>P: forward to that pod IP
    P-->>C: stream tokens + x-inference-pod</div>
  <figcaption>Envoy calls the EPP over ext_proc. The EPP filters, scores, and picks a pod, and Envoy forwards the request to it.</figcaption>
</figure>

<p>At <code>v: 3</code> the EPP logs each step. Here the affinity filter cut 8 candidates down to 1:</p>
<figure class="post-screenshot">
    <img loading="lazy" src="https://vishctl.dev/images/posts/gpu-sim-lab/09-epp-decision.png"
         alt="EPP logs showing the filter leaving 1 endpoint, then token-load-scorer, max-score-picker, and the chosen endpoint IP"/> <figcaption>
            <p>One routing decision: filter → 1 remaining endpoint → score → pick 10.244.2.70, the pod that already held this tenant&rsquo;s prompt.</p>
        </figcaption>
</figure>

<p>The EPP reads the same metrics a real vLLM pod exports:</p>
<figure class="post-screenshot">
    <img loading="lazy" src="https://vishctl.dev/images/posts/gpu-sim-lab/10-sim-metrics.png"
         alt="Simulator Prometheus metrics: kv_cache_usage_perc, num_requests_running, num_requests_waiting, prefix_cache_hits_total, prefix_cache_queries_total"/> <figcaption>
            <p>vLLM metric names, from a simulator. Your Grafana dashboards and alerts work unchanged.</p>
        </figcaption>
</figure>

<h2 id="the-experiment">The experiment</h2>
<p>Picture a shared runbook assistant. <strong>16 teams (tenants)</strong>, each with its own 3,800-token system prompt, ask short questions. Requests arrive from 8 concurrent clients in a fixed random order.</p>
<p>The load generator is ~100 lines of stdlib Python. It runs in a <code>python:3.13-slim</code> pod and prints the pod, cache hits, and TTFT per request:</p>
<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-bash" data-lang="bash"><span class="line"><span class="cl">kubectl -n llm-d-sim run client --image<span class="o">=</span>python:3.13-slim --command -- sleep infinity
</span></span><span class="line"><span class="cl">kubectl -n llm-d-sim <span class="nb">exec</span> client -- sh -c <span class="s1">&#39;apt-get -qq update &amp;&amp; apt-get -qq install -y curl&#39;</span>
</span></span><span class="line"><span class="cl">kubectl cp loadtest.py llm-d-sim/client:/loadtest.py
</span></span></code></pre></div><figure class="mermaid-figure">
  <div class="mermaid">flowchart LR
    L["loadtest.py<br/>fixed seed"] e1@--> S["Service qwen3-32b<br/>kube-proxy picks"]
    L e2@--> R["sim-lab-epp<br/>llm-d picks"]
    S e3@--> P["same 8 sim pods<br/>cold caches"]
    R e4@--> P
    P e5@--> O["hit rate · TTFT<br/>req/s · pod spread"]
    e1@{ animate: true }
    e2@{ animate: true }
    e3@{ animate: true }
    e4@{ animate: true }
    e5@{ animate: true }
    classDef input fill:#13264a,stroke:#38bdf8,color:#e6f6ff;
    classDef idle fill:#101e27,stroke:#324950,color:#8aa0a8;
    classDef router fill:#281d48,stroke:#a78bfa,color:#f1edff;
    classDef gpu fill:#173f59,stroke:#22d3ee,color:#ecfeff;
    class L,O input;
    class S idle;
    class R router;
    class P gpu;</div>
  <figcaption>Same request sequence, two targets. I deleted the sim pods between runs so every run starts with cold caches.</figcaption>
</figure>

<blockquote>
<p><strong>Gotcha:</strong> run the comparison from <strong>inside</strong> the cluster. <code>kubectl port-forward svc/qwen3-32b</code> picks <strong>one</strong> pod and sends everything there, so it isn&rsquo;t a fair baseline. Port-forwarding the router is fine, because the router does the spreading.</p>
</blockquote>
<p>Reset between runs:</p>
<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-bash" data-lang="bash"><span class="line"><span class="cl">kubectl -n llm-d-sim delete pod -l <span class="nv">app</span><span class="o">=</span>qwen3-32b-sim --wait
</span></span><span class="line"><span class="cl">kubectl -n llm-d-sim rollout status deploy/qwen3-32b-sim
</span></span></code></pre></div><h3 id="watch-12-requests">Watch 12 requests</h3>
<p>First, a slow trace: 3 tenants, one request at a time.</p>
<p>Through the plain Service, kube-proxy picks a pod at random. Tenant 00 visits <strong>five different pods</strong> and rebuilds its prompt each time:</p>
<figure class="post-screenshot">
    <img loading="lazy" src="https://vishctl.dev/images/posts/gpu-sim-lab/11-trace-service.png"
         alt="Trace through the plain Service: 9 misses at about 795 ms and 3 hits at about 27 ms"/> <figcaption>
            <p>Plain Service: 3 hits out of 12. Every miss costs about 795 ms of prefill.</p>
        </figcaption>
</figure>

<p>Through llm-d, each tenant sticks to one pod after its first request:</p>
<figure class="post-screenshot">
    <img loading="lazy" src="https://vishctl.dev/images/posts/gpu-sim-lab/12-trace-llmd.png"
         alt="Trace through llm-d: three first-time misses, then every request is a cache hit on the same pod per tenant"/> <figcaption>
            <p>llm-d: 9 hits out of 12. The only misses are each tenant&rsquo;s very first request.</p>
        </figcaption>
</figure>

<h3 id="320-requests-16-tenants">320 requests, 16 tenants</h3>
<figure class="post-screenshot">
    <img loading="lazy" src="https://vishctl.dev/images/posts/gpu-sim-lab/13-bench-service.png"
         alt="Plain Service benchmark: 23.7 percent hit rate, TTFT p50 796 ms, 7.6 req/s, every pod saw 12 to 16 tenants"/> <figcaption>
            <p>Plain Service: every pod sees nearly all 16 tenants. Its 16k-token cache can&rsquo;t hold them, so it keeps evicting.</p>
        </figcaption>
</figure>

<figure class="post-screenshot">
    <img loading="lazy" src="https://vishctl.dev/images/posts/gpu-sim-lab/14-bench-llmd.png"
         alt="llm-d benchmark: 94.5 percent hit rate, TTFT p50 34 ms, 23.1 req/s, each pod saw 1 to 3 tenants"/> <figcaption>
            <p>llm-d: each pod sees 1 to 3 tenants. They fit in its cache, so nearly every request is a hit.</p>
        </figcaption>
</figure>

<div class="table-scroll">
  <table>
    <thead>
        <tr>
            <th scope="col">16 tenants</th>
            <th scope="col">Plain Service</th>
            <th scope="col">llm-d</th>
        </tr>
    </thead>
    <tbody>
        <tr>
            <td>Prefix cache hit rate</td>
            <td>23.7%</td>
            <td><strong>94.5%</strong></td>
        </tr>
        <tr>
            <td>TTFT p50</td>
            <td>796 ms</td>
            <td><strong>34 ms</strong></td>
        </tr>
        <tr>
            <td>TTFT p90</td>
            <td>1,317 ms</td>
            <td><strong>54 ms</strong></td>
        </tr>
        <tr>
            <td>Throughput</td>
            <td>7.6 req/s</td>
            <td><strong>23.1 req/s</strong></td>
        </tr>
    </tbody>
  </table>
</div>
<p>Same pods and same cache size: <strong>3x the throughput.</strong> llm-d effectively turned eight 16k-token caches into one 128k-token cache by giving each pod its own slice of the tenants.</p>
<h2 id="when-llm-d-loses">When llm-d loses</h2>
<p>Now flip the traffic: <strong>one hot tenant</strong>, 24 concurrent clients, 200 requests. Picture a single popular app.</p>
<figure class="post-screenshot">
    <img loading="lazy" src="https://vishctl.dev/images/posts/gpu-sim-lab/15-hot-service.png"
         alt="Plain Service with one hot tenant: TTFT p50 45 ms, 24.3 req/s, spread across all 8 pods"/> <figcaption>
            <p>Plain Service: random spreading warms all 8 pods with one miss each, and then every pod helps.</p>
        </figcaption>
</figure>

<figure class="post-screenshot">
    <img loading="lazy" src="https://vishctl.dev/images/posts/gpu-sim-lab/16-hot-llmd-default.png"
         alt="llm-d defaults with one hot tenant: all 200 requests on one pod, TTFT p50 4265 ms, 4.4 req/s"/> <figcaption>
            <p>llm-d defaults: all 200 requests on one pod. 99% cache hits, and a 4.3-second TTFT from queueing.</p>
        </figcaption>
</figure>

<p><strong>5x worse than the plain Service.</strong> llm-d had a perfect cache hit rate and still lost, because the requests piled up in one pod&rsquo;s queue.</p>
<p>The affinity filter is meant to break stickiness when the sticky pod gets slow. That check is the <strong>TTFT load gate</strong>, and it didn&rsquo;t fire. The <a href="https://github.com/llm-d/llm-d-router/tree/main/pkg/epp/framework/plugins/scheduling/filter/prefixcacheaffinity">filter&rsquo;s docs</a> explain why:</p>
<figure class="mermaid-figure">
  <div class="mermaid">flowchart TD
    R["Hot tenant request"] e1@--> F{"Affinity filter<br/>sticky pod has the prefix"}
    F e2@--> G{"TTFT gate<br/>est. TTFT = in-flight tokens<br/>÷ peakPrefillThroughput"}
    G e3@-->|"sticky slower by<br/>&gt; maxTTFTPenaltyMs?"| B["Break stickiness<br/>all 8 pods eligible"]
    G e4@-->|"no: default 18,000 ms"| S["Stay sticky<br/>queue grows"]
    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 gpu fill:#173f59,stroke:#22d3ee,color:#ecfeff;
    classDef warn fill:#302410,stroke:#fbbf24,color:#fff3cf;
    class R input;
    class F,G router;
    class B gpu;
    class S warn;</div>
  <figcaption>The gate estimates TTFT from in-flight prefill tokens. Cache hits have almost no prefill, so a pod drowning in decode work still looks idle to the gate.</figcaption>
</figure>

<p>Two settings decide this:</p>
<ul>
<li><code>peakPrefillThroughput</code>: tokens per second one pod can prefill. The default, <code>15928</code>, was measured for Qwen3-32B on H100s.</li>
<li><code>maxTTFTPenaltyMs</code>: how much slower the sticky pod may get before the router spreads. The default is <strong>18 seconds</strong>.</li>
</ul>
<h3 id="calibrate-with-the-official-script">Calibrate with the official script</h3>
<p>llm-d ships a calibration Job. It sends uncacheable prompts and measures real prefill speed. It works against the simulator too:</p>
<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-bash" data-lang="bash"><span class="line"><span class="cl">git clone --depth <span class="m">1</span> --branch v0.10.0 https://github.com/llm-d/llm-d.git
</span></span><span class="line"><span class="cl"><span class="nv">GUIDE_NAME</span><span class="o">=</span>sim-lab <span class="nv">NAMESPACE</span><span class="o">=</span>llm-d-sim <span class="nv">CHUNK_SIZE</span><span class="o">=</span><span class="m">4096</span> <span class="se">\
</span></span></span><span class="line"><span class="cl">  ./llm-d/guides/recipes/router/calibration/calibrate.sh
</span></span></code></pre></div><figure class="post-screenshot">
    <img loading="lazy" src="https://vishctl.dev/images/posts/gpu-sim-lab/17-calibrate.png"
         alt="calibrate.sh output: measured peakPrefillThroughput = 4838 tokens/sec"/> <figcaption>
            <p>4,838 tokens/s. The sim&rsquo;s settings predict 4,096 ÷ (20 ms + 4,096 × 0.2 ms) ≈ 4,880. The calibration Job measured the simulator correctly.</p>
        </figcaption>
</figure>

<p>Calibration alone didn&rsquo;t fix the hot tenant. With the measured value and a 2-second penalty, all 200 requests still went to one pod:</p>
<figure class="post-screenshot">
    <img loading="lazy" src="https://vishctl.dev/images/posts/gpu-sim-lab/18-prefill-profile-hot.png"
         alt="Prefill-tuned profile with one hot tenant: still one pod, TTFT p50 4333 ms"/> <figcaption>
            <p>Calibrated, still stuck on one pod. The gate measures prefill pressure, and this pod&rsquo;s problem is decode.</p>
        </figcaption>
</figure>

<p>On the 16-tenant traffic, the same calibrated profile did as well as the defaults:</p>
<figure class="post-screenshot">
    <img loading="lazy" src="https://vishctl.dev/images/posts/gpu-sim-lab/21-prefill-profile-16.png"
         alt="Prefill-tuned profile with 16 tenants: 94.5 percent hit rate, TTFT p90 49 ms, 21.8 req/s"/> <figcaption>
            <p>Calibrated prefill profile on 16 tenants: 94.5% hits and 49 ms p90 TTFT.</p>
        </figcaption>
</figure>

<p>The filter&rsquo;s README covers this case: use <code>token-load-scorer</code> for <strong>prefill-bound</strong> traffic and <code>active-request-scorer</code> for <strong>decode-bound</strong> traffic. A hot, fully cached prefix is decode-bound. So I made a second profile:</p>
<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-diff" data-lang="diff"><span class="line"><span class="cl">         - type: prefix-cache-affinity-filter
</span></span><span class="line"><span class="cl"><span class="gi">+          parameters:
</span></span></span><span class="line"><span class="cl"><span class="gi">+            peakPrefillThroughput: 4838   # from calibrate.sh
</span></span></span><span class="line"><span class="cl"><span class="gi">+            maxTTFTPenaltyMs: 1000        # default 18000
</span></span></span><span class="line"><span class="cl"><span class="gd">-        - type: token-load-scorer
</span></span></span><span class="line"><span class="cl"><span class="gi">+        - type: active-request-scorer
</span></span></span><span class="line"><span class="cl">         schedulingProfiles:
</span></span><span class="line"><span class="cl">         - name: default
</span></span><span class="line"><span class="cl">           plugins:
</span></span><span class="line"><span class="cl">           - pluginRef: prefix-cache-affinity-filter
</span></span><span class="line"><span class="cl"><span class="gd">-          - pluginRef: token-load-scorer
</span></span></span><span class="line"><span class="cl"><span class="gi">+          - pluginRef: active-request-scorer
</span></span></span></code></pre></div><div class="highlight"><pre tabindex="0" class="chroma"><code class="language-bash" data-lang="bash"><span class="line"><span class="cl">helm upgrade sim-lab oci://ghcr.io/llm-d/charts/llm-d-router-standalone <span class="se">\
</span></span></span><span class="line"><span class="cl">  --version v0 -n llm-d-sim -f router-values-decode.yaml
</span></span><span class="line"><span class="cl">kubectl -n llm-d-sim rollout restart deploy/sim-lab-epp
</span></span></code></pre></div><figure class="post-screenshot">
    <img loading="lazy" src="https://vishctl.dev/images/posts/gpu-sim-lab/19-decode-profile-hot.png"
         alt="Decode profile with one hot tenant: TTFT p50 41 ms, p90 47 ms, 29.2 req/s, spread across all 8 pods"/> <figcaption>
            <p>Decode profile: spread across all 8 pods, p90 TTFT 47 ms, 29.2 req/s. Better than the plain Service.</p>
        </figcaption>
</figure>

<p>It costs something on the 16-tenant workload, though:</p>
<figure class="post-screenshot">
    <img loading="lazy" src="https://vishctl.dev/images/posts/gpu-sim-lab/20-decode-profile-16.png"
         alt="Decode profile with 16 tenants: 78.2 percent hit rate, TTFT p90 793 ms, 17.8 req/s"/> <figcaption>
            <p>Decode profile on 16 tenants: hit rate drops to 78%, because it spreads requests that should have stayed sticky.</p>
        </figcaption>
</figure>

<h2 id="the-scoreboard">The scoreboard</h2>
<p>Every number below comes from a screenshot in this post. Each run started with cold caches.</p>
<div class="table-scroll">
  <table>
    <thead>
        <tr>
            <th scope="col">Router config</th>
            <th scope="col">16 tenants: hit rate</th>
            <th scope="col">16 tenants: p90 TTFT</th>
            <th scope="col">16 tenants: req/s</th>
            <th scope="col">Hot tenant: p90 TTFT</th>
            <th scope="col">Hot tenant: req/s</th>
        </tr>
    </thead>
    <tbody>
        <tr>
            <td>Plain Service</td>
            <td>23.7%</td>
            <td>1,317 ms</td>
            <td>7.6</td>
            <td>555 ms</td>
            <td>24.3</td>
        </tr>
        <tr>
            <td>llm-d defaults</td>
            <td>94.5%</td>
            <td>54 ms</td>
            <td><strong>23.1</strong></td>
            <td>5,453 ms</td>
            <td>4.4</td>
        </tr>
        <tr>
            <td>llm-d prefill profile (calibrated, 2 s gate)</td>
            <td>94.5%</td>
            <td><strong>49 ms</strong></td>
            <td>21.8</td>
            <td>5,477 ms</td>
            <td>4.6</td>
        </tr>
        <tr>
            <td>llm-d decode profile (active-request, 1 s gate)</td>
            <td>78.2%</td>
            <td>793 ms</td>
            <td>17.8</td>
            <td><strong>47 ms</strong></td>
            <td><strong>29.2</strong></td>
        </tr>
    </tbody>
  </table>
</div>
<figure class="post-screenshot"><a href="https://vishctl.dev/images/posts/gpu-sim-lab/23-ttft-scoreboard.svg">
    <img loading="lazy" src="https://vishctl.dev/images/posts/gpu-sim-lab/23-ttft-scoreboard.svg"
         alt="Bar chart of p90 TTFT: with 16 tenants the prefill profile is best at 49 ms and the plain Service worst at 1,317 ms; with one hot tenant the decode profile is best at 47 ms and the llm-d defaults worst at about 5.5 s"/> </a><figcaption>
            <p>The same four configs on two traffic patterns. Each workload has a different winner.</p>
        </figcaption>
</figure>

<p><strong>No config wins both workloads.</strong> Router tuning is a choice about <strong>your</strong> traffic mix, and the simulator let me find that out in under a minute per run instead of renting 16 H100s.</p>
<figure class="mermaid-figure">
  <div class="mermaid">flowchart TD
    Q{"What does your<br/>traffic look like?"} e1@-->|"many apps or tenants,<br/>long shared prompts"| P["Prefill-bound<br/>token-load-scorer<br/>calibrate peakPrefillThroughput"]
    Q e2@-->|"few hot prompts,<br/>long answers, high concurrency"| D["Decode-bound<br/>active-request-scorer<br/>lower maxTTFTPenaltyMs"]
    Q e3@-->|"short unique prompts"| N["No shared prefix<br/>a plain Service is fine"]
    e1@{ animate: true }
    e2@{ animate: true }
    e3@{ animate: true }
    classDef router fill:#281d48,stroke:#a78bfa,color:#f1edff;
    classDef gpu fill:#173f59,stroke:#22d3ee,color:#ecfeff;
    classDef warn fill:#302410,stroke:#fbbf24,color:#fff3cf;
    classDef idle fill:#101e27,stroke:#324950,color:#8aa0a8;
    class Q router;
    class P gpu;
    class D warn;
    class N idle;</div>
  <figcaption>A starting point. Then measure against your own traffic.</figcaption>
</figure>

<h2 id="bonus-gpu-dashboards-without-gpus">Bonus: GPU dashboards without GPUs</h2>
<p>The fake operator&rsquo;s DCGM exporter publishes the same <code>DCGM_FI_DEV_*</code> series as NVIDIA&rsquo;s, labelled with the pod that owns each GPU:</p>
<figure class="post-screenshot">
    <img loading="lazy" src="https://vishctl.dev/images/posts/gpu-sim-lab/22-fake-dcgm.png"
         alt="Fake DCGM GPU utilization per GPU index with the owning sim pod name"/> <figcaption>
            <p>Per-GPU utilization mapped to pods, ready for Prometheus and Grafana. It reads 100% by default.</p>
        </figcaption>
</figure>

<p>To make the numbers move, annotate pods with <code>run.ai/simulated-gpu-utilization: &quot;10-30&quot;</code>. You can then build and test GPU alerts before you own a GPU.</p>
<h2 id="what-a-simulator-cant-tell-you">What a simulator can&rsquo;t tell you</h2>
<p>Simulators answer <strong>&ldquo;does my platform behave correctly?&rdquo;</strong> They don&rsquo;t answer <strong>&ldquo;how fast is my model?&rdquo;</strong></p>
<div class="table-scroll">
  <table>
    <thead>
        <tr>
            <th scope="col">You can trust the lab for</th>
            <th scope="col">You can&rsquo;t trust the lab for</th>
        </tr>
    </thead>
    <tbody>
        <tr>
            <td>Scheduling, quotas, Pending pods, rollouts</td>
            <td>Real tokens/s or TTFT for your model</td>
        </tr>
        <tr>
            <td>Which pod the router picks, and why</td>
            <td>GPU memory pressure and OOMs</td>
        </tr>
        <tr>
            <td>Cache hit rates under your traffic shape</td>
            <td>CUDA kernels, NCCL, tensor parallelism</td>
        </tr>
        <tr>
            <td>Router and autoscaler config changes</td>
            <td>Answer quality</td>
        </tr>
        <tr>
            <td>Metrics, dashboards, alerts</td>
            <td>Network effects (RDMA, KV transfer cost)</td>
        </tr>
    </tbody>
  </table>
</div>
<p>Two more caveats from this run:</p>
<ul>
<li>The sim&rsquo;s latency is <strong>what you configure</strong>. Set <code>prefill-time-per-token</code> and <code>inter-token-latency</code> from a real benchmark of your model, or use the <a href="https://github.com/llm-d/llm-d-inference-sim/blob/main/docs/latency-profiles.md">ready-made profiles</a>. My <code>max-num-seqs=4</code> exaggerates saturation compared with real vLLM.</li>
<li>Without <code>--render-url</code>, the sim uses a fake tokenizer and logs a warning. That&rsquo;s fine for the approximate prefix routing used here. For <strong>precise</strong> prefix-cache routing, which compares real token hashes, run the vLLM render sidecar shown in the sim&rsquo;s <a href="https://github.com/llm-d/llm-d-inference-sim/blob/main/manifests/deployment.yaml"><code>deployment.yaml</code></a>.</li>
</ul>
<p>When the lab says your config is right, rent a GPU by the hour, apply the <strong>same manifests</strong>, and measure the parts that need real hardware.</p>
<h2 id="the-load-generator">The load generator</h2>
<p>Save it as <code>loadtest.py</code>. It uses only the standard library.</p>
<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-python" data-lang="python"><span class="line"><span class="cl"><span class="s2">&#34;&#34;&#34;Tiny load generator: N tenants, each with a long shared system prompt.
</span></span></span><span class="line"><span class="cl"><span class="s2">
</span></span></span><span class="line"><span class="cl"><span class="s2">Sends the same request sequence to a target URL and reports which pod served
</span></span></span><span class="line"><span class="cl"><span class="s2">each request, prefix-cache hits, and time to first token (TTFT).
</span></span></span><span class="line"><span class="cl"><span class="s2">Stdlib only, so it runs in a plain python:3.13-slim pod.
</span></span></span><span class="line"><span class="cl"><span class="s2">&#34;&#34;&#34;</span>
</span></span><span class="line"><span class="cl"><span class="kn">import</span> <span class="nn">argparse</span><span class="o">,</span> <span class="nn">collections</span><span class="o">,</span> <span class="nn">json</span><span class="o">,</span> <span class="nn">random</span><span class="o">,</span> <span class="nn">statistics</span><span class="o">,</span> <span class="nn">threading</span><span class="o">,</span> <span class="nn">time</span><span class="o">,</span> <span class="nn">urllib.request</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl"><span class="n">WORDS</span> <span class="o">=</span> <span class="p">(</span><span class="s2">&#34;node drain cordon pod deployment rollout restart replica service ingress &#34;</span>
</span></span><span class="line"><span class="cl">         <span class="s2">&#34;secret configmap volume claim taint toleration affinity quota limit probe &#34;</span>
</span></span><span class="line"><span class="cl">         <span class="s2">&#34;readiness liveness gpu driver kernel upgrade backup restore alert page &#34;</span>
</span></span><span class="line"><span class="cl">         <span class="s2">&#34;oncall escalate rollback canary metrics logs trace dashboard&#34;</span><span class="p">)</span><span class="o">.</span><span class="n">split</span><span class="p">()</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl"><span class="k">def</span> <span class="nf">system_prompt</span><span class="p">(</span><span class="n">tenant</span><span class="p">,</span> <span class="n">words</span><span class="o">=</span><span class="mi">3800</span><span class="p">):</span>
</span></span><span class="line"><span class="cl">    <span class="n">rnd</span> <span class="o">=</span> <span class="n">random</span><span class="o">.</span><span class="n">Random</span><span class="p">(</span><span class="n">tenant</span><span class="p">)</span>
</span></span><span class="line"><span class="cl">    <span class="n">body</span> <span class="o">=</span> <span class="s2">&#34; &#34;</span><span class="o">.</span><span class="n">join</span><span class="p">(</span><span class="n">rnd</span><span class="o">.</span><span class="n">choice</span><span class="p">(</span><span class="n">WORDS</span><span class="p">)</span> <span class="k">for</span> <span class="n">_</span> <span class="ow">in</span> <span class="nb">range</span><span class="p">(</span><span class="n">words</span><span class="p">))</span>
</span></span><span class="line"><span class="cl">    <span class="k">return</span> <span class="sa">f</span><span class="s2">&#34;[tenant-</span><span class="si">{</span><span class="n">tenant</span><span class="si">:</span><span class="s2">02d</span><span class="si">}</span><span class="s2">] You are the runbook assistant for team </span><span class="si">{</span><span class="n">tenant</span><span class="si">}</span><span class="s2">. Runbook: </span><span class="si">{</span><span class="n">body</span><span class="si">}</span><span class="s2">&#34;</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl"><span class="n">QUESTIONS</span> <span class="o">=</span> <span class="p">[</span><span class="s2">&#34;How do I drain a node?&#34;</span><span class="p">,</span> <span class="s2">&#34;What are the pre-upgrade checks?&#34;</span><span class="p">,</span>
</span></span><span class="line"><span class="cl">             <span class="s2">&#34;How do I roll back a deploy?&#34;</span><span class="p">,</span> <span class="s2">&#34;Who do I page for GPU alerts?&#34;</span><span class="p">,</span>
</span></span><span class="line"><span class="cl">             <span class="s2">&#34;How do I restore from backup?&#34;</span><span class="p">,</span> <span class="s2">&#34;Why is the pod not ready?&#34;</span><span class="p">]</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl"><span class="k">def</span> <span class="nf">one_request</span><span class="p">(</span><span class="n">url</span><span class="p">,</span> <span class="n">model</span><span class="p">,</span> <span class="n">tenant</span><span class="p">,</span> <span class="n">q</span><span class="p">,</span> <span class="n">max_tokens</span><span class="p">):</span>
</span></span><span class="line"><span class="cl">    <span class="n">body</span> <span class="o">=</span> <span class="n">json</span><span class="o">.</span><span class="n">dumps</span><span class="p">({</span>
</span></span><span class="line"><span class="cl">        <span class="s2">&#34;model&#34;</span><span class="p">:</span> <span class="n">model</span><span class="p">,</span> <span class="s2">&#34;stream&#34;</span><span class="p">:</span> <span class="kc">True</span><span class="p">,</span> <span class="s2">&#34;max_tokens&#34;</span><span class="p">:</span> <span class="n">max_tokens</span><span class="p">,</span>
</span></span><span class="line"><span class="cl">        <span class="s2">&#34;stream_options&#34;</span><span class="p">:</span> <span class="p">{</span><span class="s2">&#34;include_usage&#34;</span><span class="p">:</span> <span class="kc">True</span><span class="p">},</span>
</span></span><span class="line"><span class="cl">        <span class="s2">&#34;messages&#34;</span><span class="p">:</span> <span class="p">[{</span><span class="s2">&#34;role&#34;</span><span class="p">:</span> <span class="s2">&#34;system&#34;</span><span class="p">,</span> <span class="s2">&#34;content&#34;</span><span class="p">:</span> <span class="n">system_prompt</span><span class="p">(</span><span class="n">tenant</span><span class="p">)},</span>
</span></span><span class="line"><span class="cl">                     <span class="p">{</span><span class="s2">&#34;role&#34;</span><span class="p">:</span> <span class="s2">&#34;user&#34;</span><span class="p">,</span> <span class="s2">&#34;content&#34;</span><span class="p">:</span> <span class="n">q</span><span class="p">}],</span>
</span></span><span class="line"><span class="cl">    <span class="p">})</span><span class="o">.</span><span class="n">encode</span><span class="p">()</span>
</span></span><span class="line"><span class="cl">    <span class="n">req</span> <span class="o">=</span> <span class="n">urllib</span><span class="o">.</span><span class="n">request</span><span class="o">.</span><span class="n">Request</span><span class="p">(</span><span class="n">url</span><span class="p">,</span> <span class="n">body</span><span class="p">,</span> <span class="p">{</span><span class="s2">&#34;Content-Type&#34;</span><span class="p">:</span> <span class="s2">&#34;application/json&#34;</span><span class="p">})</span>
</span></span><span class="line"><span class="cl">    <span class="n">t0</span> <span class="o">=</span> <span class="n">time</span><span class="o">.</span><span class="n">perf_counter</span><span class="p">();</span> <span class="n">ttft</span> <span class="o">=</span> <span class="kc">None</span><span class="p">;</span> <span class="n">usage</span> <span class="o">=</span> <span class="p">{}</span>
</span></span><span class="line"><span class="cl">    <span class="k">with</span> <span class="n">urllib</span><span class="o">.</span><span class="n">request</span><span class="o">.</span><span class="n">urlopen</span><span class="p">(</span><span class="n">req</span><span class="p">,</span> <span class="n">timeout</span><span class="o">=</span><span class="mi">120</span><span class="p">)</span> <span class="k">as</span> <span class="n">r</span><span class="p">:</span>
</span></span><span class="line"><span class="cl">        <span class="n">pod</span> <span class="o">=</span> <span class="n">r</span><span class="o">.</span><span class="n">headers</span><span class="o">.</span><span class="n">get</span><span class="p">(</span><span class="s2">&#34;x-inference-pod&#34;</span><span class="p">,</span> <span class="s2">&#34;?&#34;</span><span class="p">)</span>
</span></span><span class="line"><span class="cl">        <span class="k">for</span> <span class="n">line</span> <span class="ow">in</span> <span class="n">r</span><span class="p">:</span>
</span></span><span class="line"><span class="cl">            <span class="n">line</span> <span class="o">=</span> <span class="n">line</span><span class="o">.</span><span class="n">strip</span><span class="p">()</span>
</span></span><span class="line"><span class="cl">            <span class="k">if</span> <span class="ow">not</span> <span class="n">line</span><span class="o">.</span><span class="n">startswith</span><span class="p">(</span><span class="sa">b</span><span class="s2">&#34;data:&#34;</span><span class="p">)</span> <span class="ow">or</span> <span class="n">line</span> <span class="o">==</span> <span class="sa">b</span><span class="s2">&#34;data: [DONE]&#34;</span><span class="p">:</span>
</span></span><span class="line"><span class="cl">                <span class="k">continue</span>
</span></span><span class="line"><span class="cl">            <span class="n">chunk</span> <span class="o">=</span> <span class="n">json</span><span class="o">.</span><span class="n">loads</span><span class="p">(</span><span class="n">line</span><span class="p">[</span><span class="mi">5</span><span class="p">:])</span>
</span></span><span class="line"><span class="cl">            <span class="k">if</span> <span class="n">ttft</span> <span class="ow">is</span> <span class="kc">None</span> <span class="ow">and</span> <span class="n">chunk</span><span class="o">.</span><span class="n">get</span><span class="p">(</span><span class="s2">&#34;choices&#34;</span><span class="p">):</span>
</span></span><span class="line"><span class="cl">                <span class="n">ttft</span> <span class="o">=</span> <span class="n">time</span><span class="o">.</span><span class="n">perf_counter</span><span class="p">()</span> <span class="o">-</span> <span class="n">t0</span>
</span></span><span class="line"><span class="cl">            <span class="k">if</span> <span class="n">chunk</span><span class="o">.</span><span class="n">get</span><span class="p">(</span><span class="s2">&#34;usage&#34;</span><span class="p">):</span>
</span></span><span class="line"><span class="cl">                <span class="n">usage</span> <span class="o">=</span> <span class="n">chunk</span><span class="p">[</span><span class="s2">&#34;usage&#34;</span><span class="p">]</span>
</span></span><span class="line"><span class="cl">    <span class="n">prompt</span> <span class="o">=</span> <span class="n">usage</span><span class="o">.</span><span class="n">get</span><span class="p">(</span><span class="s2">&#34;prompt_tokens&#34;</span><span class="p">,</span> <span class="mi">0</span><span class="p">)</span>
</span></span><span class="line"><span class="cl">    <span class="n">cached</span> <span class="o">=</span> <span class="p">(</span><span class="n">usage</span><span class="o">.</span><span class="n">get</span><span class="p">(</span><span class="s2">&#34;prompt_tokens_details&#34;</span><span class="p">)</span> <span class="ow">or</span> <span class="p">{})</span><span class="o">.</span><span class="n">get</span><span class="p">(</span><span class="s2">&#34;cached_tokens&#34;</span><span class="p">,</span> <span class="mi">0</span><span class="p">)</span>
</span></span><span class="line"><span class="cl">    <span class="k">return</span> <span class="nb">dict</span><span class="p">(</span><span class="n">tenant</span><span class="o">=</span><span class="n">tenant</span><span class="p">,</span> <span class="n">pod</span><span class="o">=</span><span class="n">pod</span><span class="p">,</span> <span class="n">ttft</span><span class="o">=</span><span class="n">ttft</span><span class="p">,</span> <span class="n">e2e</span><span class="o">=</span><span class="n">time</span><span class="o">.</span><span class="n">perf_counter</span><span class="p">()</span> <span class="o">-</span> <span class="n">t0</span><span class="p">,</span>
</span></span><span class="line"><span class="cl">                <span class="n">prompt</span><span class="o">=</span><span class="n">prompt</span><span class="p">,</span> <span class="n">cached</span><span class="o">=</span><span class="n">cached</span><span class="p">)</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl"><span class="k">def</span> <span class="nf">pct</span><span class="p">(</span><span class="n">xs</span><span class="p">,</span> <span class="n">p</span><span class="p">):</span>
</span></span><span class="line"><span class="cl">    <span class="n">xs</span> <span class="o">=</span> <span class="nb">sorted</span><span class="p">(</span><span class="n">xs</span><span class="p">);</span> <span class="k">return</span> <span class="n">xs</span><span class="p">[</span><span class="nb">min</span><span class="p">(</span><span class="nb">len</span><span class="p">(</span><span class="n">xs</span><span class="p">)</span> <span class="o">-</span> <span class="mi">1</span><span class="p">,</span> <span class="nb">int</span><span class="p">(</span><span class="nb">round</span><span class="p">(</span><span class="n">p</span> <span class="o">/</span> <span class="mi">100</span> <span class="o">*</span> <span class="p">(</span><span class="nb">len</span><span class="p">(</span><span class="n">xs</span><span class="p">)</span> <span class="o">-</span> <span class="mi">1</span><span class="p">))))]</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl"><span class="k">def</span> <span class="nf">main</span><span class="p">():</span>
</span></span><span class="line"><span class="cl">    <span class="n">ap</span> <span class="o">=</span> <span class="n">argparse</span><span class="o">.</span><span class="n">ArgumentParser</span><span class="p">()</span>
</span></span><span class="line"><span class="cl">    <span class="n">ap</span><span class="o">.</span><span class="n">add_argument</span><span class="p">(</span><span class="s2">&#34;--url&#34;</span><span class="p">,</span> <span class="n">required</span><span class="o">=</span><span class="kc">True</span><span class="p">)</span>
</span></span><span class="line"><span class="cl">    <span class="n">ap</span><span class="o">.</span><span class="n">add_argument</span><span class="p">(</span><span class="s2">&#34;--label&#34;</span><span class="p">,</span> <span class="n">default</span><span class="o">=</span><span class="s2">&#34;run&#34;</span><span class="p">)</span>
</span></span><span class="line"><span class="cl">    <span class="n">ap</span><span class="o">.</span><span class="n">add_argument</span><span class="p">(</span><span class="s2">&#34;--model&#34;</span><span class="p">,</span> <span class="n">default</span><span class="o">=</span><span class="s2">&#34;Qwen/Qwen3-32B&#34;</span><span class="p">)</span>
</span></span><span class="line"><span class="cl">    <span class="n">ap</span><span class="o">.</span><span class="n">add_argument</span><span class="p">(</span><span class="s2">&#34;--tenants&#34;</span><span class="p">,</span> <span class="nb">type</span><span class="o">=</span><span class="nb">int</span><span class="p">,</span> <span class="n">default</span><span class="o">=</span><span class="mi">16</span><span class="p">)</span>
</span></span><span class="line"><span class="cl">    <span class="n">ap</span><span class="o">.</span><span class="n">add_argument</span><span class="p">(</span><span class="s2">&#34;--requests&#34;</span><span class="p">,</span> <span class="nb">type</span><span class="o">=</span><span class="nb">int</span><span class="p">,</span> <span class="n">default</span><span class="o">=</span><span class="mi">320</span><span class="p">)</span>
</span></span><span class="line"><span class="cl">    <span class="n">ap</span><span class="o">.</span><span class="n">add_argument</span><span class="p">(</span><span class="s2">&#34;--concurrency&#34;</span><span class="p">,</span> <span class="nb">type</span><span class="o">=</span><span class="nb">int</span><span class="p">,</span> <span class="n">default</span><span class="o">=</span><span class="mi">8</span><span class="p">)</span>
</span></span><span class="line"><span class="cl">    <span class="n">ap</span><span class="o">.</span><span class="n">add_argument</span><span class="p">(</span><span class="s2">&#34;--max-tokens&#34;</span><span class="p">,</span> <span class="nb">type</span><span class="o">=</span><span class="nb">int</span><span class="p">,</span> <span class="n">default</span><span class="o">=</span><span class="mi">30</span><span class="p">)</span>
</span></span><span class="line"><span class="cl">    <span class="n">ap</span><span class="o">.</span><span class="n">add_argument</span><span class="p">(</span><span class="s2">&#34;--seed&#34;</span><span class="p">,</span> <span class="nb">type</span><span class="o">=</span><span class="nb">int</span><span class="p">,</span> <span class="n">default</span><span class="o">=</span><span class="mi">7</span><span class="p">)</span>
</span></span><span class="line"><span class="cl">    <span class="n">ap</span><span class="o">.</span><span class="n">add_argument</span><span class="p">(</span><span class="s2">&#34;--trace&#34;</span><span class="p">,</span> <span class="nb">type</span><span class="o">=</span><span class="nb">int</span><span class="p">,</span> <span class="n">default</span><span class="o">=</span><span class="mi">0</span><span class="p">,</span> <span class="n">help</span><span class="o">=</span><span class="s2">&#34;print the first N requests&#34;</span><span class="p">)</span>
</span></span><span class="line"><span class="cl">    <span class="n">a</span> <span class="o">=</span> <span class="n">ap</span><span class="o">.</span><span class="n">parse_args</span><span class="p">()</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl">    <span class="n">rnd</span> <span class="o">=</span> <span class="n">random</span><span class="o">.</span><span class="n">Random</span><span class="p">(</span><span class="n">a</span><span class="o">.</span><span class="n">seed</span><span class="p">)</span>
</span></span><span class="line"><span class="cl">    <span class="n">jobs</span> <span class="o">=</span> <span class="p">[(</span><span class="n">rnd</span><span class="o">.</span><span class="n">randrange</span><span class="p">(</span><span class="n">a</span><span class="o">.</span><span class="n">tenants</span><span class="p">),</span> <span class="n">rnd</span><span class="o">.</span><span class="n">choice</span><span class="p">(</span><span class="n">QUESTIONS</span><span class="p">))</span> <span class="k">for</span> <span class="n">_</span> <span class="ow">in</span> <span class="nb">range</span><span class="p">(</span><span class="n">a</span><span class="o">.</span><span class="n">requests</span><span class="p">)]</span>
</span></span><span class="line"><span class="cl">    <span class="n">results</span><span class="p">,</span> <span class="n">lock</span><span class="p">,</span> <span class="n">it</span> <span class="o">=</span> <span class="p">[],</span> <span class="n">threading</span><span class="o">.</span><span class="n">Lock</span><span class="p">(),</span> <span class="nb">iter</span><span class="p">(</span><span class="nb">enumerate</span><span class="p">(</span><span class="n">jobs</span><span class="p">))</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl">    <span class="k">def</span> <span class="nf">worker</span><span class="p">():</span>
</span></span><span class="line"><span class="cl">        <span class="k">while</span> <span class="kc">True</span><span class="p">:</span>
</span></span><span class="line"><span class="cl">            <span class="k">with</span> <span class="n">lock</span><span class="p">:</span>
</span></span><span class="line"><span class="cl">                <span class="n">nxt</span> <span class="o">=</span> <span class="nb">next</span><span class="p">(</span><span class="n">it</span><span class="p">,</span> <span class="kc">None</span><span class="p">)</span>
</span></span><span class="line"><span class="cl">            <span class="k">if</span> <span class="n">nxt</span> <span class="ow">is</span> <span class="kc">None</span><span class="p">:</span>
</span></span><span class="line"><span class="cl">                <span class="k">return</span>
</span></span><span class="line"><span class="cl">            <span class="n">i</span><span class="p">,</span> <span class="p">(</span><span class="n">t</span><span class="p">,</span> <span class="n">q</span><span class="p">)</span> <span class="o">=</span> <span class="n">nxt</span>
</span></span><span class="line"><span class="cl">            <span class="n">r</span> <span class="o">=</span> <span class="n">one_request</span><span class="p">(</span><span class="n">a</span><span class="o">.</span><span class="n">url</span><span class="p">,</span> <span class="n">a</span><span class="o">.</span><span class="n">model</span><span class="p">,</span> <span class="n">t</span><span class="p">,</span> <span class="n">q</span><span class="p">,</span> <span class="n">a</span><span class="o">.</span><span class="n">max_tokens</span><span class="p">)</span>
</span></span><span class="line"><span class="cl">            <span class="n">r</span><span class="p">[</span><span class="s2">&#34;i&#34;</span><span class="p">]</span> <span class="o">=</span> <span class="n">i</span>
</span></span><span class="line"><span class="cl">            <span class="k">with</span> <span class="n">lock</span><span class="p">:</span>
</span></span><span class="line"><span class="cl">                <span class="n">results</span><span class="o">.</span><span class="n">append</span><span class="p">(</span><span class="n">r</span><span class="p">)</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl">    <span class="n">t0</span> <span class="o">=</span> <span class="n">time</span><span class="o">.</span><span class="n">perf_counter</span><span class="p">()</span>
</span></span><span class="line"><span class="cl">    <span class="n">threads</span> <span class="o">=</span> <span class="p">[</span><span class="n">threading</span><span class="o">.</span><span class="n">Thread</span><span class="p">(</span><span class="n">target</span><span class="o">=</span><span class="n">worker</span><span class="p">)</span> <span class="k">for</span> <span class="n">_</span> <span class="ow">in</span> <span class="nb">range</span><span class="p">(</span><span class="n">a</span><span class="o">.</span><span class="n">concurrency</span><span class="p">)]</span>
</span></span><span class="line"><span class="cl">    <span class="p">[</span><span class="n">t</span><span class="o">.</span><span class="n">start</span><span class="p">()</span> <span class="k">for</span> <span class="n">t</span> <span class="ow">in</span> <span class="n">threads</span><span class="p">];</span> <span class="p">[</span><span class="n">t</span><span class="o">.</span><span class="n">join</span><span class="p">()</span> <span class="k">for</span> <span class="n">t</span> <span class="ow">in</span> <span class="n">threads</span><span class="p">]</span>
</span></span><span class="line"><span class="cl">    <span class="n">wall</span> <span class="o">=</span> <span class="n">time</span><span class="o">.</span><span class="n">perf_counter</span><span class="p">()</span> <span class="o">-</span> <span class="n">t0</span>
</span></span><span class="line"><span class="cl">    <span class="n">results</span><span class="o">.</span><span class="n">sort</span><span class="p">(</span><span class="n">key</span><span class="o">=</span><span class="k">lambda</span> <span class="n">r</span><span class="p">:</span> <span class="n">r</span><span class="p">[</span><span class="s2">&#34;i&#34;</span><span class="p">])</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl">    <span class="k">for</span> <span class="n">r</span> <span class="ow">in</span> <span class="n">results</span><span class="p">[:</span><span class="n">a</span><span class="o">.</span><span class="n">trace</span><span class="p">]:</span>
</span></span><span class="line"><span class="cl">        <span class="n">hit</span> <span class="o">=</span> <span class="s2">&#34;HIT &#34;</span> <span class="k">if</span> <span class="n">r</span><span class="p">[</span><span class="s2">&#34;cached&#34;</span><span class="p">]</span> <span class="o">&gt;</span> <span class="n">r</span><span class="p">[</span><span class="s2">&#34;prompt&#34;</span><span class="p">]</span> <span class="o">/</span> <span class="mi">2</span> <span class="k">else</span> <span class="s2">&#34;miss&#34;</span>
</span></span><span class="line"><span class="cl">        <span class="nb">print</span><span class="p">(</span><span class="sa">f</span><span class="s2">&#34;#</span><span class="si">{</span><span class="n">r</span><span class="p">[</span><span class="s1">&#39;i&#39;</span><span class="p">]</span><span class="si">:</span><span class="s2">&lt;3</span><span class="si">}</span><span class="s2"> tenant-</span><span class="si">{</span><span class="n">r</span><span class="p">[</span><span class="s1">&#39;tenant&#39;</span><span class="p">]</span><span class="si">:</span><span class="s2">02d</span><span class="si">}</span><span class="s2"> -&gt; </span><span class="si">{</span><span class="n">r</span><span class="p">[</span><span class="s1">&#39;pod&#39;</span><span class="p">][</span><span class="o">-</span><span class="mi">5</span><span class="p">:]</span><span class="si">}</span><span class="s2">  </span><span class="si">{</span><span class="n">hit</span><span class="si">}</span><span class="s2">  &#34;</span>
</span></span><span class="line"><span class="cl">              <span class="sa">f</span><span class="s2">&#34;cached </span><span class="si">{</span><span class="n">r</span><span class="p">[</span><span class="s1">&#39;cached&#39;</span><span class="p">]</span><span class="si">:</span><span class="s2">&gt;4</span><span class="si">}</span><span class="s2">/</span><span class="si">{</span><span class="n">r</span><span class="p">[</span><span class="s1">&#39;prompt&#39;</span><span class="p">]</span><span class="si">:</span><span class="s2">&lt;4</span><span class="si">}</span><span class="s2">  ttft </span><span class="si">{</span><span class="n">r</span><span class="p">[</span><span class="s1">&#39;ttft&#39;</span><span class="p">]</span><span class="o">*</span><span class="mi">1000</span><span class="si">:</span><span class="s2">6.0f</span><span class="si">}</span><span class="s2"> ms&#34;</span><span class="p">)</span>
</span></span><span class="line"><span class="cl">    <span class="k">if</span> <span class="n">a</span><span class="o">.</span><span class="n">trace</span><span class="p">:</span>
</span></span><span class="line"><span class="cl">        <span class="nb">print</span><span class="p">()</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl">    <span class="n">ttfts</span> <span class="o">=</span> <span class="p">[</span><span class="n">r</span><span class="p">[</span><span class="s2">&#34;ttft&#34;</span><span class="p">]</span> <span class="k">for</span> <span class="n">r</span> <span class="ow">in</span> <span class="n">results</span><span class="p">]</span>
</span></span><span class="line"><span class="cl">    <span class="n">prompt</span> <span class="o">=</span> <span class="nb">sum</span><span class="p">(</span><span class="n">r</span><span class="p">[</span><span class="s2">&#34;prompt&#34;</span><span class="p">]</span> <span class="k">for</span> <span class="n">r</span> <span class="ow">in</span> <span class="n">results</span><span class="p">);</span> <span class="n">cached</span> <span class="o">=</span> <span class="nb">sum</span><span class="p">(</span><span class="n">r</span><span class="p">[</span><span class="s2">&#34;cached&#34;</span><span class="p">]</span> <span class="k">for</span> <span class="n">r</span> <span class="ow">in</span> <span class="n">results</span><span class="p">)</span>
</span></span><span class="line"><span class="cl">    <span class="n">per_pod</span> <span class="o">=</span> <span class="n">collections</span><span class="o">.</span><span class="n">Counter</span><span class="p">(</span><span class="n">r</span><span class="p">[</span><span class="s2">&#34;pod&#34;</span><span class="p">][</span><span class="o">-</span><span class="mi">5</span><span class="p">:]</span> <span class="k">for</span> <span class="n">r</span> <span class="ow">in</span> <span class="n">results</span><span class="p">)</span>
</span></span><span class="line"><span class="cl">    <span class="n">tenants_per_pod</span> <span class="o">=</span> <span class="n">collections</span><span class="o">.</span><span class="n">defaultdict</span><span class="p">(</span><span class="nb">set</span><span class="p">)</span>
</span></span><span class="line"><span class="cl">    <span class="k">for</span> <span class="n">r</span> <span class="ow">in</span> <span class="n">results</span><span class="p">:</span>
</span></span><span class="line"><span class="cl">        <span class="n">tenants_per_pod</span><span class="p">[</span><span class="n">r</span><span class="p">[</span><span class="s2">&#34;pod&#34;</span><span class="p">][</span><span class="o">-</span><span class="mi">5</span><span class="p">:]]</span><span class="o">.</span><span class="n">add</span><span class="p">(</span><span class="n">r</span><span class="p">[</span><span class="s2">&#34;tenant&#34;</span><span class="p">])</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl">    <span class="nb">print</span><span class="p">(</span><span class="sa">f</span><span class="s2">&#34;== </span><span class="si">{</span><span class="n">a</span><span class="o">.</span><span class="n">label</span><span class="si">}</span><span class="s2">: </span><span class="si">{</span><span class="nb">len</span><span class="p">(</span><span class="n">results</span><span class="p">)</span><span class="si">}</span><span class="s2"> requests, </span><span class="si">{</span><span class="n">a</span><span class="o">.</span><span class="n">tenants</span><span class="si">}</span><span class="s2"> tenants, concurrency </span><span class="si">{</span><span class="n">a</span><span class="o">.</span><span class="n">concurrency</span><span class="si">}</span><span class="s2">&#34;</span><span class="p">)</span>
</span></span><span class="line"><span class="cl">    <span class="nb">print</span><span class="p">(</span><span class="sa">f</span><span class="s2">&#34;prefix cache hit rate : </span><span class="si">{</span><span class="n">cached</span> <span class="o">/</span> <span class="n">prompt</span><span class="si">:</span><span class="s2">6.1%</span><span class="si">}</span><span class="s2">  (</span><span class="si">{</span><span class="n">cached</span><span class="si">:</span><span class="s2">,</span><span class="si">}</span><span class="s2"> of </span><span class="si">{</span><span class="n">prompt</span><span class="si">:</span><span class="s2">,</span><span class="si">}</span><span class="s2"> prompt tokens)&#34;</span><span class="p">)</span>
</span></span><span class="line"><span class="cl">    <span class="nb">print</span><span class="p">(</span><span class="sa">f</span><span class="s2">&#34;TTFT p50 / p90 / p99  : </span><span class="si">{</span><span class="n">pct</span><span class="p">(</span><span class="n">ttfts</span><span class="p">,</span><span class="mi">50</span><span class="p">)</span><span class="o">*</span><span class="mi">1000</span><span class="si">:</span><span class="s2">5.0f</span><span class="si">}</span><span class="s2"> / </span><span class="si">{</span><span class="n">pct</span><span class="p">(</span><span class="n">ttfts</span><span class="p">,</span><span class="mi">90</span><span class="p">)</span><span class="o">*</span><span class="mi">1000</span><span class="si">:</span><span class="s2">5.0f</span><span class="si">}</span><span class="s2"> / </span><span class="si">{</span><span class="n">pct</span><span class="p">(</span><span class="n">ttfts</span><span class="p">,</span><span class="mi">99</span><span class="p">)</span><span class="o">*</span><span class="mi">1000</span><span class="si">:</span><span class="s2">5.0f</span><span class="si">}</span><span class="s2"> ms&#34;</span><span class="p">)</span>
</span></span><span class="line"><span class="cl">    <span class="nb">print</span><span class="p">(</span><span class="sa">f</span><span class="s2">&#34;mean TTFT             : </span><span class="si">{</span><span class="n">statistics</span><span class="o">.</span><span class="n">mean</span><span class="p">(</span><span class="n">ttfts</span><span class="p">)</span><span class="o">*</span><span class="mi">1000</span><span class="si">:</span><span class="s2">5.0f</span><span class="si">}</span><span class="s2"> ms&#34;</span><span class="p">)</span>
</span></span><span class="line"><span class="cl">    <span class="nb">print</span><span class="p">(</span><span class="sa">f</span><span class="s2">&#34;throughput            : </span><span class="si">{</span><span class="nb">len</span><span class="p">(</span><span class="n">results</span><span class="p">)</span><span class="o">/</span><span class="n">wall</span><span class="si">:</span><span class="s2">5.1f</span><span class="si">}</span><span class="s2"> req/s  (wall </span><span class="si">{</span><span class="n">wall</span><span class="si">:</span><span class="s2">.1f</span><span class="si">}</span><span class="s2">s)&#34;</span><span class="p">)</span>
</span></span><span class="line"><span class="cl">    <span class="nb">print</span><span class="p">(</span><span class="s2">&#34;pod    requests  tenants-seen&#34;</span><span class="p">)</span>
</span></span><span class="line"><span class="cl">    <span class="k">for</span> <span class="n">pod</span> <span class="ow">in</span> <span class="nb">sorted</span><span class="p">(</span><span class="n">per_pod</span><span class="p">):</span>
</span></span><span class="line"><span class="cl">        <span class="nb">print</span><span class="p">(</span><span class="sa">f</span><span class="s2">&#34;</span><span class="si">{</span><span class="n">pod</span><span class="si">}</span><span class="s2">  </span><span class="si">{</span><span class="n">per_pod</span><span class="p">[</span><span class="n">pod</span><span class="p">]</span><span class="si">:</span><span class="s2">&gt;8</span><span class="si">}</span><span class="s2">  </span><span class="si">{</span><span class="nb">len</span><span class="p">(</span><span class="n">tenants_per_pod</span><span class="p">[</span><span class="n">pod</span><span class="p">])</span><span class="si">:</span><span class="s2">&gt;12</span><span class="si">}</span><span class="s2">&#34;</span><span class="p">)</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl"><span class="k">if</span> <span class="vm">__name__</span> <span class="o">==</span> <span class="s2">&#34;__main__&#34;</span><span class="p">:</span>
</span></span><span class="line"><span class="cl">    <span class="n">main</span><span class="p">()</span>
</span></span></code></pre></div><p>The runs in this post:</p>
<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-bash" data-lang="bash"><span class="line"><span class="cl"><span class="c1"># 16 tenants (default): the plain Service, then the router</span>
</span></span><span class="line"><span class="cl">kubectl -n llm-d-sim <span class="nb">exec</span> client -- python /loadtest.py <span class="se">\
</span></span></span><span class="line"><span class="cl">  --url http://qwen3-32b:8000/v1/chat/completions --label <span class="s2">&#34;plain Service&#34;</span>
</span></span><span class="line"><span class="cl">kubectl -n llm-d-sim <span class="nb">exec</span> client -- python /loadtest.py <span class="se">\
</span></span></span><span class="line"><span class="cl">  --url http://sim-lab-epp/v1/chat/completions --label <span class="s2">&#34;llm-d router&#34;</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl"><span class="c1"># one hot tenant</span>
</span></span><span class="line"><span class="cl">kubectl -n llm-d-sim <span class="nb">exec</span> client -- python /loadtest.py <span class="se">\
</span></span></span><span class="line"><span class="cl">  --url http://sim-lab-epp/v1/chat/completions <span class="se">\
</span></span></span><span class="line"><span class="cl">  --tenants <span class="m">1</span> --requests <span class="m">200</span> --concurrency <span class="m">24</span> --max-tokens <span class="m">60</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl"><span class="c1"># the 12-request trace</span>
</span></span><span class="line"><span class="cl">kubectl -n llm-d-sim <span class="nb">exec</span> client -- python /loadtest.py <span class="se">\
</span></span></span><span class="line"><span class="cl">  --url http://sim-lab-epp/v1/chat/completions <span class="se">\
</span></span></span><span class="line"><span class="cl">  --tenants <span class="m">3</span> --requests <span class="m">12</span> --concurrency <span class="m">1</span> --trace <span class="m">12</span>
</span></span></code></pre></div><h2 id="clean-up">Clean up</h2>
<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-bash" data-lang="bash"><span class="line"><span class="cl">kind delete cluster --name gpu-sim
</span></span><span class="line"><span class="cl">colima stop
</span></span></code></pre></div><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">kind                  = Kubernetes nodes as Docker containers
</span></span><span class="line"><span class="cl">fake-gpu-operator     = nvidia.com/gpu, nvidia-smi, DCGM metrics, no GPU
</span></span><span class="line"><span class="cl">llm-d-inference-sim   = vLLM&#39;s API + metrics + prefix cache, no model
</span></span><span class="line"><span class="cl">EPP                   = Endpoint Picker: llm-d&#39;s router brain, picks the pod
</span></span><span class="line"><span class="cl">Envoy                 = the proxy that asks the EPP, then forwards
</span></span><span class="line"><span class="cl">ext_proc              = the Envoy hook Envoy uses to ask the EPP
</span></span><span class="line"><span class="cl">x-inference-pod       = which pod served the request
</span></span><span class="line"><span class="cl">cached_tokens         = did it hit the prefix cache
</span></span><span class="line"><span class="cl">calibrate.sh          = measures peakPrefillThroughput, works on the sim
</span></span><span class="line"><span class="cl">prefill-bound traffic = token-load-scorer, stay sticky
</span></span><span class="line"><span class="cl">decode-bound traffic  = active-request-scorer, lower maxTTFTPenaltyMs
</span></span><span class="line"><span class="cl">the lab can&#39;t tell you = real speed, memory, CUDA, quality
</span></span></code></pre></div><p>Fake the GPU and the model. Keep everything else real. That&rsquo;s enough to learn how the platform behaves without renting a GPU.</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></channel></rss>