<?xml version="1.0" encoding="utf-8"?>
<feed xmlns="http://www.w3.org/2005/Atom">
  <generator uri="https://jekyllrb.com/" version="3.10.0">Jekyll</generator>
  <link href="https://raghavpotluri.com/feed.xml" rel="self" type="application/atom+xml" />
  <link href="https://raghavpotluri.com/" rel="alternate" type="text/html" />
  <updated>2026-07-24T00:00:00+00:00</updated>
  <id>https://raghavpotluri.com/feed.xml</id>
  <title type="html">Raghav Potluri</title>
  <subtitle>Raghav Potluri — engineering notes on the infrastructure beneath large language models: distributed inference, prompt caching, and the telemetry that makes it debuggable.</subtitle>
  <author>
    <name>Raghav Potluri</name>
    <uri>https://raghavpotluri.com/</uri>
  </author>
  <entry>
    <title type="html">There is no prefill phase</title>
    <link href="https://raghavpotluri.com/series/inside-vllm/there-is-no-prefill-phase/" rel="alternate" type="text/html" title="There is no prefill phase" />
    <published>2026-07-24T00:00:00+00:00</published>
    <updated>2026-07-24T00:00:00+00:00</updated>
    <id>https://raghavpotluri.com/series/inside-vllm/there-is-no-prefill-phase/</id>
    <author>
      <name>Raghav Potluri</name>
    </author>
    <summary type="html">vLLM&#39;s scheduler has no concept of prefill or decode. It has one integer per request and a token budget, and the phases everyone teaches are what you get when you plot the output. Part one of a walk through the V1 engine, from the code.</summary>
    <category term="inside-vllm" label="Inside vLLM" />
    <category term="vllm" />
    <category term="inference" />
    <category term="scheduler" />
    <category term="continuous-batching" />
    <category term="kv-cache" />
    <content type="html" xml:base="https://raghavpotluri.com/series/inside-vllm/there-is-no-prefill-phase/">&lt;p&gt;There’s a comment sitting above vLLM’s main scheduling loop that explains the engine better than any architecture diagram of it:&lt;/p&gt;

&lt;blockquote&gt;
  &lt;p&gt;There’s no “decoding phase” nor “prefill phase” in the scheduler. Each request just has the &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;num_computed_tokens&lt;/code&gt; and &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;num_tokens_with_spec&lt;/code&gt;. […] At each step, the scheduler tries to assign tokens to the requests so that each request’s &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;num_computed_tokens&lt;/code&gt; can catch up its &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;num_tokens_with_spec&lt;/code&gt;.&lt;/p&gt;

  &lt;p&gt;— &lt;a href=&quot;https://github.com/vllm-project/vllm/blob/6a1acac3fe2924ca221fdf048b74f9a020dabddd/vllm/v1/core/sched/scheduler.py#L429-L438&quot;&gt;&lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;vllm/v1/core/sched/scheduler.py:429-438&lt;/code&gt;&lt;/a&gt;&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;Prefill and decode are the two words every explanation of LLM inference is built on, mine included. They name nothing in vLLM’s scheduler. There is a counter per request and a token budget per step, and the phases are what you get when you plot the result.&lt;/p&gt;

&lt;p&gt;That gap — between the model everyone teaches and the loop that actually runs — is why the engine looks like a black box from outside. This is the first of a series where I go through the V1 engine one component at a time, reading the code instead of the paper. The loop comes first because every other component hangs off it.&lt;/p&gt;

&lt;p&gt;Everything below is pinned to &lt;a href=&quot;https://github.com/vllm-project/vllm/commit/6a1acac3fe2924ca221fdf048b74f9a020dabddd&quot;&gt;&lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;6a1acac&lt;/code&gt;&lt;/a&gt; on &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;main&lt;/code&gt;, 1,469 commits past the &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;v0.23.1rc0&lt;/code&gt; tag. Line numbers move; the SHA doesn’t.&lt;/p&gt;

&lt;h2 id=&quot;the-phases-are-real-but-they-arent-control-flow&quot;&gt;The phases are real, but they aren’t control flow&lt;/h2&gt;

&lt;p&gt;Start with what the two words are actually for, because they’re not wrong — they name two shapes of matrix multiply.&lt;/p&gt;

&lt;p&gt;You send a 2000-token prompt. To produce the first output token, the model has to compute attention keys and values for all 2000 positions. That’s one forward pass with 2000 tokens in it. Each weight matrix gets read from HBM once and multiplied against a 2000-row activation matrix. The GPU is doing enough arithmetic per byte loaded to keep its tensor cores busy. That’s &lt;strong&gt;prefill&lt;/strong&gt;, and it’s compute-bound.&lt;/p&gt;

&lt;p&gt;Now the second output token. The keys and values for those 2000 positions are already sitting in GPU memory, so the model computes over exactly one new position. One forward pass, one token. Every weight matrix still gets read from HBM in full — all ~140 GB of them for a 70B model in bf16 — and gets multiplied against a &lt;em&gt;single row&lt;/em&gt;. The arithmetic is trivial; the memory traffic is identical. That’s &lt;strong&gt;decode&lt;/strong&gt;, and it’s memory-bandwidth-bound — on an H100 SXM it’s the &lt;a href=&quot;https://www.nvidia.com/en-us/data-center/h100/&quot;&gt;3.35 TB/s&lt;/a&gt; of HBM bandwidth that sets the ceiling, not the tensor cores.&lt;/p&gt;

&lt;p&gt;Two shapes, two bottlenecks, both real. The mistake is carrying that distinction up into your mental model of the scheduler and assuming the engine runs a prefill stage and then a decode stage. It doesn’t. It runs one loop, and the loop counts tokens.&lt;/p&gt;

&lt;h2 id=&quot;one-counter-one-budget&quot;&gt;One counter, one budget&lt;/h2&gt;

&lt;p&gt;Every request carries &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;num_computed_tokens&lt;/code&gt; — how many of its tokens the model has KV entries for — and &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;num_tokens_with_spec&lt;/code&gt;, which is &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;len(prompt) + len(output_so_far) + len(draft_tokens)&lt;/code&gt;. The scheduler’s entire job is closing that gap.&lt;/p&gt;

&lt;p&gt;Each step it starts with a budget, &lt;a href=&quot;https://github.com/vllm-project/vllm/blob/6a1acac3fe2924ca221fdf048b74f9a020dabddd/vllm/v1/core/sched/scheduler.py#L447&quot;&gt;&lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;token_budget = self.max_num_scheduled_tokens&lt;/code&gt;&lt;/a&gt;, which &lt;a href=&quot;https://github.com/vllm-project/vllm/blob/6a1acac3fe2924ca221fdf048b74f9a020dabddd/vllm/v1/core/sched/scheduler.py#L110-L114&quot;&gt;falls back to &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;max_num_batched_tokens&lt;/code&gt;&lt;/a&gt; when you haven’t set it. Then it walks the running queue first, and for each request asks for the gap, clipped:&lt;/p&gt;

&lt;div class=&quot;language-python highlighter-rouge&quot;&gt;&lt;div class=&quot;highlight&quot;&gt;&lt;pre class=&quot;highlight&quot;&gt;&lt;code&gt;&lt;span class=&quot;n&quot;&gt;num_new_tokens&lt;/span&gt; &lt;span class=&quot;o&quot;&gt;=&lt;/span&gt; &lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;
    &lt;span class=&quot;n&quot;&gt;request&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;num_tokens_with_spec&lt;/span&gt;
    &lt;span class=&quot;o&quot;&gt;+&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;request&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;num_output_placeholders&lt;/span&gt;
    &lt;span class=&quot;o&quot;&gt;-&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;request&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;num_computed_tokens&lt;/span&gt;
&lt;span class=&quot;p&quot;&gt;)&lt;/span&gt;
&lt;span class=&quot;k&quot;&gt;if&lt;/span&gt; &lt;span class=&quot;mi&quot;&gt;0&lt;/span&gt; &lt;span class=&quot;o&quot;&gt;&amp;lt;&lt;/span&gt; &lt;span class=&quot;bp&quot;&gt;self&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;scheduler_config&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;long_prefill_token_threshold&lt;/span&gt; &lt;span class=&quot;o&quot;&gt;&amp;lt;&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;num_new_tokens&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;:&lt;/span&gt;
    &lt;span class=&quot;n&quot;&gt;num_new_tokens&lt;/span&gt; &lt;span class=&quot;o&quot;&gt;=&lt;/span&gt; &lt;span class=&quot;bp&quot;&gt;self&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;scheduler_config&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;long_prefill_token_threshold&lt;/span&gt;
&lt;span class=&quot;n&quot;&gt;num_new_tokens&lt;/span&gt; &lt;span class=&quot;o&quot;&gt;=&lt;/span&gt; &lt;span class=&quot;nb&quot;&gt;min&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;num_new_tokens&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;token_budget&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;/div&gt;
&lt;p&gt;&lt;span class=&quot;src&quot;&gt;&lt;a href=&quot;https://github.com/vllm-project/vllm/blob/6a1acac3fe2924ca221fdf048b74f9a020dabddd/vllm/v1/core/sched/scheduler.py#L504-L511&quot;&gt;&lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;scheduler.py:504-511&lt;/code&gt;&lt;/a&gt;&lt;/span&gt;&lt;/p&gt;

