<?xml version="1.0" encoding="utf-8" standalone="yes"?><rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom"><channel><title>Preemption on lo0 — Tech Blog</title><link>https://blog.lo0.es/en/tags/preemption/</link><description>Recent content in Preemption on lo0 — Tech Blog</description><generator>Hugo -- gohugo.io</generator><language>en</language><lastBuildDate>Mon, 08 Jun 2026 05:00:00 +0200</lastBuildDate><atom:link href="https://blog.lo0.es/en/tags/preemption/index.xml" rel="self" type="application/rss+xml"/><item><title>The pass: the maître d' who builds every round — vLLM's scheduler step</title><link>https://blog.lo0.es/en/posts/vllm-scheduler-step-maitre-d-each-round/</link><pubDate>Mon, 08 Jun 2026 05:00:00 +0200</pubDate><guid>https://blog.lo0.es/en/posts/vllm-scheduler-step-maitre-d-each-round/</guid><description>&lt;blockquote>
&lt;p>Part of the &lt;em>under the engine&lt;/em> series. Earlier posts looked at the silicon that runs the kernels (&lt;a href="https://blog.lo0.es/en/posts/sms-cuda-streams-cuda-graphs-inference/">SMs and CUDA graphs&lt;/a>) and at loading the weights (&lt;a href="https://blog.lo0.es/en/posts/pantry-lift-from-disk-to-hbm-why-the-kitchen-opens-late/">from disk to HBM&lt;/a>). This one goes up a floor: &lt;em>who decides&lt;/em> what runs in each forward. Before the GPU launches a single kernel, someone has had to build this round&amp;rsquo;s order. That someone is the &lt;strong>scheduler&lt;/strong>, and it is the heart of &lt;a href="https://blog.lo0.es/en/posts/continuous-batching-orca-vllm-chunked-prefill-goodput/">continuous batching&lt;/a>.&lt;/p>
&lt;/blockquote>
&lt;h2 id="tldr">TL;DR&lt;/h2>
&lt;p>An LLM server does not serve one whole request and then the next: it advances every live request at once, a little on each engine iteration. The piece that decides &lt;em>how far&lt;/em> each one advances in each step is the &lt;strong>scheduler&lt;/strong>, and its output is deceptively simple: a dictionary &lt;code>{req_id: number of tokens}&lt;/code> that the model runner turns into a single forward on the GPU. vLLM V1&amp;rsquo;s most important decision was to &lt;strong>erase the distinction between prefill and decode&lt;/strong>: for the scheduler, a prompt token and a freshly generated token are the same thing, tokens that need processing, and that is why it can put a 4000-token prompt in the same batch as 200 one-token decodes. There are four pieces: the &lt;strong>token budget&lt;/strong> (&lt;code>max_num_batched_tokens&lt;/code>), a fixed-size tray filled every round; &lt;strong>chunked prefill&lt;/strong>, which splits a huge prompt into chunks so it does not hog the tray and blow up everyone else&amp;rsquo;s latency; the two &lt;strong>queues&lt;/strong> (waiting, in arrival order, and running); and &lt;strong>preemption&lt;/strong>, because when the KV cache runs out somebody has to get off the train. This post explains the loop, the arithmetic of the budget and of concurrency, the 10 knobs and the headline trap: raising the budget improves &lt;em>throughput&lt;/em> but worsens &lt;em>ITL&lt;/em> (inter-token latency), and almost nobody measures both at once. On the generic 4×H100 SXM cluster.&lt;/p>
&lt;h2 id="where-you-are-the-pass-before-the-kitchen-starts">Where you are: the pass, before the kitchen starts&lt;/h2>
&lt;p>Picture the pass of a restaurant with a busy dining room. There is no one cook per customer; there is a shared kitchen and a &lt;strong>maître d&amp;rsquo;&lt;/strong> who, every few seconds, looks at all the open orders and builds a &lt;strong>tray&lt;/strong> to send to the stoves. That tray holds, say, 8000 &amp;ldquo;units of work&amp;rdquo;. The maître d&amp;rsquo; decides what goes in: tables already eating (requests in &lt;em>decode&lt;/em>) get one more dish each; new tables that have just ordered (requests in &lt;em>prefill&lt;/em>, with their whole prompt still to process) get as much of their order as fits in whatever is left of the tray. The tray goes out, the kitchen executes it in one go, and it starts again. Hundreds of times per second.&lt;/p>
&lt;p>That maître d&amp;rsquo; is the &lt;strong>scheduler&lt;/strong>. The kitchen is the GPU running a &lt;em>forward pass&lt;/em>. And the house rule is that the kitchen never stops to wait for a single table: if a new order is enormous (a 30,000-token prompt), it is not sent whole in one go blocking everyone else, it is sent in chunks. That is, in one sentence, the whole mechanics of vLLM&amp;rsquo;s scheduler.&lt;/p>
&lt;h2 id="the-engine-loop-one-dictionary-per-iteration">The engine loop: one dictionary per iteration&lt;/h2>
&lt;p>The inference engine is a very short loop. On each pass:&lt;/p>
&lt;ol>
&lt;li>The &lt;strong>scheduler&lt;/strong> looks at the queues and produces a decision.&lt;/li>
&lt;li>The &lt;strong>model runner&lt;/strong> runs a forward with that batch on the GPU.&lt;/li>
&lt;li>The &lt;strong>sampler&lt;/strong> draws one new token for each active sequence.&lt;/li>
&lt;li>State is updated (KV cache, positions, finished requests) and it goes back to 1.&lt;/li>
&lt;/ol>
&lt;p>The surprising part is the shape of the decision in step 1. In vLLM V1 it is not a complex structure with phases: it is literally a dictionary&lt;/p>
$$\text{schedule} = \{\, \text{req id} \rightarrow n_\text{tokens} \,\}$$
&lt;p>that says, for each request entering this round, how many tokens of it get processed. For a request that is generating text, &lt;code>n_tokens = 1&lt;/code> (one autoregressive step). For a new request, &lt;code>n_tokens&lt;/code> can be the entire length of its prompt. And it can be anything in between, a &lt;em>chunk&lt;/em> of prompt, under chunked prefill, prefix caching or speculative decoding (&lt;a href="https://docs.vllm.ai/en/v0.9.2/usage/v1_guide.html">vLLM V1 docs&lt;/a>).&lt;/p>
&lt;svg viewBox="0 0 720 250" xmlns="http://www.w3.org/2000/svg" font-family="sans-serif" font-size="13" role="img" aria-label="Inference engine loop with the scheduler">
&lt;rect x="20" y="100" width="120" height="50" rx="6" fill="none" stroke="currentColor" stroke-width="1.5"/>
&lt;text x="80" y="122" text-anchor="middle" fill="currentColor">Scheduler&lt;/text>
&lt;text x="80" y="140" text-anchor="middle" fill="#2563eb" font-size="11">{req_id: n_tokens}&lt;/text>
&lt;rect x="200" y="100" width="120" height="50" rx="6" fill="none" stroke="currentColor" stroke-width="1.5"/>
&lt;text x="260" y="122" text-anchor="middle" fill="currentColor">Model runner&lt;/text>
&lt;text x="260" y="140" text-anchor="middle" fill="#16a34a" font-size="11">1 forward (GPU)&lt;/text>
&lt;rect x="380" y="100" width="120" height="50" rx="6" fill="none" stroke="currentColor" stroke-width="1.5"/>
&lt;text x="440" y="122" text-anchor="middle" fill="currentColor">Sampler&lt;/text>
&lt;text x="440" y="140" text-anchor="middle" fill="#16a34a" font-size="11">+1 token / seq&lt;/text>
&lt;rect x="560" y="100" width="130" height="50" rx="6" fill="none" stroke="currentColor" stroke-width="1.5"/>
&lt;text x="625" y="122" text-anchor="middle" fill="currentColor">Update&lt;/text>
&lt;text x="625" y="140" text-anchor="middle" fill="currentColor" font-size="11">KV, positions&lt;/text>
&lt;path d="M140 125 H200" stroke="currentColor" stroke-width="1.5" marker-end="url(#a)"/>
&lt;path d="M320 125 H380" stroke="currentColor" stroke-width="1.5" marker-end="url(#a)"/>
&lt;path d="M500 125 H560" stroke="currentColor" stroke-width="1.5" marker-end="url(#a)"/>
&lt;path d="M625 100 V60 H80 V100" stroke="currentColor" stroke-width="1.5" fill="none" marker-end="url(#a)"/>
&lt;text x="350" y="48" text-anchor="middle" fill="currentColor" font-size="11">one iteration = one step (ms order on H100)&lt;/text>
&lt;defs>&lt;marker id="a" markerWidth="8" markerHeight="8" refX="7" refY="4" orient="auto">&lt;path d="M0 0 L8 4 L0 8 z" fill="currentColor"/>&lt;/marker>&lt;/defs>
&lt;/svg>
&lt;p>That the decision fits in a &lt;code>{id: number}&lt;/code> dictionary is not a neat implementation detail: it is &lt;strong>why continuous batching works&lt;/strong>. Because the scheduler does not think in &amp;ldquo;phases&amp;rdquo; but in &amp;ldquo;how many tokens for each&amp;rdquo;, it can mix requests at any point of their life in the same forward. The GPU receives a single heterogeneous token tensor and processes it in one go.&lt;/p>
&lt;h2 id="the-death-of-the-prefilldecode-distinction">The death of the prefill/decode distinction&lt;/h2>
&lt;p>This is the idea that costs the most effort and matters the most. In the first LLM server architectures, a request lived in &lt;strong>two separate phases&lt;/strong>: first &lt;em>prefill&lt;/em> (process the whole prompt and fill the &lt;a href="https://blog.lo0.es/en/posts/kv-cache-working-memory-llm-inference/">KV cache&lt;/a>), then &lt;em>decode&lt;/em> (generate token by token). The scheduler had to choreograph the move from one phase to the other, and mixing them was hard.&lt;/p>
&lt;p>vLLM V1 &lt;strong>removed the distinction&lt;/strong> (&lt;a href="https://openlm.ai/vllm-v1/">V1 design&lt;/a>). The scheduler treats prompt tokens and generated tokens uniformly: they are all tokens the model has to process in a forward. The practical consequence is enormous. A 4000-token prompt and a sequence that has generated 800 tokens and needs one more are, to the scheduler, &amp;ldquo;4000 tokens of request A&amp;rdquo; and &amp;ldquo;1 token of request B&amp;rdquo;. They fit together on the same tray. There is no phase choreography, just a budget to hand out.&lt;/p>
&lt;p>This unlocks the pattern that really pays: &lt;strong>mixing prefill and decode in every step&lt;/strong>. Prefill is &lt;em>compute-bound&lt;/em> work (a lot of matrix to multiply); decode is &lt;em>memory-bound&lt;/em> (little compute, a lot of KV to move). Mixing them in the same batch fills the gaps: while the GPU is busy with the heavy prefill, it advances the light decodes &amp;ldquo;for free&amp;rdquo;. It is the same efficiency principle as &lt;a href="https://blog.lo0.es/en/posts/continuous-batching-orca-vllm-chunked-prefill-goodput/">continuous batching&lt;/a>, taken to its cleanest form.&lt;/p>
&lt;h2 id="the-token-budget-the-fixed-size-tray">The token budget: the fixed-size tray&lt;/h2>
&lt;p>The tray has a size: &lt;code>max_num_batched_tokens&lt;/code>. It is the maximum number of tokens the scheduler can put in a single step (&lt;a href="https://docs.vllm.ai/en/stable/configuration/optimization/">vLLM optimisation&lt;/a>). The policy, with chunked prefill on (it always is in V1), is clear:&lt;/p>
&lt;ol>
&lt;li>&lt;strong>Decodes first.&lt;/strong> Room is reserved for one token per request in the &lt;em>running&lt;/em> queue. They are cheap, and they are diners already eating: they are not left waiting.&lt;/li>
&lt;li>&lt;strong>What is left goes to prefills.&lt;/strong> With the remaining budget, prompt tokens from &lt;em>waiting&lt;/em> requests are added, in arrival order (FCFS), split into chunks if needed.&lt;/li>
&lt;/ol>
&lt;p>An example with numbers. Budget &lt;code>max_num_batched_tokens = 8192&lt;/code>, and at this instant there are 200 requests generating:&lt;/p>
$$\text{decode} = 200 \times 1 = 200 \text{ tokens}$$
$$\text{free budget} = 8192 - 200 = 7992 \text{ tokens for prefill}$$
&lt;p>If a new request arrives with a 4000-token prompt, it fits whole in this step (4000 &amp;lt; 7992) and 3992 are left for another. If one arrives with 30,000 tokens, it does not fit: the scheduler sends it a 7992-token chunk this step, and the remaining 22,008 in later steps. That is chunked prefill.&lt;/p>
&lt;svg viewBox="0 0 720 170" xmlns="http://www.w3.org/2000/svg" font-family="sans-serif" font-size="12" role="img" aria-label="Token budget split within a step">
&lt;text x="20" y="24" fill="currentColor" font-size="13">Tray: max_num_batched_tokens = 8192 tokens/step&lt;/text>
&lt;rect x="20" y="40" width="680" height="40" fill="none" stroke="currentColor" stroke-width="1.5"/>
&lt;rect x="20" y="40" width="20" height="40" fill="#2563eb"/>
&lt;rect x="40" y="40" width="660" height="40" fill="#16a34a" opacity="0.75"/>
&lt;text x="30" y="100" text-anchor="middle" fill="#2563eb" font-size="10">200 decode&lt;/text>
&lt;text x="370" y="100" text-anchor="middle" fill="#16a34a" font-size="11">7992 for prefill (a 4000-token prompt fits whole; a 30000-token one goes in chunks)&lt;/text>
&lt;text x="20" y="140" fill="currentColor">Decode first (diners eating) · prefill fills the rest (FCFS) · if it does not fit, it is chunked&lt;/text>
&lt;/svg>
&lt;p>The budget matters because it fixes how many forwards are needed to swallow a prompt. A 30,000-token prompt with an 8192 tray takes 4 steps in prefill alone before it emits its first token. With a 2048 tray it takes 15 steps, but each of those steps leaves more room for other people&amp;rsquo;s decodes, so the other diners feel the jam less.&lt;/p>
&lt;h2 id="the-two-queues-and-preemption-when-someone-gets-off-the-train">The two queues and preemption: when someone gets off the train&lt;/h2>
&lt;p>The scheduler handles two queues. The &lt;strong>waiting&lt;/strong> queue holds requests that have not started yet (their prompt has not been processed), served in arrival order, FCFS by default, though there is a &lt;code>priority&lt;/code> policy. The &lt;strong>running&lt;/strong> queue holds those already alive and generating (&lt;a href="https://audreywongkg.medium.com/understanding-vllm-scheduling-token-budgets-chunked-prefill-and-policies-2c879e3980e3">vLLM scheduling&lt;/a>).&lt;/p>
&lt;p>There is a second budget, harder than the token one: the &lt;strong>KV cache&lt;/strong>. Every live token occupies KV blocks in HBM, and they are finite (&lt;code>gpu_memory_utilization&lt;/code> fixes them). When the scheduler wants to advance the running requests but there are no free blocks for the next token&amp;rsquo;s KV, someone has to get off: that is &lt;strong>preemption&lt;/strong>.&lt;/p>
&lt;p>vLLM V1 preempts by &lt;strong>RECOMPUTE&lt;/strong> by default, not by SWAP (&lt;a href="https://docs.vllm.ai/en/v0.9.2/usage/v1_guide.html">V1 guide&lt;/a>). The difference:&lt;/p>
&lt;ul>
&lt;li>&lt;strong>SWAP&lt;/strong>: copy the victim&amp;rsquo;s KV to host RAM and bring it back later. It moves gigabytes across PCIe (see &lt;a href="https://blog.lo0.es/en/posts/pcie-topology-gpudirect-p2p-acs-security-guard/">the PCIe and P2P post&lt;/a>).&lt;/li>
&lt;li>&lt;strong>RECOMPUTE&lt;/strong>: throw the victim&amp;rsquo;s KV away and, when there is room again, redo its prefill from scratch. It sounds expensive, but in the V1 architecture it works out cheaper than swapping, because prefill is work the GPU does very fast and you save the round trip across the bus.&lt;/li>
&lt;/ul>
&lt;p>The victim is usually the &lt;strong>newest&lt;/strong> request in the running queue (so as not to penalise whoever has been waiting longest for their answer). The danger is &lt;strong>thrashing&lt;/strong>: admit too many requests at once and the system enters a preempt-recompute-preempt cycle that drives throughput into the floor. That is why the second ceiling exists.&lt;/p>
&lt;h2 id="the-arithmetic-that-matters-concurrency-and-the-budget-trade-off">The arithmetic that matters: concurrency and the budget trade-off&lt;/h2>
&lt;p>&lt;strong>How many requests fit at once.&lt;/strong> The real concurrency limit is not &lt;code>max_num_seqs&lt;/code> (the nominal ceiling on simultaneous sequences); it is usually the &lt;strong>KV cache&lt;/strong>. If a node has $B$ free KV blocks, each block holds $b$ tokens (16 by default), and each request occupies $L$ context tokens on average, the maximum sustainable concurrency is:&lt;/p>
$$N_\text{max} \approx \frac{B \cdot b}{L}$$
&lt;p>Take a 70B model in FP16 on 4×H100 SXM (320 GB), with the bulk of HBM in weights and, say, ~120 GB free for KV. With a KV of ~0.3 MB/token (an order-of-magnitude figure, it depends on layers and heads), that is ~400,000 KV tokens. With average contexts of 4000 tokens:&lt;/p>
$$N_\text{max} \approx \frac{400000}{4000} = 100 \text{ concurrent requests}$$
&lt;p>Raising &lt;code>max_num_seqs&lt;/code> to 400 does not give you 400 concurrent: it gives you preemption and thrashing as soon as contexts grow. The KV rules.&lt;/p>
&lt;p>&lt;strong>The budget trade-off.&lt;/strong> Raising &lt;code>max_num_batched_tokens&lt;/code> puts more work in each forward, so fewer forwards for the same total work, and more &lt;em>throughput&lt;/em>. But a large budget lets a huge prefill occupy almost the whole tray in one step, and that step takes longer, so everyone else&amp;rsquo;s decodes wait for that entire step, and &lt;strong>ITL&lt;/strong> (inter-token latency) rises for everybody. The rule of thumb (&lt;a href="https://docs.vllm.ai/en/stable/configuration/optimization/">vLLM optimisation&lt;/a>):&lt;/p>
&lt;table>
&lt;thead>
&lt;tr>
&lt;th>Budget&lt;/th>
&lt;th>Effect&lt;/th>
&lt;th>At the cost of&lt;/th>
&lt;/tr>
&lt;/thead>
&lt;tbody>
&lt;tr>
&lt;td>Low (e.g. 2048)&lt;/td>
&lt;td>more interleaving, stable ITL&lt;/td>
&lt;td>less peak throughput&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>High (e.g. 16384)&lt;/td>
&lt;td>maximum throughput&lt;/td>
&lt;td>ITL spikes when a large prefill arrives&lt;/td>
&lt;/tr>
&lt;/tbody>
&lt;/table>
&lt;p>There is no &amp;ldquo;correct&amp;rdquo; value: there is a point for your load. And you only find it by &lt;strong>measuring throughput and ITL at the same time&lt;/strong>, which is exactly what almost nobody does.&lt;/p>
&lt;h2 id="the-schedulers-10-knobs">The scheduler&amp;rsquo;s 10 knobs&lt;/h2>
&lt;table>
&lt;thead>
&lt;tr>
&lt;th>#&lt;/th>
&lt;th>Knob&lt;/th>
&lt;th>What it controls&lt;/th>
&lt;th>Cost if you overdo it&lt;/th>
&lt;/tr>
&lt;/thead>
&lt;tbody>
&lt;tr>
&lt;td>1&lt;/td>
&lt;td>&lt;code>max_num_batched_tokens&lt;/code>&lt;/td>
&lt;td>tray size per step&lt;/td>
&lt;td>high ITL if too large&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>2&lt;/td>
&lt;td>&lt;code>max_num_seqs&lt;/code>&lt;/td>
&lt;td>nominal concurrency ceiling&lt;/td>
&lt;td>preemption if the KV falls short&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>3&lt;/td>
&lt;td>&lt;code>gpu_memory_utilization&lt;/code>&lt;/td>
&lt;td>KV blocks available&lt;/td>
&lt;td>OOM if too high&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>4&lt;/td>
&lt;td>chunked prefill (threshold)&lt;/td>
&lt;td>prompt chunk size&lt;/td>
&lt;td>chunking overhead if too fine&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>5&lt;/td>
&lt;td>policy (&lt;code>fcfs&lt;/code>/&lt;code>priority&lt;/code>)&lt;/td>
&lt;td>who gets served first&lt;/td>
&lt;td>starvation of low priority&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>6&lt;/td>
&lt;td>preemption mode&lt;/td>
&lt;td>RECOMPUTE vs SWAP&lt;/td>
&lt;td>PCIe traffic / recompute&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>7&lt;/td>
&lt;td>&lt;code>enable_prefix_caching&lt;/code>&lt;/td>
&lt;td>reuse prefix KV&lt;/td>
&lt;td>little; almost always on&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>8&lt;/td>
&lt;td>&lt;code>max_model_len&lt;/code>&lt;/td>
&lt;td>maximum context per request&lt;/td>
&lt;td>pessimistic KV reservation&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>9&lt;/td>
&lt;td>CUDA graph sizes&lt;/td>
&lt;td>align batch with buckets&lt;/td>
&lt;td>padding / capture (see below)&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>10&lt;/td>
&lt;td>speculative tokens&lt;/td>
&lt;td>extra tokens per step&lt;/td>
&lt;td>wasted work if acceptance drops&lt;/td>
&lt;/tr>
&lt;/tbody>
&lt;/table>
&lt;h2 id="how-it-connects-to-the-rest-of-the-stack">How it connects to the rest of the stack&lt;/h2>
&lt;p>&lt;strong>With continuous batching.&lt;/strong> The scheduler &lt;em>is&lt;/em> continuous batching turned into code. The &lt;a href="https://blog.lo0.es/en/posts/continuous-batching-orca-vllm-chunked-prefill-goodput/">batching post&lt;/a> explains the &lt;em>what&lt;/em> (advance every request at once); this one explains the &lt;em>how&lt;/em> (the per-step token dictionary).&lt;/p>
&lt;p>&lt;strong>With the KV cache and the block manager.&lt;/strong> The second budget, the blocks, is managed by the &lt;a href="https://blog.lo0.es/en/posts/pagedattention-vllm-block-manager/">PagedAttention block manager&lt;/a>. The scheduler asks for blocks; if there are none, it preempts. The two pieces are coupled through memory.&lt;/p>
&lt;p>&lt;strong>With CUDA graphs.&lt;/strong> &lt;a href="https://blog.lo0.es/en/posts/sms-cuda-streams-cuda-graphs-inference/">CUDA graphs&lt;/a> are captured for specific batch sizes (&lt;em>buckets&lt;/em>). The scheduler should produce batches whose size lands in those buckets to avoid padding; otherwise part of the graph&amp;rsquo;s benefit is lost.&lt;/p>
&lt;p>&lt;strong>With chunked prefill and the prefix cache.&lt;/strong> Chunking a prompt interacts with &lt;a href="https://blog.lo0.es/en/posts/prefix-cache-hit-rate-engineering/">prefix caching&lt;/a>: chunks that match an already cached prefix skip the compute, and the scheduler reflects that by lowering that request&amp;rsquo;s &lt;code>n_tokens&lt;/code>.&lt;/p>
&lt;p>&lt;strong>With speculative decoding.&lt;/strong> &lt;a href="https://blog.lo0.es/en/posts/speculative-decoding-fundamentals-maths-state-of-play/">Speculative decoding&lt;/a> makes a step verify several tokens at once; the scheduler models it as &lt;code>n_tokens &amp;gt; 1&lt;/code> for a request in decode.&lt;/p>
&lt;p>&lt;strong>With disaggregated serving.&lt;/strong> In &lt;a href="https://blog.lo0.es/en/posts/disaggregated-serving-prefill-decode-specialised-pods/">disaggregated prefill/decode&lt;/a> there are two schedulers, one per pool, each with its own budget; the phase distinction that V1 erased &lt;em>inside&lt;/em> an engine reappears &lt;em>between&lt;/em> engines.&lt;/p>
&lt;p>&lt;strong>With autoscaling.&lt;/strong> The metrics that drive &lt;a href="https://blog.lo0.es/en/posts/llm-inference-autoscaling-kubernetes-hpa-keda/">autoscaling with KEDA&lt;/a>, waiting-queue length and preempted requests, come straight out of the scheduler&amp;rsquo;s state.&lt;/p>
&lt;h2 id="traps-and-things-that-are-not-what-they-look-like">Traps and things that are not what they look like&lt;/h2>
&lt;p>&lt;strong>&amp;ldquo;Raising &lt;code>max_num_batched_tokens&lt;/code> always helps.&amp;rdquo;&lt;/strong> It improves throughput and worsens ITL. If you only look at tokens/s in a large-batch benchmark, you &amp;ldquo;confirm&amp;rdquo; that more is better; in interactive production, your users feel the stutter. Measure both metrics or you are not measuring.&lt;/p>
&lt;p>&lt;strong>&amp;ldquo;The engine does all the prefills first and then the decodes.&amp;rdquo;&lt;/strong> That is the old architecture&amp;rsquo;s intuition. In V1 there are no phases: each step mixes prefill and decode according to the budget. Reasoning with the phase model leads to wrong conclusions about why latency rises.&lt;/p>
&lt;p>&lt;strong>&amp;ldquo;More &lt;code>max_num_seqs&lt;/code> = more throughput.&amp;rdquo;&lt;/strong> Only until the KV cache runs out. Past that, more nominal concurrency produces &lt;strong>preemption&lt;/strong>, and cascading preemption (thrashing) &lt;em>lowers&lt;/em> throughput. The real ceiling is the KV, not the parameter.&lt;/p>
&lt;p>&lt;strong>&amp;ldquo;RECOMPUTE is a waste, SWAP is better.&amp;rdquo;&lt;/strong> In V1, RECOMPUTE usually wins: prefill is extremely fast on GPU and SWAP puts a gigabyte-scale round trip across PCIe on the critical path. Switching to SWAP &amp;ldquo;to avoid recomputing&amp;rdquo; can make latency worse.&lt;/p>
&lt;p>&lt;strong>&amp;ldquo;The scheduler is the bottleneck.&amp;rdquo;&lt;/strong> Almost never. The decision is a dictionary built in microseconds; the cost of the round is the &lt;em>forward&lt;/em> on the GPU, three orders of magnitude above. If your scheduling CPU shows up in the profiler, the problem is usually host-thread jitter (see &lt;a href="https://blog.lo0.es/en/posts/numa-hugepages-cpu-isolation-gpu-waits-kernel/">NUMA and CPU isolation&lt;/a>), not the scheduler logic.&lt;/p>
&lt;p>&lt;strong>Chunked prefill that is too fine.&lt;/strong> Tiny chunks make a large prompt take many steps and add fixed per-step overhead. Chunking is there to &lt;em>bound&lt;/em> the ITL impact, not to pulverise the prompt.&lt;/p>
&lt;h2 id="conclusion">Conclusion&lt;/h2>
&lt;p>All the power of a modern LLM server, swallowing hundreds of requests at once without any of them blocking the rest, rests on a decision that fits in a &lt;code>{request: how many tokens}&lt;/code> dictionary, taken hundreds of times per second. The idea that made it possible was not a faster kernel or a bigger GPU: it was &lt;strong>to stop thinking in phases&lt;/strong>. When a prompt token and a generated token are the same thing, the scheduler can fill every tray by mixing the heavy and the light, and the kitchen never stops. The rest is two budgets, tokens and KV blocks, and a rule for when the second runs out. The maître d&amp;rsquo; does not cook; he only decides what goes to the stoves in each round. But that decision, repeated without pause, is what separates an idle GPU waiting for orders from a kitchen running flat out. And the uncomfortable lesson for whoever is tuning: throughput and latency meet in the budget, and optimising one blindly means worsening the other without noticing.&lt;/p>
&lt;h2 id="see-also">See also&lt;/h2>
&lt;ul>
&lt;li>&lt;a href="https://blog.lo0.es/en/posts/continuous-batching-orca-vllm-chunked-prefill-goodput/">Continuous batching: why we do not wait for a request to finish&lt;/a> — the &lt;em>what&lt;/em>; this post is the &lt;em>how&lt;/em> that implements it.&lt;/li>
&lt;li>&lt;a href="https://blog.lo0.es/en/posts/pagedattention-vllm-block-manager/">PagedAttention and the block manager&lt;/a> — the scheduler&amp;rsquo;s second budget, the KV blocks; when they run out, preemption.&lt;/li>
&lt;li>&lt;a href="https://blog.lo0.es/en/posts/kv-cache-working-memory-llm-inference/">KV cache: the working memory&lt;/a> — what each live token occupies and why concurrency is limited by memory, not by the parameter.&lt;/li>
&lt;li>&lt;a href="https://blog.lo0.es/en/posts/sms-cuda-streams-cuda-graphs-inference/">SMs, CUDA streams and CUDA graphs&lt;/a> — the capture buckets the scheduler should respect to avoid paying for padding.&lt;/li>
&lt;li>&lt;a href="https://blog.lo0.es/en/posts/prefix-cache-hit-rate-engineering/">Prefix cache hit rate engineering&lt;/a> — how cached chunks lower the &lt;code>n_tokens&lt;/code> the scheduler assigns.&lt;/li>
&lt;li>&lt;a href="https://blog.lo0.es/en/posts/speculative-decoding-fundamentals-maths-state-of-play/">Speculative decoding&lt;/a> — the &lt;code>n_tokens &amp;gt; 1&lt;/code> case in decode.&lt;/li>
&lt;li>&lt;a href="https://blog.lo0.es/en/posts/disaggregated-serving-prefill-decode-specialised-pods/">Disaggregated serving: prefill and decode apart&lt;/a> — two schedulers, the phase distinction that returns between engines.&lt;/li>
&lt;li>&lt;a href="https://blog.lo0.es/en/posts/llm-inference-autoscaling-kubernetes-hpa-keda/">LLM autoscaling with KEDA&lt;/a> — scheduler metrics (waiting queue, preempted requests) as a scaling signal.&lt;/li>
&lt;/ul>
&lt;h2 id="references">References&lt;/h2>
&lt;ul>
&lt;li>vLLM, &lt;em>vLLM V1: A Major Upgrade to vLLM&amp;rsquo;s Core Architecture&lt;/em>: &lt;a href="https://openlm.ai/vllm-v1/">https://openlm.ai/vllm-v1/&lt;/a>.&lt;/li>
&lt;li>vLLM, &lt;em>vLLM V1 User Guide&lt;/em> (chunked prefill por defecto, preemption RECOMPUTE): &lt;a href="https://docs.vllm.ai/en/v0.9.2/usage/v1_guide.html">https://docs.vllm.ai/en/v0.9.2/usage/v1_guide.html&lt;/a>.&lt;/li>
&lt;li>vLLM, &lt;em>Optimization and Tuning&lt;/em> (&lt;code>max_num_batched_tokens&lt;/code>, presupuesto y trade-off): &lt;a href="https://docs.vllm.ai/en/stable/configuration/optimization/">https://docs.vllm.ai/en/stable/configuration/optimization/&lt;/a>.&lt;/li>
&lt;li>A. Wong, &lt;em>Understanding vLLM Scheduling: Token Budgets, Chunked Prefill, and Policies&lt;/em>: &lt;a href="https://audreywongkg.medium.com/understanding-vllm-scheduling-token-budgets-chunked-prefill-and-policies-2c879e3980e3">https://audreywongkg.medium.com/understanding-vllm-scheduling-token-budgets-chunked-prefill-and-policies-2c879e3980e3&lt;/a>.&lt;/li>
&lt;li>W. Kwon et al., &lt;em>Efficient Memory Management for Large Language Model Serving with PagedAttention&lt;/em> (SOSP 2023): &lt;a href="https://arxiv.org/pdf/2309.06180">https://arxiv.org/pdf/2309.06180&lt;/a>.&lt;/li>
&lt;/ul></description></item></channel></rss>