&lt;p&gt;A request that has generated 40 tokens and needs its 41st has a gap of 1, so it gets 1. A request that arrived this step with a 16,000-token prompt has a gap of 16,000, gets clipped to whatever budget is left, and comes back next step for the rest. Same three lines. The first case is what we call decode and the second is what we call chunked prefill, and the code does not distinguish them — chunked prefill isn’t a feature bolted onto the loop, it’s what &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;min(gap, budget)&lt;/code&gt; does when the gap is large.&lt;/p&gt;

&lt;p&gt;Running requests are served before waiting ones (&lt;a href=&quot;https://github.com/vllm-project/vllm/blob/6a1acac3fe2924ca221fdf048b74f9a020dabddd/vllm/v1/core/sched/scheduler.py#L473&quot;&gt;&lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;scheduler.py:473&lt;/code&gt;&lt;/a&gt; opens that loop; the waiting queue is drained afterwards with whatever budget survives). That ordering is the whole scheduling policy for the common case: keep everything already generating alive, then admit new work with the leftovers.&lt;/p&gt;

&lt;figure class=&quot;figure figure-scroll&quot;&gt;
&lt;svg viewBox=&quot;0 0 880 396&quot; role=&quot;img&quot; aria-label=&quot;A grid of five requests by eight engine steps. Each cell shows how many tokens that request was scheduled for in that step. Prefill chunks and single decode tokens appear inside the same vertical column, meaning the same engine step. A bar under each column shows the step&#39;s total against the 8192-token budget; two steps carry only 3 and 4 tokens.&quot;&gt;

  &lt;!-- legend --&gt;
  &lt;g transform=&quot;translate(0,16)&quot;&gt;
    &lt;rect x=&quot;0&quot; y=&quot;0&quot; width=&quot;11&quot; height=&quot;11&quot; rx=&quot;2&quot; class=&quot;dg-prefill&quot; /&gt;
    &lt;text x=&quot;17&quot; y=&quot;9&quot; class=&quot;dg-small&quot;&gt;PREFILL CHUNK&lt;/text&gt;
    &lt;rect x=&quot;132&quot; y=&quot;0&quot; width=&quot;11&quot; height=&quot;11&quot; rx=&quot;2&quot; class=&quot;dg-decode&quot; /&gt;
    &lt;text x=&quot;149&quot; y=&quot;9&quot; class=&quot;dg-small&quot;&gt;DECODE (1 TOKEN)&lt;/text&gt;
    &lt;rect x=&quot;292&quot; y=&quot;0&quot; width=&quot;11&quot; height=&quot;11&quot; rx=&quot;2&quot; fill=&quot;none&quot; stroke=&quot;var(--ink-faint)&quot; stroke-dasharray=&quot;2 2&quot; /&gt;
    &lt;text x=&quot;309&quot; y=&quot;9&quot; class=&quot;dg-small&quot;&gt;PREEMPTED&lt;/text&gt;
    &lt;text x=&quot;880&quot; y=&quot;9&quot; class=&quot;dg-small&quot; text-anchor=&quot;end&quot;&gt;max_num_batched_tokens = 8192&lt;/text&gt;
  &lt;/g&gt;

  &lt;!-- step headers --&gt;
  &lt;g class=&quot;dg-small&quot;&gt;
    &lt;text x=&quot;153&quot; y=&quot;58&quot; text-anchor=&quot;middle&quot;&gt;STEP N&lt;/text&gt;
    &lt;text x=&quot;241&quot; y=&quot;58&quot; text-anchor=&quot;middle&quot;&gt;N+1&lt;/text&gt;
    &lt;text x=&quot;329&quot; y=&quot;58&quot; text-anchor=&quot;middle&quot;&gt;N+2&lt;/text&gt;
    &lt;text x=&quot;417&quot; y=&quot;58&quot; text-anchor=&quot;middle&quot;&gt;N+3&lt;/text&gt;
    &lt;text x=&quot;505&quot; y=&quot;58&quot; text-anchor=&quot;middle&quot;&gt;N+4&lt;/text&gt;
    &lt;text x=&quot;593&quot; y=&quot;58&quot; text-anchor=&quot;middle&quot;&gt;N+5&lt;/text&gt;
    &lt;text x=&quot;681&quot; y=&quot;58&quot; text-anchor=&quot;middle&quot;&gt;N+6&lt;/text&gt;
    &lt;text x=&quot;769&quot; y=&quot;58&quot; text-anchor=&quot;middle&quot;&gt;N+7&lt;/text&gt;
  &lt;/g&gt;

  &lt;!-- row lanes --&gt;
  &lt;g class=&quot;dg-track&quot;&gt;
    &lt;rect x=&quot;116&quot; y=&quot;78&quot; width=&quot;690&quot; height=&quot;26&quot; rx=&quot;4&quot; /&gt;
    &lt;rect x=&quot;116&quot; y=&quot;114&quot; width=&quot;690&quot; height=&quot;26&quot; rx=&quot;4&quot; /&gt;
    &lt;rect x=&quot;116&quot; y=&quot;150&quot; width=&quot;690&quot; height=&quot;26&quot; rx=&quot;4&quot; /&gt;
    &lt;rect x=&quot;116&quot; y=&quot;186&quot; width=&quot;690&quot; height=&quot;26&quot; rx=&quot;4&quot; /&gt;
    &lt;rect x=&quot;116&quot; y=&quot;222&quot; width=&quot;690&quot; height=&quot;26&quot; rx=&quot;4&quot; /&gt;
  &lt;/g&gt;

  &lt;!-- row labels --&gt;
  &lt;g class=&quot;dg-mono&quot; text-anchor=&quot;end&quot;&gt;
    &lt;text x=&quot;104&quot; y=&quot;96&quot;&gt;req A&lt;/text&gt;
    &lt;text x=&quot;104&quot; y=&quot;132&quot;&gt;req B&lt;/text&gt;
    &lt;text x=&quot;104&quot; y=&quot;168&quot;&gt;req C&lt;/text&gt;
    &lt;text x=&quot;104&quot; y=&quot;204&quot;&gt;req D&lt;/text&gt;
    &lt;text x=&quot;104&quot; y=&quot;240&quot;&gt;req E&lt;/text&gt;
  &lt;/g&gt;

  &lt;!-- req A: 16,892-token prompt, chunked over three steps, then decodes --&gt;
  &lt;g&gt;
    &lt;rect x=&quot;116&quot; y=&quot;78&quot; width=&quot;74&quot; height=&quot;26&quot; rx=&quot;4&quot; class=&quot;dg-prefill&quot; /&gt;&lt;text x=&quot;153&quot; y=&quot;95&quot; class=&quot;dg-cell-txt&quot;&gt;8190&lt;/text&gt;
    &lt;rect x=&quot;204&quot; y=&quot;78&quot; width=&quot;74&quot; height=&quot;26&quot; rx=&quot;4&quot; class=&quot;dg-prefill&quot; /&gt;&lt;text x=&quot;241&quot; y=&quot;95&quot; class=&quot;dg-cell-txt&quot;&gt;8190&lt;/text&gt;
    &lt;rect x=&quot;292&quot; y=&quot;78&quot; width=&quot;74&quot; height=&quot;26&quot; rx=&quot;4&quot; class=&quot;dg-prefill&quot; /&gt;&lt;text x=&quot;329&quot; y=&quot;95&quot; class=&quot;dg-cell-txt&quot;&gt;512&lt;/text&gt;
    &lt;rect x=&quot;380&quot; y=&quot;78&quot; width=&quot;74&quot; height=&quot;26&quot; rx=&quot;4&quot; class=&quot;dg-decode&quot; /&gt;&lt;text x=&quot;417&quot; y=&quot;95&quot; class=&quot;dg-cell-txt&quot;&gt;1&lt;/text&gt;
    &lt;rect x=&quot;468&quot; y=&quot;78&quot; width=&quot;74&quot; height=&quot;26&quot; rx=&quot;4&quot; class=&quot;dg-decode&quot; /&gt;&lt;text x=&quot;505&quot; y=&quot;95&quot; class=&quot;dg-cell-txt&quot;&gt;1&lt;/text&gt;
    &lt;rect x=&quot;556&quot; y=&quot;78&quot; width=&quot;74&quot; height=&quot;26&quot; rx=&quot;4&quot; class=&quot;dg-decode&quot; /&gt;&lt;text x=&quot;593&quot; y=&quot;95&quot; class=&quot;dg-cell-txt&quot;&gt;1&lt;/text&gt;
    &lt;rect x=&quot;644&quot; y=&quot;78&quot; width=&quot;74&quot; height=&quot;26&quot; rx=&quot;4&quot; class=&quot;dg-decode&quot; /&gt;&lt;text x=&quot;681&quot; y=&quot;95&quot; class=&quot;dg-cell-txt&quot;&gt;1&lt;/text&gt;
    &lt;rect x=&quot;732&quot; y=&quot;78&quot; width=&quot;74&quot; height=&quot;26&quot; rx=&quot;4&quot; class=&quot;dg-decode&quot; /&gt;&lt;text x=&quot;769&quot; y=&quot;95&quot; class=&quot;dg-cell-txt&quot;&gt;1&lt;/text&gt;
  &lt;/g&gt;

  &lt;!-- req B: already decoding, finishes at N+5 --&gt;
  &lt;g&gt;
    &lt;rect x=&quot;116&quot; y=&quot;114&quot; width=&quot;74&quot; height=&quot;26&quot; rx=&quot;4&quot; class=&quot;dg-decode&quot; /&gt;&lt;text x=&quot;153&quot; y=&quot;131&quot; class=&quot;dg-cell-txt&quot;&gt;1&lt;/text&gt;
    &lt;rect x=&quot;204&quot; y=&quot;114&quot; width=&quot;74&quot; height=&quot;26&quot; rx=&quot;4&quot; class=&quot;dg-decode&quot; /&gt;&lt;text x=&quot;241&quot; y=&quot;131&quot; class=&quot;dg-cell-txt&quot;&gt;1&lt;/text&gt;
    &lt;rect x=&quot;292&quot; y=&quot;114&quot; width=&quot;74&quot; height=&quot;26&quot; rx=&quot;4&quot; class=&quot;dg-decode&quot; /&gt;&lt;text x=&quot;329&quot; y=&quot;131&quot; class=&quot;dg-cell-txt&quot;&gt;1&lt;/text&gt;
    &lt;rect x=&quot;380&quot; y=&quot;114&quot; width=&quot;74&quot; height=&quot;26&quot; rx=&quot;4&quot; class=&quot;dg-decode&quot; /&gt;&lt;text x=&quot;417&quot; y=&quot;131&quot; class=&quot;dg-cell-txt&quot;&gt;1&lt;/text&gt;
    &lt;rect x=&quot;468&quot; y=&quot;114&quot; width=&quot;74&quot; height=&quot;26&quot; rx=&quot;4&quot; class=&quot;dg-decode&quot; /&gt;&lt;text x=&quot;505&quot; y=&quot;131&quot; class=&quot;dg-cell-txt&quot;&gt;1&lt;/text&gt;
    &lt;rect x=&quot;556&quot; y=&quot;114&quot; width=&quot;74&quot; height=&quot;26&quot; rx=&quot;4&quot; class=&quot;dg-decode&quot; /&gt;&lt;text x=&quot;593&quot; y=&quot;131&quot; class=&quot;dg-cell-txt&quot;&gt;1&lt;/text&gt;
    &lt;text x=&quot;681&quot; y=&quot;132&quot; class=&quot;dg-small&quot; text-anchor=&quot;middle&quot;&gt;EOS — FREED&lt;/text&gt;
  &lt;/g&gt;

  &lt;!-- req C: arrives at N+2 --&gt;
  &lt;g&gt;
    &lt;rect x=&quot;292&quot; y=&quot;150&quot; width=&quot;74&quot; height=&quot;26&quot; rx=&quot;4&quot; class=&quot;dg-prefill&quot; /&gt;&lt;text x=&quot;329&quot; y=&quot;167&quot; class=&quot;dg-cell-txt&quot;&gt;7678&lt;/text&gt;
    &lt;rect x=&quot;380&quot; y=&quot;150&quot; width=&quot;74&quot; height=&quot;26&quot; rx=&quot;4&quot; class=&quot;dg-prefill&quot; /&gt;&lt;text x=&quot;417&quot; y=&quot;167&quot; class=&quot;dg-cell-txt&quot;&gt;8189&lt;/text&gt;
    &lt;rect x=&quot;468&quot; y=&quot;150&quot; width=&quot;74&quot; height=&quot;26&quot; rx=&quot;4&quot; class=&quot;dg-decode&quot; /&gt;&lt;text x=&quot;505&quot; y=&quot;167&quot; class=&quot;dg-cell-txt&quot;&gt;1&lt;/text&gt;
    &lt;rect x=&quot;556&quot; y=&quot;150&quot; width=&quot;74&quot; height=&quot;26&quot; rx=&quot;4&quot; class=&quot;dg-decode&quot; /&gt;&lt;text x=&quot;593&quot; y=&quot;167&quot; class=&quot;dg-cell-txt&quot;&gt;1&lt;/text&gt;
    &lt;rect x=&quot;644&quot; y=&quot;150&quot; width=&quot;74&quot; height=&quot;26&quot; rx=&quot;4&quot; class=&quot;dg-decode&quot; /&gt;&lt;text x=&quot;681&quot; y=&quot;167&quot; class=&quot;dg-cell-txt&quot;&gt;1&lt;/text&gt;
    &lt;rect x=&quot;732&quot; y=&quot;150&quot; width=&quot;74&quot; height=&quot;26&quot; rx=&quot;4&quot; class=&quot;dg-decode&quot; /&gt;&lt;text x=&quot;769&quot; y=&quot;167&quot; class=&quot;dg-cell-txt&quot;&gt;1&lt;/text&gt;
    &lt;text x=&quot;204&quot; y=&quot;168&quot; class=&quot;dg-small&quot; text-anchor=&quot;middle&quot;&gt;ARRIVES →&lt;/text&gt;
  &lt;/g&gt;

  &lt;!-- req D: decoding, preempted at N+4, recomputed at N+6 --&gt;
  &lt;g&gt;
    &lt;rect x=&quot;116&quot; y=&quot;186&quot; width=&quot;74&quot; height=&quot;26&quot; rx=&quot;4&quot; class=&quot;dg-decode&quot; /&gt;&lt;text x=&quot;153&quot; y=&quot;203&quot; class=&quot;dg-cell-txt&quot;&gt;1&lt;/text&gt;
    &lt;rect x=&quot;204&quot; y=&quot;186&quot; width=&quot;74&quot; height=&quot;26&quot; rx=&quot;4&quot; class=&quot;dg-decode&quot; /&gt;&lt;text x=&quot;241&quot; y=&quot;203&quot; class=&quot;dg-cell-txt&quot;&gt;1&lt;/text&gt;
    &lt;rect x=&quot;292&quot; y=&quot;186&quot; width=&quot;74&quot; height=&quot;26&quot; rx=&quot;4&quot; class=&quot;dg-decode&quot; /&gt;&lt;text x=&quot;329&quot; y=&quot;203&quot; class=&quot;dg-cell-txt&quot;&gt;1&lt;/text&gt;
    &lt;rect x=&quot;380&quot; y=&quot;186&quot; width=&quot;74&quot; height=&quot;26&quot; rx=&quot;4&quot; class=&quot;dg-decode&quot; /&gt;&lt;text x=&quot;417&quot; y=&quot;203&quot; class=&quot;dg-cell-txt&quot;&gt;1&lt;/text&gt;
    &lt;rect x=&quot;468&quot; y=&quot;186&quot; width=&quot;74&quot; height=&quot;26&quot; rx=&quot;4&quot; fill=&quot;none&quot; stroke=&quot;var(--ink-faint)&quot; stroke-dasharray=&quot;3 3&quot; /&gt;
    &lt;rect x=&quot;556&quot; y=&quot;186&quot; width=&quot;74&quot; height=&quot;26&quot; rx=&quot;4&quot; fill=&quot;none&quot; stroke=&quot;var(--ink-faint)&quot; stroke-dasharray=&quot;3 3&quot; /&gt;
    &lt;text x=&quot;505&quot; y=&quot;203&quot; class=&quot;dg-small&quot; text-anchor=&quot;middle&quot;&gt;BLOCKS&lt;/text&gt;
    &lt;text x=&quot;593&quot; y=&quot;203&quot; class=&quot;dg-small&quot; text-anchor=&quot;middle&quot;&gt;FREED&lt;/text&gt;
    &lt;rect x=&quot;644&quot; y=&quot;186&quot; width=&quot;74&quot; height=&quot;26&quot; rx=&quot;4&quot; class=&quot;dg-prefill&quot; /&gt;&lt;text x=&quot;681&quot; y=&quot;203&quot; class=&quot;dg-cell-txt&quot;&gt;8189&lt;/text&gt;
    &lt;rect x=&quot;732&quot; y=&quot;186&quot; width=&quot;74&quot; height=&quot;26&quot; rx=&quot;4&quot; class=&quot;dg-decode&quot; /&gt;&lt;text x=&quot;769&quot; y=&quot;203&quot; class=&quot;dg-cell-txt&quot;&gt;1&lt;/text&gt;
  &lt;/g&gt;

  &lt;!-- req E: arrives at N+5 --&gt;
  &lt;g&gt;
    &lt;rect x=&quot;556&quot; y=&quot;222&quot; width=&quot;74&quot; height=&quot;26&quot; rx=&quot;4&quot; class=&quot;dg-prefill&quot; /&gt;&lt;text x=&quot;593&quot; y=&quot;239&quot; class=&quot;dg-cell-txt&quot;&gt;8189&lt;/text&gt;
    &lt;rect x=&quot;644&quot; y=&quot;222&quot; width=&quot;74&quot; height=&quot;26&quot; rx=&quot;4&quot; class=&quot;dg-decode&quot; /&gt;&lt;text x=&quot;681&quot; y=&quot;239&quot; class=&quot;dg-cell-txt&quot;&gt;1&lt;/text&gt;
    &lt;rect x=&quot;732&quot; y=&quot;222&quot; width=&quot;74&quot; height=&quot;26&quot; rx=&quot;4&quot; class=&quot;dg-decode&quot; /&gt;&lt;text x=&quot;769&quot; y=&quot;239&quot; class=&quot;dg-cell-txt&quot;&gt;1&lt;/text&gt;
    &lt;text x=&quot;468&quot; y=&quot;240&quot; class=&quot;dg-small&quot; text-anchor=&quot;middle&quot;&gt;ARRIVES →&lt;/text&gt;
  &lt;/g&gt;

  &lt;!-- budget band --&gt;
  &lt;line x1=&quot;116&quot; y1=&quot;318&quot; x2=&quot;806&quot; y2=&quot;318&quot; stroke=&quot;var(--line-strong)&quot; /&gt;
  &lt;text x=&quot;104&quot; y=&quot;300&quot; class=&quot;dg-mono&quot; text-anchor=&quot;end&quot;&gt;budget&lt;/text&gt;
  &lt;g&gt;
    &lt;rect x=&quot;116&quot; y=&quot;274&quot; width=&quot;74&quot; height=&quot;44&quot; rx=&quot;3&quot; class=&quot;dg-prefill&quot; /&gt;
    &lt;rect x=&quot;204&quot; y=&quot;274&quot; width=&quot;74&quot; height=&quot;44&quot; rx=&quot;3&quot; class=&quot;dg-prefill&quot; /&gt;
    &lt;rect x=&quot;292&quot; y=&quot;274&quot; width=&quot;74&quot; height=&quot;44&quot; rx=&quot;3&quot; class=&quot;dg-prefill&quot; /&gt;
    &lt;rect x=&quot;380&quot; y=&quot;274&quot; width=&quot;74&quot; height=&quot;44&quot; rx=&quot;3&quot; class=&quot;dg-prefill&quot; /&gt;
    &lt;rect x=&quot;468&quot; y=&quot;316&quot; width=&quot;74&quot; height=&quot;2&quot; rx=&quot;1&quot; class=&quot;dg-decode&quot; /&gt;
    &lt;rect x=&quot;556&quot; y=&quot;274&quot; width=&quot;74&quot; height=&quot;44&quot; rx=&quot;3&quot; class=&quot;dg-prefill&quot; /&gt;
    &lt;rect x=&quot;644&quot; y=&quot;274&quot; width=&quot;74&quot; height=&quot;44&quot; rx=&quot;3&quot; class=&quot;dg-prefill&quot; /&gt;
    &lt;rect x=&quot;732&quot; y=&quot;316&quot; width=&quot;74&quot; height=&quot;2&quot; rx=&quot;1&quot; class=&quot;dg-decode&quot; /&gt;
  &lt;/g&gt;
  &lt;g class=&quot;dg-small&quot; text-anchor=&quot;middle&quot;&gt;
    &lt;text x=&quot;153&quot; y=&quot;334&quot;&gt;8192&lt;/text&gt;
    &lt;text x=&quot;241&quot; y=&quot;334&quot;&gt;8192&lt;/text&gt;
    &lt;text x=&quot;329&quot; y=&quot;334&quot;&gt;8192&lt;/text&gt;
    &lt;text x=&quot;417&quot; y=&quot;334&quot;&gt;8192&lt;/text&gt;
    &lt;text x=&quot;505&quot; y=&quot;334&quot;&gt;3&lt;/text&gt;
    &lt;text x=&quot;593&quot; y=&quot;334&quot;&gt;8192&lt;/text&gt;
    &lt;text x=&quot;681&quot; y=&quot;334&quot;&gt;8192&lt;/text&gt;
    &lt;text x=&quot;769&quot; y=&quot;334&quot;&gt;4&lt;/text&gt;
  &lt;/g&gt;

  &lt;text x=&quot;116&quot; y=&quot;362&quot; class=&quot;dg-mono&quot;&gt;Read a column, not a row: one step is one forward pass over every cell in it.&lt;/text&gt;
  &lt;text x=&quot;116&quot; y=&quot;380&quot; class=&quot;dg-small&quot;&gt;STEPS N+4 AND N+7 ARE DRAWN AT A 2PX FLOOR — AT TRUE SCALE THEY WOULD BE SUB-PIXEL.&lt;/text&gt;
&lt;/svg&gt;
&lt;figcaption&gt;
Eight consecutive engine steps on a single GPU. &lt;b&gt;A column is one &lt;code&gt;execute_model&lt;/code&gt; call&lt;/b&gt;, and every column here mixes prefill chunks with single decode tokens — the scheduler has no notion of a prefill phase to separate them. Request D is preempted at N+4 to free blocks for C and E, and pays for it at N+6 by recomputing all 8189 of its tokens from scratch. The two decode-only steps schedule 3 and 4 tokens against an 8192 budget — under 0.05% of it — while still reading every weight in the model once.
&lt;/figcaption&gt;
&lt;/figure&gt;

&lt;p&gt;Read that diagram down a column, not across a row. A column is one &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;execute_model&lt;/code&gt; call — one batch, one set of kernel launches. Column N+2 contains request A finishing its prompt with a 512-token chunk, request C starting a 7678-token chunk, and requests B and D contributing one token each. There is no step in that picture that is “a prefill step.”&lt;/p&gt;

&lt;h2 id=&quot;your-request-is-a-row-in-a-matrix-that-changes-every-step&quot;&gt;Your request is a row in a matrix that changes every step&lt;/h2&gt;

&lt;p&gt;The other half of the black box is that nothing in the engine owns your request end to end. It’s decomposed across two processes, and the boundary is worth knowing because it’s where most operational surprises live.&lt;/p&gt;

&lt;p&gt;The API server process does HTTP, tokenization, and multimodal loading; the engine core process runs the scheduler and drives the GPU workers; they talk over ZMQ (&lt;a href=&quot;https://github.com/vllm-project/vllm/blob/6a1acac3fe2924ca221fdf048b74f9a020dabddd/docs/design/arch_overview.md#L82-L99&quot;&gt;&lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;docs/design/arch_overview.md&lt;/code&gt;&lt;/a&gt;). That’s not decoration. Separate processes mean separate GILs, so a burst of tokenization work in the frontend can’t stall the loop that’s feeding the GPU. It also means the thing you profile as “vLLM latency” is at least two queues deep.&lt;/p&gt;

&lt;pre&gt;&lt;code class=&quot;language-mermaid&quot;&gt;sequenceDiagram
    autonumber
    participant C as client
    participant API as API server proc&amp;lt;br/&amp;gt;(tokenize, HTTP)
    participant EC as EngineCore proc&amp;lt;br/&amp;gt;(scheduler)
    participant W as worker proc&amp;lt;br/&amp;gt;(GPU)

    C-&amp;gt;&amp;gt;API: POST /v1/chat/completions
    API-&amp;gt;&amp;gt;API: tokenize → EngineCoreRequest
    API-&amp;gt;&amp;gt;EC: ZMQ: add_request
    Note over EC: request lands in the&amp;lt;br/&amp;gt;waiting queue
    loop every engine step
        EC-&amp;gt;&amp;gt;EC: schedule() → SchedulerOutput
        EC-&amp;gt;&amp;gt;W: execute_model(SchedulerOutput)
        W--&amp;gt;&amp;gt;EC: sampled token ids
        EC-&amp;gt;&amp;gt;EC: update_from_output()
        EC--&amp;gt;&amp;gt;API: ZMQ: EngineCoreOutputs
        API-&amp;gt;&amp;gt;API: detokenize, stop-string check
        API--&amp;gt;&amp;gt;C: SSE chunk
    end
&lt;/code&gt;&lt;/pre&gt;

&lt;p&gt;The engine core side is a busy loop with two statements in it:&lt;/p&gt;

&lt;div class=&quot;language-python highlighter-rouge&quot;&gt;&lt;div class=&quot;highlight&quot;&gt;&lt;pre class=&quot;highlight&quot;&gt;&lt;code&gt;&lt;span class=&quot;k&quot;&gt;def&lt;/span&gt; &lt;span class=&quot;nf&quot;&gt;run_busy_loop&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;bp&quot;&gt;self&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;):&lt;/span&gt;
    &lt;span class=&quot;s&quot;&gt;&quot;&quot;&quot;Core busy loop of the EngineCore.&quot;&quot;&quot;&lt;/span&gt;
    &lt;span class=&quot;k&quot;&gt;while&lt;/span&gt; &lt;span class=&quot;bp&quot;&gt;self&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;_handle_shutdown&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;():&lt;/span&gt;
        &lt;span class=&quot;c1&quot;&gt;# 1) Poll the input queue until there is work to do.
&lt;/span&gt;        &lt;span class=&quot;bp&quot;&gt;self&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;_process_input_queue&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;()&lt;/span&gt;
        &lt;span class=&quot;c1&quot;&gt;# 2) Step the engine core and return the outputs.
&lt;/span&gt;        &lt;span class=&quot;bp&quot;&gt;self&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;_process_engine_step&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;()&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;/div&gt;
&lt;p&gt;&lt;span class=&quot;src&quot;&gt;&lt;a href=&quot;https://github.com/vllm-project/vllm/blob/6a1acac3fe2924ca221fdf048b74f9a020dabddd/vllm/v1/engine/core.py#L1358-L1364&quot;&gt;&lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;core.py:1358-1364&lt;/code&gt;&lt;/a&gt;&lt;/span&gt;&lt;/p&gt;

&lt;p&gt;And a step, in full (&lt;a href=&quot;https://github.com/vllm-project/vllm/blob/6a1acac3fe2924ca221fdf048b74f9a020dabddd/vllm/v1/engine/core.py#L576-L606&quot;&gt;&lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;core.py:576-606&lt;/code&gt;&lt;/a&gt;):&lt;/p&gt;

&lt;pre&gt;&lt;code class=&quot;language-mermaid&quot;&gt;flowchart LR
    A[&quot;has_requests()?&quot;] --&amp;gt;|no| Z[&quot;return, poll input queue&quot;]
    A --&amp;gt;|yes| B[&quot;scheduler.schedule()&amp;lt;br/&amp;gt;→ SchedulerOutput&quot;]
    B --&amp;gt; C[&quot;model_executor.execute_model()&amp;lt;br/&amp;gt;non_block=True&quot;]
    C --&amp;gt; D[&quot;get_grammar_bitmask()&amp;lt;br/&amp;gt;overlaps the GPU&quot;]
    D --&amp;gt; E[&quot;future.result()&amp;lt;br/&amp;gt;+ sample_tokens()&quot;]
    E --&amp;gt; F[&quot;scheduler.update_from_output()&amp;lt;br/&amp;gt;→ EngineCoreOutputs&quot;]
    F --&amp;gt; A
&lt;/code&gt;&lt;/pre&gt;

&lt;p&gt;&lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;SchedulerOutput&lt;/code&gt; is the entire contract between the scheduler and the GPU: which requests, how many tokens each, which KV blocks they own. The worker doesn’t know about queues or priorities. The scheduler doesn’t know about kernels. That seam is why vLLM can swap attention backends and hardware platforms without touching scheduling logic — and it’s the seam I’ll follow in the next few posts.&lt;/p&gt;

&lt;p&gt;Two details in &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;step()&lt;/code&gt; are worth naming, because they’re the kind of line you read straight through. &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;execute_model&lt;/code&gt; is called with &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;non_block=True&lt;/code&gt;, and the grammar bitmask for structured output is computed while the GPU is busy — CPU work deliberately overlapped with the forward pass. And aborts are drained &lt;em&gt;after&lt;/em&gt; the model returns but &lt;em&gt;before&lt;/em&gt; &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;update_from_output&lt;/code&gt;, so a client that disconnects mid-step doesn’t corrupt the accounting for the step already in flight.&lt;/p&gt;

&lt;h2 id=&quot;what-the-shape-buys-and-what-it-costs&quot;&gt;What the shape buys, and what it costs&lt;/h2&gt;

&lt;p&gt;The payoff for collapsing everything into one counter is that features that sound independent stop needing separate machinery. Chunked prefill is &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;min(gap, budget)&lt;/code&gt;. Prefix caching is starting a request with &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;num_computed_tokens&lt;/code&gt; already above zero. Speculative decoding is a gap of &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;1 + num_speculative_tokens&lt;/code&gt; instead of 1, which is why &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;num_tokens_with_spec&lt;/code&gt; and not &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;num_tokens&lt;/code&gt; is the target in the first place. Three features, one subtraction.&lt;/p&gt;

&lt;p&gt;The cost shows up in two places, and both are visible in the diagram.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Preemption is amnesia.&lt;/strong&gt; When the KV cache runs out of blocks, the scheduler evicts a running request. &lt;a href=&quot;https://github.com/vllm-project/vllm/blob/6a1acac3fe2924ca221fdf048b74f9a020dabddd/vllm/v1/core/sched/scheduler.py#L1203-L1225&quot;&gt;&lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;_preempt_request&lt;/code&gt;&lt;/a&gt; frees its blocks, sets &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;request.num_computed_tokens = 0&lt;/code&gt;, and prepends it to the waiting queue. There’s no swap to host memory on this path — the KV cache for those tokens is gone, and the request re-prefills its entire context from scratch. That’s request D in the diagram paying 8189 tokens at step N+6 for tokens it had already computed by step N. If your p99 has a cliff under load, the counter declared as &lt;a href=&quot;https://github.com/vllm-project/vllm/blob/6a1acac3fe2924ca221fdf048b74f9a020dabddd/vllm/v1/metrics/loggers.py#L661-L664&quot;&gt;&lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;vllm:num_preemptions&lt;/code&gt;&lt;/a&gt; — &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;vllm:num_preemptions_total&lt;/code&gt; once Prometheus suffixes it — is the first thing to look at, and the fix is usually more KV blocks or a lower &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;max_num_seqs&lt;/code&gt;, not a bigger batch.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Decode-only steps waste almost the entire budget.&lt;/strong&gt; Steps N+4 and N+7 schedule 3 and 4 tokens against a budget of 8192, and each one still pulls the model’s whole active weight set out of HBM — every weight, in a dense model. Prefill work is what fills that gap, which is the real argument for chunked prefill: not that long prompts get chopped up, but that the chopped pieces ride along in steps that were going to be memory-bound anyway.&lt;/p&gt;

&lt;p&gt;That budget number isn’t arbitrary either, and the way it’s chosen is a small lesson in not trusting defaults:&lt;/p&gt;

&lt;div class=&quot;language-python highlighter-rouge&quot;&gt;&lt;div class=&quot;highlight&quot;&gt;&lt;pre class=&quot;highlight&quot;&gt;&lt;code&gt;&lt;span class=&quot;k&quot;&gt;if&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;device_memory&lt;/span&gt; &lt;span class=&quot;o&quot;&gt;&amp;gt;=&lt;/span&gt; &lt;span class=&quot;mi&quot;&gt;70&lt;/span&gt; &lt;span class=&quot;o&quot;&gt;*&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;GiB_bytes&lt;/span&gt; &lt;span class=&quot;ow&quot;&gt;and&lt;/span&gt; &lt;span class=&quot;s&quot;&gt;&quot;a100&quot;&lt;/span&gt; &lt;span class=&quot;ow&quot;&gt;not&lt;/span&gt; &lt;span class=&quot;ow&quot;&gt;in&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;device_name&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;:&lt;/span&gt;
    &lt;span class=&quot;c1&quot;&gt;# For GPUs like H100 and MI300x, use larger default values.
&lt;/span&gt;    &lt;span class=&quot;n&quot;&gt;default_max_num_batched_tokens&lt;/span&gt; &lt;span class=&quot;o&quot;&gt;=&lt;/span&gt; &lt;span class=&quot;p&quot;&gt;{&lt;/span&gt;
        &lt;span class=&quot;n&quot;&gt;UsageContext&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;LLM_CLASS&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;:&lt;/span&gt; &lt;span class=&quot;mi&quot;&gt;16384&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt;
        &lt;span class=&quot;n&quot;&gt;UsageContext&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;OPENAI_API_SERVER&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;:&lt;/span&gt; &lt;span class=&quot;mi&quot;&gt;8192&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt;
    &lt;span class=&quot;p&quot;&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;/div&gt;
&lt;p&gt;&lt;span class=&quot;src&quot;&gt;&lt;a href=&quot;https://github.com/vllm-project/vllm/blob/6a1acac3fe2924ca221fdf048b74f9a020dabddd/vllm/engine/arg_utils.py#L2478-L2483&quot;&gt;&lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;arg_utils.py:2478-2483&lt;/code&gt;&lt;/a&gt;&lt;/span&gt;&lt;/p&gt;

&lt;p&gt;Same GPU class, but the offline &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;LLM&lt;/code&gt; path gets twice the budget the OpenAI server gets, because the server is optimizing for time-to-first-token and the offline path for throughput. And the A100 is carved out of the large default by a substring match on the device name, because raising the budget there &lt;em&gt;lowered&lt;/em&gt; throughput — &lt;a href=&quot;https://github.com/vllm-project/vllm/pull/17885&quot;&gt;PR #17885&lt;/a&gt; measured Llama-3.1-70B at TP=2 going from 1054 to 1438 tok/s (+36%) by making the batch smaller. One &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;if&lt;/code&gt; statement, three different answers, none of them in the flag’s help text.&lt;/p&gt;

&lt;h2 id=&quot;what-to-carry-into-the-rest-of-the-series&quot;&gt;What to carry into the rest of the series&lt;/h2&gt;

&lt;p&gt;If you take one thing from the loop: &lt;strong&gt;your request is not a unit of work in vLLM, it’s a row in a batch that is re-formed from scratch on every step.&lt;/strong&gt; The engine holds no plan for it. Each step it re-decides who runs, and the only state carried forward is a counter and a set of KV blocks.&lt;/p&gt;

&lt;p&gt;That reframing is what makes the rest of the system readable. “How much memory does my request use” becomes a question about the block pool. “Why did TTFT spike” becomes a question about how many steps your prompt waited for budget. “Why is my prefix cache not hitting” becomes a question about block hashes, which is the next post — the block pool and the flat dict that vLLM calls a prefix cache, and why it isn’t the radix tree you’re picturing.&lt;/p&gt;</content>
  </entry>
  <entry>
    <title type="html">Prefix-cache routing silently dies under disaggregation</title>
    <link href="https://raghavpotluri.com/writing/prefix-cache-routing-under-disaggregation/" rel="alternate" type="text/html" title="Prefix-cache routing silently dies under disaggregation" />
    <published>2026-07-15T00:00:00+00:00</published>
    <updated>2026-07-15T00:00:00+00:00</updated>
    <id>https://raghavpotluri.com/writing/prefix-cache-routing-under-disaggregation/</id>
    <author>
      <name>Raghav Potluri</name>
    </author>
    <summary type="html">Split vLLM into prefill/decode under Dynamo and the KV-events subject moves from component.backend to component.prefill. A router still on the old subject gets zero events and fails open — no error, only silent round-robin.</summary>
    <category term="dynamo" />
    <category term="vllm" />
    <category term="kv-cache" />
    <category term="prefix-caching" />
    <category term="disaggregation" />
    <category term="nats" />
    <content type="html" xml:base="https://raghavpotluri.com/writing/prefix-cache-routing-under-disaggregation/">&lt;p&gt;My prefix-cache hit rate was zero. Not low — zero, across every request and every workload, for hours. The router in front of the fleet was wired right, and its prefix index had 14,000 nodes in it. It took me too long to stop debugging the scorer and go read what Dynamo actually publishes, and where.&lt;/p&gt;

&lt;p&gt;The answer is a one-token subject mismatch that no log line will ever tell you about. If you route by prefix cache in front of disaggregated vLLM, you can hit this and never know — your routing quietly degrades to round-robin and everything still returns 200s.&lt;/p&gt;

&lt;h2 id=&quot;the-setup&quot;&gt;The setup&lt;/h2&gt;

&lt;p&gt;I run an external router in front of a Dynamo 1.2.1 deployment: prefix-aware placement, so a request lands on the worker that already holds its prompt prefix in KV and skips the recompute. To know &lt;em&gt;which&lt;/em&gt; worker holds &lt;em&gt;which&lt;/em&gt; prefix, the router builds a radix index from Dynamo’s KV-cache event plane — the &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;BlockStored&lt;/code&gt; / &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;BlockRemoved&lt;/code&gt; events vLLM emits and Dynamo republishes on NATS. Standard push model: workers publish cache state, the router subscribes and indexes it, routing reads the index.&lt;/p&gt;

&lt;p&gt;This worked in aggregated mode. Then I moved the same deployment to prefill/decode disaggregation — split the workers, one pool computes prefill and ships KV over NIXL, another pool decodes — and prefix affinity went dead.&lt;/p&gt;

&lt;h2 id=&quot;the-symptom-that-looks-like-a-scoring-bug&quot;&gt;The symptom that looks like a scoring bug&lt;/h2&gt;

&lt;p&gt;The router’s per-pod match-fraction metric was a clean zero on every worker, every request. Not noisy-low — exactly zero. My first instinct was the scorer: a sign flip, a bad normalization, a lookup keyed wrong. That was the wrong instinct, and I burned real time on it.&lt;/p&gt;

&lt;p&gt;Two facts eventually pointed away from the scorer:&lt;/p&gt;

&lt;ol&gt;
  &lt;li&gt;The index was &lt;strong&gt;not empty&lt;/strong&gt; — 14,000 nodes. So something was populating it.&lt;/li&gt;
  &lt;li&gt;Every node in it was keyed to a &lt;strong&gt;decode&lt;/strong&gt; worker. Zero prefill-worker keys.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;That second fact is its own small trap. In disaggregation the request’s routing primary is the decode worker (that’s where the response streams from), so the router’s own speculative fallback — the “I sent this prefix to worker X a moment ago, remember that” insert — was recording decode pods. But prefix affinity for &lt;em&gt;prefill&lt;/em&gt; placement needs to know which &lt;em&gt;prefill&lt;/em&gt; worker computed and cached the prefix. The index was full of the wrong role. And the authoritative signal that would have carried the right role — the engine’s own &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;BlockStored&lt;/code&gt; events — was nowhere in it.&lt;/p&gt;

&lt;p&gt;So the real question wasn’t “why is the scorer returning zero,” it was “why am I receiving zero KV-cache events.”&lt;/p&gt;

&lt;h2 id=&quot;read-the-source-not-the-tea-leaves&quot;&gt;Read the source, not the tea leaves&lt;/h2&gt;

&lt;p&gt;Dynamo builds the KV-event subject in one place. In &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;lib/llm/src/kv_router/indexer/subscriber.rs&lt;/code&gt;:&lt;/p&gt;

&lt;div class=&quot;language-rust highlighter-rouge&quot;&gt;&lt;div class=&quot;highlight&quot;&gt;&lt;pre class=&quot;highlight&quot;&gt;&lt;code&gt;&lt;span class=&quot;k&quot;&gt;let&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;kv_event_subject&lt;/span&gt; &lt;span class=&quot;o&quot;&gt;=&lt;/span&gt; &lt;span class=&quot;nd&quot;&gt;format!&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;
    &lt;span class=&quot;s&quot;&gt;&quot;namespace.{}.component.{}.{}&quot;&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt;
    &lt;span class=&quot;n&quot;&gt;component&lt;/span&gt;&lt;span class=&quot;nf&quot;&gt;.namespace&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;()&lt;/span&gt;&lt;span class=&quot;nf&quot;&gt;.name&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(),&lt;/span&gt;
    &lt;span class=&quot;n&quot;&gt;component&lt;/span&gt;&lt;span class=&quot;nf&quot;&gt;.name&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(),&lt;/span&gt;
    &lt;span class=&quot;n&quot;&gt;KV_EVENT_SUBJECT&lt;/span&gt;
&lt;span class=&quot;p&quot;&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;/div&gt;

&lt;p&gt;and &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;KV_EVENT_SUBJECT&lt;/code&gt; is &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;&quot;kv-events&quot;&lt;/code&gt; (&lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;lib/kv-router/src/protocols.rs&lt;/code&gt;). So the full subject is:&lt;/p&gt;

&lt;div class=&quot;language-plaintext highlighter-rouge&quot;&gt;&lt;div class=&quot;highlight&quot;&gt;&lt;pre class=&quot;highlight&quot;&gt;&lt;code&gt;namespace.&amp;lt;namespace&amp;gt;.component.&amp;lt;component&amp;gt;.kv-events
&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;/div&gt;

&lt;p&gt;The load-bearing token is &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;&amp;lt;component&amp;gt;&lt;/code&gt; — the worker’s Dynamo component name. And that name is not stable across serving topologies:&lt;/p&gt;

&lt;ul&gt;
  &lt;li&gt;Aggregated vLLM registers as component &lt;strong&gt;&lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;backend&lt;/code&gt;&lt;/strong&gt; — that’s the default (&lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;lib/backend-common/src/args.rs&lt;/code&gt;, &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;DYN_COMPONENT&lt;/code&gt; defaults to &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;&quot;backend&quot;&lt;/code&gt;; &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;components/src/dynamo/vllm/args.py&lt;/code&gt; sets &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;dynamo_config.component = &quot;backend&quot;&lt;/code&gt;).&lt;/li&gt;
  &lt;li&gt;A disaggregated deployment overrides it per role: &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;DYN_COMPONENT=prefill&lt;/code&gt; and &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;DYN_COMPONENT=decode&lt;/code&gt;.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;My subscription was still on &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;component.backend.kv-events&lt;/code&gt; from the aggregated setup. Under disaggregation nothing publishes to &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;backend&lt;/code&gt; anymore — the events are on &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;component.prefill.kv-events&lt;/code&gt; and &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;component.decode.kv-events&lt;/code&gt;. My subscriber matched nothing. Zero events, no error, index never learns a single prefill key.&lt;/p&gt;

&lt;h2 id=&quot;confirm-it-on-the-wire&quot;&gt;Confirm it on the wire&lt;/h2&gt;

&lt;p&gt;Reading the format is a hypothesis; the bus is the proof. A raw NATS subscribe on &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;&amp;gt;&lt;/code&gt; while firing a burst of identical-prefix requests, and there it is:&lt;/p&gt;

&lt;div class=&quot;language-plaintext highlighter-rouge&quot;&gt;&lt;div class=&quot;highlight&quot;&gt;&lt;pre class=&quot;highlight&quot;&gt;&lt;code&gt;  12  namespace.&amp;lt;ns&amp;gt;.component.prefill.kv-events      &amp;lt;== one per request
 150  namespace.&amp;lt;ns&amp;gt;.component.prefill.forward-pass-metrics
 135  namespace.&amp;lt;ns&amp;gt;.component.backend.forward-pass-metrics
  60  namespace.&amp;lt;ns&amp;gt;.kv_metrics
&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;/div&gt;

&lt;p&gt;Twelve requests, twelve kv-events, all on &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;component.prefill.kv-events&lt;/code&gt;. (The namespace token also carries a worker-hash suffix, so match it with a wildcard, not a literal.)&lt;/p&gt;

&lt;p&gt;Two things worth pinning here, because they surprised me:&lt;/p&gt;

&lt;ul&gt;
  &lt;li&gt;&lt;strong&gt;Only the prefill worker publishes prefix events (in a standard disagg config).&lt;/strong&gt; The decode worker is run with &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;--no-enable-prefix-caching&lt;/code&gt; — it receives KV over NIXL, it doesn’t own or recompute the prefix, so it emits nothing useful for prefix routing. All the affinity signal lives on the prefill side. If you’re going to subscribe to one role, it’s &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;prefill&lt;/code&gt;.&lt;/li&gt;
  &lt;li&gt;&lt;strong&gt;There’s a &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;backend&lt;/code&gt; component still on the bus&lt;/strong&gt; — but only for &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;forward-pass-metrics&lt;/code&gt;, not kv-events. Enough to make you think you’re on the right namespace while you’re on the wrong subject. Don’t let the presence of &lt;em&gt;a&lt;/em&gt; &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;backend.*&lt;/code&gt; subject convince you your kv-events subscription is live.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2 id=&quot;the-fix&quot;&gt;The fix&lt;/h2&gt;

&lt;p&gt;One line — point the subscription at the prefill component:&lt;/p&gt;

&lt;div class=&quot;language-plaintext highlighter-rouge&quot;&gt;&lt;div class=&quot;highlight&quot;&gt;&lt;pre class=&quot;highlight&quot;&gt;&lt;code&gt;component.backend.kv-events   →   component.prefill.kv-events
&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;/div&gt;

&lt;p&gt;Events start flowing on the next request. The index keys on prefill workers. And the behavior you actually wanted shows up immediately: fire the same prefix repeatedly and the requests concentrate on the one worker that cached it, instead of spraying across the pool. Match rate goes from a flat zero to tracking the real reuse in the workload.&lt;/p&gt;

&lt;h2 id=&quot;the-lesson&quot;&gt;The lesson&lt;/h2&gt;

&lt;p&gt;Prefix-cache routing is only as good as the event-plane wiring underneath it, and disaggregation quietly re-topics that event plane per role. The failure mode is the nasty kind: it &lt;strong&gt;fails open&lt;/strong&gt;. A wrong subject isn’t a crash or a refused connection — it’s a subscription that sits there healthy and receives nothing, so your smart router degrades to round-robin and every request still succeeds. Nothing in your logs says “you are no longer prefix-aware.”&lt;/p&gt;

&lt;p&gt;Concrete takeaways if you build or operate this:&lt;/p&gt;

&lt;ul&gt;
  &lt;li&gt;&lt;strong&gt;If your cache-hit rate is a clean zero, suspect the subject before you touch the scorer.&lt;/strong&gt; Zero is a plumbing signature, not a scoring one. A real scorer bug is usually noisy-wrong, not perfectly-zero.&lt;/li&gt;
  &lt;li&gt;&lt;strong&gt;The component name is part of your contract.&lt;/strong&gt; &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;backend&lt;/code&gt; for aggregated, &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;prefill&lt;/code&gt;/&lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;decode&lt;/code&gt; for disaggregated. If your subscriber hardcodes a component, it breaks silently the day someone splits the workers.&lt;/li&gt;
  &lt;li&gt;&lt;strong&gt;Subscribe to &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;prefill&lt;/code&gt; for prefix affinity.&lt;/strong&gt; In a standard disagg config the decode side has prefix caching off and publishes nothing you can route on.&lt;/li&gt;
  &lt;li&gt;&lt;strong&gt;Watch the bus, not only your metrics.&lt;/strong&gt; A 30-second raw NATS subscribe told me in one shot what hours of staring at a zero gauge did not.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Everything above is upstream Dynamo/vLLM behavior traced to its own source; observed on Dynamo 1.2.1, with the file references pinned to that release’s commit below so they don’t rot as line numbers drift.&lt;/p&gt;

&lt;hr /&gt;

&lt;p&gt;&lt;em&gt;Source references, Dynamo 1.2.1, pinned to &lt;a href=&quot;https://github.com/ai-dynamo/dynamo/commit/919682da679aa699d5bca9c872f4c1d9a530bbc0&quot;&gt;ai-dynamo/dynamo@&lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;919682d&lt;/code&gt;&lt;/a&gt;:&lt;/em&gt;&lt;/p&gt;

&lt;ul&gt;
  &lt;li&gt;&lt;em&gt;KV-event subject format — &lt;a href=&quot;https://github.com/ai-dynamo/dynamo/blob/919682da679aa699d5bca9c872f4c1d9a530bbc0/lib/llm/src/kv_router/indexer/subscriber.rs#L48&quot;&gt;&lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;kv_router/indexer/subscriber.rs#L48&lt;/code&gt;&lt;/a&gt;&lt;/em&gt;&lt;/li&gt;
  &lt;li&gt;&lt;em&gt;&lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;KV_EVENT_SUBJECT = &quot;kv-events&quot;&lt;/code&gt; — &lt;a href=&quot;https://github.com/ai-dynamo/dynamo/blob/919682da679aa699d5bca9c872f4c1d9a530bbc0/lib/kv-router/src/protocols.rs#L18&quot;&gt;&lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;kv-router/src/protocols.rs#L18&lt;/code&gt;&lt;/a&gt;&lt;/em&gt;&lt;/li&gt;
  &lt;li&gt;&lt;em&gt;&lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;DYN_COMPONENT&lt;/code&gt; default &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;&quot;backend&quot;&lt;/code&gt; — &lt;a href=&quot;https://github.com/ai-dynamo/dynamo/blob/919682da679aa699d5bca9c872f4c1d9a530bbc0/lib/backend-common/src/args.rs#L36&quot;&gt;&lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;backend-common/src/args.rs#L36&lt;/code&gt;&lt;/a&gt;&lt;/em&gt;&lt;/li&gt;
  &lt;li&gt;&lt;em&gt;component set to &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;&quot;backend&quot;&lt;/code&gt; — &lt;a href=&quot;https://github.com/ai-dynamo/dynamo/blob/919682da679aa699d5bca9c872f4c1d9a530bbc0/components/src/dynamo/vllm/args.py#L168&quot;&gt;&lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;vllm/args.py#L168&lt;/code&gt;&lt;/a&gt;&lt;/em&gt;&lt;/li&gt;
&lt;/ul&gt;</content>
  </entry>
  <entry>
    <title type="html">What your router actually knows about the KV cache</title>
    <link href="https://raghavpotluri.com/writing/what-your-router-knows-about-the-kv-cache/" rel="alternate" type="text/html" title="What your router actually knows about the KV cache" />
    <published>2026-07-15T00:00:00+00:00</published>
    <updated>2026-07-15T00:00:00+00:00</updated>
    <id>https://raghavpotluri.com/writing/what-your-router-knows-about-the-kv-cache/</id>
    <author>
      <name>Raghav Potluri</name>
    </author>
    <summary type="html">vLLM&#39;s KV-event stream never fires on a cache hit, and the caches under it aren&#39;t the radix-trie-plus-RCU design everyone whiteboards. Two corrections for anyone building a cache-aware router.</summary>
    <category term="vllm" />
    <category term="sglang" />
    <category term="llm-d" />
    <category term="kv-cache" />
    <category term="routing" />
    <content type="html" xml:base="https://raghavpotluri.com/writing/what-your-router-knows-about-the-kv-cache/">&lt;p&gt;vLLM’s KV-event stream never fires on a cache hit. The single most common thing a prefix cache does — reuse a block that’s already resident — puts nothing on the wire. If you’re building a cache-aware router on top of that stream, that’s the first thing to know, because it bounds what your router can know.&lt;/p&gt;

&lt;p&gt;vLLM emits exactly three events (&lt;a href=&quot;https://github.com/vllm-project/vllm/blob/42ae5e7ac61910815bf368da22f67a721179ee45/vllm/distributed/kv_events.py#L48-L108&quot;&gt;&lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;vllm/distributed/kv_events.py&lt;/code&gt;&lt;/a&gt;): &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;BlockStored&lt;/code&gt;, &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;BlockRemoved&lt;/code&gt;, &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;AllBlocksCleared&lt;/code&gt;. &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;BlockStored&lt;/code&gt; fires once, in &lt;a href=&quot;https://github.com/vllm-project/vllm/blob/42ae5e7ac61910815bf368da22f67a721179ee45/vllm/v1/core/block_pool.py#L211-L331&quot;&gt;&lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;cache_full_blocks()&lt;/code&gt;&lt;/a&gt;, the first time a full block is hashed and inserted. Eviction fires &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;BlockRemoved&lt;/code&gt;. A full &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;reset_prefix_cache()&lt;/code&gt; fires &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;AllBlocksCleared&lt;/code&gt;. That’s the entire vocabulary.&lt;/p&gt;

&lt;p&gt;The hit path is &lt;a href=&quot;https://github.com/vllm-project/vllm/blob/42ae5e7ac61910815bf368da22f67a721179ee45/vllm/v1/core/block_pool.py#L402-L417&quot;&gt;&lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;touch()&lt;/code&gt;&lt;/a&gt;. It runs when a request reuses blocks already in the cache: it bumps the ref count, pulls the block off the free list if it was idle, records a metric. It does not append to the event queue — there is no emit anywhere in the function. It’s called from &lt;a href=&quot;https://github.com/vllm-project/vllm/blob/42ae5e7ac61910815bf368da22f67a721179ee45/vllm/v1/core/single_type_kv_cache_manager.py#L229-L231&quot;&gt;&lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;allocate_new_computed_blocks()&lt;/code&gt;&lt;/a&gt; to touch the blocks a request just matched in the prefix cache, so they aren’t evicted.&lt;/p&gt;

&lt;p&gt;So a block announces itself once, when it’s first computed. Every reuse after that — the same request’s next segment, or a different request sharing the prefix — is invisible. A consumer building a global index from these events learns which blocks exist and on which pod. It learns nothing about how recently any of them was touched. That’s fine if all you want is “who has this prefix.” It’s a hole the moment you want to reason about staleness: the schema carries no recency signal, and the hit that would carry it never fires.&lt;/p&gt;

&lt;p&gt;SGLang has the same gap, in the same place — and that’s not convergence. Its &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;match_prefix()&lt;/code&gt; → &lt;a href=&quot;https://github.com/sgl-project/sglang/blob/dd2e4cdc9921043f15abff47006fed945eba3666/python/sglang/srt/mem_cache/radix_cache.py#L649-L673&quot;&gt;&lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;_match_prefix_helper()&lt;/code&gt;&lt;/a&gt; updates &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;last_access_time&lt;/code&gt; on every node it walks, and emits nothing. SGLang’s &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;kv_events&lt;/code&gt; code is a credited, near-verbatim port of vLLM’s: &lt;a href=&quot;https://github.com/sgl-project/sglang/pull/6098&quot;&gt;PR #6098&lt;/a&gt;’s own description says “Event classes and publisher code borrowed from vllm-project/vllm#16750.” Same class names, same fields, same &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;buffer_steps=10_000&lt;/code&gt;, same &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;hwm=100_000&lt;/code&gt;, same port 5557. The blind spot isn’t a vLLM oversight you switch engines to escape. It’s a property of the shared design. Both engines model the stream as births and deaths of blocks, never traffic on blocks.&lt;/p&gt;

&lt;p&gt;One more thing the stream won’t hand you: recovery has a floor. It isn’t pure fire-and-forget — the publisher pairs a &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;zmq.PUB&lt;/code&gt; socket with a &lt;a href=&quot;https://github.com/vllm-project/vllm/blob/42ae5e7ac61910815bf368da22f67a721179ee45/vllm/distributed/kv_events.py#L278-L470&quot;&gt;&lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;zmq.ROUTER&lt;/code&gt; for replay&lt;/a&gt;, stamps each batch with a sequence number, and keeps the last 10,000 batches in a ring buffer, so a subscriber that sees a gap can request a replay from its last sequence. But if the gap is older than that window, there’s no snapshot to fall back on — NVIDIA’s own &lt;a href=&quot;https://github.com/ai-dynamo/dynamo/blob/81b73698c6d687321e436f654b3c89f69483ff79/docs/components/router/kv-event-replay-comparison.md&quot;&gt;Dynamo-vs-vLLM replay comparison&lt;/a&gt; says vLLM’s consumer has “no built-in initial state sync” and “must rebuild state through other means.” Dynamo’s own indexer has that fallback: ask for a range that’s too old and you get a &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;TreeDump&lt;/code&gt;, the full RadixTree as synthetic events. vLLM and SGLang have no &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;TreeDump&lt;/code&gt;. That was deliberate — &lt;a href=&quot;https://github.com/vllm-project/vllm/pull/16750&quot;&gt;PR #16750&lt;/a&gt; existed specifically to avoid pulling Dynamo’s Rust/NATS publisher into vLLM as a dependency — but it means the two ecosystems give you different reliability guarantees, not just different transports. No recency on hits, and no full resync past the buffer window. Build your index to tolerate both.&lt;/p&gt;

&lt;h2 id=&quot;the-structure-under-it-isnt-a-radix-trie-with-rcu&quot;&gt;The structure under it isn’t a radix trie with RCU&lt;/h2&gt;

&lt;p&gt;Ask how these caches are built and you’ll hear “radix trie with RCU” — a prefix tree, lock-free reads racing a writer that publishes new versions. Clean mental model. It’s wrong for two of the three canonical open-source implementations, and the third doesn’t use RCU.&lt;/p&gt;

&lt;p&gt;SGLang is the only one that’s actually a radix tree (&lt;a href=&quot;https://github.com/sgl-project/sglang/blob/dd2e4cdc9921043f15abff47006fed945eba3666/python/sglang/srt/mem_cache/radix_cache.py&quot;&gt;&lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;radix_cache.py&lt;/code&gt;&lt;/a&gt;). And it has no locks — not lock-free-as-in-RCU, no concurrency primitives at all. Grep &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;radix_cache.py&lt;/code&gt; and the C++ port (&lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;tree_v2.{h,cpp}&lt;/code&gt;) for &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;mutex|lock|rcu|atomic&lt;/code&gt; and the only hit is &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;lock_ref&lt;/code&gt;, which is a reference count for eviction, not a mutex. It gets away with that because the tree is never shared across threads: SGLang’s scheduler owns it and drives match, insert, and evict from a single loop. There’s no reader/writer race to solve, so there’s no lock.&lt;/p&gt;

&lt;p&gt;vLLM v1 isn’t a tree at all. Its prefix cache is a flat dict — &lt;a href=&quot;https://github.com/vllm-project/vllm/blob/42ae5e7ac61910815bf368da22f67a721179ee45/vllm/v1/core/block_pool.py#L34-L127&quot;&gt;&lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;BlockHashToBlockMap&lt;/code&gt;&lt;/a&gt;, hash → block, degrading to a nested dict only on hash collision. Same single-writer story: one &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;EngineCore&lt;/code&gt; process, one scheduling loop, no lock.&lt;/p&gt;

&lt;p&gt;The one component that genuinely has a concurrency problem is llm-d’s cross-pod indexer (&lt;a href=&quot;https://github.com/llm-d/llm-d-kv-cache&quot;&gt;&lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;llm-d/llm-d-kv-cache&lt;/code&gt;&lt;/a&gt;) — because that’s where many pods’ event streams write while many routing decisions read. It’s not a tree, and it’s not RCU. It’s a two-level LRU hash map with sharded locks: &lt;a href=&quot;https://github.com/llm-d/llm-d-kv-cache/blob/f48eb79575c897cd2246d3c02607cb251a8bd94b/pkg/kvcache/kvblock/in_memory.go#L79-L151&quot;&gt;&lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;InMemoryIndex&lt;/code&gt;&lt;/a&gt; scopes each mutex to a single key, &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;Lookup&lt;/code&gt; takes no global lock, and the write path runs &lt;a href=&quot;https://github.com/llm-d/llm-d-kv-cache/blob/f48eb79575c897cd2246d3c02607cb251a8bd94b/pkg/kvevents/pool.go#L180-L192&quot;&gt;FNV-1a-sharded workqueues&lt;/a&gt; so events for one pod stay ordered while different pods parallelize. Textbook sharded-lock table, not a versioned structure with grace-period reclamation. Nobody solved lock-free concurrent tree reads here. They sidestepped the problem three different ways: single writer thread, SPMD replication, per-key locks.&lt;/p&gt;

&lt;h2 id=&quot;what-actually-makes-it-interoperate-is-the-hash-not-the-tree&quot;&gt;What actually makes it interoperate is the hash, not the tree&lt;/h2&gt;

&lt;p&gt;The thing that lets a router index another engine’s cache isn’t the data structure — it’s the block hash. vLLM keys each block by a &lt;a href=&quot;https://github.com/vllm-project/vllm/blob/42ae5e7ac61910815bf368da22f67a721179ee45/vllm/v1/core/kv_cache_utils.py#L563-L589&quot;&gt;chained hash&lt;/a&gt; over &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;(parent_block_hash, token_chunk, extra_keys)&lt;/code&gt;, &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;sha256_cbor&lt;/code&gt; or &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;xxhash_cbor&lt;/code&gt;, seeded off &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;PYTHONHASHSEED&lt;/code&gt;. llm-d’s indexer recomputes the same shape — FNV-64a over the CBOR encoding of &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;(parent, chunk, extra)&lt;/code&gt;, block size 16 — and its config &lt;a href=&quot;https://github.com/llm-d/llm-d-kv-cache/blob/f48eb79575c897cd2246d3c02607cb251a8bd94b/pkg/kvcache/kvblock/token_processor.go#L43-L47&quot;&gt;explicitly aligns the seed&lt;/a&gt; to vLLM’s &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;PYTHONHASHSEED&lt;/code&gt;. That wire-compatibility is the load-bearing part. The index is a map of hash → pod, and it works only because two separate codebases agree, byte for byte, on how to hash a block.&lt;/p&gt;

&lt;p&gt;I came at this from the metrics side. My merged PR to &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;llm-d-inference-sim&lt;/code&gt; (&lt;a href=&quot;https://github.com/llm-d/llm-d-inference-sim/pull/358&quot;&gt;#358&lt;/a&gt;) adds &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;vllm:prefix_cache_hits&lt;/code&gt; and &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;vllm:prefix_cache_queries&lt;/code&gt; counters, so &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;rate(hits) / rate(queries)&lt;/code&gt; reproduces vLLM’s token-level cache-hit rate in Prometheus. The simulator’s synthetic cache doesn’t model a radix tree — it calls llm-d’s own chunked-hash token processor (&lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;TokensToKVBlockKeys&lt;/code&gt;) to turn tokens into block keys. Even the simulator is built on hash-chain block indexing. Once you leave SGLang, the trie is the exception, not the rule.&lt;/p&gt;

&lt;p&gt;Two corrections to carry if you’re building on this stack. The event stream tells you a block was born and when it died, never that it was used, and it won’t resync you past its buffer — so hold recency and staleness in your own logic, not the stream’s. And the structure underneath is probably a sharded hash map, not a radix trie — the interop that lets your router index another engine’s cache lives in the block hash.&lt;/p&gt;</content>
  </entry>
</feed>
